diff --git a/Data/quick_demo/COMPARISON_REPORT.md b/Data/quick_demo/COMPARISON_REPORT.md deleted file mode 100644 index b38aa2c8429899a216824b476b41b3f9f1dfded4..0000000000000000000000000000000000000000 --- a/Data/quick_demo/COMPARISON_REPORT.md +++ /dev/null @@ -1,19 +0,0 @@ -# Court Scheduling System - Performance Comparison - -Generated: 2025-11-26 05:47:24 - -## Configuration - -- Training Cases: 10,000 -- Simulation Period: 90 days (0.2 years) -- RL Episodes: 20 -- RL Learning Rate: 0.15 -- RL Epsilon: 0.4 -- Policies Compared: readiness, rl - -## Results Summary - -| Policy | Disposals | Disposal Rate | Utilization | Avg Hearings/Day | -|--------|-----------|---------------|-------------|------------------| -| Readiness | 5,421 | 54.2% | 84.2% | 635.4 | -| Rl | 5,439 | 54.4% | 83.7% | 631.9 | diff --git a/Data/quick_demo/EXECUTIVE_SUMMARY.md b/Data/quick_demo/EXECUTIVE_SUMMARY.md deleted file mode 100644 index d568b99dbc87917ea146a70768ff28ea875f5e58..0000000000000000000000000000000000000000 --- a/Data/quick_demo/EXECUTIVE_SUMMARY.md +++ /dev/null @@ -1,47 +0,0 @@ -# Court Scheduling System - Executive Summary - -## Hackathon Submission: Karnataka High Court - -### System Overview -This intelligent court scheduling system uses Reinforcement Learning to optimize case allocation and improve judicial efficiency. The system was evaluated using a comprehensive 2-year simulation with 10,000 real cases. - -### Key Achievements - -**54.4% Case Disposal Rate** - Significantly improved case clearance -**83.7% Court Utilization** - Optimal resource allocation -**56,874 Hearings Scheduled** - Over 90 days -**AI-Powered Decisions** - Reinforcement learning with 20 training episodes - -### Technical Innovation - -- **Reinforcement Learning**: Tabular Q-learning with 6D state space -- **Real-time Adaptation**: Dynamic policy adjustment based on case characteristics -- **Multi-objective Optimization**: Balances disposal rate, fairness, and utilization -- **Production Ready**: Generates daily cause lists for immediate deployment - -### Impact Metrics - -- **Cases Disposed**: 5,439 out of 10,000 -- **Average Hearings per Day**: 631.9 -- **System Scalability**: Handles 50,000+ case simulations efficiently -- **Judicial Time Saved**: Estimated 75 productive court days - -### Deployment Readiness - -**Daily Cause Lists**: Automated generation for 90 days -**Performance Monitoring**: Comprehensive metrics and analytics -**Judicial Override**: Complete control system for judge approval -**Multi-courtroom Support**: Load-balanced allocation across courtrooms - -### Next Steps - -1. **Pilot Deployment**: Begin with select courtrooms for validation -2. **Judge Training**: Familiarization with AI-assisted scheduling -3. **Performance Monitoring**: Track real-world improvement metrics -4. **System Expansion**: Scale to additional court complexes - ---- - -**Generated**: 2025-11-26 05:47:24 -**System Version**: 2.0 (Hackathon Submission) -**Contact**: Karnataka High Court Digital Innovation Team diff --git a/Data/quick_demo/trained_rl_agent.pkl b/Data/quick_demo/trained_rl_agent.pkl deleted file mode 100644 index 703f8f09c4435bc736978dd7feb3cf2bf98ed20b..0000000000000000000000000000000000000000 Binary files a/Data/quick_demo/trained_rl_agent.pkl and /dev/null differ diff --git a/Data/quick_demo/visualizations/performance_charts.md b/Data/quick_demo/visualizations/performance_charts.md deleted file mode 100644 index 6356c99165b5c2970a72c29bf3fbb0b09132a0d7..0000000000000000000000000000000000000000 --- a/Data/quick_demo/visualizations/performance_charts.md +++ /dev/null @@ -1,7 +0,0 @@ -# Performance Visualizations - -Generated charts showing: -- Daily disposal rates -- Court utilization over time -- Case type performance -- Load balancing effectiveness diff --git a/HACKATHON_SUBMISSION.md b/HACKATHON_SUBMISSION.md deleted file mode 100644 index 57be4a56937e1c498b981ca49627fb20147dd128..0000000000000000000000000000000000000000 --- a/HACKATHON_SUBMISSION.md +++ /dev/null @@ -1,264 +0,0 @@ -# Hackathon Submission Guide -## Intelligent Court Scheduling System with Reinforcement Learning - -### Quick Start - Hackathon Demo - -#### Option 1: Full Workflow (Recommended) -```bash -# Run complete pipeline: generate cases + simulate -uv run court-scheduler workflow --cases 50000 --days 730 -``` - -This executes: -- EDA parameter extraction (if needed) -- Case generation with realistic distributions -- Multi-year simulation with policy comparison -- Performance analysis and reporting - -#### Option 2: Quick Demo -```bash -# 90-day quick demo with 10,000 cases -uv run court-scheduler workflow --cases 10000 --days 90 -``` - -#### Option 3: Step-by-Step -```bash -# 1. Extract parameters from historical data -uv run court-scheduler eda - -# 2. Generate synthetic cases -uv run court-scheduler generate --cases 50000 - -# 3. Train RL agent (optional) -uv run court-scheduler train --episodes 100 - -# 4. Run simulation -uv run court-scheduler simulate --cases data/cases.csv --days 730 --policy readiness -``` - -### What the Pipeline Does - -The comprehensive pipeline executes 7 automated steps: - -**Step 1: EDA & Parameter Extraction** -- Analyzes 739K+ historical hearings -- Extracts transition probabilities, duration statistics -- Generates simulation parameters - -**Step 2: Data Generation** -- Creates realistic synthetic case dataset -- Configurable size (default: 50,000 cases) -- Diverse case types and complexity levels - -**Step 3: RL Training** -- Trains Tabular Q-learning agent -- Real-time progress monitoring with reward tracking -- Configurable episodes and hyperparameters - -**Step 4: 2-Year Simulation** -- Runs 730-day court scheduling simulation -- Compares RL agent vs baseline algorithms -- Tracks disposal rates, utilization, fairness metrics - -**Step 5: Daily Cause List Generation** -- Generates production-ready daily cause lists -- Exports for all simulation days -- Court-room wise scheduling details - -**Step 6: Performance Analysis** -- Comprehensive comparison reports -- Performance visualizations -- Statistical analysis of all metrics - -**Step 7: Executive Summary** -- Hackathon-ready summary document -- Key achievements and impact metrics -- Deployment readiness checklist - -### Expected Output - -After completion, you'll find in your output directory: - -``` -data/hackathon_run/ -|-- pipeline_config.json # Full configuration used -|-- training_cases.csv # Generated case dataset -|-- trained_rl_agent.pkl # Trained RL model -|-- EXECUTIVE_SUMMARY.md # Hackathon submission summary -|-- COMPARISON_REPORT.md # Detailed performance comparison -|-- simulation_rl/ # RL policy results - |-- events.csv - |-- metrics.csv - |-- report.txt - |-- cause_lists/ - |-- daily_cause_list.csv # 730 days of cause lists -|-- simulation_readiness/ # Baseline results - |-- ... -|-- visualizations/ # Performance charts - |-- performance_charts.md -``` - -### Hackathon Winning Features - -#### 1. Real-World Impact -- **52%+ Disposal Rate**: Demonstrable case clearance improvement -- **730 Days of Cause Lists**: Ready for immediate court deployment -- **Multi-Courtroom Support**: Load-balanced allocation across 5+ courtrooms -- **Scalability**: Tested with 50,000+ cases - -#### 2. Technical Innovation -- **Reinforcement Learning**: AI-powered adaptive scheduling -- **6D State Space**: Comprehensive case characteristic modeling -- **Hybrid Architecture**: Combines RL intelligence with rule-based constraints -- **Real-time Learning**: Continuous improvement through experience - -#### 3. Production Readiness -- **Interactive CLI**: User-friendly parameter configuration -- **Comprehensive Reporting**: Executive summaries and detailed analytics -- **Quality Assurance**: Validated against baseline algorithms -- **Professional Output**: Court-ready cause lists and reports - -#### 4. Judicial Integration -- **Ripeness Classification**: Filters unready cases (40%+ efficiency gain) -- **Fairness Metrics**: Low Gini coefficient for equitable distribution -- **Transparency**: Explainable decision-making process -- **Override Capability**: Complete judicial control maintained - -### Performance Benchmarks - -Based on comprehensive testing: - -| Metric | RL Agent | Baseline | Advantage | -|--------|----------|----------|-----------| -| Disposal Rate | 52.1% | 51.9% | +0.4% | -| Court Utilization | 85%+ | 85%+ | Comparable | -| Load Balance (Gini) | 0.248 | 0.243 | Comparable | -| Scalability | 50K cases | 50K cases | Yes | -| Adaptability | High | Fixed | High | - -### Customization Options - -#### For Hackathon Judges -```bash -# Large-scale impressive demo -uv run court-scheduler workflow --cases 100000 --days 730 - -# With all policies compared -uv run court-scheduler simulate --cases data/cases.csv --days 730 --policy readiness -uv run court-scheduler simulate --cases data/cases.csv --days 730 --policy fifo -uv run court-scheduler simulate --cases data/cases.csv --days 730 --policy age -``` - -#### For Technical Evaluation -```bash -# Focus on RL training quality -uv run court-scheduler train --episodes 200 --lr 0.12 --cases 500 --output models/intensive_agent.pkl - -# Then simulate with trained agent -uv run court-scheduler simulate --cases data/cases.csv --days 730 --policy rl --agent models/intensive_agent.pkl -``` - -#### For Quick Demo/Testing -```bash -# Fast proof-of-concept -uv run court-scheduler workflow --cases 10000 --days 90 - -# Pre-configured: -# - 10,000 cases -# - 90 days simulation -# - ~5-10 minutes runtime -``` - -### Tips for Winning Presentation - -1. **Start with the Problem** - - Show Karnataka High Court case pendency statistics - - Explain judicial efficiency challenges - - Highlight manual scheduling limitations - -2. **Demonstrate the Solution** - - Run the interactive pipeline live - - Show real-time RL training progress - - Display generated cause lists - -3. **Present the Results** - - Open EXECUTIVE_SUMMARY.md - - Highlight key achievements from comparison table - - Show actual cause list files (730 days ready) - -4. **Emphasize Innovation** - - Reinforcement Learning for judicial scheduling (novel) - - Production-ready from day 1 (practical) - - Scalable to entire court system (impactful) - -5. **Address Concerns** - - Judicial oversight: Complete override capability - - Fairness: Low Gini coefficients, transparent metrics - - Reliability: Tested against proven baselines - - Deployment: Ready-to-use cause lists generated - -### System Requirements - -- **Python**: 3.10+ with UV -- **Memory**: 8GB+ RAM (16GB recommended for 50K cases) -- **Storage**: 2GB+ for full pipeline outputs -- **Runtime**: - - Quick demo: 5-10 minutes - - Full 2-year sim (50K cases): 30-60 minutes - - Large-scale (100K cases): 1-2 hours - -### Troubleshooting - -**Issue**: Out of memory during simulation -**Solution**: Reduce n_cases to 10,000-20,000 or increase system RAM - -**Issue**: RL training very slow -**Solution**: Reduce episodes to 50 or cases_per_episode to 500 - -**Issue**: EDA parameters not found -**Solution**: Run `uv run court-scheduler eda` first - -**Issue**: Import errors -**Solution**: Ensure UV environment is activated, run `uv sync` - -### Advanced Configuration - -For fine-tuned control, use configuration files: - -```bash -# Create configs/ directory with TOML files -# Example: configs/generate_config.toml -# [generation] -# n_cases = 50000 -# start_date = "2022-01-01" -# end_date = "2023-12-31" - -# Then run with config -uv run court-scheduler generate --config configs/generate_config.toml -uv run court-scheduler simulate --config configs/simulate_config.toml -``` - -Or use command-line options: -```bash -# Full customization -uv run court-scheduler workflow \ - --cases 50000 \ - --days 730 \ - --start 2022-01-01 \ - --end 2023-12-31 \ - --output data/custom_run \ - --seed 42 -``` - -### Contact & Support - -For hackathon questions or technical support: -- Review PIPELINE.md for detailed architecture -- Check README.md for system overview -- See rl/README.md for RL-specific documentation - ---- - -**Good luck with your hackathon submission!** - -This system represents a genuine breakthrough in applying AI to judicial efficiency. The combination of production-ready cause lists, proven performance metrics, and innovative RL architecture positions this as a compelling winning submission. \ No newline at end of file diff --git a/README.md b/README.md index 26fdbdfaf86f5e9715799b38456fc1241ca953de..71124a0595de6c0cad29a87e60070606b09fd0cc 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,31 @@ This project delivers a **comprehensive** court scheduling system featuring: ## Quick Start -### Unified CLI (Recommended) +### Interactive Dashboard (Primary Interface) -All operations now use a single entry point: +**For submission/demo, use the dashboard - it's fully self-contained:** + +```bash +# Launch dashboard +uv run streamlit run scheduler/dashboard/app.py + +# Open browser to http://localhost:8501 +``` + +**The dashboard handles everything:** +1. Run EDA pipeline (processes raw data, extracts parameters, generates visualizations) +2. Explore historical data and parameters +3. Test ripeness classification +4. Generate cases and run simulations +5. Review cause lists with judge override capability +6. Train RL models +7. Compare performance and generate reports + +**No CLI commands required** - everything is accessible through the web interface. + +### Alternative: Command Line Interface + +For automation or scripting, all operations available via CLI: ```bash # See all available commands @@ -282,17 +304,10 @@ These fixes ensure that RL training is reproducible, aligned with evaluation con ## Documentation -### Hackathon & Presentation -- `HACKATHON_SUBMISSION.md` - Complete hackathon submission guide -- `court_scheduler_rl.py` - Interactive CLI for full pipeline - -### Technical Documentation -- `COMPREHENSIVE_ANALYSIS.md` - EDA findings and insights -- `RIPENESS_VALIDATION.md` - Ripeness system validation results -- `PIPELINE.md` - Complete development and deployment pipeline -- `rl/README.md` - Reinforcement learning module documentation +**Primary**: This README (complete user guide) +**Additional**: `docs/` folder contains: +- `DASHBOARD.md` - Dashboard usage and architecture +- `CONFIGURATION.md` - Configuration system reference +- `HACKATHON_SUBMISSION.md` - Hackathon-specific submission guide -### Outputs & Configuration -- `reports/figures/` - Parameter visualizations -- `data/sim_runs/` - Simulation outputs and metrics -- `configs/` - RL training configurations and profiles +**Scripts**: See `scripts/README.md` for analysis utilities diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py deleted file mode 100644 index 9937ab92022cc4a45171ed12f171cd2e6d519056..0000000000000000000000000000000000000000 --- a/cli/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""CLI command modules.""" diff --git a/cli/config.py b/cli/config.py index 474c931fc7d313c0af0228c8ccf9d53748446440..842c07269e50fe416bbca4e70b35626c843045a5 100644 --- a/cli/config.py +++ b/cli/config.py @@ -10,7 +10,6 @@ from typing import Any, Dict, Optional from pydantic import BaseModel, Field, field_validator - # Configuration Models class GenerateConfig(BaseModel): diff --git a/cli/main.py b/cli/main.py index b0b165e9f42a5cab8079b77e6cae3ecd7ff320bd..a185c36bb220817c953aa1cbcb0973cc7f427b9e 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,17 +1,15 @@ """Unified CLI for Court Scheduling System. -This module provides a single entry point for all court scheduling operations: +This module provides a single entry point for key court scheduling operations: - EDA pipeline execution - Case generation -- Simulation runs -- RL training +- Simulation runs - Full workflow orchestration """ from __future__ import annotations import sys -from datetime import date from pathlib import Path import typer @@ -20,13 +18,20 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from cli import __version__ +try: + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") +except Exception: + pass + # Initialize Typer app and console app = typer.Typer( name="court-scheduler", help="Court Scheduling System for Karnataka High Court", add_completion=False, ) -console = Console() +# Use force_terminal=False to avoid legacy Windows rendering issues with Unicode +console = Console(legacy_windows=False) @app.command() @@ -37,15 +42,14 @@ def eda( ) -> None: """Run the EDA pipeline (load, explore, extract parameters).""" console.print("[bold blue]Running EDA Pipeline[/bold blue]") - + try: # Import here to avoid loading heavy dependencies if not needed - from src.eda_load_clean import run_load_and_clean - from src.eda_exploration import run_exploration - from src.eda_parameters import run_parameter_export - + from eda.exploration import run_exploration + from eda.load_clean import run_load_and_clean + from eda.parameters import run_parameter_export + with Progress( - SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: @@ -53,23 +57,23 @@ def eda( task = progress.add_task("Step 1/3: Load and clean data...", total=None) run_load_and_clean() progress.update(task, completed=True) - console.print("[green]\u2713[/green] Data loaded and cleaned") - + console.print("Data loaded and cleaned") + if not skip_viz: task = progress.add_task("Step 2/3: Generate visualizations...", total=None) run_exploration() progress.update(task, completed=True) - console.print("[green]\u2713[/green] Visualizations generated") - + console.print("Visualizations generated") + if not skip_params: task = progress.add_task("Step 3/3: Extract parameters...", total=None) run_parameter_export() progress.update(task, completed=True) - console.print("[green]\u2713[/green] Parameters extracted") - - console.print("\n[bold green]\u2713 EDA Pipeline Complete![/bold green]") + console.print("Parameters extracted") + + console.print("\n[bold]EDA Pipeline Complete[/bold]") console.print("Outputs: reports/figures/") - + except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") raise typer.Exit(code=1) @@ -77,21 +81,41 @@ def eda( @app.command() def generate( - config: Path = typer.Option(None, "--config", exists=True, dir_okay=False, readable=True, help="Path to config (.toml or .json)"), - interactive: bool = typer.Option(False, "--interactive", help="Prompt for parameters interactively"), + config: Path = typer.Option( # noqa: B008 + None, + "--config", + exists=True, + dir_okay=False, + readable=True, + help="Path to config (.toml or .json)", + ), + interactive: bool = typer.Option( + False, "--interactive", help="Prompt for parameters interactively" + ), n_cases: int = typer.Option(10000, "--cases", "-n", help="Number of cases to generate"), start_date: str = typer.Option("2022-01-01", "--start", help="Start date (YYYY-MM-DD)"), end_date: str = typer.Option("2023-12-31", "--end", help="End date (YYYY-MM-DD)"), - output: str = typer.Option("data/generated/cases.csv", "--output", "-o", help="Output CSV file"), + output: str = typer.Option( + "data/generated/cases.csv", "--output", "-o", help="Output CSV file" + ), seed: int = typer.Option(42, "--seed", help="Random seed for reproducibility"), + case_type_dist: str = typer.Option( + None, + "--case-type-dist", + help=( + 'Custom case type distribution. Accepts JSON (e.g., \'{"Writ":0.6,"Civil":0.4}\') ' + "or comma-separated pairs 'Writ:0.6,Civil:0.4'. Defaults to historical distribution." + ), + ), ) -> None: """Generate synthetic test cases for simulation.""" console.print(f"[bold blue]Generating {n_cases:,} test cases[/bold blue]") - + try: from datetime import date as date_cls + + from cli.config import GenerateConfig, load_generate_config from scheduler.data.case_generator import CaseGenerator - from cli.config import load_generate_config, GenerateConfig # Resolve parameters: config -> interactive -> flags if config: @@ -115,23 +139,58 @@ def generate( end = cfg.end output_path = cfg.output output_path.parent.mkdir(parents=True, exist_ok=True) - + with Progress( - SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: task = progress.add_task("Generating cases...", total=None) - + + # Parse optional custom case type distribution + def _parse_case_type_dist(s: str | None) -> dict | None: + if not s: + return None + s = s.strip() + try: + import json + + obj = json.loads(s) + if isinstance(obj, dict): + return obj + except Exception: + pass + # Try comma-separated pairs format + parts = [p.strip() for p in s.split(",") if p.strip()] + dist: dict[str, float] = {} + for part in parts: + if ":" not in part: + continue + k, v = part.split(":", 1) + k = k.strip() + try: + val = float(v) + except ValueError: + continue + if k: + dist[k] = val + return dist or None + + user_dist = _parse_case_type_dist(case_type_dist) + gen = CaseGenerator(start=start, end=end, seed=seed) - cases = gen.generate(n_cases, stage_mix_auto=True) + cases = gen.generate(n_cases, stage_mix_auto=True, case_type_distribution=user_dist) + # Write primary cases file CaseGenerator.to_csv(cases, output_path) - + # Also write detailed hearings history alongside, for the dashboard/classifier + hearings_path = output_path.parent / "hearings.csv" + CaseGenerator.to_hearings_csv(cases, hearings_path) + progress.update(task, completed=True) - - console.print(f"[green]\u2713[/green] Generated {len(cases):,} cases") - console.print(f"[green]\u2713[/green] Saved to: {output_path}") - + + console.print(f"Generated {len(cases):,} cases") + console.print(f"Saved to: {output_path}") + console.print(f"Hearings history: {hearings_path}") + except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") raise typer.Exit(code=1) @@ -139,43 +198,60 @@ def generate( @app.command() def simulate( - config: Path = typer.Option(None, "--config", exists=True, dir_okay=False, readable=True, help="Path to config (.toml or .json)"), - interactive: bool = typer.Option(False, "--interactive", help="Prompt for parameters interactively"), + config: Path = typer.Option( + None, + "--config", + exists=True, + dir_okay=False, + readable=True, + help="Path to config (.toml or .json)", + ), + interactive: bool = typer.Option( + False, "--interactive", help="Prompt for parameters interactively" + ), cases_csv: str = typer.Option("data/generated/cases.csv", "--cases", help="Input cases CSV"), days: int = typer.Option(384, "--days", "-d", help="Number of working days to simulate"), start_date: str = typer.Option(None, "--start", help="Simulation start date (YYYY-MM-DD)"), - policy: str = typer.Option("readiness", "--policy", "-p", help="Scheduling policy (fifo/age/readiness)"), + policy: str = typer.Option( + "readiness", "--policy", "-p", help="Scheduling policy (fifo/age/readiness)" + ), seed: int = typer.Option(42, "--seed", help="Random seed"), log_dir: str = typer.Option(None, "--log-dir", "-o", help="Output directory for logs"), ) -> None: """Run court scheduling simulation.""" console.print(f"[bold blue]Running {days}-day simulation[/bold blue]") - + try: from datetime import date as date_cls + + from cli.config import SimulateConfig, load_simulate_config from scheduler.core.case import CaseStatus from scheduler.data.case_generator import CaseGenerator from scheduler.metrics.basic import gini from scheduler.simulation.engine import CourtSim, CourtSimConfig - from cli.config import load_simulate_config, SimulateConfig - + # Resolve parameters: config -> interactive -> flags if config: scfg = load_simulate_config(config) # CLI flags override config if provided - scfg = scfg.model_copy(update={ - "cases": Path(cases_csv) if cases_csv else scfg.cases, - "days": days if days else scfg.days, - "start": (date_cls.fromisoformat(start_date) if start_date else scfg.start), - "policy": policy if policy else scfg.policy, - "seed": seed if seed else scfg.seed, - "log_dir": (Path(log_dir) if log_dir else scfg.log_dir), - }) + scfg = scfg.model_copy( + update={ + "cases": Path(cases_csv) if cases_csv else scfg.cases, + "days": days if days else scfg.days, + "start": (date_cls.fromisoformat(start_date) if start_date else scfg.start), + "policy": policy if policy else scfg.policy, + "seed": seed if seed else scfg.seed, + "log_dir": (Path(log_dir) if log_dir else scfg.log_dir), + } + ) else: if interactive: cases_csv = typer.prompt("Cases CSV", default=cases_csv) days = typer.prompt("Days to simulate", default=days) - start_date = typer.prompt("Start date (YYYY-MM-DD) or blank", default=start_date or "") or None + start_date = ( + typer.prompt("Start date (YYYY-MM-DD) or blank", default=start_date or "") + or None + ) policy = typer.prompt("Policy [readiness|fifo|age]", default=policy) seed = typer.prompt("Random seed", default=seed) log_dir = typer.prompt("Log dir (or blank)", default=log_dir or "") or None @@ -198,7 +274,7 @@ def simulate( start = scfg.start or date_cls.today().replace(day=1) gen = CaseGenerator(start=start, end=start.replace(day=28), seed=scfg.seed) cases = gen.generate(n_cases=5 * 151) - + # Run simulation cfg = CourtSimConfig( start=start, @@ -208,7 +284,7 @@ def simulate( duration_percentile="median", log_dir=scfg.log_dir, ) - + with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), @@ -218,94 +294,46 @@ def simulate( sim = CourtSim(cfg, cases) res = sim.run() progress.update(task, completed=True) - + # Display results console.print("\n[bold green]Simulation Complete![/bold green]") - console.print(f"\nHorizon: {cfg.start} \u2192 {res.end_date} ({days} days)") - console.print(f"\n[bold]Hearing Metrics:[/bold]") + console.print(f"\nHorizon: {cfg.start} -> {res.end_date} ({days} days)") + console.print("\n[bold]Hearing Metrics:[/bold]") console.print(f" Total: {res.hearings_total:,}") - console.print(f" Heard: {res.hearings_heard:,} ({res.hearings_heard/max(1,res.hearings_total):.1%})") - console.print(f" Adjourned: {res.hearings_adjourned:,} ({res.hearings_adjourned/max(1,res.hearings_total):.1%})") - - disp_times = [(c.disposal_date - c.filed_date).days for c in cases - if c.disposal_date is not None and c.status == CaseStatus.DISPOSED] + console.print( + f" Heard: {res.hearings_heard:,} ({res.hearings_heard / max(1, res.hearings_total):.1%})" + ) + console.print( + f" Adjourned: {res.hearings_adjourned:,} ({res.hearings_adjourned / max(1, res.hearings_total):.1%})" + ) + + disp_times = [ + (c.disposal_date - c.filed_date).days + for c in cases + if c.disposal_date is not None and c.status == CaseStatus.DISPOSED + ] gini_disp = gini(disp_times) if disp_times else 0.0 - - console.print(f"\n[bold]Disposal Metrics:[/bold]") - console.print(f" Cases disposed: {res.disposals:,} ({res.disposals/len(cases):.1%})") + + console.print("\n[bold]Disposal Metrics:[/bold]") + console.print(f" Cases disposed: {res.disposals:,} ({res.disposals / len(cases):.1%})") console.print(f" Gini coefficient: {gini_disp:.3f}") - - console.print(f"\n[bold]Efficiency:[/bold]") + + console.print("\n[bold]Efficiency:[/bold]") console.print(f" Utilization: {res.utilization:.1%}") - console.print(f" Avg hearings/day: {res.hearings_total/days:.1f}") - + console.print(f" Avg hearings/day: {res.hearings_total / days:.1f}") + if log_dir: - console.print(f"\n[bold cyan]Output Files:[/bold cyan]") + console.print("\n[bold cyan]Output Files:[/bold cyan]") console.print(f" - {log_dir}/report.txt") console.print(f" - {log_dir}/metrics.csv") console.print(f" - {log_dir}/events.csv") - + except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") raise typer.Exit(code=1) -@app.command() -def train( - episodes: int = typer.Option(20, "--episodes", "-e", help="Number of training episodes"), - cases_per_episode: int = typer.Option(200, "--cases", "-n", help="Cases per episode"), - learning_rate: float = typer.Option(0.15, "--lr", help="Learning rate"), - epsilon: float = typer.Option(0.4, "--epsilon", help="Initial epsilon for exploration"), - output: str = typer.Option("models/rl_agent.pkl", "--output", "-o", help="Output model file"), - seed: int = typer.Option(42, "--seed", help="Random seed"), -) -> None: - """Train RL agent for case scheduling.""" - console.print(f"[bold blue]Training RL Agent ({episodes} episodes)[/bold blue]") - - try: - from rl.simple_agent import TabularQAgent - from rl.training import train_agent - from rl.config import RLTrainingConfig - import pickle - - # Create agent - agent = TabularQAgent(learning_rate=learning_rate, epsilon=epsilon, discount=0.95) - - # Configure training - config = RLTrainingConfig( - episodes=episodes, - cases_per_episode=cases_per_episode, - training_seed=seed, - initial_epsilon=epsilon, - learning_rate=learning_rate, - ) - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - task = progress.add_task(f"Training {episodes} episodes...", total=None) - stats = train_agent(agent, rl_config=config, verbose=False) - progress.update(task, completed=True) - - # Save model - output_path = Path(output) - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("wb") as f: - pickle.dump(agent, f) - - console.print("\n[bold green]\u2713 Training Complete![/bold green]") - console.print(f"\nFinal Statistics:") - console.print(f" Episodes: {len(stats['episodes'])}") - console.print(f" Final disposal rate: {stats['disposal_rates'][-1]:.1%}") - console.print(f" States explored: {stats['states_explored'][-1]:,}") - console.print(f" Q-table size: {len(agent.q_table):,}") - console.print(f"\nModel saved to: {output_path}") - - except Exception as e: - console.print(f"[bold red]Error:[/bold red] {e}") - raise typer.Exit(code=1) +# RL training command removed @app.command() @@ -317,33 +345,34 @@ def workflow( ) -> None: """Run full workflow: EDA -> Generate -> Simulate -> Report.""" console.print("[bold blue]Running Full Workflow[/bold blue]\n") - + output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) - + try: # Step 1: EDA (skip if already done recently) console.print("[bold]Step 1/3:[/bold] EDA Pipeline") console.print(" Skipping (use 'court-scheduler eda' to regenerate)\n") - + # Step 2: Generate cases console.print("[bold]Step 2/3:[/bold] Generate Cases") cases_file = output_path / "cases.csv" from datetime import date as date_cls + from scheduler.data.case_generator import CaseGenerator - + start = date_cls(2022, 1, 1) end = date_cls(2023, 12, 31) - + gen = CaseGenerator(start=start, end=end, seed=seed) cases = gen.generate(n_cases, stage_mix_auto=True) CaseGenerator.to_csv(cases, cases_file) - console.print(f" [green]\u2713[/green] Generated {len(cases):,} cases\n") - + console.print(f" Generated {len(cases):,} cases\n") + # Step 3: Run simulation console.print("[bold]Step 3/3:[/bold] Run Simulation") from scheduler.simulation.engine import CourtSim, CourtSimConfig - + sim_start = max(c.filed_date for c in cases) cfg = CourtSimConfig( start=sim_start, @@ -352,19 +381,19 @@ def workflow( policy="readiness", log_dir=output_path, ) - + sim = CourtSim(cfg, cases) - res = sim.run() - console.print(f" [green]\u2713[/green] Simulation complete\n") - + sim.run() + console.print(" Simulation complete\n") + # Summary - console.print("[bold green]\u2713 Workflow Complete![/bold green]") + console.print("[bold]Workflow Complete[/bold]") console.print(f"\nResults: {output_path}/") console.print(f" - cases.csv ({len(cases):,} cases)") - console.print(f" - report.txt (simulation summary)") - console.print(f" - metrics.csv (daily metrics)") - console.print(f" - events.csv (event log)") - + console.print(" - report.txt (simulation summary)") + console.print(" - metrics.csv (daily metrics)") + console.print(" - events.csv (event log)") + except Exception as e: console.print(f"[bold red]Error:[/bold red] {e}") raise typer.Exit(code=1) @@ -379,18 +408,18 @@ def dashboard( console.print("[bold blue]Launching Interactive Dashboard[/bold blue]") console.print(f"Dashboard will be available at: http://{host}:{port}") console.print("Press Ctrl+C to stop the dashboard\n") - + try: import subprocess import sys - + # Get path to dashboard app app_path = Path(__file__).parent.parent / "scheduler" / "dashboard" / "app.py" - + if not app_path.exists(): console.print(f"[bold red]Error:[/bold red] Dashboard app not found at {app_path}") raise typer.Exit(code=1) - + # Run streamlit cmd = [ sys.executable, @@ -405,9 +434,9 @@ def dashboard( "--browser.gatherUsageStats", "false", ] - + subprocess.run(cmd) - + except KeyboardInterrupt: console.print("\n[yellow]Dashboard stopped[/yellow]") except Exception as e: diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index c7583649331211bc343efa98618016790412dbd6..057f2673773a1d69f558b47e81b5f67901030e5e 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -1,404 +1,41 @@ -# Interactive Dashboard - Living Documentation +# Interactive Dashboard -**Last Updated**: 2025-11-27 -**Status**: Initial Implementation Complete -**Version**: 0.1.0 +**Last Updated**: 2025-11-29 +**Status**: Production Ready +**Version**: 1.0.0 -## Overview +## Launch -This document tracks the design decisions, architecture, usage patterns, and evolution of the Interactive Multi-Page Dashboard for the Court Scheduling System. - -## Purpose and Goals - -The dashboard provides three key functionalities: -1. **EDA Analysis** - Visualize and explore court case data patterns -2. **Ripeness Classifier** - Interactive explainability and threshold tuning -3. **RL Training** - Train and visualize reinforcement learning agents - -### Design Philosophy -- Transparency: Every algorithm decision should be explainable -- Interactivity: Users can adjust parameters and see immediate impact -- Efficiency: Data caching to minimize load times -- Integration: Seamless integration with existing CLI and modules - -## Architecture - -### Technology Stack - -**Framework**: Streamlit 1.28+ -- Chosen for rapid prototyping and native multi-page support -- Built-in state management via `st.session_state` -- Excellent integration with Plotly and Pandas/Polars - -**Visualization**: Plotly -- Interactive charts (zoom, pan, hover) -- Better aesthetics than Matplotlib for dashboards -- Native Streamlit support - -**Data Processing**: -- Polars for fast CSV loading -- Pandas for compatibility with existing code -- Caching with `@st.cache_data` decorator - -### Directory Structure - -``` -scheduler/ - dashboard/ - __init__.py # Package initialization - app.py # Main entry point (home page) - utils/ - __init__.py - data_loader.py # Cached data loading functions - pages/ - 1_EDA_Analysis.py # EDA visualizations - 2_Ripeness_Classifier.py # Ripeness explainability - 3_RL_Training.py # RL training interface -``` - -### Module Reuse Strategy - -The dashboard reuses existing components without duplication: -- `scheduler.data.param_loader.ParameterLoader` - Load EDA-derived parameters -- `scheduler.data.case_generator.CaseGenerator` - Load generated cases -- `scheduler.core.ripeness.RipenessClassifier` - Classification logic -- `scheduler.core.case.Case` - Case data structure -- `rl.training.train_agent()` - RL training (future integration) - -## Page Implementations - -### Page 1: EDA Analysis - -**Features**: -- Key metrics dashboard (total cases, adjournment rates, stages) -- Interactive filters (case type, stage) -- Multiple visualizations: - - Case distribution by type (bar chart + pie chart) - - Stage analysis (bar chart + pie chart) - - Adjournment patterns (bar charts by type and stage) - - Adjournment probability heatmap (stage × case type) -- Raw data viewer with download capability - -**Data Sources**: -- `Data/processed/cleaned_cases.csv` - Cleaned case data from EDA pipeline -- `configs/parameters/` - Pre-computed parameters from ParameterLoader - -**Design Decisions**: -- Use tabs instead of separate sections for better organization -- Show top 10/15 items in charts to avoid clutter -- Provide download button for filtered data -- Cache data with 1-hour TTL to balance freshness and performance - -### Page 2: Ripeness Classifier - -**Features**: -- **Tab 1: Configuration** - - Display current thresholds - - Stage-specific rules table - - Decision tree logic explanation -- **Tab 2: Interactive Testing** - - Synthetic case creation - - Real-time classification with explanations - - Feature importance visualization - - Criteria pass/fail breakdown -- **Tab 3: Batch Classification** - - Load generated test cases - - Classify all with current thresholds - - Show distribution (RIPE/UNRIPE/UNKNOWN) - -**State Management**: -- Thresholds stored in `st.session_state` -- Sidebar sliders for real-time adjustment -- Reset button to restore defaults -- Session-based (not persisted to disk) - -**Explainability Approach**: -- Clear criteria breakdown (service hearings, case age, stage days, keywords) -- Visual indicators (✓/✗) for pass/fail -- Feature importance bar chart -- Before/after comparison capability - -**Design Decisions**: -- Simplified classification logic for demo (uses basic criteria) -- Future: Integrate actual RipenessClassifier.classify_case() -- Stage-specific rules hardcoded for now (future: load from config) -- Color coding: green (RIPE), orange (UNKNOWN), red (UNRIPE) - -### Page 3: RL Training - -**Features**: -- **Tab 1: Train Agent** - - Configuration form (episodes, learning rate, epsilon, etc.) - - Training progress visualization (demo mode) - - Multiple live charts (disposal rate, rewards, states, epsilon decay) - - Command generation for CLI training -- **Tab 2: Training History** - - Load and display previous training runs - - Plot historical performance -- **Tab 3: Model Comparison** - - Load saved models from models/ directory - - Compare Q-table sizes and hyperparameters - - Visualization of model differences - -**Demo Mode**: -- Current implementation simulates training results -- Generates synthetic stats for visualization -- Shows CLI command for actual training -- Future: Integrate real-time training with rl.training.train_agent() - -**Design Decisions**: -- Demo mode chosen for initial release (no blocking UI during training) -- Future: Add async training with progress updates -- Hyperparameter guide in expander for educational value -- Model persistence via pickle (existing pattern) - -## CLI Integration - -### Command ```bash -uv run court-scheduler dashboard [--port PORT] [--host HOST] +uv run streamlit run scheduler/dashboard/app.py +# Open http://localhost:8501 ``` -**Default**: `http://localhost:8501` - -**Implementation**: -- Added to `cli/main.py` as `@app.command()` -- Uses subprocess to launch Streamlit -- Validates dashboard app.py exists before launching -- Handles KeyboardInterrupt gracefully - -**Usage Example**: -```bash -# Launch on default port -uv run court-scheduler dashboard - -# Custom port -uv run court-scheduler dashboard --port 8080 - -# Bind to all interfaces -uv run court-scheduler dashboard --host 0.0.0.0 --port 8080 -``` - -## Data Flow - -### Loading Sequence -1. User launches dashboard via CLI -2. `app.py` loads, displays home page and system status -3. User navigates to a page (e.g., EDA Analysis) -4. Page imports data_loader utilities -5. `@st.cache_data` checks cache for data -6. If not cached, load from disk and cache -7. Data processed and visualized -8. User interactions trigger re-renders (cached data reused) +## Pages -### Caching Strategy -- **TTL**: 3600 seconds (1 hour) for data files -- **No TTL**: For computed statistics (invalidates on data change) -- **Session State**: For UI state (thresholds, training configs) +1. **Data & Insights** - Historical analysis of 739K+ hearings +2. **Ripeness Classifier** - Case bottleneck detection with explainability +3. **RL Training** - Train and evaluate RL scheduling agents +4. **Simulation Workflow** - Run simulations with configurable policies +5. **Cause Lists & Overrides** - Judge override interface for cause lists +6. **Analytics & Reports** - Performance comparison and reporting -### Performance Considerations -- Polars for fast CSV loading -- Limit DataFrame display to first 100 rows -- Top N filtering for visualizations (top 10/15) -- Lazy loading (pages only load data when accessed) +## Workflows -## Usage Patterns +**EDA Exploration**: Run EDA → Launch dashboard → Filter and visualize data +**Judge Overrides**: Launch dashboard → Simulation Workflow → Review/modify cause lists +**RL Training**: Launch dashboard → RL Training page → Configure and train -### Typical Workflow 1: EDA Exploration -1. Run EDA pipeline: `uv run court-scheduler eda` -2. Launch dashboard: `uv run court-scheduler dashboard` -3. Navigate to EDA Analysis page -4. Apply filters (case type, stage) -5. Explore visualizations -6. Download filtered data if needed +## Data Sources -### Typical Workflow 2: Threshold Tuning -1. Generate test cases: `uv run court-scheduler generate` -2. Launch dashboard: `uv run court-scheduler dashboard` -3. Navigate to Ripeness Classifier page -4. Adjust thresholds in sidebar -5. Test with synthetic case (Tab 2) -6. Run batch classification (Tab 3) -7. Analyze impact on RIPE/UNRIPE distribution - -### Typical Workflow 3: RL Training -1. Launch dashboard: `uv run court-scheduler dashboard` -2. Navigate to RL Training page -3. Configure hyperparameters (Tab 1) -4. Copy CLI command and run separately (or use demo) -5. Return to dashboard, view history (Tab 2) -6. Compare models (Tab 3) - -## Future Enhancements - -### Planned Features -- [ ] Real-time RL training integration (non-blocking) -- [ ] RipenessCalibrator integration (auto-suggest thresholds) -- [ ] RipenessMetrics tracking (false positive/negative rates) -- [ ] Actual RipenessClassifier integration (not simplified logic) -- [ ] EDA plot regeneration option -- [ ] Export threshold configurations -- [ ] Simulation runner from dashboard -- [ ] Authentication (if deployed externally) - -### Technical Improvements -- [ ] Async data loading for large datasets -- [ ] WebSocket support for real-time training updates -- [ ] Plotly Dash migration (if more customization needed) -- [ ] Unit tests for dashboard components -- [ ] Playwright automated UI tests - -### UX Improvements -- [ ] Dark mode support -- [ ] Custom color themes -- [ ] Keyboard shortcuts -- [ ] Save/load dashboard state -- [ ] Export visualizations as PNG/PDF -- [ ] Guided tour for new users - -## Testing Strategy - -### Manual Testing Checklist -- [ ] Dashboard launches without errors -- [ ] All pages load correctly -- [ ] EDA page: filters work, visualizations render -- [ ] Ripeness page: sliders adjust thresholds, classification updates -- [ ] RL page: form submission works, charts render -- [ ] CLI command generation correct -- [ ] System status checks work - -### Integration Testing -- [ ] Load actual cleaned data -- [ ] Load generated test cases -- [ ] Load parameters from configs/ -- [ ] Verify caching behavior -- [ ] Test with missing data files - -### Performance Testing -- [ ] Large dataset loading (100K+ rows) +- Historical data: `reports/figures/v*/cases_clean.parquet` and `hearings_clean.parquet` +- Parameters: `reports/figures/v*/params/` (auto-detected latest version) +- Falls back to bundled defaults if EDA not run - [ ] Batch classification (10K+ cases) - [ ] Multiple concurrent users (if deployed) ## Troubleshooting -### Common Issues - -**Issue**: Dashboard won't launch -- **Check**: Is Streamlit installed? `pip list | grep streamlit` -- **Solution**: Ensure venv is activated, run `uv sync` - -**Issue**: "Data file not found" warnings -- **Check**: Has EDA pipeline been run? -- **Solution**: Run `uv run court-scheduler eda` - -**Issue**: Empty visualizations -- **Check**: Is `Data/processed/cleaned_cases.csv` empty? -- **Solution**: Verify EDA pipeline completed successfully - -**Issue**: Ripeness batch classification fails -- **Check**: Are test cases generated? -- **Solution**: Run `uv run court-scheduler generate` - -**Issue**: Slow page loads -- **Check**: Is data being cached? -- **Solution**: Check Streamlit cache, reduce data size - -## Design Decisions Log - -### Decision 1: Streamlit over Dash/Gradio -**Date**: 2025-11-27 -**Rationale**: -- Already in dependencies (no new install) -- Simpler multi-page support -- Better for data science workflows -- Faster development time - -**Alternatives Considered**: -- Dash: More customizable but more boilerplate -- Gradio: Better for ML demos, less flexible - -### Decision 2: Plotly over Matplotlib -**Date**: 2025-11-27 -**Rationale**: -- Interactive by default (zoom, pan, hover) -- Better aesthetics for dashboards -- Native Streamlit integration -- Users expect interactivity in modern dashboards - -**Note**: Matplotlib still used for static EDA plots already generated - -### Decision 3: Session State for Thresholds -**Date**: 2025-11-27 -**Rationale**: -- Ephemeral experimentation (users can reset easily) -- No need to persist to disk -- Simpler implementation -- Users can export configs separately if needed - -**Future**: May add "save configuration" feature - -### Decision 4: Demo Mode for RL Training -**Date**: 2025-11-27 -**Rationale**: -- Avoid blocking UI during long training runs -- Show visualization capabilities -- Guide users to use CLI for actual training -- Simpler initial implementation - -**Future**: Add async training with WebSocket updates - -### Decision 5: Simplified Ripeness Logic -**Date**: 2025-11-27 -**Rationale**: -- Demonstrate explainability concept -- Avoid tight coupling with RipenessClassifier implementation -- Easier to understand for users -- Placeholder for full integration - -**Future**: Integrate actual RipenessClassifier.classify_case() - -## Maintenance Notes - -### Dependencies -- Streamlit: Keep updated for security fixes -- Plotly: Monitor for breaking changes -- Polars: Ensure compatibility with Pandas conversion - -### Code Quality -- Follow project ruff/black style -- Add docstrings to new functions -- Keep pages under 350 lines if possible -- Extract reusable components to utils/ - -### Performance Monitoring -- Monitor cache hit rates -- Track page load times -- Watch for memory leaks with large datasets - -## Educational Value - -The dashboard serves an educational purpose: -- **Transparency**: Shows how algorithms work (ripeness classifier) -- **Interactivity**: Lets users experiment (threshold tuning) -- **Visualization**: Makes complex data accessible (EDA plots) -- **Learning**: Explains RL concepts (hyperparameter guide) - -This aligns with the "explainability" goal of the Code4Change project. - -## Conclusion - -The dashboard successfully provides: -1. Comprehensive EDA visualization -2. Full ripeness classifier explainability -3. RL training interface (demo mode) -4. CLI integration -5. Cached data loading -6. Interactive threshold tuning - -Next steps focus on integrating real RL training and enhancing the ripeness classifier with actual implementation. - ---- - -**Contributors**: Roy Aalekh (Initial Implementation) -**Project**: Code4Change Court Scheduling System -**Target**: Karnataka High Court Scheduling Optimization +**Dashboard won't launch**: Run `uv sync` to install dependencies +**Empty visualizations**: Run `uv run court-scheduler eda` first +**Slow loading**: Data auto-cached after first load (1-hour TTL) diff --git a/docs/ENHANCEMENT_PLAN.md b/docs/ENHANCEMENT_PLAN.md deleted file mode 100644 index bfdf72ae36fb686c25f513ea993ab39c129e2865..0000000000000000000000000000000000000000 --- a/docs/ENHANCEMENT_PLAN.md +++ /dev/null @@ -1,311 +0,0 @@ -# Court Scheduling System - Bug Fixes & Enhancements - -## Completed Enhancements - -### 2.3 Add Learning Feedback Loop (COMPLETED) -**Status**: Implemented (Dec 2024) -**Solution**: -- Created `RipenessMetrics` class to track predictions vs outcomes -- Created `RipenessCalibrator` with 5 calibration rules -- Added `set_thresholds()` and `get_current_thresholds()` to RipenessClassifier -- Tracks false positive/negative rates, generates confusion matrix -- Suggests threshold adjustments with confidence levels - -**Files**: -- scheduler/monitoring/ripeness_metrics.py (254 lines) -- scheduler/monitoring/ripeness_calibrator.py (279 lines) -- scheduler/core/ripeness.py (enhanced with threshold management) - -### 4.0.4 Fix RL Reward Computation (COMPLETED) -**Status**: Fixed (Dec 2024) -**Solution**: -- Integrated ParameterLoader into RLTrainingEnvironment -- Replaced hardcoded probabilities (0.7, 0.6, 0.4) with EDA-derived parameters -- Training now uses param_loader.get_adjournment_prob() and param_loader.get_stage_transitions_fast() -- Validation: adjournment rates align within 1% of EDA (43.0% vs 42.3%) - -**Files**: -- rl/training.py (enhanced _simulate_hearing_outcome) - ---- - -## Priority 1: Fix State Management Bugs (P0 - Critical) - -### 1.1 Fix Override State Pollution -**Problem**: Override flags persist across runs, priority overrides don't clear -**Impact**: Cases keep boosted priority in subsequent schedules - -**Solution**: -- Add `clear_overrides()` method to Case class -- Call after each scheduling day or at simulation reset -- Store overrides in separate tracking dict instead of mutating case objects -- Alternative: Use immutable override context passed to scheduler - -**Files**: -- scheduler/core/case.py (add clear method) -- scheduler/control/overrides.py (refactor to non-mutating approach) -- scheduler/simulation/engine.py (call clear after scheduling) - -### 1.2 Preserve Override Auditability -**Problem**: Invalid overrides removed in-place from input list -**Impact**: Caller loses original override list, can't audit rejections - -**Solution**: -- Validate into separate collections: `valid_overrides`, `rejected_overrides` -- Return structured result: `OverrideResult(applied, rejected_with_reasons)` -- Keep original override list immutable -- Log all rejections with clear error messages - -**Files**: -- scheduler/control/overrides.py (refactor apply_overrides) -- scheduler/core/algorithm.py (update override handling) - -### 1.3 Track Override Outcomes Explicitly -**Problem**: Applied overrides in list, rejected as None in unscheduled -**Impact**: Hard to distinguish "not selected" from "override rejected" - -**Solution**: -- Create `OverrideAudit` dataclass: (override_id, status, reason, timestamp) -- Return audit log from schedule_day: `result.override_audit` -- Separate tracking: `cases_not_selected`, `overrides_accepted`, `overrides_rejected` - -**Files**: -- scheduler/core/algorithm.py (add audit tracking) -- scheduler/control/overrides.py (structured audit log) - -## Priority 2: Strengthen Ripeness Detection (P0 - Critical) - -### 2.1 Require Positive Evidence for RIPE -**Problem**: Defaults to RIPE when signals ambiguous -**Impact**: Schedules cases that may not be ready - -**Solution**: -- Add `UNKNOWN` status to RipenessStatus enum -- Require explicit RIPE signals: stage progression, document check, age threshold -- Default to UNKNOWN (not RIPE) when data insufficient -- Add confidence score: `ripeness_confidence: float` (0.0-1.0) - -**Files**: -- scheduler/core/ripeness.py (add UNKNOWN, confidence scoring) -- scheduler/simulation/engine.py (filter UNKNOWN cases) - -### 2.2 Enrich Ripeness Signals -**Problem**: Only uses keyword search and basic stage checks -**Impact**: Misses nuanced bottlenecks - -**Solution**: -- Add signals: - - Filing age relative to case type median - - Adjournment reason history (recurring "summons pending") - - Outstanding task list (if available in data) - - Party/lawyer attendance rate - - Document submission completeness -- Multi-signal scoring: weighted combination -- Configurable thresholds per signal - -**Files**: -- scheduler/core/ripeness.py (add signal extraction) -- scheduler/data/config.py (ripeness thresholds) - -### 2.3 Add Learning Feedback Loop (COMPLETED - See top of document) -~~Moved to Completed Enhancements section~~ - -## Priority 3: Re-enable Simulation Inflow (P1 - High) - -### 3.1 Parameterize Case Filing -**Problem**: New filings commented out, no caseload growth -**Impact**: Unrealistic long-term simulations - -**Solution**: -- Add `enable_inflow: bool` to CourtSimConfig -- Add `filing_rate_multiplier: float` (default 1.0 for historical rate) -- Expose inflow controls in pipeline config -- Surface inflow metrics in simulation results - -**Files**: -- scheduler/simulation/engine.py (uncomment + gate filings) -- court_scheduler_rl.py (add config parameters) - -### 3.2 Make Ripeness Re-evaluation Configurable -**Problem**: Fixed 7-day re-evaluation may be too infrequent -**Impact**: Stale classifications drive multiple days - -**Solution**: -- Add `ripeness_eval_frequency_days: int` to config (default 7) -- Consider adaptive frequency: more frequent when backlog high -- Log ripeness re-evaluation events - -**Files**: -- scheduler/simulation/engine.py (configurable frequency) -- scheduler/data/config.py (add parameter) - -## Priority 4: EDA and Configuration Robustness (P1 - High) - -### 4.0.1 Fix EDA Memory Issues -**Problem**: EDA converts full Parquet to pandas, risks memory exhaustion -**Impact**: Pipeline fails on large datasets (>50K cases) - -**Solution**: -- Add sampling parameter: `eda_sample_size: Optional[int]` (default None = full) -- Stream data instead of loading all at once -- Downcast numeric columns before conversion -- Add memory monitoring and warnings - -**Files**: -- src/eda_exploration.py (add sampling) -- src/eda_config.py (memory limits) - -### 4.0.2 Fix Headless Rendering -**Problem**: Plotly renderer defaults to "browser", fails in CI/CD -**Impact**: Cannot run EDA in automated pipelines - -**Solution**: -- Detect headless environment (check DISPLAY env var) -- Default to "png" or "svg" renderer in headless mode -- Add `--renderer` CLI flag to override - -**Files**: -- src/eda_exploration.py (renderer detection) -- court_scheduler_rl.py (add CLI flag) - -### 4.0.3 Fix Missing Parameters Fallback -**Problem**: get_latest_params_dir raises when no params exist -**Impact**: Fresh environments can't run simulations - -**Solution**: -- Bundle baseline parameters in `scheduler/data/defaults/` -- Fallback to bundled params if no EDA run found -- Add `--use-defaults` flag to force baseline params -- Log warning when using defaults vs EDA-derived - -**Files**: -- scheduler/data/config.py (fallback logic) -- scheduler/data/defaults/ (new directory with baseline params) - -### 4.0.4 Fix RL Parameter Alignment (COMPLETED - See top of document) -~~Moved to Completed Enhancements section~~ - -## Priority 5: Enhanced Scheduling Constraints (P2 - Medium) - -### 4.1 Judge Blocking & Availability -**Problem**: No per-judge blocked dates -**Impact**: Schedules hearings when judge unavailable - -**Solution**: -- Add `blocked_dates: list[date]` to Judge entity -- Add `availability_override: dict[date, bool]` for one-time changes -- Filter eligible courtrooms by judge availability - -**Files**: -- scheduler/core/judge.py (add availability fields) -- scheduler/core/algorithm.py (check availability) - -### 4.2 Per-Case Gap Overrides -**Problem**: Global MIN_GAP_BETWEEN_HEARINGS, no exceptions -**Impact**: Urgent cases can't be expedited - -**Solution**: -- Add `min_gap_override: Optional[int]` to Case -- Apply in eligibility check: `gap = case.min_gap_override or MIN_GAP` -- Track override applications in metrics - -**Files**: -- scheduler/core/case.py (add field) -- scheduler/core/algorithm.py (use override in eligibility) - -### 4.3 Courtroom Capacity Changes -**Problem**: Fixed daily capacity, no dynamic adjustments -**Impact**: Can't model half-days, special sessions - -**Solution**: -- Add `capacity_overrides: dict[date, int]` to Courtroom -- Apply in allocation: check date-specific capacity first -- Support judge preferences (e.g., "Property cases Mondays") - -**Files**: -- scheduler/core/courtroom.py (add override dict) -- scheduler/simulation/allocator.py (check overrides) - -## Priority 5: Testing & Validation (P1 - High) - -### 5.1 Unit Tests for Bug Fixes -**Coverage**: -- Override state clearing -- Ripeness UNKNOWN handling -- Inflow rate calculations -- Constraint validation - -**Files**: -- tests/test_overrides.py (new) -- tests/test_ripeness.py (expand) -- tests/test_simulation.py (inflow tests) - -### 5.2 Integration Tests -**Scenarios**: -- Full pipeline with overrides applied -- Ripeness transitions over time -- Blocked judge dates respected -- Capacity overrides honored - -**Files**: -- tests/integration/test_scheduling_pipeline.py (new) - -## Implementation Order - -1. **Week 1**: Fix critical bugs - - State management (1.1, 1.2, 1.3) - - Configuration robustness (4.0.3 - parameter fallback) - - Unit tests for above - -2. **Week 2**: Strengthen core systems - - Ripeness detection (2.1, 2.2 - UNKNOWN status, multi-signal) - - RL reward alignment (4.0.4 - shared reward logic) - - Re-enable inflow (3.1, 3.2) - -3. **Week 3**: Robustness and constraints - - EDA scaling (4.0.1 - memory management) - - Headless rendering (4.0.2 - CI/CD compatibility) - - Enhanced constraints (5.1, 5.2, 5.3) - -4. **Week 4**: Testing and polish - - Comprehensive integration tests - - Ripeness learning feedback (2.3) - - All edge cases documented - -## Success Criteria - -**Bug Fixes**: -- Override state doesn't leak between runs -- All override decisions auditable -- Rejected overrides tracked with reasons - -**Ripeness**: -- UNKNOWN status used when confidence low -- False positive rate < 15% (marked RIPE but adjourned) -- Multi-signal scoring operational - -**Simulation Realism**: -- Inflow configurable and metrics tracked -- Long runs show realistic caseload patterns -- Ripeness re-evaluation frequency tunable - -**Constraints**: -- Judge blocked dates respected 100% -- Per-case gap overrides functional -- Capacity changes applied correctly - -**Quality**: -- 90%+ test coverage for bug fixes -- Integration tests pass -- All edge cases documented - -## Background - -This plan addresses critical bugs and architectural improvements identified through code analysis: - -1. **State Management**: Override flags persist across runs, causing silent bias -2. **Ripeness Defaults**: System defaults to RIPE when uncertain, risking premature scheduling -3. **Closed Simulation**: No case inflow, making long-term runs unrealistic -4. **Limited Auditability**: In-place mutations make debugging and QA difficult - -See commit history for OutputManager refactoring and Windows compatibility fixes already completed. diff --git a/models/intensive_trained_rl_agent.pkl b/models/intensive_trained_rl_agent.pkl deleted file mode 100644 index 6300ef155535f88611b5814f0ceeb3564d887f97..0000000000000000000000000000000000000000 Binary files a/models/intensive_trained_rl_agent.pkl and /dev/null differ diff --git a/models/latest.pkl b/models/latest.pkl deleted file mode 120000 index ffdb1d0215d379dbb9e642052b05d2779dfb604f..0000000000000000000000000000000000000000 --- a/models/latest.pkl +++ /dev/null @@ -1 +0,0 @@ -D:/personal/code4change/code4change-analysis/outputs/runs/run_20251127_054834/training/agent.pkl \ No newline at end of file diff --git a/models/trained_rl_agent.pkl b/models/trained_rl_agent.pkl deleted file mode 100644 index 703f8f09c4435bc736978dd7feb3cf2bf98ed20b..0000000000000000000000000000000000000000 Binary files a/models/trained_rl_agent.pkl and /dev/null differ diff --git a/outputs/runs/run_20251127_054834/reports/COMPARISON_REPORT.md b/outputs/runs/run_20251127_054834/reports/COMPARISON_REPORT.md deleted file mode 100644 index c5a2f1cb5ce8b5e7d5a3837ad0baed3901a75bab..0000000000000000000000000000000000000000 --- a/outputs/runs/run_20251127_054834/reports/COMPARISON_REPORT.md +++ /dev/null @@ -1,19 +0,0 @@ -# Court Scheduling System - Performance Comparison - -Generated: 2025-11-27 05:50:04 - -## Configuration - -- Training Cases: 10,000 -- Simulation Period: 90 days (0.2 years) -- RL Episodes: 20 -- RL Learning Rate: 0.15 -- RL Epsilon: 0.4 -- Policies Compared: readiness, rl - -## Results Summary - -| Policy | Disposals | Disposal Rate | Utilization | Avg Hearings/Day | -|--------|-----------|---------------|-------------|------------------| -| Readiness | 5,343 | 53.4% | 78.8% | 594.7 | -| Rl | 5,365 | 53.6% | 78.5% | 593.0 | diff --git a/outputs/runs/run_20251127_054834/reports/EXECUTIVE_SUMMARY.md b/outputs/runs/run_20251127_054834/reports/EXECUTIVE_SUMMARY.md deleted file mode 100644 index 53036affe695d63b726fdfed144a9e4c0bf5947b..0000000000000000000000000000000000000000 --- a/outputs/runs/run_20251127_054834/reports/EXECUTIVE_SUMMARY.md +++ /dev/null @@ -1,47 +0,0 @@ -# Court Scheduling System - Executive Summary - -## Hackathon Submission: Karnataka High Court - -### System Overview -This intelligent court scheduling system uses Reinforcement Learning to optimize case allocation and improve judicial efficiency. The system was evaluated using a comprehensive 2-year simulation with 10,000 real cases. - -### Key Achievements - -**53.6% Case Disposal Rate** - Significantly improved case clearance -**78.5% Court Utilization** - Optimal resource allocation -**53,368 Hearings Scheduled** - Over 90 days -**AI-Powered Decisions** - Reinforcement learning with 20 training episodes - -### Technical Innovation - -- **Reinforcement Learning**: Tabular Q-learning with 6D state space -- **Real-time Adaptation**: Dynamic policy adjustment based on case characteristics -- **Multi-objective Optimization**: Balances disposal rate, fairness, and utilization -- **Production Ready**: Generates daily cause lists for immediate deployment - -### Impact Metrics - -- **Cases Disposed**: 5,365 out of 10,000 -- **Average Hearings per Day**: 593.0 -- **System Scalability**: Handles 50,000+ case simulations efficiently -- **Judicial Time Saved**: Estimated 71 productive court days - -### Deployment Readiness - -**Daily Cause Lists**: Automated generation for 90 days -**Performance Monitoring**: Comprehensive metrics and analytics -**Judicial Override**: Complete control system for judge approval -**Multi-courtroom Support**: Load-balanced allocation across courtrooms - -### Next Steps - -1. **Pilot Deployment**: Begin with select courtrooms for validation -2. **Judge Training**: Familiarization with AI-assisted scheduling -3. **Performance Monitoring**: Track real-world improvement metrics -4. **System Expansion**: Scale to additional court complexes - ---- - -**Generated**: 2025-11-27 05:50:04 -**System Version**: 2.0 (Hackathon Submission) -**Contact**: Karnataka High Court Digital Innovation Team diff --git a/outputs/runs/run_20251127_054834/reports/visualizations/performance_charts.md b/outputs/runs/run_20251127_054834/reports/visualizations/performance_charts.md deleted file mode 100644 index 6356c99165b5c2970a72c29bf3fbb0b09132a0d7..0000000000000000000000000000000000000000 --- a/outputs/runs/run_20251127_054834/reports/visualizations/performance_charts.md +++ /dev/null @@ -1,7 +0,0 @@ -# Performance Visualizations - -Generated charts showing: -- Daily disposal rates -- Court utilization over time -- Case type performance -- Load balancing effectiveness diff --git a/outputs/runs/run_20251127_054834/training/agent.pkl b/outputs/runs/run_20251127_054834/training/agent.pkl deleted file mode 100644 index ebe62c2434aaae7af2d0b6f29eb690676acfc7e5..0000000000000000000000000000000000000000 Binary files a/outputs/runs/run_20251127_054834/training/agent.pkl and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 17f998332cae89336ced60661e770e0127fd1f6c..a461392882d9bd8ad2ab492febb8e877d9b0955b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ target-version = ["py311"] [tool.ruff] select = ["E", "F", "I", "B", "C901", "N", "D"] line-length = 100 -src = ["src"] +src = [".", "scheduler"] [tool.ruff.pydocstyle] convention = "google" @@ -63,5 +63,11 @@ markers = [ "unit: Unit tests", "integration: Integration tests", "fairness: Fairness validation tests", - "performance: Performance benchmark tests" + "performance: Performance benchmark tests", + "rl: Reinforcement learning tests", + "simulation: Simulation engine tests", + "edge_case: Edge case and boundary condition tests", + "failure: Failure scenario tests", + "slow: Slow-running tests (>5 seconds)" ] + diff --git a/report.txt b/report.txt deleted file mode 100644 index 1a15e60bec13466f210d80f623b177f879e3b82f..0000000000000000000000000000000000000000 --- a/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 3000 - Days simulated: 60 - Policy: readiness - Horizon end: 2024-06-20 - -Hearing Metrics: - Total hearings: 16,137 - Heard: 9,981 (61.9%) - Adjourned: 6,156 (38.1%) - -Disposal Metrics: - Cases disposed: 708 - Disposal rate: 23.6% - Gini coefficient: 0.195 - -Disposal Rates by Case Type: - CA : 159/ 587 ( 27.1%) - CCC : 133/ 334 ( 39.8%) - CMP : 14/ 86 ( 16.3%) - CP : 105/ 294 ( 35.7%) - CRP : 142/ 612 ( 23.2%) - RFA : 77/ 519 ( 14.8%) - RSA : 78/ 568 ( 13.7%) - -Efficiency Metrics: - Court utilization: 35.6% - Avg hearings/day: 268.9 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 3,360 - Filter rate: 17.2% - -Final Ripeness Distribution: - RIPE: 2236 (97.6%) - UNRIPE_DEPENDENT: 19 (0.8%) - UNRIPE_SUMMONS: 37 (1.6%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.002 - Avg daily load: 53.8 cases - Allocation changes: 10,527 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 3,244 cases (54.1/day) - Courtroom 2: 3,233 cases (53.9/day) - Courtroom 3: 3,227 cases (53.8/day) - Courtroom 4: 3,221 cases (53.7/day) - Courtroom 5: 3,212 cases (53.5/day) diff --git a/rl/README.md b/rl/README.md deleted file mode 100644 index 23b16f255d91a838ebd3762968c948ab2f6b5d86..0000000000000000000000000000000000000000 --- a/rl/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# Reinforcement Learning Module - -This module implements tabular Q-learning for court case scheduling prioritization, following the hybrid approach outlined in `RL_EXPLORATION_PLAN.md`. - -## Architecture - -### Core Components - -- **`simple_agent.py`**: Tabular Q-learning agent with 6D state space -- **`training.py`**: Training environment and learning pipeline -- **`__init__.py`**: Module exports and interface - -### State Representation (6D) - -Cases are represented by a 6-dimensional state vector: - -1. **Stage** (0-10): Current litigation stage (discretized) -2. **Age** (0-9): Case age in days (normalized and discretized) -3. **Days since last** (0-9): Days since last hearing (normalized) -4. **Urgency** (0-1): Binary urgent status -5. **Ripeness** (0-1): Binary ripeness status -6. **Hearing count** (0-9): Number of previous hearings (normalized) - -### Reward Function - -- **Base scheduling**: +0.5 for taking action -- **Disposal**: +10.0 for case disposal/settlement -- **Progress**: +3.0 for case advancement -- **Adjournment**: -3.0 penalty -- **Urgency bonus**: +2.0 for urgent cases -- **Ripeness penalty**: -4.0 for scheduling unripe cases -- **Long pending bonus**: +2.0 for cases >365 days old - -## Usage - -### Basic Training - -```python -from rl import TabularQAgent, train_agent - -# Create agent -agent = TabularQAgent(learning_rate=0.1, epsilon=0.3) - -# Train -stats = train_agent(agent, episodes=50, cases_per_episode=500) - -# Save -agent.save(Path("models/my_agent.pkl")) -``` - -### Configuration-Driven Training - -```bash -# Use predefined config -uv run python train_rl_agent.py --config configs/rl_training_fast.json - -# Override specific parameters -uv run python train_rl_agent.py --episodes 100 --learning-rate 0.2 - -# Custom model name -uv run python train_rl_agent.py --model-name "custom_agent.pkl" -``` - -### Integration with Simulation - -```python -from scheduler.simulation.policies import RLPolicy - -# Use trained agent in simulation -policy = RLPolicy(agent_path=Path("models/intensive_rl_agent.pkl")) - -# Or auto-load latest trained agent -policy = RLPolicy() # Automatically finds intensive_trained_rl_agent.pkl -``` - -## Configuration Files - -### Fast Training (`configs/rl_training_fast.json`) -- 20 episodes, 200 cases/episode -- Higher learning rate (0.2) and exploration (0.5) -- Suitable for quick experiments - -### Intensive Training (`configs/rl_training_intensive.json`) -- 100 episodes, 1000 cases/episode -- Balanced parameters for production training -- Generates `intensive_rl_agent.pkl` - -## Performance - -Current results on 10,000 case dataset (90-day simulation): -- **RL Agent**: 52.1% disposal rate -- **Baseline**: 51.9% disposal rate -- **Status**: Performance parity achieved - -## Hybrid Design - -The RL agent works within a **hybrid architecture**: - -1. **Rule-based filtering**: Maintains fairness and judicial constraints -2. **RL prioritization**: Learns optimal case priority scoring -3. **Deterministic allocation**: Respects courtroom capacity limits - -This ensures the system remains explainable and legally compliant while leveraging learned scheduling patterns. - -## Development Notes - -- State space: 44,000 theoretical states, ~100 typically explored -- Training requires 10,000+ diverse cases for effective learning -- Agent learns to match expert heuristics rather than exceed them -- Suitable for research and proof-of-concept applications \ No newline at end of file diff --git a/rl/__init__.py b/rl/__init__.py deleted file mode 100644 index b8767de01fa53462ac2e599737274b26a895e077..0000000000000000000000000000000000000000 --- a/rl/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""RL-based court scheduling components. - -This module contains the reinforcement learning components for court scheduling: -- Tabular Q-learning agent for case priority scoring -- Training environment and loops -- Explainability tools for judicial decisions -""" - -from .simple_agent import TabularQAgent -from .training import train_agent, evaluate_agent, RLTrainingEnvironment - -__all__ = ['TabularQAgent', 'train_agent', 'evaluate_agent', 'RLTrainingEnvironment'] diff --git a/rl/config.py b/rl/config.py deleted file mode 100644 index 3c08a38af8db7c82e2e226202b6c36eef6a77576..0000000000000000000000000000000000000000 --- a/rl/config.py +++ /dev/null @@ -1,115 +0,0 @@ -"""RL training configuration and hyperparameters. - -This module contains all configurable parameters for RL agent training, -separate from domain constants and simulation settings. -""" - -from dataclasses import dataclass - - -@dataclass -class RLTrainingConfig: - """Configuration for RL agent training. - - Hyperparameters that affect learning behavior and convergence. - """ - # Training episodes - episodes: int = 100 - cases_per_episode: int = 1000 - episode_length_days: int = 60 - - # Courtroom + allocation constraints - courtrooms: int = 5 - daily_capacity_per_courtroom: int = 151 - cap_daily_allocations: bool = True - max_daily_allocations: int | None = None # Optional hard cap (overrides computed capacity) - enforce_min_gap: bool = True - apply_judge_preferences: bool = True - - # Q-learning hyperparameters - learning_rate: float = 0.15 - discount_factor: float = 0.95 - - # Exploration strategy - initial_epsilon: float = 0.4 - epsilon_decay: float = 0.99 - min_epsilon: float = 0.05 - - # Training data generation - training_seed: int = 42 - stage_mix_auto: bool = True # Use EDA-derived stage distribution - - def __post_init__(self): - """Validate configuration parameters.""" - if not (0.0 < self.learning_rate <= 1.0): - raise ValueError(f"learning_rate must be in (0, 1], got {self.learning_rate}") - - if not (0.0 <= self.discount_factor <= 1.0): - raise ValueError(f"discount_factor must be in [0, 1], got {self.discount_factor}") - - if not (0.0 <= self.initial_epsilon <= 1.0): - raise ValueError(f"initial_epsilon must be in [0, 1], got {self.initial_epsilon}") - - if self.episodes < 1: - raise ValueError(f"episodes must be >= 1, got {self.episodes}") - - if self.cases_per_episode < 1: - raise ValueError(f"cases_per_episode must be >= 1, got {self.cases_per_episode}") - - if self.courtrooms < 1: - raise ValueError(f"courtrooms must be >= 1, got {self.courtrooms}") - - if self.daily_capacity_per_courtroom < 1: - raise ValueError( - f"daily_capacity_per_courtroom must be >= 1, got {self.daily_capacity_per_courtroom}" - ) - - if self.max_daily_allocations is not None and self.max_daily_allocations < 1: - raise ValueError( - f"max_daily_allocations must be >= 1 when provided, got {self.max_daily_allocations}" - ) - - -@dataclass -class PolicyConfig: - """Configuration for scheduling policy behavior. - - Settings that affect how policies prioritize and filter cases. - """ - # Minimum gap between hearings (days) - min_gap_days: int = 7 # From MIN_GAP_BETWEEN_HEARINGS in config.py - - # Maximum gap before alert (days) - max_gap_alert_days: int = 90 # From MAX_GAP_WITHOUT_ALERT - - # Old case threshold for priority boost (days) - old_case_threshold_days: int = 180 - - # Ripeness filtering - skip_unripe_cases: bool = True - allow_old_unripe_cases: bool = True # Allow scheduling if age > old_case_threshold - - def __post_init__(self): - """Validate configuration parameters.""" - if self.min_gap_days < 0: - raise ValueError(f"min_gap_days must be >= 0, got {self.min_gap_days}") - - if self.max_gap_alert_days < self.min_gap_days: - raise ValueError( - f"max_gap_alert_days ({self.max_gap_alert_days}) must be >= " - f"min_gap_days ({self.min_gap_days})" - ) - - -# Default configurations -DEFAULT_RL_TRAINING_CONFIG = RLTrainingConfig() -DEFAULT_POLICY_CONFIG = PolicyConfig() - -# Quick demo configuration (for testing) -QUICK_DEMO_RL_CONFIG = RLTrainingConfig( - episodes=20, - cases_per_episode=1000, - episode_length_days=45, - learning_rate=0.15, - initial_epsilon=0.4, -) diff --git a/rl/rewards.py b/rl/rewards.py deleted file mode 100644 index f27a3883fb7ddad360c28b4225e7dd4efe1d1c6e..0000000000000000000000000000000000000000 --- a/rl/rewards.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Shared reward helper utilities for RL agents. - -The helper operates on episode-level statistics so that reward shaping -reflects system-wide outcomes (disposal rate, gap compliance, urgent -case latency, and fairness across cases). -""" - -from __future__ import annotations - -from collections import defaultdict -from dataclasses import dataclass, field -from typing import Dict, Iterable, Optional - -import numpy as np - -from scheduler.core.case import Case - - -@dataclass -class EpisodeRewardHelper: - """Aggregates episode metrics and computes shaped rewards.""" - - total_cases: int - target_gap_days: int = 30 - max_urgent_latency: int = 60 - disposal_weight: float = 4.0 - gap_weight: float = 1.5 - urgent_weight: float = 2.0 - fairness_weight: float = 1.0 - _disposed_cases: int = 0 - _hearing_counts: Dict[str, int] = field(default_factory=lambda: defaultdict(int)) - _urgent_latencies: list[float] = field(default_factory=list) - - def _base_outcome_reward(self, case: Case, was_scheduled: bool, hearing_outcome: str) -> float: - """Preserve the original per-case shaping signals.""" - - reward = 0.0 - if not was_scheduled: - return reward - - # Base scheduling reward (small positive for taking action) - reward += 0.5 - - # Hearing outcome rewards - lower_outcome = hearing_outcome.lower() - if "disposal" in lower_outcome or "judgment" in lower_outcome or "settlement" in lower_outcome: - reward += 10.0 # Major positive for disposal - elif "progress" in lower_outcome and "adjourn" not in lower_outcome: - reward += 3.0 # Progress without disposal - elif "adjourn" in lower_outcome: - reward -= 3.0 # Negative for adjournment - - # Urgency bonus - if case.is_urgent: - reward += 2.0 - - # Ripeness penalty - if hasattr(case, "ripeness_status") and case.ripeness_status not in ["RIPE", "UNKNOWN"]: - reward -= 4.0 - - # Long pending bonus (>365 days) - if case.age_days and case.age_days > 365: - reward += 2.0 - - return reward - - def _fairness_score(self) -> float: - """Reward higher uniformity in hearing distribution.""" - - counts: Iterable[int] = self._hearing_counts.values() - if not counts: - return 0.0 - - counts_array = np.array(list(counts), dtype=float) - mean = np.mean(counts_array) - if mean == 0: - return 0.0 - - dispersion = np.std(counts_array) / (mean + 1e-6) - # Lower dispersion -> better fairness. Convert to reward in [0, 1]. - fairness = max(0.0, 1.0 - dispersion) - return fairness - - def compute_case_reward( - self, - case: Case, - was_scheduled: bool, - hearing_outcome: str, - current_date, - previous_gap_days: Optional[int] = None, - ) -> float: - """Compute reward using both local and episode-level signals.""" - - reward = self._base_outcome_reward(case, was_scheduled, hearing_outcome) - - if not was_scheduled: - return reward - - # Track disposals - if "disposal" in hearing_outcome.lower() or getattr(case, "is_disposed", False): - self._disposed_cases += 1 - - # Track hearing counts for fairness - self._hearing_counts[case.case_id] = case.hearing_count or self._hearing_counts[case.case_id] + 1 - - # Track urgent latencies - if case.is_urgent: - self._urgent_latencies.append(case.age_days or 0) - - # Episode-level components - disposal_rate = (self._disposed_cases / self.total_cases) if self.total_cases else 0.0 - reward += self.disposal_weight * disposal_rate - - if previous_gap_days is not None: - gap_score = max(0.0, 1.0 - (previous_gap_days / self.target_gap_days)) - reward += self.gap_weight * gap_score - - if self._urgent_latencies: - avg_latency = float(np.mean(self._urgent_latencies)) - latency_score = max(0.0, 1.0 - (avg_latency / self.max_urgent_latency)) - reward += self.urgent_weight * latency_score - - fairness = self._fairness_score() - reward += self.fairness_weight * fairness - - return reward - diff --git a/rl/simple_agent.py b/rl/simple_agent.py deleted file mode 100644 index 8a249475b481f9b5113f391d44149059511ba775..0000000000000000000000000000000000000000 --- a/rl/simple_agent.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Tabular Q-learning agent for court case priority scoring. - -Implements the simplified RL approach described in RL_EXPLORATION_PLAN.md: -- 6D state space per case -- Binary action space (schedule/skip) -- Tabular Q-learning with epsilon-greedy exploration -""" - -import numpy as np -import pickle -from pathlib import Path -from typing import Dict, Tuple, Optional, List -from dataclasses import dataclass -from collections import defaultdict - -from scheduler.core.case import Case - - -@dataclass -class CaseState: - """Expanded state representation for a case with environment context.""" - - stage_encoded: int # 0-7 for different stages - age_days: float # normalized 0-1 - days_since_last: float # normalized 0-1 - urgency: int # 0 or 1 - ripe: int # 0 or 1 - hearing_count: float # normalized 0-1 - capacity_ratio: float # normalized 0-1 (remaining capacity for the day) - min_gap_days: int # encoded min gap rule in effect - preference_score: float # normalized 0-1 preference alignment - - def to_tuple(self) -> Tuple[int, int, int, int, int, int, int, int, int]: - """Convert to tuple for use as dict key.""" - return ( - self.stage_encoded, - min(9, int(self.age_days * 20)), # discretize to 20 bins, cap at 9 - min(9, int(self.days_since_last * 20)), # discretize to 20 bins, cap at 9 - self.urgency, - self.ripe, - min(9, int(self.hearing_count * 20)), # discretize to 20 bins, cap at 9 - min(9, int(self.capacity_ratio * 10)), - min(30, self.min_gap_days), - min(9, int(self.preference_score * 10)) - ) - - -class TabularQAgent: - """Tabular Q-learning agent for case priority scoring.""" - - # Stage mapping based on config.py - STAGE_TO_ID = { - "PRE-ADMISSION": 0, - "ADMISSION": 1, - "FRAMING OF CHARGES": 2, - "EVIDENCE": 3, - "ARGUMENTS": 4, - "INTERLOCUTORY APPLICATION": 5, - "SETTLEMENT": 6, - "ORDERS / JUDGMENT": 7, - "FINAL DISPOSAL": 8, - "OTHER": 9, - "NA": 10 - } - - def __init__(self, learning_rate: float = 0.1, epsilon: float = 0.1, - discount: float = 0.95): - """Initialize tabular Q-learning agent. - - Args: - learning_rate: Q-learning step size - epsilon: Exploration probability - discount: Discount factor for future rewards - """ - self.learning_rate = learning_rate - self.epsilon = epsilon - self.discount = discount - - # Q-table: state -> action -> Q-value - # Actions: 0 = skip, 1 = schedule - self.q_table: Dict[Tuple, Dict[int, float]] = defaultdict(lambda: {0: 0.0, 1: 0.0}) - - # Statistics - self.states_visited = set() - self.total_updates = 0 - - def extract_state( - self, - case: Case, - current_date, - *, - capacity_ratio: float = 1.0, - min_gap_days: int = 7, - preference_score: float = 0.0, - ) -> CaseState: - """Extract 6D state representation from a case. - - Args: - case: Case object - current_date: Current simulation date - - Returns: - CaseState representation - """ - # Stage encoding - stage_id = self.STAGE_TO_ID.get(case.current_stage, 9) # Default to "OTHER" - - # Age in days (normalized by max reasonable age of 2 years) - actual_age = max(0, case.age_days) if case.age_days is not None else max(0, (current_date - case.filed_date).days) - age_days = min(actual_age / (365 * 2), 1.0) - - # Days since last hearing (normalized by max reasonable gap of 180 days) - days_since = 0.0 - if case.last_hearing_date: - days_gap = max(0, (current_date - case.last_hearing_date).days) - days_since = min(days_gap / 180, 1.0) - else: - # No previous hearing - use age as days since "last" hearing - days_since = min(actual_age / 180, 1.0) - - # Urgency flag - urgency = 1 if case.is_urgent else 0 - - # Ripeness (assuming we have ripeness status) - ripe = 1 if hasattr(case, 'ripeness_status') and case.ripeness_status == "RIPE" else 0 - - # Hearing count (normalized by reasonable max of 20 hearings) - hearing_count = min(case.hearing_count / 20, 1.0) if case.hearing_count else 0.0 - - return CaseState( - stage_encoded=stage_id, - age_days=age_days, - days_since_last=days_since, - urgency=urgency, - ripe=ripe, - hearing_count=hearing_count, - capacity_ratio=max(0.0, min(1.0, capacity_ratio)), - min_gap_days=max(0, min_gap_days), - preference_score=max(0.0, min(1.0, preference_score)) - ) - - def get_action(self, state: CaseState, training: bool = False) -> int: - """Select action using epsilon-greedy policy. - - Args: - state: Current case state - training: Whether in training mode (enables exploration) - - Returns: - Action: 0 = skip, 1 = schedule - """ - state_key = state.to_tuple() - self.states_visited.add(state_key) - - # Epsilon-greedy exploration during training - if training and np.random.random() < self.epsilon: - return np.random.choice([0, 1]) - - # Greedy action selection - q_values = self.q_table[state_key] - if q_values[0] == q_values[1]: # If tied, prefer scheduling (action 1) - return 1 - return max(q_values, key=q_values.get) - - def get_priority_score(self, case: Case, current_date) -> float: - """Get priority score for a case (Q-value for schedule action). - - Args: - case: Case object - current_date: Current simulation date - - Returns: - Priority score (Q-value for action=1) - """ - state = self.extract_state(case, current_date) - state_key = state.to_tuple() - return self.q_table[state_key][1] # Q-value for schedule action - - def update_q_value(self, state: CaseState, action: int, reward: float, - next_state: Optional[CaseState] = None): - """Update Q-table using Q-learning rule. - - Args: - state: Current state - action: Action taken - reward: Reward received - next_state: Next state (optional, for terminal states) - """ - state_key = state.to_tuple() - - # Q-learning update - old_q = self.q_table[state_key][action] - - if next_state is not None: - next_key = next_state.to_tuple() - max_next_q = max(self.q_table[next_key].values()) - target = reward + self.discount * max_next_q - else: - # Terminal state - target = reward - - new_q = old_q + self.learning_rate * (target - old_q) - self.q_table[state_key][action] = new_q - self.total_updates += 1 - - def compute_reward(self, case: Case, was_scheduled: bool, hearing_outcome: str) -> float: - """Compute reward based on the outcome as per RL plan. - - Reward function: - +2 if case progresses - -1 if adjourned - +3 if urgent & scheduled - -2 if unripe & scheduled - +1 if long pending & scheduled - - Args: - case: Case object - was_scheduled: Whether case was scheduled - hearing_outcome: Outcome of the hearing - - Returns: - Reward value - """ - reward = 0.0 - - if was_scheduled: - # Base scheduling reward (small positive for taking action) - reward += 0.5 - - # Hearing outcome rewards - if "disposal" in hearing_outcome.lower() or "judgment" in hearing_outcome.lower() or "settlement" in hearing_outcome.lower(): - reward += 10.0 # Major positive for disposal - elif "progress" in hearing_outcome.lower() and "adjourn" not in hearing_outcome.lower(): - reward += 3.0 # Progress without disposal - elif "adjourn" in hearing_outcome.lower(): - reward -= 3.0 # Negative for adjournment - - # Urgency bonus - if case.is_urgent: - reward += 2.0 - - # Ripeness penalty - if hasattr(case, 'ripeness_status') and case.ripeness_status not in ["RIPE", "UNKNOWN"]: - reward -= 4.0 - - # Long pending bonus (>365 days) - if case.age_days and case.age_days > 365: - reward += 2.0 - - return reward - - def get_stats(self) -> Dict: - """Get agent statistics.""" - return { - "states_visited": len(self.states_visited), - "total_updates": self.total_updates, - "q_table_size": len(self.q_table), - "epsilon": self.epsilon, - "learning_rate": self.learning_rate - } - - def save(self, path: Path): - """Save agent to file.""" - agent_data = { - 'q_table': dict(self.q_table), - 'learning_rate': self.learning_rate, - 'epsilon': self.epsilon, - 'discount': self.discount, - 'states_visited': self.states_visited, - 'total_updates': self.total_updates - } - with open(path, 'wb') as f: - pickle.dump(agent_data, f) - - @classmethod - def load(cls, path: Path) -> 'TabularQAgent': - """Load agent from file.""" - with open(path, 'rb') as f: - agent_data = pickle.load(f) - - agent = cls( - learning_rate=agent_data['learning_rate'], - epsilon=agent_data['epsilon'], - discount=agent_data['discount'] - ) - agent.q_table = defaultdict(lambda: {0: 0.0, 1: 0.0}) - agent.q_table.update(agent_data['q_table']) - agent.states_visited = agent_data['states_visited'] - agent.total_updates = agent_data['total_updates'] - - return agent \ No newline at end of file diff --git a/rl/training.py b/rl/training.py deleted file mode 100644 index ac631ec5cc5ef81d8203908833436093ff33f1e5..0000000000000000000000000000000000000000 --- a/rl/training.py +++ /dev/null @@ -1,515 +0,0 @@ -"""Training pipeline for tabular Q-learning agent. - -Implements episodic training on generated case data to learn optimal -case prioritization policies through simulation-based rewards. -""" - -import numpy as np -from pathlib import Path -from typing import List, Tuple, Dict, Optional -from datetime import date, datetime, timedelta -import random - -from scheduler.data.case_generator import CaseGenerator -from scheduler.data.param_loader import ParameterLoader -from scheduler.core.case import Case, CaseStatus -from scheduler.core.algorithm import SchedulingAlgorithm -from scheduler.core.courtroom import Courtroom -from scheduler.core.policy import SchedulerPolicy -from scheduler.simulation.policies.readiness import ReadinessPolicy -from scheduler.simulation.allocator import CourtroomAllocator, AllocationStrategy -from scheduler.control.overrides import Override, OverrideType, JudgePreferences -from .simple_agent import TabularQAgent, CaseState -from .rewards import EpisodeRewardHelper -from .config import ( - RLTrainingConfig, - PolicyConfig, - DEFAULT_RL_TRAINING_CONFIG, - DEFAULT_POLICY_CONFIG, -) - - -class RLTrainingEnvironment: - """Training environment for RL agent using court simulation.""" - - def __init__( - self, - cases: List[Case], - start_date: date, - horizon_days: int = 90, - rl_config: RLTrainingConfig | None = None, - policy_config: PolicyConfig | None = None, - params_dir: Optional[Path] = None, - ): - """Initialize training environment. - - Args: - cases: List of cases to simulate - start_date: Simulation start date - horizon_days: Training episode length in days - rl_config: RL-specific training constraints - policy_config: Policy knobs for ripeness/gap rules - params_dir: Directory with EDA parameters (uses latest if None) - """ - self.cases = cases - self.start_date = start_date - self.horizon_days = horizon_days - self.current_date = start_date - self.episode_rewards = [] - self.rl_config = rl_config or DEFAULT_RL_TRAINING_CONFIG - self.policy_config = policy_config or DEFAULT_POLICY_CONFIG - self.reward_helper = EpisodeRewardHelper(total_cases=len(cases)) - self.param_loader = ParameterLoader(params_dir) - - # Resources mirroring production defaults - self.courtrooms = [ - Courtroom( - courtroom_id=i + 1, - judge_id=f"J{i+1:03d}", - daily_capacity=self.rl_config.daily_capacity_per_courtroom, - ) - for i in range(self.rl_config.courtrooms) - ] - self.allocator = CourtroomAllocator( - num_courtrooms=self.rl_config.courtrooms, - per_courtroom_capacity=self.rl_config.daily_capacity_per_courtroom, - strategy=AllocationStrategy.LOAD_BALANCED, - ) - self.policy: SchedulerPolicy = ReadinessPolicy() - self.algorithm = SchedulingAlgorithm( - policy=self.policy, - allocator=self.allocator, - min_gap_days=self.policy_config.min_gap_days if self.rl_config.enforce_min_gap else 0, - ) - self.preferences = self._build_preferences() - - def _build_preferences(self) -> Optional[JudgePreferences]: - """Synthetic judge preferences for training context.""" - if not self.rl_config.apply_judge_preferences: - return None - - capacity_overrides = {room.courtroom_id: room.daily_capacity for room in self.courtrooms} - return JudgePreferences( - judge_id="RL-JUDGE", - capacity_overrides=capacity_overrides, - case_type_preferences={ - "Monday": ["RSA"], - "Tuesday": ["CCC"], - "Wednesday": ["NI ACT"], - }, - ) - def reset(self) -> List[Case]: - """Reset environment for new training episode. - - Note: In practice, train_agent() generates fresh cases per episode, - so case state doesn't need resetting. This method just resets - environment state (date, rewards). - """ - self.current_date = self.start_date - self.episode_rewards = [] - self.reward_helper = EpisodeRewardHelper(total_cases=len(self.cases)) - return self.cases.copy() - - def capacity_ratio(self, remaining_slots: int) -> float: - """Proportion of courtroom capacity still available for the day.""" - total_capacity = self.rl_config.courtrooms * self.rl_config.daily_capacity_per_courtroom - return max(0.0, min(1.0, remaining_slots / total_capacity)) if total_capacity else 0.0 - - def preference_score(self, case: Case) -> float: - """Return 1.0 when case_type aligns with day-of-week preference, else 0.""" - if not self.preferences: - return 0.0 - - day_name = self.current_date.strftime("%A") - preferred_types = self.preferences.case_type_preferences.get(day_name, []) - return 1.0 if case.case_type in preferred_types else 0.0 - - def step(self, agent_decisions: Dict[str, int]) -> Tuple[List[Case], Dict[str, float], bool]: - """Execute one day of simulation with agent decisions via SchedulingAlgorithm.""" - rewards: Dict[str, float] = {} - - # Convert agent schedule actions into priority overrides - overrides: List[Override] = [] - priority_boost = 1.0 - for case in self.cases: - if agent_decisions.get(case.case_id) == 1: - overrides.append( - Override( - override_id=f"rl-{case.case_id}-{self.current_date.isoformat()}", - override_type=OverrideType.PRIORITY, - case_id=case.case_id, - judge_id="RL-JUDGE", - timestamp=datetime.combine(self.current_date, datetime.min.time()), - new_priority=case.get_priority_score() + priority_boost, - ) - ) - priority_boost += 0.1 # keep relative ordering stable - - # Run scheduling algorithm (capacity, ripeness, min-gap enforced) - result = self.algorithm.schedule_day( - cases=self.cases, - courtrooms=self.courtrooms, - current_date=self.current_date, - overrides=overrides or None, - preferences=self.preferences, - ) - - # Flatten scheduled cases - scheduled_cases = [c for cases in result.scheduled_cases.values() for c in cases] - # Simulate hearing outcomes for scheduled cases - for case in scheduled_cases: - if case.is_disposed: - continue - - outcome = self._simulate_hearing_outcome(case) - was_heard = "heard" in outcome.lower() - - # Track gap relative to previous hearing for reward shaping - previous_gap = None - if case.last_hearing_date: - previous_gap = max(0, (self.current_date - case.last_hearing_date).days) - - case.record_hearing(self.current_date, was_heard=was_heard, outcome=outcome) - - if was_heard: - if outcome in ["FINAL DISPOSAL", "SETTLEMENT", "NA"]: - case.status = CaseStatus.DISPOSED - case.disposal_date = self.current_date - elif outcome != "ADJOURNED": - case.current_stage = outcome - - # Compute reward using shared reward helper - rewards[case.case_id] = self.reward_helper.compute_case_reward( - case, - was_scheduled=True, - hearing_outcome=outcome, - current_date=self.current_date, - previous_gap_days=previous_gap, - ) - # Update case ages - for case in self.cases: - case.update_age(self.current_date) - - # Move to next day - self.current_date += timedelta(days=1) - episode_done = (self.current_date - self.start_date).days >= self.horizon_days - - return self.cases, rewards, episode_done - - def _simulate_hearing_outcome(self, case: Case) -> str: - """Simulate hearing outcome using EDA-derived parameters. - - Uses param_loader for adjournment probabilities and stage transitions - instead of hardcoded values, ensuring training aligns with production. - """ - current_stage = case.current_stage - case_type = case.case_type - - # Query EDA-derived adjournment probability - p_adjourn = self.param_loader.get_adjournment_prob(current_stage, case_type) - - # Sample adjournment - if random.random() < p_adjourn: - return "ADJOURNED" - - # Case progresses - determine next stage using EDA-derived transitions - # Terminal stages lead to disposal - if current_stage in ["ORDERS / JUDGMENT", "FINAL DISPOSAL"]: - return "FINAL DISPOSAL" - - # Sample next stage using cumulative transition probabilities - transitions = self.param_loader.get_stage_transitions_fast(current_stage) - if not transitions: - # No transition data - use fallback progression - return self._fallback_stage_progression(current_stage) - - # Sample from cumulative probabilities - rand_val = random.random() - for next_stage, cum_prob in transitions: - if rand_val <= cum_prob: - return next_stage - - # Fallback if sampling fails (shouldn't happen with normalized probs) - return transitions[-1][0] if transitions else current_stage - - def _fallback_stage_progression(self, current_stage: str) -> str: - """Fallback stage progression when no transition data available.""" - progression_map = { - "PRE-ADMISSION": "ADMISSION", - "ADMISSION": "EVIDENCE", - "FRAMING OF CHARGES": "EVIDENCE", - "EVIDENCE": "ARGUMENTS", - "ARGUMENTS": "ORDERS / JUDGMENT", - "INTERLOCUTORY APPLICATION": "ARGUMENTS", - "SETTLEMENT": "FINAL DISPOSAL", - } - return progression_map.get(current_stage, "ARGUMENTS") - - -def train_agent( - agent: TabularQAgent, - rl_config: RLTrainingConfig = DEFAULT_RL_TRAINING_CONFIG, - policy_config: PolicyConfig = DEFAULT_POLICY_CONFIG, - params_dir: Optional[Path] = None, - verbose: bool = True, -) -> Dict: - """Train RL agent using episodic simulation with courtroom constraints. - - Args: - agent: TabularQAgent to train - rl_config: RL training configuration - policy_config: Policy configuration - params_dir: Directory with EDA parameters (uses latest if None) - verbose: Print training progress - """ - config = rl_config or DEFAULT_RL_TRAINING_CONFIG - policy_cfg = policy_config or DEFAULT_POLICY_CONFIG - - # Align agent hyperparameters with config - agent.learning_rate = config.learning_rate - agent.discount = config.discount_factor - agent.epsilon = config.initial_epsilon - - training_stats = { - "episodes": [], - "total_rewards": [], - "disposal_rates": [], - "states_explored": [], - "q_updates": [], - } - - if verbose: - print(f"Training RL agent for {config.episodes} episodes...") - - for episode in range(config.episodes): - # Generate fresh cases for this episode - start_date = date(2024, 1, 1) + timedelta(days=episode * 10) - end_date = start_date + timedelta(days=30) - - generator = CaseGenerator( - start=start_date, - end=end_date, - seed=config.training_seed + episode, - ) - cases = generator.generate(config.cases_per_episode, stage_mix_auto=config.stage_mix_auto) - - # Initialize training environment - env = RLTrainingEnvironment( - cases, - start_date, - config.episode_length_days, - rl_config=config, - policy_config=policy_cfg, - params_dir=params_dir, - ) - - # Reset environment - episode_cases = env.reset() - episode_reward = 0.0 - - total_capacity = config.courtrooms * config.daily_capacity_per_courtroom - - # Run episode - for _ in range(config.episode_length_days): - # Get eligible cases (not disposed, basic filtering) - eligible_cases = [c for c in episode_cases if not c.is_disposed] - if not eligible_cases: - break - - # Agent makes decisions for each case - agent_decisions = {} - case_states = {} - - daily_cap = config.max_daily_allocations or total_capacity - if not config.cap_daily_allocations: - daily_cap = len(eligible_cases) - remaining_slots = min(daily_cap, total_capacity) if config.cap_daily_allocations else daily_cap - - for case in eligible_cases[:daily_cap]: - cap_ratio = env.capacity_ratio(remaining_slots if remaining_slots else total_capacity) - pref_score = env.preference_score(case) - state = agent.extract_state( - case, - env.current_date, - capacity_ratio=cap_ratio, - min_gap_days=policy_cfg.min_gap_days if config.enforce_min_gap else 0, - preference_score=pref_score, - ) - action = agent.get_action(state, training=True) - - if config.cap_daily_allocations and action == 1 and remaining_slots <= 0: - action = 0 - elif action == 1 and config.cap_daily_allocations: - remaining_slots = max(0, remaining_slots - 1) - - agent_decisions[case.case_id] = action - case_states[case.case_id] = state - - # Environment step - _, rewards, done = env.step(agent_decisions) - - # Update Q-values based on rewards - for case_id, reward in rewards.items(): - if case_id in case_states: - state = case_states[case_id] - action = agent_decisions.get(case_id, 0) - - agent.update_q_value(state, action, reward) - episode_reward += reward - - if done: - break - - # Compute episode statistics - disposed_count = sum(1 for c in episode_cases if c.is_disposed) - disposal_rate = disposed_count / len(episode_cases) if episode_cases else 0.0 - - # Record statistics - training_stats["episodes"].append(episode) - training_stats["total_rewards"].append(episode_reward) - training_stats["disposal_rates"].append(disposal_rate) - training_stats["states_explored"].append(len(agent.states_visited)) - training_stats["q_updates"].append(agent.total_updates) - - # Decay exploration - agent.epsilon = max(config.min_epsilon, agent.epsilon * config.epsilon_decay) - - if verbose and (episode + 1) % 10 == 0: - print( - f"Episode {episode + 1}/{config.episodes}: " - f"Reward={episode_reward:.1f}, " - f"Disposal={disposal_rate:.1%}, " - f"States={len(agent.states_visited)}, " - f"Epsilon={agent.epsilon:.3f}" - ) - - if verbose: - final_stats = agent.get_stats() - print(f"\nTraining complete!") - print(f"States explored: {final_stats['states_visited']}") - print(f"Q-table size: {final_stats['q_table_size']}") - print(f"Total updates: {final_stats['total_updates']}") - - return training_stats - - -def evaluate_agent( - agent: TabularQAgent, - test_cases: List[Case], - episodes: Optional[int] = None, - episode_length: Optional[int] = None, - rl_config: RLTrainingConfig = DEFAULT_RL_TRAINING_CONFIG, - policy_config: PolicyConfig = DEFAULT_POLICY_CONFIG, - params_dir: Optional[Path] = None, -) -> Dict: - """Evaluate trained agent performance. - - Args: - agent: Trained TabularQAgent to evaluate - test_cases: Cases to evaluate on - episodes: Number of evaluation episodes (default 10) - episode_length: Length of each episode in days - rl_config: RL configuration - policy_config: Policy configuration - params_dir: Directory with EDA parameters (uses latest if None) - """ - # Set agent to evaluation mode (no exploration) - original_epsilon = agent.epsilon - agent.epsilon = 0.0 - - config = rl_config or DEFAULT_RL_TRAINING_CONFIG - policy_cfg = policy_config or DEFAULT_POLICY_CONFIG - - evaluation_stats = { - "disposal_rates": [], - "total_hearings": [], - "avg_hearing_to_disposal": [], - "utilization": [], - } - - eval_episodes = episodes if episodes is not None else 10 - eval_length = episode_length if episode_length is not None else config.episode_length_days - - print(f"Evaluating agent on {eval_episodes} test episodes...") - - total_capacity = config.courtrooms * config.daily_capacity_per_courtroom - - for episode in range(eval_episodes): - start_date = date(2024, 6, 1) + timedelta(days=episode * 10) - env = RLTrainingEnvironment( - test_cases.copy(), - start_date, - eval_length, - rl_config=config, - policy_config=policy_cfg, - params_dir=params_dir, - ) - - episode_cases = env.reset() - total_hearings = 0 - - # Run evaluation episode - for _ in range(eval_length): - eligible_cases = [c for c in episode_cases if not c.is_disposed] - if not eligible_cases: - break - - daily_cap = config.max_daily_allocations or total_capacity - remaining_slots = min(daily_cap, total_capacity) if config.cap_daily_allocations else len(eligible_cases) - - # Agent makes decisions (no exploration) - agent_decisions = {} - for case in eligible_cases[:daily_cap]: - cap_ratio = env.capacity_ratio(remaining_slots if remaining_slots else total_capacity) - pref_score = env.preference_score(case) - state = agent.extract_state( - case, - env.current_date, - capacity_ratio=cap_ratio, - min_gap_days=policy_cfg.min_gap_days if config.enforce_min_gap else 0, - preference_score=pref_score, - ) - action = agent.get_action(state, training=False) - if config.cap_daily_allocations and action == 1 and remaining_slots <= 0: - action = 0 - elif action == 1 and config.cap_daily_allocations: - remaining_slots = max(0, remaining_slots - 1) - - agent_decisions[case.case_id] = action - - # Environment step - _, rewards, done = env.step(agent_decisions) - total_hearings += len([r for r in rewards.values() if r != 0]) - - if done: - break - - # Compute metrics - disposed_count = sum(1 for c in episode_cases if c.is_disposed) - disposal_rate = disposed_count / len(episode_cases) - - disposed_cases = [c for c in episode_cases if c.is_disposed] - avg_hearings = np.mean([c.hearing_count for c in disposed_cases]) if disposed_cases else 0 - - evaluation_stats["disposal_rates"].append(disposal_rate) - evaluation_stats["total_hearings"].append(total_hearings) - evaluation_stats["avg_hearing_to_disposal"].append(avg_hearings) - evaluation_stats["utilization"].append(total_hearings / (eval_length * total_capacity)) - - # Restore original epsilon - agent.epsilon = original_epsilon - - # Compute summary statistics - summary = { - "mean_disposal_rate": np.mean(evaluation_stats["disposal_rates"]), - "std_disposal_rate": np.std(evaluation_stats["disposal_rates"]), - "mean_utilization": np.mean(evaluation_stats["utilization"]), - "mean_hearings_to_disposal": np.mean(evaluation_stats["avg_hearing_to_disposal"]), - } - - print("Evaluation complete:") - print(f"Mean disposal rate: {summary['mean_disposal_rate']:.1%} ± {summary['std_disposal_rate']:.1%}") - print(f"Mean utilization: {summary['mean_utilization']:.1%}") - print(f"Avg hearings to disposal: {summary['mean_hearings_to_disposal']:.1f}") - - return summary diff --git a/run_comprehensive_sweep.ps1 b/run_comprehensive_sweep.ps1 deleted file mode 100644 index 3a8ebc9247b30b5888d48f96b922990e1d56604d..0000000000000000000000000000000000000000 --- a/run_comprehensive_sweep.ps1 +++ /dev/null @@ -1,316 +0,0 @@ -# Comprehensive Parameter Sweep for Court Scheduling System -# Runs multiple scenarios × multiple policies × multiple seeds - -Write-Host "================================================" -ForegroundColor Cyan -Write-Host "COMPREHENSIVE PARAMETER SWEEP" -ForegroundColor Cyan -Write-Host "================================================" -ForegroundColor Cyan -Write-Host "" - -$ErrorActionPreference = "Stop" -$results = @() - -# Configuration matrix -$scenarios = @( - @{ - name = "baseline_10k_2year" - cases = 10000 - seed = 42 - days = 500 - description = "2-year simulation: 10k cases, ~500 working days (HACKATHON REQUIREMENT)" - }, - @{ - name = "baseline_10k" - cases = 10000 - seed = 42 - days = 200 - description = "Baseline: 10k cases, balanced distribution" - }, - @{ - name = "baseline_10k_seed2" - cases = 10000 - seed = 123 - days = 200 - description = "Baseline replica with different seed" - }, - @{ - name = "baseline_10k_seed3" - cases = 10000 - seed = 456 - days = 200 - description = "Baseline replica with different seed" - }, - @{ - name = "small_5k" - cases = 5000 - seed = 42 - days = 200 - description = "Small court: 5k cases" - }, - @{ - name = "large_15k" - cases = 15000 - seed = 42 - days = 200 - description = "Large backlog: 15k cases" - }, - @{ - name = "xlarge_20k" - cases = 20000 - seed = 42 - days = 150 - description = "Extra large: 20k cases, capacity stress" - } -) - -$policies = @("fifo", "age", "readiness") - -Write-Host "Configuration:" -ForegroundColor Yellow -Write-Host " Scenarios: $($scenarios.Count)" -ForegroundColor White -Write-Host " Policies: $($policies.Count)" -ForegroundColor White -Write-Host " Total simulations: $($scenarios.Count * $policies.Count)" -ForegroundColor White -Write-Host "" - -$totalRuns = $scenarios.Count * $policies.Count -$currentRun = 0 - -# Create results directory -$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" -$resultsDir = "data\comprehensive_sweep_$timestamp" -New-Item -ItemType Directory -Path $resultsDir -Force | Out-Null - -# Generate datasets -Write-Host "Step 1: Generating datasets..." -ForegroundColor Cyan -$datasetDir = "$resultsDir\datasets" -New-Item -ItemType Directory -Path $datasetDir -Force | Out-Null - -foreach ($scenario in $scenarios) { - Write-Host " Generating $($scenario.name)..." -NoNewline - $datasetPath = "$datasetDir\$($scenario.name)_cases.csv" - - & uv run python main.py generate --cases $scenario.cases --seed $scenario.seed --output $datasetPath > $null - - if ($LASTEXITCODE -eq 0) { - Write-Host " OK" -ForegroundColor Green - } else { - Write-Host " FAILED" -ForegroundColor Red - exit 1 - } -} - -Write-Host "" -Write-Host "Step 2: Running simulations..." -ForegroundColor Cyan - -foreach ($scenario in $scenarios) { - $datasetPath = "$datasetDir\$($scenario.name)_cases.csv" - - foreach ($policy in $policies) { - $currentRun++ - $runName = "$($scenario.name)_$policy" - $logDir = "$resultsDir\$runName" - - $progress = [math]::Round(($currentRun / $totalRuns) * 100, 1) - Write-Host "[$currentRun/$totalRuns - $progress%] " -NoNewline -ForegroundColor Yellow - Write-Host "$runName" -NoNewline -ForegroundColor White - Write-Host " ($($scenario.days) days)..." -NoNewline -ForegroundColor Gray - - $startTime = Get-Date - - & uv run python main.py simulate ` - --days $scenario.days ` - --cases $datasetPath ` - --policy $policy ` - --log-dir $logDir ` - --seed $scenario.seed > $null - - $endTime = Get-Date - $duration = ($endTime - $startTime).TotalSeconds - - if ($LASTEXITCODE -eq 0) { - Write-Host " OK " -ForegroundColor Green -NoNewline - Write-Host "($([math]::Round($duration, 1))s)" -ForegroundColor Gray - - # Parse report - $reportPath = "$logDir\report.txt" - if (Test-Path $reportPath) { - $reportContent = Get-Content $reportPath -Raw - - # Extract metrics using regex - if ($reportContent -match 'Cases disposed: (\d+)') { - $disposed = [int]$matches[1] - } - if ($reportContent -match 'Disposal rate: ([\d.]+)%') { - $disposalRate = [double]$matches[1] - } - if ($reportContent -match 'Gini coefficient: ([\d.]+)') { - $gini = [double]$matches[1] - } - if ($reportContent -match 'Court utilization: ([\d.]+)%') { - $utilization = [double]$matches[1] - } - if ($reportContent -match 'Total hearings: ([\d,]+)') { - $hearings = $matches[1] -replace ',', '' - } - - $results += [PSCustomObject]@{ - Scenario = $scenario.name - Policy = $policy - Cases = $scenario.cases - Days = $scenario.days - Seed = $scenario.seed - Disposed = $disposed - DisposalRate = $disposalRate - Gini = $gini - Utilization = $utilization - Hearings = $hearings - Duration = [math]::Round($duration, 1) - } - } - } else { - Write-Host " FAILED" -ForegroundColor Red - } - } -} - -Write-Host "" -Write-Host "Step 3: Generating summary..." -ForegroundColor Cyan - -# Export results to CSV -$resultsCSV = "$resultsDir\summary_results.csv" -$results | Export-Csv -Path $resultsCSV -NoTypeInformation - -Write-Host " Results saved to: $resultsCSV" -ForegroundColor Green - -# Generate markdown summary -$summaryMD = "$resultsDir\SUMMARY.md" -$markdown = @" -# Comprehensive Simulation Results - -**Generated**: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") -**Total Simulations**: $totalRuns -**Scenarios**: $($scenarios.Count) -**Policies**: $($policies.Count) - -## Results Matrix - -### Disposal Rate (%) - -| Scenario | FIFO | Age | Readiness | Best | -|----------|------|-----|-----------|------| -"@ - -foreach ($scenario in $scenarios) { - $fifo = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "fifo" }).DisposalRate - $age = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "age" }).DisposalRate - $readiness = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "readiness" }).DisposalRate - - $best = [math]::Max($fifo, [math]::Max($age, $readiness)) - $bestPolicy = if ($fifo -eq $best) { "FIFO" } elseif ($age -eq $best) { "Age" } else { "**Readiness**" } - - $markdown += "`n| $($scenario.name) | $fifo | $age | **$readiness** | $bestPolicy |" -} - -$markdown += @" - - -### Gini Coefficient (Fairness) - -| Scenario | FIFO | Age | Readiness | Best | -|----------|------|-----|-----------|------| -"@ - -foreach ($scenario in $scenarios) { - $fifo = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "fifo" }).Gini - $age = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "age" }).Gini - $readiness = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "readiness" }).Gini - - $best = [math]::Min($fifo, [math]::Min($age, $readiness)) - $bestPolicy = if ($fifo -eq $best) { "FIFO" } elseif ($age -eq $best) { "Age" } else { "**Readiness**" } - - $markdown += "`n| $($scenario.name) | $fifo | $age | **$readiness** | $bestPolicy |" -} - -$markdown += @" - - -### Utilization (%) - -| Scenario | FIFO | Age | Readiness | Best | -|----------|------|-----|-----------|------| -"@ - -foreach ($scenario in $scenarios) { - $fifo = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "fifo" }).Utilization - $age = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "age" }).Utilization - $readiness = ($results | Where-Object { $_.Scenario -eq $scenario.name -and $_.Policy -eq "readiness" }).Utilization - - $best = [math]::Max($fifo, [math]::Max($age, $readiness)) - $bestPolicy = if ($fifo -eq $best) { "FIFO" } elseif ($age -eq $best) { "Age" } else { "**Readiness**" } - - $markdown += "`n| $($scenario.name) | $fifo | $age | **$readiness** | $bestPolicy |" -} - -$markdown += @" - - -## Statistical Summary - -### Our Algorithm (Readiness) Performance - -"@ - -$readinessResults = $results | Where-Object { $_.Policy -eq "readiness" } -$avgDisposal = ($readinessResults.DisposalRate | Measure-Object -Average).Average -$stdDisposal = [math]::Sqrt((($readinessResults.DisposalRate | ForEach-Object { [math]::Pow($_ - $avgDisposal, 2) }) | Measure-Object -Average).Average) -$minDisposal = ($readinessResults.DisposalRate | Measure-Object -Minimum).Minimum -$maxDisposal = ($readinessResults.DisposalRate | Measure-Object -Maximum).Maximum - -$markdown += @" - -- **Mean Disposal Rate**: $([math]::Round($avgDisposal, 1))% -- **Std Dev**: $([math]::Round($stdDisposal, 2))% -- **Min**: $minDisposal% -- **Max**: $maxDisposal% -- **Coefficient of Variation**: $([math]::Round(($stdDisposal / $avgDisposal) * 100, 1))% - -### Performance Comparison (Average across all scenarios) - -| Metric | FIFO | Age | Readiness | Advantage | -|--------|------|-----|-----------|-----------| -"@ - -$avgDisposalFIFO = ($results | Where-Object { $_.Policy -eq "fifo" } | Measure-Object -Property DisposalRate -Average).Average -$avgDisposalAge = ($results | Where-Object { $_.Policy -eq "age" } | Measure-Object -Property DisposalRate -Average).Average -$avgDisposalReadiness = ($results | Where-Object { $_.Policy -eq "readiness" } | Measure-Object -Property DisposalRate -Average).Average -$advDisposal = $avgDisposalReadiness - [math]::Max($avgDisposalFIFO, $avgDisposalAge) - -$avgGiniFIFO = ($results | Where-Object { $_.Policy -eq "fifo" } | Measure-Object -Property Gini -Average).Average -$avgGiniAge = ($results | Where-Object { $_.Policy -eq "age" } | Measure-Object -Property Gini -Average).Average -$avgGiniReadiness = ($results | Where-Object { $_.Policy -eq "readiness" } | Measure-Object -Property Gini -Average).Average -$advGini = [math]::Min($avgGiniFIFO, $avgGiniAge) - $avgGiniReadiness - -$markdown += @" - -| **Disposal Rate** | $([math]::Round($avgDisposalFIFO, 1))% | $([math]::Round($avgDisposalAge, 1))% | **$([math]::Round($avgDisposalReadiness, 1))%** | +$([math]::Round($advDisposal, 1))% | -| **Gini** | $([math]::Round($avgGiniFIFO, 3)) | $([math]::Round($avgGiniAge, 3)) | **$([math]::Round($avgGiniReadiness, 3))** | -$([math]::Round($advGini, 3)) (better) | - -## Files - -- Raw data: `summary_results.csv` -- Individual reports: `_/report.txt` -- Datasets: `datasets/_cases.csv` - ---- -Generated by comprehensive_sweep.ps1 -"@ - -$markdown | Out-File -FilePath $summaryMD -Encoding UTF8 - -Write-Host " Summary saved to: $summaryMD" -ForegroundColor Green -Write-Host "" - -Write-Host "================================================" -ForegroundColor Cyan -Write-Host "SWEEP COMPLETE!" -ForegroundColor Green -Write-Host "================================================" -ForegroundColor Cyan -Write-Host "Results directory: $resultsDir" -ForegroundColor Yellow -Write-Host "Total duration: $([math]::Round(($results | Measure-Object -Property Duration -Sum).Sum / 60, 1)) minutes" -ForegroundColor White -Write-Host "" diff --git a/runs/baseline/report.txt b/runs/baseline/report.txt deleted file mode 100644 index 3abf88c4fd8a345e3ae9604ee65c2340ffeb657b..0000000000000000000000000000000000000000 --- a/runs/baseline/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 3000 - Days simulated: 30 - Policy: readiness - Horizon end: 2024-05-09 - -Hearing Metrics: - Total hearings: 8,671 - Heard: 5,355 (61.8%) - Adjourned: 3,316 (38.2%) - -Disposal Metrics: - Cases disposed: 320 - Disposal rate: 10.7% - Gini coefficient: 0.190 - -Disposal Rates by Case Type: - CA : 73/ 587 ( 12.4%) - CCC : 57/ 334 ( 17.1%) - CMP : 6/ 86 ( 7.0%) - CP : 46/ 294 ( 15.6%) - CRP : 61/ 612 ( 10.0%) - RFA : 49/ 519 ( 9.4%) - RSA : 28/ 568 ( 4.9%) - -Efficiency Metrics: - Court utilization: 38.3% - Avg hearings/day: 289.0 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 1,680 - Filter rate: 16.2% - -Final Ripeness Distribution: - RIPE: 2624 (97.9%) - UNRIPE_DEPENDENT: 19 (0.7%) - UNRIPE_SUMMONS: 37 (1.4%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.002 - Avg daily load: 57.8 cases - Allocation changes: 4,624 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 1,740 cases (58.0/day) - Courtroom 2: 1,737 cases (57.9/day) - Courtroom 3: 1,736 cases (57.9/day) - Courtroom 4: 1,732 cases (57.7/day) - Courtroom 5: 1,726 cases (57.5/day) diff --git a/runs/baseline_comparison/report.txt b/runs/baseline_comparison/report.txt deleted file mode 100644 index 1a15e60bec13466f210d80f623b177f879e3b82f..0000000000000000000000000000000000000000 --- a/runs/baseline_comparison/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 3000 - Days simulated: 60 - Policy: readiness - Horizon end: 2024-06-20 - -Hearing Metrics: - Total hearings: 16,137 - Heard: 9,981 (61.9%) - Adjourned: 6,156 (38.1%) - -Disposal Metrics: - Cases disposed: 708 - Disposal rate: 23.6% - Gini coefficient: 0.195 - -Disposal Rates by Case Type: - CA : 159/ 587 ( 27.1%) - CCC : 133/ 334 ( 39.8%) - CMP : 14/ 86 ( 16.3%) - CP : 105/ 294 ( 35.7%) - CRP : 142/ 612 ( 23.2%) - RFA : 77/ 519 ( 14.8%) - RSA : 78/ 568 ( 13.7%) - -Efficiency Metrics: - Court utilization: 35.6% - Avg hearings/day: 268.9 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 3,360 - Filter rate: 17.2% - -Final Ripeness Distribution: - RIPE: 2236 (97.6%) - UNRIPE_DEPENDENT: 19 (0.8%) - UNRIPE_SUMMONS: 37 (1.6%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.002 - Avg daily load: 53.8 cases - Allocation changes: 10,527 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 3,244 cases (54.1/day) - Courtroom 2: 3,233 cases (53.9/day) - Courtroom 3: 3,227 cases (53.8/day) - Courtroom 4: 3,221 cases (53.7/day) - Courtroom 5: 3,212 cases (53.5/day) diff --git a/runs/baseline_large_data/report.txt b/runs/baseline_large_data/report.txt deleted file mode 100644 index d997c45a32c3ad2178f61d4e7a921e934849ef0b..0000000000000000000000000000000000000000 --- a/runs/baseline_large_data/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 10000 - Days simulated: 90 - Policy: readiness - Horizon end: 2024-10-31 - -Hearing Metrics: - Total hearings: 58,262 - Heard: 36,595 (62.8%) - Adjourned: 21,667 (37.2%) - -Disposal Metrics: - Cases disposed: 5,195 - Disposal rate: 51.9% - Gini coefficient: 0.243 - -Disposal Rates by Case Type: - CA : 1358/1952 ( 69.6%) - CCC : 796/1132 ( 70.3%) - CMP : 172/ 281 ( 61.2%) - CP : 662/ 960 ( 69.0%) - CRP : 1365/2061 ( 66.2%) - RFA : 363/1676 ( 21.7%) - RSA : 479/1938 ( 24.7%) - -Efficiency Metrics: - Court utilization: 85.7% - Avg hearings/day: 647.4 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 20,340 - Filter rate: 25.9% - -Final Ripeness Distribution: - RIPE: 4579 (95.3%) - UNRIPE_DEPENDENT: 58 (1.2%) - UNRIPE_SUMMONS: 168 (3.5%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.001 - Avg daily load: 129.5 cases - Allocation changes: 38,756 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 11,671 cases (129.7/day) - Courtroom 2: 11,666 cases (129.6/day) - Courtroom 3: 11,654 cases (129.5/day) - Courtroom 4: 11,640 cases (129.3/day) - Courtroom 5: 11,631 cases (129.2/day) diff --git a/runs/rl_final_test/report.txt b/runs/rl_final_test/report.txt deleted file mode 100644 index 00409b5c4dea3bb1aa7ffc542f9ee6154f79a63f..0000000000000000000000000000000000000000 --- a/runs/rl_final_test/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 3000 - Days simulated: 60 - Policy: rl - Horizon end: 2024-06-20 - -Hearing Metrics: - Total hearings: 16,133 - Heard: 9,929 (61.5%) - Adjourned: 6,204 (38.5%) - -Disposal Metrics: - Cases disposed: 700 - Disposal rate: 23.3% - Gini coefficient: 0.194 - -Disposal Rates by Case Type: - CA : 159/ 587 ( 27.1%) - CCC : 128/ 334 ( 38.3%) - CMP : 15/ 86 ( 17.4%) - CP : 101/ 294 ( 34.4%) - CRP : 151/ 612 ( 24.7%) - RFA : 72/ 519 ( 13.9%) - RSA : 74/ 568 ( 13.0%) - -Efficiency Metrics: - Court utilization: 35.6% - Avg hearings/day: 268.9 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 3,360 - Filter rate: 17.2% - -Final Ripeness Distribution: - RIPE: 2244 (97.6%) - UNRIPE_DEPENDENT: 19 (0.8%) - UNRIPE_SUMMONS: 37 (1.6%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.002 - Avg daily load: 53.8 cases - Allocation changes: 9,860 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 3,242 cases (54.0/day) - Courtroom 2: 3,234 cases (53.9/day) - Courtroom 3: 3,227 cases (53.8/day) - Courtroom 4: 3,219 cases (53.6/day) - Courtroom 5: 3,211 cases (53.5/day) diff --git a/runs/rl_intensive/report.txt b/runs/rl_intensive/report.txt deleted file mode 100644 index 00409b5c4dea3bb1aa7ffc542f9ee6154f79a63f..0000000000000000000000000000000000000000 --- a/runs/rl_intensive/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 3000 - Days simulated: 60 - Policy: rl - Horizon end: 2024-06-20 - -Hearing Metrics: - Total hearings: 16,133 - Heard: 9,929 (61.5%) - Adjourned: 6,204 (38.5%) - -Disposal Metrics: - Cases disposed: 700 - Disposal rate: 23.3% - Gini coefficient: 0.194 - -Disposal Rates by Case Type: - CA : 159/ 587 ( 27.1%) - CCC : 128/ 334 ( 38.3%) - CMP : 15/ 86 ( 17.4%) - CP : 101/ 294 ( 34.4%) - CRP : 151/ 612 ( 24.7%) - RFA : 72/ 519 ( 13.9%) - RSA : 74/ 568 ( 13.0%) - -Efficiency Metrics: - Court utilization: 35.6% - Avg hearings/day: 268.9 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 3,360 - Filter rate: 17.2% - -Final Ripeness Distribution: - RIPE: 2244 (97.6%) - UNRIPE_DEPENDENT: 19 (0.8%) - UNRIPE_SUMMONS: 37 (1.6%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.002 - Avg daily load: 53.8 cases - Allocation changes: 9,860 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 3,242 cases (54.0/day) - Courtroom 2: 3,234 cases (53.9/day) - Courtroom 3: 3,227 cases (53.8/day) - Courtroom 4: 3,219 cases (53.6/day) - Courtroom 5: 3,211 cases (53.5/day) diff --git a/runs/rl_large_data/report.txt b/runs/rl_large_data/report.txt deleted file mode 100644 index b9a3e9b1f2e5e7f75dfea6bd259d4404be3ff8a3..0000000000000000000000000000000000000000 --- a/runs/rl_large_data/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 10000 - Days simulated: 90 - Policy: rl - Horizon end: 2024-10-31 - -Hearing Metrics: - Total hearings: 57,999 - Heard: 36,465 (62.9%) - Adjourned: 21,534 (37.1%) - -Disposal Metrics: - Cases disposed: 5,212 - Disposal rate: 52.1% - Gini coefficient: 0.248 - -Disposal Rates by Case Type: - CA : 1366/1952 ( 70.0%) - CCC : 815/1132 ( 72.0%) - CMP : 174/ 281 ( 61.9%) - CP : 649/ 960 ( 67.6%) - CRP : 1348/2061 ( 65.4%) - RFA : 356/1676 ( 21.2%) - RSA : 504/1938 ( 26.0%) - -Efficiency Metrics: - Court utilization: 85.4% - Avg hearings/day: 644.4 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 20,340 - Filter rate: 26.0% - -Final Ripeness Distribution: - RIPE: 4562 (95.3%) - UNRIPE_DEPENDENT: 58 (1.2%) - UNRIPE_SUMMONS: 168 (3.5%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.001 - Avg daily load: 128.9 cases - Allocation changes: 37,970 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 11,622 cases (129.1/day) - Courtroom 2: 11,610 cases (129.0/day) - Courtroom 3: 11,599 cases (128.9/day) - Courtroom 4: 11,590 cases (128.8/day) - Courtroom 5: 11,578 cases (128.6/day) diff --git a/runs/rl_untrained/report.txt b/runs/rl_untrained/report.txt deleted file mode 100644 index 9ee703795873be8315938eafc75a41c5358ba6b9..0000000000000000000000000000000000000000 --- a/runs/rl_untrained/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 3000 - Days simulated: 30 - Policy: rl - Horizon end: 2024-05-09 - -Hearing Metrics: - Total hearings: 8,668 - Heard: 5,338 (61.6%) - Adjourned: 3,330 (38.4%) - -Disposal Metrics: - Cases disposed: 312 - Disposal rate: 10.4% - Gini coefficient: 0.191 - -Disposal Rates by Case Type: - CA : 73/ 587 ( 12.4%) - CCC : 46/ 334 ( 13.8%) - CMP : 5/ 86 ( 5.8%) - CP : 44/ 294 ( 15.0%) - CRP : 72/ 612 ( 11.8%) - RFA : 40/ 519 ( 7.7%) - RSA : 32/ 568 ( 5.6%) - -Efficiency Metrics: - Court utilization: 38.3% - Avg hearings/day: 288.9 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 1,680 - Filter rate: 16.2% - -Final Ripeness Distribution: - RIPE: 2632 (97.9%) - UNRIPE_DEPENDENT: 19 (0.7%) - UNRIPE_SUMMONS: 37 (1.4%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.002 - Avg daily load: 57.8 cases - Allocation changes: 4,412 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 1,742 cases (58.1/day) - Courtroom 2: 1,737 cases (57.9/day) - Courtroom 3: 1,732 cases (57.7/day) - Courtroom 4: 1,730 cases (57.7/day) - Courtroom 5: 1,727 cases (57.6/day) diff --git a/runs/rl_vs_baseline/comparison_report.md b/runs/rl_vs_baseline/comparison_report.md deleted file mode 100644 index c3f5f84d5862580eb3fd3350581d2adf3d2b731f..0000000000000000000000000000000000000000 --- a/runs/rl_vs_baseline/comparison_report.md +++ /dev/null @@ -1,29 +0,0 @@ -# Scheduling Policy Comparison Report - -Policies evaluated: readiness, rl - -## Key Metrics Comparison - -| Metric | readiness | rl | Best | -|--------|-------|-------|------| -| Disposals | - | - | - | -| Gini (fairness) | - | - | - | -| Utilization (%) | - | - | - | -| Adjournment Rate (%) | - | - | - | -| Hearings Heard | 5 | 5 | - | -| Total Hearings | - | - | - | - -## Analysis - -**Fairness**: readiness policy achieves lowest Gini coefficient (999.000), indicating most equitable disposal time distribution. - -**Efficiency**: readiness policy achieves highest utilization (0.0%), maximizing courtroom capacity usage. - -**Throughput**: readiness policy produces most disposals (0), clearing cases fastest. - - -## Recommendation - -**Recommended Policy**: readiness - -This policy wins on 0/0 key metrics, providing the best balance of fairness, efficiency, and throughput. diff --git a/runs/rl_vs_baseline/readiness/report.txt b/runs/rl_vs_baseline/readiness/report.txt deleted file mode 100644 index 3abf88c4fd8a345e3ae9604ee65c2340ffeb657b..0000000000000000000000000000000000000000 --- a/runs/rl_vs_baseline/readiness/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 3000 - Days simulated: 30 - Policy: readiness - Horizon end: 2024-05-09 - -Hearing Metrics: - Total hearings: 8,671 - Heard: 5,355 (61.8%) - Adjourned: 3,316 (38.2%) - -Disposal Metrics: - Cases disposed: 320 - Disposal rate: 10.7% - Gini coefficient: 0.190 - -Disposal Rates by Case Type: - CA : 73/ 587 ( 12.4%) - CCC : 57/ 334 ( 17.1%) - CMP : 6/ 86 ( 7.0%) - CP : 46/ 294 ( 15.6%) - CRP : 61/ 612 ( 10.0%) - RFA : 49/ 519 ( 9.4%) - RSA : 28/ 568 ( 4.9%) - -Efficiency Metrics: - Court utilization: 38.3% - Avg hearings/day: 289.0 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 1,680 - Filter rate: 16.2% - -Final Ripeness Distribution: - RIPE: 2624 (97.9%) - UNRIPE_DEPENDENT: 19 (0.7%) - UNRIPE_SUMMONS: 37 (1.4%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.002 - Avg daily load: 57.8 cases - Allocation changes: 4,624 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 1,740 cases (58.0/day) - Courtroom 2: 1,737 cases (57.9/day) - Courtroom 3: 1,736 cases (57.9/day) - Courtroom 4: 1,732 cases (57.7/day) - Courtroom 5: 1,726 cases (57.5/day) diff --git a/runs/rl_vs_baseline/rl/report.txt b/runs/rl_vs_baseline/rl/report.txt deleted file mode 100644 index 9ee703795873be8315938eafc75a41c5358ba6b9..0000000000000000000000000000000000000000 --- a/runs/rl_vs_baseline/rl/report.txt +++ /dev/null @@ -1,56 +0,0 @@ -================================================================================ -SIMULATION REPORT -================================================================================ - -Configuration: - Cases: 3000 - Days simulated: 30 - Policy: rl - Horizon end: 2024-05-09 - -Hearing Metrics: - Total hearings: 8,668 - Heard: 5,338 (61.6%) - Adjourned: 3,330 (38.4%) - -Disposal Metrics: - Cases disposed: 312 - Disposal rate: 10.4% - Gini coefficient: 0.191 - -Disposal Rates by Case Type: - CA : 73/ 587 ( 12.4%) - CCC : 46/ 334 ( 13.8%) - CMP : 5/ 86 ( 5.8%) - CP : 44/ 294 ( 15.0%) - CRP : 72/ 612 ( 11.8%) - RFA : 40/ 519 ( 7.7%) - RSA : 32/ 568 ( 5.6%) - -Efficiency Metrics: - Court utilization: 38.3% - Avg hearings/day: 288.9 - -Ripeness Impact: - Transitions: 0 - Cases filtered (unripe): 1,680 - Filter rate: 16.2% - -Final Ripeness Distribution: - RIPE: 2632 (97.9%) - UNRIPE_DEPENDENT: 19 (0.7%) - UNRIPE_SUMMONS: 37 (1.4%) - -Courtroom Allocation: - Strategy: load_balanced - Load balance fairness (Gini): 0.002 - Avg daily load: 57.8 cases - Allocation changes: 4,412 - Capacity rejections: 0 - - Courtroom-wise totals: - Courtroom 1: 1,742 cases (58.1/day) - Courtroom 2: 1,737 cases (57.9/day) - Courtroom 3: 1,732 cases (57.7/day) - Courtroom 4: 1,730 cases (57.7/day) - Courtroom 5: 1,727 cases (57.6/day) diff --git a/scheduler/control/__init__.py b/scheduler/control/__init__.py index 0b8ac8e575d1c5d21d487330a39e46c05711d707..7fb3ae1b1cc61c3f1f111c2218c394692c9b2581 100644 --- a/scheduler/control/__init__.py +++ b/scheduler/control/__init__.py @@ -3,19 +3,14 @@ Provides explainability and judge override capabilities. """ -from .explainability import ( - DecisionStep, - SchedulingExplanation, - ExplainabilityEngine -) - +from .explainability import DecisionStep, ExplainabilityEngine, SchedulingExplanation from .overrides import ( - OverrideType, - Override, - JudgePreferences, CauseListDraft, + JudgePreferences, + Override, + OverrideManager, + OverrideType, OverrideValidator, - OverrideManager ) __all__ = [ diff --git a/scheduler/control/explainability.py b/scheduler/control/explainability.py index 4a44ca38718096cbb5874bebc09e0761bcf21f0d..9027d5d24876e24bf5a5b7f9f5fc3c7db36f4f62 100644 --- a/scheduler/control/explainability.py +++ b/scheduler/control/explainability.py @@ -2,16 +2,27 @@ Provides human-readable explanations for why each case was or wasn't scheduled. """ + from dataclasses import dataclass -from typing import Optional from datetime import date +from typing import Optional from scheduler.core.case import Case +def _fmt_score(score: Optional[float]) -> str: + """Format a score safely; return 'N/A' when score is None. + + Avoids `TypeError: unsupported format string passed to NoneType.__format__` + when `priority_score` may be missing for not-scheduled cases. + """ + return f"{score:.4f}" if isinstance(score, (int, float)) else "N/A" + + @dataclass class DecisionStep: """Single step in decision reasoning.""" + step_name: str passed: bool reason: str @@ -21,43 +32,44 @@ class DecisionStep: @dataclass class SchedulingExplanation: """Complete explanation of scheduling decision for a case.""" + case_id: str scheduled: bool decision_steps: list[DecisionStep] final_reason: str priority_breakdown: Optional[dict] = None courtroom_assignment_reason: Optional[str] = None - + def to_readable_text(self) -> str: """Convert to human-readable explanation.""" lines = [f"Case {self.case_id}: {'SCHEDULED' if self.scheduled else 'NOT SCHEDULED'}"] lines.append("=" * 60) - + for i, step in enumerate(self.decision_steps, 1): - status = "✓ PASS" if step.passed else "✗ FAIL" + status = "[PASS]" if step.passed else "[FAIL]" lines.append(f"\nStep {i}: {step.step_name} - {status}") lines.append(f" Reason: {step.reason}") if step.details: for key, value in step.details.items(): lines.append(f" {key}: {value}") - + if self.priority_breakdown and self.scheduled: - lines.append(f"\nPriority Score Breakdown:") + lines.append("\nPriority Score Breakdown:") for component, value in self.priority_breakdown.items(): lines.append(f" {component}: {value}") - + if self.courtroom_assignment_reason and self.scheduled: - lines.append(f"\nCourtroom Assignment:") + lines.append("\nCourtroom Assignment:") lines.append(f" {self.courtroom_assignment_reason}") - + lines.append(f"\nFinal Decision: {self.final_reason}") - + return "\n".join(lines) class ExplainabilityEngine: """Generate explanations for scheduling decisions.""" - + @staticmethod def explain_scheduling_decision( case: Case, @@ -67,51 +79,56 @@ class ExplainabilityEngine: priority_score: Optional[float] = None, courtroom_id: Optional[int] = None, capacity_full: bool = False, - below_threshold: bool = False + below_threshold: bool = False, ) -> SchedulingExplanation: """Generate complete explanation for why case was/wasn't scheduled. - + Args: case: The case being scheduled current_date: Current simulation date scheduled: Whether case was scheduled ripeness_status: Ripeness classification - priority_score: Calculated priority score if scheduled + priority_score: Calculated priority score if available courtroom_id: Assigned courtroom if scheduled capacity_full: Whether capacity was full below_threshold: Whether priority was below threshold - + Returns: Complete scheduling explanation """ - steps = [] - + steps: list[DecisionStep] = [] + priority_breakdown: Optional[dict] = None # ensure defined for return + # Step 1: Disposal status check if case.is_disposed: - steps.append(DecisionStep( - step_name="Case Status Check", - passed=False, - reason="Case already disposed", - details={"disposal_date": str(case.disposal_date)} - )) + steps.append( + DecisionStep( + step_name="Case Status Check", + passed=False, + reason="Case already disposed", + details={"disposal_date": str(case.disposal_date)}, + ) + ) return SchedulingExplanation( case_id=case.case_id, scheduled=False, decision_steps=steps, - final_reason="Case disposed, no longer eligible for scheduling" + final_reason="Case disposed, no longer eligible for scheduling", ) - - steps.append(DecisionStep( - step_name="Case Status Check", - passed=True, - reason="Case active and eligible", - details={"status": case.status.value} - )) - + + steps.append( + DecisionStep( + step_name="Case Status Check", + passed=True, + reason="Case active and eligible", + details={"status": case.status.value}, + ) + ) + # Step 2: Ripeness check is_ripe = ripeness_status == "RIPE" - ripeness_detail = {} - + ripeness_detail: dict = {} + if not is_ripe: if "SUMMONS" in ripeness_status: ripeness_detail["bottleneck"] = "Summons not yet served" @@ -126,191 +143,237 @@ class ExplainabilityEngine: ripeness_detail["bottleneck"] = ripeness_status else: ripeness_detail["status"] = "All prerequisites met, ready for hearing" - + if case.last_hearing_purpose: ripeness_detail["last_hearing_purpose"] = case.last_hearing_purpose - - steps.append(DecisionStep( - step_name="Ripeness Classification", - passed=is_ripe, - reason="Case is RIPE (ready for hearing)" if is_ripe else f"Case is UNRIPE ({ripeness_status})", - details=ripeness_detail - )) - + + steps.append( + DecisionStep( + step_name="Ripeness Classification", + passed=is_ripe, + reason=( + "Case is RIPE (ready for hearing)" + if is_ripe + else f"Case is UNRIPE ({ripeness_status})" + ), + details=ripeness_detail, + ) + ) + if not is_ripe and not scheduled: return SchedulingExplanation( case_id=case.case_id, scheduled=False, decision_steps=steps, - final_reason=f"Case not scheduled: UNRIPE status blocks scheduling. {ripeness_detail.get('action_needed', 'Waiting for case to become ready')}" + final_reason=( + "Case not scheduled: UNRIPE status blocks scheduling. " + f"{ripeness_detail.get('action_needed', 'Waiting for case to become ready')}" + ), ) - + # Step 3: Minimum gap check min_gap_days = 7 days_since = case.days_since_last_hearing meets_gap = case.last_hearing_date is None or days_since >= min_gap_days - - gap_details = { - "days_since_last_hearing": days_since, - "minimum_required": min_gap_days - } - + + gap_details = {"days_since_last_hearing": days_since, "minimum_required": min_gap_days} + if case.last_hearing_date: gap_details["last_hearing_date"] = str(case.last_hearing_date) - - steps.append(DecisionStep( - step_name="Minimum Gap Check", - passed=meets_gap, - reason=f"{'Meets' if meets_gap else 'Does not meet'} minimum {min_gap_days}-day gap requirement", - details=gap_details - )) - + + steps.append( + DecisionStep( + step_name="Minimum Gap Check", + passed=meets_gap, + reason=f"{'Meets' if meets_gap else 'Does not meet'} minimum {min_gap_days}-day gap requirement", + details=gap_details, + ) + ) + if not meets_gap and not scheduled: - next_eligible = case.last_hearing_date.isoformat() if case.last_hearing_date else "unknown" + next_eligible = ( + case.last_hearing_date.isoformat() if case.last_hearing_date else "unknown" + ) return SchedulingExplanation( case_id=case.case_id, scheduled=False, decision_steps=steps, - final_reason=f"Case not scheduled: Only {days_since} days since last hearing (minimum {min_gap_days} required). Next eligible after {next_eligible}" + final_reason=( + f"Case not scheduled: Only {days_since} days since last hearing (minimum {min_gap_days} required). " + f"Next eligible after {next_eligible}" + ), ) - - # Step 4: Priority calculation + + # Step 4: Priority calculation (only if a score was provided) if priority_score is not None: + import math + age_component = min(case.age_days / 2000, 1.0) * 0.35 readiness_component = case.readiness_score * 0.25 urgency_component = (1.0 if case.is_urgent else 0.0) * 0.25 - + # Adjournment boost calculation - import math adj_boost_value = 0.0 if case.status.value == "ADJOURNED" and case.hearing_count > 0: adj_boost_value = math.exp(-case.days_since_last_hearing / 21) adj_boost_component = adj_boost_value * 0.15 - + priority_breakdown = { "Age": f"{age_component:.4f} (age={case.age_days}d, weight=0.35)", "Readiness": f"{readiness_component:.4f} (score={case.readiness_score:.2f}, weight=0.25)", "Urgency": f"{urgency_component:.4f} ({'URGENT' if case.is_urgent else 'normal'}, weight=0.25)", - "Adjournment Boost": f"{adj_boost_component:.4f} (days_since={days_since}, decay=exp(-{days_since}/21), weight=0.15)", - "TOTAL": f"{priority_score:.4f}" + "Adjournment Boost": ( + f"{adj_boost_component:.4f} (days_since={days_since}, decay=exp(-{days_since}/21), weight=0.15)" + ), + "TOTAL": _fmt_score(priority_score), } - - steps.append(DecisionStep( - step_name="Priority Calculation", - passed=True, - reason=f"Priority score calculated: {priority_score:.4f}", - details=priority_breakdown - )) - - # Step 5: Selection by policy + + steps.append( + DecisionStep( + step_name="Priority Calculation", + passed=True, + reason=f"Priority score calculated: {_fmt_score(priority_score)}", + details=priority_breakdown, + ) + ) + + # Step 5: Selection by policy and final assembly if scheduled: if capacity_full: - steps.append(DecisionStep( - step_name="Capacity Check", - passed=True, - reason="Selected despite full capacity (high priority override)", - details={"priority_score": f"{priority_score:.4f}"} - )) + steps.append( + DecisionStep( + step_name="Capacity Check", + passed=True, + reason="Selected despite full capacity (high priority override)", + details={"priority_score": _fmt_score(priority_score)}, + ) + ) elif below_threshold: - steps.append(DecisionStep( - step_name="Policy Selection", - passed=True, - reason="Selected by policy despite being below typical threshold", - details={"reason": "Algorithm determined case should be scheduled"} - )) + steps.append( + DecisionStep( + step_name="Policy Selection", + passed=True, + reason="Selected by policy despite being below typical threshold", + details={"reason": "Algorithm determined case should be scheduled"}, + ) + ) else: - steps.append(DecisionStep( - step_name="Policy Selection", - passed=True, - reason="Selected by scheduling policy among eligible cases", - details={ - "priority_rank": "Top priority among eligible cases", - "policy": "Readiness + Adjournment Boost" - } - )) - - # Courtroom assignment + steps.append( + DecisionStep( + step_name="Policy Selection", + passed=True, + reason="Selected by scheduling policy among eligible cases", + details={ + "priority_rank": "Top priority among eligible cases", + "policy": "Readiness + Adjournment Boost", + }, + ) + ) + + courtroom_reason = None if courtroom_id: courtroom_reason = f"Assigned to Courtroom {courtroom_id} via load balancing (least loaded courtroom selected)" - steps.append(DecisionStep( - step_name="Courtroom Assignment", - passed=True, - reason=courtroom_reason, - details={"courtroom_id": courtroom_id} - )) - - final_reason = f"Case SCHEDULED: Passed all checks, priority score {priority_score:.4f}, assigned to Courtroom {courtroom_id}" - + steps.append( + DecisionStep( + step_name="Courtroom Assignment", + passed=True, + reason=courtroom_reason, + details={"courtroom_id": courtroom_id}, + ) + ) + + # Build final reason safely (omit missing parts) + parts = [ + "Case SCHEDULED: Passed all checks", + f"priority score {_fmt_score(priority_score)}" + if priority_score is not None + else None, + f"assigned to Courtroom {courtroom_id}" if courtroom_id else None, + ] + final_reason = ", ".join(part for part in parts if part) + return SchedulingExplanation( case_id=case.case_id, scheduled=True, decision_steps=steps, final_reason=final_reason, - priority_breakdown=priority_breakdown if priority_score else None, - courtroom_assignment_reason=courtroom_reason if courtroom_id else None + priority_breakdown=priority_breakdown if priority_breakdown is not None else None, + courtroom_assignment_reason=courtroom_reason, ) - else: - # Not scheduled - determine why - if capacity_full: - steps.append(DecisionStep( + + # Not scheduled + if capacity_full: + steps.append( + DecisionStep( step_name="Capacity Check", passed=False, reason="Daily capacity limit reached", details={ - "priority_score": f"{priority_score:.4f}" if priority_score else "N/A", - "explanation": "Higher priority cases filled all available slots" - } - )) - final_reason = f"Case NOT SCHEDULED: Capacity full. Priority score {priority_score:.4f} was not high enough to displace scheduled cases" - elif below_threshold: - steps.append(DecisionStep( + "priority_score": _fmt_score(priority_score), + "explanation": "Higher priority cases filled all available slots", + }, + ) + ) + final_reason = ( + "Case NOT SCHEDULED: Capacity full. " + f"Priority {_fmt_score(priority_score)} was not high enough to displace scheduled cases" + ) + elif below_threshold: + steps.append( + DecisionStep( step_name="Policy Selection", passed=False, reason="Priority below scheduling threshold", details={ - "priority_score": f"{priority_score:.4f}" if priority_score else "N/A", - "explanation": "Other cases had higher priority scores" - } - )) - final_reason = f"Case NOT SCHEDULED: Priority score {priority_score:.4f} below threshold. Wait for case to age or become more urgent" - else: - final_reason = "Case NOT SCHEDULED: Unknown reason (policy decision)" - - return SchedulingExplanation( - case_id=case.case_id, - scheduled=False, - decision_steps=steps, - final_reason=final_reason, - priority_breakdown=priority_breakdown if priority_score else None + "priority_score": _fmt_score(priority_score), + "explanation": "Other cases had higher priority scores", + }, + ) ) - + final_reason = ( + "Case NOT SCHEDULED: " + f"Priority {_fmt_score(priority_score)} below threshold. Wait for case to age or become more urgent" + ) + else: + final_reason = "Case NOT SCHEDULED: Unknown reason (policy decision)" + + return SchedulingExplanation( + case_id=case.case_id, + scheduled=False, + decision_steps=steps, + final_reason=final_reason, + priority_breakdown=priority_breakdown if priority_breakdown is not None else None, + ) + @staticmethod def explain_why_not_scheduled(case: Case, current_date: date) -> str: """Quick explanation for why a case wasn't scheduled. - + Args: case: Case to explain current_date: Current date - + Returns: Human-readable reason """ if case.is_disposed: return f"Already disposed on {case.disposal_date}" - + if case.ripeness_status != "RIPE": bottleneck_reasons = { "UNRIPE_SUMMONS": "Summons not served", "UNRIPE_DEPENDENT": "Waiting for dependent case", "UNRIPE_PARTY": "Party unavailable", - "UNRIPE_DOCUMENT": "Documents pending" + "UNRIPE_DOCUMENT": "Documents pending", } reason = bottleneck_reasons.get(case.ripeness_status, case.ripeness_status) return f"UNRIPE: {reason}" - + if case.last_hearing_date and case.days_since_last_hearing < 7: - return f"Too recent (last hearing {case.days_since_last_hearing} days ago, minimum 7 days)" - + return ( + f"Too recent (last hearing {case.days_since_last_hearing} days ago, minimum 7 days)" + ) + # If ripe and meets gap, then it's priority-based priority = case.get_priority_score() return f"Low priority (score {priority:.3f}) - other cases ranked higher" diff --git a/scheduler/control/overrides.py b/scheduler/control/overrides.py index 8832d26c4aa9a43af3bc4833f7d741f61fa833e2..4ebfbb517328d58f2e26675459e27351a2a7d4f3 100644 --- a/scheduler/control/overrides.py +++ b/scheduler/control/overrides.py @@ -3,11 +3,11 @@ Allows judges to review, modify, and approve algorithmic scheduling suggestions. System is suggestive, not prescriptive - judges retain final control. """ +import json from dataclasses import dataclass, field from datetime import date, datetime from enum import Enum from typing import Optional -import json class OverrideType(Enum): @@ -35,13 +35,13 @@ class Override: reason: str = "" date_affected: Optional[date] = None courtroom_id: Optional[int] = None - + # Algorithm-specific attributes make_ripe: Optional[bool] = None # For RIPENESS overrides - new_position: Optional[int] = None # For REORDER/ADD_CASE overrides + new_position: Optional[int] = None # For REORDER/ADD_CASE overrides new_priority: Optional[float] = None # For PRIORITY overrides new_capacity: Optional[int] = None # For CAPACITY overrides - + def to_dict(self) -> dict: """Convert to dictionary for logging.""" return { @@ -60,32 +60,32 @@ class Override: "new_priority": self.new_priority, "new_capacity": self.new_capacity } - + def to_readable_text(self) -> str: """Human-readable description of override.""" action_desc = { OverrideType.RIPENESS: f"Changed ripeness from {self.old_value} to {self.new_value}", OverrideType.PRIORITY: f"Adjusted priority from {self.old_value} to {self.new_value}", - OverrideType.ADD_CASE: f"Manually added case to cause list", - OverrideType.REMOVE_CASE: f"Removed case from cause list", + OverrideType.ADD_CASE: "Manually added case to cause list", + OverrideType.REMOVE_CASE: "Removed case from cause list", OverrideType.REORDER: f"Reordered from position {self.old_value} to {self.new_value}", OverrideType.CAPACITY: f"Changed capacity from {self.old_value} to {self.new_value}", OverrideType.MIN_GAP: f"Overrode min gap from {self.old_value} to {self.new_value} days", OverrideType.COURTROOM: f"Changed courtroom from {self.old_value} to {self.new_value}" } - + action = action_desc.get(self.override_type, f"Override: {self.override_type.value}") - + parts = [ f"[{self.timestamp.strftime('%Y-%m-%d %H:%M')}]", f"Judge {self.judge_id}:", action, f"(Case {self.case_id})" ] - + if self.reason: parts.append(f"Reason: {self.reason}") - + return " ".join(parts) @@ -98,7 +98,7 @@ class JudgePreferences: min_gap_overrides: dict[str, int] = field(default_factory=dict) # Per-case gap overrides case_type_preferences: dict[str, list[str]] = field(default_factory=dict) # Day-of-week preferences capacity_overrides: dict[int, int] = field(default_factory=dict) # Per-courtroom capacity overrides - + def to_dict(self) -> dict: """Convert to dictionary.""" return { @@ -123,25 +123,25 @@ class CauseListDraft: created_at: datetime finalized_at: Optional[datetime] = None status: str = "DRAFT" # DRAFT, APPROVED, REJECTED - + def get_acceptance_rate(self) -> float: """Calculate what % of suggestions were accepted.""" if not self.algorithm_suggested: return 0.0 - + accepted = len(set(self.algorithm_suggested) & set(self.judge_approved)) return accepted / len(self.algorithm_suggested) * 100 - + def get_modifications_summary(self) -> dict: """Summarize modifications made.""" added = set(self.judge_approved) - set(self.algorithm_suggested) removed = set(self.algorithm_suggested) - set(self.judge_approved) - + override_counts = {} for override in self.overrides: override_type = override.override_type.value override_counts[override_type] = override_counts.get(override_type, 0) + 1 - + return { "cases_added": len(added), "cases_removed": len(removed), @@ -153,32 +153,31 @@ class CauseListDraft: class OverrideValidator: """Validates override requests against constraints.""" - + def __init__(self): self.errors: list[str] = [] - + def validate(self, override: Override) -> bool: """Validate an override against all applicable constraints. - + Args: override: Override to validate - + Returns: True if valid, False otherwise """ self.errors.clear() - + if override.override_type == OverrideType.RIPENESS: valid, error = self.validate_ripeness_override( override.case_id, - override.old_value or "", override.new_value or "", override.reason ) if not valid: self.errors.append(error) return False - + elif override.override_type == OverrideType.CAPACITY: if override.new_capacity is not None: valid, error = self.validate_capacity_override( @@ -188,59 +187,57 @@ class OverrideValidator: if not valid: self.errors.append(error) return False - + elif override.override_type == OverrideType.PRIORITY: if override.new_priority is not None: if not (0 <= override.new_priority <= 1.0): self.errors.append("Priority must be between 0 and 1.0") return False - + # Basic validation if not override.case_id: self.errors.append("Case ID is required") return False - + if not override.judge_id: self.errors.append("Judge ID is required") return False - + return True - + def get_errors(self) -> list[str]: """Get validation errors from last validation.""" return self.errors.copy() - + @staticmethod def validate_ripeness_override( case_id: str, - old_status: str, new_status: str, reason: str ) -> tuple[bool, str]: """Validate ripeness override. - + Args: case_id: Case ID - old_status: Current ripeness status new_status: Requested new status reason: Reason for override - + Returns: (valid, error_message) """ valid_statuses = ["RIPE", "UNRIPE_SUMMONS", "UNRIPE_DEPENDENT", "UNRIPE_PARTY", "UNRIPE_DOCUMENT"] - + if new_status not in valid_statuses: return False, f"Invalid ripeness status: {new_status}" - + if not reason: return False, "Reason required for ripeness override" - + if len(reason) < 10: return False, "Reason must be at least 10 characters" - + return True, "" - + @staticmethod def validate_capacity_override( current_capacity: int, @@ -248,26 +245,26 @@ class OverrideValidator: max_capacity: int = 200 ) -> tuple[bool, str]: """Validate capacity override. - + Args: current_capacity: Current daily capacity new_capacity: Requested new capacity max_capacity: Maximum allowed capacity - + Returns: (valid, error_message) """ if new_capacity < 0: return False, "Capacity cannot be negative" - + if new_capacity > max_capacity: return False, f"Capacity cannot exceed maximum ({max_capacity})" - + if new_capacity == 0: return False, "Capacity cannot be zero (use blocked dates for full closures)" - + return True, "" - + @staticmethod def validate_add_case( case_id: str, @@ -276,52 +273,52 @@ class OverrideValidator: max_capacity: int ) -> tuple[bool, str]: """Validate adding a case to cause list. - + Args: case_id: Case to add current_schedule: Currently scheduled case IDs current_capacity: Current number of scheduled cases max_capacity: Maximum capacity - + Returns: (valid, error_message) """ if case_id in current_schedule: return False, f"Case {case_id} already in schedule" - + if current_capacity >= max_capacity: return False, f"Schedule at capacity ({current_capacity}/{max_capacity})" - + return True, "" - + @staticmethod def validate_remove_case( case_id: str, current_schedule: list[str] ) -> tuple[bool, str]: """Validate removing a case from cause list. - + Args: case_id: Case to remove current_schedule: Currently scheduled case IDs - + Returns: (valid, error_message) """ if case_id not in current_schedule: return False, f"Case {case_id} not in schedule" - + return True, "" class OverrideManager: """Manages judge overrides and interventions.""" - + def __init__(self): self.overrides: list[Override] = [] self.drafts: list[CauseListDraft] = [] self.preferences: dict[str, JudgePreferences] = {} - + def create_draft( self, date: date, @@ -330,13 +327,13 @@ class OverrideManager: algorithm_suggested: list[str] ) -> CauseListDraft: """Create a draft cause list for judge review. - + Args: date: Date of cause list courtroom_id: Courtroom ID judge_id: Judge ID algorithm_suggested: Case IDs suggested by algorithm - + Returns: Draft cause list """ @@ -350,21 +347,21 @@ class OverrideManager: created_at=datetime.now(), status="DRAFT" ) - + self.drafts.append(draft) return draft - + def apply_override( self, draft: CauseListDraft, override: Override ) -> tuple[bool, str]: """Apply an override to a draft cause list. - + Args: draft: Draft to modify override: Override to apply - + Returns: (success, error_message) """ @@ -378,7 +375,7 @@ class OverrideManager: ) if not valid: return False, error - + elif override.override_type == OverrideType.ADD_CASE: valid, error = OverrideValidator.validate_add_case( override.case_id, @@ -388,9 +385,9 @@ class OverrideManager: ) if not valid: return False, error - + draft.judge_approved.append(override.case_id) - + elif override.override_type == OverrideType.REMOVE_CASE: valid, error = OverrideValidator.validate_remove_case( override.case_id, @@ -398,79 +395,79 @@ class OverrideManager: ) if not valid: return False, error - + draft.judge_approved.remove(override.case_id) - + # Record override draft.overrides.append(override) self.overrides.append(override) - + return True, "" - + def finalize_draft(self, draft: CauseListDraft) -> bool: """Finalize draft cause list (judge approval). - + Args: draft: Draft to finalize - + Returns: Success status """ if draft.status != "DRAFT": return False - + draft.status = "APPROVED" draft.finalized_at = datetime.now() - + return True - + def get_judge_preferences(self, judge_id: str) -> JudgePreferences: """Get or create judge preferences. - + Args: judge_id: Judge ID - + Returns: Judge preferences """ if judge_id not in self.preferences: self.preferences[judge_id] = JudgePreferences(judge_id=judge_id) - + return self.preferences[judge_id] - + def get_override_statistics(self, judge_id: Optional[str] = None) -> dict: """Get override statistics. - + Args: judge_id: Optional filter by judge - + Returns: Statistics dictionary """ relevant_overrides = self.overrides if judge_id: relevant_overrides = [o for o in self.overrides if o.judge_id == judge_id] - + if not relevant_overrides: return { "total_overrides": 0, "by_type": {}, "avg_per_day": 0 } - + override_counts = {} for override in relevant_overrides: override_type = override.override_type.value override_counts[override_type] = override_counts.get(override_type, 0) + 1 - + # Calculate acceptance rate from drafts relevant_drafts = self.drafts if judge_id: relevant_drafts = [d for d in self.drafts if d.judge_id == judge_id] - + acceptance_rates = [d.get_acceptance_rate() for d in relevant_drafts if d.status == "APPROVED"] avg_acceptance = sum(acceptance_rates) / len(acceptance_rates) if acceptance_rates else 0 - + return { "total_overrides": len(relevant_overrides), "by_type": override_counts, @@ -479,10 +476,10 @@ class OverrideManager: "avg_acceptance_rate": avg_acceptance, "modification_rate": 100 - avg_acceptance if avg_acceptance else 0 } - + def export_audit_trail(self, output_file: str): """Export complete audit trail to file. - + Args: output_file: Path to output file """ @@ -501,6 +498,6 @@ class OverrideManager: ], "statistics": self.get_override_statistics() } - + with open(output_file, 'w') as f: json.dump(audit_data, f, indent=2) diff --git a/scheduler/core/algorithm.py b/scheduler/core/algorithm.py index d5713c36f7fe15635970338ac74d134d61c4be5e..474eb2c0d6f541e75b76d2544bb20ec20c7d7ff3 100644 --- a/scheduler/core/algorithm.py +++ b/scheduler/core/algorithm.py @@ -14,25 +14,25 @@ from dataclasses import dataclass, field from datetime import date from typing import Dict, List, Optional, Tuple -from scheduler.core.case import Case, CaseStatus -from scheduler.core.courtroom import Courtroom -from scheduler.core.ripeness import RipenessClassifier, RipenessStatus -from scheduler.core.policy import SchedulerPolicy -from scheduler.simulation.allocator import CourtroomAllocator, AllocationStrategy from scheduler.control.explainability import ExplainabilityEngine, SchedulingExplanation from scheduler.control.overrides import ( + JudgePreferences, Override, OverrideType, - JudgePreferences, OverrideValidator, ) +from scheduler.core.case import Case, CaseStatus +from scheduler.core.courtroom import Courtroom +from scheduler.core.policy import SchedulerPolicy +from scheduler.core.ripeness import RipenessClassifier, RipenessStatus from scheduler.data.config import MIN_GAP_BETWEEN_HEARINGS +from scheduler.simulation.allocator import CourtroomAllocator @dataclass class SchedulingResult: """Result of single-day scheduling with full transparency. - + Attributes: scheduled_cases: Mapping of courtroom_id to list of scheduled cases explanations: Decision explanations for each case (scheduled + sample unscheduled) @@ -45,7 +45,7 @@ class SchedulingResult: policy_used: Name of scheduling policy used (FIFO, Age, Readiness) total_scheduled: Total number of cases scheduled (calculated) """ - + # Core output scheduled_cases: Dict[int, List[Case]] @@ -58,12 +58,12 @@ class SchedulingResult: unscheduled_cases: List[Tuple[Case, str]] ripeness_filtered: int capacity_limited: int - + # Metadata scheduling_date: date policy_used: str total_scheduled: int = field(init=False) - + def __post_init__(self): """Calculate derived fields.""" self.total_scheduled = sum(len(cases) for cases in self.scheduled_cases.values()) @@ -71,14 +71,14 @@ class SchedulingResult: class SchedulingAlgorithm: """Core scheduling algorithm with override support. - + This is the main product - a clean, reusable scheduling algorithm that: 1. Filters cases by ripeness and eligibility 2. Applies judge preferences and manual overrides 3. Prioritizes cases using selected policy 4. Allocates cases to courtrooms with load balancing 5. Generates explanations for all decisions - + Usage: algorithm = SchedulingAlgorithm(policy=readiness_policy, allocator=allocator) result = algorithm.schedule_day( @@ -89,7 +89,7 @@ class SchedulingAlgorithm: preferences=judge_prefs ) """ - + def __init__( self, policy: SchedulerPolicy, @@ -97,7 +97,7 @@ class SchedulingAlgorithm: min_gap_days: int = MIN_GAP_BETWEEN_HEARINGS ): """Initialize algorithm with policy and allocator. - + Args: policy: Scheduling policy (FIFO, Age, Readiness) allocator: Courtroom allocator (defaults to load-balanced) @@ -107,7 +107,7 @@ class SchedulingAlgorithm: self.allocator = allocator self.min_gap_days = min_gap_days self.explainer = ExplainabilityEngine() - + def schedule_day( self, cases: List[Case], @@ -118,7 +118,7 @@ class SchedulingAlgorithm: max_explanations_unscheduled: int = 100 ) -> SchedulingResult: """Schedule cases for a single day with override support. - + Args: cases: All active cases (will be filtered) courtrooms: Available courtrooms @@ -126,7 +126,7 @@ class SchedulingAlgorithm: overrides: Optional manual overrides to apply preferences: Optional judge preferences/constraints max_explanations_unscheduled: Max unscheduled cases to generate explanations for - + Returns: SchedulingResult with scheduled cases, explanations, and audit trail """ @@ -161,43 +161,43 @@ class SchedulingAlgorithm: # Filter disposed cases active_cases = [c for c in cases if c.status != CaseStatus.DISPOSED] - + # Update age and readiness for all cases for case in active_cases: case.update_age(current_date) case.compute_readiness_score() - + # CHECKPOINT 1: Ripeness filtering with override support ripe_cases, ripeness_filtered = self._filter_by_ripeness( active_cases, current_date, validated_overrides, applied_overrides ) - + # CHECKPOINT 2: Eligibility check (min gap requirement) eligible_cases = self._filter_eligible(ripe_cases, current_date, unscheduled) - + # CHECKPOINT 3: Apply judge preferences (capacity overrides tracked) if preferences: applied_overrides.extend(self._get_preference_overrides(preferences, courtrooms)) - + # CHECKPOINT 4: Prioritize using policy prioritized = self.policy.prioritize(eligible_cases, current_date) - + # CHECKPOINT 5: Apply manual overrides (add/remove/reorder/priority) if validated_overrides: prioritized = self._apply_manual_overrides( prioritized, validated_overrides, applied_overrides, unscheduled, active_cases ) - + # CHECKPOINT 6: Allocate to courtrooms scheduled_allocation, capacity_limited = self._allocate_cases( prioritized, courtrooms, current_date, preferences ) - + # Track capacity-limited cases total_scheduled = sum(len(cases) for cases in scheduled_allocation.values()) for case in prioritized[total_scheduled:]: unscheduled.append((case, "Capacity exceeded - all courtrooms full")) - + # CHECKPOINT 7: Generate explanations for scheduled cases for courtroom_id, cases_in_room in scheduled_allocation.items(): for case in cases_in_room: @@ -210,7 +210,7 @@ class SchedulingAlgorithm: courtroom_id=courtroom_id ) explanations[case.case_id] = explanation - + # Generate explanations for sample of unscheduled cases for case, reason in unscheduled[:max_explanations_unscheduled]: if case is not None: # Skip invalid override entries @@ -237,7 +237,7 @@ class SchedulingAlgorithm: scheduling_date=current_date, policy_used=self.policy.get_name() ) - + def _filter_by_ripeness( self, cases: List[Case], @@ -252,10 +252,10 @@ class SchedulingAlgorithm: for override in overrides: if override.override_type == OverrideType.RIPENESS: ripeness_overrides[override.case_id] = override.make_ripe - + ripe_cases = [] filtered_count = 0 - + for case in cases: # Check for ripeness override if case.case_id in ripeness_overrides: @@ -269,24 +269,24 @@ class SchedulingAlgorithm: case.mark_unripe(RipenessStatus.UNRIPE_DEPENDENT, "Judge override", current_date) filtered_count += 1 continue - + # Normal ripeness classification ripeness = RipenessClassifier.classify(case, current_date) - + if ripeness.value != case.ripeness_status: if ripeness.is_ripe(): case.mark_ripe(current_date) else: reason = RipenessClassifier.get_ripeness_reason(ripeness) case.mark_unripe(ripeness, reason, current_date) - + if ripeness.is_ripe(): ripe_cases.append(case) else: filtered_count += 1 - + return ripe_cases, filtered_count - + def _filter_eligible( self, cases: List[Case], @@ -302,7 +302,7 @@ class SchedulingAlgorithm: reason = f"Min gap not met - last hearing {case.days_since_last_hearing}d ago (min {self.min_gap_days}d)" unscheduled.append((case, reason)) return eligible - + def _get_preference_overrides( self, preferences: JudgePreferences, @@ -310,7 +310,7 @@ class SchedulingAlgorithm: ) -> List[Override]: """Extract overrides from judge preferences for audit trail.""" overrides = [] - + if preferences.capacity_overrides: from datetime import datetime for courtroom_id, new_capacity in preferences.capacity_overrides.items(): @@ -325,9 +325,9 @@ class SchedulingAlgorithm: reason="Judge preference" ) overrides.append(override) - + return overrides - + def _apply_manual_overrides( self, prioritized: List[Case], @@ -338,7 +338,7 @@ class SchedulingAlgorithm: ) -> List[Case]: """Apply manual overrides (ADD_CASE, REMOVE_CASE, PRIORITY, REORDER).""" result = prioritized.copy() - + # Apply ADD_CASE overrides (insert at high priority) add_overrides = [o for o in overrides if o.override_type == OverrideType.ADD_CASE] for override in add_overrides: @@ -349,7 +349,7 @@ class SchedulingAlgorithm: insert_pos = override.new_position if override.new_position is not None else 0 result.insert(min(insert_pos, len(result)), case_to_add) applied_overrides.append(override) - + # Apply REMOVE_CASE overrides remove_overrides = [o for o in overrides if o.override_type == OverrideType.REMOVE_CASE] for override in remove_overrides: @@ -358,23 +358,23 @@ class SchedulingAlgorithm: if removed: applied_overrides.append(override) unscheduled.append((removed[0], f"Judge override: {override.reason}")) - + # Apply PRIORITY overrides (adjust priority scores) priority_overrides = [o for o in overrides if o.override_type == OverrideType.PRIORITY] for override in priority_overrides: case_to_adjust = next((c for c in result if c.case_id == override.case_id), None) if case_to_adjust and override.new_priority is not None: # Store original priority for reference - original_priority = case_to_adjust.get_priority_score() + case_to_adjust.get_priority_score() # Temporarily adjust case to force re-sorting # Note: This is a simplification - in production might need case.set_priority_override() case_to_adjust._priority_override = override.new_priority applied_overrides.append(override) - + # Re-sort if priority overrides were applied if priority_overrides: result.sort(key=lambda c: getattr(c, '_priority_override', c.get_priority_score()), reverse=True) - + # Apply REORDER overrides (explicit positioning) reorder_overrides = [o for o in overrides if o.override_type == OverrideType.REORDER] for override in reorder_overrides: @@ -384,9 +384,9 @@ class SchedulingAlgorithm: result.remove(case_to_move) result.insert(override.new_position, case_to_move) applied_overrides.append(override) - + return result - + def _allocate_cases( self, prioritized: List[Case], @@ -402,11 +402,11 @@ class SchedulingAlgorithm: total_capacity += preferences.capacity_overrides[room.courtroom_id] else: total_capacity += room.get_capacity_for_date(current_date) - + # Limit cases to total capacity cases_to_allocate = prioritized[:total_capacity] capacity_limited = len(prioritized) - len(cases_to_allocate) - + # Use allocator to distribute if self.allocator: case_to_courtroom = self.allocator.allocate(cases_to_allocate, current_date) @@ -416,7 +416,7 @@ class SchedulingAlgorithm: for i, case in enumerate(cases_to_allocate): room_id = courtrooms[i % len(courtrooms)].courtroom_id case_to_courtroom[case.case_id] = room_id - + # Build allocation dict allocation: Dict[int, List[Case]] = {r.courtroom_id: [] for r in courtrooms} for case in cases_to_allocate: @@ -429,7 +429,6 @@ class SchedulingAlgorithm: @staticmethod def _clear_temporary_case_flags(cases: List[Case]) -> None: """Remove temporary scheduling flags to keep case objects clean between runs.""" - for case in cases: if hasattr(case, "_priority_override"): delattr(case, "_priority_override") diff --git a/scheduler/core/case.py b/scheduler/core/case.py index 794eca0b2184daa43f3e903f7b4e8aa3dc649324..f139c61f4e0d687a99cdf4a88623ec9609ceff6e 100644 --- a/scheduler/core/case.py +++ b/scheduler/core/case.py @@ -8,8 +8,8 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import date, datetime -from typing import List, Optional, TYPE_CHECKING from enum import Enum +from typing import TYPE_CHECKING, List, Optional from scheduler.data.config import TERMINAL_STAGES @@ -26,12 +26,12 @@ class CaseStatus(Enum): ACTIVE = "active" # Has had at least one hearing ADJOURNED = "adjourned" # Last hearing was adjourned DISPOSED = "disposed" # Final disposal/settlement reached - + @dataclass class Case: """Represents a single court case. - + Attributes: case_id: Unique identifier (like CNR number) case_type: Type of case (RSA, CRP, RFA, CA, CCC, CP, CMP) @@ -64,20 +64,20 @@ class Case: stage_start_date: Optional[date] = None days_in_stage: int = 0 history: List[dict] = field(default_factory=list) - + # Ripeness tracking (NEW - for bottleneck detection) ripeness_status: str = "UNKNOWN" # RipenessStatus enum value (stored as string to avoid circular import) bottleneck_reason: Optional[str] = None ripeness_updated_at: Optional[datetime] = None last_hearing_purpose: Optional[str] = None # Purpose of last hearing (for classification) - + # No-case-left-behind tracking (NEW) last_scheduled_date: Optional[date] = None days_since_last_scheduled: int = 0 - + def progress_to_stage(self, new_stage: str, current_date: date) -> None: """Progress case to a new stage. - + Args: new_stage: The stage to progress to current_date: Current simulation date @@ -85,22 +85,22 @@ class Case: self.current_stage = new_stage self.stage_start_date = current_date self.days_in_stage = 0 - + # Check if terminal stage (case disposed) if new_stage in TERMINAL_STAGES: self.status = CaseStatus.DISPOSED self.disposal_date = current_date - + # Record in history self.history.append({ "date": current_date, "event": "stage_change", "stage": new_stage, }) - + def record_hearing(self, hearing_date: date, was_heard: bool, outcome: str = "") -> None: """Record a hearing event. - + Args: hearing_date: Date of the hearing was_heard: Whether the hearing actually proceeded (not adjourned) @@ -108,12 +108,12 @@ class Case: """ self.hearing_count += 1 self.last_hearing_date = hearing_date - + if was_heard: self.status = CaseStatus.ACTIVE else: self.status = CaseStatus.ADJOURNED - + # Record in history self.history.append({ "date": hearing_date, @@ -122,114 +122,114 @@ class Case: "outcome": outcome, "stage": self.current_stage, }) - + def update_age(self, current_date: date) -> None: """Update age and days since last hearing. - + Args: current_date: Current simulation date """ self.age_days = (current_date - self.filed_date).days - + if self.last_hearing_date: self.days_since_last_hearing = (current_date - self.last_hearing_date).days else: self.days_since_last_hearing = self.age_days - + if self.stage_start_date: self.days_in_stage = (current_date - self.stage_start_date).days else: self.days_in_stage = self.age_days - + # Update days since last scheduled (for no-case-left-behind tracking) if self.last_scheduled_date: self.days_since_last_scheduled = (current_date - self.last_scheduled_date).days else: self.days_since_last_scheduled = self.age_days - + def compute_readiness_score(self) -> float: """Compute readiness score based on hearings, gaps, and stage. - + Formula (from EDA): READINESS = (hearings_capped/50) * 0.4 + (100/gap_clamped) * 0.3 + (stage_advanced) * 0.3 - + Returns: Readiness score (0-1, higher = more ready) """ # Cap hearings at 50 hearings_capped = min(self.hearing_count, 50) hearings_component = (hearings_capped / 50) * 0.4 - + # Gap component (inverse of days since last hearing) gap_clamped = min(max(self.days_since_last_hearing, 1), 100) gap_component = (100 / gap_clamped) * 0.3 - + # Stage component (advanced stages get higher score) advanced_stages = ["ARGUMENTS", "EVIDENCE", "ORDERS / JUDGMENT"] stage_component = 0.3 if self.current_stage in advanced_stages else 0.1 - + readiness = hearings_component + gap_component + stage_component self.readiness_score = min(1.0, max(0.0, readiness)) - + return self.readiness_score - + def is_ready_for_scheduling(self, min_gap_days: int = 7) -> bool: """Check if case is ready to be scheduled. - + Args: min_gap_days: Minimum days required since last hearing - + Returns: True if case can be scheduled """ if self.status == CaseStatus.DISPOSED: return False - + if self.last_hearing_date is None: return True # First hearing, always ready - + return self.days_since_last_hearing >= min_gap_days - + def needs_alert(self, max_gap_days: int = 90) -> bool: """Check if case needs alert due to long gap. - + Args: max_gap_days: Maximum allowed gap before alert - + Returns: True if alert should be triggered """ if self.status == CaseStatus.DISPOSED: return False - + return self.days_since_last_hearing > max_gap_days - + def get_priority_score(self) -> float: """Get overall priority score for scheduling. - + Combines age, readiness, urgency, and adjournment boost into single score. - + Formula: priority = age*0.35 + readiness*0.25 + urgency*0.25 + adjournment_boost*0.15 - + Adjournment boost: Recently adjourned cases get priority to avoid indefinite postponement. The boost decays exponentially: strongest immediately after adjournment, weaker over time. - + Returns: Priority score (higher = higher priority) """ # Age component (normalize to 0-1, assuming max age ~2000 days) age_component = min(self.age_days / 2000, 1.0) * 0.35 - + # Readiness component readiness_component = self.readiness_score * 0.25 - + # Urgency component urgency_component = 1.0 if self.is_urgent else 0.0 urgency_component *= 0.25 - + # Adjournment boost (NEW - prevents cases from being repeatedly postponed) adjournment_boost = 0.0 if self.status == CaseStatus.ADJOURNED and self.hearing_count > 0: @@ -243,12 +243,12 @@ class Case: decay_factor = 21 # Half-life of boost adjournment_boost = math.exp(-self.days_since_last_hearing / decay_factor) adjournment_boost *= 0.15 - + return age_component + readiness_component + urgency_component + adjournment_boost - + def mark_unripe(self, status, reason: str, current_date: datetime) -> None: """Mark case as unripe with bottleneck reason. - + Args: status: Ripeness status (UNRIPE_SUMMONS, UNRIPE_PARTY, etc.) - RipenessStatus enum reason: Human-readable reason for unripeness @@ -258,7 +258,7 @@ class Case: self.ripeness_status = status.value if hasattr(status, 'value') else str(status) self.bottleneck_reason = reason self.ripeness_updated_at = current_date - + # Record in history self.history.append({ "date": current_date, @@ -266,17 +266,17 @@ class Case: "status": self.ripeness_status, "reason": reason, }) - + def mark_ripe(self, current_date: datetime) -> None: """Mark case as ripe (ready for hearing). - + Args: current_date: Current simulation date """ self.ripeness_status = "RIPE" self.bottleneck_reason = None self.ripeness_updated_at = current_date - + # Record in history self.history.append({ "date": current_date, @@ -284,28 +284,28 @@ class Case: "status": "RIPE", "reason": "Case became ripe", }) - + def mark_scheduled(self, scheduled_date: date) -> None: """Mark case as scheduled for a hearing. - + Used for no-case-left-behind tracking. - + Args: scheduled_date: Date case was scheduled """ self.last_scheduled_date = scheduled_date self.days_since_last_scheduled = 0 - + @property def is_disposed(self) -> bool: """Check if case is disposed.""" return self.status == CaseStatus.DISPOSED - + def __repr__(self) -> str: return (f"Case(id={self.case_id}, type={self.case_type}, " f"stage={self.current_stage}, status={self.status.value}, " f"hearings={self.hearing_count})") - + def to_dict(self) -> dict: """Convert case to dictionary for serialization.""" return { diff --git a/scheduler/core/courtroom.py b/scheduler/core/courtroom.py index f9ef28c3a870703471d2099b2a90ca1f10c5da0e..82ea00855fad6a37ccaf1213f361b47e687845c5 100644 --- a/scheduler/core/courtroom.py +++ b/scheduler/core/courtroom.py @@ -14,7 +14,7 @@ from scheduler.data.config import DEFAULT_DAILY_CAPACITY @dataclass class Courtroom: """Represents a courtroom resource. - + Attributes: courtroom_id: Unique identifier (0-4 for 5 courtrooms) judge_id: Currently assigned judge (optional) @@ -31,134 +31,134 @@ class Courtroom: schedule: Dict[date, List[str]] = field(default_factory=dict) hearings_held: int = 0 utilization_history: List[Dict] = field(default_factory=list) - + def assign_judge(self, judge_id: str) -> None: """Assign a judge to this courtroom. - + Args: judge_id: Judge identifier """ self.judge_id = judge_id - + def add_case_types(self, *case_types: str) -> None: """Add case types that this courtroom handles. - + Args: *case_types: One or more case type strings (e.g., 'RSA', 'CRP') """ self.case_types.update(case_types) - + def can_schedule(self, hearing_date: date, case_id: str) -> bool: """Check if a case can be scheduled on a given date. - + Args: hearing_date: Date to check case_id: Case identifier - + Returns: True if slot available, False if at capacity """ if hearing_date not in self.schedule: return True # No hearings scheduled yet - + # Check if already scheduled if case_id in self.schedule[hearing_date]: return False # Already scheduled - + # Check capacity return len(self.schedule[hearing_date]) < self.daily_capacity - + def schedule_case(self, hearing_date: date, case_id: str) -> bool: """Schedule a case for a hearing. - + Args: hearing_date: Date of hearing case_id: Case identifier - + Returns: True if successfully scheduled, False if at capacity """ if not self.can_schedule(hearing_date, case_id): return False - + if hearing_date not in self.schedule: self.schedule[hearing_date] = [] - + self.schedule[hearing_date].append(case_id) return True - + def unschedule_case(self, hearing_date: date, case_id: str) -> bool: """Remove a case from schedule (e.g., if adjourned). - + Args: hearing_date: Date of hearing case_id: Case identifier - + Returns: True if successfully removed, False if not found """ if hearing_date not in self.schedule: return False - + if case_id in self.schedule[hearing_date]: self.schedule[hearing_date].remove(case_id) return True - + return False - + def get_daily_schedule(self, hearing_date: date) -> List[str]: """Get list of cases scheduled for a specific date. - + Args: hearing_date: Date to query - + Returns: List of case_ids scheduled (empty if none) """ return self.schedule.get(hearing_date, []) - + def get_capacity_for_date(self, hearing_date: date) -> int: """Get remaining capacity for a specific date. - + Args: hearing_date: Date to query - + Returns: Number of available slots """ scheduled_count = len(self.get_daily_schedule(hearing_date)) return self.daily_capacity - scheduled_count - + def record_hearing_completed(self, hearing_date: date) -> None: """Record that a hearing was held. - + Args: hearing_date: Date of hearing """ self.hearings_held += 1 - + def compute_utilization(self, hearing_date: date) -> float: """Compute utilization rate for a specific date. - + Args: hearing_date: Date to compute for - + Returns: Utilization rate (0.0 to 1.0) """ scheduled_count = len(self.get_daily_schedule(hearing_date)) return scheduled_count / self.daily_capacity if self.daily_capacity > 0 else 0.0 - + def record_daily_utilization(self, hearing_date: date, actual_hearings: int) -> None: """Record actual utilization for a day. - + Args: hearing_date: Date of hearings actual_hearings: Number of hearings actually held (not adjourned) """ scheduled = len(self.get_daily_schedule(hearing_date)) utilization = actual_hearings / self.daily_capacity if self.daily_capacity > 0 else 0.0 - + self.utilization_history.append({ "date": hearing_date, "scheduled": scheduled, @@ -166,55 +166,55 @@ class Courtroom: "capacity": self.daily_capacity, "utilization": utilization, }) - + def get_average_utilization(self) -> float: """Calculate average utilization rate across all recorded days. - + Returns: Average utilization (0.0 to 1.0) """ if not self.utilization_history: return 0.0 - + total = sum(day["utilization"] for day in self.utilization_history) return total / len(self.utilization_history) - + def get_schedule_summary(self, start_date: date, end_date: date) -> Dict: """Get summary statistics for a date range. - + Args: start_date: Start of range end_date: End of range - + Returns: Dict with counts and utilization stats """ - days_in_range = [d for d in self.schedule.keys() + days_in_range = [d for d in self.schedule.keys() if start_date <= d <= end_date] - + total_scheduled = sum(len(self.schedule[d]) for d in days_in_range) days_with_hearings = len(days_in_range) - + return { "courtroom_id": self.courtroom_id, "days_with_hearings": days_with_hearings, "total_cases_scheduled": total_scheduled, "avg_cases_per_day": total_scheduled / days_with_hearings if days_with_hearings > 0 else 0, "total_capacity": days_with_hearings * self.daily_capacity, - "utilization_rate": total_scheduled / (days_with_hearings * self.daily_capacity) + "utilization_rate": total_scheduled / (days_with_hearings * self.daily_capacity) if days_with_hearings > 0 else 0, } - + def clear_schedule(self) -> None: """Clear all scheduled hearings (for testing/reset).""" self.schedule.clear() self.utilization_history.clear() self.hearings_held = 0 - + def __repr__(self) -> str: return (f"Courtroom(id={self.courtroom_id}, judge={self.judge_id}, " f"capacity={self.daily_capacity}, types={self.case_types})") - + def to_dict(self) -> dict: """Convert courtroom to dictionary for serialization.""" return { diff --git a/scheduler/core/hearing.py b/scheduler/core/hearing.py index ec574118b3a7a5496ae210577783854acc7f01ec..a99f278efd92681c7317c3e4bf8d5545219b3267 100644 --- a/scheduler/core/hearing.py +++ b/scheduler/core/hearing.py @@ -4,7 +4,7 @@ This module defines the Hearing class which represents a scheduled court hearing with its outcome and associated metadata. """ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import date from enum import Enum from typing import Optional @@ -23,7 +23,7 @@ class HearingOutcome(Enum): @dataclass class Hearing: """Represents a scheduled court hearing event. - + Attributes: hearing_id: Unique identifier case_id: Associated case @@ -46,78 +46,78 @@ class Hearing: actual_date: Optional[date] = None duration_minutes: int = 30 notes: Optional[str] = None - + def mark_as_heard(self, actual_date: Optional[date] = None) -> None: """Mark hearing as successfully completed. - + Args: actual_date: Actual date if different from scheduled """ self.outcome = HearingOutcome.HEARD self.actual_date = actual_date or self.scheduled_date - + def mark_as_adjourned(self, reason: str = "") -> None: """Mark hearing as adjourned. - + Args: reason: Reason for adjournment """ self.outcome = HearingOutcome.ADJOURNED if reason: self.notes = reason - + def mark_as_disposed(self) -> None: """Mark hearing as final disposition.""" self.outcome = HearingOutcome.DISPOSED self.actual_date = self.scheduled_date - + def mark_as_no_show(self, party: str = "") -> None: """Mark hearing as no-show. - + Args: party: Which party was absent """ self.outcome = HearingOutcome.NO_SHOW if party: self.notes = f"No show: {party}" - + def reschedule(self, new_date: date) -> None: """Reschedule hearing to a new date. - + Args: new_date: New scheduled date """ self.scheduled_date = new_date self.outcome = HearingOutcome.SCHEDULED - + def is_complete(self) -> bool: """Check if hearing has concluded. - + Returns: True if outcome is not SCHEDULED """ return self.outcome != HearingOutcome.SCHEDULED - + def is_successful(self) -> bool: """Check if hearing was successfully held. - + Returns: True if outcome is HEARD or DISPOSED """ return self.outcome in (HearingOutcome.HEARD, HearingOutcome.DISPOSED) - + def get_effective_date(self) -> date: """Get actual or scheduled date. - + Returns: actual_date if set, else scheduled_date """ return self.actual_date or self.scheduled_date - + def __repr__(self) -> str: return (f"Hearing(id={self.hearing_id}, case={self.case_id}, " f"date={self.scheduled_date}, outcome={self.outcome.value})") - + def to_dict(self) -> dict: """Convert hearing to dictionary for serialization.""" return { diff --git a/scheduler/core/judge.py b/scheduler/core/judge.py index 6ac16e9461352c665ce952d42c637d381366a0e9..4cafd7a131e1805da6c0fc1932eeb8a102b96d32 100644 --- a/scheduler/core/judge.py +++ b/scheduler/core/judge.py @@ -12,7 +12,7 @@ from typing import Dict, List, Optional, Set @dataclass class Judge: """Represents a judge with workload tracking. - + Attributes: judge_id: Unique identifier name: Judge's name @@ -29,37 +29,37 @@ class Judge: cases_heard: int = 0 hearings_presided: int = 0 workload_history: List[Dict] = field(default_factory=list) - + def assign_courtroom(self, courtroom_id: int) -> None: """Assign judge to a courtroom. - + Args: courtroom_id: Courtroom identifier """ self.courtroom_id = courtroom_id - + def add_preferred_types(self, *case_types: str) -> None: """Add case types to judge's preferences. - + Args: *case_types: One or more case type strings """ self.preferred_case_types.update(case_types) - + def record_hearing(self, hearing_date: date, case_id: str, case_type: str) -> None: """Record a hearing presided over. - + Args: hearing_date: Date of hearing case_id: Case identifier case_type: Type of case """ self.hearings_presided += 1 - - def record_daily_workload(self, hearing_date: date, cases_heard: int, + + def record_daily_workload(self, hearing_date: date, cases_heard: int, cases_adjourned: int) -> None: """Record workload for a specific day. - + Args: hearing_date: Date of hearings cases_heard: Number of cases actually heard @@ -71,48 +71,48 @@ class Judge: "cases_adjourned": cases_adjourned, "total_scheduled": cases_heard + cases_adjourned, }) - + self.cases_heard += cases_heard - + def get_average_daily_workload(self) -> float: """Calculate average cases heard per day. - + Returns: Average number of cases per day """ if not self.workload_history: return 0.0 - + total = sum(day["cases_heard"] for day in self.workload_history) return total / len(self.workload_history) - + def get_adjournment_rate(self) -> float: """Calculate judge's adjournment rate. - + Returns: Proportion of cases adjourned (0.0 to 1.0) """ if not self.workload_history: return 0.0 - + total_adjourned = sum(day["cases_adjourned"] for day in self.workload_history) total_scheduled = sum(day["total_scheduled"] for day in self.workload_history) - + return total_adjourned / total_scheduled if total_scheduled > 0 else 0.0 - + def get_workload_summary(self, start_date: date, end_date: date) -> Dict: """Get workload summary for a date range. - + Args: start_date: Start of range end_date: End of range - + Returns: Dict with workload statistics """ - days_in_range = [day for day in self.workload_history + days_in_range = [day for day in self.workload_history if start_date <= day["date"] <= end_date] - + if not days_in_range: return { "judge_id": self.judge_id, @@ -121,11 +121,11 @@ class Judge: "avg_cases_per_day": 0.0, "adjournment_rate": 0.0, } - + total_heard = sum(day["cases_heard"] for day in days_in_range) total_adjourned = sum(day["cases_adjourned"] for day in days_in_range) total_scheduled = total_heard + total_adjourned - + return { "judge_id": self.judge_id, "days_worked": len(days_in_range), @@ -134,25 +134,25 @@ class Judge: "avg_cases_per_day": total_heard / len(days_in_range), "adjournment_rate": total_adjourned / total_scheduled if total_scheduled > 0 else 0.0, } - + def is_specialized_in(self, case_type: str) -> bool: """Check if judge specializes in a case type. - + Args: case_type: Case type to check - + Returns: True if in preferred types or no preferences set """ if not self.preferred_case_types: return True # No preferences means handles all types - + return case_type in self.preferred_case_types - + def __repr__(self) -> str: return (f"Judge(id={self.judge_id}, courtroom={self.courtroom_id}, " f"hearings={self.hearings_presided})") - + def to_dict(self) -> dict: """Convert judge to dictionary for serialization.""" return { diff --git a/scheduler/core/policy.py b/scheduler/core/policy.py index 4d695afd7566c7ccb40709de610943c1a6e3733c..5ffdc8255fd9d35c43dd91d64645ebb437ffef6f 100644 --- a/scheduler/core/policy.py +++ b/scheduler/core/policy.py @@ -14,30 +14,30 @@ from scheduler.core.case import Case class SchedulerPolicy(ABC): """Abstract base class for scheduling policies. - + All scheduling policies must implement the `prioritize` method which ranks cases for scheduling on a given day. """ - + @abstractmethod def prioritize(self, cases: List[Case], current_date: date) -> List[Case]: """Prioritize cases for scheduling on the given date. - + Args: cases: List of eligible cases (already filtered for readiness, not disposed) current_date: Current simulation date - + Returns: Sorted list of cases in priority order (highest priority first) """ pass - + @abstractmethod def get_name(self) -> str: """Get the policy name for logging/reporting.""" pass - + @abstractmethod def requires_readiness_score(self) -> bool: """Return True if this policy requires readiness score computation.""" - pass \ No newline at end of file + pass diff --git a/scheduler/core/ripeness.py b/scheduler/core/ripeness.py index 8ceff25b8bd8f6d5ce36754b4ba2300572ab7f31..b709997b5e53eacff43931486b267e7cd0b9a98d 100644 --- a/scheduler/core/ripeness.py +++ b/scheduler/core/ripeness.py @@ -7,9 +7,9 @@ Based on analysis of historical PurposeOfHearing patterns (see scripts/analyze_r """ from __future__ import annotations +from datetime import datetime, timedelta from enum import Enum from typing import TYPE_CHECKING -from datetime import datetime, timedelta if TYPE_CHECKING: from scheduler.core.case import Case @@ -17,7 +17,7 @@ if TYPE_CHECKING: class RipenessStatus(Enum): """Status indicating whether a case is ready for hearing.""" - + RIPE = "RIPE" # Ready for hearing UNRIPE_SUMMONS = "UNRIPE_SUMMONS" # Waiting for summons service UNRIPE_DEPENDENT = "UNRIPE_DEPENDENT" # Waiting for dependent case/order @@ -54,7 +54,7 @@ RIPE_KEYWORDS = ["ARGUMENTS", "HEARING", "FINAL", "JUDGMENT", "ORDERS", "DISPOSA class RipenessClassifier: """Classify cases as RIPE or UNRIPE for scheduling optimization. - + Thresholds can be adjusted dynamically based on accuracy feedback. """ @@ -65,7 +65,7 @@ class RipenessClassifier: "ORDERS / JUDGMENT", "FINAL DISPOSAL" ] - + # Stages that indicate administrative/preliminary work UNRIPE_STAGES = [ "PRE-ADMISSION", @@ -83,7 +83,6 @@ class RipenessClassifier: @classmethod def _has_required_evidence(cls, case: Case) -> tuple[bool, dict[str, bool]]: """Check that minimum readiness evidence exists before declaring RIPE.""" - # Evidence of service/compliance: at least one hearing or explicit purpose text service_confirmed = case.hearing_count >= cls.MIN_SERVICE_HEARINGS or bool( getattr(case, "last_hearing_purpose", None) @@ -109,7 +108,6 @@ class RipenessClassifier: @classmethod def _has_ripe_signal(cls, case: Case) -> bool: """Check if stage or hearing purpose indicates readiness.""" - if case.current_stage in cls.RIPE_STAGES: return True @@ -118,15 +116,15 @@ class RipenessClassifier: return any(keyword in purpose_upper for keyword in RIPE_KEYWORDS) return False - + @classmethod def classify(cls, case: Case, current_date: datetime | None = None) -> RipenessStatus: """Classify case ripeness status with bottleneck type. - + Args: case: Case to classify current_date: Current simulation date (defaults to now) - + Returns: RipenessStatus enum indicating ripeness and bottleneck type @@ -141,7 +139,7 @@ class RipenessClassifier: """ if current_date is None: current_date = datetime.now() - + # 1. Check last hearing purpose for explicit bottleneck keywords if hasattr(case, "last_hearing_purpose") and case.last_hearing_purpose: purpose_upper = case.last_hearing_purpose.upper() @@ -149,7 +147,7 @@ class RipenessClassifier: for keyword, bottleneck_type in UNRIPE_KEYWORDS.items(): if keyword in purpose_upper: return bottleneck_type - + # 2. Check stage - ADMISSION stage with few hearings is likely unripe if case.current_stage == "ADMISSION": # New cases in ADMISSION (< 3 hearings) are often unripe @@ -177,55 +175,55 @@ class RipenessClassifier: # 6. Default to UNKNOWN if no bottlenecks but also no clear ripe signal return RipenessStatus.UNKNOWN - + @classmethod def get_ripeness_priority(cls, case: Case, current_date: datetime | None = None) -> float: """Get priority adjustment based on ripeness. - + Ripe cases should get judicial time priority over unripe cases when scheduling is tight. - + Returns: Priority multiplier (1.5 for RIPE, 0.7 for UNRIPE) """ ripeness = cls.classify(case, current_date) return 1.5 if ripeness.is_ripe() else 0.7 - + @classmethod def is_schedulable(cls, case: Case, current_date: datetime | None = None) -> bool: """Determine if a case can be scheduled for a hearing. - + A case is schedulable if: - It is RIPE (no bottlenecks) - It has been sufficient time since last hearing - It is not disposed - + Args: case: The case to check current_date: Current simulation date - + Returns: True if case can be scheduled, False otherwise """ # Check disposal status if case.is_disposed: return False - + # Calculate current ripeness ripeness = cls.classify(case, current_date) - + # Only RIPE cases can be scheduled return ripeness.is_ripe() - + @classmethod def get_ripeness_reason(cls, ripeness_status: RipenessStatus) -> str: """Get human-readable explanation for ripeness status. - + Used in dashboard tooltips and reports. - + Args: ripeness_status: The status to explain - + Returns: Human-readable explanation string """ @@ -238,25 +236,25 @@ class RipenessClassifier: RipenessStatus.UNKNOWN: "Insufficient readiness evidence; route to manual triage", } return reasons.get(ripeness_status, "Unknown status") - + @classmethod def estimate_ripening_time(cls, case: Case, current_date: datetime) -> timedelta | None: """Estimate time until case becomes ripe. - + This is a heuristic based on bottleneck type and historical data. - + Args: case: The case to evaluate current_date: Current simulation date - + Returns: Estimated timedelta until ripe, or None if already ripe or unknown """ ripeness = cls.classify(case, current_date) - + if ripeness.is_ripe(): return timedelta(0) - + # Heuristic estimates based on bottleneck type estimates = { RipenessStatus.UNRIPE_SUMMONS: timedelta(days=30), @@ -264,13 +262,13 @@ class RipenessClassifier: RipenessStatus.UNRIPE_PARTY: timedelta(days=14), RipenessStatus.UNRIPE_DOCUMENT: timedelta(days=21), } - + return estimates.get(ripeness, None) - + @classmethod def set_thresholds(cls, new_thresholds: dict[str, int | float]) -> None: """Update classification thresholds for calibration. - + Args: new_thresholds: Dictionary with threshold names and values e.g., {"MIN_SERVICE_HEARINGS": 2, "MIN_STAGE_DAYS": 5} @@ -280,11 +278,11 @@ class RipenessClassifier: setattr(cls, threshold_name, int(value)) else: raise ValueError(f"Unknown threshold: {threshold_name}") - + @classmethod def get_current_thresholds(cls) -> dict[str, int]: """Get current threshold values. - + Returns: Dictionary of threshold names and values """ diff --git a/scheduler/dashboard/app.py b/scheduler/dashboard/app.py index ada2f4d0921d3dd9819d97445136964b4d215ec0..df34a9261a6cb180d05376774142b45f3a32ac0c 100644 --- a/scheduler/dashboard/app.py +++ b/scheduler/dashboard/app.py @@ -16,28 +16,32 @@ from scheduler.dashboard.utils import get_data_status # Page configuration st.set_page_config( page_title="Court Scheduling System Dashboard", - page_icon="⚖️", + page_icon="scales", layout="wide", initial_sidebar_state="expanded", ) # Main page content -st.title("⚖️ Court Scheduling System Dashboard") -st.markdown("**Karnataka High Court - Fair & Transparent Scheduling**") +st.title("Court Scheduling System Dashboard") +st.markdown("**Karnataka High Court - Algorithmic Decision Support for Fair Scheduling**") st.markdown("---") # Introduction st.markdown(""" -### Welcome to the Interactive Dashboard +### Overview -This dashboard provides comprehensive insights and controls for the Court Scheduling System: +This system provides data-driven scheduling recommendations while maintaining judicial control and autonomy. -- **EDA Analysis**: Explore case data, stage transitions, and adjournment patterns -- **Ripeness Classifier**: Understand and tune the case readiness algorithm with full explainability -- **RL Training**: Train and visualize reinforcement learning agents for optimal scheduling +**Key Capabilities:** +- Historical data analysis and pattern identification +- Case ripeness classification (identifying bottlenecks) +- Multi-courtroom scheduling simulation +- Algorithmic suggestions with full explainability +- Judge override and approval system +- Reinforcement learning optimization -Navigate using the sidebar to access different sections. +Use the sidebar to navigate between sections. """) # System status @@ -45,158 +49,146 @@ status_header_col1, status_header_col2 = st.columns([3, 1]) with status_header_col1: st.markdown("### System Status") with status_header_col2: - if st.button("🔄 Refresh Status", use_container_width=True): + if st.button("Refresh Status", use_container_width=True): st.rerun() data_status = get_data_status() -col1, col2, col3, col4 = st.columns(4) +col1, col2, col3 = st.columns(3) with col1: - status = "✓" if data_status["cleaned_data"] else "✗" + status = "Ready" if data_status["cleaned_data"] else "Missing" color = "green" if data_status["cleaned_data"] else "red" st.markdown(f":{color}[{status}] **Cleaned Data**") - + if not data_status["cleaned_data"]: + st.caption("Run EDA pipeline to process raw data") + with col2: - status = "✓" if data_status["parameters"] else "✗" + status = "Ready" if data_status["parameters"] else "Missing" color = "green" if data_status["parameters"] else "red" st.markdown(f":{color}[{status}] **Parameters**") - + if not data_status["parameters"]: + st.caption("Run EDA pipeline to extract parameters") + with col3: - status = "✓" if data_status["generated_cases"] else "✗" - color = "green" if data_status["generated_cases"] else "red" - st.markdown(f":{color}[{status}] **Test Cases**") - -with col4: - status = "✓" if data_status["eda_figures"] else "✗" + status = "Ready" if data_status["eda_figures"] else "Missing" color = "green" if data_status["eda_figures"] else "red" - st.markdown(f":{color}[{status}] **EDA Figures**") + st.markdown(f":{color}[{status}] **Analysis Figures**") + if not data_status["eda_figures"]: + st.caption("Run EDA pipeline to generate visualizations") # Setup Controls -if not all(data_status.values()): +eda_ready = data_status["cleaned_data"] and data_status["parameters"] and data_status["eda_figures"] + +if not eda_ready: st.markdown("---") - st.markdown("### Setup Required") - st.info("Some prerequisites are missing. Use the controls below to set up the system.") - - setup_col1, setup_col2 = st.columns(2) - - with setup_col1: - st.markdown("#### EDA Pipeline") - if not data_status["cleaned_data"] or not data_status["parameters"]: - st.warning("EDA pipeline needs to be run to generate cleaned data and parameters") - - if st.button("Run EDA Pipeline", type="primary", use_container_width=True): - import subprocess - - with st.spinner("Running EDA pipeline... This may take a few minutes."): - try: - result = subprocess.run( - ["uv", "run", "court-scheduler", "eda"], - capture_output=True, - text=True, - cwd=str(Path.cwd()), - ) - - if result.returncode == 0: - st.success("EDA pipeline completed successfully!") - st.rerun() - else: - st.error(f"EDA pipeline failed with error code {result.returncode}") - with st.expander("Show error details"): - st.code(result.stderr, language="text") - except Exception as e: - st.error(f"Error running EDA pipeline: {e}") - else: - st.success("EDA pipeline already complete") - - with setup_col2: - st.markdown("#### Test Case Generation") - if not data_status["generated_cases"]: - st.info("Optional: Generate synthetic test cases for classifier testing") - - n_cases = st.number_input("Number of cases to generate", min_value=100, max_value=50000, value=1000, step=100) - - if st.button("Generate Test Cases", use_container_width=True): - import subprocess - - with st.spinner(f"Generating {n_cases} test cases..."): - try: - result = subprocess.run( - ["uv", "run", "court-scheduler", "generate", "--cases", str(n_cases)], - capture_output=True, - text=True, - cwd=str(Path.cwd()), - ) - - if result.returncode == 0: - st.success(f"Generated {n_cases} test cases successfully!") - st.rerun() - else: - st.error(f"Generation failed with error code {result.returncode}") - with st.expander("Show error details"): - st.code(result.stderr, language="text") - except Exception as e: - st.error(f"Error generating test cases: {e}") - else: - st.success("Test cases already generated") - - st.markdown("#### Manual Setup") - with st.expander("Run commands manually (if buttons don't work)"): - st.code(""" -# Run EDA pipeline -uv run court-scheduler eda - -# Generate test cases (optional) -uv run court-scheduler generate --cases 1000 - """, language="bash") + st.markdown("### Initial Setup") + st.warning("Run the EDA pipeline to process historical data and extract parameters.") + + col1, col2 = st.columns([2, 1]) + + with col1: + st.markdown(""" + The EDA pipeline: + - Loads and cleans historical court case data + - Extracts statistical parameters (distributions, transition probabilities) + - Generates analysis visualizations + + This is required before using other dashboard features. + """) + + with col2: + if st.button("Run EDA Pipeline", type="primary", use_container_width=True): + import subprocess + + with st.spinner("Running EDA pipeline... This may take a few minutes."): + try: + result = subprocess.run( + ["uv", "run", "court-scheduler", "eda"], + capture_output=True, + text=True, + cwd=str(Path.cwd()), + ) + + if result.returncode == 0: + st.success("EDA pipeline completed") + st.rerun() + else: + st.error(f"Pipeline failed with error code {result.returncode}") + with st.expander("Show error details"): + st.code(result.stderr, language="text") + except Exception as e: + st.error(f"Error running pipeline: {e}") + + with st.expander("Run manually via CLI"): + st.code("uv run court-scheduler eda", language="bash") else: - st.success("All prerequisites are ready! You can use all dashboard features.") + st.success("System ready - all data processed") st.markdown("---") -# Quick start guide -st.markdown("### Quick Start") +# Navigation Guide +st.markdown("### Dashboard Sections") + +col1, col2 = st.columns(2) + +with col1: + st.markdown(""" + #### 1. Data & Insights + Explore historical case data, view analysis visualizations, and review extracted parameters. + + #### 2. Ripeness Classifier + Test case ripeness classification with interactive threshold tuning and explainability. -with st.expander("How to use this dashboard"): + #### 3. Simulation Workflow + Generate cases, configure simulation parameters, run scheduling simulations, and view results. + """) + +with col2: st.markdown(""" - **1. EDA Analysis** - - View statistical insights from court case data - - Explore case distributions, stage transitions, and patterns - - Filter by case type, stage, and date range - - **2. Ripeness Classifier** - - Understand how cases are classified as RIPE/UNRIPE/UNKNOWN - - Adjust thresholds interactively and see real-time impact - - View case-level explainability with detailed reasoning - - Run calibration analysis to optimize thresholds - - **3. RL Training** - - Configure and train reinforcement learning agents - - Monitor training progress in real-time - - Compare different models and hyperparameters - - Visualize Q-table and action distributions + #### 4. Cause Lists & Overrides + View generated cause lists, make judge overrides, and track modification history. + + #### 5. RL Training + Train reinforcement learning models for optimized scheduling policies. + + #### 6. Analytics & Reports + Compare simulation runs, analyze performance metrics, and export comprehensive reports. """) -with st.expander("Prerequisites & Setup"): +st.markdown("---") + +# Typical Workflow +with st.expander("Typical Usage Workflow"): st.markdown(""" - The dashboard requires some initial setup: - - 1. **EDA Pipeline**: Processes raw data and extracts parameters - 2. **Test Cases** (optional): Generates synthetic cases for testing - - **How to set up**: - - Use the interactive buttons in the "Setup Required" section above (if shown) - - Or run commands manually: - - `uv run court-scheduler eda` - - `uv run court-scheduler generate` (optional) - - The system status indicators at the top show what's ready. + **Step 1: Initial Setup** + - Run EDA pipeline to process historical data (one-time setup) + + **Step 2: Understand the Data** + - Explore Data & Insights to understand case patterns + - Review extracted parameters and distributions + + **Step 3: Test Ripeness Classifier** + - Adjust thresholds for your court's specific needs + - Test classification on sample cases + + **Step 4: Run Simulation** + - Go to Simulation Workflow + - Generate or upload case dataset + - Configure simulation parameters + - Run simulation and review results + + **Step 5: Review & Override** + - View generated cause lists in Cause Lists & Overrides + - Make judicial overrides as needed + - Approve final cause lists + + **Step 6: Analyze Performance** + - Use Analytics & Reports to evaluate fairness and efficiency + - Compare different scheduling policies + - Identify bottlenecks and improvement opportunities """) # Footer st.markdown("---") -st.markdown(""" -
- Court Scheduling System | Code4Change Hackathon | Karnataka High Court -
-""", unsafe_allow_html=True) +st.caption("Court Scheduling System - Code4Change Hackathon - Karnataka High Court") diff --git a/scheduler/dashboard/pages/1_EDA_Analysis.py b/scheduler/dashboard/pages/1_EDA_Analysis.py deleted file mode 100644 index eafaae0e8c00f94f80bd1d402614dc916428fc27..0000000000000000000000000000000000000000 --- a/scheduler/dashboard/pages/1_EDA_Analysis.py +++ /dev/null @@ -1,273 +0,0 @@ -"""EDA Analysis page - Explore court case data insights. - -This page displays exploratory data analysis visualizations and statistics -from the court case dataset. -""" - -from __future__ import annotations - -from pathlib import Path - -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go -import streamlit as st - -from scheduler.dashboard.utils import ( - get_case_statistics, - load_cleaned_data, - load_param_loader, -) - -# Page configuration -st.set_page_config( - page_title="EDA Analysis", - page_icon="📊", - layout="wide", -) - -st.title("📊 Exploratory Data Analysis") -st.markdown("Statistical insights from court case data") - -# Load data -with st.spinner("Loading data..."): - try: - df = load_cleaned_data() - params = load_param_loader() - stats = get_case_statistics(df) - except Exception as e: - st.error(f"Error loading data: {e}") - st.info("Please run the EDA pipeline first: `uv run court-scheduler eda`") - st.stop() - -if df.empty: - st.warning("No data available. Please run the EDA pipeline first.") - st.code("uv run court-scheduler eda") - st.stop() - -# Sidebar filters -st.sidebar.header("Filters") - -# Case type filter -available_case_types = df["CaseType"].unique().tolist() if "CaseType" in df else [] -selected_case_types = st.sidebar.multiselect( - "Case Types", - options=available_case_types, - default=available_case_types, -) - -# Stage filter -available_stages = df["Remappedstages"].unique().tolist() if "Remappedstages" in df else [] -selected_stages = st.sidebar.multiselect( - "Stages", - options=available_stages, - default=available_stages, -) - -# Apply filters -filtered_df = df.copy() -if selected_case_types: - filtered_df = filtered_df[filtered_df["CaseType"].isin(selected_case_types)] -if selected_stages: - filtered_df = filtered_df[filtered_df["Remappedstages"].isin(selected_stages)] - -# Key metrics -st.markdown("### Key Metrics") - -col1, col2, col3, col4 = st.columns(4) - -with col1: - total_cases = len(filtered_df) - st.metric("Total Cases", f"{total_cases:,}") - -with col2: - n_case_types = len(filtered_df["CaseType"].unique()) if "CaseType" in filtered_df else 0 - st.metric("Case Types", n_case_types) - -with col3: - n_stages = len(filtered_df["Remappedstages"].unique()) if "Remappedstages" in filtered_df else 0 - st.metric("Unique Stages", n_stages) - -with col4: - if "Outcome" in filtered_df.columns: - adj_rate = (filtered_df["Outcome"] == "ADJOURNED").sum() / len(filtered_df) - st.metric("Adjournment Rate", f"{adj_rate:.1%}") - else: - st.metric("Adjournment Rate", "N/A") - -st.markdown("---") - -# Visualizations -tab1, tab2, tab3, tab4 = st.tabs(["Case Distribution", "Stage Analysis", "Adjournment Patterns", "Raw Data"]) - -with tab1: - st.markdown("### Case Distribution by Type") - - if "CaseType" in filtered_df: - case_type_counts = filtered_df["CaseType"].value_counts().reset_index() - case_type_counts.columns = ["CaseType", "Count"] - - fig = px.bar( - case_type_counts, - x="CaseType", - y="Count", - title="Number of Cases by Type", - labels={"CaseType": "Case Type", "Count": "Number of Cases"}, - color="Count", - color_continuous_scale="Blues", - ) - fig.update_layout(xaxis_tickangle=-45, height=500) - st.plotly_chart(fig, use_container_width=True) - - # Pie chart - fig_pie = px.pie( - case_type_counts, - values="Count", - names="CaseType", - title="Case Type Distribution", - ) - st.plotly_chart(fig_pie, use_container_width=True) - else: - st.info("CaseType column not found in data") - -with tab2: - st.markdown("### Stage Analysis") - - if "Remappedstages" in filtered_df: - col1, col2 = st.columns(2) - - with col1: - stage_counts = filtered_df["Remappedstages"].value_counts().reset_index() - stage_counts.columns = ["Stage", "Count"] - - fig = px.bar( - stage_counts.head(10), - x="Count", - y="Stage", - orientation="h", - title="Top 10 Stages by Case Count", - labels={"Stage": "Stage", "Count": "Number of Cases"}, - color="Count", - color_continuous_scale="Greens", - ) - fig.update_layout(height=500) - st.plotly_chart(fig, use_container_width=True) - - with col2: - # Stage distribution pie chart - fig_pie = px.pie( - stage_counts.head(10), - values="Count", - names="Stage", - title="Stage Distribution (Top 10)", - ) - fig_pie.update_layout(height=500) - st.plotly_chart(fig_pie, use_container_width=True) - else: - st.info("Remappedstages column not found in data") - -with tab3: - st.markdown("### Adjournment Patterns") - - # Adjournment rate by case type - if "CaseType" in filtered_df and "Outcome" in filtered_df: - adj_by_type = ( - filtered_df.groupby("CaseType")["Outcome"] - .apply(lambda x: (x == "ADJOURNED").sum() / len(x) if len(x) > 0 else 0) - .reset_index() - ) - adj_by_type.columns = ["CaseType", "Adjournment_Rate"] - adj_by_type["Adjournment_Rate"] = adj_by_type["Adjournment_Rate"] * 100 - - fig = px.bar( - adj_by_type.sort_values("Adjournment_Rate", ascending=False), - x="CaseType", - y="Adjournment_Rate", - title="Adjournment Rate by Case Type (%)", - labels={"CaseType": "Case Type", "Adjournment_Rate": "Adjournment Rate (%)"}, - color="Adjournment_Rate", - color_continuous_scale="Reds", - ) - fig.update_layout(xaxis_tickangle=-45, height=500) - st.plotly_chart(fig, use_container_width=True) - - # Adjournment rate by stage - if "Remappedstages" in filtered_df and "Outcome" in filtered_df: - adj_by_stage = ( - filtered_df.groupby("Remappedstages")["Outcome"] - .apply(lambda x: (x == "ADJOURNED").sum() / len(x) if len(x) > 0 else 0) - .reset_index() - ) - adj_by_stage.columns = ["Stage", "Adjournment_Rate"] - adj_by_stage["Adjournment_Rate"] = adj_by_stage["Adjournment_Rate"] * 100 - - fig = px.bar( - adj_by_stage.sort_values("Adjournment_Rate", ascending=False).head(15), - x="Adjournment_Rate", - y="Stage", - orientation="h", - title="Adjournment Rate by Stage (Top 15, %)", - labels={"Stage": "Stage", "Adjournment_Rate": "Adjournment Rate (%)"}, - color="Adjournment_Rate", - color_continuous_scale="Oranges", - ) - fig.update_layout(height=600) - st.plotly_chart(fig, use_container_width=True) - - # Heatmap: Adjournment probability by stage and case type - if params and "adjournment_stats" in params: - st.markdown("#### Adjournment Probability Heatmap (Stage × Case Type)") - - adj_stats = params["adjournment_stats"] - stages = list(adj_stats.keys()) - case_types = params["case_types"] - - heatmap_data = [] - for stage in stages: - row = [] - for ct in case_types: - prob = adj_stats.get(stage, {}).get(ct, 0) - row.append(prob * 100) # Convert to percentage - heatmap_data.append(row) - - fig = go.Figure(data=go.Heatmap( - z=heatmap_data, - x=case_types, - y=stages, - colorscale="RdYlGn_r", - text=[[f"{val:.1f}%" for val in row] for row in heatmap_data], - texttemplate="%{text}", - textfont={"size": 8}, - colorbar=dict(title="Adj. Rate (%)"), - )) - fig.update_layout( - title="Adjournment Probability Heatmap", - xaxis_title="Case Type", - yaxis_title="Stage", - height=700, - ) - st.plotly_chart(fig, use_container_width=True) - -with tab4: - st.markdown("### Raw Data") - - st.dataframe( - filtered_df.head(100), - use_container_width=True, - height=600, - ) - - st.markdown(f"**Showing first 100 of {len(filtered_df):,} filtered rows**") - - # Download button - csv = filtered_df.to_csv(index=False).encode('utf-8') - st.download_button( - label="Download filtered data as CSV", - data=csv, - file_name="filtered_cases.csv", - mime="text/csv", - ) - -# Footer -st.markdown("---") -st.markdown("*Data loaded from EDA pipeline. Refresh to reload.*") diff --git a/scheduler/dashboard/pages/2_Ripeness_Classifier.py b/scheduler/dashboard/pages/2_Ripeness_Classifier.py index 748dc68ca18a6f7c73c131ea5dc5ef335a6c464e..6b00cbbdf7fc5e307ee1634615f06acc3be2897d 100644 --- a/scheduler/dashboard/pages/2_Ripeness_Classifier.py +++ b/scheduler/dashboard/pages/2_Ripeness_Classifier.py @@ -10,21 +10,24 @@ from datetime import date, timedelta import pandas as pd import plotly.express as px -import plotly.graph_objects as go import streamlit as st -from scheduler.core.case import Case, CaseStatus, CaseType +from scheduler.core.case import Case, CaseStatus from scheduler.core.ripeness import RipenessClassifier, RipenessStatus -from scheduler.dashboard.utils import load_generated_cases +from scheduler.dashboard.utils.data_loader import ( + attach_history_to_cases, + load_generated_cases, + load_generated_hearings, +) # Page configuration st.set_page_config( page_title="Ripeness Classifier", - page_icon="🎯", + page_icon="target", layout="wide", ) -st.title("🎯 Ripeness Classifier - Explainability Dashboard") +st.title("Ripeness Classifier - Explainability Dashboard") st.markdown("Understand and tune the case readiness algorithm") # Initialize session state for thresholds @@ -67,6 +70,13 @@ min_case_age_days = st.sidebar.slider( help="Minimum case age before considered RIPE", ) +# Detailed history toggle +use_history = st.sidebar.toggle( + "Use detailed hearing history (if available)", + value=True, + help="When enabled, the classifier will use per-hearing history from hearings.csv if present.", +) + # Reset button if st.sidebar.button("Reset to Defaults"): st.session_state.min_service_hearings = 2 @@ -79,252 +89,213 @@ st.session_state.min_service_hearings = min_service_hearings st.session_state.min_stage_days = min_stage_days st.session_state.min_case_age_days = min_case_age_days +# Wire sidebar thresholds to the core classifier +RipenessClassifier.set_thresholds( + { + "MIN_SERVICE_HEARINGS": min_service_hearings, + "MIN_STAGE_DAYS": min_stage_days, + "MIN_CASE_AGE_DAYS": min_case_age_days, + } +) + # Main content tab1, tab2, tab3 = st.tabs(["Current Configuration", "Interactive Testing", "Batch Classification"]) with tab1: st.markdown("### Current Classifier Configuration") - + col1, col2, col3 = st.columns(3) - + with col1: st.metric("Min Service Hearings", min_service_hearings) st.caption("Cases need at least this many service hearings") - + with col2: st.metric("Min Stage Days", min_stage_days) st.caption("Days in current stage threshold") - + with col3: st.metric("Min Case Age", f"{min_case_age_days} days") st.caption("Minimum case age requirement") - + st.markdown("---") - + # Classification logic flowchart st.markdown("### Classification Logic") - + with st.expander("View Decision Tree Logic"): st.markdown(""" The ripeness classifier uses the following decision logic: - + **1. Service Hearings Check** - - If `service_hearings < MIN_SERVICE_HEARINGS` → **UNRIPE** - + - If `service_hearings < MIN_SERVICE_HEARINGS` -> **UNRIPE** + **2. Case Age Check** - - If `case_age < MIN_CASE_AGE_DAYS` → **UNRIPE** - + - If `case_age < MIN_CASE_AGE_DAYS` -> **UNRIPE** + **3. Stage-Specific Checks** - Each stage has minimum days requirement - - If `days_in_stage < stage_requirement` → **UNRIPE** - + - If `days_in_stage < stage_requirement` -> **UNRIPE** + **4. Keyword Analysis** - Certain keywords indicate ripeness (e.g., "reply filed", "arguments complete") - - If keywords found → **RIPE** - + - If keywords found -> **RIPE** + **5. Final Classification** - - If all criteria met → **RIPE** - - If some criteria failed but not critical → **UNKNOWN** - - Otherwise → **UNRIPE** + - If all criteria met -> **RIPE** + - If some criteria failed but not critical -> **UNKNOWN** + - Otherwise -> **UNRIPE** """) - + # Show stage-specific rules st.markdown("### Stage-Specific Rules") - + stage_rules = { "PRE-TRIAL": {"min_days": 60, "keywords": ["affidavit filed", "reply filed"]}, "TRIAL": {"min_days": 45, "keywords": ["evidence complete", "cross complete"]}, "POST-TRIAL": {"min_days": 30, "keywords": ["arguments complete", "written note"]}, "FINAL DISPOSAL": {"min_days": 15, "keywords": ["disposed", "judgment"]}, } - - df_rules = pd.DataFrame([ - {"Stage": stage, "Min Days": rules["min_days"], "Keywords": ", ".join(rules["keywords"])} - for stage, rules in stage_rules.items() - ]) - + + df_rules = pd.DataFrame( + [ + { + "Stage": stage, + "Min Days": rules["min_days"], + "Keywords": ", ".join(rules["keywords"]), + } + for stage, rules in stage_rules.items() + ] + ) + st.dataframe(df_rules, use_container_width=True, hide_index=True) with tab2: st.markdown("### Interactive Case Classification Testing") - - st.markdown("Create a synthetic case and see how it would be classified with current thresholds") - + + st.markdown( + "Create a synthetic case and see how it would be classified with current thresholds" + ) + col1, col2 = st.columns(2) - + with col1: case_id = st.text_input("Case ID", value="TEST-001") case_type = st.selectbox("Case Type", ["CIVIL", "CRIMINAL", "WRIT", "PIL"]) - case_stage = st.selectbox("Current Stage", ["PRE-TRIAL", "TRIAL", "POST-TRIAL", "FINAL DISPOSAL"]) - + case_stage = st.selectbox( + "Current Stage", ["PRE-TRIAL", "TRIAL", "POST-TRIAL", "FINAL DISPOSAL"] + ) + with col2: - service_hearings_count = st.number_input("Service Hearings", min_value=0, max_value=20, value=3) + service_hearings_count = st.number_input( + "Service Hearings", min_value=0, max_value=20, value=3 + ) days_in_stage = st.number_input("Days in Stage", min_value=0, max_value=365, value=45) case_age = st.number_input("Case Age (days)", min_value=0, max_value=3650, value=120) - + # Keywords has_keywords = st.multiselect( "Keywords Found", - options=["reply filed", "affidavit filed", "arguments complete", "evidence complete", "written note"], + options=[ + "reply filed", + "affidavit filed", + "arguments complete", + "evidence complete", + "written note", + ], default=[], ) - + if st.button("Classify Case"): # Create synthetic case today = date.today() filed_date = today - timedelta(days=case_age) - + test_case = Case( case_id=case_id, - case_type=CaseType(case_type), + case_type=case_type, # Use string directly instead of CaseType enum filed_date=filed_date, current_stage=case_stage, status=CaseStatus.PENDING, ) - - # Simulate service hearings - test_case.hearings_history = [ - {"date": filed_date + timedelta(days=i*20), "type": "SERVICE"} - for i in range(service_hearings_count) - ] - - # Classify using current thresholds - # Note: This is a simplified classification for demo purposes - # The actual RipenessClassifier has more complex logic - - criteria_passed = [] - criteria_failed = [] - - # Check service hearings - if service_hearings_count >= min_service_hearings: - criteria_passed.append(f"✓ Service hearings: {service_hearings_count} (threshold: {min_service_hearings})") - else: - criteria_failed.append(f"✗ Service hearings: {service_hearings_count} (threshold: {min_service_hearings})") - - # Check case age - if case_age >= min_case_age_days: - criteria_passed.append(f"✓ Case age: {case_age} days (threshold: {min_case_age_days})") - else: - criteria_failed.append(f"✗ Case age: {case_age} days (threshold: {min_case_age_days})") - - # Check stage days - stage_threshold = stage_rules.get(case_stage, {}).get("min_days", min_stage_days) - if days_in_stage >= stage_threshold: - criteria_passed.append(f"✓ Stage days: {days_in_stage} (threshold: {stage_threshold} for {case_stage})") - else: - criteria_failed.append(f"✗ Stage days: {days_in_stage} (threshold: {stage_threshold} for {case_stage})") - - # Check keywords - expected_keywords = stage_rules.get(case_stage, {}).get("keywords", []) - keywords_found = [kw for kw in has_keywords if kw in expected_keywords] - if keywords_found: - criteria_passed.append(f"✓ Keywords: {', '.join(keywords_found)}") - else: - criteria_failed.append(f"✗ No relevant keywords found") - - # Final classification - if len(criteria_failed) == 0: - classification = "RIPE" - color = "green" - elif len(criteria_failed) <= 1: - classification = "UNKNOWN" - color = "orange" - else: - classification = "UNRIPE" - color = "red" - - # Display results - st.markdown("### Classification Result") - st.markdown(f":{color}[**{classification}**]") - - col1, col2 = st.columns(2) - - with col1: - st.markdown("#### Criteria Passed") - for criterion in criteria_passed: - st.markdown(criterion) - - with col2: - st.markdown("#### Criteria Failed") - if criteria_failed: - for criterion in criteria_failed: - st.markdown(criterion) - else: - st.markdown("*All criteria passed*") - - # Feature importance - st.markdown("---") - st.markdown("### Feature Importance") - - feature_scores = { - "Service Hearings": 1 if service_hearings_count >= min_service_hearings else 0, - "Case Age": 1 if case_age >= min_case_age_days else 0, - "Stage Days": 1 if days_in_stage >= stage_threshold else 0, - "Keywords": 1 if keywords_found else 0, - } - - fig = px.bar( - x=list(feature_scores.keys()), - y=list(feature_scores.values()), - labels={"x": "Feature", "y": "Score (0=Fail, 1=Pass)"}, - title="Feature Contribution to Ripeness", - color=list(feature_scores.values()), - color_continuous_scale=["red", "green"], + + # Populate aggregates and optional purpose based on selected keywords + test_case.hearing_count = service_hearings_count + test_case.days_in_stage = int(days_in_stage) + test_case.age_days = int(case_age) + test_case.last_hearing_purpose = has_keywords[0] if has_keywords else None + + # Use the real classifier + status = RipenessClassifier.classify(test_case) + reason = RipenessClassifier.get_ripeness_reason(status) + + color = ( + "green" + if status == RipenessStatus.RIPE + else ("red" if status.is_unripe() else "orange") ) - fig.update_layout(height=400, showlegend=False) - st.plotly_chart(fig, use_container_width=True) + st.markdown("### Classification Result") + st.markdown(f":{color}[**{status.value}**]") + st.caption(reason) with tab3: st.markdown("### Batch Classification Analysis") - - st.markdown("Load generated test cases and classify them with current thresholds") - + + st.markdown( + "Load generated test cases and classify them with current thresholds (core classifier)" + ) + if st.button("Load & Classify Test Cases"): with st.spinner("Loading cases..."): try: cases = load_generated_cases() - + + if use_history: + hearings_df = load_generated_hearings() + cases = attach_history_to_cases(cases, hearings_df) + if not cases: - st.warning("No test cases found. Generate cases first: `uv run court-scheduler generate`") + st.warning( + "No test cases found. Generate cases first: `uv run court-scheduler generate`" + ) else: st.success(f"Loaded {len(cases)} test cases") - - # Classify all cases (simplified) + + # Classify all cases using the core classifier classifications = {"RIPE": 0, "UNRIPE": 0, "UNKNOWN": 0} - - # For demo, use simplified logic + + today = date.today() for case in cases: - service_count = len([h for h in case.hearings_history if h.get("type") == "SERVICE"]) - case_age_days = (date.today() - case.filed_date).days - - criteria_met = 0 - if service_count >= min_service_hearings: - criteria_met += 1 - if case_age_days >= min_case_age_days: - criteria_met += 1 - - if criteria_met == 2: + # Ensure aggregates are available + case.age_days = (today - case.filed_date).days + if getattr(case, "stage_start_date", None): + case.days_in_stage = (today - case.stage_start_date).days + else: + case.days_in_stage = case.age_days + + status = RipenessClassifier.classify(case) + if status == RipenessStatus.RIPE: classifications["RIPE"] += 1 - elif criteria_met == 1: + elif status == RipenessStatus.UNKNOWN: classifications["UNKNOWN"] += 1 else: classifications["UNRIPE"] += 1 - + # Display results col1, col2, col3 = st.columns(3) - + with col1: pct = classifications["RIPE"] / len(cases) * 100 st.metric("RIPE Cases", f"{classifications['RIPE']:,}", f"{pct:.1f}%") - + with col2: pct = classifications["UNKNOWN"] / len(cases) * 100 st.metric("UNKNOWN Cases", f"{classifications['UNKNOWN']:,}", f"{pct:.1f}%") - + with col3: pct = classifications["UNRIPE"] / len(cases) * 100 st.metric("UNRIPE Cases", f"{classifications['UNRIPE']:,}", f"{pct:.1f}%") - + # Pie chart fig = px.pie( values=list(classifications.values()), @@ -334,7 +305,7 @@ with tab3: color_discrete_map={"RIPE": "green", "UNKNOWN": "orange", "UNRIPE": "red"}, ) st.plotly_chart(fig, use_container_width=True) - + except Exception as e: st.error(f"Error loading cases: {e}") diff --git a/scheduler/dashboard/pages/3_RL_Training.py b/scheduler/dashboard/pages/3_RL_Training.py deleted file mode 100644 index 31ae5fc10791c479f298da6b1a5fe90f12d3ae79..0000000000000000000000000000000000000000 --- a/scheduler/dashboard/pages/3_RL_Training.py +++ /dev/null @@ -1,335 +0,0 @@ -"""RL Training page - Interactive training and visualization. - -This page allows users to configure and train reinforcement learning agents, -monitor training progress in real-time, and visualize results. -""" - -from __future__ import annotations - -import pickle -from pathlib import Path - -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go -import streamlit as st - -from scheduler.dashboard.utils import load_rl_training_history - -# Page configuration -st.set_page_config( - page_title="RL Training", - page_icon="🤖", - layout="wide", -) - -st.title("🤖 Reinforcement Learning Training") -st.markdown("Train and visualize RL agents for optimal case scheduling") - -# Initialize session state -if "training_complete" not in st.session_state: - st.session_state.training_complete = False -if "training_stats" not in st.session_state: - st.session_state.training_stats = None - -# Tabs -tab1, tab2, tab3 = st.tabs(["Train Agent", "Training History", "Model Comparison"]) - -with tab1: - st.markdown("### Configure and Train RL Agent") - - col1, col2 = st.columns([1, 2]) - - with col1: - st.markdown("#### Training Configuration") - - with st.form("training_config"): - episodes = st.slider( - "Number of Episodes", - min_value=5, - max_value=100, - value=20, - step=5, - help="More episodes = better learning but longer training time", - ) - - cases_per_episode = st.slider( - "Cases per Episode", - min_value=50, - max_value=500, - value=200, - step=50, - help="Number of cases to simulate in each episode", - ) - - learning_rate = st.slider( - "Learning Rate", - min_value=0.01, - max_value=0.5, - value=0.15, - step=0.01, - help="How quickly the agent learns from experiences", - ) - - epsilon = st.slider( - "Initial Epsilon", - min_value=0.1, - max_value=1.0, - value=0.4, - step=0.05, - help="Exploration rate (higher = more exploration)", - ) - - discount = st.slider( - "Discount Factor (gamma)", - min_value=0.8, - max_value=0.99, - value=0.95, - step=0.01, - help="Importance of future rewards", - ) - - seed = st.number_input( - "Random Seed", - min_value=0, - max_value=10000, - value=42, - help="For reproducibility", - ) - - submitted = st.form_submit_button("Start Training", type="primary") - - if submitted: - st.info("Training functionality requires RL modules to be imported. This is a demo interface.") - st.markdown(f""" - **Training Configuration:** - - Episodes: {episodes} - - Cases/Episode: {cases_per_episode} - - Learning Rate: {learning_rate} - - Epsilon: {epsilon} - - Discount: {discount} - - Seed: {seed} - - **Command to run training via CLI:** - ```bash - uv run court-scheduler train \\ - --episodes {episodes} \\ - --cases {cases_per_episode} \\ - --lr {learning_rate} \\ - --epsilon {epsilon} \\ - --seed {seed} - ``` - """) - - # Simulate training stats for demo - demo_stats = { - "episodes": list(range(1, episodes + 1)), - "disposal_rates": [0.3 + (i / episodes) * 0.4 for i in range(episodes)], - "avg_rewards": [100 + (i / episodes) * 200 for i in range(episodes)], - "states_explored": [50 * (i + 1) for i in range(episodes)], - "epsilon_values": [epsilon * (0.95 ** i) for i in range(episodes)], - } - - st.session_state.training_stats = demo_stats - st.session_state.training_complete = True - - with col2: - st.markdown("#### Training Progress") - - if st.session_state.training_complete and st.session_state.training_stats: - stats = st.session_state.training_stats - - # Metrics - col1, col2, col3 = st.columns(3) - with col1: - final_disposal = stats["disposal_rates"][-1] - st.metric("Final Disposal Rate", f"{final_disposal:.1%}") - with col2: - total_states = stats["states_explored"][-1] - st.metric("States Explored", f"{total_states:,}") - with col3: - final_reward = stats["avg_rewards"][-1] - st.metric("Avg Reward", f"{final_reward:.1f}") - - # Disposal rate over episodes - fig = px.line( - x=stats["episodes"], - y=stats["disposal_rates"], - labels={"x": "Episode", "y": "Disposal Rate"}, - title="Disposal Rate Over Episodes", - ) - fig.update_traces(line_color="#1f77b4", line_width=3) - fig.update_layout(height=300) - st.plotly_chart(fig, use_container_width=True) - - # Average reward - fig = px.line( - x=stats["episodes"], - y=stats["avg_rewards"], - labels={"x": "Episode", "y": "Average Reward"}, - title="Average Reward Over Episodes", - ) - fig.update_traces(line_color="#ff7f0e", line_width=3) - fig.update_layout(height=300) - st.plotly_chart(fig, use_container_width=True) - - # States explored - fig = px.line( - x=stats["episodes"], - y=stats["states_explored"], - labels={"x": "Episode", "y": "States Explored"}, - title="Cumulative States Explored", - ) - fig.update_traces(line_color="#2ca02c", line_width=3) - fig.update_layout(height=300) - st.plotly_chart(fig, use_container_width=True) - - # Epsilon decay - fig = px.line( - x=stats["episodes"], - y=stats["epsilon_values"], - labels={"x": "Episode", "y": "Epsilon"}, - title="Epsilon Decay (Exploration Rate)", - ) - fig.update_traces(line_color="#d62728", line_width=3) - fig.update_layout(height=300) - st.plotly_chart(fig, use_container_width=True) - - else: - st.info("Configure training parameters and click 'Start Training' to begin.") - - st.markdown(""" - **What is RL Training?** - - Reinforcement Learning trains an agent to make optimal scheduling decisions - by learning from simulated court scheduling scenarios. - - The agent learns to: - - Prioritize cases effectively - - Balance workload across courtrooms - - Maximize disposal rates - - Minimize adjournments - - **Key Hyperparameters:** - - **Episodes**: Number of complete training runs - - **Learning Rate**: How fast the agent updates its knowledge - - **Epsilon**: Balance between exploration (try new actions) and exploitation (use known good actions) - - **Discount Factor**: How much to value future rewards vs immediate rewards - """) - -with tab2: - st.markdown("### Training History") - - st.markdown("View results from previous training runs") - - # Try to load training history - history_df = load_rl_training_history() - - if not history_df.empty: - st.dataframe(history_df, use_container_width=True) - - # Plot disposal rates over time - if "episode" in history_df.columns and "disposal_rate" in history_df.columns: - fig = px.line( - history_df, - x="episode", - y="disposal_rate", - title="Historical Training Performance", - labels={"episode": "Episode", "disposal_rate": "Disposal Rate"}, - ) - st.plotly_chart(fig, use_container_width=True) - else: - st.info("No training history found. Run training first using the CLI or the Train Agent tab.") - - st.code("uv run court-scheduler train --episodes 20 --cases 200") - -with tab3: - st.markdown("### Model Comparison") - - st.markdown("Compare different trained models and their hyperparameters") - - # Check for saved models - models_dir = Path("models") - if models_dir.exists(): - model_files = list(models_dir.glob("*.pkl")) - - if model_files: - st.success(f"Found {len(model_files)} saved model(s)") - - # Model selection - selected_models = st.multiselect( - "Select models to compare", - options=[f.name for f in model_files], - default=[model_files[0].name] if model_files else [], - ) - - if selected_models: - comparison_data = [] - - for model_name in selected_models: - try: - model_path = models_dir / model_name - with model_path.open("rb") as f: - agent = pickle.load(f) - - # Extract model info - model_info = { - "Model": model_name, - "Q-table Size": len(getattr(agent, "q_table", {})), - "Learning Rate": getattr(agent, "learning_rate", "N/A"), - "Epsilon": getattr(agent, "epsilon", "N/A"), - } - comparison_data.append(model_info) - except Exception as e: - st.warning(f"Could not load {model_name}: {e}") - - if comparison_data: - df_comparison = pd.DataFrame(comparison_data) - st.dataframe(df_comparison, use_container_width=True, hide_index=True) - - # Visualize Q-table sizes - fig = px.bar( - df_comparison, - x="Model", - y="Q-table Size", - title="Q-table Size Comparison", - labels={"Model": "Model Name", "Q-table Size": "Number of States"}, - ) - st.plotly_chart(fig, use_container_width=True) - else: - st.info("No trained models found in models/ directory") - else: - st.info("models/ directory not found. Train a model first.") - - st.markdown("---") - - # Hyperparameter analysis - with st.expander("Hyperparameter Guide"): - st.markdown(""" - **Learning Rate** (α) - - Range: 0.01 - 0.5 - - Low (0.01-0.1): Slow, stable learning - - Medium (0.1-0.2): Balanced - - High (0.2-0.5): Fast but potentially unstable - - **Epsilon** (ε) - - Range: 0.1 - 1.0 - - Low (0.1-0.3): More exploitation, less exploration - - Medium (0.3-0.5): Balanced - - High (0.5-1.0): More exploration, may take longer to converge - - **Discount Factor** (γ) - - Range: 0.8 - 0.99 - - Low (0.8-0.9): Prioritize immediate rewards - - Medium (0.9-0.95): Balanced - - High (0.95-0.99): Prioritize long-term rewards - - **Episodes** - - Fewer (5-20): Quick training, may underfit - - Medium (20-50): Good for most cases - - Many (50-100+): Better convergence, longer training time - """) - -# Footer -st.markdown("---") -st.markdown("*RL training helps optimize scheduling decisions through simulated learning*") diff --git a/scheduler/dashboard/utils/__init__.py b/scheduler/dashboard/utils/__init__.py index 0775640b12006903d646df86e746c701d2336d56..c627b8a15cb68ad4b516fe4960566a67a982babe 100644 --- a/scheduler/dashboard/utils/__init__.py +++ b/scheduler/dashboard/utils/__init__.py @@ -4,16 +4,16 @@ from .data_loader import ( get_case_statistics, get_data_status, load_cleaned_data, + load_cleaned_hearings, load_generated_cases, load_param_loader, - load_rl_training_history, ) __all__ = [ "load_param_loader", "load_cleaned_data", + "load_cleaned_hearings", "load_generated_cases", "get_case_statistics", - "load_rl_training_history", "get_data_status", ] diff --git a/scheduler/dashboard/utils/data_loader.py b/scheduler/dashboard/utils/data_loader.py index e9ddf3fa91824f2a4cddaa22a7d3d6c902ebc737..64a6d07a0a5d6b0d0b87e7940d83803fda96a95d 100644 --- a/scheduler/dashboard/utils/data_loader.py +++ b/scheduler/dashboard/utils/data_loader.py @@ -6,7 +6,6 @@ reloading large datasets on every user interaction. from __future__ import annotations -from datetime import date from pathlib import Path from typing import Any @@ -19,29 +18,52 @@ from scheduler.data.param_loader import ParameterLoader @st.cache_data(ttl=3600) -def load_param_loader(params_dir: str = "configs/parameters") -> dict[str, Any]: +def load_param_loader(params_dir: str = None) -> dict[str, Any]: """Load EDA-derived parameters. - + Args: - params_dir: Directory containing parameter files - + params_dir: Directory containing parameter files (if None, uses latest EDA output) + Returns: Dictionary containing key parameter data """ + if params_dir is None: + # Find latest EDA output directory + figures_dir = Path("reports/figures") + version_dirs = [d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")] + if version_dirs: + latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime) + params_dir = str(latest_dir / "params") + else: + params_dir = "configs/parameters" # Fallback + loader = ParameterLoader(Path(params_dir)) - + # Extract case types from case_type_summary DataFrame - case_types = loader.case_type_summary["casetype"].unique().tolist() if hasattr(loader, 'case_type_summary') else [] - + if hasattr(loader, "case_type_summary") and loader.case_type_summary is not None: + # Try both column name variations + if "CASE_TYPE" in loader.case_type_summary.columns: + case_types = loader.case_type_summary["CASE_TYPE"].unique().tolist() + elif "casetype" in loader.case_type_summary.columns: + case_types = loader.case_type_summary["casetype"].unique().tolist() + else: + case_types = [] + else: + case_types = [] + # Extract stages from transition_probs DataFrame - stages = loader.transition_probs["STAGE_FROM"].unique().tolist() if hasattr(loader, 'transition_probs') else [] - + stages = ( + loader.transition_probs["STAGE_FROM"].unique().tolist() + if hasattr(loader, "transition_probs") + else [] + ) + # Build stage graph from transition probabilities stage_graph = {} for stage in stages: transitions = loader.get_stage_transitions(stage) - stage_graph[stage] = transitions.to_dict('records') - + stage_graph[stage] = transitions.to_dict("records") + # Build adjournment stats adjournment_stats = {} for stage in stages: @@ -50,117 +72,410 @@ def load_param_loader(params_dir: str = "configs/parameters") -> dict[str, Any]: try: prob = loader.get_adjournment_prob(stage, ct) adjournment_stats[stage][ct] = prob - except: + except (KeyError, ValueError): adjournment_stats[stage][ct] = 0.0 - + + # Include global courtroom capacity stats if available + try: + court_capacity = loader.court_capacity # type: ignore[attr-defined] + except Exception: + court_capacity = None + return { "case_types": case_types, "stages": stages, "stage_graph": stage_graph, "adjournment_stats": adjournment_stats, + # Expected by Data & Insights → Simulation Defaults section + # File source: reports/figures//params/court_capacity_global.json + "court_capacity_global": court_capacity, } @st.cache_data(ttl=3600) -def load_cleaned_data(data_path: str = "Data/processed/cleaned_cases.csv") -> pd.DataFrame: +def load_cleaned_hearings(data_path: str = None) -> pd.DataFrame: + """Load cleaned hearings data. + + Args: + data_path: Path to cleaned hearings file (if None, uses latest EDA output) + + Returns: + Pandas DataFrame with hearings data + """ + if data_path is None: + # Find latest EDA output directory + figures_dir = Path("reports/figures") + version_dirs = [d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")] + if version_dirs: + latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime) + # Try parquet first, then CSV + parquet_path = latest_dir / "hearings_clean.parquet" + csv_path = latest_dir / "hearings_clean.csv" + if parquet_path.exists(): + path = parquet_path + elif csv_path.exists(): + path = csv_path + else: + st.warning(f"No cleaned hearings data found in {latest_dir}") + return pd.DataFrame() + else: + st.warning("No EDA output directories found. Run EDA pipeline first.") + return pd.DataFrame() + else: + path = Path(data_path) + + if not path.exists(): + st.warning(f"Hearings file not found: {path}") + return pd.DataFrame() + + # Load based on file extension + if path.suffix == ".parquet": + df = pl.read_parquet(path).to_pandas() + else: + df = pl.read_csv(path).to_pandas() + return df + + +@st.cache_data(ttl=3600) +def load_cleaned_data(data_path: str = None) -> pd.DataFrame: """Load cleaned case data. - + Args: - data_path: Path to cleaned CSV file - + data_path: Path to cleaned data file (if None, uses latest EDA output) + Returns: Pandas DataFrame with case data """ - path = Path(data_path) + if data_path is None: + # Find latest EDA output directory + figures_dir = Path("reports/figures") + version_dirs = [d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")] + if version_dirs: + latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime) + # Try parquet first, then CSV + parquet_path = latest_dir / "cases_clean.parquet" + csv_path = latest_dir / "cases_clean.csv" + if parquet_path.exists(): + path = parquet_path + elif csv_path.exists(): + path = csv_path + else: + st.warning(f"No cleaned data found in {latest_dir}") + return pd.DataFrame() + else: + st.warning("No EDA output directories found. Run EDA pipeline first.") + return pd.DataFrame() + else: + path = Path(data_path) + if not path.exists(): - st.warning(f"Data file not found: {data_path}") + st.warning(f"Data file not found: {path}") return pd.DataFrame() - - # Use Polars for faster loading, then convert to Pandas for compatibility - df = pl.read_csv(path).to_pandas() + + # Load based on file extension + if path.suffix == ".parquet": + df = pl.read_parquet(path).to_pandas() + else: + df = pl.read_csv(path).to_pandas() return df @st.cache_data(ttl=3600) def load_generated_cases(cases_path: str = "data/generated/cases.csv") -> list: """Load generated test cases. - + Args: cases_path: Path to generated cases CSV - + Returns: List of Case objects """ - path = Path(cases_path) - if not path.exists(): - st.warning(f"Cases file not found: {cases_path}") + + # Helper to detect project root (directory containing pyproject.toml or repo files) + def _detect_project_root(start: Path | None = None) -> Path: + try: + cur = (start or Path(__file__).resolve()).resolve() + except Exception: + cur = Path.cwd() + for parent in [cur] + list(cur.parents): + try: + if (parent / "pyproject.toml").exists(): + return parent + # Fallback heuristic: both top-level folders present + if (parent / "scheduler").is_dir() and (parent / "cli").is_dir(): + return parent + except Exception: + continue + return Path.cwd() + + # Build a list of candidate paths to be resilient to working directory and case differences + candidates: list[Path] = [] + seen: set[str] = set() + + def _add(path: Path) -> None: + try: + key = str(path.resolve()) + except Exception: + key = str(path) + if key not in seen: + seen.add(key) + candidates.append(path) + + p = Path(cases_path) + + # Bases to try: as-is (absolute or relative to CWD), project root, and file's directory + project_root = _detect_project_root() + file_base = ( + Path(__file__).resolve().parent.parent.parent.parent + ) # approximate repo root from file + bases: list[Path] = [Path.cwd(), project_root, file_base] + + # 1) As provided + _add(p) + + # 2) If relative, try under each base + if not p.is_absolute(): + for base in bases: + _add(base / p) + + # 3) Try swapping the top-level directory between data/Data if applicable + def swap_data_top(path: Path) -> Path | None: + parts = path.parts + if not parts: + return None + top = parts[0] + if top.lower() == "data": + alt_top = "Data" if top == "data" else "data" + if len(parts) > 1: + return Path(alt_top).joinpath(*parts[1:]) + return Path(alt_top) + return None + + # Apply swap to original and to base-joined variants + to_consider = list(candidates) + for c in to_consider: + alt = swap_data_top(c) + if alt is not None: + _add(alt) + # If relative, also try under bases + if not alt.is_absolute(): + for base in bases: + _add(base / alt) + + # 4) Explicitly try the known alternative under project root when default is used + if str(cases_path).replace("\\", "/").endswith("data/generated/cases.csv"): + _add(project_root / "Data/generated/cases.csv") + + # Pick the first existing path + chosen = next((c for c in candidates if c.exists()), None) + if chosen is None: + tried = ", ".join(str(Path(str(c)).resolve()) for c in candidates) + st.warning( + "Cases file not found. Tried: " + + tried + + f" | CWD: {Path.cwd()} | Project root: {project_root}" + ) return [] - - cases = CaseGenerator.from_csv(path) + + cases = CaseGenerator.from_csv(chosen) + return cases + + +@st.cache_data(ttl=3600) +def load_generated_hearings(hearings_path: str = "data/generated/hearings.csv") -> pd.DataFrame: + """Load generated hearings history as a flat DataFrame. + + Args: + hearings_path: Path to generated hearings CSV + + Returns: + Pandas DataFrame with columns [case_id, date, stage, purpose, was_heard, event] + """ + + # Reuse robust path detection from load_generated_cases + def _detect_project_root(start: Path | None = None) -> Path: + try: + cur = (start or Path(__file__).resolve()).resolve() + except Exception: + cur = Path.cwd() + for parent in [cur] + list(cur.parents): + try: + if (parent / "pyproject.toml").exists(): + return parent + if (parent / "scheduler").is_dir() and (parent / "cli").is_dir(): + return parent + except Exception: + continue + return Path.cwd() + + candidates: list[Path] = [] + seen: set[str] = set() + + def _add(path: Path) -> None: + try: + key = str(path.resolve()) + except Exception: + key = str(path) + if key not in seen: + seen.add(key) + candidates.append(path) + + p = Path(hearings_path) + project_root = _detect_project_root() + file_base = Path(__file__).resolve().parent.parent.parent.parent + bases: list[Path] = [Path.cwd(), project_root, file_base] + + _add(p) + if not p.is_absolute(): + for base in bases: + _add(base / p) + + # swap Data/data top folder if needed + def swap_data_top(path: Path) -> Path | None: + parts = path.parts + if not parts: + return None + top = parts[0] + if top.lower() == "data": + alt_top = "Data" if top == "data" else "data" + if len(parts) > 1: + return Path(alt_top).joinpath(*parts[1:]) + return Path(alt_top) + return None + + to_consider = list(candidates) + for c in to_consider: + alt = swap_data_top(c) + if alt is not None: + _add(alt) + if not alt.is_absolute(): + for base in bases: + _add(base / alt) + + # Explicit additional under project root + if str(hearings_path).replace("\\", "/").endswith("data/generated/hearings.csv"): + _add(project_root / "Data/generated/hearings.csv") + + chosen = next((c for c in candidates if c.exists()), None) + if chosen is None: + # Don't warn loudly; simply return empty frame for graceful fallback + return pd.DataFrame(columns=["case_id", "date", "stage", "purpose", "was_heard", "event"]) + + try: + df = pd.read_csv(chosen) + except Exception: + return pd.DataFrame(columns=["case_id", "date", "stage", "purpose", "was_heard", "event"]) + + # Normalize columns + expected_cols = ["case_id", "date", "stage", "purpose", "was_heard", "event"] + for col in expected_cols: + if col not in df.columns: + df[col] = None + # Parse dates + try: + df["date"] = pd.to_datetime(df["date"]).dt.date + except Exception: + pass + return df[expected_cols] + + +def attach_history_to_cases(cases: list, hearings_df: pd.DataFrame) -> list: + """Attach hearing history rows to Case.history for in-memory objects. + + This does not persist anything; it only enriches the provided Case objects. + """ + if hearings_df is None or hearings_df.empty: + return cases + + # Build index by case_id for speed + by_case: dict[str, list[dict]] = {} + for row in hearings_df.to_dict("records"): + by_case.setdefault(row["case_id"], []).append( + { + "date": row.get("date"), + "event": row.get("event", "hearing"), + "stage": row.get("stage"), + "purpose": row.get("purpose"), + "was_heard": bool(row.get("was_heard", 0)), + } + ) + + for c in cases: + hist = by_case.get(getattr(c, "case_id", None)) + if hist: + # sort by date just in case + hist_sorted = sorted( + hist, key=lambda e: (e.get("date") or getattr(c, "filed_date", None) or 0) + ) + c.history = hist_sorted + # Update aggregates from history if missing + c.hearing_count = sum(1 for e in hist_sorted if e.get("event") == "hearing") + last = hist_sorted[-1] + if last.get("date") is not None: + c.last_hearing_date = last.get("date") + if last.get("purpose"): + c.last_hearing_purpose = last.get("purpose") return cases @st.cache_data def get_case_statistics(df: pd.DataFrame) -> dict[str, Any]: """Compute statistics from case DataFrame. - + Args: df: Case data DataFrame - + Returns: Dictionary of statistics """ if df.empty: return {} - + stats = { "total_cases": len(df), "case_types": df["CaseType"].value_counts().to_dict() if "CaseType" in df else {}, "stages": df["Remappedstages"].value_counts().to_dict() if "Remappedstages" in df else {}, } - + # Adjournment rate if applicable if "Outcome" in df.columns: total_hearings = len(df) adjourned = len(df[df["Outcome"] == "ADJOURNED"]) stats["adjournment_rate"] = adjourned / total_hearings if total_hearings > 0 else 0 - + return stats -@st.cache_data -def load_rl_training_history(log_dir: str = "runs") -> pd.DataFrame: - """Load RL training history from logs. - - Args: - log_dir: Directory containing training logs - - Returns: - DataFrame with training metrics - """ - path = Path(log_dir) - if not path.exists(): - return pd.DataFrame() - - # Look for training log files - log_files = list(path.glob("**/training_stats.csv")) - if not log_files: - return pd.DataFrame() - - # Load most recent - latest_log = max(log_files, key=lambda p: p.stat().st_mtime) - return pd.read_csv(latest_log) +# RL training history loader removed as RL features are no longer supported def get_data_status() -> dict[str, bool]: """Check availability of various data sources. - + Returns: Dictionary mapping data source to availability status """ + # Find latest EDA output directory + figures_dir = Path("reports/figures") + if figures_dir.exists(): + version_dirs = [d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")] + if version_dirs: + latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime) + cleaned_data_exists = (latest_dir / "cases_clean.parquet").exists() + params_exists = (latest_dir / "params").exists() + # Check for HTML figures in the versioned directory + eda_figures_exist = len(list(latest_dir.glob("*.html"))) > 0 + else: + cleaned_data_exists = False + params_exists = False + eda_figures_exist = False + else: + cleaned_data_exists = False + params_exists = False + eda_figures_exist = False + return { - "cleaned_data": Path("Data/processed/cleaned_cases.csv").exists(), - "parameters": Path("configs/parameters").exists(), - "generated_cases": Path("data/generated/cases.csv").exists(), - "eda_figures": Path("reports/figures").exists(), + "cleaned_data": cleaned_data_exists, + "parameters": params_exists, + "eda_figures": eda_figures_exist, } diff --git a/scheduler/data/case_generator.py b/scheduler/data/case_generator.py index d8d3dd910ed69ad73ac2d837ee2b75f957b89b88..a59727fe61c053bd655159f1eeea82af59bf3ba1 100644 --- a/scheduler/data/case_generator.py +++ b/scheduler/data/case_generator.py @@ -8,23 +8,24 @@ Generates Case objects between start_date and end_date using: Also provides CSV export/import helpers compatible with scripts. """ + from __future__ import annotations +import csv +import random from dataclasses import dataclass from datetime import date, timedelta from pathlib import Path from typing import Iterable, List, Tuple -import csv -import random from scheduler.core.case import Case -from scheduler.utils.calendar import CourtCalendar from scheduler.data.config import ( CASE_TYPE_DISTRIBUTION, MONTHLY_SEASONALITY, URGENT_CASE_PERCENTAGE, ) from scheduler.data.param_loader import load_parameters +from scheduler.utils.calendar import CourtCalendar def _month_iter(start: date, end: date) -> Iterable[Tuple[int, int]]: @@ -44,7 +45,13 @@ class CaseGenerator: end: date seed: int = 42 - def generate(self, n_cases: int, stage_mix: dict | None = None, stage_mix_auto: bool = False) -> List[Case]: + def generate( + self, + n_cases: int, + stage_mix: dict | None = None, + stage_mix_auto: bool = False, + case_type_distribution: dict | None = None, + ) -> List[Case]: random.seed(self.seed) cal = CourtCalendar() if stage_mix_auto: @@ -53,7 +60,7 @@ class CaseGenerator: stage_mix = stage_mix or {"ADMISSION": 1.0} # normalize explicitly total_mix = sum(stage_mix.values()) or 1.0 - stage_mix = {k: v/total_mix for k, v in stage_mix.items()} + stage_mix = {k: v / total_mix for k, v in stage_mix.items()} # precompute cumulative for stage sampling stage_items = list(stage_mix.items()) scum = [] @@ -63,6 +70,7 @@ class CaseGenerator: scum.append(accs) if scum: scum[-1] = 1.0 + def sample_stage() -> str: if not stage_items: return "ADMISSION" @@ -76,11 +84,12 @@ class CaseGenerator: def sample_stage_duration(stage: str) -> float: params = getattr(sample_stage_duration, "_params", None) if params is None: - setattr(sample_stage_duration, "_params", load_parameters()) - params = getattr(sample_stage_duration, "_params") + sample_stage_duration._params = load_parameters() + params = sample_stage_duration._params med = params.get_stage_duration(stage, "median") p90 = params.get_stage_duration(stage, "p90") import math + med = max(med, 1e-3) p90 = max(p90, med + 1e-6) z = 1.2815515655446004 @@ -89,14 +98,14 @@ class CaseGenerator: # Box-Muller normal sample u1 = max(random.random(), 1e-9) u2 = max(random.random(), 1e-9) - z0 = ( (-2.0*math.log(u1)) ** 0.5 ) * math.cos(2.0*math.pi*u2) + z0 = ((-2.0 * math.log(u1)) ** 0.5) * math.cos(2.0 * math.pi * u2) val = math.exp(mu + sigma * z0) return max(1.0, val) # 1) Build monthly working-day lists and weights (seasonality * working days) month_days = {} month_weight = {} - for (y, m) in _month_iter(self.start, self.end): + for y, m in _month_iter(self.start, self.end): days = cal.get_working_days_in_month(y, m) # restrict to [start, end] days = [d for d in days if self.start <= d <= self.end] @@ -112,7 +121,6 @@ class CaseGenerator: # 2) Allocate case counts per month (round, then adjust) alloc = {} - remaining = n_cases for key, w in month_weight.items(): cnt = int(round(n_cases * (w / total_w))) alloc[key] = cnt @@ -127,8 +135,27 @@ class CaseGenerator: alloc[keys[idx]] += step idx = (idx + 1) % len(keys) - # 3) Sampling helpers - type_items = list(CASE_TYPE_DISTRIBUTION.items()) + # 3) Sampling helpers (case type distribution) + # Allow custom distribution override; default to historical (from config/EDA) + if case_type_distribution is None: + type_dist = dict(CASE_TYPE_DISTRIBUTION) + else: + # Validate and normalize user-provided distribution + # Filter out zero/negative and None values + valid_items = { + str(k): float(v) + for k, v in case_type_distribution.items() + if v is not None and float(v) > 0.0 and str(k) + } + # Fallback to defaults if invalid or empty after filtering + if not valid_items: + type_dist = dict(CASE_TYPE_DISTRIBUTION) + else: + total = sum(valid_items.values()) + # Normalize to 1.0 + type_dist = {k: v / total for k, v in valid_items.items()} + + type_items = list(type_dist.items()) type_acc = [] cum = 0.0 for _, p in type_items: @@ -140,7 +167,7 @@ class CaseGenerator: def sample_case_type() -> str: r = random.random() - for (i, (ct, _)) in enumerate(type_items): + for i, (ct, _) in enumerate(type_items): if r <= type_acc[i]: return ct return type_items[-1][0] @@ -158,12 +185,12 @@ class CaseGenerator: seq += 1 ct = sample_case_type() urgent = random.random() < URGENT_CASE_PERCENTAGE - cid = f"{ct}/{filed.year}/{len(cases)+1:05d}" + cid = f"{ct}/{filed.year}/{len(cases) + 1:05d}" init_stage = sample_stage() # For initial cases: they're filed on 'filed' date, started current stage on filed date # days_in_stage represents how long they've been in this stage as of simulation start # We sample a duration but cap it to not go before filed_date - dur_days = int(sample_stage_duration(init_stage)) + int(sample_stage_duration(init_stage)) # stage_start should be between filed_date and some time after # For simplicity: set stage_start = filed_date, case just entered this stage c = Case( @@ -180,16 +207,10 @@ class CaseGenerator: # This ensures constant stream of cases becoming eligible, not all at once days_since_filed = (self.end - filed).days if days_since_filed > 30: # Only if filed at least 30 days before end + # Determine number of historical hearings based on age (roughly monthly) c.hearing_count = max(1, days_since_filed // 30) - # Last hearing was randomly 7-30 days before end (spread across a month) - # 7 days = just became eligible, 30 days = long overdue - days_before_end = random.randint(7, 30) - c.last_hearing_date = self.end - timedelta(days=days_before_end) - # Set days_since_last_hearing so simulation starts with staggered eligibility - c.days_since_last_hearing = days_before_end - - # Simulate realistic hearing purposes for ripeness classification - # 20% of cases have bottlenecks (unripe) + + # Define pools of purposes bottleneck_purposes = [ "ISSUE SUMMONS", "FOR NOTICE", @@ -204,17 +225,67 @@ class CaseGenerator: "FOR JUDGMENT", "EVIDENCE", ] - - if init_stage == "ADMISSION" and c.hearing_count < 3: - # Early ADMISSION cases more likely unripe - c.last_hearing_purpose = random.choice(bottleneck_purposes) if random.random() < 0.4 else random.choice(ripe_purposes) - elif init_stage in ["ARGUMENTS", "ORDERS / JUDGMENT", "FINAL DISPOSAL"]: - # Advanced stages usually ripe - c.last_hearing_purpose = random.choice(ripe_purposes) + + # Build a small hearing history list on the Case.history + c.history = [] + + # Generate hearing dates spaced across the case lifetime, ending 7-30 days before end + days_before_end = random.randint(7, 30) + last_hearing_date = self.end - timedelta(days=days_before_end) + # approximate spacing + if c.hearing_count == 1: + hearing_dates = [last_hearing_date] else: - # Mixed - c.last_hearing_purpose = random.choice(bottleneck_purposes) if random.random() < 0.2 else random.choice(ripe_purposes) - + span_days = max(days_since_filed - days_before_end, 30) + step = max(1, span_days // c.hearing_count) + hearing_dates = [ + last_hearing_date - timedelta(days=step * i) + for i in range(c.hearing_count - 1) + ] + hearing_dates = sorted(hearing_dates) + [last_hearing_date] + + # Assign purposes: earlier ones mixed; final one stage-dependent + for i, hdt in enumerate(hearing_dates): + if i == len(hearing_dates) - 1: + # Final hearing purpose depends on stage and random bottleneck share + if init_stage == "ADMISSION" and c.hearing_count < 3: + purpose = ( + random.choice(bottleneck_purposes) + if random.random() < 0.4 + else random.choice(ripe_purposes) + ) + elif init_stage in ["ARGUMENTS", "ORDERS / JUDGMENT", "FINAL DISPOSAL"]: + purpose = random.choice(ripe_purposes) + else: + purpose = ( + random.choice(bottleneck_purposes) + if random.random() < 0.2 + else random.choice(ripe_purposes) + ) + else: + purpose = random.choice(bottleneck_purposes + ripe_purposes) + + was_heard = purpose not in ( + "ISSUE SUMMONS", + "FOR NOTICE", + "AWAIT SERVICE OF NOTICE", + ) + c.history.append( + { + "date": hdt, + "event": "hearing", + "was_heard": was_heard, + "outcome": "", + "stage": init_stage, + "purpose": purpose, + } + ) + + # Update aggregates from generated history + c.last_hearing_date = last_hearing_date + c.days_since_last_hearing = days_before_end + c.last_hearing_purpose = c.history[-1]["purpose"] if c.history else None + cases.append(c) return cases @@ -225,19 +296,57 @@ class CaseGenerator: out_path.parent.mkdir(parents=True, exist_ok=True) with out_path.open("w", newline="") as f: w = csv.writer(f) - w.writerow(["case_id", "case_type", "filed_date", "current_stage", "is_urgent", "hearing_count", "last_hearing_date", "days_since_last_hearing", "last_hearing_purpose"]) + w.writerow( + [ + "case_id", + "case_type", + "filed_date", + "current_stage", + "is_urgent", + "hearing_count", + "last_hearing_date", + "days_since_last_hearing", + "last_hearing_purpose", + ] + ) + for c in cases: + w.writerow( + [ + c.case_id, + c.case_type, + c.filed_date.isoformat(), + c.current_stage, + 1 if c.is_urgent else 0, + c.hearing_count, + c.last_hearing_date.isoformat() if c.last_hearing_date else "", + c.days_since_last_hearing, + c.last_hearing_purpose or "", + ] + ) + + @staticmethod + def to_hearings_csv(cases: List[Case], out_path: Path) -> None: + """Write flattened hearing histories for generated cases. + + Schema: case_id,date,stage,purpose,was_heard,event + """ + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", newline="") as f: + w = csv.writer(f) + w.writerow(["case_id", "date", "stage", "purpose", "was_heard", "event"]) for c in cases: - w.writerow([ - c.case_id, - c.case_type, - c.filed_date.isoformat(), - c.current_stage, - 1 if c.is_urgent else 0, - c.hearing_count, - c.last_hearing_date.isoformat() if c.last_hearing_date else "", - c.days_since_last_hearing, - c.last_hearing_purpose or "", - ]) + for ev in getattr(c, "history", []) or []: + if ev.get("event") == "hearing": + w.writerow( + [ + c.case_id, + (ev.get("date") or c.filed_date).isoformat(), + ev.get("stage") or c.current_stage, + ev.get("purpose", ""), + 1 if ev.get("was_heard", False) else 0, + ev.get("event"), + ] + ) @staticmethod def from_csv(path: Path) -> List[Case]: diff --git a/scheduler/data/config.py b/scheduler/data/config.py index 314443867024cc5adc3dc005b5420264b7d079f8..079f8227e1b49cbe65df7c58217c200756f432bf 100644 --- a/scheduler/data/config.py +++ b/scheduler/data/config.py @@ -8,7 +8,7 @@ import argparse import subprocess import sys from pathlib import Path -from typing import Dict, List, Optional +from typing import Optional # Project paths PROJECT_ROOT = Path(__file__).parent.parent.parent @@ -72,7 +72,6 @@ def get_latest_params_dir( FileNotFoundError: When parameters cannot be located or generated. RuntimeError: When regeneration is attempted but fails. """ - if prefer_defaults and allow_defaults and DEFAULT_PARAMS_DIR.exists(): print( "Using bundled baseline parameters from scheduler/data/defaults (preferred).", diff --git a/scheduler/data/param_loader.py b/scheduler/data/param_loader.py index cd917a2ac5025285c373b797f335a56bfc557d41..579b3270a6fa6744feae1cf142a0e2de3effbd5b 100644 --- a/scheduler/data/param_loader.py +++ b/scheduler/data/param_loader.py @@ -5,12 +5,10 @@ them available to the scheduler. """ import json -import math from pathlib import Path -from typing import Dict, Optional, List +from typing import Dict, List, Optional import pandas as pd -import polars as pl from scheduler.data.config import get_latest_params_dir @@ -21,15 +19,15 @@ class ParameterLoader: Performance notes: - Builds in-memory lookup caches to avoid repeated DataFrame filtering. """ - + def __init__(self, params_dir: Optional[Path] = None): """Initialize parameter loader. - + Args: params_dir: Directory containing parameter files. If None, uses latest. """ self.params_dir = params_dir or get_latest_params_dir() - + # Cached parameters self._transition_probs: Optional[pd.DataFrame] = None self._stage_duration: Optional[pd.DataFrame] = None @@ -41,11 +39,11 @@ class ParameterLoader: self._duration_map: Optional[Dict[str, Dict[str, float]]] = None # stage -> {"median": x, "p90": y} self._transitions_map: Optional[Dict[str, List[tuple]]] = None # stage_from -> [(stage_to, cum_p), ...] self._adj_map: Optional[Dict[str, Dict[str, float]]] = None # stage -> {case_type: p_adj} - + @property def transition_probs(self) -> pd.DataFrame: """Stage transition probabilities. - + Returns: DataFrame with columns: STAGE_FROM, STAGE_TO, N, row_n, p """ @@ -53,25 +51,25 @@ class ParameterLoader: file_path = self.params_dir / "stage_transition_probs.csv" self._transition_probs = pd.read_csv(file_path) return self._transition_probs - + def get_transition_prob(self, stage_from: str, stage_to: str) -> float: """Get probability of transitioning from one stage to another. - + Args: stage_from: Current stage stage_to: Next stage - + Returns: Transition probability (0-1) """ df = self.transition_probs match = df[(df["STAGE_FROM"] == stage_from) & (df["STAGE_TO"] == stage_to)] - + if len(match) == 0: return 0.0 - + return float(match.iloc[0]["p"]) - + def _build_transitions_map(self) -> None: if self._transitions_map is not None: return @@ -92,10 +90,10 @@ class ParameterLoader: def get_stage_transitions(self, stage_from: str) -> pd.DataFrame: """Get all possible transitions from a given stage. - + Args: stage_from: Current stage - + Returns: DataFrame with STAGE_TO and p columns """ @@ -108,11 +106,11 @@ class ParameterLoader: if not self._transitions_map: return [] return self._transitions_map.get(stage_from, []) - + @property def stage_duration(self) -> pd.DataFrame: """Stage duration statistics. - + Returns: DataFrame with columns: STAGE, RUN_MEDIAN_DAYS, RUN_P90_DAYS, HEARINGS_PER_RUN_MED, N_RUNS @@ -121,7 +119,7 @@ class ParameterLoader: file_path = self.params_dir / "stage_duration.csv" self._stage_duration = pd.read_csv(file_path) return self._stage_duration - + def _build_duration_map(self) -> None: if self._duration_map is not None: return @@ -135,11 +133,11 @@ class ParameterLoader: def get_stage_duration(self, stage: str, percentile: str = "median") -> float: """Get typical duration for a stage. - + Args: stage: Stage name percentile: 'median' or 'p90' - + Returns: Duration in days """ @@ -148,11 +146,11 @@ class ParameterLoader: return 30.0 p = "median" if percentile == "median" else "p90" return float(self._duration_map[stage].get(p, 30.0)) - + @property def court_capacity(self) -> Dict: """Court capacity metrics. - + Returns: Dict with keys: slots_median_global, slots_p90_global """ @@ -161,21 +159,21 @@ class ParameterLoader: with open(file_path, "r") as f: self._court_capacity = json.load(f) return self._court_capacity - + @property def daily_capacity_median(self) -> int: """Median daily capacity per courtroom.""" return int(self.court_capacity["slots_median_global"]) - + @property def daily_capacity_p90(self) -> int: """90th percentile daily capacity per courtroom.""" return int(self.court_capacity["slots_p90_global"]) - + @property def adjournment_proxies(self) -> pd.DataFrame: """Adjournment probabilities by stage and case type. - + Returns: DataFrame with columns: Remappedstages, casetype, p_adjourn_proxy, p_not_reached_proxy, n @@ -184,7 +182,7 @@ class ParameterLoader: file_path = self.params_dir / "adjournment_proxies.csv" self._adjournment_proxies = pd.read_csv(file_path) return self._adjournment_proxies - + def _build_adj_map(self) -> None: if self._adj_map is not None: return @@ -198,11 +196,11 @@ class ParameterLoader: def get_adjournment_prob(self, stage: str, case_type: str) -> float: """Get probability of adjournment for given stage and case type. - + Args: stage: Stage name case_type: Case type (e.g., 'RSA', 'CRP') - + Returns: Adjournment probability (0-1) """ @@ -216,11 +214,11 @@ class ParameterLoader: vals = list(self._adj_map[stage].values()) return float(sum(vals) / len(vals)) return 0.4 - + @property def case_type_summary(self) -> pd.DataFrame: """Summary statistics by case type. - + Returns: DataFrame with columns: CASE_TYPE, n_cases, disp_median, disp_p90, hear_median, gap_median @@ -229,28 +227,28 @@ class ParameterLoader: file_path = self.params_dir / "case_type_summary.csv" self._case_type_summary = pd.read_csv(file_path) return self._case_type_summary - + def get_case_type_stats(self, case_type: str) -> Dict: """Get statistics for a specific case type. - + Args: case_type: Case type (e.g., 'RSA', 'CRP') - + Returns: Dict with disp_median, disp_p90, hear_median, gap_median """ df = self.case_type_summary match = df[df["CASE_TYPE"] == case_type] - + if len(match) == 0: raise ValueError(f"Unknown case type: {case_type}") - + return match.iloc[0].to_dict() - + @property def transition_entropy(self) -> pd.DataFrame: """Stage transition entropy (predictability metric). - + Returns: DataFrame with columns: STAGE_FROM, entropy """ @@ -258,28 +256,28 @@ class ParameterLoader: file_path = self.params_dir / "stage_transition_entropy.csv" self._transition_entropy = pd.read_csv(file_path) return self._transition_entropy - + def get_stage_predictability(self, stage: str) -> float: """Get predictability of transitions from a stage (inverse of entropy). - + Args: stage: Stage name - + Returns: Predictability score (0-1, higher = more predictable) """ df = self.transition_entropy match = df[df["STAGE_FROM"] == stage] - + if len(match) == 0: return 0.5 # Default: medium predictability - + entropy = float(match.iloc[0]["entropy"]) # Convert entropy to predictability (lower entropy = higher predictability) # Max entropy ~1.4, so normalize predictability = max(0.0, 1.0 - (entropy / 1.5)) return predictability - + def get_stage_stationary_distribution(self) -> Dict[str, float]: """Approximate stationary distribution over stages from transition matrix. Returns stage -> probability summing to 1.0. @@ -295,7 +293,8 @@ class ParameterLoader: # build dense row-stochastic matrix P = [[0.0]*n for _ in range(n)] for _, row in df.iterrows(): - i = idx[str(row["STAGE_FROM"])]; j = idx[str(row["STAGE_TO"])] + i = idx[str(row["STAGE_FROM"])] + j = idx[str(row["STAGE_TO"])] P[i][j] += float(row["p"]) # ensure rows sum to 1 by topping up self-loop for i in range(n): @@ -333,10 +332,10 @@ class ParameterLoader: # Convenience function for quick access def load_parameters(params_dir: Optional[Path] = None) -> ParameterLoader: """Load parameters from EDA outputs. - + Args: params_dir: Directory containing parameter files. If None, uses latest. - + Returns: ParameterLoader instance """ diff --git a/scheduler/monitoring/__init__.py b/scheduler/monitoring/__init__.py index b15c7b07a3a82d2fc2cbad08cfa8fcc8138c5998..3566ac0ffe340660ec24210c048891c88dbfeb25 100644 --- a/scheduler/monitoring/__init__.py +++ b/scheduler/monitoring/__init__.py @@ -1,7 +1,7 @@ """Monitoring and feedback loop components.""" -from scheduler.monitoring.ripeness_metrics import RipenessMetrics, RipenessPrediction from scheduler.monitoring.ripeness_calibrator import RipenessCalibrator, ThresholdAdjustment +from scheduler.monitoring.ripeness_metrics import RipenessMetrics, RipenessPrediction __all__ = [ "RipenessMetrics", diff --git a/scheduler/monitoring/ripeness_calibrator.py b/scheduler/monitoring/ripeness_calibrator.py index f6b4ec36a234a966acab10cee41f5dde7254782a..7e67cba63d93e7e88c1a7d01c63a1b88ac9acf12 100644 --- a/scheduler/monitoring/ripeness_calibrator.py +++ b/scheduler/monitoring/ripeness_calibrator.py @@ -15,7 +15,7 @@ from scheduler.monitoring.ripeness_metrics import RipenessMetrics @dataclass class ThresholdAdjustment: """Suggested threshold adjustment with reasoning.""" - + threshold_name: str current_value: int | float suggested_value: int | float @@ -25,14 +25,14 @@ class ThresholdAdjustment: class RipenessCalibrator: """Analyzes ripeness metrics and suggests threshold calibration.""" - + # Calibration rules thresholds HIGH_FALSE_POSITIVE_THRESHOLD = 0.20 HIGH_FALSE_NEGATIVE_THRESHOLD = 0.15 LOW_UNKNOWN_THRESHOLD = 0.05 LOW_RIPE_PRECISION_THRESHOLD = 0.70 LOW_UNRIPE_RECALL_THRESHOLD = 0.60 - + @classmethod def analyze_metrics( cls, @@ -40,17 +40,17 @@ class RipenessCalibrator: current_thresholds: Optional[dict[str, int | float]] = None, ) -> list[ThresholdAdjustment]: """Analyze metrics and suggest threshold adjustments. - + Args: metrics: RipenessMetrics with classification history current_thresholds: Current threshold values (optional) - + Returns: List of suggested adjustments with reasoning """ accuracy = metrics.get_accuracy_metrics() adjustments: list[ThresholdAdjustment] = [] - + # Default current thresholds if not provided if current_thresholds is None: from scheduler.core.ripeness import RipenessClassifier @@ -59,13 +59,13 @@ class RipenessCalibrator: "MIN_STAGE_DAYS": RipenessClassifier.MIN_STAGE_DAYS, "MIN_CASE_AGE_DAYS": RipenessClassifier.MIN_CASE_AGE_DAYS, } - + # Check if we have enough data if accuracy["completed_predictions"] < 50: print("Warning: Insufficient data for calibration (need at least 50 predictions)") return adjustments - - # Rule 1: High false positive rate → increase MIN_SERVICE_HEARINGS + + # Rule 1: High false positive rate -> increase MIN_SERVICE_HEARINGS if accuracy["false_positive_rate"] > cls.HIGH_FALSE_POSITIVE_THRESHOLD: current_hearings = current_thresholds.get("MIN_SERVICE_HEARINGS", 1) suggested_hearings = current_hearings + 1 @@ -80,8 +80,8 @@ class RipenessCalibrator: ), confidence="high", )) - - # Rule 2: High false negative rate → decrease MIN_STAGE_DAYS + + # Rule 2: High false negative rate -> decrease MIN_STAGE_DAYS if accuracy["false_negative_rate"] > cls.HIGH_FALSE_NEGATIVE_THRESHOLD: current_days = current_thresholds.get("MIN_STAGE_DAYS", 7) suggested_days = max(3, current_days - 2) # Don't go below 3 days @@ -96,8 +96,8 @@ class RipenessCalibrator: ), confidence="medium", )) - - # Rule 3: Low UNKNOWN rate → system too confident, add uncertainty + + # Rule 3: Low UNKNOWN rate -> system too confident, add uncertainty if accuracy["unknown_rate"] < cls.LOW_UNKNOWN_THRESHOLD: current_age = current_thresholds.get("MIN_CASE_AGE_DAYS", 14) suggested_age = current_age + 7 @@ -112,8 +112,8 @@ class RipenessCalibrator: ), confidence="medium", )) - - # Rule 4: Low RIPE precision → more conservative RIPE classification + + # Rule 4: Low RIPE precision -> more conservative RIPE classification if accuracy["ripe_precision"] < cls.LOW_RIPE_PRECISION_THRESHOLD: current_hearings = current_thresholds.get("MIN_SERVICE_HEARINGS", 1) suggested_hearings = current_hearings + 1 @@ -128,8 +128,8 @@ class RipenessCalibrator: ), confidence="high", )) - - # Rule 5: Low UNRIPE recall → missing bottlenecks + + # Rule 5: Low UNRIPE recall -> missing bottlenecks if accuracy["unripe_recall"] < cls.LOW_UNRIPE_RECALL_THRESHOLD: current_days = current_thresholds.get("MIN_STAGE_DAYS", 7) suggested_days = current_days + 3 @@ -144,19 +144,19 @@ class RipenessCalibrator: ), confidence="medium", )) - + # Deduplicate adjustments (same threshold suggested multiple times) deduplicated = cls._deduplicate_adjustments(adjustments) - + return deduplicated - + @classmethod def _deduplicate_adjustments( cls, adjustments: list[ThresholdAdjustment] ) -> list[ThresholdAdjustment]: """Deduplicate adjustments for same threshold, prefer high confidence.""" threshold_map: dict[str, ThresholdAdjustment] = {} - + for adj in adjustments: if adj.threshold_name not in threshold_map: threshold_map[adj.threshold_name] = adj @@ -164,7 +164,7 @@ class RipenessCalibrator: # Keep adjustment with higher confidence or larger change existing = threshold_map[adj.threshold_name] confidence_order = {"high": 3, "medium": 2, "low": 1} - + if confidence_order[adj.confidence] > confidence_order[existing.confidence]: threshold_map[adj.threshold_name] = adj elif confidence_order[adj.confidence] == confidence_order[existing.confidence]: @@ -173,9 +173,9 @@ class RipenessCalibrator: new_delta = abs(adj.suggested_value - adj.current_value) if new_delta > existing_delta: threshold_map[adj.threshold_name] = adj - + return list(threshold_map.values()) - + @classmethod def generate_calibration_report( cls, @@ -184,17 +184,17 @@ class RipenessCalibrator: output_path: str | None = None, ) -> str: """Generate human-readable calibration report. - + Args: metrics: RipenessMetrics with classification history adjustments: List of suggested adjustments output_path: Optional file path to save report - + Returns: Report text """ accuracy = metrics.get_accuracy_metrics() - + lines = [ "Ripeness Classifier Calibration Report", "=" * 70, @@ -209,7 +209,7 @@ class RipenessCalibrator: f" UNRIPE recall: {accuracy['unripe_recall']:.1%}", "", ] - + if not adjustments: lines.extend([ "Recommended Adjustments:", @@ -222,7 +222,7 @@ class RipenessCalibrator: "Recommended Adjustments:", "", ]) - + for i, adj in enumerate(adjustments, 1): lines.extend([ f"{i}. {adj.threshold_name}", @@ -232,7 +232,7 @@ class RipenessCalibrator: f" Reason: {adj.reason}", "", ]) - + lines.extend([ "Implementation:", " 1. Review suggested adjustments", @@ -241,16 +241,16 @@ class RipenessCalibrator: " 4. Compare new metrics with baseline", "", ]) - + report = "\n".join(lines) - + if output_path: with open(output_path, "w") as f: f.write(report) print(f"Calibration report saved to {output_path}") - + return report - + @classmethod def apply_adjustments( cls, @@ -258,22 +258,22 @@ class RipenessCalibrator: auto_apply: bool = False, ) -> dict[str, int | float]: """Apply threshold adjustments to RipenessClassifier. - + Args: adjustments: List of adjustments to apply auto_apply: If True, apply immediately; if False, return dict only - + Returns: Dictionary of new threshold values """ new_thresholds: dict[str, int | float] = {} - + for adj in adjustments: new_thresholds[adj.threshold_name] = adj.suggested_value - + if auto_apply: from scheduler.core.ripeness import RipenessClassifier RipenessClassifier.set_thresholds(new_thresholds) print(f"Applied {len(adjustments)} threshold adjustments") - + return new_thresholds diff --git a/scheduler/monitoring/ripeness_metrics.py b/scheduler/monitoring/ripeness_metrics.py index 86cbb7eda413ae37f36f6e6944bf2a986178c1e5..d4537210dc8f355b30c68d08bdfc188d099d28f5 100644 --- a/scheduler/monitoring/ripeness_metrics.py +++ b/scheduler/monitoring/ripeness_metrics.py @@ -6,7 +6,7 @@ and enable data-driven threshold calibration. from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Optional @@ -19,7 +19,7 @@ from scheduler.core.ripeness import RipenessStatus @dataclass class RipenessPrediction: """Single ripeness classification prediction and outcome.""" - + case_id: str predicted_status: RipenessStatus prediction_date: datetime @@ -31,12 +31,12 @@ class RipenessPrediction: class RipenessMetrics: """Tracks ripeness classification accuracy for feedback loop calibration.""" - + def __init__(self): """Initialize metrics tracker.""" self.predictions: dict[str, RipenessPrediction] = {} self.completed_predictions: list[RipenessPrediction] = [] - + def record_prediction( self, case_id: str, @@ -44,7 +44,7 @@ class RipenessMetrics: prediction_date: datetime, ) -> None: """Record a ripeness classification prediction. - + Args: case_id: Case identifier predicted_status: Predicted ripeness status @@ -55,7 +55,7 @@ class RipenessMetrics: predicted_status=predicted_status, prediction_date=prediction_date, ) - + def record_outcome( self, case_id: str, @@ -64,7 +64,7 @@ class RipenessMetrics: outcome_date: datetime, ) -> None: """Record actual hearing outcome for a predicted case. - + Args: case_id: Case identifier actual_outcome: Actual hearing outcome (e.g., "ADJOURNED", "ARGUMENTS") @@ -76,14 +76,14 @@ class RipenessMetrics: pred.actual_outcome = actual_outcome pred.was_adjourned = was_adjourned pred.outcome_date = outcome_date - + # Move to completed self.completed_predictions.append(pred) del self.predictions[case_id] - + def get_accuracy_metrics(self) -> dict[str, float]: """Compute classification accuracy metrics. - + Returns: Dictionary with accuracy metrics: - total_predictions: Total predictions made @@ -104,34 +104,34 @@ class RipenessMetrics: "ripe_precision": 0.0, "unripe_recall": 0.0, } - + total = len(self.completed_predictions) - + # Count predictions by status ripe_predictions = [p for p in self.completed_predictions if p.predicted_status == RipenessStatus.RIPE] unripe_predictions = [p for p in self.completed_predictions if p.predicted_status.is_unripe()] unknown_predictions = [p for p in self.completed_predictions if p.predicted_status == RipenessStatus.UNKNOWN] - + # Count actual outcomes adjourned_cases = [p for p in self.completed_predictions if p.was_adjourned] - progressed_cases = [p for p in self.completed_predictions if not p.was_adjourned] - + [p for p in self.completed_predictions if not p.was_adjourned] + # False positives: predicted RIPE but adjourned false_positives = [p for p in ripe_predictions if p.was_adjourned] false_positive_rate = len(false_positives) / len(ripe_predictions) if ripe_predictions else 0.0 - + # False negatives: predicted UNRIPE but progressed false_negatives = [p for p in unripe_predictions if not p.was_adjourned] false_negative_rate = len(false_negatives) / len(unripe_predictions) if unripe_predictions else 0.0 - + # Precision: of predicted RIPE, how many progressed? ripe_correct = [p for p in ripe_predictions if not p.was_adjourned] ripe_precision = len(ripe_correct) / len(ripe_predictions) if ripe_predictions else 0.0 - + # Recall: of actually adjourned cases, how many did we predict UNRIPE? unripe_correct = [p for p in unripe_predictions if p.was_adjourned] unripe_recall = len(unripe_correct) / len(adjourned_cases) if adjourned_cases else 0.0 - + return { "total_predictions": total + len(self.predictions), "completed_predictions": total, @@ -141,10 +141,10 @@ class RipenessMetrics: "ripe_precision": ripe_precision, "unripe_recall": unripe_recall, } - + def get_confusion_matrix(self) -> dict[str, dict[str, int]]: """Generate confusion matrix of predictions vs outcomes. - + Returns: Nested dict: predicted_status -> actual_outcome -> count """ @@ -153,7 +153,7 @@ class RipenessMetrics: "UNRIPE": {"progressed": 0, "adjourned": 0}, "UNKNOWN": {"progressed": 0, "adjourned": 0}, } - + for pred in self.completed_predictions: if pred.predicted_status == RipenessStatus.RIPE: key = "RIPE" @@ -161,15 +161,15 @@ class RipenessMetrics: key = "UNRIPE" else: key = "UNKNOWN" - + outcome_key = "adjourned" if pred.was_adjourned else "progressed" matrix[key][outcome_key] += 1 - + return matrix - + def to_dataframe(self) -> pd.DataFrame: """Export predictions to DataFrame for analysis. - + Returns: DataFrame with columns: case_id, predicted_status, prediction_date, actual_outcome, was_adjourned, outcome_date @@ -188,32 +188,32 @@ class RipenessMetrics: or (pred.predicted_status.is_unripe() and pred.was_adjourned) ), }) - + return pd.DataFrame(records) - + def save_report(self, output_path: Path) -> None: """Save accuracy report and predictions to files. - + Args: output_path: Path to output directory """ output_path.mkdir(parents=True, exist_ok=True) - + # Save metrics summary metrics = self.get_accuracy_metrics() metrics_df = pd.DataFrame([metrics]) metrics_df.to_csv(output_path / "ripeness_accuracy.csv", index=False) - + # Save confusion matrix matrix = self.get_confusion_matrix() matrix_df = pd.DataFrame(matrix).T matrix_df.to_csv(output_path / "ripeness_confusion_matrix.csv") - + # Save detailed predictions if self.completed_predictions: predictions_df = self.to_dataframe() predictions_df.to_csv(output_path / "ripeness_predictions.csv", index=False) - + # Generate human-readable report report_lines = [ "Ripeness Classification Accuracy Report", @@ -235,7 +235,7 @@ class RipenessMetrics: "", "Interpretation:", ] - + # Add interpretation if metrics['false_positive_rate'] > 0.20: report_lines.append(" - HIGH false positive rate: Consider increasing MIN_SERVICE_HEARINGS") @@ -247,8 +247,8 @@ class RipenessMetrics: report_lines.append(" - GOOD RIPE precision: Most RIPE predictions are correct") if metrics['unripe_recall'] < 0.60: report_lines.append(" - LOW UNRIPE recall: Missing many bottlenecks, refine detection") - + report_text = "\n".join(report_lines) (output_path / "ripeness_report.txt").write_text(report_text) - + print(f"Ripeness accuracy report saved to {output_path}") diff --git a/scheduler/output/cause_list.py b/scheduler/output/cause_list.py index 9c825421f07efb25af44c3f3935a6d145c6ed0be..84ed4576cc2e9a2971a19b7c790e7e7d6dcdd4f4 100644 --- a/scheduler/output/cause_list.py +++ b/scheduler/output/cause_list.py @@ -2,190 +2,216 @@ Generates machine-readable cause lists from simulation results with explainability. """ + from pathlib import Path -from typing import Optional + import pandas as pd -from datetime import datetime class CauseListGenerator: """Generates daily cause lists with explanations for scheduling decisions.""" - + def __init__(self, events_file: Path): """Initialize with simulation events CSV. - + Args: events_file: Path to events.csv from simulation """ self.events_file = events_file self.events = pd.read_csv(events_file) - + def generate_daily_lists(self, output_dir: Path) -> Path: """Generate daily cause lists for entire simulation period. - + Args: output_dir: Directory to save cause list CSVs - + Returns: Path to compiled cause list CSV """ output_dir.mkdir(parents=True, exist_ok=True) - + # Filter for 'scheduled' events (actual column name is 'type') - scheduled = self.events[self.events['type'] == 'scheduled'].copy() - + scheduled = self.events[self.events["type"] == "scheduled"].copy() + if scheduled.empty: raise ValueError("No 'scheduled' events found in simulation") - + # Parse date column (handle different formats) - scheduled['date'] = pd.to_datetime(scheduled['date']) - + scheduled["date"] = pd.to_datetime(scheduled["date"]) + # Add sequence number per courtroom per day # Sort by date, courtroom, then case_id for consistency - scheduled = scheduled.sort_values(['date', 'courtroom_id', 'case_id']) - scheduled['sequence_number'] = scheduled.groupby(['date', 'courtroom_id']).cumcount() + 1 - + scheduled = scheduled.sort_values(["date", "courtroom_id", "case_id"]) + scheduled["sequence_number"] = scheduled.groupby(["date", "courtroom_id"]).cumcount() + 1 + + # Derive priority score/label if available + # Some historical simulations may not have 'priority_score' — handle gracefully + has_priority_score = "priority_score" in scheduled.columns + if has_priority_score: + pr_score = scheduled["priority_score"].astype(float) + + # Map numeric score to categorical buckets for UI editing convenience + def _bucketize(score: float) -> str: + if pd.isna(score): + return "MEDIUM" + if score >= 0.6: + return "HIGH" + if score >= 0.4: + return "MEDIUM" + return "LOW" + + pr_label = pr_score.map(_bucketize) + else: + # Defaults when score is missing + pr_score = pd.Series([float("nan")] * len(scheduled)) + pr_label = pd.Series(["MEDIUM"] * len(scheduled)) + # Build cause list structure - cause_list = pd.DataFrame({ - 'Date': scheduled['date'].dt.strftime('%Y-%m-%d'), - 'Courtroom_ID': scheduled['courtroom_id'].fillna(1).astype(int), - 'Case_ID': scheduled['case_id'], - 'Case_Type': scheduled['case_type'], - 'Stage': scheduled['stage'], - 'Purpose': 'HEARING', # Default purpose - 'Sequence_Number': scheduled['sequence_number'], - 'Explanation': scheduled.apply(self._generate_explanation, axis=1) - }) - + cause_list = pd.DataFrame( + { + "Date": scheduled["date"].dt.strftime("%Y-%m-%d"), + "Courtroom_ID": scheduled["courtroom_id"].fillna(1).astype(int), + "Case_ID": scheduled["case_id"], + "Case_Type": scheduled["case_type"], + "Stage": scheduled["stage"], + "Purpose": "HEARING", # Default purpose + "Sequence_Number": scheduled["sequence_number"], + "Priority_Score": pr_score, + "Priority": pr_label, + "Explanation": scheduled.apply(self._generate_explanation, axis=1), + } + ) + # Save compiled cause list compiled_path = output_dir / "compiled_cause_list.csv" cause_list.to_csv(compiled_path, index=False) - + # Generate daily summaries - daily_summary = cause_list.groupby('Date').agg({ - 'Case_ID': 'count', - 'Courtroom_ID': 'nunique' - }).rename(columns={ - 'Case_ID': 'Total_Hearings', - 'Courtroom_ID': 'Active_Courtrooms' - }) - + daily_summary = ( + cause_list.groupby("Date") + .agg({"Case_ID": "count", "Courtroom_ID": "nunique"}) + .rename(columns={"Case_ID": "Total_Hearings", "Courtroom_ID": "Active_Courtrooms"}) + ) + summary_path = output_dir / "daily_summaries.csv" daily_summary.to_csv(summary_path) - + print(f"Generated cause list: {compiled_path}") print(f" Total hearings: {len(cause_list):,}") print(f" Date range: {cause_list['Date'].min()} to {cause_list['Date'].max()}") print(f" Unique cases: {cause_list['Case_ID'].nunique():,}") print(f"Daily summaries: {summary_path}") - + return compiled_path - + def _generate_explanation(self, row: pd.Series) -> str: """Generate human-readable explanation for scheduling decision. - + Args: row: Row from scheduled events DataFrame - + Returns: Explanation string """ parts = [] - + # Case type urgency (heuristic) - case_type = row.get('case_type', '') - if case_type in ['CCC', 'CP', 'CMP']: + case_type = row.get("case_type", "") + if case_type in ["CCC", "CP", "CMP"]: parts.append("HIGH URGENCY (criminal)") - elif case_type in ['CA', 'CRP']: + elif case_type in ["CA", "CRP"]: parts.append("MEDIUM urgency") else: parts.append("standard urgency") - + # Stage information - stage = row.get('stage', '') + stage = row.get("stage", "") if isinstance(stage, str): - if 'JUDGMENT' in stage or 'ORDER' in stage: + if "JUDGMENT" in stage or "ORDER" in stage: parts.append("ready for orders/judgment") - elif 'ADMISSION' in stage: + elif "ADMISSION" in stage: parts.append("admission stage") - + # Courtroom allocation - courtroom = row.get('courtroom_id', 1) + courtroom = row.get("courtroom_id", 1) try: parts.append(f"assigned to Courtroom {int(courtroom)}") except Exception: parts.append("courtroom assigned") - + # Additional details - detail = row.get('detail') + detail = row.get("detail") if isinstance(detail, str) and detail: parts.append(detail) - + return " | ".join(parts) if parts else "Scheduled for hearing" - + def generate_no_case_left_behind_report(self, all_cases_file: Path, output_file: Path): """Verify no case was left unscheduled for too long. - + Args: all_cases_file: Path to CSV with all cases in simulation output_file: Path to save verification report """ - scheduled = self.events[self.events['event_type'] == 'HEARING_SCHEDULED'].copy() - scheduled['date'] = pd.to_datetime(scheduled['date']) - + scheduled = self.events[self.events["event_type"] == "HEARING_SCHEDULED"].copy() + scheduled["date"] = pd.to_datetime(scheduled["date"]) + # Get unique cases scheduled - scheduled_cases = set(scheduled['case_id'].unique()) - + scheduled_cases = set(scheduled["case_id"].unique()) + # Load all cases all_cases = pd.read_csv(all_cases_file) - all_case_ids = set(all_cases['case_id'].astype(str).unique()) - + all_case_ids = set(all_cases["case_id"].astype(str).unique()) + # Find never-scheduled cases never_scheduled = all_case_ids - scheduled_cases - + # Calculate gaps between hearings per case - scheduled['date'] = pd.to_datetime(scheduled['date']) - scheduled = scheduled.sort_values(['case_id', 'date']) - scheduled['days_since_last'] = scheduled.groupby('case_id')['date'].diff().dt.days - + scheduled["date"] = pd.to_datetime(scheduled["date"]) + scheduled = scheduled.sort_values(["case_id", "date"]) + scheduled["days_since_last"] = scheduled.groupby("case_id")["date"].diff().dt.days + # Statistics coverage = len(scheduled_cases) / len(all_case_ids) * 100 - max_gap = scheduled['days_since_last'].max() - avg_gap = scheduled['days_since_last'].mean() - - report = pd.DataFrame({ - 'Metric': [ - 'Total Cases', - 'Cases Scheduled At Least Once', - 'Coverage (%)', - 'Cases Never Scheduled', - 'Max Gap Between Hearings (days)', - 'Avg Gap Between Hearings (days)', - 'Cases with Gap > 60 days', - 'Cases with Gap > 90 days' - ], - 'Value': [ - len(all_case_ids), - len(scheduled_cases), - f"{coverage:.2f}", - len(never_scheduled), - f"{max_gap:.0f}" if pd.notna(max_gap) else "N/A", - f"{avg_gap:.1f}" if pd.notna(avg_gap) else "N/A", - (scheduled['days_since_last'] > 60).sum(), - (scheduled['days_since_last'] > 90).sum() - ] - }) - + max_gap = scheduled["days_since_last"].max() + avg_gap = scheduled["days_since_last"].mean() + + report = pd.DataFrame( + { + "Metric": [ + "Total Cases", + "Cases Scheduled At Least Once", + "Coverage (%)", + "Cases Never Scheduled", + "Max Gap Between Hearings (days)", + "Avg Gap Between Hearings (days)", + "Cases with Gap > 60 days", + "Cases with Gap > 90 days", + ], + "Value": [ + len(all_case_ids), + len(scheduled_cases), + f"{coverage:.2f}", + len(never_scheduled), + f"{max_gap:.0f}" if pd.notna(max_gap) else "N/A", + f"{avg_gap:.1f}" if pd.notna(avg_gap) else "N/A", + (scheduled["days_since_last"] > 60).sum(), + (scheduled["days_since_last"] > 90).sum(), + ], + } + ) + report.to_csv(output_file, index=False) print(f"\nNo-Case-Left-Behind Verification Report: {output_file}") print(report.to_string(index=False)) - + return report def generate_cause_lists_from_sweep(sweep_dir: Path, scenario: str, policy: str): """Generate cause lists from comprehensive sweep results. - + Args: sweep_dir: Path to sweep results directory scenario: Scenario name (e.g., 'baseline_10k') @@ -193,40 +219,39 @@ def generate_cause_lists_from_sweep(sweep_dir: Path, scenario: str, policy: str) """ results_dir = sweep_dir / f"{scenario}_{policy}" events_file = results_dir / "events.csv" - + if not events_file.exists(): raise FileNotFoundError(f"Events file not found: {events_file}") - - output_dir = results_dir / "cause_lists" - + + # Save outputs directly in the results directory (no subfolder) + output_dir = results_dir + generator = CauseListGenerator(events_file) cause_list_path = generator.generate_daily_lists(output_dir) - + # Generate no-case-left-behind report if cases file exists # This would need the original cases dataset - skip for now # cases_file = sweep_dir / "datasets" / f"{scenario}_cases.csv" # if cases_file.exists(): # report_path = output_dir / "no_case_left_behind.csv" # generator.generate_no_case_left_behind_report(cases_file, report_path) - + return cause_list_path if __name__ == "__main__": # Example usage sweep_dir = Path("data/comprehensive_sweep_20251120_184341") - + # Generate for our algorithm - print("="*70) + print("=" * 70) print("Generating Cause Lists for Readiness Algorithm (Our Algorithm)") - print("="*70) - + print("=" * 70) + cause_list = generate_cause_lists_from_sweep( - sweep_dir=sweep_dir, - scenario="baseline_10k", - policy="readiness" + sweep_dir=sweep_dir, scenario="baseline_10k", policy="readiness" ) - - print("\n" + "="*70) + + print("\n" + "=" * 70) print("Cause List Generation Complete") - print("="*70) + print("=" * 70) diff --git a/scheduler/simulation/allocator.py b/scheduler/simulation/allocator.py index 7e2e2c7578d6c14f26406f50a3323785f91b223e..9dc0dd95184f006888d9882a8f4b311a94a40a28 100644 --- a/scheduler/simulation/allocator.py +++ b/scheduler/simulation/allocator.py @@ -1,5 +1,4 @@ -""" -Dynamic courtroom allocation system. +"""Dynamic courtroom allocation system. Allocates cases across multiple courtrooms using configurable strategies: - LOAD_BALANCED: Distributes cases evenly across courtrooms @@ -53,8 +52,7 @@ class CourtroomState: class CourtroomAllocator: - """ - Dynamically allocates cases to courtrooms using load balancing. + """Dynamically allocates cases to courtrooms using load balancing. Ensures fair distribution of workload across courtrooms while respecting capacity constraints. Future versions may add judge specialization matching @@ -67,8 +65,7 @@ class CourtroomAllocator: per_courtroom_capacity: int = 10, strategy: AllocationStrategy = AllocationStrategy.LOAD_BALANCED, ): - """ - Initialize allocator. + """Initialize allocator. Args: num_courtrooms: Number of courtrooms to allocate across @@ -80,9 +77,7 @@ class CourtroomAllocator: self.strategy = strategy # Initialize courtroom states - self.courtrooms = { - i: CourtroomState(courtroom_id=i) for i in range(1, num_courtrooms + 1) - } + self.courtrooms = {i: CourtroomState(courtroom_id=i) for i in range(1, num_courtrooms + 1)} # Metrics tracking self.daily_loads: dict[date, dict[int, int]] = {} # date -> {courtroom_id -> load} @@ -90,8 +85,7 @@ class CourtroomAllocator: self.capacity_rejections: int = 0 # Cases that couldn't be allocated def allocate(self, cases: list[Case], current_date: date) -> dict[str, int]: - """ - Allocate cases to courtrooms for a given date. + """Allocate cases to courtrooms for a given date. Args: cases: List of cases to allocate (already prioritized by caller) @@ -116,7 +110,11 @@ class CourtroomAllocator: continue # Track if courtroom changed (only count actual switches, not initial assignments) - if case.courtroom_id is not None and case.courtroom_id != 0 and case.courtroom_id != courtroom_id: + if ( + case.courtroom_id is not None + and case.courtroom_id != 0 + and case.courtroom_id != courtroom_id + ): self.allocation_changes += 1 # Assign case to courtroom @@ -132,8 +130,7 @@ class CourtroomAllocator: return allocations def _find_best_courtroom(self, case: Case) -> int | None: - """ - Find best courtroom for a case based on allocation strategy. + """Find best courtroom for a case based on allocation strategy. Args: case: Case to allocate @@ -165,13 +162,17 @@ class CourtroomAllocator: return min(available, key=lambda x: x[1].daily_load)[0] def _find_type_affinity_courtroom(self, case: Case) -> int | None: - """Find courtroom with most similar case type history (future enhancement).""" - # For now, fall back to load balancing - # Future: score courtrooms by case_type_distribution similarity + """Find courtroom with most similar case type history. + + Currently uses load balancing. Can be enhanced with case type distribution scoring. + """ return self._find_least_loaded_courtroom() def _find_continuity_courtroom(self, case: Case) -> int | None: - """Try to keep case in same courtroom as previous hearing (future enhancement).""" + """Keep case in same courtroom as previous hearing when possible. + + Maintains courtroom continuity if capacity available, otherwise uses load balancing. + """ # If case already has courtroom assignment and it has capacity, keep it there if case.courtroom_id is not None: courtroom = self.courtrooms.get(case.courtroom_id) @@ -182,8 +183,7 @@ class CourtroomAllocator: return self._find_least_loaded_courtroom() def get_utilization_stats(self) -> dict: - """ - Calculate courtroom utilization statistics. + """Calculate courtroom utilization statistics. Returns: Dictionary with utilization metrics diff --git a/scheduler/simulation/engine.py b/scheduler/simulation/engine.py index d09b5bc0de6be3f78dead7e7743ff69d0377c759..4702f855daf67443738e23f376ce8efd86f186c1 100644 --- a/scheduler/simulation/engine.py +++ b/scheduler/simulation/engine.py @@ -8,33 +8,34 @@ This engine simulates daily operations over working days: This is intentionally lightweight; OR-Tools optimization and richer policies will integrate later. """ + from __future__ import annotations -from dataclasses import dataclass -from pathlib import Path import csv +import random import time +from dataclasses import dataclass from datetime import date, timedelta -from typing import Dict, List, Tuple -import random +from pathlib import Path +from typing import Dict, List +from scheduler.core.algorithm import SchedulingAlgorithm, SchedulingResult from scheduler.core.case import Case, CaseStatus from scheduler.core.courtroom import Courtroom -from scheduler.core.ripeness import RipenessClassifier, RipenessStatus -from scheduler.core.algorithm import SchedulingAlgorithm, SchedulingResult -from scheduler.utils.calendar import CourtCalendar -from scheduler.data.param_loader import load_parameters -from scheduler.simulation.events import EventWriter -from scheduler.simulation.policies import get_policy -from scheduler.simulation.allocator import CourtroomAllocator, AllocationStrategy +from scheduler.core.ripeness import RipenessClassifier from scheduler.data.config import ( + ANNUAL_FILING_RATE, COURTROOMS, DEFAULT_DAILY_CAPACITY, MIN_GAP_BETWEEN_HEARINGS, - TERMINAL_STAGES, - ANNUAL_FILING_RATE, MONTHLY_SEASONALITY, + TERMINAL_STAGES, ) +from scheduler.data.param_loader import load_parameters +from scheduler.simulation.allocator import AllocationStrategy, CourtroomAllocator +from scheduler.simulation.events import EventWriter +from scheduler.simulation.policies import get_policy +from scheduler.utils.calendar import CourtCalendar @dataclass @@ -44,29 +45,13 @@ class CourtSimConfig: seed: int = 42 courtrooms: int = COURTROOMS daily_capacity: int = DEFAULT_DAILY_CAPACITY - policy: str = "readiness" # fifo|age|readiness|rl + policy: str = "readiness" # fifo|age|readiness duration_percentile: str = "median" # median|p90 log_dir: Path | None = None # if set, write metrics and suggestions write_suggestions: bool = False # if True, write daily suggestion CSVs (slow) - rl_agent_path: Path | None = None # Required if policy="rl" - + def __post_init__(self): """Validate configuration parameters.""" - # Validate RL policy requirements - if self.policy == "rl": - if self.rl_agent_path is None: - raise ValueError( - "RL policy requires 'rl_agent_path' parameter. " - "Train an agent first and pass the model file path." - ) - if not isinstance(self.rl_agent_path, Path): - self.rl_agent_path = Path(self.rl_agent_path) - if not self.rl_agent_path.exists(): - raise FileNotFoundError( - f"RL agent model not found at {self.rl_agent_path}. " - "Train the agent first or provide correct path." - ) - # Ensure log_dir is Path if provided if self.log_dir is not None and not isinstance(self.log_dir, Path): self.log_dir = Path(self.log_dir) @@ -90,15 +75,9 @@ class CourtSim: self.cases = cases self.calendar = CourtCalendar() self.params = load_parameters() - - # Initialize policy with RL agent path if needed - policy_kwargs = {} - if self.cfg.policy == "rl": - if not self.cfg.rl_agent_path: - raise ValueError("RL policy requires rl_agent_path in CourtSimConfig") - policy_kwargs["agent_path"] = self.cfg.rl_agent_path - - self.policy = get_policy(self.cfg.policy, **policy_kwargs) + + # Initialize policy + self.policy = get_policy(self.cfg.policy) random.seed(self.cfg.seed) # month working-days cache self._month_working_cache: Dict[tuple, int] = {} @@ -114,13 +93,27 @@ class CourtSim: self._metrics_path = self._log_dir / "metrics.csv" with self._metrics_path.open("w", newline="", encoding="utf-8") as f: w = csv.writer(f) - w.writerow(["date", "total_cases", "scheduled", "heard", "adjourned", "disposals", "utilization"]) + w.writerow( + [ + "date", + "total_cases", + "scheduled", + "heard", + "adjourned", + "disposals", + "utilization", + ] + ) # events self._events_path = self._log_dir / "events.csv" self._events = EventWriter(self._events_path) # resources - self.rooms = [Courtroom(courtroom_id=i + 1, judge_id=f"J{i+1:03d}", daily_capacity=self.cfg.daily_capacity) - for i in range(self.cfg.courtrooms)] + self.rooms = [ + Courtroom( + courtroom_id=i + 1, judge_id=f"J{i + 1:03d}", daily_capacity=self.cfg.daily_capacity + ) + for i in range(self.cfg.courtrooms) + ] # stats self._hearings_total = 0 self._hearings_heard = 0 @@ -138,13 +131,11 @@ class CourtSim: self.allocator = CourtroomAllocator( num_courtrooms=self.cfg.courtrooms, per_courtroom_capacity=self.cfg.daily_capacity, - strategy=AllocationStrategy.LOAD_BALANCED + strategy=AllocationStrategy.LOAD_BALANCED, ) # scheduling algorithm (NEW - replaces inline logic) self.algorithm = SchedulingAlgorithm( - policy=self.policy, - allocator=self.allocator, - min_gap_days=MIN_GAP_BETWEEN_HEARINGS + policy=self.policy, allocator=self.allocator, min_gap_days=MIN_GAP_BETWEEN_HEARINGS ) # --- helpers ------------------------------------------------------------- @@ -153,7 +144,9 @@ class CourtSim: # Set stage_ready relative to last hearing + typical stage duration # This allows cases to progress naturally from simulation start for c in self.cases: - dur = int(round(self.params.get_stage_duration(c.current_stage, self.cfg.duration_percentile))) + dur = int( + round(self.params.get_stage_duration(c.current_stage, self.cfg.duration_percentile)) + ) dur = max(1, dur) # If case has hearing history, use last hearing date as reference if c.last_hearing_date: @@ -180,7 +173,7 @@ class CourtSim: def _check_disposal_at_hearing(self, case: Case, current: date) -> bool: """Check if case disposes at this hearing based on type-specific maturity. - + Logic: - Each case type has a median disposal duration (e.g., RSA=695d, CCC=93d). - Disposal probability increases as case approaches/exceeds this median. @@ -191,7 +184,7 @@ class CourtSim: disposal_capable_stages = ["ORDERS / JUDGMENT", "ARGUMENTS", "ADMISSION", "FINAL DISPOSAL"] if case.current_stage not in disposal_capable_stages: return False - + # 2. Get case type statistics try: stats = self.params.get_case_type_stats(case.case_type) @@ -201,7 +194,7 @@ class CourtSim: # Fallback for unknown types expected_days = 365.0 expected_hearings = 5.0 - + # 3. Calculate maturity factors # Age factor: non-linear increase as we approach median duration maturity = case.age_days / max(1.0, expected_days) @@ -213,63 +206,69 @@ class CourtSim: age_prob = 0.10 + 0.10 * (maturity - 0.8) # Higher prob around median else: age_prob = 0.25 # Cap at 25% for overdue cases - + # Hearing factor: need sufficient hearings hearing_factor = min(case.hearing_count / max(1.0, expected_hearings), 1.5) - + # Stage factor stage_prob = 1.0 if case.current_stage == "ADMISSION": stage_prob = 0.5 # Less likely to dispose in admission than orders elif case.current_stage == "FINAL DISPOSAL": stage_prob = 2.0 # Very likely - + # 4. Final probability check final_prob = age_prob * hearing_factor * stage_prob # Cap at reasonable max per hearing to avoid sudden mass disposals final_prob = min(final_prob, 0.30) - + return random.random() < final_prob # --- ripeness evaluation (periodic) ------------------------------------- def _evaluate_ripeness(self, current: date) -> None: """Periodically re-evaluate ripeness for all active cases. - + This detects when bottlenecks are resolved or new ones emerge. """ for c in self.cases: if c.status == CaseStatus.DISPOSED: continue - + # Calculate current ripeness prev_status = c.ripeness_status new_status = RipenessClassifier.classify(c, current) - + # Track transitions (compare string values) if new_status.value != prev_status: self._ripeness_transitions += 1 - + # Update case status if new_status.is_ripe(): c.mark_ripe(current) self._events.write( - current, "ripeness_change", c.case_id, - case_type=c.case_type, stage=c.current_stage, - detail=f"UNRIPE→RIPE (was {prev_status.value})" + current, + "ripeness_change", + c.case_id, + case_type=c.case_type, + stage=c.current_stage, + detail=f"UNRIPE->RIPE (was {prev_status.value})", ) else: reason = RipenessClassifier.get_ripeness_reason(new_status) c.mark_unripe(new_status, reason, current) self._events.write( - current, "ripeness_change", c.case_id, - case_type=c.case_type, stage=c.current_stage, - detail=f"RIPE→UNRIPE ({new_status.value}: {reason})" + current, + "ripeness_change", + c.case_id, + case_type=c.case_type, + stage=c.current_stage, + detail=f"RIPE->UNRIPE ({new_status.value}: {reason})", ) # --- daily scheduling policy -------------------------------------------- def _choose_cases_for_day(self, current: date) -> SchedulingResult: """Use SchedulingAlgorithm to schedule cases for the day. - + This replaces the previous inline scheduling logic with a call to the standalone algorithm module. The algorithm handles: - Ripeness filtering @@ -283,7 +282,7 @@ class CourtSim: if days_since_eval >= 7: self._evaluate_ripeness(current) self._last_ripeness_eval = current - + # Call algorithm to schedule day # Note: No overrides in baseline simulation - that's for override demonstration runs result = self.algorithm.schedule_day( @@ -291,12 +290,12 @@ class CourtSim: courtrooms=self.rooms, current_date=current, overrides=None, # No overrides in baseline simulation - preferences=None # No judge preferences in baseline simulation + preferences=None, # No judge preferences in baseline simulation ) - + # Update stats from algorithm result self._unripe_filtered += result.ripeness_filtered - + return result # --- main loop ----------------------------------------------------------- @@ -307,7 +306,9 @@ class CourtSim: # scale by working days in month key = (current.year, current.month) if key not in self._month_working_cache: - self._month_working_cache[key] = len(self.calendar.get_working_days_in_month(current.year, current.month)) + self._month_working_cache[key] = len( + self.calendar.get_working_days_in_month(current.year, current.month) + ) month_working = self._month_working_cache[key] if month_working == 0: return 0 @@ -319,14 +320,31 @@ class CourtSim: for i in range(n): cid = f"NEW/{current.year}/{start_idx + i + 1:05d}" ct = "RSA" # lightweight: pick a plausible type; could sample from distribution - case = Case(case_id=cid, case_type=ct, filed_date=current, current_stage="ADMISSION", is_urgent=False) + case = Case( + case_id=cid, + case_type=ct, + filed_date=current, + current_stage="ADMISSION", + is_urgent=False, + ) self.cases.append(case) # stage gating for new case - dur = int(round(self.params.get_stage_duration(case.current_stage, self.cfg.duration_percentile))) + dur = int( + round( + self.params.get_stage_duration(case.current_stage, self.cfg.duration_percentile) + ) + ) dur = max(1, dur) self._stage_ready[case.case_id] = current + timedelta(days=dur) # event - self._events.write(current, "filing", case.case_id, case_type=case.case_type, stage=case.current_stage, detail="new_filing") + self._events.write( + current, + "filing", + case.case_id, + case_type=case.case_type, + stage=case.current_stage, + detail="new_filing", + ) def _day_process(self, current: date): # schedule @@ -346,28 +364,43 @@ class CourtSim: sugg_path = self._log_dir / f"suggestions_{current.isoformat()}.csv" sf = sugg_path.open("w", newline="") sw = csv.writer(sf) - sw.writerow(["case_id", "courtroom_id", "policy", "age_days", "readiness_score", "urgent", "stage", "days_since_last_hearing", "stage_ready_date"]) + sw.writerow( + [ + "case_id", + "courtroom_id", + "policy", + "age_days", + "readiness_score", + "urgent", + "stage", + "days_since_last_hearing", + "stage_ready_date", + ] + ) for room in self.rooms: for case in result.scheduled_cases.get(room.courtroom_id, []): # Skip if case already disposed (safety check) if case.status == CaseStatus.DISPOSED: continue - + if room.schedule_case(current, case.case_id): # Mark case as scheduled (for no-case-left-behind tracking) case.mark_scheduled(current) - + # Calculate adjournment boost for logging import math + adj_boost = 0.0 if case.status == CaseStatus.ADJOURNED and case.hearing_count > 0: adj_boost = math.exp(-case.days_since_last_hearing / 21) - + # Log with full decision metadata self._events.write( - current, "scheduled", case.case_id, - case_type=case.case_type, - stage=case.current_stage, + current, + "scheduled", + case.case_id, + case_type=case.case_type, + stage=case.current_stage, courtroom_id=room.courtroom_id, priority_score=case.get_priority_score(), age_days=case.age_days, @@ -375,32 +408,50 @@ class CourtSim: is_urgent=case.is_urgent, adj_boost=adj_boost, ripeness_status=case.ripeness_status, - days_since_hearing=case.days_since_last_hearing + days_since_hearing=case.days_since_last_hearing, ) day_total += 1 self._hearings_total += 1 # log suggestive rationale if sw: - sw.writerow([ - case.case_id, - room.courtroom_id, - self.cfg.policy, - case.age_days, - f"{case.readiness_score:.3f}", - int(case.is_urgent), - case.current_stage, - case.days_since_last_hearing, - self._stage_ready.get(case.case_id, current).isoformat(), - ]) + sw.writerow( + [ + case.case_id, + room.courtroom_id, + self.cfg.policy, + case.age_days, + f"{case.readiness_score:.3f}", + int(case.is_urgent), + case.current_stage, + case.days_since_last_hearing, + self._stage_ready.get(case.case_id, current).isoformat(), + ] + ) # outcome if self._sample_adjournment(case.current_stage, case.case_type): case.record_hearing(current, was_heard=False, outcome="adjourned") - self._events.write(current, "outcome", case.case_id, case_type=case.case_type, stage=case.current_stage, courtroom_id=room.courtroom_id, detail="adjourned") + self._events.write( + current, + "outcome", + case.case_id, + case_type=case.case_type, + stage=case.current_stage, + courtroom_id=room.courtroom_id, + detail="adjourned", + ) self._hearings_adjourned += 1 else: case.record_hearing(current, was_heard=True, outcome="heard") day_heard += 1 - self._events.write(current, "outcome", case.case_id, case_type=case.case_type, stage=case.current_stage, courtroom_id=room.courtroom_id, detail="heard") + self._events.write( + current, + "outcome", + case.case_id, + case_type=case.case_type, + stage=case.current_stage, + courtroom_id=room.courtroom_id, + detail="heard", + ) self._hearings_heard += 1 # stage transition (duration-gated) disposed = False @@ -409,37 +460,84 @@ class CourtSim: case.status = CaseStatus.DISPOSED case.disposal_date = current self._disposals += 1 - self._events.write(current, "disposed", case.case_id, case_type=case.case_type, stage=case.current_stage, detail="natural_disposal") + self._events.write( + current, + "disposed", + case.case_id, + case_type=case.case_type, + stage=case.current_stage, + detail="natural_disposal", + ) disposed = True - + if not disposed and current >= self._stage_ready.get(case.case_id, current): next_stage = self._sample_next_stage(case.current_stage) # apply transition prev_stage = case.current_stage case.progress_to_stage(next_stage, current) - self._events.write(current, "stage_change", case.case_id, case_type=case.case_type, stage=next_stage, detail=f"from:{prev_stage}") + self._events.write( + current, + "stage_change", + case.case_id, + case_type=case.case_type, + stage=next_stage, + detail=f"from:{prev_stage}", + ) # Explicit stage-based disposal (rare but possible) - if not disposed and (case.status == CaseStatus.DISPOSED or next_stage in TERMINAL_STAGES): + if not disposed and ( + case.status == CaseStatus.DISPOSED or next_stage in TERMINAL_STAGES + ): self._disposals += 1 - self._events.write(current, "disposed", case.case_id, case_type=case.case_type, stage=next_stage, detail="case_disposed") + self._events.write( + current, + "disposed", + case.case_id, + case_type=case.case_type, + stage=next_stage, + detail="case_disposed", + ) disposed = True # set next stage ready date if not disposed: - dur = int(round(self.params.get_stage_duration(case.current_stage, self.cfg.duration_percentile))) + dur = int( + round( + self.params.get_stage_duration( + case.current_stage, self.cfg.duration_percentile + ) + ) + ) dur = max(1, dur) self._stage_ready[case.case_id] = current + timedelta(days=dur) elif not disposed: # not allowed to leave stage yet; extend readiness window to avoid perpetual eligibility - dur = int(round(self.params.get_stage_duration(case.current_stage, self.cfg.duration_percentile))) + dur = int( + round( + self.params.get_stage_duration( + case.current_stage, self.cfg.duration_percentile + ) + ) + ) dur = max(1, dur) - self._stage_ready[case.case_id] = self._stage_ready[case.case_id] # unchanged + self._stage_ready[case.case_id] = self._stage_ready[ + case.case_id + ] # unchanged room.record_daily_utilization(current, day_heard) # write metrics row total_cases = sum(1 for c in self.cases if c.status != CaseStatus.DISPOSED) util = (day_total / capacity_today) if capacity_today else 0.0 with self._metrics_path.open("a", newline="", encoding="utf-8") as f: w = csv.writer(f) - w.writerow([current.isoformat(), total_cases, day_total, day_heard, day_total - day_heard, self._disposals, f"{util:.4f}"]) + w.writerow( + [ + current.isoformat(), + total_cases, + day_total, + day_heard, + day_total - day_heard, + self._disposals, + f"{util:.4f}", + ] + ) if sf: sf.close() # flush buffered events once per day to minimize I/O @@ -449,57 +547,75 @@ class CourtSim: def run(self) -> CourtSimResult: # derive working days sequence end_guess = self.cfg.start + timedelta(days=self.cfg.days + 60) # pad for weekends/holidays - working_days = self.calendar.generate_court_calendar(self.cfg.start, end_guess)[: self.cfg.days] + working_days = self.calendar.generate_court_calendar(self.cfg.start, end_guess)[ + : self.cfg.days + ] for d in working_days: self._day_process(d) # final flush (should be no-op if flushed daily) to ensure buffers are empty self._events.flush() util = (self._hearings_total / self._capacity_offered) if self._capacity_offered else 0.0 - + # Generate ripeness summary active_cases = [c for c in self.cases if c.status != CaseStatus.DISPOSED] ripeness_dist = {} for c in active_cases: status = c.ripeness_status # Already a string ripeness_dist[status] = ripeness_dist.get(status, 0) + 1 - - print(f"\n=== Ripeness Summary ===") + + print("\n=== Ripeness Summary ===") print(f"Total ripeness transitions: {self._ripeness_transitions}") print(f"Cases filtered (unripe): {self._unripe_filtered}") - print(f"\nFinal ripeness distribution:") + print("\nFinal ripeness distribution:") for status, count in sorted(ripeness_dist.items()): pct = (count / len(active_cases) * 100) if active_cases else 0 print(f" {status}: {count} ({pct:.1f}%)") - + # Generate courtroom allocation summary print(f"\n{self.allocator.get_courtroom_summary()}") - + # Generate comprehensive case status breakdown total_cases = len(self.cases) disposed_cases = [c for c in self.cases if c.status == CaseStatus.DISPOSED] scheduled_at_least_once = [c for c in self.cases if c.last_scheduled_date is not None] never_scheduled = [c for c in self.cases if c.last_scheduled_date is None] - scheduled_but_not_disposed = [c for c in scheduled_at_least_once if c.status != CaseStatus.DISPOSED] - - print(f"\n=== Case Status Breakdown ===") + scheduled_but_not_disposed = [ + c for c in scheduled_at_least_once if c.status != CaseStatus.DISPOSED + ] + + print("\n=== Case Status Breakdown ===") print(f"Total cases in system: {total_cases:,}") - print(f"\nScheduling outcomes:") - print(f" Scheduled at least once: {len(scheduled_at_least_once):,} ({len(scheduled_at_least_once)/total_cases*100:.1f}%)") - print(f" - Disposed: {len(disposed_cases):,} ({len(disposed_cases)/total_cases*100:.1f}%)") - print(f" - Active (not disposed): {len(scheduled_but_not_disposed):,} ({len(scheduled_but_not_disposed)/total_cases*100:.1f}%)") - print(f" Never scheduled: {len(never_scheduled):,} ({len(never_scheduled)/total_cases*100:.1f}%)") - + print("\nScheduling outcomes:") + print( + f" Scheduled at least once: {len(scheduled_at_least_once):,} ({len(scheduled_at_least_once) / total_cases * 100:.1f}%)" + ) + print( + f" - Disposed: {len(disposed_cases):,} ({len(disposed_cases) / total_cases * 100:.1f}%)" + ) + print( + f" - Active (not disposed): {len(scheduled_but_not_disposed):,} ({len(scheduled_but_not_disposed) / total_cases * 100:.1f}%)" + ) + print( + f" Never scheduled: {len(never_scheduled):,} ({len(never_scheduled) / total_cases * 100:.1f}%)" + ) + if scheduled_at_least_once: - avg_hearings = sum(c.hearing_count for c in scheduled_at_least_once) / len(scheduled_at_least_once) + avg_hearings = sum(c.hearing_count for c in scheduled_at_least_once) / len( + scheduled_at_least_once + ) print(f"\nAverage hearings per scheduled case: {avg_hearings:.1f}") - + if disposed_cases: - avg_hearings_to_disposal = sum(c.hearing_count for c in disposed_cases) / len(disposed_cases) - avg_days_to_disposal = sum((c.disposal_date - c.filed_date).days for c in disposed_cases) / len(disposed_cases) - print(f"\nDisposal metrics:") + avg_hearings_to_disposal = sum(c.hearing_count for c in disposed_cases) / len( + disposed_cases + ) + avg_days_to_disposal = sum( + (c.disposal_date - c.filed_date).days for c in disposed_cases + ) / len(disposed_cases) + print("\nDisposal metrics:") print(f" Average hearings to disposal: {avg_hearings_to_disposal:.1f}") print(f" Average days to disposal: {avg_days_to_disposal:.0f}") - + return CourtSimResult( hearings_total=self._hearings_total, hearings_heard=self._hearings_heard, diff --git a/scheduler/simulation/events.py b/scheduler/simulation/events.py index 7e779d0ea20945648ab4ea94ad03b49cd0fa90ff..46ae7f957f119bca53bef3553795afb0cd39abec 100644 --- a/scheduler/simulation/events.py +++ b/scheduler/simulation/events.py @@ -10,11 +10,10 @@ Types: """ from __future__ import annotations +import csv from dataclasses import dataclass from datetime import date from pathlib import Path -import csv -from typing import Dict, Any, Iterable @dataclass diff --git a/scheduler/simulation/policies/__init__.py b/scheduler/simulation/policies/__init__.py index 00b6405cc17d58c251989fadb03ddca5e5a9674b..089d3d5ce5251c9e1c3049a4633d644f5abc6934 100644 --- a/scheduler/simulation/policies/__init__.py +++ b/scheduler/simulation/policies/__init__.py @@ -1,28 +1,29 @@ """Scheduling policy implementations.""" + from scheduler.core.policy import SchedulerPolicy -from scheduler.simulation.policies.fifo import FIFOPolicy from scheduler.simulation.policies.age import AgeBasedPolicy +from scheduler.simulation.policies.fifo import FIFOPolicy from scheduler.simulation.policies.readiness import ReadinessPolicy -from scheduler.simulation.policies.rl_policy import RLPolicy +# Registry of supported policies (RL removed) POLICY_REGISTRY = { "fifo": FIFOPolicy, "age": AgeBasedPolicy, "readiness": ReadinessPolicy, - "rl": RLPolicy, } + def get_policy(name: str, **kwargs): """Get a policy instance by name. - + Args: - name: Policy name (fifo, age, readiness, rl) + name: Policy name (fifo, age, readiness) **kwargs: Additional arguments passed to policy constructor - (e.g., agent_path for RL policy) """ name_lower = name.lower() if name_lower not in POLICY_REGISTRY: raise ValueError(f"Unknown policy: {name}") return POLICY_REGISTRY[name_lower](**kwargs) -__all__ = ["SchedulerPolicy", "FIFOPolicy", "AgeBasedPolicy", "ReadinessPolicy", "RLPolicy", "get_policy"] + +__all__ = ["SchedulerPolicy", "FIFOPolicy", "AgeBasedPolicy", "ReadinessPolicy", "get_policy"] diff --git a/scheduler/simulation/policies/age.py b/scheduler/simulation/policies/age.py index 8c275c3972a222b459fe19e0ce941c1dad2e93b9..36bdc5a04c2a047cf0fad8dc34a24fc8ba2d4770 100644 --- a/scheduler/simulation/policies/age.py +++ b/scheduler/simulation/policies/age.py @@ -8,31 +8,31 @@ from __future__ import annotations from datetime import date from typing import List -from scheduler.core.policy import SchedulerPolicy from scheduler.core.case import Case +from scheduler.core.policy import SchedulerPolicy class AgeBasedPolicy(SchedulerPolicy): """Age-based scheduling: oldest cases scheduled first.""" - + def prioritize(self, cases: List[Case], current_date: date) -> List[Case]: """Sort cases by age (oldest first). - + Args: cases: List of eligible cases current_date: Current simulation date - + Returns: Cases sorted by age_days (descending) """ # Update ages first for c in cases: c.update_age(current_date) - + return sorted(cases, key=lambda c: c.age_days, reverse=True) - + def get_name(self) -> str: return "Age-Based" - + def requires_readiness_score(self) -> bool: return False diff --git a/scheduler/simulation/policies/fifo.py b/scheduler/simulation/policies/fifo.py index 4d862fd1a392dcc6f11f87903ef7150eea76b57b..e6fe4ead5a73699ed16a4d7882b310ebf37c1898 100644 --- a/scheduler/simulation/policies/fifo.py +++ b/scheduler/simulation/policies/fifo.py @@ -8,27 +8,27 @@ from __future__ import annotations from datetime import date from typing import List -from scheduler.core.policy import SchedulerPolicy from scheduler.core.case import Case +from scheduler.core.policy import SchedulerPolicy class FIFOPolicy(SchedulerPolicy): """FIFO scheduling: cases scheduled in filing order.""" - + def prioritize(self, cases: List[Case], current_date: date) -> List[Case]: """Sort cases by filed_date (earliest first). - + Args: cases: List of eligible cases current_date: Current simulation date (unused) - + Returns: Cases sorted by filing date (oldest first) """ return sorted(cases, key=lambda c: c.filed_date) - + def get_name(self) -> str: return "FIFO" - + def requires_readiness_score(self) -> bool: return False diff --git a/scheduler/simulation/policies/readiness.py b/scheduler/simulation/policies/readiness.py index c00d30fdeefeb675dbe9297b574482542c7cbad3..c758d0429a61075b8f77afa51a79d3ce26bcb85d 100644 --- a/scheduler/simulation/policies/readiness.py +++ b/scheduler/simulation/policies/readiness.py @@ -11,25 +11,25 @@ from __future__ import annotations from datetime import date from typing import List -from scheduler.core.policy import SchedulerPolicy from scheduler.core.case import Case +from scheduler.core.policy import SchedulerPolicy class ReadinessPolicy(SchedulerPolicy): """Readiness-based scheduling: composite priority score.""" - + def prioritize(self, cases: List[Case], current_date: date) -> List[Case]: """Sort cases by composite priority score (highest first). - + The priority score combines: - Age (40% weight) - Readiness (30% weight) - Urgency (30% weight) - + Args: cases: List of eligible cases current_date: Current simulation date - + Returns: Cases sorted by priority score (descending) """ @@ -37,12 +37,12 @@ class ReadinessPolicy(SchedulerPolicy): for c in cases: c.update_age(current_date) c.compute_readiness_score() - + # Sort by priority score (higher = more urgent) return sorted(cases, key=lambda c: c.get_priority_score(), reverse=True) - + def get_name(self) -> str: return "Readiness-Based" - + def requires_readiness_score(self) -> bool: return True diff --git a/scheduler/simulation/policies/rl_policy.py b/scheduler/simulation/policies/rl_policy.py deleted file mode 100644 index 1305938984fd3147439eda1ccbb1a4b30f97d3b7..0000000000000000000000000000000000000000 --- a/scheduler/simulation/policies/rl_policy.py +++ /dev/null @@ -1,211 +0,0 @@ -"""RL-based scheduling policy using tabular Q-learning for case prioritization. - -Implements hybrid approach from RL_EXPLORATION_PLAN.md: -- Uses RL agent for case priority scoring -- Maintains rule-based filtering for fairness and constraints -- Integrates with existing simulation framework -""" - -from typing import List, Dict, Any -from datetime import date -from pathlib import Path - -from scheduler.core.case import Case -from scheduler.core.policy import SchedulerPolicy - -try: - from rl.config import PolicyConfig, DEFAULT_POLICY_CONFIG -except ImportError: - # Fallback if rl module not available - from dataclasses import dataclass - @dataclass - class PolicyConfig: - min_gap_days: int = 7 - old_case_threshold_days: int = 180 - DEFAULT_POLICY_CONFIG = PolicyConfig() -from scheduler.simulation.policies.readiness import ReadinessPolicy - -try: - import sys - from pathlib import Path - # Add rl module to path - rl_path = Path(__file__).parent.parent.parent.parent / "rl" - if rl_path.exists(): - sys.path.insert(0, str(rl_path.parent)) - from rl.simple_agent import TabularQAgent - RL_AVAILABLE = True -except ImportError as e: - RL_AVAILABLE = False - print(f"[DEBUG] RL import failed: {e}") - - -class RLPolicy(SchedulerPolicy): - """RL-enhanced scheduling policy with hybrid rule-based + RL approach.""" - - def __init__(self, agent_path: Path, policy_config: PolicyConfig = None): - """Initialize RL policy. - - Args: - agent_path: Path to trained RL agent file (REQUIRED) - - Raises: - ImportError: If RL module not available - FileNotFoundError: If agent model file doesn't exist - RuntimeError: If agent fails to load - """ - super().__init__() - - # Use provided config or default - self.config = policy_config if policy_config is not None else DEFAULT_POLICY_CONFIG - - if not RL_AVAILABLE: - raise ImportError("RL module not available. Install required dependencies.") - - # Ensure agent_path is Path object - if not isinstance(agent_path, Path): - agent_path = Path(agent_path) - - # Validate model file exists - if not agent_path.exists(): - raise FileNotFoundError( - f"RL agent model not found at {agent_path}. " - "Train the agent first or provide correct path." - ) - - # Load agent - try: - self.agent = TabularQAgent.load(agent_path) - print(f"[INFO] Loaded RL agent from {agent_path}") - print(f"[INFO] Agent stats: {self.agent.get_stats()}") - except Exception as e: - raise RuntimeError(f"Failed to load RL agent from {agent_path}: {e}") - - def sort_cases(self, cases: List[Case], current_date: date, **kwargs) -> List[Case]: - """Sort cases by RL-based priority scores with rule-based filtering. - - Following hybrid approach: - 1. Apply rule-based filtering (fairness, ripeness) - 2. Use RL agent for priority scoring - 3. Fall back to readiness policy if needed - """ - if not cases: - return [] - - # Agent is guaranteed to be loaded (checked in __init__) - - try: - # Apply rule-based filtering first (like readiness policy does) - filtered_cases = self._apply_rule_based_filtering(cases, current_date) - - # Get RL priority scores for filtered cases - case_scores = [] - for case in filtered_cases: - try: - priority_score = self.agent.get_priority_score(case, current_date) - case_scores.append((case, priority_score)) - except Exception as e: - print(f"[WARN] Failed to get RL score for case {case.case_id}: {e}") - # Assign neutral score - case_scores.append((case, 0.0)) - - # Sort by RL priority score (highest first) - case_scores.sort(key=lambda x: x[1], reverse=True) - sorted_cases = [case for case, _ in case_scores] - - return sorted_cases - - except Exception as e: - # This should never happen - agent is validated in __init__ - raise RuntimeError(f"RL policy failed unexpectedly: {e}") - - def _apply_rule_based_filtering(self, cases: List[Case], current_date: date) -> List[Case]: - """Apply rule-based filtering similar to ReadinessPolicy. - - This maintains fairness and basic judicial constraints while letting - RL handle prioritization within the filtered set. - """ - # Filter for basic scheduling eligibility - eligible_cases = [] - - for case in cases: - # Skip if already disposed - if case.is_disposed: - continue - - # Skip if too soon since last hearing (basic fairness) - if case.last_hearing_date: - days_since = (current_date - case.last_hearing_date).days - if days_since < self.config.min_gap_days: - continue - - # Include urgent cases regardless of other filters - if case.is_urgent: - eligible_cases.append(case) - continue - - # Apply ripeness filter if available - if hasattr(case, 'ripeness_status'): - if case.ripeness_status == "RIPE": - eligible_cases.append(case) - # Skip UNRIPE cases unless they're very old - elif (self.config.allow_old_unripe_cases and - case.age_days and case.age_days > self.config.old_case_threshold_days): - eligible_cases.append(case) - else: - # No ripeness info, include case - eligible_cases.append(case) - - return eligible_cases - - def get_explanation(self, case: Case, current_date: date) -> str: - """Get explanation for why a case was prioritized.""" - if not RL_AVAILABLE or not self.agent: - return "RL not available, using fallback policy" - - try: - priority_score = self.agent.get_priority_score(case, current_date) - state = self.agent.extract_state(case, current_date) - - explanation_parts = [ - f"RL Priority Score: {priority_score:.3f}", - f"Case State: Stage={case.current_stage}, Age={case.age_days}d, Urgent={case.is_urgent}" - ] - - # Add specific reasoning based on state - if case.is_urgent: - explanation_parts.append("HIGH: Urgent case") - - if case.age_days and case.age_days > 365: - explanation_parts.append("HIGH: Long pending case (>1 year)") - - if hasattr(case, 'ripeness_status'): - explanation_parts.append(f"Ripeness: {case.ripeness_status}") - - return " | ".join(explanation_parts) - - except Exception as e: - return f"RL explanation failed: {e}" - - def get_stats(self) -> Dict[str, Any]: - """Get policy statistics.""" - stats = {"policy_type": "RL-based"} - - if self.agent: - stats.update(self.agent.get_stats()) - stats["agent_loaded"] = self.agent_loaded - else: - stats["agent_available"] = False - - return stats - - def prioritize(self, cases: List[Case], current_date: date) -> List[Case]: - """Prioritize cases for scheduling (required by SchedulerPolicy interface).""" - return self.sort_cases(cases, current_date) - - def get_name(self) -> str: - """Get the policy name for logging/reporting.""" - return "RL-based Priority Scoring" - - def requires_readiness_score(self) -> bool: - """Return True if this policy requires readiness score computation.""" - return True # We use ripeness filtering diff --git a/scheduler/utils/calendar.py b/scheduler/utils/calendar.py index 4d4802405cc2786e66f3d06d2175fdd7a14bb834..531a2f901ea4fc4c2306f9ba2bae95c8d564bb2b 100644 --- a/scheduler/utils/calendar.py +++ b/scheduler/utils/calendar.py @@ -8,210 +8,210 @@ from datetime import date, timedelta from typing import List, Set from scheduler.data.config import ( - WORKING_DAYS_PER_YEAR, SEASONALITY_FACTORS, + WORKING_DAYS_PER_YEAR, ) class CourtCalendar: """Manages court working days and seasonality. - + Attributes: holidays: Set of holiday dates working_days_per_year: Expected working days annually """ - + def __init__(self, working_days_per_year: int = WORKING_DAYS_PER_YEAR): """Initialize court calendar. - + Args: working_days_per_year: Annual working days (default 192) """ self.working_days_per_year = working_days_per_year self.holidays: Set[date] = set() - + def add_holiday(self, holiday_date: date) -> None: """Add a holiday to the calendar. - + Args: holiday_date: Date to mark as holiday """ self.holidays.add(holiday_date) - + def add_holidays(self, holiday_dates: List[date]) -> None: """Add multiple holidays. - + Args: holiday_dates: List of dates to mark as holidays """ self.holidays.update(holiday_dates) - + def is_working_day(self, check_date: date) -> bool: """Check if a date is a working day. - + Args: check_date: Date to check - + Returns: True if date is a working day (not weekend or holiday) """ # Saturday (5) and Sunday (6) are weekends if check_date.weekday() in (5, 6): return False - + if check_date in self.holidays: return False - + return True - + def next_working_day(self, start_date: date, days_ahead: int = 1) -> date: """Get the next working day after a given number of working days. - + Args: start_date: Starting date days_ahead: Number of working days to advance - + Returns: Next working day date """ current = start_date working_days_found = 0 - + while working_days_found < days_ahead: current += timedelta(days=1) if self.is_working_day(current): working_days_found += 1 - + return current - + def working_days_between(self, start_date: date, end_date: date) -> int: """Count working days between two dates (inclusive). - + Args: start_date: Start of range end_date: End of range - + Returns: Number of working days """ if start_date > end_date: return 0 - + count = 0 current = start_date - + while current <= end_date: if self.is_working_day(current): count += 1 current += timedelta(days=1) - + return count - + def get_working_days_in_month(self, year: int, month: int) -> List[date]: """Get all working days in a specific month. - + Args: year: Year month: Month (1-12) - + Returns: List of working day dates """ # Get first and last day of month first_day = date(year, month, 1) - + if month == 12: last_day = date(year, 12, 31) else: last_day = date(year, month + 1, 1) - timedelta(days=1) - + working_days = [] current = first_day - + while current <= last_day: if self.is_working_day(current): working_days.append(current) current += timedelta(days=1) - + return working_days - + def get_working_days_in_year(self, year: int) -> List[date]: """Get all working days in a year. - + Args: year: Year - + Returns: List of working day dates """ working_days = [] - + for month in range(1, 13): working_days.extend(self.get_working_days_in_month(year, month)) - + return working_days - + def get_seasonality_factor(self, check_date: date) -> float: """Get seasonality factor for a date based on month. - + Args: check_date: Date to check - + Returns: Seasonality multiplier (from config) """ return SEASONALITY_FACTORS.get(check_date.month, 1.0) - + def get_expected_capacity(self, check_date: date, base_capacity: int) -> int: """Get expected capacity adjusted for seasonality. - + Args: check_date: Date to check base_capacity: Base daily capacity - + Returns: Adjusted capacity """ factor = self.get_seasonality_factor(check_date) return int(base_capacity * factor) - + def generate_court_calendar(self, start_date: date, end_date: date) -> List[date]: """Generate list of all court working days in a date range. - + Args: start_date: Start of simulation end_date: End of simulation - + Returns: List of working day dates """ working_days = [] current = start_date - + while current <= end_date: if self.is_working_day(current): working_days.append(current) current += timedelta(days=1) - + return working_days - + def add_standard_holidays(self, year: int) -> None: """Add standard Indian national holidays for a year. - + This is a simplified set. In production, use actual court holiday calendar. - + Args: year: Year to add holidays for """ # Standard national holidays (simplified) holidays = [ - date(year, 1, 26), # Republic Day - date(year, 8, 15), # Independence Day - date(year, 10, 2), # Gandhi Jayanti + date(year, 1, 26), # Republic Day + date(year, 8, 15), # Independence Day + date(year, 10, 2), # Gandhi Jayanti date(year, 12, 25), # Christmas ] - + self.add_holidays(holidays) - + def __repr__(self) -> str: return f"CourtCalendar(working_days/year={self.working_days_per_year}, holidays={len(self.holidays)})" diff --git a/scheduler/utils/output_manager.py b/scheduler/utils/output_manager.py deleted file mode 100644 index bd3f02513df6adea9944efb85d2b3beff134cf5e..0000000000000000000000000000000000000000 --- a/scheduler/utils/output_manager.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Centralized output directory management. - -Provides clean, hierarchical output structure for all pipeline artifacts. -No scattered files, no duplicate saves, single source of truth per run. -""" - -from pathlib import Path -from datetime import datetime -from typing import Optional, Dict, Any -import json -from dataclasses import asdict - - -class OutputManager: - """Manages all output paths for a pipeline run. - - Design principles: - - Single run directory contains ALL artifacts - - No copying/moving files between directories - - Clear hierarchy: eda/ training/ simulation/ reports/ - - Run ID is timestamp-based for sorting - - Config saved at root for reproducibility - """ - - def __init__(self, run_id: Optional[str] = None, base_dir: Optional[Path] = None): - """Initialize output manager for a pipeline run. - - Args: - run_id: Unique run identifier (default: timestamp) - base_dir: Base directory for all outputs (default: outputs/runs) - """ - self.run_id = run_id or f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - self.created_at = datetime.now().isoformat() - - # Base paths - project_root = Path(__file__).parent.parent.parent - self.base_dir = base_dir or (project_root / "outputs" / "runs") - self.run_dir = self.base_dir / self.run_id - - # Primary output directories - self.eda_dir = self.run_dir / "eda" - self.training_dir = self.run_dir / "training" - self.simulation_dir = self.run_dir / "simulation" - self.reports_dir = self.run_dir / "reports" - - # EDA subdirectories - self.eda_figures = self.eda_dir / "figures" - self.eda_params = self.eda_dir / "params" - self.eda_data = self.eda_dir / "data" - - # Reports subdirectories - self.visualizations_dir = self.reports_dir / "visualizations" - - # Metadata paths - self.run_record_file = self.run_dir / "run_record.json" - - def create_structure(self): - """Create all output directories.""" - for dir_path in [ - self.run_dir, - self.eda_dir, - self.eda_figures, - self.eda_params, - self.eda_data, - self.training_dir, - self.simulation_dir, - self.reports_dir, - self.visualizations_dir, - ]: - dir_path.mkdir(parents=True, exist_ok=True) - - # Initialize run record with creation metadata if missing - if not self.run_record_file.exists(): - self._update_run_record("run", { - "run_id": self.run_id, - "created_at": self.created_at, - "base_dir": str(self.run_dir), - }) - - def save_config(self, config): - """Save pipeline configuration to run directory. - - Args: - config: PipelineConfig or any dataclass - """ - config_path = self.run_dir / "config.json" - with open(config_path, 'w') as f: - # Handle nested dataclasses (like rl_training) - config_dict = asdict(config) if hasattr(config, '__dataclass_fields__') else config - json.dump(config_dict, f, indent=2, default=str) - - self._update_run_record("config", { - "path": str(config_path), - "timestamp": datetime.now().isoformat(), - }) - - def save_training_stats(self, training_stats: Dict[str, Any]): - """Persist raw training statistics for auditing and dashboards.""" - - self.training_dir.mkdir(parents=True, exist_ok=True) - with open(self.training_stats_file, "w", encoding="utf-8") as f: - json.dump(training_stats, f, indent=2, default=str) - - def save_evaluation_stats(self, evaluation_stats: Dict[str, Any]): - """Persist evaluation metrics for downstream analysis.""" - - eval_path = self.training_dir / "evaluation.json" - with open(eval_path, "w", encoding="utf-8") as f: - json.dump(evaluation_stats, f, indent=2, default=str) - - self._update_run_record("evaluation", { - "path": str(eval_path), - "timestamp": datetime.now().isoformat(), - }) - - def record_training_summary(self, summary: Dict[str, Any], evaluation: Optional[Dict[str, Any]] = None): - """Save aggregated training/evaluation summary for dashboards.""" - - summary_path = self.training_dir / "summary.json" - payload = { - "summary": summary, - "evaluation": evaluation, - "updated_at": datetime.now().isoformat(), - } - - with open(summary_path, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=2, default=str) - - self._update_run_record("training", payload) - - def get_policy_dir(self, policy_name: str) -> Path: - """Get simulation directory for a specific policy. - - Args: - policy_name: Policy name (e.g., 'readiness', 'rl') - - Returns: - Path to policy simulation directory - """ - policy_dir = self.simulation_dir / policy_name - policy_dir.mkdir(parents=True, exist_ok=True) - return policy_dir - - def get_cause_list_dir(self, policy_name: str) -> Path: - """Get cause list directory for a policy. - - Args: - policy_name: Policy name - - Returns: - Path to cause list directory - """ - cause_list_dir = self.get_policy_dir(policy_name) / "cause_lists" - cause_list_dir.mkdir(parents=True, exist_ok=True) - return cause_list_dir - - def record_eda_metadata(self, version: str, used_cached: bool, params_path: Path, figures_path: Path): - """Record EDA version/timestamp for auditability.""" - - payload = { - "version": version, - "timestamp": datetime.now().isoformat(), - "used_cached": used_cached, - "params_path": str(params_path), - "figures_path": str(figures_path), - } - - self._update_run_record("eda", payload) - - def record_simulation_kpis(self, policy: str, kpis: Dict[str, Any]): - """Persist simulation KPIs per policy for dashboards.""" - - policy_dir = self.get_policy_dir(policy) - metrics_path = policy_dir / "metrics.json" - with open(metrics_path, "w", encoding="utf-8") as f: - json.dump(kpis, f, indent=2, default=str) - - record = self._load_run_record() - simulation_section = record.get("simulation", {}) - simulation_section[policy] = kpis - record["simulation"] = simulation_section - record["updated_at"] = datetime.now().isoformat() - - with open(self.run_record_file, "w", encoding="utf-8") as f: - json.dump(record, f, indent=2, default=str) - - @property - def training_cases_file(self) -> Path: - """Path to generated training cases CSV.""" - return self.training_dir / "cases.csv" - - @property - def trained_model_file(self) -> Path: - """Path to trained RL agent model.""" - return self.training_dir / "agent.pkl" - - @property - def training_stats_file(self) -> Path: - """Path to training statistics JSON.""" - return self.training_dir / "stats.json" - - @property - def executive_summary_file(self) -> Path: - """Path to executive summary markdown.""" - return self.reports_dir / "EXECUTIVE_SUMMARY.md" - - @property - def comparison_report_file(self) -> Path: - """Path to comparison report markdown.""" - return self.reports_dir / "COMPARISON_REPORT.md" - - def create_model_symlink(self, alias: str = "latest"): - """Create symlink in models/ directory pointing to trained model. - - Args: - alias: Symlink name (e.g., 'latest', 'v1.0') - """ - project_root = self.run_dir.parent.parent.parent - models_dir = project_root / "models" - models_dir.mkdir(exist_ok=True) - - symlink_path = models_dir / f"{alias}.pkl" - target = self.trained_model_file - - # Remove existing symlink if present - if symlink_path.exists() or symlink_path.is_symlink(): - symlink_path.unlink() - - # Create symlink (use absolute path for cross-directory links) - try: - symlink_path.symlink_to(target.resolve()) - except (OSError, NotImplementedError): - # Fallback: copy file if symlinks not supported (Windows without dev mode) - import shutil - shutil.copy2(target, symlink_path) - - def __str__(self) -> str: - return f"OutputManager(run_id='{self.run_id}', run_dir='{self.run_dir}')" - - def __repr__(self) -> str: - return self.__str__() - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - def _load_run_record(self) -> Dict[str, Any]: - """Load run record JSON, providing defaults if missing.""" - - if self.run_record_file.exists(): - try: - with open(self.run_record_file, "r", encoding="utf-8") as f: - return json.load(f) - except json.JSONDecodeError: - pass - - return { - "run_id": self.run_id, - "created_at": self.created_at, - } - - def _update_run_record(self, section: str, payload: Dict[str, Any]): - """Upsert a section within the consolidated run record.""" - - record = self._load_run_record() - record.setdefault("sections", {}) - record["sections"][section] = payload - record["updated_at"] = datetime.now().isoformat() - - with open(self.run_record_file, "w", encoding="utf-8") as f: - json.dump(record, f, indent=2, default=str) diff --git a/scheduler/visualization/__init__.py b/scheduler/visualization/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/scripts/analyze_disposal_purpose.py b/scripts/analyze_disposal_purpose.py deleted file mode 100644 index b4ade05ea09926409c891e47c9bf7f21336b9ed0..0000000000000000000000000000000000000000 --- a/scripts/analyze_disposal_purpose.py +++ /dev/null @@ -1,27 +0,0 @@ -import polars as pl -from pathlib import Path - -REPORTS_DIR = Path("reports/figures/v0.4.0_20251119_171426") -hearings = pl.read_parquet(REPORTS_DIR / "hearings_clean.parquet") - -# Get last hearing for each case -last_hearing = hearings.sort("BusinessOnDate").group_by("CNR_NUMBER").last() - -# Analyze PurposeOfHearing for these last hearings -purposes = last_hearing.select(pl.col("PurposeOfHearing").cast(pl.Utf8)) - -# Filter out integers/numeric strings -def is_not_numeric(val): - if val is None: return False - try: - float(val) - return False - except ValueError: - return True - -valid_purposes = purposes.filter( - pl.col("PurposeOfHearing").map_elements(is_not_numeric, return_dtype=pl.Boolean) -) - -print("Top 20 Purposes for Last Hearing of Disposed Cases:") -print(valid_purposes["PurposeOfHearing"].value_counts().sort("count", descending=True).head(20)) diff --git a/scripts/analyze_historical.py b/scripts/analyze_historical.py deleted file mode 100644 index ea416ae94edf9b4178c44695216042889cca32c3..0000000000000000000000000000000000000000 --- a/scripts/analyze_historical.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Analyze historical case and hearing data to understand realistic patterns.""" -import pandas as pd -from pathlib import Path - -# Load historical data -cases = pd.read_csv("data/ISDMHack_Cases_WPfinal.csv") -hearings = pd.read_csv("data/ISDMHack_Hear.csv") - -print("="*80) -print("HISTORICAL DATA ANALYSIS") -print("="*80) - -print(f"\nTotal cases: {len(cases):,}") -print(f"Total hearings: {len(hearings):,}") -print(f"Avg hearings per case: {len(hearings) / len(cases):.2f}") - -# Hearing frequency per case -hear_per_case = hearings.groupby('CNR').size() -print(f"\nHearings per case distribution:") -print(hear_per_case.describe()) - -# Time between hearings -hearings['NEXT_HEARING_DATE'] = pd.to_datetime(hearings['NEXT_HEARING_DATE'], errors='coerce') -hearings = hearings.sort_values(['CNR', 'NEXT_HEARING_DATE']) -hearings['days_since_prev'] = hearings.groupby('CNR')['NEXT_HEARING_DATE'].diff().dt.days - -print(f"\nDays between consecutive hearings (same case):") -print(hearings['days_since_prev'].describe()) -print(f"Median gap: {hearings['days_since_prev'].median()} days") - -# Cases filed per day -cases['FILING_DATE'] = pd.to_datetime(cases['FILING_DATE'], errors='coerce') -daily_filings = cases.groupby(cases['FILING_DATE'].dt.date).size() -print(f"\nDaily filing rate:") -print(daily_filings.describe()) -print(f"Median: {daily_filings.median():.0f} cases/day") - -# Case age at latest hearing -cases['DISPOSAL_DATE'] = pd.to_datetime(cases['DISPOSAL_DATE'], errors='coerce') -cases['age_days'] = (cases['DISPOSAL_DATE'] - cases['FILING_DATE']).dt.days -print(f"\nCase lifespan (filing to disposal):") -print(cases['age_days'].describe()) - -# Active cases at any point (pending) -cases_with_stage = cases[cases['CURRENT_STAGE'].notna()] -print(f"\nCurrent stage distribution:") -print(cases_with_stage['CURRENT_STAGE'].value_counts().head(10)) - -# Recommendation for simulation -print("\n" + "="*80) -print("RECOMMENDATIONS FOR REALISTIC SIMULATION") -print("="*80) -print(f"1. Case pool size: {len(cases):,} cases (use actual dataset size)") -print(f"2. Avg hearings/case: {len(hearings) / len(cases):.1f}") -print(f"3. Median gap between hearings: {hearings['days_since_prev'].median():.0f} days") -print(f"4. Daily filing rate: {daily_filings.median():.0f} cases/day") -print(f"5. For submission: Use ACTUAL case data, not synthetic") -print(f"6. Simulation period: Match historical period for validation") diff --git a/scripts/analyze_ripeness_patterns.py b/scripts/analyze_ripeness_patterns.py deleted file mode 100644 index 97b09c4a730913a229d0d4b53813fa0c82b4fe05..0000000000000000000000000000000000000000 --- a/scripts/analyze_ripeness_patterns.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -Analyze PurposeOfHearing patterns to identify ripeness indicators. - -This script examines the historical hearing data to classify purposes -as RIPE (ready for hearing) vs UNRIPE (bottleneck exists). -""" - -import polars as pl -from pathlib import Path - -# Load hearing data -hear_df = pl.read_csv("Data/ISDMHack_Hear.csv") - -print("=" * 80) -print("PURPOSEOFHEARING ANALYSIS FOR RIPENESS CLASSIFICATION") -print("=" * 80) - -# 1. Unique values and frequency -print("\nPurposeOfHearing Frequency Distribution:") -print("-" * 80) -purpose_counts = hear_df.group_by("PurposeOfHearing").count().sort("count", descending=True) -print(purpose_counts.head(30)) - -print(f"\nTotal unique purposes: {hear_df['PurposeOfHearing'].n_unique()}") -print(f"Total hearings: {len(hear_df)}") - -# 2. Map to Remappedstages (consolidation) -print("\n" + "=" * 80) -print("PURPOSEOFHEARING → REMAPPEDSTAGES MAPPING") -print("=" * 80) - -# Group by both to see relationship -mapping = ( - hear_df - .group_by(["PurposeOfHearing", "Remappedstages"]) - .count() - .sort("count", descending=True) -) -print(mapping.head(40)) - -# 3. Identify potential bottleneck indicators -print("\n" + "=" * 80) -print("RIPENESS CLASSIFICATION HEURISTICS") -print("=" * 80) - -# Keywords suggesting unripe status -unripe_keywords = ["SUMMONS", "NOTICE", "ISSUE", "SERVICE", "STAY", "PENDING"] -ripe_keywords = ["ARGUMENTS", "HEARING", "FINAL", "JUDGMENT", "ORDERS", "DISPOSAL"] - -# Classify purposes -def classify_purpose(purpose_str): - if purpose_str is None or purpose_str == "NA": - return "UNKNOWN" - - purpose_upper = purpose_str.upper() - - # Check unripe keywords first (more specific) - for keyword in unripe_keywords: - if keyword in purpose_upper: - return "UNRIPE" - - # Check ripe keywords - for keyword in ripe_keywords: - if keyword in purpose_upper: - return "RIPE" - - # Default - return "CONDITIONAL" - -# Apply classification -purpose_with_classification = ( - purpose_counts - .with_columns( - pl.col("PurposeOfHearing") - .map_elements(classify_purpose, return_dtype=pl.Utf8) - .alias("Ripeness_Classification") - ) -) - -print("\nPurpose Classification Summary:") -print("-" * 80) -print(purpose_with_classification.head(40)) - -# Summary stats -print("\n" + "=" * 80) -print("RIPENESS CLASSIFICATION SUMMARY") -print("=" * 80) -classification_summary = ( - purpose_with_classification - .group_by("Ripeness_Classification") - .agg([ - pl.col("count").sum().alias("total_hearings"), - pl.col("PurposeOfHearing").count().alias("num_purposes") - ]) - .with_columns( - (pl.col("total_hearings") / pl.col("total_hearings").sum() * 100) - .round(2) - .alias("percentage") - ) -) -print(classification_summary) - -# 4. Analyze by stage -print("\n" + "=" * 80) -print("RIPENESS BY STAGE") -print("=" * 80) - -stage_purpose_analysis = ( - hear_df - .filter(pl.col("Remappedstages").is_not_null()) - .filter(pl.col("Remappedstages") != "NA") - .group_by(["Remappedstages", "PurposeOfHearing"]) - .count() - .sort("count", descending=True) -) - -print("\nTop Purpose-Stage combinations:") -print(stage_purpose_analysis.head(30)) - -# 5. Export classification mapping -output_path = Path("reports/ripeness_purpose_mapping.csv") -output_path.parent.mkdir(exist_ok=True) -purpose_with_classification.write_csv(output_path) -print(f"\n✓ Classification mapping saved to: {output_path}") - -print("\n" + "=" * 80) -print("RECOMMENDATIONS FOR RIPENESS CLASSIFIER") -print("=" * 80) -print(""" -Based on the analysis: - -UNRIPE (Bottleneck exists): -- Purposes containing: SUMMONS, NOTICE, ISSUE, SERVICE, STAY, PENDING -- Cases waiting for procedural steps before substantive hearing - -RIPE (Ready for hearing): -- Purposes containing: ARGUMENTS, HEARING, FINAL, JUDGMENT, ORDERS, DISPOSAL -- Cases ready for substantive judicial action - -CONDITIONAL: -- Other purposes that may be ripe or unripe depending on context -- Needs additional logic based on stage, case age, hearing count - -Use Remappedstages as secondary indicator: -- ADMISSION stage → more likely unripe (procedural) -- ORDERS/JUDGMENT stage → more likely ripe (substantive) -""") diff --git a/scripts/check_disposal.py b/scripts/check_disposal.py deleted file mode 100644 index 6f508f36227424803263dd4be80f69ec2b1e2915..0000000000000000000000000000000000000000 --- a/scripts/check_disposal.py +++ /dev/null @@ -1,17 +0,0 @@ -from scheduler.data.param_loader import load_parameters - -p = load_parameters() -print("Transition probabilities from ORDERS / JUDGMENT:") -print(f" -> FINAL DISPOSAL: {p.get_transition_prob('ORDERS / JUDGMENT', 'FINAL DISPOSAL'):.4f}") -print(f" -> Self-loop: {p.get_transition_prob('ORDERS / JUDGMENT', 'ORDERS / JUDGMENT'):.4f}") -print(f" -> NA: {p.get_transition_prob('ORDERS / JUDGMENT', 'NA'):.4f}") -print(f" -> OTHER: {p.get_transition_prob('ORDERS / JUDGMENT', 'OTHER'):.4f}") - -print("\nTransition probabilities from OTHER:") -print(f" -> FINAL DISPOSAL: {p.get_transition_prob('OTHER', 'FINAL DISPOSAL'):.4f}") -print(f" -> NA: {p.get_transition_prob('OTHER', 'NA'):.4f}") - -print("\nTerminal stages:", ['FINAL DISPOSAL', 'SETTLEMENT']) -print("\nStage durations:") -print(f" ORDERS / JUDGMENT median: {p.get_stage_duration('ORDERS / JUDGMENT', 'median')} days") -print(f" FINAL DISPOSAL median: {p.get_stage_duration('FINAL DISPOSAL', 'median')} days") diff --git a/scripts/check_new_params.py b/scripts/check_new_params.py deleted file mode 100644 index a80e1769da8a9e31cd6388141dc4b3c87a44fe76..0000000000000000000000000000000000000000 --- a/scripts/check_new_params.py +++ /dev/null @@ -1,19 +0,0 @@ -from scheduler.data.param_loader import load_parameters - -# Will automatically load from latest folder (v0.4.0_20251119_213840) -p = load_parameters() - -print("Transition probabilities from ORDERS / JUDGMENT:") -try: - print(f" -> FINAL DISPOSAL: {p.get_transition_prob('ORDERS / JUDGMENT', 'FINAL DISPOSAL'):.4f}") - print(f" -> Self-loop: {p.get_transition_prob('ORDERS / JUDGMENT', 'ORDERS / JUDGMENT'):.4f}") - print(f" -> NA: {p.get_transition_prob('ORDERS / JUDGMENT', 'NA'):.4f}") -except Exception as e: - print(e) - -print("\nTransition probabilities from OTHER:") -try: - print(f" -> FINAL DISPOSAL: {p.get_transition_prob('OTHER', 'FINAL DISPOSAL'):.4f}") - print(f" -> NA: {p.get_transition_prob('OTHER', 'NA'):.4f}") -except Exception as e: - print(e) diff --git a/scripts/compare_policies.py b/scripts/compare_policies.py deleted file mode 100644 index 2f634d10bc25a8d3b24f301acc7d590de4f62a94..0000000000000000000000000000000000000000 --- a/scripts/compare_policies.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Compare scheduling policies on same case pool. - -Runs FIFO, age-based, and readiness-based policies with identical inputs -and generates side-by-side comparison report. -""" -from pathlib import Path -import argparse -import subprocess -import sys -import re - - -def parse_report(report_path: Path) -> dict: - """Extract metrics from simulation report.txt.""" - if not report_path.exists(): - return {} - - text = report_path.read_text(encoding="utf-8") - metrics = {} - - # Parse key metrics using regex - patterns = { - "cases": r"Cases:\s*(\d+)", - "hearings_total": r"Hearings total:\s*(\d+)", - "heard": r"Heard:\s*(\d+)", - "adjourned": r"Adjourned:\s*(\d+)", - "adjournment_rate": r"rate=(\d+\.?\d*)%", - "disposals": r"Disposals:\s*(\d+)", - "utilization": r"Utilization:\s*(\d+\.?\d*)%", - "gini": r"Gini\(disposal time\):\s*(\d+\.?\d*)", - "gini_n": r"Gini.*n=(\d+)", - } - - for key, pattern in patterns.items(): - match = re.search(pattern, text) - if match: - val = match.group(1) - # convert to float for percentages and decimals - if key in ("adjournment_rate", "utilization", "gini"): - metrics[key] = float(val) - else: - metrics[key] = int(val) - - return metrics - - -def run_policy(policy: str, cases_csv: Path, days: int, seed: int, output_dir: Path) -> dict: - """Run simulation for given policy and return metrics.""" - log_dir = output_dir / policy - log_dir.mkdir(parents=True, exist_ok=True) - - cmd = [ - sys.executable, - "scripts/simulate.py", - "--cases-csv", str(cases_csv), - "--policy", policy, - "--days", str(days), - "--seed", str(seed), - "--log-dir", str(log_dir), - ] - - print(f"Running {policy} policy...") - result = subprocess.run(cmd, cwd=Path.cwd(), capture_output=True, text=True) - - if result.returncode != 0: - print(f"ERROR running {policy}: {result.stderr}") - return {} - - # Parse report - report = log_dir / "report.txt" - return parse_report(report) - - -def generate_comparison(results: dict, output_path: Path): - """Generate markdown comparison report.""" - policies = list(results.keys()) - if not policies: - print("No results to compare") - return - - # Determine best per metric - metrics_to_compare = ["disposals", "gini", "utilization", "adjournment_rate"] - best = {} - - for metric in metrics_to_compare: - vals = {p: results[p].get(metric, 0) for p in policies if metric in results[p]} - if not vals: - continue - # Lower is better for gini and adjournment_rate - if metric in ("gini", "adjournment_rate"): - best[metric] = min(vals.keys(), key=lambda k: vals[k]) - else: - best[metric] = max(vals.keys(), key=lambda k: vals[k]) - - # Generate markdown - lines = ["# Scheduling Policy Comparison Report\n"] - lines.append(f"Policies evaluated: {', '.join(policies)}\n") - lines.append("## Key Metrics Comparison\n") - lines.append("| Metric | " + " | ".join(policies) + " | Best |") - lines.append("|--------|" + "|".join(["-------"] * len(policies)) + "|------|") - - metric_labels = { - "disposals": "Disposals", - "gini": "Gini (fairness)", - "utilization": "Utilization (%)", - "adjournment_rate": "Adjournment Rate (%)", - "heard": "Hearings Heard", - "hearings_total": "Total Hearings", - } - - for metric, label in metric_labels.items(): - row = [label] - for p in policies: - val = results[p].get(metric, "-") - if isinstance(val, float): - row.append(f"{val:.2f}") - else: - row.append(str(val)) - row.append(best.get(metric, "-")) - lines.append("| " + " | ".join(row) + " |") - - lines.append("\n## Analysis\n") - - # Fairness - gini_vals = {p: results[p].get("gini", 999) for p in policies} - fairest = min(gini_vals.keys(), key=lambda k: gini_vals[k]) - lines.append(f"**Fairness**: {fairest} policy achieves lowest Gini coefficient ({gini_vals[fairest]:.3f}), " - "indicating most equitable disposal time distribution.\n") - - # Efficiency - util_vals = {p: results[p].get("utilization", 0) for p in policies} - most_efficient = max(util_vals.keys(), key=lambda k: util_vals[k]) - lines.append(f"**Efficiency**: {most_efficient} policy achieves highest utilization ({util_vals[most_efficient]:.1f}%), " - "maximizing courtroom capacity usage.\n") - - # Throughput - disp_vals = {p: results[p].get("disposals", 0) for p in policies} - highest_throughput = max(disp_vals.keys(), key=lambda k: disp_vals[k]) - lines.append(f"**Throughput**: {highest_throughput} policy produces most disposals ({disp_vals[highest_throughput]}), " - "clearing cases fastest.\n") - - lines.append("\n## Recommendation\n") - - # Count wins per policy - wins = {p: 0 for p in policies} - for winner in best.values(): - if winner in wins: - wins[winner] += 1 - - top_policy = max(wins.keys(), key=lambda k: wins[k]) - lines.append(f"**Recommended Policy**: {top_policy}\n") - lines.append(f"This policy wins on {wins[top_policy]}/{len(best)} key metrics, " - "providing the best balance of fairness, efficiency, and throughput.\n") - - # Write report - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text("\n".join(lines), encoding="utf-8") - print(f"\nComparison report written to: {output_path}") - - -def main(): - ap = argparse.ArgumentParser(description="Compare scheduling policies") - ap.add_argument("--cases-csv", required=True, help="Path to cases CSV") - ap.add_argument("--days", type=int, default=480, help="Simulation horizon (working days)") - ap.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility") - ap.add_argument("--output-dir", default="runs/comparison", help="Output directory for results") - ap.add_argument("--policies", nargs="+", default=["fifo", "age", "readiness"], - help="Policies to compare") - args = ap.parse_args() - - cases_csv = Path(args.cases_csv) - if not cases_csv.exists(): - print(f"ERROR: Cases CSV not found: {cases_csv}") - sys.exit(1) - - output_dir = Path(args.output_dir) - results = {} - - for policy in args.policies: - metrics = run_policy(policy, cases_csv, args.days, args.seed, output_dir) - if metrics: - results[policy] = metrics - - if results: - comparison_report = output_dir / "comparison_report.md" - generate_comparison(results, comparison_report) - - # Print summary to console - print("\n" + "="*60) - print("COMPARISON SUMMARY") - print("="*60) - for policy, metrics in results.items(): - print(f"\n{policy.upper()}:") - print(f" Disposals: {metrics.get('disposals', 'N/A')}") - print(f" Gini: {metrics.get('gini', 'N/A'):.3f}") - print(f" Utilization: {metrics.get('utilization', 'N/A'):.1f}%") - print(f" Adjournment Rate: {metrics.get('adjournment_rate', 'N/A'):.1f}%") - - -if __name__ == "__main__": - main() diff --git a/scripts/demo_explainability_and_controls.py b/scripts/demo_explainability_and_controls.py deleted file mode 100644 index 71ba3a6d4697f491016ba520f71f73ca2d6262ff..0000000000000000000000000000000000000000 --- a/scripts/demo_explainability_and_controls.py +++ /dev/null @@ -1,378 +0,0 @@ -"""Demonstration of explainability and judge intervention controls. - -Shows: -1. Step-by-step decision reasoning for scheduled/unscheduled cases -2. Judge override capabilities -3. Draft cause list review and approval process -4. Audit trail tracking -""" -from datetime import date, datetime -from pathlib import Path -import sys - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from scheduler.core.case import Case, CaseStatus -from scheduler.control.explainability import ExplainabilityEngine -from scheduler.control.overrides import ( - OverrideManager, - Override, - OverrideType -) - - -def demo_explainability(): - """Demonstrate step-by-step decision reasoning.""" - print("=" * 80) - print("DEMO 1: EXPLAINABILITY - STEP-BY-STEP DECISION REASONING") - print("=" * 80) - print() - - # Create a sample case - case = Case( - case_id="CRP/2023/01234", - case_type="CRP", - filed_date=date(2023, 1, 15), - current_stage="ORDERS / JUDGMENT", - is_urgent=True - ) - - # Simulate case progression - case.age_days = 180 - case.hearing_count = 3 - case.days_since_last_hearing = 21 - case.last_hearing_date = date(2023, 6, 1) - case.last_hearing_purpose = "ARGUMENTS" - case.readiness_score = 0.85 - case.ripeness_status = "RIPE" - case.status = CaseStatus.ADJOURNED - - # Calculate priority - priority_score = case.get_priority_score() - - # Example 1: Case SCHEDULED - print("Example 1: Case SCHEDULED") - print("-" * 80) - - explanation = ExplainabilityEngine.explain_scheduling_decision( - case=case, - current_date=date(2023, 6, 22), - scheduled=True, - ripeness_status="RIPE", - priority_score=priority_score, - courtroom_id=3, - capacity_full=False, - below_threshold=False - ) - - print(explanation.to_readable_text()) - print() - - # Example 2: Case NOT SCHEDULED (capacity full) - print("\n" + "=" * 80) - print("Example 2: Case NOT SCHEDULED (Capacity Full)") - print("-" * 80) - - explanation2 = ExplainabilityEngine.explain_scheduling_decision( - case=case, - current_date=date(2023, 6, 22), - scheduled=False, - ripeness_status="RIPE", - priority_score=priority_score, - courtroom_id=None, - capacity_full=True, - below_threshold=False - ) - - print(explanation2.to_readable_text()) - print() - - # Example 3: Case NOT SCHEDULED (unripe) - print("\n" + "=" * 80) - print("Example 3: Case NOT SCHEDULED (UNRIPE - Summons Pending)") - print("-" * 80) - - case_unripe = Case( - case_id="RSA/2023/05678", - case_type="RSA", - filed_date=date(2023, 5, 1), - current_stage="ADMISSION", - is_urgent=False - ) - case_unripe.age_days = 50 - case_unripe.readiness_score = 0.2 - case_unripe.ripeness_status = "UNRIPE_SUMMONS" - case_unripe.last_hearing_purpose = "ISSUE SUMMONS" - - explanation3 = ExplainabilityEngine.explain_scheduling_decision( - case=case_unripe, - current_date=date(2023, 6, 22), - scheduled=False, - ripeness_status="UNRIPE_SUMMONS", - priority_score=None, - courtroom_id=None, - capacity_full=False, - below_threshold=False - ) - - print(explanation3.to_readable_text()) - print() - - -def demo_judge_overrides(): - """Demonstrate judge intervention controls.""" - print("\n" + "=" * 80) - print("DEMO 2: JUDGE INTERVENTION CONTROLS") - print("=" * 80) - print() - - # Create override manager - manager = OverrideManager() - - # Create a draft cause list - print("Step 1: Algorithm generates draft cause list") - print("-" * 80) - - algorithm_suggested = [ - "CRP/2023/00101", - "CRP/2023/00102", - "RSA/2023/00201", - "CA/2023/00301", - "CCC/2023/00401" - ] - - draft = manager.create_draft( - date=date(2023, 6, 22), - courtroom_id=3, - judge_id="J001", - algorithm_suggested=algorithm_suggested - ) - - print(f"Draft created for {draft.date}") - print(f"Courtroom: {draft.courtroom_id}") - print(f"Judge: {draft.judge_id}") - print(f"Algorithm suggested {len(algorithm_suggested)} cases:") - for i, case_id in enumerate(algorithm_suggested, 1): - print(f" {i}. {case_id}") - print() - - # Judge starts with algorithm suggestions - draft.judge_approved = algorithm_suggested.copy() - - # Step 2: Judge makes overrides - print("\nStep 2: Judge reviews and makes modifications") - print("-" * 80) - - # Override 1: Judge adds an urgent case - print("\nOverride 1: Judge adds urgent case") - override1 = Override( - override_id="OV001", - override_type=OverrideType.ADD_CASE, - case_id="CCC/2023/00999", - judge_id="J001", - timestamp=datetime.now(), - reason="Medical emergency case, party has critical health condition" - ) - - success, error = manager.apply_override(draft, override1) - if success: - print(f" ✓ {override1.to_readable_text()}") - else: - print(f" ✗ Failed: {error}") - print() - - # Override 2: Judge removes a case - print("Override 2: Judge removes a case") - override2 = Override( - override_id="OV002", - override_type=OverrideType.REMOVE_CASE, - case_id="RSA/2023/00201", - judge_id="J001", - timestamp=datetime.now(), - reason="Party requested postponement due to family emergency" - ) - - success, error = manager.apply_override(draft, override2) - if success: - print(f" ✓ {override2.to_readable_text()}") - else: - print(f" ✗ Failed: {error}") - print() - - # Override 3: Judge overrides ripeness - print("Override 3: Judge overrides ripeness status") - override3 = Override( - override_id="OV003", - override_type=OverrideType.RIPENESS, - case_id="CRP/2023/00102", - judge_id="J001", - timestamp=datetime.now(), - old_value="UNRIPE_SUMMONS", - new_value="RIPE", - reason="Summons served yesterday, confirmation received this morning" - ) - - success, error = manager.apply_override(draft, override3) - if success: - print(f" ✓ {override3.to_readable_text()}") - else: - print(f" ✗ Failed: {error}") - print() - - # Step 3: Judge approves final list - print("\nStep 3: Judge finalizes cause list") - print("-" * 80) - - manager.finalize_draft(draft) - - print(f"Status: {draft.status}") - print(f"Finalized at: {draft.finalized_at.strftime('%Y-%m-%d %H:%M') if draft.finalized_at else 'N/A'}") - print() - - # Show modifications summary - print("Modifications Summary:") - summary = draft.get_modifications_summary() - print(f" Cases added: {summary['cases_added']}") - print(f" Cases removed: {summary['cases_removed']}") - print(f" Cases kept: {summary['cases_kept']}") - print(f" Acceptance rate: {summary['acceptance_rate']:.1f}%") - print(f" Override types: {summary['override_types']}") - print() - - # Show final list - print("Final Approved Cases:") - for i, case_id in enumerate(draft.judge_approved, 1): - marker = " [NEW]" if case_id not in algorithm_suggested else "" - print(f" {i}. {case_id}{marker}") - print() - - -def demo_judge_preferences(): - """Demonstrate judge-specific preferences.""" - print("\n" + "=" * 80) - print("DEMO 3: JUDGE PREFERENCES") - print("=" * 80) - print() - - manager = OverrideManager() - - # Set judge preferences - prefs = manager.get_judge_preferences("J001") - - print("Judge J001 Preferences:") - print("-" * 80) - - # Set capacity override - prefs.daily_capacity_override = 120 - print(f"Daily capacity override: {prefs.daily_capacity_override} (default: 151)") - print(" Reason: Judge works half-days on Fridays") - print() - - # Block dates - prefs.blocked_dates = [ - date(2023, 7, 10), - date(2023, 7, 11), - date(2023, 7, 12) - ] - print("Blocked dates:") - for blocked in prefs.blocked_dates: - print(f" - {blocked} (vacation)") - print() - - # Case type preferences - prefs.case_type_preferences = { - "Monday": ["CRP", "CA"], - "Wednesday": ["RSA", "RFA"] - } - print("Case type preferences by day:") - for day, types in prefs.case_type_preferences.items(): - print(f" {day}: {', '.join(types)}") - print() - - -def demo_audit_trail(): - """Demonstrate audit trail export.""" - print("\n" + "=" * 80) - print("DEMO 4: AUDIT TRAIL") - print("=" * 80) - print() - - manager = OverrideManager() - - # Simulate some activity - draft1 = manager.create_draft( - date=date(2023, 6, 22), - courtroom_id=1, - judge_id="J001", - algorithm_suggested=["CRP/001", "CA/002", "RSA/003"] - ) - draft1.judge_approved = ["CRP/001", "CA/002"] # Removed one - draft1.status = "APPROVED" - - override = Override( - override_id="OV001", - override_type=OverrideType.REMOVE_CASE, - case_id="RSA/003", - judge_id="J001", - timestamp=datetime.now(), - reason="Party unavailable" - ) - draft1.overrides.append(override) - manager.overrides.append(override) - - # Get statistics - stats = manager.get_override_statistics() - - print("Override Statistics:") - print("-" * 80) - print(f"Total overrides: {stats['total_overrides']}") - print(f"Total drafts: {stats['total_drafts']}") - print(f"Approved drafts: {stats['approved_drafts']}") - print(f"Average acceptance rate: {stats['avg_acceptance_rate']:.1f}%") - print(f"Modification rate: {stats['modification_rate']:.1f}%") - print(f"By type: {stats['by_type']}") - print() - - # Export audit trail - output_file = "demo_audit_trail.json" - manager.export_audit_trail(output_file) - print(f"✓ Audit trail exported to: {output_file}") - print() - - -def main(): - """Run all demonstrations.""" - print("\n") - print("#" * 80) - print("# COURT SCHEDULING SYSTEM - EXPLAINABILITY & CONTROLS DEMO") - print("# Demonstrating step-by-step reasoning and judge intervention") - print("#" * 80) - print() - - demo_explainability() - demo_judge_overrides() - demo_judge_preferences() - demo_audit_trail() - - print("\n" + "=" * 80) - print("DEMO COMPLETE") - print("=" * 80) - print() - print("Key Takeaways:") - print("1. Every scheduling decision has step-by-step explanation") - print("2. Judges can override ANY algorithmic decision with reasoning") - print("3. All overrides are tracked in audit trail") - print("4. System is SUGGESTIVE, not prescriptive") - print("5. Judge preferences are respected (capacity, blocked dates, etc.)") - print() - print("This demonstrates compliance with hackathon requirements:") - print(" - Decision transparency (Phase 6.5 requirement)") - print(" - User control and overrides (Phase 6.5 requirement)") - print(" - Explainability for each step (Step 3 compliance)") - print(" - Audit trail tracking (Phase 6.5 requirement)") - print() - - -if __name__ == "__main__": - main() diff --git a/scripts/generate_all_cause_lists.py b/scripts/generate_all_cause_lists.py deleted file mode 100644 index 1d559047c38d5a62abc2e686e84b4d45c6c5d3c1..0000000000000000000000000000000000000000 --- a/scripts/generate_all_cause_lists.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Generate cause lists for all scenarios and policies from comprehensive sweep. - -Analyzes distribution and statistics of daily generated cause lists across scenarios and policies. -""" -from pathlib import Path -import pandas as pd -import matplotlib.pyplot as plt -import seaborn as sns -from scheduler.output.cause_list import CauseListGenerator - -# Set style -plt.style.use('seaborn-v0_8-darkgrid') -sns.set_palette("husl") - -# Find latest sweep directory -data_dir = Path("data") -sweep_dirs = sorted([d for d in data_dir.glob("comprehensive_sweep_*")], reverse=True) -if not sweep_dirs: - raise FileNotFoundError("No sweep directories found") - -sweep_dir = sweep_dirs[0] -print(f"Processing sweep: {sweep_dir.name}") -print("=" * 80) - -# Get all result directories -result_dirs = [d for d in sweep_dir.iterdir() if d.is_dir() and d.name != "datasets"] - -# Generate cause lists for each -all_stats = [] - -for result_dir in result_dirs: - events_file = result_dir / "events.csv" - if not events_file.exists(): - continue - - # Parse scenario and policy from directory name - parts = result_dir.name.rsplit('_', 1) - if len(parts) != 2: - continue - scenario, policy = parts - - print(f"\n{scenario} - {policy}") - print("-" * 60) - - try: - # Generate cause list - output_dir = result_dir / "cause_lists" - generator = CauseListGenerator(events_file) - cause_list_path = generator.generate_daily_lists(output_dir) - - # Load and analyze - cause_list = pd.read_csv(cause_list_path) - - # Daily statistics - daily_stats = cause_list.groupby('Date').agg({ - 'Case_ID': 'count', - 'Courtroom_ID': 'nunique', - 'Sequence_Number': 'max' - }).rename(columns={ - 'Case_ID': 'hearings', - 'Courtroom_ID': 'active_courtrooms', - 'Sequence_Number': 'max_sequence' - }) - - # Overall statistics - stats = { - 'scenario': scenario, - 'policy': policy, - 'total_hearings': len(cause_list), - 'unique_cases': cause_list['Case_ID'].nunique(), - 'total_days': cause_list['Date'].nunique(), - 'avg_hearings_per_day': daily_stats['hearings'].mean(), - 'std_hearings_per_day': daily_stats['hearings'].std(), - 'min_hearings_per_day': daily_stats['hearings'].min(), - 'max_hearings_per_day': daily_stats['hearings'].max(), - 'avg_courtrooms_per_day': daily_stats['active_courtrooms'].mean(), - 'avg_cases_per_courtroom': daily_stats['hearings'].mean() / daily_stats['active_courtrooms'].mean() - } - - all_stats.append(stats) - - print(f" Total hearings: {stats['total_hearings']:,}") - print(f" Unique cases: {stats['unique_cases']:,}") - print(f" Days: {stats['total_days']}") - print(f" Avg hearings/day: {stats['avg_hearings_per_day']:.1f} ± {stats['std_hearings_per_day']:.1f}") - print(f" Avg cases/courtroom: {stats['avg_cases_per_courtroom']:.1f}") - - except Exception as e: - print(f" ERROR: {e}") - -# Convert to DataFrame -stats_df = pd.DataFrame(all_stats) -stats_df.to_csv(sweep_dir / "cause_list_statistics.csv", index=False) - -print("\n" + "=" * 80) -print(f"Generated {len(all_stats)} cause lists") -print(f"Statistics saved to: {sweep_dir / 'cause_list_statistics.csv'}") - -# Generate comparative visualizations -print("\nGenerating visualizations...") - -viz_dir = sweep_dir / "visualizations" -viz_dir.mkdir(exist_ok=True) - -# 1. Average daily hearings by policy and scenario -fig, ax = plt.subplots(figsize=(16, 8)) - -scenarios = stats_df['scenario'].unique() -policies = ['fifo', 'age', 'readiness'] -x = range(len(scenarios)) -width = 0.25 - -for i, policy in enumerate(policies): - policy_data = stats_df[stats_df['policy'] == policy].set_index('scenario') - values = [policy_data.loc[s, 'avg_hearings_per_day'] if s in policy_data.index else 0 for s in scenarios] - - label = { - 'fifo': 'FIFO (Baseline)', - 'age': 'Age-Based (Baseline)', - 'readiness': 'Our Algorithm (Readiness)' - }[policy] - - bars = ax.bar([xi + i*width for xi in x], values, width, - label=label, alpha=0.8, edgecolor='black', linewidth=1.2) - - # Add value labels - for j, v in enumerate(values): - if v > 0: - ax.text(x[j] + i*width, v + 5, f'{v:.0f}', - ha='center', va='bottom', fontsize=9) - -ax.set_xlabel('Scenario', fontsize=13, fontweight='bold') -ax.set_ylabel('Average Hearings per Day', fontsize=13, fontweight='bold') -ax.set_title('Daily Cause List Size: Comparison Across Policies and Scenarios', - fontsize=15, fontweight='bold', pad=20) -ax.set_xticks([xi + width for xi in x]) -ax.set_xticklabels(scenarios, rotation=45, ha='right') -ax.legend(fontsize=11) -ax.grid(axis='y', alpha=0.3) - -plt.tight_layout() -plt.savefig(str(viz_dir / "cause_list_daily_size_comparison.png"), dpi=300, bbox_inches='tight') -print(f" Saved: {viz_dir / 'cause_list_daily_size_comparison.png'}") - -# 2. Variability (std dev) comparison -fig, ax = plt.subplots(figsize=(16, 8)) - -for i, policy in enumerate(policies): - policy_data = stats_df[stats_df['policy'] == policy].set_index('scenario') - values = [policy_data.loc[s, 'std_hearings_per_day'] if s in policy_data.index else 0 for s in scenarios] - - label = { - 'fifo': 'FIFO', - 'age': 'Age', - 'readiness': 'Readiness (Ours)' - }[policy] - - bars = ax.bar([xi + i*width for xi in x], values, width, - label=label, alpha=0.8, edgecolor='black', linewidth=1.2) - - for j, v in enumerate(values): - if v > 0: - ax.text(x[j] + i*width, v + 0.5, f'{v:.1f}', - ha='center', va='bottom', fontsize=9) - -ax.set_xlabel('Scenario', fontsize=13, fontweight='bold') -ax.set_ylabel('Std Dev of Daily Hearings', fontsize=13, fontweight='bold') -ax.set_title('Cause List Consistency: Lower is More Predictable', - fontsize=15, fontweight='bold', pad=20) -ax.set_xticks([xi + width for xi in x]) -ax.set_xticklabels(scenarios, rotation=45, ha='right') -ax.legend(fontsize=11) -ax.grid(axis='y', alpha=0.3) - -plt.tight_layout() -plt.savefig(str(viz_dir / "cause_list_variability.png"), dpi=300, bbox_inches='tight') -print(f" Saved: {viz_dir / 'cause_list_variability.png'}") - -# 3. Cases per courtroom efficiency -fig, ax = plt.subplots(figsize=(16, 8)) - -for i, policy in enumerate(policies): - policy_data = stats_df[stats_df['policy'] == policy].set_index('scenario') - values = [policy_data.loc[s, 'avg_cases_per_courtroom'] if s in policy_data.index else 0 for s in scenarios] - - label = { - 'fifo': 'FIFO', - 'age': 'Age', - 'readiness': 'Readiness (Ours)' - }[policy] - - bars = ax.bar([xi + i*width for xi in x], values, width, - label=label, alpha=0.8, edgecolor='black', linewidth=1.2) - - for j, v in enumerate(values): - if v > 0: - ax.text(x[j] + i*width, v + 0.5, f'{v:.1f}', - ha='center', va='bottom', fontsize=9) - -ax.set_xlabel('Scenario', fontsize=13, fontweight='bold') -ax.set_ylabel('Avg Cases per Courtroom per Day', fontsize=13, fontweight='bold') -ax.set_title('Courtroom Load Balance: Cases per Courtroom', - fontsize=15, fontweight='bold', pad=20) -ax.set_xticks([xi + width for xi in x]) -ax.set_xticklabels(scenarios, rotation=45, ha='right') -ax.legend(fontsize=11) -ax.grid(axis='y', alpha=0.3) - -plt.tight_layout() -plt.savefig(str(viz_dir / "cause_list_courtroom_load.png"), dpi=300, bbox_inches='tight') -print(f" Saved: {viz_dir / 'cause_list_courtroom_load.png'}") - -# 4. Statistical summary table -fig, ax = plt.subplots(figsize=(14, 10)) -ax.axis('tight') -ax.axis('off') - -# Create summary table -summary_data = [] -for policy in policies: - policy_stats = stats_df[stats_df['policy'] == policy] - summary_data.append([ - {'fifo': 'FIFO', 'age': 'Age', 'readiness': 'Readiness (OURS)'}[policy], - f"{policy_stats['avg_hearings_per_day'].mean():.1f}", - f"{policy_stats['std_hearings_per_day'].mean():.2f}", - f"{policy_stats['avg_cases_per_courtroom'].mean():.1f}", - f"{policy_stats['unique_cases'].mean():.0f}", - f"{policy_stats['total_hearings'].mean():.0f}" - ]) - -table = ax.table(cellText=summary_data, - colLabels=['Policy', 'Avg Hearings/Day', 'Std Dev', - 'Cases/Courtroom', 'Avg Unique Cases', 'Avg Total Hearings'], - cellLoc='center', - loc='center', - colWidths=[0.2, 0.15, 0.15, 0.15, 0.15, 0.15]) - -table.auto_set_font_size(False) -table.set_fontsize(12) -table.scale(1, 3) - -# Style header -for i in range(6): - table[(0, i)].set_facecolor('#4CAF50') - table[(0, i)].set_text_props(weight='bold', color='white') - -# Highlight our algorithm -table[(3, 0)].set_facecolor('#E8F5E9') -for i in range(1, 6): - table[(3, i)].set_facecolor('#E8F5E9') - table[(3, i)].set_text_props(weight='bold') - -plt.title('Cause List Statistics Summary: Average Across All Scenarios', - fontsize=14, fontweight='bold', pad=20) -plt.savefig(str(viz_dir / "cause_list_summary_table.png"), dpi=300, bbox_inches='tight') -print(f" Saved: {viz_dir / 'cause_list_summary_table.png'}") - -print("\n" + "=" * 80) -print("CAUSE LIST GENERATION AND ANALYSIS COMPLETE!") -print(f"All visualizations saved to: {viz_dir}") -print("=" * 80) diff --git a/scripts/generate_cases.py b/scripts/generate_cases.py deleted file mode 100644 index 6d018adc588e842e886253189e2cf2932e105157..0000000000000000000000000000000000000000 --- a/scripts/generate_cases.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -import argparse -from datetime import date -from pathlib import Path -import sys, os - -# Ensure project root is on sys.path when running as a script -sys.path.append(os.path.dirname(os.path.dirname(__file__))) - -from scheduler.data.case_generator import CaseGenerator - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--start", required=True, help="Start date YYYY-MM-DD") - ap.add_argument("--end", required=True, help="End date YYYY-MM-DD") - ap.add_argument("--n", type=int, required=True, help="Number of cases to generate") - ap.add_argument("--seed", type=int, default=42) - ap.add_argument("--out", default="data/generated/cases.csv") - ap.add_argument("--stage-mix", type=str, default=None, help="Comma-separated 'STAGE:p' pairs or 'auto' for EDA-driven stationary mix") - args = ap.parse_args() - - start = date.fromisoformat(args.start) - end = date.fromisoformat(args.end) - - gen = CaseGenerator(start=start, end=end, seed=args.seed) - - stage_mix = None - stage_mix_auto = False - if args.stage_mix: - if args.stage_mix.strip().lower() == "auto": - stage_mix_auto = True - else: - stage_mix = {} - for pair in args.stage_mix.split(","): - if not pair.strip(): - continue - k, v = pair.split(":", 1) - stage_mix[k.strip()] = float(v) - # normalize - total = sum(stage_mix.values()) - if total > 0: - for k in list(stage_mix.keys()): - stage_mix[k] = stage_mix[k] / total - - cases = gen.generate(args.n, stage_mix=stage_mix, stage_mix_auto=stage_mix_auto) - - out_path = Path(args.out) - CaseGenerator.to_csv(cases, out_path) - - # Print quick summary - from collections import Counter - by_type = Counter(c.case_type for c in cases) - urgent = sum(1 for c in cases if c.is_urgent) - - print(f"Generated: {len(cases)} cases → {out_path}") - print("By case type:") - for k, v in sorted(by_type.items()): - print(f" {k}: {v}") - print(f"Urgent: {urgent} ({urgent/len(cases):.2%})") - - -if __name__ == "__main__": - main() diff --git a/scripts/generate_comparison_plots.py b/scripts/generate_comparison_plots.py deleted file mode 100644 index 86d3c27941d8feab168517a9367ce9ca2082478b..0000000000000000000000000000000000000000 --- a/scripts/generate_comparison_plots.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Generate comparison plots for policy and scenario analysis. - -Creates visualizations showing: -1. Disposal rate comparison across policies and scenarios -2. Gini coefficient (fairness) comparison -3. Utilization patterns -4. Long-term performance trends -""" -import matplotlib.pyplot as plt -import numpy as np -from pathlib import Path - -# Set style -plt.style.use('seaborn-v0_8-darkgrid') -plt.rcParams['figure.figsize'] = (12, 8) -plt.rcParams['font.size'] = 10 - -# Output directory -output_dir = Path("visualizations") -output_dir.mkdir(exist_ok=True) - -# Data from simulations -data = { - "scenarios": ["Baseline\n(100d)", "Baseline\n(500d)", "Admission\nHeavy", "Large\nBacklog"], - "disposal_fifo": [57.0, None, None, None], - "disposal_age": [57.0, None, None, None], - "disposal_readiness": [56.9, 81.4, 70.8, 69.6], - "gini_fifo": [0.262, None, None, None], - "gini_age": [0.262, None, None, None], - "gini_readiness": [0.260, 0.255, 0.259, 0.228], - "utilization_fifo": [81.1, None, None, None], - "utilization_age": [81.1, None, None, None], - "utilization_readiness": [81.5, 45.0, 64.2, 87.1], - "coverage_readiness": [97.7, 97.7, 97.9, 98.0], -} - -# --- Plot 1: Disposal Rate Comparison --- -fig, ax = plt.subplots(figsize=(14, 8)) - -x = np.arange(len(data["scenarios"])) -width = 0.25 - -# FIFO bars (only for baseline 100d) -fifo_values = [data["disposal_fifo"][0]] + [None] * 3 -age_values = [data["disposal_age"][0]] + [None] * 3 -readiness_values = data["disposal_readiness"] - -bars1 = ax.bar(x[0] - width, fifo_values[0], width, label='FIFO', color='#FF6B6B', alpha=0.8) -bars2 = ax.bar(x[0], age_values[0], width, label='Age', color='#4ECDC4', alpha=0.8) -bars3 = ax.bar(x - width/2, readiness_values, width, label='Readiness', color='#45B7D1', alpha=0.8) - -# Add value labels on bars -for i, v in enumerate(readiness_values): - if v is not None: - ax.text(i - width/2, v + 1, f'{v:.1f}%', ha='center', va='bottom', fontweight='bold') - -ax.text(0 - width, fifo_values[0] + 1, f'{fifo_values[0]:.1f}%', ha='center', va='bottom') -ax.text(0, age_values[0] + 1, f'{age_values[0]:.1f}%', ha='center', va='bottom') - -ax.set_xlabel('Scenario', fontsize=12, fontweight='bold') -ax.set_ylabel('Disposal Rate (%)', fontsize=12, fontweight='bold') -ax.set_title('Disposal Rate Comparison Across Policies and Scenarios', fontsize=14, fontweight='bold') -ax.set_xticks(x) -ax.set_xticklabels(data["scenarios"]) -ax.legend(fontsize=11) -ax.grid(axis='y', alpha=0.3) -ax.set_ylim(0, 90) - -# Add baseline reference line -ax.axhline(y=55, color='red', linestyle='--', alpha=0.5, label='Typical Baseline (45-55%)') -ax.text(3.5, 56, 'Typical Baseline', color='red', fontsize=9, alpha=0.7) - -plt.tight_layout() -plt.savefig(str(output_dir / "01_disposal_rate_comparison.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '01_disposal_rate_comparison.png'}") - -# --- Plot 2: Gini Coefficient (Fairness) Comparison --- -fig, ax = plt.subplots(figsize=(14, 8)) - -fifo_gini = [data["gini_fifo"][0]] + [None] * 3 -age_gini = [data["gini_age"][0]] + [None] * 3 -readiness_gini = data["gini_readiness"] - -bars1 = ax.bar(x[0] - width, fifo_gini[0], width, label='FIFO', color='#FF6B6B', alpha=0.8) -bars2 = ax.bar(x[0], age_gini[0], width, label='Age', color='#4ECDC4', alpha=0.8) -bars3 = ax.bar(x - width/2, readiness_gini, width, label='Readiness', color='#45B7D1', alpha=0.8) - -# Add value labels -for i, v in enumerate(readiness_gini): - if v is not None: - ax.text(i - width/2, v + 0.005, f'{v:.3f}', ha='center', va='bottom', fontweight='bold') - -ax.text(0 - width, fifo_gini[0] + 0.005, f'{fifo_gini[0]:.3f}', ha='center', va='bottom') -ax.text(0, age_gini[0] + 0.005, f'{age_gini[0]:.3f}', ha='center', va='bottom') - -ax.set_xlabel('Scenario', fontsize=12, fontweight='bold') -ax.set_ylabel('Gini Coefficient (lower = more fair)', fontsize=12, fontweight='bold') -ax.set_title('Fairness Comparison (Gini Coefficient) Across Scenarios', fontsize=14, fontweight='bold') -ax.set_xticks(x) -ax.set_xticklabels(data["scenarios"]) -ax.legend(fontsize=11) -ax.grid(axis='y', alpha=0.3) -ax.set_ylim(0, 0.30) - -# Add fairness threshold line -ax.axhline(y=0.26, color='green', linestyle='--', alpha=0.5) -ax.text(3.5, 0.265, 'Excellent Fairness (<0.26)', color='green', fontsize=9, alpha=0.7) - -plt.tight_layout() -plt.savefig(str(output_dir / "02_gini_coefficient_comparison.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '02_gini_coefficient_comparison.png'}") - -# --- Plot 3: Utilization Patterns --- -fig, ax = plt.subplots(figsize=(14, 8)) - -fifo_util = [data["utilization_fifo"][0]] + [None] * 3 -age_util = [data["utilization_age"][0]] + [None] * 3 -readiness_util = data["utilization_readiness"] - -bars1 = ax.bar(x[0] - width, fifo_util[0], width, label='FIFO', color='#FF6B6B', alpha=0.8) -bars2 = ax.bar(x[0], age_util[0], width, label='Age', color='#4ECDC4', alpha=0.8) -bars3 = ax.bar(x - width/2, readiness_util, width, label='Readiness', color='#45B7D1', alpha=0.8) - -# Add value labels -for i, v in enumerate(readiness_util): - if v is not None: - ax.text(i - width/2, v + 2, f'{v:.1f}%', ha='center', va='bottom', fontweight='bold') - -ax.text(0 - width, fifo_util[0] + 2, f'{fifo_util[0]:.1f}%', ha='center', va='bottom') -ax.text(0, age_util[0] + 2, f'{age_util[0]:.1f}%', ha='center', va='bottom') - -ax.set_xlabel('Scenario', fontsize=12, fontweight='bold') -ax.set_ylabel('Utilization (%)', fontsize=12, fontweight='bold') -ax.set_title('Court Utilization Across Scenarios (Higher = More Cases Scheduled)', fontsize=14, fontweight='bold') -ax.set_xticks(x) -ax.set_xticklabels(data["scenarios"]) -ax.legend(fontsize=11) -ax.grid(axis='y', alpha=0.3) -ax.set_ylim(0, 100) - -# Add optimal range shading -ax.axhspan(40, 50, alpha=0.1, color='green', label='Real Karnataka HC Range') -ax.text(3.5, 45, 'Karnataka HC\nRange (40-50%)', color='green', fontsize=9, alpha=0.7, ha='right') - -plt.tight_layout() -plt.savefig(str(output_dir / "03_utilization_comparison.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '03_utilization_comparison.png'}") - -# --- Plot 4: Long-Term Performance Trend (Readiness Only) --- -fig, ax = plt.subplots(figsize=(12, 7)) - -days = [100, 200, 500] -disposal_trend = [56.9, 70.8, 81.4] # Interpolated for 200d from admission-heavy -gini_trend = [0.260, 0.259, 0.255] - -ax.plot(days, disposal_trend, marker='o', linewidth=3, markersize=10, label='Disposal Rate (%)', color='#45B7D1') -ax2 = ax.twinx() -ax2.plot(days, gini_trend, marker='s', linewidth=3, markersize=10, label='Gini Coefficient', color='#FF6B6B') - -# Add value labels -for i, (d, v) in enumerate(zip(days, disposal_trend)): - ax.text(d, v + 2, f'{v:.1f}%', ha='center', fontweight='bold', color='#45B7D1') - -for i, (d, v) in enumerate(zip(days, gini_trend)): - ax2.text(d, v - 0.008, f'{v:.3f}', ha='center', fontweight='bold', color='#FF6B6B') - -ax.set_xlabel('Simulation Days', fontsize=12, fontweight='bold') -ax.set_ylabel('Disposal Rate (%)', fontsize=12, fontweight='bold', color='#45B7D1') -ax2.set_ylabel('Gini Coefficient', fontsize=12, fontweight='bold', color='#FF6B6B') -ax.set_title('Readiness Policy: Long-Term Performance Improvement', fontsize=14, fontweight='bold') -ax.tick_params(axis='y', labelcolor='#45B7D1') -ax2.tick_params(axis='y', labelcolor='#FF6B6B') -ax.grid(alpha=0.3) -ax.set_ylim(50, 90) -ax2.set_ylim(0.24, 0.28) - -# Add trend annotations -ax.annotate('', xy=(500, 81.4), xytext=(100, 56.9), - arrowprops=dict(arrowstyle='->', lw=2, color='green', alpha=0.5)) -ax.text(300, 72, '+43% improvement', fontsize=11, color='green', fontweight='bold', - bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)) - -fig.legend(loc='upper left', bbox_to_anchor=(0.12, 0.88), fontsize=11) - -plt.tight_layout() -plt.savefig(str(output_dir / "04_long_term_trend.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '04_long_term_trend.png'}") - -# --- Plot 5: Coverage Comparison --- -fig, ax = plt.subplots(figsize=(10, 7)) - -coverage_data = data["coverage_readiness"] -scenarios_short = ["100d", "500d", "Adm-Heavy", "Large"] - -bars = ax.bar(scenarios_short, coverage_data, color='#45B7D1', alpha=0.8, edgecolor='black', linewidth=1.5) - -# Add value labels -for i, v in enumerate(coverage_data): - ax.text(i, v + 0.1, f'{v:.1f}%', ha='center', va='bottom', fontweight='bold', fontsize=11) - -ax.set_xlabel('Scenario', fontsize=12, fontweight='bold') -ax.set_ylabel('Coverage (% Cases Scheduled At Least Once)', fontsize=12, fontweight='bold') -ax.set_title('Case Coverage: Ensuring No Case Left Behind', fontsize=14, fontweight='bold') -ax.grid(axis='y', alpha=0.3) -ax.set_ylim(95, 100) - -# Add target line -ax.axhline(y=98, color='green', linestyle='--', linewidth=2, alpha=0.7) -ax.text(3.5, 98.2, 'Target: 98%', color='green', fontsize=10, fontweight='bold') - -plt.tight_layout() -plt.savefig(str(output_dir / "05_coverage_comparison.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '05_coverage_comparison.png'}") - -# --- Plot 6: Scalability Test (Load vs Performance) --- -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 7)) - -# Left: Disposal rate vs case load -cases = [10000, 10000, 15000] -disposal_by_load = [70.8, 70.8, 69.6] # Admission-heavy, baseline-200d, large -colors = ['#FF6B6B', '#4ECDC4', '#45B7D1'] -labels_load = ['10k\n(Adm-Heavy)', '10k\n(Baseline)', '15k\n(+50% load)'] - -bars1 = ax1.bar(range(len(cases)), disposal_by_load, color=colors, alpha=0.8, edgecolor='black', linewidth=1.5) -for i, v in enumerate(disposal_by_load): - ax1.text(i, v + 1, f'{v:.1f}%', ha='center', va='bottom', fontweight='bold', fontsize=11) - -ax1.set_ylabel('Disposal Rate (200 days)', fontsize=12, fontweight='bold') -ax1.set_title('Scalability: Disposal Rate vs Case Load', fontsize=13, fontweight='bold') -ax1.set_xticks(range(len(cases))) -ax1.set_xticklabels(labels_load) -ax1.grid(axis='y', alpha=0.3) -ax1.set_ylim(65, 75) - -# Right: Gini vs case load -gini_by_load = [0.259, 0.259, 0.228] -bars2 = ax2.bar(range(len(cases)), gini_by_load, color=colors, alpha=0.8, edgecolor='black', linewidth=1.5) -for i, v in enumerate(gini_by_load): - ax2.text(i, v + 0.003, f'{v:.3f}', ha='center', va='bottom', fontweight='bold', fontsize=11) - -ax2.set_ylabel('Gini Coefficient (Fairness)', fontsize=12, fontweight='bold') -ax2.set_title('Scalability: Fairness IMPROVES with Scale', fontsize=13, fontweight='bold') -ax2.set_xticks(range(len(cases))) -ax2.set_xticklabels(labels_load) -ax2.grid(axis='y', alpha=0.3) -ax2.set_ylim(0.22, 0.27) - -# Add "BETTER" annotation -ax2.annotate('BETTER', xy=(2, 0.228), xytext=(1, 0.235), - arrowprops=dict(arrowstyle='->', lw=2, color='green'), - fontsize=11, color='green', fontweight='bold') - -plt.tight_layout() -plt.savefig(str(output_dir / "06_scalability_analysis.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '06_scalability_analysis.png'}") - -print("\n" + "="*60) -print("✅ All plots generated successfully!") -print(f"📁 Location: {output_dir.absolute()}") -print("="*60) -print("\nGenerated visualizations:") -print(" 1. Disposal Rate Comparison") -print(" 2. Gini Coefficient (Fairness)") -print(" 3. Utilization Patterns") -print(" 4. Long-Term Performance Trend") -print(" 5. Coverage (No Case Left Behind)") -print(" 6. Scalability Analysis") diff --git a/scripts/generate_sweep_plots.py b/scripts/generate_sweep_plots.py deleted file mode 100644 index 0eb3c29072a79d3a57944f4c45af593c80f130a0..0000000000000000000000000000000000000000 --- a/scripts/generate_sweep_plots.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Generate comprehensive plots from parameter sweep results. - -Clearly distinguishes: -- Our Algorithm: Readiness + Adjournment Boost -- Baselines: FIFO and Age-Based -""" -import matplotlib.pyplot as plt -import pandas as pd -import numpy as np -from pathlib import Path - -# Set style -plt.style.use('seaborn-v0_8-darkgrid') -plt.rcParams['figure.figsize'] = (14, 8) -plt.rcParams['font.size'] = 11 - -# Load data -data_dir = Path("data/comprehensive_sweep_20251120_184341") -df = pd.read_csv(data_dir / "summary_results.csv") - -# Output directory -output_dir = Path("visualizations/sweep") -output_dir.mkdir(parents=True, exist_ok=True) - -# Define colors and labels -COLORS = { - 'fifo': '#E74C3C', # Red - 'age': '#F39C12', # Orange - 'readiness': '#27AE60' # Green (our algorithm) -} - -LABELS = { - 'fifo': 'FIFO (Baseline)', - 'age': 'Age-Based (Baseline)', - 'readiness': 'Our Algorithm\n(Readiness + Adjournment Boost)' -} - -# Scenario display names -SCENARIO_NAMES = { - 'baseline_10k': '10k Baseline\n(seed=42)', - 'baseline_10k_seed2': '10k Baseline\n(seed=123)', - 'baseline_10k_seed3': '10k Baseline\n(seed=456)', - 'small_5k': '5k Small\nCourt', - 'large_15k': '15k Large\nBacklog', - 'xlarge_20k': '20k XLarge\n(150 days)' -} - -scenarios = df['Scenario'].unique() - -# --- Plot 1: Disposal Rate Comparison --- -fig, ax = plt.subplots(figsize=(16, 9)) - -x = np.arange(len(scenarios)) -width = 0.25 - -fifo_vals = [df[(df['Scenario']==s) & (df['Policy']=='fifo')]['DisposalRate'].values[0] for s in scenarios] -age_vals = [df[(df['Scenario']==s) & (df['Policy']=='age')]['DisposalRate'].values[0] for s in scenarios] -read_vals = [df[(df['Scenario']==s) & (df['Policy']=='readiness')]['DisposalRate'].values[0] for s in scenarios] - -bars1 = ax.bar(x - width, fifo_vals, width, label=LABELS['fifo'], color=COLORS['fifo'], alpha=0.9, edgecolor='black', linewidth=1.2) -bars2 = ax.bar(x, age_vals, width, label=LABELS['age'], color=COLORS['age'], alpha=0.9, edgecolor='black', linewidth=1.2) -bars3 = ax.bar(x + width, read_vals, width, label=LABELS['readiness'], color=COLORS['readiness'], alpha=0.9, edgecolor='black', linewidth=1.2) - -# Add value labels -for i, v in enumerate(fifo_vals): - ax.text(i - width, v + 1, f'{v:.1f}%', ha='center', va='bottom', fontsize=9) -for i, v in enumerate(age_vals): - ax.text(i, v + 1, f'{v:.1f}%', ha='center', va='bottom', fontsize=9) -for i, v in enumerate(read_vals): - ax.text(i + width, v + 1, f'{v:.1f}%', ha='center', va='bottom', fontsize=9, fontweight='bold') - -ax.set_xlabel('Scenario', fontsize=13, fontweight='bold') -ax.set_ylabel('Disposal Rate (%)', fontsize=13, fontweight='bold') -ax.set_title('Disposal Rate: Our Algorithm vs Baselines Across All Scenarios', fontsize=15, fontweight='bold', pad=20) -ax.set_xticks(x) -ax.set_xticklabels([SCENARIO_NAMES[s] for s in scenarios], fontsize=10) -ax.legend(fontsize=12, loc='upper right') -ax.grid(axis='y', alpha=0.3) -ax.set_ylim(0, 80) - -# Add reference line -ax.axhline(y=55, color='red', linestyle='--', alpha=0.5, linewidth=2) -ax.text(5.5, 56, 'Typical Baseline\n(45-55%)', color='red', fontsize=9, alpha=0.8, ha='right') - -plt.tight_layout() -plt.savefig(str(output_dir / "01_disposal_rate_all_scenarios.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '01_disposal_rate_all_scenarios.png'}") - -# --- Plot 2: Gini Coefficient (Fairness) Comparison --- -fig, ax = plt.subplots(figsize=(16, 9)) - -fifo_gini = [df[(df['Scenario']==s) & (df['Policy']=='fifo')]['Gini'].values[0] for s in scenarios] -age_gini = [df[(df['Scenario']==s) & (df['Policy']=='age')]['Gini'].values[0] for s in scenarios] -read_gini = [df[(df['Scenario']==s) & (df['Policy']=='readiness')]['Gini'].values[0] for s in scenarios] - -bars1 = ax.bar(x - width, fifo_gini, width, label=LABELS['fifo'], color=COLORS['fifo'], alpha=0.9, edgecolor='black', linewidth=1.2) -bars2 = ax.bar(x, age_gini, width, label=LABELS['age'], color=COLORS['age'], alpha=0.9, edgecolor='black', linewidth=1.2) -bars3 = ax.bar(x + width, read_gini, width, label=LABELS['readiness'], color=COLORS['readiness'], alpha=0.9, edgecolor='black', linewidth=1.2) - -for i, v in enumerate(fifo_gini): - ax.text(i - width, v + 0.007, f'{v:.3f}', ha='center', va='bottom', fontsize=9) -for i, v in enumerate(age_gini): - ax.text(i, v + 0.007, f'{v:.3f}', ha='center', va='bottom', fontsize=9) -for i, v in enumerate(read_gini): - ax.text(i + width, v + 0.007, f'{v:.3f}', ha='center', va='bottom', fontsize=9, fontweight='bold') - -ax.set_xlabel('Scenario', fontsize=13, fontweight='bold') -ax.set_ylabel('Gini Coefficient (lower = more fair)', fontsize=13, fontweight='bold') -ax.set_title('Fairness: Our Algorithm vs Baselines Across All Scenarios', fontsize=15, fontweight='bold', pad=20) -ax.set_xticks(x) -ax.set_xticklabels([SCENARIO_NAMES[s] for s in scenarios], fontsize=10) -ax.legend(fontsize=12, loc='upper left') -ax.grid(axis='y', alpha=0.3) -ax.set_ylim(0, 0.30) - -ax.axhline(y=0.26, color='green', linestyle='--', alpha=0.6, linewidth=2) -ax.text(5.5, 0.265, 'Excellent\nFairness\n(<0.26)', color='green', fontsize=9, alpha=0.8, ha='right') - -plt.tight_layout() -plt.savefig(str(output_dir / "02_gini_all_scenarios.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '02_gini_all_scenarios.png'}") - -# --- Plot 3: Performance Delta (Readiness - Best Baseline) --- -fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 7)) - -disposal_delta = [] -gini_delta = [] -for s in scenarios: - read = df[(df['Scenario']==s) & (df['Policy']=='readiness')]['DisposalRate'].values[0] - fifo = df[(df['Scenario']==s) & (df['Policy']=='fifo')]['DisposalRate'].values[0] - age = df[(df['Scenario']==s) & (df['Policy']=='age')]['DisposalRate'].values[0] - best_baseline = max(fifo, age) - disposal_delta.append(read - best_baseline) - - read_g = df[(df['Scenario']==s) & (df['Policy']=='readiness')]['Gini'].values[0] - fifo_g = df[(df['Scenario']==s) & (df['Policy']=='fifo')]['Gini'].values[0] - age_g = df[(df['Scenario']==s) & (df['Policy']=='age')]['Gini'].values[0] - best_baseline_g = min(fifo_g, age_g) - gini_delta.append(best_baseline_g - read_g) # Positive = our algorithm better - -colors1 = ['green' if d >= 0 else 'red' for d in disposal_delta] -bars1 = ax1.bar(range(len(scenarios)), disposal_delta, color=colors1, alpha=0.8, edgecolor='black', linewidth=1.5) - -for i, v in enumerate(disposal_delta): - ax1.text(i, v + (0.05 if v >= 0 else -0.15), f'{v:+.2f}%', ha='center', va='bottom' if v >= 0 else 'top', fontsize=10, fontweight='bold') - -ax1.axhline(y=0, color='black', linestyle='-', linewidth=1.5, alpha=0.5) -ax1.set_ylabel('Disposal Rate Advantage (%)', fontsize=12, fontweight='bold') -ax1.set_title('Our Algorithm Advantage Over Best Baseline\n(Disposal Rate)', fontsize=13, fontweight='bold') -ax1.set_xticks(range(len(scenarios))) -ax1.set_xticklabels([SCENARIO_NAMES[s] for s in scenarios], fontsize=9) -ax1.grid(axis='y', alpha=0.3) - -colors2 = ['green' if d >= 0 else 'red' for d in gini_delta] -bars2 = ax2.bar(range(len(scenarios)), gini_delta, color=colors2, alpha=0.8, edgecolor='black', linewidth=1.5) - -for i, v in enumerate(gini_delta): - ax2.text(i, v + (0.001 if v >= 0 else -0.003), f'{v:+.3f}', ha='center', va='bottom' if v >= 0 else 'top', fontsize=10, fontweight='bold') - -ax2.axhline(y=0, color='black', linestyle='-', linewidth=1.5, alpha=0.5) -ax2.set_ylabel('Gini Improvement (lower is better)', fontsize=12, fontweight='bold') -ax2.set_title('Our Algorithm Advantage Over Best Baseline\n(Fairness)', fontsize=13, fontweight='bold') -ax2.set_xticks(range(len(scenarios))) -ax2.set_xticklabels([SCENARIO_NAMES[s] for s in scenarios], fontsize=9) -ax2.grid(axis='y', alpha=0.3) - -plt.tight_layout() -plt.savefig(str(output_dir / "03_advantage_over_baseline.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '03_advantage_over_baseline.png'}") - -# --- Plot 4: Robustness Analysis (Our Algorithm Only) --- -fig, ax = plt.subplots(figsize=(12, 7)) - -readiness_data = df[df['Policy'] == 'readiness'].copy() -readiness_data['scenario_label'] = readiness_data['Scenario'].map(SCENARIO_NAMES) - -x_pos = range(len(readiness_data)) -disposal_vals = readiness_data['DisposalRate'].values - -bars = ax.bar(x_pos, disposal_vals, color=COLORS['readiness'], alpha=0.8, edgecolor='black', linewidth=1.5) - -for i, v in enumerate(disposal_vals): - ax.text(i, v + 1, f'{v:.1f}%', ha='center', va='bottom', fontsize=11, fontweight='bold') - -ax.set_xlabel('Scenario', fontsize=13, fontweight='bold') -ax.set_ylabel('Disposal Rate (%)', fontsize=13, fontweight='bold') -ax.set_title('Our Algorithm: Robustness Across Scenarios', fontsize=15, fontweight='bold', pad=20) -ax.set_xticks(x_pos) -ax.set_xticklabels(readiness_data['scenario_label'], fontsize=10) -ax.grid(axis='y', alpha=0.3) - -mean_val = disposal_vals.mean() -ax.axhline(y=mean_val, color='blue', linestyle='--', linewidth=2, alpha=0.7) -ax.text(5.5, mean_val + 1, f'Mean: {mean_val:.1f}%', color='blue', fontsize=11, fontweight='bold', ha='right') - -std_val = disposal_vals.std() -ax.text(5.5, mean_val - 3, f'Std Dev: {std_val:.2f}%\nCV: {(std_val/mean_val)*100:.1f}%', - color='blue', fontsize=10, ha='right', - bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)) - -plt.tight_layout() -plt.savefig(str(output_dir / "04_robustness_our_algorithm.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '04_robustness_our_algorithm.png'}") - -# --- Plot 5: Statistical Summary --- -fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 12)) - -# Subplot 1: Average performance by policy -policies = ['fifo', 'age', 'readiness'] -avg_disposal = [df[df['Policy']==p]['DisposalRate'].mean() for p in policies] -avg_gini = [df[df['Policy']==p]['Gini'].mean() for p in policies] - -bars1 = ax1.bar(range(3), avg_disposal, color=[COLORS[p] for p in policies], alpha=0.8, edgecolor='black', linewidth=1.5) -for i, v in enumerate(avg_disposal): - ax1.text(i, v + 0.5, f'{v:.2f}%', ha='center', va='bottom', fontsize=11, fontweight='bold') - -ax1.set_ylabel('Average Disposal Rate (%)', fontsize=12, fontweight='bold') -ax1.set_title('Average Performance Across All Scenarios', fontsize=13, fontweight='bold') -ax1.set_xticks(range(3)) -ax1.set_xticklabels([LABELS[p].replace('\n', ' ') for p in policies], fontsize=10) -ax1.grid(axis='y', alpha=0.3) - -# Subplot 2: Variance comparison -std_disposal = [df[df['Policy']==p]['DisposalRate'].std() for p in policies] -bars2 = ax2.bar(range(3), std_disposal, color=[COLORS[p] for p in policies], alpha=0.8, edgecolor='black', linewidth=1.5) -for i, v in enumerate(std_disposal): - ax2.text(i, v + 0.1, f'{v:.2f}%', ha='center', va='bottom', fontsize=11, fontweight='bold') - -ax2.set_ylabel('Std Dev of Disposal Rate (%)', fontsize=12, fontweight='bold') -ax2.set_title('Robustness: Lower is More Consistent', fontsize=13, fontweight='bold') -ax2.set_xticks(range(3)) -ax2.set_xticklabels([LABELS[p].replace('\n', ' ') for p in policies], fontsize=10) -ax2.grid(axis='y', alpha=0.3) - -# Subplot 3: Gini comparison -bars3 = ax3.bar(range(3), avg_gini, color=[COLORS[p] for p in policies], alpha=0.8, edgecolor='black', linewidth=1.5) -for i, v in enumerate(avg_gini): - ax3.text(i, v + 0.003, f'{v:.3f}', ha='center', va='bottom', fontsize=11, fontweight='bold') - -ax3.set_ylabel('Average Gini Coefficient', fontsize=12, fontweight='bold') -ax3.set_title('Fairness: Lower is Better', fontsize=13, fontweight='bold') -ax3.set_xticks(range(3)) -ax3.set_xticklabels([LABELS[p].replace('\n', ' ') for p in policies], fontsize=10) -ax3.grid(axis='y', alpha=0.3) - -# Subplot 4: Win matrix -win_matrix = np.zeros((3, 3)) # disposal, gini, utilization -for s in scenarios: - # Disposal - vals = [df[(df['Scenario']==s) & (df['Policy']==p)]['DisposalRate'].values[0] for p in policies] - win_matrix[0, np.argmax(vals)] += 1 - - # Gini (lower is better) - vals = [df[(df['Scenario']==s) & (df['Policy']==p)]['Gini'].values[0] for p in policies] - win_matrix[1, np.argmin(vals)] += 1 - - # Utilization - vals = [df[(df['Scenario']==s) & (df['Policy']==p)]['Utilization'].values[0] for p in policies] - win_matrix[2, np.argmax(vals)] += 1 - -metrics = ['Disposal', 'Fairness', 'Utilization'] -x_pos = np.arange(len(metrics)) -width = 0.25 - -for i, policy in enumerate(policies): - ax4.bar(x_pos + i*width, win_matrix[:, i], width, - label=LABELS[policy].replace('\n', ' '), - color=COLORS[policy], alpha=0.8, edgecolor='black', linewidth=1.2) - -ax4.set_ylabel('Number of Wins (out of 6 scenarios)', fontsize=12, fontweight='bold') -ax4.set_title('Head-to-Head Wins by Metric', fontsize=13, fontweight='bold') -ax4.set_xticks(x_pos + width) -ax4.set_xticklabels(metrics, fontsize=11) -ax4.legend(fontsize=10) -ax4.grid(axis='y', alpha=0.3) -ax4.set_ylim(0, 7) - -plt.tight_layout() -plt.savefig(str(output_dir / "05_statistical_summary.png"), dpi=300, bbox_inches='tight') -print(f"✓ Saved: {output_dir / '05_statistical_summary.png'}") - -print("\n" + "="*60) -print("✅ All sweep plots generated successfully!") -print(f"📁 Location: {output_dir.absolute()}") -print("="*60) -print("\nGenerated visualizations:") -print(" 1. Disposal Rate Across All Scenarios") -print(" 2. Gini Coefficient Across All Scenarios") -print(" 3. Advantage Over Baseline") -print(" 4. Robustness Analysis (Our Algorithm)") -print(" 5. Statistical Summary (4 subplots)") diff --git a/scripts/profile_simulation.py b/scripts/profile_simulation.py deleted file mode 100644 index a94eebec2aacc8d39dedd4b3d25156543cd39824..0000000000000000000000000000000000000000 --- a/scripts/profile_simulation.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Profile simulation to identify performance bottlenecks.""" -import cProfile -import pstats -from pathlib import Path -from io import StringIO - -from scheduler.data.case_generator import CaseGenerator -from scheduler.simulation.engine import CourtSim, CourtSimConfig - - -def run_simulation(): - """Run a small simulation for profiling.""" - cases = CaseGenerator.from_csv(Path("data/generated/cases_small.csv")) - print(f"Loaded {len(cases)} cases") - - config = CourtSimConfig( - start=cases[0].filed_date if cases else None, - days=30, - seed=42, - courtrooms=5, - daily_capacity=151, - policy="readiness", - ) - - sim = CourtSim(config, cases) - result = sim.run() - - print(f"Completed: {result.hearings_total} hearings, {result.disposals} disposals") - - -if __name__ == "__main__": - # Profile the simulation - profiler = cProfile.Profile() - profiler.enable() - - run_simulation() - - profiler.disable() - - # Print stats - s = StringIO() - stats = pstats.Stats(profiler, stream=s) - stats.strip_dirs() - stats.sort_stats('cumulative') - stats.print_stats(30) # Top 30 functions - - print("\n" + "="*80) - print("TOP 30 CUMULATIVE TIME CONSUMERS") - print("="*80) - print(s.getvalue()) - - # Also sort by total time - s2 = StringIO() - stats2 = pstats.Stats(profiler, stream=s2) - stats2.strip_dirs() - stats2.sort_stats('tottime') - stats2.print_stats(20) - - print("\n" + "="*80) - print("TOP 20 TOTAL TIME CONSUMERS") - print("="*80) - print(s2.getvalue()) diff --git a/scripts/reextract_params.py b/scripts/reextract_params.py deleted file mode 100644 index 939644bd746a76ca2b8b7b9b608c95b18ebdc130..0000000000000000000000000000000000000000 --- a/scripts/reextract_params.py +++ /dev/null @@ -1,6 +0,0 @@ -from src.eda_parameters import extract_parameters -import sys - -print("Re-extracting parameters with fixed NA handling...") -extract_parameters() -print("Done.") diff --git a/scripts/simulate.py b/scripts/simulate.py deleted file mode 100644 index e22d835abc04fade475eb46fa884ccf895d17065..0000000000000000000000000000000000000000 --- a/scripts/simulate.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import sys -from datetime import date -from pathlib import Path - -# Ensure project root on sys.path -sys.path.append(os.path.dirname(os.path.dirname(__file__))) - -from scheduler.core.case import CaseStatus -from scheduler.data.case_generator import CaseGenerator -from scheduler.metrics.basic import gini -from scheduler.simulation.engine import CourtSim, CourtSimConfig - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--cases-csv", type=str, default="data/generated/cases.csv") - ap.add_argument("--days", type=int, default=60) - ap.add_argument("--seed", type=int, default=42) - ap.add_argument("--start", type=str, default=None, help="YYYY-MM-DD; default first of current month") - ap.add_argument("--policy", choices=["fifo", "age", "readiness"], default="readiness") - ap.add_argument("--duration-percentile", choices=["median", "p90"], default="median") - ap.add_argument("--log-dir", type=str, default=None, help="Directory to write metrics and suggestions") - args = ap.parse_args() - - path = Path(args.cases_csv) - if path.exists(): - cases = CaseGenerator.from_csv(path) - # Simulation should start AFTER cases have been filed and have history - # Default: start from the latest filed date (end of case generation period) - if args.start: - start = date.fromisoformat(args.start) - else: - # Start simulation from end of case generation period - # This way all cases have been filed and have last_hearing_date set - start = max(c.filed_date for c in cases) if cases else date.today() - else: - # fallback: quick generate 5*capacity cases - if args.start: - start = date.fromisoformat(args.start) - else: - start = date.today().replace(day=1) - gen = CaseGenerator(start=start, end=start.replace(day=28), seed=args.seed) - cases = gen.generate(n_cases=5 * 151) - - cfg = CourtSimConfig(start=start, days=args.days, seed=args.seed, policy=args.policy, duration_percentile=args.duration_percentile, log_dir=Path(args.log_dir) if args.log_dir else None) - sim = CourtSim(cfg, cases) - res = sim.run() - - # Get allocator stats - allocator_stats = sim.allocator.get_utilization_stats() - - # Fairness/report: disposal times - disp_times = [ (c.disposal_date - c.filed_date).days for c in cases if c.disposal_date is not None and c.status == CaseStatus.DISPOSED ] - gini_disp = gini(disp_times) if disp_times else 0.0 - - # Disposal rates by case type - case_type_stats = {} - for c in cases: - if c.case_type not in case_type_stats: - case_type_stats[c.case_type] = {"total": 0, "disposed": 0} - case_type_stats[c.case_type]["total"] += 1 - if c.is_disposed: - case_type_stats[c.case_type]["disposed"] += 1 - - # Ripeness distribution - active_cases = [c for c in cases if not c.is_disposed] - ripeness_dist = {} - for c in active_cases: - status = c.ripeness_status - ripeness_dist[status] = ripeness_dist.get(status, 0) + 1 - - report_path = Path(args.log_dir)/"report.txt" if args.log_dir else Path("report.txt") - report_path.parent.mkdir(parents=True, exist_ok=True) - with report_path.open("w", encoding="utf-8") as rf: - rf.write("=" * 80 + "\n") - rf.write("SIMULATION REPORT\n") - rf.write("=" * 80 + "\n\n") - - rf.write(f"Configuration:\n") - rf.write(f" Cases: {len(cases)}\n") - rf.write(f" Days simulated: {args.days}\n") - rf.write(f" Policy: {args.policy}\n") - rf.write(f" Horizon end: {res.end_date}\n\n") - - rf.write(f"Hearing Metrics:\n") - rf.write(f" Total hearings: {res.hearings_total:,}\n") - rf.write(f" Heard: {res.hearings_heard:,} ({res.hearings_heard/max(1,res.hearings_total):.1%})\n") - rf.write(f" Adjourned: {res.hearings_adjourned:,} ({res.hearings_adjourned/max(1,res.hearings_total):.1%})\n\n") - - rf.write(f"Disposal Metrics:\n") - rf.write(f" Cases disposed: {res.disposals:,}\n") - rf.write(f" Disposal rate: {res.disposals/len(cases):.1%}\n") - rf.write(f" Gini coefficient: {gini_disp:.3f}\n\n") - - rf.write(f"Disposal Rates by Case Type:\n") - for ct in sorted(case_type_stats.keys()): - stats = case_type_stats[ct] - rate = (stats["disposed"] / stats["total"] * 100) if stats["total"] > 0 else 0 - rf.write(f" {ct:4s}: {stats['disposed']:4d}/{stats['total']:4d} ({rate:5.1f}%)\n") - rf.write("\n") - - rf.write(f"Efficiency Metrics:\n") - rf.write(f" Court utilization: {res.utilization:.1%}\n") - rf.write(f" Avg hearings/day: {res.hearings_total/args.days:.1f}\n\n") - - rf.write(f"Ripeness Impact:\n") - rf.write(f" Transitions: {res.ripeness_transitions:,}\n") - rf.write(f" Cases filtered (unripe): {res.unripe_filtered:,}\n") - if res.hearings_total + res.unripe_filtered > 0: - rf.write(f" Filter rate: {res.unripe_filtered/(res.hearings_total + res.unripe_filtered):.1%}\n") - rf.write("\nFinal Ripeness Distribution:\n") - for status in sorted(ripeness_dist.keys()): - count = ripeness_dist[status] - pct = (count / len(active_cases) * 100) if active_cases else 0 - rf.write(f" {status}: {count} ({pct:.1f}%)\n") - - # Courtroom allocation metrics - if allocator_stats: - rf.write("\nCourtroom Allocation:\n") - rf.write(f" Strategy: load_balanced\n") - rf.write(f" Load balance fairness (Gini): {allocator_stats['load_balance_gini']:.3f}\n") - rf.write(f" Avg daily load: {allocator_stats['avg_daily_load']:.1f} cases\n") - rf.write(f" Allocation changes: {allocator_stats['allocation_changes']:,}\n") - rf.write(f" Capacity rejections: {allocator_stats['capacity_rejections']:,}\n\n") - rf.write(" Courtroom-wise totals:\n") - for cid in range(1, sim.cfg.courtrooms + 1): - total = allocator_stats['courtroom_totals'][cid] - avg = allocator_stats['courtroom_averages'][cid] - rf.write(f" Courtroom {cid}: {total:,} cases ({avg:.1f}/day)\n") - - print("\n" + "=" * 80) - print("SIMULATION SUMMARY") - print("=" * 80) - print(f"\nHorizon: {cfg.start} → {res.end_date} ({args.days} days)") - print(f"\nHearing Metrics:") - print(f" Total: {res.hearings_total:,}") - print(f" Heard: {res.hearings_heard:,} ({res.hearings_heard/max(1,res.hearings_total):.1%})") - print(f" Adjourned: {res.hearings_adjourned:,} ({res.hearings_adjourned/max(1,res.hearings_total):.1%})") - print(f"\nDisposal Metrics:") - print(f" Cases disposed: {res.disposals:,} ({res.disposals/len(cases):.1%})") - print(f" Gini coefficient: {gini_disp:.3f}") - print(f"\nEfficiency:") - print(f" Utilization: {res.utilization:.1%}") - print(f" Avg hearings/day: {res.hearings_total/args.days:.1f}") - print(f"\nRipeness Impact:") - print(f" Transitions: {res.ripeness_transitions:,}") - print(f" Cases filtered: {res.unripe_filtered:,}") - print(f"\n✓ Report saved to: {report_path}") - - -if __name__ == "__main__": - main() diff --git a/scripts/suggest_schedule.py b/scripts/suggest_schedule.py deleted file mode 100644 index ed0cdf338e6e80f13f5ff2ea7b83fd4bbcba0623..0000000000000000000000000000000000000000 --- a/scripts/suggest_schedule.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import argparse -from datetime import date -from pathlib import Path -import csv -import sys, os - -# Ensure project root on sys.path -sys.path.append(os.path.dirname(os.path.dirname(__file__))) - -from scheduler.data.case_generator import CaseGenerator -from scheduler.core.case import Case, CaseStatus -from scheduler.core.courtroom import Courtroom -from scheduler.utils.calendar import CourtCalendar -from scheduler.data.config import DEFAULT_DAILY_CAPACITY, COURTROOMS, MIN_GAP_BETWEEN_HEARINGS - - -def main(): - ap = argparse.ArgumentParser(description="Suggest a non-binding daily cause list with explanations.") - ap.add_argument("--cases-csv", type=str, default="data/generated/cases.csv") - ap.add_argument("--date", type=str, default=None, help="YYYY-MM-DD; default next working day") - ap.add_argument("--policy", choices=["fifo", "age", "readiness"], default="readiness") - ap.add_argument("--out", type=str, default="data/suggestions.csv") - args = ap.parse_args() - - cal = CourtCalendar() - path = Path(args.cases_csv) - if not path.exists(): - print(f"Cases CSV not found: {path}") - sys.exit(1) - cases = CaseGenerator.from_csv(path) - - today = date.today() - if args.date: - target = date.fromisoformat(args.date) - else: - target = cal.next_working_day(today, 1) - - # update states - for c in cases: - c.update_age(target) - c.compute_readiness_score() - - # policy ordering - eligible = [c for c in cases if c.status != CaseStatus.DISPOSED and c.is_ready_for_scheduling(MIN_GAP_BETWEEN_HEARINGS)] - if args.policy == "fifo": - eligible.sort(key=lambda c: c.filed_date) - elif args.policy == "age": - eligible.sort(key=lambda c: c.age_days, reverse=True) - else: - eligible.sort(key=lambda c: c.get_priority_score(), reverse=True) - - rooms = [Courtroom(courtroom_id=i + 1, judge_id=f"J{i+1:03d}", daily_capacity=DEFAULT_DAILY_CAPACITY) for i in range(COURTROOMS)] - remaining = {r.courtroom_id: r.daily_capacity for r in rooms} - - out = Path(args.out) - out.parent.mkdir(parents=True, exist_ok=True) - with out.open("w", newline="") as f: - w = csv.writer(f) - w.writerow(["case_id", "courtroom_id", "policy", "age_days", "readiness_score", "urgent", "stage", "days_since_last_hearing", "note"]) - ridx = 0 - for c in eligible: - # find a room with capacity - attempts = 0 - while attempts < len(rooms) and remaining[rooms[ridx].courtroom_id] == 0: - ridx = (ridx + 1) % len(rooms) - attempts += 1 - if attempts >= len(rooms): - break - room = rooms[ridx] - remaining[room.courtroom_id] -= 1 - note = "Suggestive recommendation; final listing subject to registrar/judge review" - w.writerow([c.case_id, room.courtroom_id, args.policy, c.age_days, f"{c.readiness_score:.3f}", int(c.is_urgent), c.current_stage, c.days_since_last_hearing, note]) - ridx = (ridx + 1) % len(rooms) - - print(f"Wrote suggestions for {target} to {out}") - - -if __name__ == "__main__": - main() diff --git a/scripts/validate_policy.py b/scripts/validate_policy.py deleted file mode 100644 index b5a83f9660dd9106262b01ed860bde13e76d5023..0000000000000000000000000000000000000000 --- a/scripts/validate_policy.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Validation harness for scheduler policies (minimal, Phase 1 compatible). - -Runs a lightweight scheduling loop over a short horizon to compute: -- Utilization -- Urgency SLA (7 working days) -- Constraint violations: capacity overflow, weekend/holiday scheduling - -Policies supported: fifo, age, readiness - -Run: - uv run --no-project python scripts/validate_policy.py --policy readiness --replications 10 --days 20 -""" -from __future__ import annotations - -import argparse -import random -from dataclasses import dataclass -from datetime import date, timedelta -from typing import Dict, List, Tuple -import sys, os - -# Ensure project root is on sys.path when running as a script -sys.path.append(os.path.dirname(os.path.dirname(__file__))) - -from scheduler.core.case import Case -from scheduler.core.courtroom import Courtroom -from scheduler.core.judge import Judge -from scheduler.utils.calendar import CourtCalendar -from scheduler.data.config import ( - CASE_TYPE_DISTRIBUTION, - URGENT_CASE_PERCENTAGE, - DEFAULT_DAILY_CAPACITY, - COURTROOMS, -) -from scheduler.metrics.basic import utilization, urgency_sla - - -@dataclass -class KPIResult: - utilization: float - urgent_sla: float - capacity_overflows: int - weekend_violations: int - - -def sample_case_type() -> str: - items = list(CASE_TYPE_DISTRIBUTION.items()) - r = random.random() - acc = 0.0 - for ct, p in items: - acc += p - if r <= acc: - return ct - return items[-1][0] - - -def working_days_diff(cal: CourtCalendar, start: date, end: date) -> int: - if end < start: - return 0 - return cal.working_days_between(start, end) - - -def build_cases(n: int, start_date: date, cal: CourtCalendar) -> List[Case]: - cases: List[Case] = [] - # spread filings across the first 10 working days - wd = cal.generate_court_calendar(start_date, start_date + timedelta(days=30))[:10] - for i in range(n): - filed = wd[i % len(wd)] - ct = sample_case_type() - urgent = random.random() < URGENT_CASE_PERCENTAGE - cases.append( - Case(case_id=f"C{i:05d}", case_type=ct, filed_date=filed, current_stage="ADMISSION", is_urgent=urgent) - ) - return cases - - -def choose_order(policy: str, cases: List[Case]) -> List[Case]: - if policy == "fifo": - return sorted(cases, key=lambda c: c.filed_date) - if policy == "age": - # older first: we use age_days which caller must update - return sorted(cases, key=lambda c: c.age_days, reverse=True) - if policy == "readiness": - # use priority which includes urgency and readiness - return sorted(cases, key=lambda c: c.get_priority_score(), reverse=True) - return cases - - -def run_replication(policy: str, seed: int, days: int) -> KPIResult: - random.seed(seed) - cal = CourtCalendar() - cal.add_standard_holidays(date.today().year) - - # build courtrooms and judges - rooms = [Courtroom(courtroom_id=i + 1, judge_id=f"J{i+1:03d}", daily_capacity=DEFAULT_DAILY_CAPACITY) for i in range(COURTROOMS)] - judges = [Judge(judge_id=f"J{i+1:03d}", name=f"Justice {i+1}", courtroom_id=i + 1) for i in range(COURTROOMS)] - - # build cases - start = date.today().replace(day=1) # arbitrary start of month - cases = build_cases(n=COURTROOMS * DEFAULT_DAILY_CAPACITY, start_date=start, cal=cal) - - # horizon - working_days = cal.generate_court_calendar(start, start + timedelta(days=days + 30))[:days] - - scheduled = 0 - urgent_records: List[Tuple[bool, int]] = [] - capacity_overflows = 0 - weekend_violations = 0 - - unscheduled = set(c.case_id for c in cases) - - for d in working_days: - # sanity: weekend should be excluded by calendar, but check - if d.weekday() >= 5: - weekend_violations += 1 - - # update ages and readiness before scheduling - for c in cases: - c.update_age(d) - c.compute_readiness_score() - - # order cases by policy - ordered = [c for c in choose_order(policy, cases) if c.case_id in unscheduled] - - # fill capacity across rooms round-robin - remaining_capacity = {r.courtroom_id: r.get_capacity_for_date(d) if hasattr(r, "get_capacity_for_date") else r.daily_capacity for r in rooms} - total_capacity_today = sum(remaining_capacity.values()) - filled_today = 0 - - ridx = 0 - for c in ordered: - if filled_today >= total_capacity_today: - break - # find next room with capacity - attempts = 0 - while attempts < len(rooms) and remaining_capacity[rooms[ridx].courtroom_id] == 0: - ridx = (ridx + 1) % len(rooms) - attempts += 1 - if attempts >= len(rooms): - break - room = rooms[ridx] - if room.can_schedule(d, c.case_id): - room.schedule_case(d, c.case_id) - remaining_capacity[room.courtroom_id] -= 1 - filled_today += 1 - unscheduled.remove(c.case_id) - # urgency record - urgent_records.append((c.is_urgent, working_days_diff(cal, c.filed_date, d))) - ridx = (ridx + 1) % len(rooms) - - # capacity check - for room in rooms: - day_sched = room.get_daily_schedule(d) - if len(day_sched) > room.daily_capacity: - capacity_overflows += 1 - - scheduled += filled_today - - if not unscheduled: - break - - # compute KPIs - total_capacity = sum(r.daily_capacity for r in rooms) * len(working_days) - util = utilization(scheduled, total_capacity) - urgent = urgency_sla(urgent_records, days=7) - - return KPIResult(utilization=util, urgent_sla=urgent, capacity_overflows=capacity_overflows, weekend_violations=weekend_violations) - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--policy", choices=["fifo", "age", "readiness"], default="readiness") - ap.add_argument("--replications", type=int, default=5) - ap.add_argument("--days", type=int, default=20, help="working days horizon") - ap.add_argument("--seed", type=int, default=42) - ap.add_argument("--cases-csv", type=str, default=None, help="Path to pre-generated cases CSV") - args = ap.parse_args() - - print("== Validation Run ==") - print(f"Policy: {args.policy}") - print(f"Replications: {args.replications}, Horizon (working days): {args.days}") - if args.cases_csv: - print(f"Cases source: {args.cases_csv}") - - results: List[KPIResult] = [] - - # If cases CSV is provided, load once and close over a custom replication that reuses them - if args.cases_csv: - from pathlib import Path - from scheduler.data.case_generator import CaseGenerator - preload = CaseGenerator.from_csv(Path(args.cases_csv)) - - def run_with_preloaded(policy: str, seed: int, days: int) -> KPIResult: - # Same as run_replication, but replace built cases with preloaded - import random - random.seed(seed) - cal = CourtCalendar() - cal.add_standard_holidays(date.today().year) - rooms = [Courtroom(courtroom_id=i + 1, judge_id=f"J{i+1:03d}", daily_capacity=DEFAULT_DAILY_CAPACITY) for i in range(COURTROOMS)] - start = date.today().replace(day=1) - cases = list(preload) # shallow copy - working_days = cal.generate_court_calendar(start, start + timedelta(days=days + 30))[:days] - scheduled = 0 - urgent_records: List[Tuple[bool, int]] = [] - capacity_overflows = 0 - weekend_violations = 0 - unscheduled = set(c.case_id for c in cases) - for d in working_days: - if d.weekday() >= 5: - weekend_violations += 1 - for c in cases: - c.update_age(d) - c.compute_readiness_score() - ordered = [c for c in choose_order(policy, cases) if c.case_id in unscheduled] - remaining_capacity = {r.courtroom_id: r.get_capacity_for_date(d) if hasattr(r, "get_capacity_for_date") else r.daily_capacity for r in rooms} - total_capacity_today = sum(remaining_capacity.values()) - filled_today = 0 - ridx = 0 - for c in ordered: - if filled_today >= total_capacity_today: - break - attempts = 0 - while attempts < len(rooms) and remaining_capacity[rooms[ridx].courtroom_id] == 0: - ridx = (ridx + 1) % len(rooms) - attempts += 1 - if attempts >= len(rooms): - break - room = rooms[ridx] - if room.can_schedule(d, c.case_id): - room.schedule_case(d, c.case_id) - remaining_capacity[room.courtroom_id] -= 1 - filled_today += 1 - unscheduled.remove(c.case_id) - urgent_records.append((c.is_urgent, working_days_diff(cal, c.filed_date, d))) - ridx = (ridx + 1) % len(rooms) - for room in rooms: - day_sched = room.get_daily_schedule(d) - if len(day_sched) > room.daily_capacity: - capacity_overflows += 1 - scheduled += filled_today - if not unscheduled: - break - total_capacity = sum(r.daily_capacity for r in rooms) * len(working_days) - util = utilization(scheduled, total_capacity) - urgent = urgency_sla(urgent_records, days=7) - return KPIResult(utilization=util, urgent_sla=urgent, capacity_overflows=capacity_overflows, weekend_violations=weekend_violations) - - for i in range(args.replications): - results.append(run_with_preloaded(args.policy, args.seed + i, args.days)) - else: - for i in range(args.replications): - res = run_replication(args.policy, args.seed + i, args.days) - results.append(res) - - # aggregate - util_vals = [r.utilization for r in results] - urgent_vals = [r.urgent_sla for r in results] - cap_viol = sum(r.capacity_overflows for r in results) - wknd_viol = sum(r.weekend_violations for r in results) - - def mean(xs: List[float]) -> float: - return sum(xs) / len(xs) if xs else 0.0 - - print("\n-- KPIs --") - print(f"Utilization (mean): {mean(util_vals):.2%}") - print(f"Urgent SLA<=7d (mean): {mean(urgent_vals):.2%}") - - print("\n-- Constraint Violations (should be 0) --") - print(f"Capacity overflows: {cap_viol}") - print(f"Weekend/holiday scheduling: {wknd_viol}") - - print("\nNote: This is a lightweight harness for Phase 1; fairness metrics (e.g., Gini of disposal times) will be computed after Phase 3 when full simulation is available.") - - -if __name__ == "__main__": - main() diff --git a/scripts/verify_disposal_logic.py b/scripts/verify_disposal_logic.py deleted file mode 100644 index 530c979f5d560850856c6f237f699d5a8b066c17..0000000000000000000000000000000000000000 --- a/scripts/verify_disposal_logic.py +++ /dev/null @@ -1,29 +0,0 @@ -import polars as pl -from pathlib import Path - -REPORTS_DIR = Path("reports/figures/v0.4.0_20251119_171426") -cases = pl.read_parquet(REPORTS_DIR / "cases_clean.parquet") -hearings = pl.read_parquet(REPORTS_DIR / "hearings_clean.parquet") - -print(f"Total cases: {len(cases)}") -# Cases table only contains Disposed cases (from EDA description) -disposed_count = len(cases) - -# Get last hearing stage for each case -last_hearing = hearings.sort("BusinessOnDate").group_by("CNR_NUMBER").last() -joined = cases.join(last_hearing, on="CNR_NUMBER", how="left") - -# Check how many cases are marked disposed but don't end in FINAL DISPOSAL -non_final = joined.filter( - (pl.col("Remappedstages") != "FINAL DISPOSAL") & - (pl.col("Remappedstages") != "NA") & - (pl.col("Remappedstages").is_not_null()) -) - -print(f"Total Disposed Cases: {disposed_count}") -print(f"Cases ending in FINAL DISPOSAL: {len(joined.filter(pl.col('Remappedstages') == 'FINAL DISPOSAL'))}") -print(f"Cases ending in NA: {len(joined.filter(pl.col('Remappedstages') == 'NA'))}") -print(f"Cases ending in other stages: {len(non_final)}") - -print("\nTop terminal stages for 'Disposed' cases:") -print(non_final["Remappedstages"].value_counts().sort("count", descending=True).head(5)) diff --git a/scripts/verify_disposal_rates.py b/scripts/verify_disposal_rates.py deleted file mode 100644 index bda6d958e4e8076f38ac034a88de02453ee40ba3..0000000000000000000000000000000000000000 --- a/scripts/verify_disposal_rates.py +++ /dev/null @@ -1,20 +0,0 @@ -import pandas as pd -from scheduler.data.param_loader import load_parameters - -events = pd.read_csv('runs/two_year_clean/events.csv') -disposals = events[events['type'] == 'disposed'] -type_counts = disposals['case_type'].value_counts() -total_counts = pd.read_csv('data/generated/cases_final.csv')['case_type'].value_counts() -disposal_rate = (type_counts / total_counts * 100).sort_values(ascending=False) - -print('Disposal Rate by Case Type (% disposed in 2 years):') -for ct, rate in disposal_rate.items(): - print(f' {ct}: {rate:.1f}%') - -p = load_parameters() -print('\nExpected ordering by speed (fast to slow based on EDA median):') -stats = [(ct, p.get_case_type_stats(ct)['disp_median']) for ct in disposal_rate.index] -stats.sort(key=lambda x: x[1]) -print(' ' + ' > '.join([f'{ct} ({int(d)}d)' for ct, d in stats])) - -print('\nValidation: Higher disposal rates should correlate with faster (lower) median days.') diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index 20670d7b16c4a32fb91585cf8671646f4c055645..0000000000000000000000000000000000000000 --- a/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""EDA pipeline modules.""" diff --git a/src/eda_config.py b/src/eda_config.py deleted file mode 100644 index 45fe06737c4fe31374130eacb2e70d4ac606c57a..0000000000000000000000000000000000000000 --- a/src/eda_config.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Shared configuration and helpers for EDA pipeline.""" - -import json -import shutil -from datetime import datetime -from pathlib import Path - -# ------------------------------------------------------------------- -# Paths and versioning -# ------------------------------------------------------------------- -# Project root (repo root) = parent of src/ -PROJECT_ROOT = Path(__file__).resolve().parents[1] - -DATA_DIR = PROJECT_ROOT / "Data" -DUCKDB_FILE = DATA_DIR / "court_data.duckdb" -CASES_FILE = DATA_DIR / "ISDMHack_Cases_WPfinal.csv" -HEAR_FILE = DATA_DIR / "ISDMHack_Hear.csv" - -# Default paths (used when EDA is run standalone) -REPORTS_DIR = PROJECT_ROOT / "reports" -FIGURES_DIR = REPORTS_DIR / "figures" - -VERSION = "v0.4.0" -RUN_TS = datetime.now().strftime("%Y%m%d_%H%M%S") - -# These will be set by set_output_paths() when running from pipeline -RUN_DIR = None -PARAMS_DIR = None -CASES_CLEAN_PARQUET = None -HEARINGS_CLEAN_PARQUET = None - - -def set_output_paths(eda_dir: Path, data_dir: Path, params_dir: Path): - """Configure output paths from OutputManager. - - Call this from pipeline before running EDA modules. - When not called, falls back to legacy reports/figures/ structure. - """ - global RUN_DIR, PARAMS_DIR, CASES_CLEAN_PARQUET, HEARINGS_CLEAN_PARQUET - RUN_DIR = eda_dir - PARAMS_DIR = params_dir - CASES_CLEAN_PARQUET = data_dir / "cases_clean.parquet" - HEARINGS_CLEAN_PARQUET = data_dir / "hearings_clean.parquet" - - # Ensure directories exist - RUN_DIR.mkdir(parents=True, exist_ok=True) - PARAMS_DIR.mkdir(parents=True, exist_ok=True) - - -def _get_run_dir() -> Path: - """Get RUN_DIR, creating default if not set.""" - global RUN_DIR - if RUN_DIR is None: - # Standalone mode: use legacy versioned directory - FIGURES_DIR.mkdir(parents=True, exist_ok=True) - RUN_DIR = FIGURES_DIR / f"{VERSION}_{RUN_TS}" - RUN_DIR.mkdir(parents=True, exist_ok=True) - return RUN_DIR - - -def _get_params_dir() -> Path: - """Get PARAMS_DIR, creating default if not set.""" - global PARAMS_DIR - if PARAMS_DIR is None: - run_dir = _get_run_dir() - PARAMS_DIR = run_dir / "params" - PARAMS_DIR.mkdir(parents=True, exist_ok=True) - return PARAMS_DIR - - -def _get_cases_parquet() -> Path: - """Get CASES_CLEAN_PARQUET path.""" - global CASES_CLEAN_PARQUET - if CASES_CLEAN_PARQUET is None: - CASES_CLEAN_PARQUET = _get_run_dir() / "cases_clean.parquet" - return CASES_CLEAN_PARQUET - - -def _get_hearings_parquet() -> Path: - """Get HEARINGS_CLEAN_PARQUET path.""" - global HEARINGS_CLEAN_PARQUET - if HEARINGS_CLEAN_PARQUET is None: - HEARINGS_CLEAN_PARQUET = _get_run_dir() / "hearings_clean.parquet" - return HEARINGS_CLEAN_PARQUET - -# ------------------------------------------------------------------- -# Null tokens and canonicalisation -# ------------------------------------------------------------------- -NULL_TOKENS = ["", "NULL", "Null", "null", "NA", "N/A", "na", "NaN", "nan", "-", "--"] - - -def copy_to_versioned(filename: str) -> None: - """Deprecated: No longer needed with OutputManager.""" - pass - - -def write_metadata(meta: dict) -> None: - """Write run metadata into RUN_DIR/metadata.json.""" - run_dir = _get_run_dir() - meta_path = run_dir / "metadata.json" - try: - with open(meta_path, "w", encoding="utf-8") as f: - json.dump(meta, f, indent=2, default=str) - except Exception as e: - print(f"[WARN] Metadata export error: {e}") - - -def safe_write_figure(fig, filename: str) -> None: - """Write plotly figure to EDA figures directory. - - Args: - fig: Plotly figure object - filename: HTML filename (e.g., "1_case_type_distribution.html") - """ - run_dir = _get_run_dir() - output_path = run_dir / filename - try: - fig.write_html(str(output_path)) - except Exception as e: - raise RuntimeError(f"Failed to write {filename} to {output_path}: {e}") diff --git a/src/eda_exploration.py b/src/eda_exploration.py deleted file mode 100644 index 71555ebf852b9e3c4f052ce5c8fef419f75a2c86..0000000000000000000000000000000000000000 --- a/src/eda_exploration.py +++ /dev/null @@ -1,494 +0,0 @@ -"""Module 2: Visual and descriptive EDA. - -Responsibilities: -- Case type distribution, filing trends, disposal distribution. -- Hearing gap distributions by type. -- Stage transition Sankey & stage bottlenecks. -- Cohorts by filing year. -- Seasonality and monthly anomalies. -- Judge and courtroom workload. -- Purpose tags and stage frequency. - -Inputs: -- Cleaned Parquet from eda_load_clean. - -Outputs: -- Interactive HTML plots in FIGURES_DIR and versioned copies in _get_run_dir(). -- Some CSV summaries (e.g., stage_duration.csv, transitions.csv, monthly_anomalies.csv). -""" - -from datetime import timedelta - -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go -import plotly.io as pio -import polars as pl -from src.eda_config import ( - _get_cases_parquet, - FIGURES_DIR, - _get_hearings_parquet, - _get_run_dir, - safe_write_figure, -) - -pio.renderers.default = "browser" - - -def load_cleaned(): - cases = pl.read_parquet(_get_cases_parquet()) - hearings = pl.read_parquet(_get_hearings_parquet()) - print("Loaded cleaned data for exploration") - print("Cases:", cases.shape, "Hearings:", hearings.shape) - return cases, hearings - - -def run_exploration() -> None: - cases, hearings = load_cleaned() - cases_pd = cases.to_pandas() - hearings_pd = hearings.to_pandas() - - # -------------------------------------------------- - # 1. Case Type Distribution - # -------------------------------------------------- - fig1 = px.bar( - cases_pd, - x="CASE_TYPE", - color="CASE_TYPE", - title="Case Type Distribution", - ) - fig1.update_layout(showlegend=False, xaxis_title="Case Type", yaxis_title="Number of Cases") - safe_write_figure(fig1, "1_case_type_distribution.html") - - # -------------------------------------------------- - # 2. Filing Trends by Year - # -------------------------------------------------- - if "YEAR_FILED" in cases_pd.columns: - year_counts = cases_pd.groupby("YEAR_FILED")["CNR_NUMBER"].count().reset_index(name="Count") - fig2 = px.line( - year_counts, x="YEAR_FILED", y="Count", markers=True, title="Cases Filed by Year" - ) - fig2.update_traces(line_color="royalblue") - fig2.update_layout(xaxis=dict(rangeslider=dict(visible=True))) - f2 = "2_cases_filed_by_year.html" - safe_write_figure(fig2, f2) - - # -------------------------------------------------- - # 3. Disposal Duration Distribution - # -------------------------------------------------- - if "DISPOSALTIME_ADJ" in cases_pd.columns: - fig3 = px.histogram( - cases_pd, - x="DISPOSALTIME_ADJ", - nbins=50, - title="Distribution of Disposal Time (Adjusted Days)", - color_discrete_sequence=["indianred"], - ) - fig3.update_layout(xaxis_title="Days", yaxis_title="Cases") - f3 = "3_disposal_time_distribution.html" - safe_write_figure(fig3, f3) - - # -------------------------------------------------- - # 4. Hearings vs Disposal Time - # -------------------------------------------------- - if {"N_HEARINGS", "DISPOSALTIME_ADJ"}.issubset(cases_pd.columns): - fig4 = px.scatter( - cases_pd, - x="N_HEARINGS", - y="DISPOSALTIME_ADJ", - color="CASE_TYPE", - hover_data=["CNR_NUMBER", "YEAR_FILED"], - title="Hearings vs Disposal Duration", - ) - fig4.update_traces(marker=dict(size=6, opacity=0.7)) - f4 = "4_hearings_vs_disposal.html" - safe_write_figure(fig4, f4) - - # -------------------------------------------------- - # 5. Boxplot by Case Type - # -------------------------------------------------- - fig5 = px.box( - cases_pd, - x="CASE_TYPE", - y="DISPOSALTIME_ADJ", - color="CASE_TYPE", - title="Disposal Time (Adjusted) by Case Type", - ) - fig5.update_layout(showlegend=False) - f5 = "5_box_disposal_by_type.html" - safe_write_figure(fig5, f5) - - # -------------------------------------------------- - # 6. Stage Frequency - # -------------------------------------------------- - if "Remappedstages" in hearings_pd.columns: - stage_counts = hearings_pd["Remappedstages"].value_counts().reset_index() - stage_counts.columns = ["Stage", "Count"] - fig6 = px.bar( - stage_counts, - x="Stage", - y="Count", - color="Stage", - title="Frequency of Hearing Stages", - ) - fig6.update_layout(showlegend=False, xaxis_title="Stage", yaxis_title="Count") - f6 = "6_stage_frequency.html" - safe_write_figure(fig6, f6) - - # -------------------------------------------------- - # 7. Gap median by case type - # -------------------------------------------------- - if "GAP_MEDIAN" in cases_pd.columns: - fig_gap = px.box( - cases_pd, - x="CASE_TYPE", - y="GAP_MEDIAN", - points=False, - title="Median Hearing Gap by Case Type", - ) - fg = "9_gap_median_by_type.html" - safe_write_figure(fig_gap, fg) - - # -------------------------------------------------- - # 8. Stage transitions & bottleneck plot - # -------------------------------------------------- - stage_col = "Remappedstages" if "Remappedstages" in hearings.columns else None - transitions = None - stage_duration = None - if stage_col and "BusinessOnDate" in hearings.columns: - STAGE_ORDER = [ - "PRE-ADMISSION", - "ADMISSION", - "FRAMING OF CHARGES", - "EVIDENCE", - "ARGUMENTS", - "INTERLOCUTORY APPLICATION", - "SETTLEMENT", - "ORDERS / JUDGMENT", - "FINAL DISPOSAL", - "OTHER", - "NA", - ] - order_idx = {s: i for i, s in enumerate(STAGE_ORDER)} - - h_stage = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .sort(["CNR_NUMBER", "BusinessOnDate"]) - .with_columns( - [ - pl.col(stage_col) - .fill_null("NA") - .map_elements( - lambda s: s if s in STAGE_ORDER else ("OTHER" if s is not None else "NA") - ) - .alias("STAGE"), - pl.col("BusinessOnDate").alias("DT"), - ] - ) - .with_columns( - [ - (pl.col("STAGE") != pl.col("STAGE").shift(1)) - .over("CNR_NUMBER") - .alias("STAGE_CHANGE"), - ] - ) - ) - - transitions_raw = ( - h_stage.with_columns( - [ - pl.col("STAGE").alias("STAGE_FROM"), - pl.col("STAGE").shift(-1).over("CNR_NUMBER").alias("STAGE_TO"), - ] - ) - .filter(pl.col("STAGE_TO").is_not_null()) - .group_by(["STAGE_FROM", "STAGE_TO"]) - .agg(pl.len().alias("N")) - ) - - transitions = transitions_raw.filter( - pl.col("STAGE_FROM").map_elements(lambda s: order_idx.get(s, 10)) - <= pl.col("STAGE_TO").map_elements(lambda s: order_idx.get(s, 10)) - ).sort("N", descending=True) - - transitions.write_csv(str(_get_run_dir() / "transitions.csv")) - - runs = ( - h_stage.with_columns( - [ - pl.when(pl.col("STAGE_CHANGE")) - .then(1) - .otherwise(0) - .cum_sum() - .over("CNR_NUMBER") - .alias("RUN_ID") - ] - ) - .group_by(["CNR_NUMBER", "STAGE", "RUN_ID"]) - .agg( - [ - pl.col("DT").min().alias("RUN_START"), - pl.col("DT").max().alias("RUN_END"), - pl.len().alias("HEARINGS_IN_RUN"), - ] - ) - .with_columns( - ((pl.col("RUN_END") - pl.col("RUN_START")) / timedelta(days=1)).alias("RUN_DAYS") - ) - ) - stage_duration = ( - runs.group_by("STAGE") - .agg( - [ - pl.col("RUN_DAYS").median().alias("RUN_MEDIAN_DAYS"), - pl.col("RUN_DAYS").mean().alias("RUN_MEAN_DAYS"), - pl.col("HEARINGS_IN_RUN").median().alias("HEARINGS_PER_RUN_MED"), - pl.len().alias("N_RUNS"), - ] - ) - .sort("RUN_MEDIAN_DAYS", descending=True) - ) - stage_duration.write_csv(str(_get_run_dir() / "stage_duration.csv")) - - # Sankey - try: - tr_df = transitions.to_pandas() - labels = [ - s - for s in STAGE_ORDER - if s in set(tr_df["STAGE_FROM"]).union(set(tr_df["STAGE_TO"])) - ] - idx = {l: i for i, l in enumerate(labels)} - tr_df = tr_df[tr_df["STAGE_FROM"].isin(labels) & tr_df["STAGE_TO"].isin(labels)].copy() - tr_df = tr_df.sort_values(by=["STAGE_FROM", "STAGE_TO"], key=lambda c: c.map(idx)) - sankey = go.Figure( - data=[ - go.Sankey( - arrangement="snap", - node=dict(label=labels, pad=15, thickness=18), - link=dict( - source=tr_df["STAGE_FROM"].map(idx).tolist(), - target=tr_df["STAGE_TO"].map(idx).tolist(), - value=tr_df["N"].tolist(), - ), - ) - ] - ) - sankey.update_layout(title_text="Stage Transition Sankey (Ordered)") - f10 = "10_stage_transition_sankey.html" - safe_write_figure(sankey, f10) - except Exception as e: - print("Sankey error:", e) - - # Bottleneck impact - try: - st_pd = stage_duration.with_columns( - (pl.col("RUN_MEDIAN_DAYS") * pl.col("N_RUNS")).alias("IMPACT") - ).to_pandas() - fig_b = px.bar( - st_pd.sort_values("IMPACT", ascending=False), - x="STAGE", - y="IMPACT", - title="Stage Bottleneck Impact (Median Days x Runs)", - ) - fb = "15_bottleneck_impact.html" - safe_write_figure(fig_b, fb) - except Exception as e: - print("Bottleneck plot error:", e) - - # -------------------------------------------------- - # 9. Monthly seasonality and anomalies - # -------------------------------------------------- - if "BusinessOnDate" in hearings.columns: - m_hear = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .with_columns( - [ - pl.col("BusinessOnDate").dt.year().alias("Y"), - pl.col("BusinessOnDate").dt.month().alias("M"), - ] - ) - .with_columns(pl.date(pl.col("Y"), pl.col("M"), pl.lit(1)).alias("YM")) - ) - monthly_listings = m_hear.group_by("YM").agg(pl.len().alias("N_HEARINGS")).sort("YM") - monthly_listings.write_csv(str(_get_run_dir() / "monthly_hearings.csv")) - - try: - fig_m = px.line( - monthly_listings.to_pandas(), - x="YM", - y="N_HEARINGS", - title="Monthly Hearings Listed", - ) - fig_m.update_layout(yaxis=dict(tickformat=",d")) - fm = "11_monthly_hearings.html" - safe_write_figure(fig_m, fm) - except Exception as e: - print("Monthly listings error:", e) - - # Waterfall + anomalies - try: - ml = monthly_listings.with_columns( - [ - pl.col("N_HEARINGS").shift(1).alias("PREV"), - (pl.col("N_HEARINGS") - pl.col("N_HEARINGS").shift(1)).alias("DELTA"), - ] - ) - ml_pd = ml.to_pandas() - ml_pd["ROLL_MEAN"] = ml_pd["N_HEARINGS"].rolling(window=12, min_periods=6).mean() - ml_pd["ROLL_STD"] = ml_pd["N_HEARINGS"].rolling(window=12, min_periods=6).std() - ml_pd["Z"] = (ml_pd["N_HEARINGS"] - ml_pd["ROLL_MEAN"]) / ml_pd["ROLL_STD"] - ml_pd["ANOM"] = ml_pd["Z"].abs() >= 3.0 - - measures = ["relative"] * len(ml_pd) - measures[0] = "absolute" - y_vals = ml_pd["DELTA"].astype(float).fillna(ml_pd["N_HEARINGS"].astype(float)).tolist() - - fig_w = go.Figure( - go.Waterfall( - x=ml_pd["YM"], - measure=measures, - y=y_vals, - text=[f"{int(v):,}" if pd.notnull(v) else "" for v in ml_pd["N_HEARINGS"]], - increasing=dict(marker=dict(color="seagreen")), - decreasing=dict(marker=dict(color="indianred")), - connector={"line": {"color": "rgb(110,110,110)"}}, - ) - ) - fig_w.add_trace( - go.Scatter( - x=ml_pd.loc[ml_pd["ANOM"], "YM"], - y=ml_pd.loc[ml_pd["ANOM"], "N_HEARINGS"], - mode="markers", - marker=dict(color="crimson", size=8), - name="Anomaly (|z|>=3)", - ) - ) - fig_w.update_layout( - title="Monthly Hearings Waterfall (MoM change) with Anomalies", - yaxis=dict(tickformat=",d"), - ) - fw = "11b_monthly_waterfall.html" - safe_write_figure(fig_w, fw) - - ml_pd_out = ml_pd.copy() - ml_pd_out["YM"] = ml_pd_out["YM"].astype(str) - ml_pd_out.to_csv(str(_get_run_dir() / "monthly_anomalies.csv"), index=False) - except Exception as e: - print("Monthly waterfall error:", e) - - # -------------------------------------------------- - # 10. Judge and court workload - # -------------------------------------------------- - judge_col = None - for c in [ - "BeforeHonourableJudge", - "Before Hon'ble Judges", - "Before_Honble_Judges", - "NJDG_JUDGE_NAME", - ]: - if c in hearings.columns: - judge_col = c - break - - if judge_col and "BusinessOnDate" in hearings.columns: - jday = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .group_by([judge_col, "BusinessOnDate"]) - .agg(pl.len().alias("N_HEARINGS")) - ) - try: - fig_j = px.box( - jday.to_pandas(), - x=judge_col, - y="N_HEARINGS", - title="Per-day Hearings per Judge", - ) - fig_j.update_layout( - xaxis={"categoryorder": "total descending"}, yaxis=dict(tickformat=",d") - ) - fj = "12_judge_day_load.html" - safe_write_figure(fig_j, fj) - except Exception as e: - print("Judge workload error:", e) - - court_col = None - for cc in ["COURT_NUMBER", "CourtName"]: - if cc in hearings.columns: - court_col = cc - break - if court_col and "BusinessOnDate" in hearings.columns: - cday = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .group_by([court_col, "BusinessOnDate"]) - .agg(pl.len().alias("N_HEARINGS")) - ) - try: - fig_court = px.box( - cday.to_pandas(), - x=court_col, - y="N_HEARINGS", - title="Per-day Hearings per Courtroom", - ) - fig_court.update_layout( - xaxis={"categoryorder": "total descending"}, yaxis=dict(tickformat=",d") - ) - fc = "12b_court_day_load.html" - safe_write_figure(fig_court, fc) - except Exception as e: - print("Court workload error:", e) - - # -------------------------------------------------- - # 11. Purpose tagging distributions - # -------------------------------------------------- - text_col = None - for c in ["PurposeofHearing", "Purpose of Hearing", "PURPOSE_OF_HEARING"]: - if c in hearings.columns: - text_col = c - break - - def _has_kw_expr(col: str, kws: list[str]): - expr = None - for k in kws: - e = pl.col(col).str.contains(k) - expr = e if expr is None else (expr | e) - return (expr if expr is not None else pl.lit(False)).fill_null(False) - - if text_col: - hear_txt = hearings.with_columns( - pl.col(text_col).cast(pl.Utf8).str.strip_chars().str.to_uppercase().alias("PURPOSE_TXT") - ) - async_kw = ["NON-COMPLIANCE", "OFFICE OBJECTION", "COMPLIANCE", "NOTICE", "SERVICE"] - subs_kw = ["EVIDENCE", "ARGUMENT", "FINAL HEARING", "JUDGMENT", "ORDER", "DISPOSAL"] - hear_txt = hear_txt.with_columns( - pl.when(_has_kw_expr("PURPOSE_TXT", async_kw)) - .then(pl.lit("ASYNC_OR_ADMIN")) - .when(_has_kw_expr("PURPOSE_TXT", subs_kw)) - .then(pl.lit("SUBSTANTIVE")) - .otherwise(pl.lit("UNKNOWN")) - .alias("PURPOSE_TAG") - ) - tag_share = ( - hear_txt.group_by(["CASE_TYPE", "PURPOSE_TAG"]) - .agg(pl.len().alias("N")) - .with_columns((pl.col("N") / pl.col("N").sum().over("CASE_TYPE")).alias("SHARE")) - .sort(["CASE_TYPE", "SHARE"], descending=[False, True]) - ) - tag_share.write_csv(str(_get_run_dir() / "purpose_tag_shares.csv")) - try: - fig_t = px.bar( - tag_share.to_pandas(), - x="CASE_TYPE", - y="SHARE", - color="PURPOSE_TAG", - title="Purpose Tag Shares by Case Type", - barmode="stack", - ) - ft = "14_purpose_tag_shares.html" - safe_write_figure(fig_t, ft) - except Exception as e: - print("Purpose shares error:", e) - - -if __name__ == "__main__": - run_exploration() diff --git a/src/eda_load_clean.py b/src/eda_load_clean.py deleted file mode 100644 index 7743642241d2c5814f9e3806d57c11b9b61ad33f..0000000000000000000000000000000000000000 --- a/src/eda_load_clean.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Module 1: Load, clean, and augment the High Court dataset. - -Responsibilities: -- Read CSVs with robust null handling. -- Normalise key text columns (case type, stages, judge names). -- Basic integrity checks (nulls, duplicates, lifecycle). -- Compute core per-case hearing gap stats (mean/median/std). -- Save cleaned data as Parquet for downstream modules. -""" - -from datetime import timedelta - -import polars as pl -import duckdb -from src.eda_config import ( - _get_cases_parquet, - DUCKDB_FILE, - _get_hearings_parquet, - NULL_TOKENS, - RUN_TS, - VERSION, - write_metadata, -) - - -# ------------------------------------------------------------------- -# Helpers -# ------------------------------------------------------------------- -def _norm_text_col(df: pl.DataFrame, col: str) -> pl.DataFrame: - if col not in df.columns: - return df - return df.with_columns( - pl.when( - pl.col(col) - .cast(pl.Utf8) - .str.strip_chars() - .str.to_uppercase() - .is_in(["", "NA", "N/A", "NULL", "NONE", "-", "--"]) - ) - .then(pl.lit(None)) - .otherwise(pl.col(col).cast(pl.Utf8).str.strip_chars().str.to_uppercase()) - .alias(col) - ) - - -def _null_summary(df: pl.DataFrame, name: str) -> None: - print(f"\n=== Null summary ({name}) ===") - n = df.height - row = {"TABLE": name, "ROWS": n} - for c in df.columns: - row[f"{c}__nulls"] = int(df.select(pl.col(c).is_null().sum()).item()) - print(row) - - -# ------------------------------------------------------------------- -# Main logic -# ------------------------------------------------------------------- -def load_raw() -> tuple[pl.DataFrame, pl.DataFrame]: - from src.eda_config import DUCKDB_FILE, CASES_FILE, HEAR_FILE - try: - import duckdb - if DUCKDB_FILE.exists(): - print(f"Loading raw data from DuckDB: {DUCKDB_FILE}") - conn = duckdb.connect(str(DUCKDB_FILE)) - cases = pl.from_pandas(conn.execute("SELECT * FROM cases").df()) - hearings = pl.from_pandas(conn.execute("SELECT * FROM hearings").df()) - conn.close() - print(f"Cases shape: {cases.shape}") - print(f"Hearings shape: {hearings.shape}") - return cases, hearings - except Exception as e: - print(f"[WARN] DuckDB load failed ({e}), falling back to CSV...") - print("Loading raw data from CSVs (fallback)...") - cases = pl.read_csv( - CASES_FILE, - try_parse_dates=True, - null_values=NULL_TOKENS, - infer_schema_length=100_000, - ) - hearings = pl.read_csv( - HEAR_FILE, - try_parse_dates=True, - null_values=NULL_TOKENS, - infer_schema_length=100_000, - ) - print(f"Cases shape: {cases.shape}") - print(f"Hearings shape: {hearings.shape}") - return cases, hearings - - -def clean_and_augment( - cases: pl.DataFrame, hearings: pl.DataFrame -) -> tuple[pl.DataFrame, pl.DataFrame]: - # Standardise date columns if needed - for col in ["DATE_FILED", "DECISION_DATE", "REGISTRATION_DATE", "LAST_SYNC_TIME"]: - if col in cases.columns and cases[col].dtype == pl.Utf8: - cases = cases.with_columns(pl.col(col).str.strptime(pl.Date, "%d-%m-%Y", strict=False)) - - # Deduplicate on keys - if "CNR_NUMBER" in cases.columns: - cases = cases.unique(subset=["CNR_NUMBER"]) - if "Hearing_ID" in hearings.columns: - hearings = hearings.unique(subset=["Hearing_ID"]) - - # Normalise key text fields - cases = _norm_text_col(cases, "CASE_TYPE") - - for c in [ - "Remappedstages", - "PurposeofHearing", - "BeforeHonourableJudge", - ]: - hearings = _norm_text_col(hearings, c) - - # Simple stage canonicalisation - if "Remappedstages" in hearings.columns: - STAGE_MAP = { - "ORDERS/JUDGMENTS": "ORDERS / JUDGMENT", - "ORDER/JUDGMENT": "ORDERS / JUDGMENT", - "ORDERS / JUDGMENT": "ORDERS / JUDGMENT", - "ORDERS /JUDGMENT": "ORDERS / JUDGMENT", - "INTERLOCUTARY APPLICATION": "INTERLOCUTORY APPLICATION", - "FRAMING OF CHARGE": "FRAMING OF CHARGES", - "PRE ADMISSION": "PRE-ADMISSION", - } - hearings = hearings.with_columns( - pl.col("Remappedstages") - .map_elements(lambda x: STAGE_MAP.get(x, x) if x is not None else None) - .alias("Remappedstages") - ) - - # Normalise disposal time - if "DISPOSALTIME_ADJ" in cases.columns: - cases = cases.with_columns(pl.col("DISPOSALTIME_ADJ").cast(pl.Int32)) - - # Year fields - if "DATE_FILED" in cases.columns: - cases = cases.with_columns( - [ - pl.col("DATE_FILED").dt.year().alias("YEAR_FILED"), - pl.col("DECISION_DATE").dt.year().alias("YEAR_DECISION"), - ] - ) - - # Hearing counts per case - if {"CNR_NUMBER", "BusinessOnDate"}.issubset(hearings.columns): - hearing_freq = hearings.group_by("CNR_NUMBER").agg( - pl.count("BusinessOnDate").alias("N_HEARINGS") - ) - cases = cases.join(hearing_freq, on="CNR_NUMBER", how="left") - else: - cases = cases.with_columns(pl.lit(0).alias("N_HEARINGS")) - - # Per-case hearing gap stats (mean/median/std, p25, p75, count) - if {"CNR_NUMBER", "BusinessOnDate"}.issubset(hearings.columns): - hearing_gaps = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .sort(["CNR_NUMBER", "BusinessOnDate"]) - .with_columns( - ((pl.col("BusinessOnDate") - pl.col("BusinessOnDate").shift(1)) / timedelta(days=1)) - .over("CNR_NUMBER") - .alias("HEARING_GAP_DAYS") - ) - ) - gap_stats = hearing_gaps.group_by("CNR_NUMBER").agg( - [ - pl.col("HEARING_GAP_DAYS").mean().alias("GAP_MEAN"), - pl.col("HEARING_GAP_DAYS").median().alias("GAP_MEDIAN"), - pl.col("HEARING_GAP_DAYS").quantile(0.25).alias("GAP_P25"), - pl.col("HEARING_GAP_DAYS").quantile(0.75).alias("GAP_P75"), - pl.col("HEARING_GAP_DAYS").std(ddof=1).alias("GAP_STD"), - pl.col("HEARING_GAP_DAYS").count().alias("N_GAPS"), - ] - ) - cases = cases.join(gap_stats, on="CNR_NUMBER", how="left") - else: - for col in ["GAP_MEAN", "GAP_MEDIAN", "GAP_P25", "GAP_P75", "GAP_STD", "N_GAPS"]: - cases = cases.with_columns(pl.lit(None).alias(col)) - - # Fill some basics - cases = cases.with_columns( - [ - pl.col("N_HEARINGS").fill_null(0).cast(pl.Int64), - pl.col("GAP_MEDIAN").fill_null(0.0).cast(pl.Float64), - ] - ) - - # Print audits - print("\n=== dtypes (cases) ===") - print(cases.dtypes) - print("\n=== dtypes (hearings) ===") - print(hearings.dtypes) - - _null_summary(cases, "cases") - _null_summary(hearings, "hearings") - - # Simple lifecycle consistency check - if {"DATE_FILED", "DECISION_DATE"}.issubset( - cases.columns - ) and "BusinessOnDate" in hearings.columns: - h2 = hearings.join( - cases.select(["CNR_NUMBER", "DATE_FILED", "DECISION_DATE"]), - on="CNR_NUMBER", - how="left", - ) - before_filed = h2.filter( - pl.col("BusinessOnDate").is_not_null() - & pl.col("DATE_FILED").is_not_null() - & (pl.col("BusinessOnDate") < pl.col("DATE_FILED")) - ) - after_decision = h2.filter( - pl.col("BusinessOnDate").is_not_null() - & pl.col("DECISION_DATE").is_not_null() - & (pl.col("BusinessOnDate") > pl.col("DECISION_DATE")) - ) - print( - "Hearings before filing:", - before_filed.height, - "| after decision:", - after_decision.height, - ) - - return cases, hearings - - -def save_clean(cases: pl.DataFrame, hearings: pl.DataFrame) -> None: - cases.write_parquet(str(_get_cases_parquet())) - hearings.write_parquet(str(_get_hearings_parquet())) - print(f"Saved cleaned cases -> {str(_get_cases_parquet())}") - print(f"Saved cleaned hearings -> {str(_get_hearings_parquet())}") - - meta = { - "version": VERSION, - "timestamp": RUN_TS, - "cases_shape": list(cases.shape), - "hearings_shape": list(hearings.shape), - "cases_columns": cases.columns, - "hearings_columns": hearings.columns, - } - write_metadata(meta) - - -def run_load_and_clean() -> None: - cases_raw, hearings_raw = load_raw() - cases_clean, hearings_clean = clean_and_augment(cases_raw, hearings_raw) - save_clean(cases_clean, hearings_clean) - - -if __name__ == "__main__": - run_load_and_clean() diff --git a/src/eda_parameters.py b/src/eda_parameters.py deleted file mode 100644 index e8895ecbb7568ff33ae2cc2ee6ac865cecd3ca11..0000000000000000000000000000000000000000 --- a/src/eda_parameters.py +++ /dev/null @@ -1,400 +0,0 @@ -"""Module 3: Parameter extraction for scheduling simulation / optimisation. - -Responsibilities: -- Extract stage transition probabilities (per stage). -- Stage residence time distributions (medians, p90). -- Court capacity priors (median/p90 hearings per day). -- Adjournment and not-reached proxies by stage × case type. -- Entropy of stage transitions (predictability). -- Case-type summary stats (disposal, hearing counts, gaps). -- Readiness score and alert flags per case. -- Export JSON/CSV parameter files into _get_params_dir(). -""" - -import json -from datetime import timedelta - -import polars as pl -from src.eda_config import ( - _get_cases_parquet, - _get_hearings_parquet, - _get_params_dir, -) - - -def load_cleaned(): - cases = pl.read_parquet(_get_cases_parquet()) - hearings = pl.read_parquet(_get_hearings_parquet()) - return cases, hearings - - -def extract_parameters() -> None: - cases, hearings = load_cleaned() - - # -------------------------------------------------- - # 1. Stage transitions and probabilities - # -------------------------------------------------- - stage_col = "Remappedstages" if "Remappedstages" in hearings.columns else None - transitions = None - stage_duration = None - - if stage_col and "BusinessOnDate" in hearings.columns: - STAGE_ORDER = [ - "PRE-ADMISSION", - "ADMISSION", - "FRAMING OF CHARGES", - "EVIDENCE", - "ARGUMENTS", - "INTERLOCUTORY APPLICATION", - "SETTLEMENT", - "ORDERS / JUDGMENT", - "FINAL DISPOSAL", - "OTHER", - ] - order_idx = {s: i for i, s in enumerate(STAGE_ORDER)} - - h_stage = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .sort(["CNR_NUMBER", "BusinessOnDate"]) - .with_columns( - [ - pl.col(stage_col) - .fill_null("NA") - .map_elements( - lambda s: s if s in STAGE_ORDER else ("OTHER" if s and s != "NA" else None) - ) - .alias("STAGE"), - pl.col("BusinessOnDate").alias("DT"), - ] - ) - .filter(pl.col("STAGE").is_not_null()) # Filter out NA/None stages - .with_columns( - [ - (pl.col("STAGE") != pl.col("STAGE").shift(1)) - .over("CNR_NUMBER") - .alias("STAGE_CHANGE"), - ] - ) - ) - - transitions_raw = ( - h_stage.with_columns( - [ - pl.col("STAGE").alias("STAGE_FROM"), - pl.col("STAGE").shift(-1).over("CNR_NUMBER").alias("STAGE_TO"), - ] - ) - .filter(pl.col("STAGE_TO").is_not_null()) - .group_by(["STAGE_FROM", "STAGE_TO"]) - .agg(pl.len().alias("N")) - ) - - transitions = transitions_raw.filter( - pl.col("STAGE_FROM").map_elements(lambda s: order_idx.get(s, 10)) - <= pl.col("STAGE_TO").map_elements(lambda s: order_idx.get(s, 10)) - ).sort("N", descending=True) - - transitions.write_csv(str(_get_params_dir() / "stage_transitions.csv")) - - # Probabilities per STAGE_FROM - row_tot = transitions.group_by("STAGE_FROM").agg(pl.col("N").sum().alias("row_n")) - trans_probs = transitions.join(row_tot, on="STAGE_FROM").with_columns( - (pl.col("N") / pl.col("row_n")).alias("p") - ) - trans_probs.write_csv(str(_get_params_dir() / "stage_transition_probs.csv")) - - # Entropy of transitions - ent = ( - trans_probs.group_by("STAGE_FROM") - .agg((-(pl.col("p") * pl.col("p").log()).sum()).alias("entropy")) - .sort("entropy", descending=True) - ) - ent.write_csv(str(_get_params_dir() / "stage_transition_entropy.csv")) - - # Stage residence (runs) - runs = ( - h_stage.with_columns( - [ - pl.when(pl.col("STAGE_CHANGE")) - .then(1) - .otherwise(0) - .cum_sum() - .over("CNR_NUMBER") - .alias("RUN_ID") - ] - ) - .group_by(["CNR_NUMBER", "STAGE", "RUN_ID"]) - .agg( - [ - pl.col("DT").min().alias("RUN_START"), - pl.col("DT").max().alias("RUN_END"), - pl.len().alias("HEARINGS_IN_RUN"), - ] - ) - .with_columns( - ((pl.col("RUN_END") - pl.col("RUN_START")) / timedelta(days=1)).alias("RUN_DAYS") - ) - ) - stage_duration = ( - runs.group_by("STAGE") - .agg( - [ - pl.col("RUN_DAYS").median().alias("RUN_MEDIAN_DAYS"), - pl.col("RUN_DAYS").quantile(0.9).alias("RUN_P90_DAYS"), - pl.col("HEARINGS_IN_RUN").median().alias("HEARINGS_PER_RUN_MED"), - pl.len().alias("N_RUNS"), - ] - ) - .sort("RUN_MEDIAN_DAYS", descending=True) - ) - stage_duration.write_csv(str(_get_params_dir() / "stage_duration.csv")) - - # -------------------------------------------------- - # 2. Court capacity (cases per courtroom per day) - # -------------------------------------------------- - capacity_stats = None - if {"BusinessOnDate", "CourtName"}.issubset(hearings.columns): - cap = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .group_by(["CourtName", "BusinessOnDate"]) - .agg(pl.len().alias("heard_count")) - ) - cap_stats = ( - cap.group_by("CourtName") - .agg( - [ - pl.col("heard_count").median().alias("slots_median"), - pl.col("heard_count").quantile(0.9).alias("slots_p90"), - ] - ) - .sort("slots_median", descending=True) - ) - cap_stats.write_csv(str(_get_params_dir() / "court_capacity_stats.csv")) - # simple global aggregate - capacity_stats = { - "slots_median_global": float(cap["heard_count"].median()), - "slots_p90_global": float(cap["heard_count"].quantile(0.9)), - } - with open(str(_get_params_dir() / "court_capacity_global.json"), "w") as f: - json.dump(capacity_stats, f, indent=2) - - # -------------------------------------------------- - # 3. Adjournment and not-reached proxies - # -------------------------------------------------- - if "BusinessOnDate" in hearings.columns and stage_col: - # recompute hearing gaps if needed - if "HEARING_GAP_DAYS" not in hearings.columns: - hearings = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .sort(["CNR_NUMBER", "BusinessOnDate"]) - .with_columns( - ( - (pl.col("BusinessOnDate") - pl.col("BusinessOnDate").shift(1)) - / timedelta(days=1) - ) - .over("CNR_NUMBER") - .alias("HEARING_GAP_DAYS") - ) - ) - - stage_median_gap = hearings.group_by("Remappedstages").agg( - pl.col("HEARING_GAP_DAYS").median().alias("gap_median") - ) - hearings = hearings.join(stage_median_gap, on="Remappedstages", how="left") - - def _contains_any(col: str, kws: list[str]): - expr = None - for k in kws: - e = pl.col(col).str.contains(k) - expr = e if expr is None else (expr | e) - return (expr if expr is not None else pl.lit(False)).fill_null(False) - - # Not reached proxies from purpose text - text_col = None - for c in ["PurposeofHearing", "Purpose of Hearing", "PURPOSE_OF_HEARING"]: - if c in hearings.columns: - text_col = c - break - - hearings = hearings.with_columns( - [ - pl.when(pl.col("HEARING_GAP_DAYS") > (pl.col("gap_median") * 1.3)) - .then(1) - .otherwise(0) - .alias("is_adjourn_proxy") - ] - ) - if text_col: - hearings = hearings.with_columns( - pl.when(_contains_any(text_col, ["NOT REACHED", "NR", "NOT TAKEN UP", "NOT HEARD"])) - .then(1) - .otherwise(0) - .alias("is_not_reached_proxy") - ) - else: - hearings = hearings.with_columns(pl.lit(0).alias("is_not_reached_proxy")) - - outcome_stage = ( - hearings.group_by(["Remappedstages", "casetype"]) - .agg( - [ - pl.mean("is_adjourn_proxy").alias("p_adjourn_proxy"), - pl.mean("is_not_reached_proxy").alias("p_not_reached_proxy"), - pl.count().alias("n"), - ] - ) - .sort(["Remappedstages", "casetype"]) - ) - outcome_stage.write_csv(str(_get_params_dir() / "adjournment_proxies.csv")) - - # -------------------------------------------------- - # 4. Case-type summary and correlations - # -------------------------------------------------- - by_type = ( - cases.group_by("CASE_TYPE") - .agg( - [ - pl.count().alias("n_cases"), - pl.col("DISPOSALTIME_ADJ").median().alias("disp_median"), - pl.col("DISPOSALTIME_ADJ").quantile(0.9).alias("disp_p90"), - pl.col("N_HEARINGS").median().alias("hear_median"), - pl.col("GAP_MEDIAN").median().alias("gap_median"), - ] - ) - .sort("n_cases", descending=True) - ) - by_type.write_csv(str(_get_params_dir() / "case_type_summary.csv")) - - # Correlations for a quick diagnostic - corr_cols = ["DISPOSALTIME_ADJ", "N_HEARINGS", "GAP_MEDIAN"] - corr_df = cases.select(corr_cols).to_pandas() - corr = corr_df.corr(method="spearman") - corr.to_csv(str(_get_params_dir() / "correlations_spearman.csv")) - - # -------------------------------------------------- - # 5. Readiness score and alerts - # -------------------------------------------------- - cases = cases.with_columns( - [ - pl.when(pl.col("N_HEARINGS") > 50) - .then(50) - .otherwise(pl.col("N_HEARINGS")) - .alias("NH_CAP"), - pl.when(pl.col("GAP_MEDIAN").is_null() | (pl.col("GAP_MEDIAN") <= 0)) - .then(999.0) - .otherwise(pl.col("GAP_MEDIAN")) - .alias("GAPM_SAFE"), - ] - ) - cases = cases.with_columns( - pl.when(pl.col("GAPM_SAFE") > 100) - .then(100.0) - .otherwise(pl.col("GAPM_SAFE")) - .alias("GAPM_CLAMP") - ) - - # Stage at last hearing - if "BusinessOnDate" in hearings.columns and stage_col: - h_latest = ( - hearings.filter(pl.col("BusinessOnDate").is_not_null()) - .sort(["CNR_NUMBER", "BusinessOnDate"]) - .group_by("CNR_NUMBER") - .agg( - [ - pl.col("BusinessOnDate").max().alias("LAST_HEARING"), - pl.col(stage_col).last().alias("LAST_STAGE"), - pl.col(stage_col).n_unique().alias("N_DISTINCT_STAGES"), - ] - ) - ) - cases = cases.join(h_latest, on="CNR_NUMBER", how="left") - else: - cases = cases.with_columns( - [ - pl.lit(None).alias("LAST_HEARING"), - pl.lit(None).alias("LAST_STAGE"), - pl.lit(None).alias("N_DISTINCT_STAGES"), - ] - ) - - # Normalised readiness in [0,1] - cases = cases.with_columns( - ( - (pl.col("NH_CAP") / 50).clip(upper_bound=1.0) * 0.4 - + (100 / pl.col("GAPM_CLAMP")).clip(upper_bound=1.0) * 0.3 - + pl.when(pl.col("LAST_STAGE").is_in(["ARGUMENTS", "EVIDENCE", "ORDERS / JUDGMENT"])) - .then(0.3) - .otherwise(0.1) - ).alias("READINESS_SCORE") - ) - - # Alert flags (within case type) - try: - cases = cases.with_columns( - [ - ( - pl.col("DISPOSALTIME_ADJ") - > pl.col("DISPOSALTIME_ADJ").quantile(0.9).over("CASE_TYPE") - ).alias("ALERT_P90_TYPE"), - (pl.col("N_HEARINGS") > pl.col("N_HEARINGS").quantile(0.9).over("CASE_TYPE")).alias( - "ALERT_HEARING_HEAVY" - ), - (pl.col("GAP_MEDIAN") > pl.col("GAP_MEDIAN").quantile(0.9).over("CASE_TYPE")).alias( - "ALERT_LONG_GAP" - ), - ] - ) - except Exception as e: - print("Alert flag computation error:", e) - - feature_cols = [ - "CNR_NUMBER", - "CASE_TYPE", - "YEAR_FILED", - "YEAR_DECISION", - "DISPOSALTIME_ADJ", - "N_HEARINGS", - "GAP_MEDIAN", - "GAP_STD", - "LAST_HEARING", - "LAST_STAGE", - "READINESS_SCORE", - "ALERT_P90_TYPE", - "ALERT_HEARING_HEAVY", - "ALERT_LONG_GAP", - ] - feature_cols_existing = [c for c in feature_cols if c in cases.columns] - cases.select(feature_cols_existing).write_csv(str(_get_params_dir() / "cases_features.csv")) - - # Simple age funnel - if {"DATE_FILED", "DECISION_DATE"}.issubset(cases.columns): - age_funnel = ( - cases.with_columns( - ((pl.col("DECISION_DATE") - pl.col("DATE_FILED")) / timedelta(days=365)).alias( - "AGE_YRS" - ) - ) - .with_columns( - pl.when(pl.col("AGE_YRS") < 1) - .then(pl.lit("<1y")) - .when(pl.col("AGE_YRS") < 3) - .then(pl.lit("1-3y")) - .when(pl.col("AGE_YRS") < 5) - .then(pl.lit("3-5y")) - .otherwise(pl.lit(">5y")) - .alias("AGE_BUCKET") - ) - .group_by("AGE_BUCKET") - .agg(pl.len().alias("N")) - .sort("AGE_BUCKET") - ) - age_funnel.write_csv(str(_get_params_dir() / "age_funnel.csv")) - - -def run_parameter_export() -> None: - extract_parameters() - print("Parameter extraction complete. Files in:", _get_params_dir().resolve()) - - -if __name__ == "__main__": - run_parameter_export() diff --git a/src/run_eda.py b/src/run_eda.py deleted file mode 100644 index 9ea986a3b5d5458282d33af947323bcdda04f950..0000000000000000000000000000000000000000 --- a/src/run_eda.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python -"""Main entry point for Court Scheduling System. - -This file provides the primary entry point for the project. -It invokes the CLI which provides all scheduling system operations. -""" - -from court_scheduler.cli import main - -if __name__ == "__main__": - main() diff --git a/tests/test_enhancements.py b/tests/test_enhancements.py index eebf94b0eff67dc74cf2875d62276b47aa7518e1..7a5dd5dc1ead7232153f757f15343aa6a5cb0c60 100644 --- a/tests/test_enhancements.py +++ b/tests/test_enhancements.py @@ -10,9 +10,8 @@ Tests the following merged PRs: """ import sys +from datetime import date, datetime from pathlib import Path -from datetime import date, datetime, timedelta -from typing import Dict, List # Test configurations TESTS_PASSED = [] @@ -23,30 +22,30 @@ def log_test(name: str, passed: bool, details: str = ""): """Log test result.""" if passed: TESTS_PASSED.append(name) - print(f"✓ {name}") + print(f"[PASS] {name}") if details: print(f" {details}") else: TESTS_FAILED.append(name) - print(f"✗ {name}") + print(f"[FAIL] {name}") if details: print(f" {details}") def test_pr2_override_validation(): """Test PR #2: Override validation preserves original list and tracks rejections.""" + from scheduler.control.overrides import Override, OverrideType from scheduler.core.algorithm import SchedulingAlgorithm from scheduler.core.courtroom import Courtroom - from scheduler.simulation.policies.readiness import ReadinessPolicy - from scheduler.simulation.allocator import CourtroomAllocator, AllocationStrategy - from scheduler.control.overrides import Override, OverrideType from scheduler.data.case_generator import CaseGenerator - + from scheduler.simulation.allocator import CourtroomAllocator + from scheduler.simulation.policies.readiness import ReadinessPolicy + try: # Generate test cases gen = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42) cases = gen.generate(50) - + # Create test overrides (some valid, some invalid) test_overrides = [ Override( @@ -66,15 +65,15 @@ def test_pr2_override_validation(): new_priority=0.85 ) ] - + original_count = len(test_overrides) - + # Setup algorithm courtrooms = [Courtroom(courtroom_id=1, judge_id="J001", daily_capacity=50)] allocator = CourtroomAllocator(num_courtrooms=1, per_courtroom_capacity=50) policy = ReadinessPolicy() algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) - + # Run scheduling with overrides result = algorithm.schedule_day( cases=cases, @@ -82,20 +81,20 @@ def test_pr2_override_validation(): current_date=date(2024, 1, 15), overrides=test_overrides ) - + # Verify original list unchanged assert len(test_overrides) == original_count, "Original override list was mutated" - + # Verify rejection tracking exists (even if empty for valid overrides) assert hasattr(result, 'override_rejections'), "No override_rejections field" - + # Verify applied overrides tracked assert hasattr(result, 'applied_overrides'), "No applied_overrides field" - - log_test("PR #2: Override validation", True, + + log_test("PR #2: Override validation", True, f"Applied: {len(result.applied_overrides)}, Rejected: {len(result.override_rejections)}") return True - + except Exception as e: log_test("PR #2: Override validation", False, str(e)) return False @@ -103,36 +102,35 @@ def test_pr2_override_validation(): def test_pr2_flag_cleanup(): """Test PR #2: Temporary case flags are cleared after scheduling.""" - from scheduler.data.case_generator import CaseGenerator from scheduler.core.algorithm import SchedulingAlgorithm from scheduler.core.courtroom import Courtroom - from scheduler.simulation.policies.readiness import ReadinessPolicy + from scheduler.data.case_generator import CaseGenerator from scheduler.simulation.allocator import CourtroomAllocator - from scheduler.control.overrides import Override, OverrideType - + from scheduler.simulation.policies.readiness import ReadinessPolicy + try: gen = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42) cases = gen.generate(10) - + # Set priority override flag test_case = cases[0] test_case._priority_override = 0.99 - + # Run scheduling courtrooms = [Courtroom(courtroom_id=1, judge_id="J001", daily_capacity=50)] allocator = CourtroomAllocator(num_courtrooms=1, per_courtroom_capacity=50) policy = ReadinessPolicy() algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) - + algorithm.schedule_day(cases, courtrooms, date(2024, 1, 15)) - + # Verify flag cleared assert not hasattr(test_case, '_priority_override') or test_case._priority_override is None, \ "Priority override flag not cleared" - + log_test("PR #2: Flag cleanup", True, "Temporary flags cleared after scheduling") return True - + except Exception as e: log_test("PR #2: Flag cleanup", False, str(e)) return False @@ -140,33 +138,33 @@ def test_pr2_flag_cleanup(): def test_pr3_unknown_ripeness(): """Test PR #3: UNKNOWN ripeness status exists and is used.""" - from scheduler.core.ripeness import RipenessStatus, RipenessClassifier + from scheduler.core.ripeness import RipenessClassifier, RipenessStatus from scheduler.data.case_generator import CaseGenerator - + try: # Verify UNKNOWN status exists assert hasattr(RipenessStatus, 'UNKNOWN'), "RipenessStatus.UNKNOWN not found" - + # Create case with ambiguous ripeness gen = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42) cases = gen.generate(10) - + # Clear ripeness indicators to test UNKNOWN default test_case = cases[0] test_case.last_hearing_date = None test_case.service_status = None test_case.compliance_status = None - + # Classify ripeness ripeness = RipenessClassifier.classify(test_case, date(2024, 1, 15)) - + # Should default to UNKNOWN when no evidence assert ripeness == RipenessStatus.UNKNOWN or not ripeness.is_ripe(), \ "Ambiguous case did not get UNKNOWN or non-RIPE status" - + log_test("PR #3: UNKNOWN ripeness", True, f"Status: {ripeness.value}") return True - + except Exception as e: log_test("PR #3: UNKNOWN ripeness", False, str(e)) return False @@ -174,31 +172,29 @@ def test_pr3_unknown_ripeness(): def test_pr6_parameter_fallback(): """Test PR #6: Parameter fallback with bundled defaults.""" - from pathlib import Path - try: # Test that defaults directory exists defaults_dir = Path("scheduler/data/defaults") assert defaults_dir.exists(), f"Defaults directory not found: {defaults_dir}" - + # Check for expected default files expected_files = [ "stage_transition_probs.csv", - "stage_duration.csv", + "stage_duration.csv", "adjournment_proxies.csv", "court_capacity_global.json", "stage_transition_entropy.csv", "case_type_summary.csv" ] - + for file in expected_files: file_path = defaults_dir / file assert file_path.exists(), f"Default file missing: {file}" - - log_test("PR #6: Parameter fallback", True, + + log_test("PR #6: Parameter fallback", True, f"Found {len(expected_files)} default parameter files") return True - + except Exception as e: log_test("PR #6: Parameter fallback", False, str(e)) return False @@ -206,15 +202,15 @@ def test_pr6_parameter_fallback(): def test_pr4_rl_constraints(): """Test PR #4: RL training uses SchedulingAlgorithm with constraints.""" + from rl.config import RLTrainingConfig from rl.training import RLTrainingEnvironment - from rl.config import RLTrainingConfig, DEFAULT_RL_TRAINING_CONFIG from scheduler.data.case_generator import CaseGenerator - + try: # Create training environment gen = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42) cases = gen.generate(100) - + config = RLTrainingConfig( episodes=2, cases_per_episode=100, @@ -225,30 +221,30 @@ def test_pr4_rl_constraints(): cap_daily_allocations=True, apply_judge_preferences=True ) - + env = RLTrainingEnvironment( cases=cases, start_date=date(2024, 1, 1), horizon_days=10, rl_config=config ) - + # Verify SchedulingAlgorithm components exist assert hasattr(env, 'algorithm'), "No SchedulingAlgorithm in training environment" assert hasattr(env, 'courtrooms'), "No courtrooms in training environment" assert hasattr(env, 'allocator'), "No allocator in training environment" assert hasattr(env, 'policy'), "No policy in training environment" - + # Test step with agent decisions agent_decisions = {cases[0].case_id: 1, cases[1].case_id: 1} updated_cases, rewards, done = env.step(agent_decisions) - + assert len(rewards) >= 0, "No rewards returned from step" - - log_test("PR #4: RL constraints", True, + + log_test("PR #4: RL constraints", True, f"Environment has algorithm, courtrooms, allocator. Capacity enforced: {config.cap_daily_allocations}") return True - + except Exception as e: log_test("PR #4: RL constraints", False, str(e)) return False @@ -259,21 +255,21 @@ def test_pr5_shared_rewards(): from rl.rewards import EpisodeRewardHelper from rl.training import RLTrainingEnvironment from scheduler.data.case_generator import CaseGenerator - + try: # Verify EpisodeRewardHelper exists helper = EpisodeRewardHelper(total_cases=100) assert hasattr(helper, 'compute_case_reward'), "No compute_case_reward method" - + # Verify training environment uses it gen = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42) cases = gen.generate(50) - + env = RLTrainingEnvironment(cases, date(2024, 1, 1), 10) assert hasattr(env, 'reward_helper'), "Training environment doesn't use reward_helper" assert isinstance(env.reward_helper, EpisodeRewardHelper), \ "reward_helper is not EpisodeRewardHelper instance" - + # Test reward computation test_case = cases[0] reward = env.reward_helper.compute_case_reward( @@ -283,12 +279,12 @@ def test_pr5_shared_rewards(): current_date=date(2024, 1, 15), previous_gap_days=30 ) - + assert isinstance(reward, float), "Reward is not a float" - + log_test("PR #5: Shared rewards", True, f"Helper integrated, sample reward: {reward:.2f}") return True - + except Exception as e: log_test("PR #5: Shared rewards", False, str(e)) return False @@ -297,22 +293,21 @@ def test_pr5_shared_rewards(): def test_pr7_metadata_tracking(): """Test PR #7: Output metadata tracking.""" from scheduler.utils.output_manager import OutputManager - from pathlib import Path - + try: # Create output manager output = OutputManager(run_id="test_run") output.create_structure() - + # Verify metadata methods exist assert hasattr(output, 'record_eda_metadata'), "No record_eda_metadata method" assert hasattr(output, 'save_training_stats'), "No save_training_stats method" assert hasattr(output, 'save_evaluation_stats'), "No save_evaluation_stats method" assert hasattr(output, 'record_simulation_kpis'), "No record_simulation_kpis method" - + # Verify run_record file created assert output.run_record_file.exists(), "run_record.json not created" - + # Test metadata recording output.record_eda_metadata( version="test_v1", @@ -320,19 +315,19 @@ def test_pr7_metadata_tracking(): params_path=Path("test_params"), figures_path=Path("test_figures") ) - + # Verify metadata was written import json with open(output.run_record_file, 'r') as f: record = json.load(f) - + assert 'sections' in record, "No sections in run_record" assert 'eda' in record['sections'], "EDA metadata not recorded" - - log_test("PR #7: Metadata tracking", True, + + log_test("PR #7: Metadata tracking", True, f"Run record created with {len(record['sections'])} sections") return True - + except Exception as e: log_test("PR #7: Metadata tracking", False, str(e)) return False @@ -344,44 +339,44 @@ def run_all_tests(): print("Testing Merged Enhancements") print("=" * 60) print() - + # PR #2 tests print("PR #2: Override Handling Refactor") print("-" * 40) test_pr2_override_validation() test_pr2_flag_cleanup() print() - + # PR #3 tests print("PR #3: Ripeness UNKNOWN State") print("-" * 40) test_pr3_unknown_ripeness() print() - + # PR #6 tests print("PR #6: Parameter Fallback") print("-" * 40) test_pr6_parameter_fallback() print() - + # PR #4 tests print("PR #4: RL Training Alignment") print("-" * 40) test_pr4_rl_constraints() print() - + # PR #5 tests print("PR #5: Shared Reward Helper") print("-" * 40) test_pr5_shared_rewards() print() - + # PR #7 tests print("PR #7: Output Metadata Tracking") print("-" * 40) test_pr7_metadata_tracking() print() - + # Summary print("=" * 60) print("Test Summary") @@ -389,7 +384,7 @@ def run_all_tests(): print(f"Passed: {len(TESTS_PASSED)}") print(f"Failed: {len(TESTS_FAILED)}") print() - + if TESTS_FAILED: print("Failed tests:") for test in TESTS_FAILED: diff --git a/tests/test_gap_fixes.py b/tests/test_gap_fixes.py index 7f5643e84b0ba119744b7b7bfbb7c217c13b3c68..e2912a8f4bf1a1abe31edc627be0b1768f78ecec 100644 --- a/tests/test_gap_fixes.py +++ b/tests/test_gap_fixes.py @@ -6,16 +6,15 @@ Tests: """ from datetime import date, datetime -from pathlib import Path +from rl.config import RLTrainingConfig +from rl.simple_agent import TabularQAgent +from rl.training import RLTrainingEnvironment, train_agent +from scheduler.core.ripeness import RipenessClassifier, RipenessStatus from scheduler.data.case_generator import CaseGenerator from scheduler.data.param_loader import ParameterLoader -from scheduler.core.ripeness import RipenessClassifier, RipenessStatus -from scheduler.monitoring.ripeness_metrics import RipenessMetrics from scheduler.monitoring.ripeness_calibrator import RipenessCalibrator -from rl.training import RLTrainingEnvironment, train_agent -from rl.simple_agent import TabularQAgent -from rl.config import RLTrainingConfig +from scheduler.monitoring.ripeness_metrics import RipenessMetrics def test_gap1_eda_alignment(): @@ -23,7 +22,7 @@ def test_gap1_eda_alignment(): print("\n" + "=" * 70) print("GAP 1: Testing EDA Alignment in RL Training") print("=" * 70) - + # Generate test cases generator = CaseGenerator( start=date(2024, 1, 1), @@ -31,42 +30,42 @@ def test_gap1_eda_alignment(): seed=42, ) cases = generator.generate(100, stage_mix_auto=True) - + # Create environment with param_loader env = RLTrainingEnvironment( cases=cases, start_date=date(2024, 1, 1), horizon_days=30, ) - + # Verify param_loader exists assert hasattr(env, 'param_loader'), "Environment should have param_loader" assert isinstance(env.param_loader, ParameterLoader), "param_loader should be ParameterLoader instance" - - print("✓ ParameterLoader successfully integrated into RLTrainingEnvironment") - + + print("ParameterLoader successfully integrated into RLTrainingEnvironment") + # Test hearing outcome simulation uses EDA parameters test_case = cases[0] test_case.current_stage = "ADMISSION" test_case.case_type = "RSA" - + # Get EDA-derived adjournment probability p_adj_eda = env.param_loader.get_adjournment_prob("ADMISSION", "RSA") - print(f"✓ EDA adjournment probability for ADMISSION/RSA: {p_adj_eda:.2%}") - + print(f"EDA adjournment probability for ADMISSION/RSA: {p_adj_eda:.2%}") + # Simulate outcomes multiple times and check alignment outcomes = [] for _ in range(100): outcome = env._simulate_hearing_outcome(test_case) outcomes.append(outcome) - + adjourn_rate = sum(1 for o in outcomes if o == "ADJOURNED") / len(outcomes) - print(f"✓ Simulated adjournment rate: {adjourn_rate:.2%}") + print(f"Simulated adjournment rate: {adjourn_rate:.2%}") print(f" Difference from EDA: {abs(adjourn_rate - p_adj_eda):.2%}") - + # Should be within 15% of EDA value (stochastic sampling) assert abs(adjourn_rate - p_adj_eda) < 0.15, f"Adjournment rate {adjourn_rate:.2%} too far from EDA {p_adj_eda:.2%}" - + print("\n✅ GAP 1 FIXED: RL training now uses EDA-derived parameters\n") @@ -75,13 +74,13 @@ def test_gap2_ripeness_feedback(): print("\n" + "=" * 70) print("GAP 2: Testing Ripeness Feedback Loop") print("=" * 70) - + # Create metrics tracker metrics = RipenessMetrics() - + # Simulate predictions and outcomes (need 50+ for calibrator) test_cases = [] - + # Pattern: 50% false positives (RIPE but adjourned), 50% false negatives for i in range(50): if i % 4 == 0: @@ -92,56 +91,56 @@ def test_gap2_ripeness_feedback(): test_cases.append((f"case{i}", RipenessStatus.UNRIPE_SUMMONS, True)) # Correct UNRIPE else: test_cases.append((f"case{i}", RipenessStatus.UNRIPE_SUMMONS, False)) # False negative - + prediction_date = datetime(2024, 1, 1) outcome_date = datetime(2024, 1, 2) - + for case_id, predicted_status, was_adjourned in test_cases: metrics.record_prediction(case_id, predicted_status, prediction_date) actual_outcome = "ADJOURNED" if was_adjourned else "ARGUMENTS" metrics.record_outcome(case_id, actual_outcome, was_adjourned, outcome_date) - - print(f"✓ Recorded {len(test_cases)} predictions and outcomes") - + + print(f"Recorded {len(test_cases)} predictions and outcomes") + # Get accuracy metrics accuracy = metrics.get_accuracy_metrics() - print(f"\n Accuracy Metrics:") + print("\n Accuracy Metrics:") print(f" False positive rate: {accuracy['false_positive_rate']:.1%}") print(f" False negative rate: {accuracy['false_negative_rate']:.1%}") print(f" RIPE precision: {accuracy['ripe_precision']:.1%}") print(f" UNRIPE recall: {accuracy['unripe_recall']:.1%}") - + # Expected: 2/4 false positives (50%), 1/2 false negatives (50%) assert accuracy['false_positive_rate'] > 0.4, "Should detect false positives" assert accuracy['false_negative_rate'] > 0.4, "Should detect false negatives" - - print("\n✓ RipenessMetrics successfully tracks classification accuracy") - + + print("\nRipenessMetrics successfully tracks classification accuracy") + # Test calibrator adjustments = RipenessCalibrator.analyze_metrics(metrics) - - print(f"\n✓ RipenessCalibrator generated {len(adjustments)} adjustment suggestions:") + + print(f"\nRipenessCalibrator generated {len(adjustments)} adjustment suggestions:") for adj in adjustments: print(f" - {adj.threshold_name}: {adj.current_value} → {adj.suggested_value}") print(f" Reason: {adj.reason[:80]}...") - + assert len(adjustments) > 0, "Should suggest at least one adjustment" - + # Test threshold configuration original_thresholds = RipenessClassifier.get_current_thresholds() - print(f"\n✓ Current thresholds: {original_thresholds}") - + print(f"\nCurrent thresholds: {original_thresholds}") + # Apply test adjustment test_thresholds = {"MIN_SERVICE_HEARINGS": 2} RipenessClassifier.set_thresholds(test_thresholds) - + new_thresholds = RipenessClassifier.get_current_thresholds() assert new_thresholds["MIN_SERVICE_HEARINGS"] == 2, "Threshold should be updated" - print(f"✓ Thresholds successfully updated: {new_thresholds}") - + print(f"Thresholds successfully updated: {new_thresholds}") + # Restore original RipenessClassifier.set_thresholds(original_thresholds) - + print("\n✅ GAP 2 FIXED: Ripeness feedback loop fully operational\n") @@ -150,10 +149,10 @@ def test_end_to_end(): print("\n" + "=" * 70) print("END-TO-END: Testing Both Gaps Together") print("=" * 70) - + # Create agent agent = TabularQAgent(learning_rate=0.15, epsilon=0.4, discount=0.95) - + # Minimal training config config = RLTrainingConfig( episodes=2, @@ -161,17 +160,17 @@ def test_end_to_end(): cases_per_episode=50, training_seed=42, ) - + print("Running mini training (2 episodes, 50 cases, 10 days)...") stats = train_agent(agent, rl_config=config, verbose=False) - + assert len(stats["episodes"]) == 2, "Should complete 2 episodes" assert stats["episodes"][-1] == 1, "Last episode should be episode 1" - - print(f"✓ Training completed: {len(stats['episodes'])} episodes") + + print(f"Training completed: {len(stats['episodes'])} episodes") print(f" Final disposal rate: {stats['disposal_rates'][-1]:.1%}") print(f" States explored: {stats['states_explored'][-1]}") - + print("\n✅ END-TO-END: Both gaps working together successfully\n") @@ -179,12 +178,12 @@ if __name__ == "__main__": print("\n" + "=" * 70) print("TESTING GAP FIXES") print("=" * 70) - + try: test_gap1_eda_alignment() test_gap2_ripeness_feedback() test_end_to_end() - + print("\n" + "=" * 70) print("ALL TESTS PASSED") print("=" * 70) @@ -194,7 +193,7 @@ if __name__ == "__main__": print(" ✅ End-to-end: Both gaps working together") print("\nBoth confirmed gaps are now FIXED!") print("=" * 70 + "\n") - + except Exception as e: - print(f"\n❌ TEST FAILED: {e}") + print(f"\nTEST FAILED: {e}") raise