diff --git a/.gitignore b/.gitignore index 4c169bf152433f48f211390c7154c3162201bea8..2c6b192d94912c59488f8b64e24ef479a6eb5816 100644 --- a/.gitignore +++ b/.gitignore @@ -17,20 +17,3 @@ __pylintrc__ .html .docx -# Large data files and simulation outputs -Data/comprehensive_sweep*/ -Data/sim_runs/ -Data/config_test/ -Data/test_verification/ -*.csv -*.png -*.json - -# Keep essential data -!Data/README.md -!pyproject.toml -!Data/court_data.duckdb - -# Bundled baseline parameters for scheduler -!scheduler/data/defaults/*.csv -!scheduler/data/defaults/*.json diff --git a/cli/main.py b/cli/main.py index 08181b58c146aded1715421a84f46e163d766e7a..a755e655a8f1d407547323a42e8b2deffefd286d 100644 --- a/cli/main.py +++ b/cli/main.py @@ -127,7 +127,7 @@ def generate( from datetime import date as date_cls from cli.config import GenerateConfig, load_generate_config - from scheduler.data.case_generator import CaseGenerator + from src.data.case_generator import CaseGenerator # Resolve parameters: config -> interactive -> flags if config: @@ -247,10 +247,10 @@ def simulate( 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 src.core.case import CaseStatus + from src.data.case_generator import CaseGenerator + from src.metrics.basic import gini + from src.simulation.engine import CourtSim, CourtSimConfig # Resolve parameters: config -> interactive -> flags if config: @@ -394,7 +394,7 @@ def workflow( cases_file = output_path / "cases.csv" from datetime import date as date_cls - from scheduler.data.case_generator import CaseGenerator + from src.data.case_generator import CaseGenerator start = date_cls(2022, 1, 1) end = date_cls(2023, 12, 31) @@ -406,7 +406,7 @@ def workflow( # Step 3: Run simulation console.print("[bold]Step 3/3:[/bold] Run Simulation") - from scheduler.simulation.engine import CourtSim, CourtSimConfig + from src.simulation.engine import CourtSim, CourtSimConfig sim_start = max(c.filed_date for c in cases) cfg = CourtSimConfig( diff --git a/eda/exploration.py b/eda/exploration.py index 655bbab98cb0b8465a296b7193f09fab590f6047..d713ca064d9fcc547afb9576367c623e15d64bb5 100644 --- a/eda/exploration.py +++ b/eda/exploration.py @@ -21,7 +21,6 @@ from datetime import timedelta import plotly.express as px import plotly.graph_objects as go -import plotly.io as pio import polars as pl from eda.config import ( @@ -31,8 +30,6 @@ from eda.config import ( safe_write_figure, ) -pio.renderers.default = "browser" - def load_cleaned(): cases = pl.read_parquet(_get_cases_parquet()) @@ -44,21 +41,19 @@ def load_cleaned(): def run_exploration() -> None: cases, hearings = load_cleaned() - cases_pd = cases.to_pandas() - hearings_pd = hearings.to_pandas() + # Keep transformations in Polars; convert only small, final results for plotting # -------------------------------------------------- # 1. Case Type Distribution (aggregated to reduce plot data size) # -------------------------------------------------- try: ct_counts = ( - cases_pd.groupby("CASE_TYPE")["CNR_NUMBER"] - .count() - .reset_index(name="COUNT") - .sort_values("COUNT", ascending=False) + cases.group_by("CASE_TYPE") + .agg(pl.len().alias("COUNT")) + .sort("COUNT", descending=True) ) fig1 = px.bar( - ct_counts, + ct_counts.to_pandas(), x="CASE_TYPE", y="COUNT", color="CASE_TYPE", @@ -77,10 +72,14 @@ def run_exploration() -> None: # -------------------------------------------------- # 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") + if "YEAR_FILED" in cases.columns: + year_counts = cases.group_by("YEAR_FILED").agg(pl.len().alias("Count")) fig2 = px.line( - year_counts, x="YEAR_FILED", y="Count", markers=True, title="Cases Filed by Year" + year_counts.to_pandas(), + 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))) @@ -90,10 +89,9 @@ def run_exploration() -> None: # -------------------------------------------------- # 3. Disposal Duration Distribution # -------------------------------------------------- - if "DISPOSALTIME_ADJ" in cases_pd.columns: + if "DISPOSALTIME_ADJ" in cases.columns: fig3 = px.histogram( - cases_pd, - x="DISPOSALTIME_ADJ", + x=cases["DISPOSALTIME_ADJ"].to_list(), nbins=50, title="Distribution of Disposal Time (Adjusted Days)", color_discrete_sequence=["indianred"], @@ -105,9 +103,13 @@ def run_exploration() -> None: # -------------------------------------------------- # 4. Hearings vs Disposal Time # -------------------------------------------------- - if {"N_HEARINGS", "DISPOSALTIME_ADJ"}.issubset(cases_pd.columns): + if {"N_HEARINGS", "DISPOSALTIME_ADJ"}.issubset(set(cases.columns)): + # Convert only necessary columns for plotting with color/hover metadata + cases_scatter = cases.select( + ["N_HEARINGS", "DISPOSALTIME_ADJ", "CASE_TYPE", "CNR_NUMBER", "YEAR_FILED"] + ).to_pandas() fig4 = px.scatter( - cases_pd, + cases_scatter, x="N_HEARINGS", y="DISPOSALTIME_ADJ", color="CASE_TYPE", @@ -122,7 +124,7 @@ def run_exploration() -> None: # 5. Boxplot by Case Type # -------------------------------------------------- fig5 = px.box( - cases_pd, + cases.select(["CASE_TYPE", "DISPOSALTIME_ADJ"]).to_pandas(), x="CASE_TYPE", y="DISPOSALTIME_ADJ", color="CASE_TYPE", @@ -135,11 +137,14 @@ def run_exploration() -> None: # -------------------------------------------------- # 6. Stage Frequency # -------------------------------------------------- - if "Remappedstages" in hearings_pd.columns: - stage_counts = hearings_pd["Remappedstages"].value_counts().reset_index() - stage_counts.columns = ["Stage", "Count"] + if "Remappedstages" in hearings.columns: + stage_counts = ( + hearings["Remappedstages"] + .value_counts() + .rename({"Remappedstages": "Stage", "count": "Count"}) + ) fig6 = px.bar( - stage_counts, + stage_counts.to_pandas(), x="Stage", y="Count", color="Stage", @@ -159,9 +164,9 @@ def run_exploration() -> None: # -------------------------------------------------- # 7. Gap median by case type # -------------------------------------------------- - if "GAP_MEDIAN" in cases_pd.columns: + if "GAP_MEDIAN" in cases.columns: fig_gap = px.box( - cases_pd, + cases.select(["CASE_TYPE", "GAP_MEDIAN"]).to_pandas(), x="CASE_TYPE", y="GAP_MEDIAN", points=False, @@ -201,7 +206,9 @@ def run_exploration() -> None: 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") + lambda s: s + if s in STAGE_ORDER + else ("OTHER" if s is not None else "NA") ) .alias("STAGE"), pl.col("BusinessOnDate").alias("DT"), @@ -255,7 +262,9 @@ def run_exploration() -> None: ] ) .with_columns( - ((pl.col("RUN_END") - pl.col("RUN_START")) / timedelta(days=1)).alias("RUN_DAYS") + ((pl.col("RUN_END") - pl.col("RUN_START")) / timedelta(days=1)).alias( + "RUN_DAYS" + ) ) ) stage_duration = ( @@ -281,8 +290,12 @@ def run_exploration() -> None: if s in set(tr_df["STAGE_FROM"]).union(set(tr_df["STAGE_TO"])) ] idx = {label: i for i, label 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)) + 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( @@ -337,7 +350,9 @@ def run_exploration() -> None: ) .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 = ( + 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: @@ -358,12 +373,18 @@ def run_exploration() -> None: 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"), + (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["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 @@ -455,10 +476,27 @@ def run_exploration() -> None: if text_col: hear_txt = hearings.with_columns( - pl.col(text_col).cast(pl.Utf8).str.strip_chars().str.to_uppercase().alias("PURPOSE_TXT") + 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"] + 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")) @@ -470,7 +508,9 @@ def run_exploration() -> None: 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")) + .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")) diff --git a/eda/load_clean.py b/eda/load_clean.py index bf91c1bbc984bdff167038f9f1a59c9e8a1abcf7..a5e7ec9c48da2c82463f95d0f976c4184867642e 100644 --- a/eda/load_clean.py +++ b/eda/load_clean.py @@ -64,8 +64,8 @@ def load_raw() -> tuple[pl.DataFrame, pl.DataFrame]: print(f"Loading Parquet files:\n- {cases_path}\n- {hearings_path}") - cases = pl.read_parquet(cases_path) - hearings = pl.read_parquet(hearings_path) + cases = pl.read_parquet(cases_path, low_memory=True) + hearings = pl.read_parquet(hearings_path, low_memory=True) print(f"Cases shape: {cases.shape}") print(f"Hearings shape: {hearings.shape}") @@ -240,6 +240,7 @@ def save_clean(cases: pl.DataFrame, hearings: pl.DataFrame) -> None: def run_load_and_clean() -> None: cases_raw, hearings_raw = load_raw() cases_clean, hearings_clean = clean_and_augment(cases_raw, hearings_raw) + del cases_raw, hearings_raw save_clean(cases_clean, hearings_clean) diff --git a/scheduler/monitoring/__init__.py b/scheduler/monitoring/__init__.py deleted file mode 100644 index 3566ac0ffe340660ec24210c048891c88dbfeb25..0000000000000000000000000000000000000000 --- a/scheduler/monitoring/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Monitoring and feedback loop components.""" - -from scheduler.monitoring.ripeness_calibrator import RipenessCalibrator, ThresholdAdjustment -from scheduler.monitoring.ripeness_metrics import RipenessMetrics, RipenessPrediction - -__all__ = [ - "RipenessMetrics", - "RipenessPrediction", - "RipenessCalibrator", - "ThresholdAdjustment", -] diff --git a/scheduler/__init__.py b/src/__init__.py similarity index 100% rename from scheduler/__init__.py rename to src/__init__.py diff --git a/scheduler/dashboard/app.py b/src/app.py similarity index 99% rename from scheduler/dashboard/app.py rename to src/app.py index 8d1cbaa64d5c4dbad6061a9b436e4d9fe99c2d46..6c2f1018e55d84677e244d254d9902f244371da5 100644 --- a/scheduler/dashboard/app.py +++ b/src/app.py @@ -9,7 +9,7 @@ from __future__ import annotations import streamlit as st -from scheduler.dashboard.utils import get_data_status +from src.dashboard.utils import get_data_status # Page configuration st.set_page_config( diff --git a/scheduler/control/__init__.py b/src/control/__init__.py similarity index 100% rename from scheduler/control/__init__.py rename to src/control/__init__.py diff --git a/scheduler/control/explainability.py b/src/control/explainability.py similarity index 92% rename from scheduler/control/explainability.py rename to src/control/explainability.py index 9027d5d24876e24bf5a5b7f9f5fc3c7db36f4f62..852a03816a87d6de2725cfb957bcf70669530856 100644 --- a/scheduler/control/explainability.py +++ b/src/control/explainability.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import date from typing import Optional -from scheduler.core.case import Case +from src.core.case import Case def _fmt_score(score: Optional[float]) -> str: @@ -42,7 +42,9 @@ class SchedulingExplanation: 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 = [ + f"Case {self.case_id}: {'SCHEDULED' if self.scheduled else 'NOT SCHEDULED'}" + ] lines.append("=" * 60) for i, step in enumerate(self.decision_steps, 1): @@ -132,13 +134,17 @@ class ExplainabilityEngine: if not is_ripe: if "SUMMONS" in ripeness_status: ripeness_detail["bottleneck"] = "Summons not yet served" - ripeness_detail["action_needed"] = "Wait for summons service confirmation" + ripeness_detail["action_needed"] = ( + "Wait for summons service confirmation" + ) elif "DEPENDENT" in ripeness_status: ripeness_detail["bottleneck"] = "Dependent on another case" ripeness_detail["action_needed"] = "Wait for dependent case resolution" elif "PARTY" in ripeness_status: ripeness_detail["bottleneck"] = "Party unavailable or unresponsive" - ripeness_detail["action_needed"] = "Wait for party availability confirmation" + ripeness_detail["action_needed"] = ( + "Wait for party availability confirmation" + ) else: ripeness_detail["bottleneck"] = ripeness_status else: @@ -176,7 +182,10 @@ class ExplainabilityEngine: 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) @@ -192,7 +201,9 @@ class ExplainabilityEngine: if not meets_gap and not scheduled: next_eligible = ( - case.last_hearing_date.isoformat() if case.last_hearing_date else "unknown" + case.last_hearing_date.isoformat() + if case.last_hearing_date + else "unknown" ) return SchedulingExplanation( case_id=case.case_id, @@ -254,7 +265,9 @@ class ExplainabilityEngine: step_name="Policy Selection", passed=True, reason="Selected by policy despite being below typical threshold", - details={"reason": "Algorithm determined case should be scheduled"}, + details={ + "reason": "Algorithm determined case should be scheduled" + }, ) ) else: @@ -297,7 +310,9 @@ class ExplainabilityEngine: scheduled=True, decision_steps=steps, final_reason=final_reason, - priority_breakdown=priority_breakdown if priority_breakdown is not None else None, + priority_breakdown=priority_breakdown + if priority_breakdown is not None + else None, courtroom_assignment_reason=courtroom_reason, ) @@ -342,7 +357,9 @@ class ExplainabilityEngine: scheduled=False, decision_steps=steps, final_reason=final_reason, - priority_breakdown=priority_breakdown if priority_breakdown is not None else None, + priority_breakdown=priority_breakdown + if priority_breakdown is not None + else None, ) @staticmethod @@ -370,9 +387,7 @@ class ExplainabilityEngine: 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() diff --git a/scheduler/control/overrides.py b/src/control/overrides.py similarity index 100% rename from scheduler/control/overrides.py rename to src/control/overrides.py diff --git a/scheduler/core/__init__.py b/src/core/__init__.py similarity index 100% rename from scheduler/core/__init__.py rename to src/core/__init__.py diff --git a/scheduler/core/algorithm.py b/src/core/algorithm.py similarity index 81% rename from scheduler/core/algorithm.py rename to src/core/algorithm.py index 474eb2c0d6f541e75b76d2544bb20ec20c7d7ff3..e651f05cbf8ca06014030328df8337f9874ea8b9 100644 --- a/scheduler/core/algorithm.py +++ b/src/core/algorithm.py @@ -8,25 +8,26 @@ This module provides the standalone scheduling algorithm that can be used by: The algorithm accepts cases, courtrooms, date, policy, and optional overrides, then returns scheduled cause list with explanations and audit trail. """ + from __future__ import annotations from dataclasses import dataclass, field from datetime import date from typing import Dict, List, Optional, Tuple -from scheduler.control.explainability import ExplainabilityEngine, SchedulingExplanation -from scheduler.control.overrides import ( +from src.control.explainability import ExplainabilityEngine, SchedulingExplanation +from src.control.overrides import ( JudgePreferences, Override, OverrideType, 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 +from src.core.case import Case, CaseStatus +from src.core.courtroom import Courtroom +from src.core.policy import SchedulerPolicy +from src.core.ripeness import RipenessClassifier, RipenessStatus +from src.data.config import MIN_GAP_BETWEEN_HEARINGS +from src.simulation.allocator import CourtroomAllocator @dataclass @@ -66,7 +67,9 @@ class SchedulingResult: def __post_init__(self): """Calculate derived fields.""" - self.total_scheduled = sum(len(cases) for cases in self.scheduled_cases.values()) + self.total_scheduled = sum( + len(cases) for cases in self.scheduled_cases.values() + ) class SchedulingAlgorithm: @@ -94,7 +97,7 @@ class SchedulingAlgorithm: self, policy: SchedulerPolicy, allocator: Optional[CourtroomAllocator] = None, - min_gap_days: int = MIN_GAP_BETWEEN_HEARINGS + min_gap_days: int = MIN_GAP_BETWEEN_HEARINGS, ): """Initialize algorithm with policy and allocator. @@ -115,7 +118,7 @@ class SchedulingAlgorithm: current_date: date, overrides: Optional[List[Override]] = None, preferences: Optional[JudgePreferences] = None, - max_explanations_unscheduled: int = 100 + max_explanations_unscheduled: int = 100, ) -> SchedulingResult: """Schedule cases for a single day with override support. @@ -145,17 +148,21 @@ class SchedulingAlgorithm: validated_overrides.append(override) else: errors = validator.get_errors() - rejection_reason = "; ".join(errors) if errors else "Validation failed" - override_rejections.append({ - "judge": override.judge_id, - "context": override.override_type.value, - "reason": rejection_reason - }) + rejection_reason = ( + "; ".join(errors) if errors else "Validation failed" + ) + override_rejections.append( + { + "judge": override.judge_id, + "context": override.override_type.value, + "reason": rejection_reason, + } + ) unscheduled.append( ( None, f"Invalid override rejected (judge {override.judge_id}): " - f"{override.override_type.value} - {rejection_reason}" + f"{override.override_type.value} - {rejection_reason}", ) ) @@ -177,7 +184,9 @@ class SchedulingAlgorithm: # CHECKPOINT 3: Apply judge preferences (capacity overrides tracked) if preferences: - applied_overrides.extend(self._get_preference_overrides(preferences, courtrooms)) + applied_overrides.extend( + self._get_preference_overrides(preferences, courtrooms) + ) # CHECKPOINT 4: Prioritize using policy prioritized = self.policy.prioritize(eligible_cases, current_date) @@ -185,7 +194,11 @@ class SchedulingAlgorithm: # 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 + prioritized, + validated_overrides, + applied_overrides, + unscheduled, + active_cases, ) # CHECKPOINT 6: Allocate to courtrooms @@ -207,7 +220,7 @@ class SchedulingAlgorithm: scheduled=True, ripeness_status=case.ripeness_status, priority_score=case.get_priority_score(), - courtroom_id=courtroom_id + courtroom_id=courtroom_id, ) explanations[case.case_id] = explanation @@ -220,7 +233,7 @@ class SchedulingAlgorithm: scheduled=False, ripeness_status=case.ripeness_status, capacity_full=("Capacity" in reason), - below_threshold=False + below_threshold=False, ) explanations[case.case_id] = explanation @@ -235,7 +248,7 @@ class SchedulingAlgorithm: ripeness_filtered=ripeness_filtered, capacity_limited=capacity_limited, scheduling_date=current_date, - policy_used=self.policy.get_name() + policy_used=self.policy.get_name(), ) def _filter_by_ripeness( @@ -243,7 +256,7 @@ class SchedulingAlgorithm: cases: List[Case], current_date: date, overrides: Optional[List[Override]], - applied_overrides: List[Override] + applied_overrides: List[Override], ) -> Tuple[List[Case], int]: """Filter cases by ripeness with override support.""" # Build override lookup @@ -263,10 +276,17 @@ class SchedulingAlgorithm: case.mark_ripe(current_date) ripe_cases.append(case) # Track override application - override = next(o for o in overrides if o.case_id == case.case_id and o.override_type == OverrideType.RIPENESS) + override = next( + o + for o in overrides + if o.case_id == case.case_id + and o.override_type == OverrideType.RIPENESS + ) applied_overrides.append(override) else: - case.mark_unripe(RipenessStatus.UNRIPE_DEPENDENT, "Judge override", current_date) + case.mark_unripe( + RipenessStatus.UNRIPE_DEPENDENT, "Judge override", current_date + ) filtered_count += 1 continue @@ -288,10 +308,7 @@ class SchedulingAlgorithm: return ripe_cases, filtered_count def _filter_eligible( - self, - cases: List[Case], - current_date: date, - unscheduled: List[Tuple[Case, str]] + self, cases: List[Case], current_date: date, unscheduled: List[Tuple[Case, str]] ) -> List[Case]: """Filter cases that meet minimum gap requirement.""" eligible = [] @@ -304,15 +321,14 @@ class SchedulingAlgorithm: return eligible def _get_preference_overrides( - self, - preferences: JudgePreferences, - courtrooms: List[Courtroom] + self, preferences: JudgePreferences, courtrooms: List[Courtroom] ) -> 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(): override = Override( override_id=f"pref-capacity-{courtroom_id}-{preferences.judge_id}", @@ -322,7 +338,7 @@ class SchedulingAlgorithm: timestamp=datetime.now(), courtroom_id=courtroom_id, new_capacity=new_capacity, - reason="Judge preference" + reason="Judge preference", ) overrides.append(override) @@ -334,24 +350,32 @@ class SchedulingAlgorithm: overrides: List[Override], applied_overrides: List[Override], unscheduled: List[Tuple[Case, str]], - all_cases: List[Case] + all_cases: List[Case], ) -> 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] + add_overrides = [ + o for o in overrides if o.override_type == OverrideType.ADD_CASE + ] for override in add_overrides: # Find case in full case list - case_to_add = next((c for c in all_cases if c.case_id == override.case_id), None) + case_to_add = next( + (c for c in all_cases if c.case_id == override.case_id), None + ) if case_to_add and case_to_add not in result: # Insert at position 0 (highest priority) or specified position - insert_pos = override.new_position if override.new_position is not None else 0 + 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] + remove_overrides = [ + o for o in overrides if o.override_type == OverrideType.REMOVE_CASE + ] for override in remove_overrides: removed = [c for c in result if c.case_id == override.case_id] result = [c for c in result if c.case_id != override.case_id] @@ -360,9 +384,13 @@ class SchedulingAlgorithm: 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] + 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) + 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 case_to_adjust.get_priority_score() @@ -373,13 +401,20 @@ class SchedulingAlgorithm: # 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) + 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] + reorder_overrides = [ + o for o in overrides if o.override_type == OverrideType.REORDER + ] for override in reorder_overrides: if override.case_id and override.new_position is not None: - case_to_move = next((c for c in result if c.case_id == override.case_id), None) + case_to_move = next( + (c for c in result if c.case_id == override.case_id), None + ) if case_to_move and 0 <= override.new_position < len(result): result.remove(case_to_move) result.insert(override.new_position, case_to_move) @@ -392,7 +427,7 @@ class SchedulingAlgorithm: prioritized: List[Case], courtrooms: List[Courtroom], current_date: date, - preferences: Optional[JudgePreferences] + preferences: Optional[JudgePreferences], ) -> Tuple[Dict[int, List[Case]], int]: """Allocate prioritized cases to courtrooms.""" # Calculate total capacity (with preference overrides) diff --git a/scheduler/core/case.py b/src/core/case.py similarity index 82% rename from scheduler/core/case.py rename to src/core/case.py index f139c61f4e0d687a99cdf4a88623ec9609ceff6e..9684169906f8ec3cd17a7002037fe6837b41a674 100644 --- a/scheduler/core/case.py +++ b/src/core/case.py @@ -11,10 +11,10 @@ from datetime import date, datetime from enum import Enum from typing import TYPE_CHECKING, List, Optional -from scheduler.data.config import TERMINAL_STAGES +from src.data.config import TERMINAL_STAGES if TYPE_CHECKING: - from scheduler.core.ripeness import RipenessStatus + from src.core.ripeness import RipenessStatus else: # Import at runtime RipenessStatus = None @@ -22,10 +22,11 @@ else: class CaseStatus(Enum): """Status of a case in the system.""" - PENDING = "pending" # Filed, awaiting first hearing - ACTIVE = "active" # Has had at least one hearing - ADJOURNED = "adjourned" # Last hearing was adjourned - DISPOSED = "disposed" # Final disposal/settlement reached + + PENDING = "pending" # Filed, awaiting first hearing + ACTIVE = "active" # Has had at least one hearing + ADJOURNED = "adjourned" # Last hearing was adjourned + DISPOSED = "disposed" # Final disposal/settlement reached @dataclass @@ -48,6 +49,7 @@ class Case: disposal_date: Date of disposal (if disposed) history: List of hearing dates and outcomes """ + case_id: str case_type: str filed_date: date @@ -69,7 +71,9 @@ class Case: 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) + last_hearing_purpose: Optional[str] = ( + None # Purpose of last hearing (for classification) + ) # No-case-left-behind tracking (NEW) last_scheduled_date: Optional[date] = None @@ -92,13 +96,17 @@ class Case: 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: + 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: @@ -115,13 +123,15 @@ class Case: self.status = CaseStatus.ADJOURNED # Record in history - self.history.append({ - "date": hearing_date, - "event": "hearing", - "was_heard": was_heard, - "outcome": outcome, - "stage": self.current_stage, - }) + self.history.append( + { + "date": hearing_date, + "event": "hearing", + "was_heard": was_heard, + "outcome": outcome, + "stage": self.current_stage, + } + ) def update_age(self, current_date: date) -> None: """Update age and days since last hearing. @@ -143,7 +153,9 @@ class Case: # 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 + self.days_since_last_scheduled = ( + current_date - self.last_scheduled_date + ).days else: self.days_since_last_scheduled = self.age_days @@ -240,11 +252,14 @@ class Case: # At 21 days: ~0.37 (weak boost) # At 28 days: ~0.26 (very weak boost) import math + 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 + 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. @@ -255,17 +270,19 @@ class Case: current_date: Current simulation date """ # Store as string to avoid circular import - self.ripeness_status = status.value if hasattr(status, 'value') else str(status) + 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, - "event": "ripeness_change", - "status": self.ripeness_status, - "reason": reason, - }) + self.history.append( + { + "date": current_date, + "event": "ripeness_change", + "status": self.ripeness_status, + "reason": reason, + } + ) def mark_ripe(self, current_date: datetime) -> None: """Mark case as ripe (ready for hearing). @@ -278,12 +295,14 @@ class Case: self.ripeness_updated_at = current_date # Record in history - self.history.append({ - "date": current_date, - "event": "ripeness_change", - "status": "RIPE", - "reason": "Case became ripe", - }) + self.history.append( + { + "date": current_date, + "event": "ripeness_change", + "status": "RIPE", + "reason": "Case became ripe", + } + ) def mark_scheduled(self, scheduled_date: date) -> None: """Mark case as scheduled for a hearing. @@ -302,9 +321,11 @@ class Case: 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})") + 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.""" @@ -318,14 +339,20 @@ class Case: "is_urgent": self.is_urgent, "readiness_score": self.readiness_score, "hearing_count": self.hearing_count, - "last_hearing_date": self.last_hearing_date.isoformat() if self.last_hearing_date else None, + "last_hearing_date": self.last_hearing_date.isoformat() + if self.last_hearing_date + else None, "days_since_last_hearing": self.days_since_last_hearing, "age_days": self.age_days, - "disposal_date": self.disposal_date.isoformat() if self.disposal_date else None, + "disposal_date": self.disposal_date.isoformat() + if self.disposal_date + else None, "ripeness_status": self.ripeness_status, "bottleneck_reason": self.bottleneck_reason, "last_hearing_purpose": self.last_hearing_purpose, - "last_scheduled_date": self.last_scheduled_date.isoformat() if self.last_scheduled_date else None, + "last_scheduled_date": self.last_scheduled_date.isoformat() + if self.last_scheduled_date + else None, "days_since_last_scheduled": self.days_since_last_scheduled, "history": self.history, } diff --git a/scheduler/core/courtroom.py b/src/core/courtroom.py similarity index 86% rename from scheduler/core/courtroom.py rename to src/core/courtroom.py index 82ea00855fad6a37ccaf1213f361b47e687845c5..131b22825dd842803cb466536de55aed9b998c0f 100644 --- a/scheduler/core/courtroom.py +++ b/src/core/courtroom.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from datetime import date from typing import Dict, List, Optional, Set -from scheduler.data.config import DEFAULT_DAILY_CAPACITY +from src.data.config import DEFAULT_DAILY_CAPACITY @dataclass @@ -24,6 +24,7 @@ class Courtroom: hearings_held: Count of hearings held utilization_history: Track daily utilization rates """ + courtroom_id: int judge_id: Optional[str] = None daily_capacity: int = DEFAULT_DAILY_CAPACITY @@ -149,7 +150,9 @@ class Courtroom: 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: + def record_daily_utilization( + self, hearing_date: date, actual_hearings: int + ) -> None: """Record actual utilization for a day. Args: @@ -157,15 +160,19 @@ class Courtroom: 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, - "actual": actual_hearings, - "capacity": self.daily_capacity, - "utilization": utilization, - }) + utilization = ( + actual_hearings / self.daily_capacity if self.daily_capacity > 0 else 0.0 + ) + + self.utilization_history.append( + { + "date": hearing_date, + "scheduled": scheduled, + "actual": actual_hearings, + "capacity": self.daily_capacity, + "utilization": utilization, + } + ) def get_average_utilization(self) -> float: """Calculate average utilization rate across all recorded days. @@ -189,8 +196,7 @@ class Courtroom: Returns: Dict with counts and utilization stats """ - days_in_range = [d for d in self.schedule.keys() - if start_date <= d <= end_date] + 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) @@ -199,10 +205,14 @@ class Courtroom: "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, + "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) - if days_with_hearings > 0 else 0, + "utilization_rate": total_scheduled + / (days_with_hearings * self.daily_capacity) + if days_with_hearings > 0 + else 0, } def clear_schedule(self) -> None: @@ -212,8 +222,10 @@ class Courtroom: 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})") + 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.""" diff --git a/scheduler/core/hearing.py b/src/core/hearing.py similarity index 100% rename from scheduler/core/hearing.py rename to src/core/hearing.py diff --git a/scheduler/core/judge.py b/src/core/judge.py similarity index 100% rename from scheduler/core/judge.py rename to src/core/judge.py diff --git a/scheduler/core/policy.py b/src/core/policy.py similarity index 97% rename from scheduler/core/policy.py rename to src/core/policy.py index 5ffdc8255fd9d35c43dd91d64645ebb437ffef6f..fb0524f90e2dc272ae1ee17cd44a80646ff75120 100644 --- a/scheduler/core/policy.py +++ b/src/core/policy.py @@ -3,13 +3,14 @@ This module defines the abstract interface that all scheduling policies must implement. Moved to core to avoid circular dependency between core.algorithm and simulation.policies. """ + from __future__ import annotations from abc import ABC, abstractmethod from datetime import date from typing import List -from scheduler.core.case import Case +from src.core.case import Case class SchedulerPolicy(ABC): diff --git a/scheduler/core/ripeness.py b/src/core/ripeness.py similarity index 94% rename from scheduler/core/ripeness.py rename to src/core/ripeness.py index b709997b5e53eacff43931486b267e7cd0b9a98d..ef0c11af14bb2304ff91b24dda138e8f222f651f 100644 --- a/scheduler/core/ripeness.py +++ b/src/core/ripeness.py @@ -5,6 +5,7 @@ Unripe cases have bottlenecks (summons, dependencies, parties, documents). Based on analysis of historical PurposeOfHearing patterns (see scripts/analyze_ripeness_patterns.py). """ + from __future__ import annotations from datetime import datetime, timedelta @@ -12,7 +13,7 @@ from enum import Enum from typing import TYPE_CHECKING if TYPE_CHECKING: - from scheduler.core.case import Case + from src.core.case import Case class RipenessStatus(Enum): @@ -59,19 +60,14 @@ class RipenessClassifier: """ # Stages that indicate case is ready for substantive hearing - RIPE_STAGES = [ - "ARGUMENTS", - "EVIDENCE", - "ORDERS / JUDGMENT", - "FINAL DISPOSAL" - ] + RIPE_STAGES = ["ARGUMENTS", "EVIDENCE", "ORDERS / JUDGMENT", "FINAL DISPOSAL"] # Stages that indicate administrative/preliminary work UNRIPE_STAGES = [ "PRE-ADMISSION", "ADMISSION", # Most cases stuck here waiting for compliance "FRAMING OF CHARGES", - "INTERLOCUTORY APPLICATION" + "INTERLOCUTORY APPLICATION", ] # Minimum evidence thresholds before declaring a case RIPE @@ -91,7 +87,8 @@ class RipenessClassifier: # Evidence the case has progressed in its current stage days_in_stage = getattr(case, "days_in_stage", 0) compliance_confirmed = ( - case.current_stage not in cls.UNRIPE_STAGES or days_in_stage >= cls.MIN_STAGE_DAYS + case.current_stage not in cls.UNRIPE_STAGES + or days_in_stage >= cls.MIN_STAGE_DAYS ) # Age-based maturity requirement @@ -118,7 +115,9 @@ class RipenessClassifier: return False @classmethod - def classify(cls, case: Case, current_date: datetime | None = None) -> RipenessStatus: + def classify( + cls, case: Case, current_date: datetime | None = None + ) -> RipenessStatus: """Classify case ripeness status with bottleneck type. Args: @@ -177,7 +176,9 @@ class RipenessClassifier: return RipenessStatus.UNKNOWN @classmethod - def get_ripeness_priority(cls, case: Case, current_date: datetime | None = None) -> float: + 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 @@ -238,7 +239,9 @@ class RipenessClassifier: return reasons.get(ripeness_status, "Unknown status") @classmethod - def estimate_ripening_time(cls, case: Case, current_date: datetime) -> timedelta | None: + 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. diff --git a/scheduler/dashboard/__init__.py b/src/dashboard/__init__.py similarity index 100% rename from scheduler/dashboard/__init__.py rename to src/dashboard/__init__.py diff --git a/scheduler/dashboard/pages/1_Data_And_Insights.py b/src/dashboard/pages/1_Data_And_Insights.py similarity index 99% rename from scheduler/dashboard/pages/1_Data_And_Insights.py rename to src/dashboard/pages/1_Data_And_Insights.py index 9ddfa0724a6dbdafb38d3be0f5a7b6fe7ad21c38..2509b9e1833c9e3812b7cc95293147b3276e402e 100644 --- a/scheduler/dashboard/pages/1_Data_And_Insights.py +++ b/src/dashboard/pages/1_Data_And_Insights.py @@ -17,7 +17,7 @@ import plotly.graph_objects as go import streamlit as st import streamlit.components.v1 as components -from scheduler.dashboard.utils import ( +from src.dashboard.utils import ( get_case_statistics, load_cleaned_data, load_cleaned_hearings, @@ -798,7 +798,7 @@ If hearing_gap > 1.3 * stage_median_gap: with col1: st.markdown("**Classification Thresholds**") - from scheduler.core.ripeness import RipenessClassifier + from src.core.ripeness import RipenessClassifier thresholds = RipenessClassifier.get_current_thresholds() @@ -895,7 +895,7 @@ UNRIPE cases: 0.7x priority with col1: st.markdown("**Default Case Type Distribution**") - from scheduler.data.config import CASE_TYPE_DISTRIBUTION + from src.data.config import CASE_TYPE_DISTRIBUTION dist_df = pd.DataFrame( [ @@ -907,13 +907,13 @@ UNRIPE cases: 0.7x priority st.caption("Based on historical distribution from EDA") st.markdown("**Urgent Case Percentage**") - from scheduler.data.config import URGENT_CASE_PERCENTAGE + from src.data.config import URGENT_CASE_PERCENTAGE st.metric("Urgent Cases", f"{URGENT_CASE_PERCENTAGE * 100:.1f}%") with col2: st.markdown("**Monthly Seasonality Factors**") - from scheduler.data.config import MONTHLY_SEASONALITY + from src.data.config import MONTHLY_SEASONALITY season_df = pd.DataFrame( [ diff --git a/scheduler/dashboard/pages/2_Ripeness_Classifier.py b/src/dashboard/pages/2_Ripeness_Classifier.py similarity index 98% rename from scheduler/dashboard/pages/2_Ripeness_Classifier.py rename to src/dashboard/pages/2_Ripeness_Classifier.py index 14e7bec3418212a599554037b2199183d84d320d..b36a8a6e47b501fd924a883095161b7e579fddae 100644 --- a/scheduler/dashboard/pages/2_Ripeness_Classifier.py +++ b/src/dashboard/pages/2_Ripeness_Classifier.py @@ -12,9 +12,9 @@ import pandas as pd import plotly.express as px import streamlit as st -from scheduler.core.case import Case, CaseStatus -from scheduler.core.ripeness import RipenessClassifier, RipenessStatus -from scheduler.dashboard.utils.data_loader import ( +from src.core.case import Case, CaseStatus +from src.core.ripeness import RipenessClassifier, RipenessStatus +from src.dashboard.utils.data_loader import ( attach_history_to_cases, load_generated_cases, load_generated_hearings, diff --git a/scheduler/dashboard/pages/3_Simulation_Workflow.py b/src/dashboard/pages/3_Simulation_Workflow.py similarity index 98% rename from scheduler/dashboard/pages/3_Simulation_Workflow.py rename to src/dashboard/pages/3_Simulation_Workflow.py index 2d1f08ef01e8c64f7ef5364df705d4b236efa0dd..afcad40d6f65e0e78520c95e6e95140483d4e522 100644 --- a/scheduler/dashboard/pages/3_Simulation_Workflow.py +++ b/src/dashboard/pages/3_Simulation_Workflow.py @@ -17,7 +17,7 @@ import plotly.express as px import streamlit as st from cli import __version__ as CLI_VERSION -from scheduler.output.cause_list import CauseListGenerator +from src.output.cause_list import CauseListGenerator # Page configuration st.set_page_config( @@ -184,7 +184,7 @@ if st.session_state.workflow_step == 1: st.success(f"Total: {total_pct}%") else: st.info("Using default distribution from historical data") - from scheduler.dashboard.utils.ui_input_parser import ( + from src.dashboard.utils.ui_input_parser import ( build_case_type_distribution, merge_with_default_config, ) @@ -205,7 +205,7 @@ if st.session_state.workflow_step == 1: with st.spinner(f"Generating {n_cases:,} cases..."): try: from cli.config import load_generate_config - from scheduler.data.case_generator import CaseGenerator + from src.data.case_generator import CaseGenerator DEFAULT_GENERATE_CFG_PATH = Path("configs/generate.sample.toml") config_from_file = None @@ -228,7 +228,7 @@ if st.session_state.workflow_step == 1: case_type_dist_dict = None if use_custom_dist: - from scheduler.dashboard.utils.ui_input_parser import ( + from src.dashboard.utils.ui_input_parser import ( build_case_type_distribution, ) @@ -484,7 +484,7 @@ elif st.session_state.workflow_step == 3: with st.spinner("Running simulation... This may take several minutes."): try: from cli.config import load_simulate_config - from scheduler.dashboard.utils.simulation_runner import ( + from src.dashboard.utils.simulation_runner import ( merge_simulation_config, run_simulation_dashboard, ) diff --git a/scheduler/dashboard/pages/4_Cause_Lists_And_Overrides.py b/src/dashboard/pages/4_Cause_Lists_And_Overrides.py similarity index 100% rename from scheduler/dashboard/pages/4_Cause_Lists_And_Overrides.py rename to src/dashboard/pages/4_Cause_Lists_And_Overrides.py diff --git a/scheduler/dashboard/pages/6_Analytics_And_Reports.py b/src/dashboard/pages/6_Analytics_And_Reports.py similarity index 100% rename from scheduler/dashboard/pages/6_Analytics_And_Reports.py rename to src/dashboard/pages/6_Analytics_And_Reports.py diff --git a/scheduler/dashboard/utils/__init__.py b/src/dashboard/utils/__init__.py similarity index 100% rename from scheduler/dashboard/utils/__init__.py rename to src/dashboard/utils/__init__.py diff --git a/scheduler/dashboard/utils/data_loader.py b/src/dashboard/utils/data_loader.py similarity index 91% rename from scheduler/dashboard/utils/data_loader.py rename to src/dashboard/utils/data_loader.py index 64a6d07a0a5d6b0d0b87e7940d83803fda96a95d..edc0db514d8c70160d73cfa9d727ca3ee19e136c 100644 --- a/scheduler/dashboard/utils/data_loader.py +++ b/src/dashboard/utils/data_loader.py @@ -13,8 +13,8 @@ import pandas as pd import polars as pl import streamlit as st -from scheduler.data.case_generator import CaseGenerator -from scheduler.data.param_loader import ParameterLoader +from src.data.case_generator import CaseGenerator +from src.data.param_loader import ParameterLoader @st.cache_data(ttl=3600) @@ -30,7 +30,9 @@ def load_param_loader(params_dir: str = None) -> dict[str, Any]: 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")] + 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") @@ -105,7 +107,9 @@ def load_cleaned_hearings(data_path: str = None) -> pd.DataFrame: 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")] + 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 @@ -149,7 +153,9 @@ def load_cleaned_data(data_path: str = None) -> pd.DataFrame: 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")] + 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 @@ -282,7 +288,9 @@ def load_generated_cases(cases_path: str = "data/generated/cases.csv") -> list: @st.cache_data(ttl=3600) -def load_generated_hearings(hearings_path: str = "data/generated/hearings.csv") -> pd.DataFrame: +def load_generated_hearings( + hearings_path: str = "data/generated/hearings.csv", +) -> pd.DataFrame: """Load generated hearings history as a flat DataFrame. Args: @@ -359,12 +367,16 @@ def load_generated_hearings(hearings_path: str = "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"]) + 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"]) + return pd.DataFrame( + columns=["case_id", "date", "stage", "purpose", "was_heard", "event"] + ) # Normalize columns expected_cols = ["case_id", "date", "stage", "purpose", "was_heard", "event"] @@ -405,7 +417,8 @@ def attach_history_to_cases(cases: list, hearings_df: pd.DataFrame) -> list: 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) + 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 @@ -433,15 +446,21 @@ def get_case_statistics(df: pd.DataFrame) -> dict[str, Any]: 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 {}, + "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 + stats["adjournment_rate"] = ( + adjourned / total_hearings if total_hearings > 0 else 0 + ) return stats @@ -458,7 +477,9 @@ def get_data_status() -> dict[str, bool]: # 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")] + 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() diff --git a/scheduler/dashboard/utils/simulation_runner.py b/src/dashboard/utils/simulation_runner.py similarity index 94% rename from scheduler/dashboard/utils/simulation_runner.py rename to src/dashboard/utils/simulation_runner.py index 06e5cd997873e6041d4d1b9b7b188786b594256c..b4ccc551475144cb3d1617702c82c1441fd5e4e2 100644 --- a/scheduler/dashboard/utils/simulation_runner.py +++ b/src/dashboard/utils/simulation_runner.py @@ -3,10 +3,10 @@ from pathlib import Path from datetime import date from cli.config import SimulateConfig -from scheduler.data.case_generator import CaseGenerator -from scheduler.simulation.engine import CourtSim, CourtSimConfig -from scheduler.core.case import CaseStatus -from scheduler.metrics.basic import gini +from src.data.case_generator import CaseGenerator +from src.simulation.engine import CourtSim, CourtSimConfig +from src.core.case import CaseStatus +from src.metrics.basic import gini def merge_simulation_config( diff --git a/scheduler/dashboard/utils/ui_input_parser.py b/src/dashboard/utils/ui_input_parser.py similarity index 100% rename from scheduler/dashboard/utils/ui_input_parser.py rename to src/dashboard/utils/ui_input_parser.py diff --git a/scheduler/data/__init__.py b/src/data/__init__.py similarity index 100% rename from scheduler/data/__init__.py rename to src/data/__init__.py diff --git a/scheduler/data/case_generator.py b/src/data/case_generator.py similarity index 96% rename from scheduler/data/case_generator.py rename to src/data/case_generator.py index a59727fe61c053bd655159f1eeea82af59bf3ba1..41e5e3eb1e22b7a77bb07e3067eaafd6127ea216 100644 --- a/scheduler/data/case_generator.py +++ b/src/data/case_generator.py @@ -18,14 +18,14 @@ from datetime import date, timedelta from pathlib import Path from typing import Iterable, List, Tuple -from scheduler.core.case import Case -from scheduler.data.config import ( +from src.core.case import Case +from src.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 +from src.data.param_loader import load_parameters +from src.utils.calendar import CourtCalendar def _month_iter(start: date, end: date) -> Iterable[Tuple[int, int]]: @@ -254,7 +254,11 @@ class CaseGenerator: if random.random() < 0.4 else random.choice(ripe_purposes) ) - elif init_stage in ["ARGUMENTS", "ORDERS / JUDGMENT", "FINAL DISPOSAL"]: + elif init_stage in [ + "ARGUMENTS", + "ORDERS / JUDGMENT", + "FINAL DISPOSAL", + ]: purpose = random.choice(ripe_purposes) else: purpose = ( @@ -284,7 +288,9 @@ class CaseGenerator: # 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 + c.last_hearing_purpose = ( + c.history[-1]["purpose"] if c.history else None + ) cases.append(c) diff --git a/scheduler/data/config.py b/src/data/config.py similarity index 100% rename from scheduler/data/config.py rename to src/data/config.py diff --git a/scheduler/data/param_loader.py b/src/data/param_loader.py similarity index 94% rename from scheduler/data/param_loader.py rename to src/data/param_loader.py index 579b3270a6fa6744feae1cf142a0e2de3effbd5b..0a65494c876a569881c89d7bcfdb13432b038159 100644 --- a/scheduler/data/param_loader.py +++ b/src/data/param_loader.py @@ -10,7 +10,7 @@ from typing import Dict, List, Optional import pandas as pd -from scheduler.data.config import get_latest_params_dir +from src.data.config import get_latest_params_dir class ParameterLoader: @@ -36,9 +36,15 @@ class ParameterLoader: self._case_type_summary: Optional[pd.DataFrame] = None self._transition_entropy: Optional[pd.DataFrame] = None # caches - 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} + 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: @@ -98,7 +104,9 @@ class ParameterLoader: DataFrame with STAGE_TO and p columns """ df = self.transition_probs - return df[df["STAGE_FROM"] == stage_from][["STAGE_TO", "p"]].reset_index(drop=True) + return df[df["STAGE_FROM"] == stage_from][["STAGE_TO", "p"]].reset_index( + drop=True + ) def get_stage_transitions_fast(self, stage_from: str) -> List[tuple]: """Fast lookup: returns list of (stage_to, cum_p).""" @@ -287,11 +295,11 @@ class ParameterLoader: df = df[df["STAGE_FROM"].notna() & df["STAGE_TO"].notna()] df["STAGE_FROM"] = df["STAGE_FROM"].astype(str) df["STAGE_TO"] = df["STAGE_TO"].astype(str) - stages = sorted(set(df["STAGE_FROM"]).union(set(df["STAGE_TO"])) ) + stages = sorted(set(df["STAGE_FROM"]).union(set(df["STAGE_TO"]))) idx = {s: i for i, s in enumerate(stages)} n = len(stages) # build dense row-stochastic matrix - P = [[0.0]*n for _ in range(n)] + 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"])] @@ -300,26 +308,26 @@ class ParameterLoader: for i in range(n): s = sum(P[i]) if s < 0.999: - P[i][i] += (1.0 - s) + P[i][i] += 1.0 - s elif s > 1.001: # normalize if slightly over - P[i] = [v/s for v in P[i]] + P[i] = [v / s for v in P[i]] # power iteration - pi = [1.0/n]*n + pi = [1.0 / n] * n for _ in range(200): - new = [0.0]*n + new = [0.0] * n for j in range(n): acc = 0.0 for i in range(n): - acc += pi[i]*P[i][j] + acc += pi[i] * P[i][j] new[j] = acc # normalize z = sum(new) if z == 0: break - new = [v/z for v in new] + new = [v / z for v in new] # check convergence - if sum(abs(new[k]-pi[k]) for k in range(n)) < 1e-9: + if sum(abs(new[k] - pi[k]) for k in range(n)) < 1e-9: pi = new break pi = new diff --git a/scheduler/metrics/__init__.py b/src/metrics/__init__.py similarity index 100% rename from scheduler/metrics/__init__.py rename to src/metrics/__init__.py diff --git a/scheduler/metrics/basic.py b/src/metrics/basic.py similarity index 100% rename from scheduler/metrics/basic.py rename to src/metrics/basic.py diff --git a/src/monitoring/__init__.py b/src/monitoring/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..282f1d7067dd09d497c12c4ff03d7a31836c07d4 --- /dev/null +++ b/src/monitoring/__init__.py @@ -0,0 +1,11 @@ +"""Monitoring and feedback loop components.""" + +from src.monitoring.ripeness_calibrator import RipenessCalibrator, ThresholdAdjustment +from src.monitoring.ripeness_metrics import RipenessMetrics, RipenessPrediction + +__all__ = [ + "RipenessMetrics", + "RipenessPrediction", + "RipenessCalibrator", + "ThresholdAdjustment", +] diff --git a/scheduler/monitoring/ripeness_calibrator.py b/src/monitoring/ripeness_calibrator.py similarity index 57% rename from scheduler/monitoring/ripeness_calibrator.py rename to src/monitoring/ripeness_calibrator.py index 7e67cba63d93e7e88c1a7d01c63a1b88ac9acf12..2c96726abf2ae249320d70883de19045c8d09db8 100644 --- a/scheduler/monitoring/ripeness_calibrator.py +++ b/src/monitoring/ripeness_calibrator.py @@ -9,7 +9,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Optional -from scheduler.monitoring.ripeness_metrics import RipenessMetrics +from src.monitoring.ripeness_metrics import RipenessMetrics @dataclass @@ -53,7 +53,8 @@ class RipenessCalibrator: # Default current thresholds if not provided if current_thresholds is None: - from scheduler.core.ripeness import RipenessClassifier + from src.core.ripeness import RipenessClassifier + current_thresholds = { "MIN_SERVICE_HEARINGS": RipenessClassifier.MIN_SERVICE_HEARINGS, "MIN_STAGE_DAYS": RipenessClassifier.MIN_STAGE_DAYS, @@ -62,88 +63,100 @@ class RipenessCalibrator: # Check if we have enough data if accuracy["completed_predictions"] < 50: - print("Warning: Insufficient data for calibration (need at least 50 predictions)") + print( + "Warning: Insufficient data for calibration (need at least 50 predictions)" + ) return adjustments # 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 - adjustments.append(ThresholdAdjustment( - threshold_name="MIN_SERVICE_HEARINGS", - current_value=current_hearings, - suggested_value=suggested_hearings, - reason=( - f"False positive rate {accuracy['false_positive_rate']:.1%} exceeds " - f"{cls.HIGH_FALSE_POSITIVE_THRESHOLD:.0%}. Cases marked RIPE are adjourning. " - f"Require more hearings as evidence of readiness." - ), - confidence="high", - )) + adjustments.append( + ThresholdAdjustment( + threshold_name="MIN_SERVICE_HEARINGS", + current_value=current_hearings, + suggested_value=suggested_hearings, + reason=( + f"False positive rate {accuracy['false_positive_rate']:.1%} exceeds " + f"{cls.HIGH_FALSE_POSITIVE_THRESHOLD:.0%}. Cases marked RIPE are adjourning. " + f"Require more hearings as evidence of readiness." + ), + confidence="high", + ) + ) # 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 - adjustments.append(ThresholdAdjustment( - threshold_name="MIN_STAGE_DAYS", - current_value=current_days, - suggested_value=suggested_days, - reason=( - f"False negative rate {accuracy['false_negative_rate']:.1%} exceeds " - f"{cls.HIGH_FALSE_NEGATIVE_THRESHOLD:.0%}. UNRIPE cases are progressing. " - f"Relax stage maturity requirement." - ), - confidence="medium", - )) + adjustments.append( + ThresholdAdjustment( + threshold_name="MIN_STAGE_DAYS", + current_value=current_days, + suggested_value=suggested_days, + reason=( + f"False negative rate {accuracy['false_negative_rate']:.1%} exceeds " + f"{cls.HIGH_FALSE_NEGATIVE_THRESHOLD:.0%}. UNRIPE cases are progressing. " + f"Relax stage maturity requirement." + ), + confidence="medium", + ) + ) # 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 - adjustments.append(ThresholdAdjustment( - threshold_name="MIN_CASE_AGE_DAYS", - current_value=current_age, - suggested_value=suggested_age, - reason=( - f"UNKNOWN rate {accuracy['unknown_rate']:.1%} below " - f"{cls.LOW_UNKNOWN_THRESHOLD:.0%}. System is overconfident. " - f"Increase case age requirement to add uncertainty for immature cases." - ), - confidence="medium", - )) + adjustments.append( + ThresholdAdjustment( + threshold_name="MIN_CASE_AGE_DAYS", + current_value=current_age, + suggested_value=suggested_age, + reason=( + f"UNKNOWN rate {accuracy['unknown_rate']:.1%} below " + f"{cls.LOW_UNKNOWN_THRESHOLD:.0%}. System is overconfident. " + f"Increase case age requirement to add uncertainty for immature cases." + ), + confidence="medium", + ) + ) # 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 - adjustments.append(ThresholdAdjustment( - threshold_name="MIN_SERVICE_HEARINGS", - current_value=current_hearings, - suggested_value=suggested_hearings, - reason=( - f"RIPE precision {accuracy['ripe_precision']:.1%} below " - f"{cls.LOW_RIPE_PRECISION_THRESHOLD:.0%}. Too many RIPE predictions fail. " - f"Be more conservative in marking cases RIPE." - ), - confidence="high", - )) + adjustments.append( + ThresholdAdjustment( + threshold_name="MIN_SERVICE_HEARINGS", + current_value=current_hearings, + suggested_value=suggested_hearings, + reason=( + f"RIPE precision {accuracy['ripe_precision']:.1%} below " + f"{cls.LOW_RIPE_PRECISION_THRESHOLD:.0%}. Too many RIPE predictions fail. " + f"Be more conservative in marking cases RIPE." + ), + confidence="high", + ) + ) # 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 - adjustments.append(ThresholdAdjustment( - threshold_name="MIN_STAGE_DAYS", - current_value=current_days, - suggested_value=suggested_days, - reason=( - f"UNRIPE recall {accuracy['unripe_recall']:.1%} below " - f"{cls.LOW_UNRIPE_RECALL_THRESHOLD:.0%}. Missing many bottlenecks. " - f"Increase stage maturity requirement to catch more unripe cases." - ), - confidence="medium", - )) + adjustments.append( + ThresholdAdjustment( + threshold_name="MIN_STAGE_DAYS", + current_value=current_days, + suggested_value=suggested_days, + reason=( + f"UNRIPE recall {accuracy['unripe_recall']:.1%} below " + f"{cls.LOW_UNRIPE_RECALL_THRESHOLD:.0%}. Missing many bottlenecks. " + f"Increase stage maturity requirement to catch more unripe cases." + ), + confidence="medium", + ) + ) # Deduplicate adjustments (same threshold suggested multiple times) deduplicated = cls._deduplicate_adjustments(adjustments) @@ -165,11 +178,19 @@ class RipenessCalibrator: existing = threshold_map[adj.threshold_name] confidence_order = {"high": 3, "medium": 2, "low": 1} - if confidence_order[adj.confidence] > confidence_order[existing.confidence]: + if ( + confidence_order[adj.confidence] + > confidence_order[existing.confidence] + ): threshold_map[adj.threshold_name] = adj - elif confidence_order[adj.confidence] == confidence_order[existing.confidence]: + elif ( + confidence_order[adj.confidence] + == confidence_order[existing.confidence] + ): # Same confidence - keep larger adjustment magnitude - existing_delta = abs(existing.suggested_value - existing.current_value) + existing_delta = abs( + existing.suggested_value - existing.current_value + ) new_delta = abs(adj.suggested_value - adj.current_value) if new_delta > existing_delta: threshold_map[adj.threshold_name] = adj @@ -211,36 +232,44 @@ class RipenessCalibrator: ] if not adjustments: - lines.extend([ - "Recommended Adjustments:", - " No adjustments needed - performance is within acceptable ranges.", - "", - "Current thresholds are performing well. Continue monitoring.", - ]) + lines.extend( + [ + "Recommended Adjustments:", + " No adjustments needed - performance is within acceptable ranges.", + "", + "Current thresholds are performing well. Continue monitoring.", + ] + ) else: - lines.extend([ - "Recommended Adjustments:", - "", - ]) + lines.extend( + [ + "Recommended Adjustments:", + "", + ] + ) for i, adj in enumerate(adjustments, 1): - lines.extend([ - f"{i}. {adj.threshold_name}", - f" Current: {adj.current_value}", - f" Suggested: {adj.suggested_value}", - f" Confidence: {adj.confidence.upper()}", - f" Reason: {adj.reason}", + lines.extend( + [ + f"{i}. {adj.threshold_name}", + f" Current: {adj.current_value}", + f" Suggested: {adj.suggested_value}", + f" Confidence: {adj.confidence.upper()}", + f" Reason: {adj.reason}", + "", + ] + ) + + lines.extend( + [ + "Implementation:", + " 1. Review suggested adjustments", + " 2. Apply using: RipenessClassifier.set_thresholds(new_values)", + " 3. Re-run simulation to validate improvements", + " 4. Compare new metrics with baseline", "", - ]) - - lines.extend([ - "Implementation:", - " 1. Review suggested adjustments", - " 2. Apply using: RipenessClassifier.set_thresholds(new_values)", - " 3. Re-run simulation to validate improvements", - " 4. Compare new metrics with baseline", - "", - ]) + ] + ) report = "\n".join(lines) @@ -272,7 +301,8 @@ class RipenessCalibrator: new_thresholds[adj.threshold_name] = adj.suggested_value if auto_apply: - from scheduler.core.ripeness import RipenessClassifier + from src.core.ripeness import RipenessClassifier + RipenessClassifier.set_thresholds(new_thresholds) print(f"Applied {len(adjustments)} threshold adjustments") diff --git a/scheduler/monitoring/ripeness_metrics.py b/src/monitoring/ripeness_metrics.py similarity index 75% rename from scheduler/monitoring/ripeness_metrics.py rename to src/monitoring/ripeness_metrics.py index d4537210dc8f355b30c68d08bdfc188d099d28f5..0b68558a43706666e10bb2ca9119c731ed7f2a76 100644 --- a/scheduler/monitoring/ripeness_metrics.py +++ b/src/monitoring/ripeness_metrics.py @@ -13,7 +13,7 @@ from typing import Optional import pandas as pd -from scheduler.core.ripeness import RipenessStatus +from src.core.ripeness import RipenessStatus @dataclass @@ -108,9 +108,19 @@ class RipenessMetrics: 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] + 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] @@ -118,19 +128,29 @@ class RipenessMetrics: # 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_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 + 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 + 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 + unripe_recall = ( + len(unripe_correct) / len(adjourned_cases) if adjourned_cases else 0.0 + ) return { "total_predictions": total + len(self.predictions), @@ -176,18 +196,23 @@ class RipenessMetrics: """ records = [] for pred in self.completed_predictions: - records.append({ - "case_id": pred.case_id, - "predicted_status": pred.predicted_status.value, - "prediction_date": pred.prediction_date, - "actual_outcome": pred.actual_outcome, - "was_adjourned": pred.was_adjourned, - "outcome_date": pred.outcome_date, - "correct_prediction": ( - (pred.predicted_status == RipenessStatus.RIPE and not pred.was_adjourned) - or (pred.predicted_status.is_unripe() and pred.was_adjourned) - ), - }) + records.append( + { + "case_id": pred.case_id, + "predicted_status": pred.predicted_status.value, + "prediction_date": pred.prediction_date, + "actual_outcome": pred.actual_outcome, + "was_adjourned": pred.was_adjourned, + "outcome_date": pred.outcome_date, + "correct_prediction": ( + ( + pred.predicted_status == RipenessStatus.RIPE + and not pred.was_adjourned + ) + or (pred.predicted_status.is_unripe() and pred.was_adjourned) + ), + } + ) return pd.DataFrame(records) @@ -237,16 +262,26 @@ class RipenessMetrics: ] # Add interpretation - if metrics['false_positive_rate'] > 0.20: - report_lines.append(" - HIGH false positive rate: Consider increasing MIN_SERVICE_HEARINGS") - if metrics['false_negative_rate'] > 0.15: - report_lines.append(" - HIGH false negative rate: Consider decreasing MIN_STAGE_DAYS") - if metrics['unknown_rate'] < 0.05: - report_lines.append(" - LOW UNKNOWN rate: System may be overconfident, add uncertainty") - if metrics['ripe_precision'] > 0.85: - 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") + if metrics["false_positive_rate"] > 0.20: + report_lines.append( + " - HIGH false positive rate: Consider increasing MIN_SERVICE_HEARINGS" + ) + if metrics["false_negative_rate"] > 0.15: + report_lines.append( + " - HIGH false negative rate: Consider decreasing MIN_STAGE_DAYS" + ) + if metrics["unknown_rate"] < 0.05: + report_lines.append( + " - LOW UNKNOWN rate: System may be overconfident, add uncertainty" + ) + if metrics["ripe_precision"] > 0.85: + 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) diff --git a/scheduler/output/__init__.py b/src/output/__init__.py similarity index 100% rename from scheduler/output/__init__.py rename to src/output/__init__.py diff --git a/scheduler/output/cause_list.py b/src/output/cause_list.py similarity index 100% rename from scheduler/output/cause_list.py rename to src/output/cause_list.py diff --git a/scheduler/simulation/__init__.py b/src/simulation/__init__.py similarity index 100% rename from scheduler/simulation/__init__.py rename to src/simulation/__init__.py diff --git a/scheduler/simulation/allocator.py b/src/simulation/allocator.py similarity index 95% rename from scheduler/simulation/allocator.py rename to src/simulation/allocator.py index 9dc0dd95184f006888d9882a8f4b311a94a40a28..a31502449f33f03f881da51b7830a3fb089e4c96 100644 --- a/scheduler/simulation/allocator.py +++ b/src/simulation/allocator.py @@ -14,7 +14,7 @@ from enum import Enum from typing import TYPE_CHECKING if TYPE_CHECKING: - from scheduler.core.case import Case + from src.core.case import Case class AllocationStrategy(Enum): @@ -32,7 +32,9 @@ class CourtroomState: courtroom_id: int daily_load: int = 0 # Number of cases scheduled today total_cases_handled: int = 0 # Lifetime count - case_type_distribution: dict[str, int] = field(default_factory=dict) # Type -> count + case_type_distribution: dict[str, int] = field( + default_factory=dict + ) # Type -> count def add_case(self, case: Case) -> None: """Register a case assigned to this courtroom.""" @@ -77,10 +79,14 @@ 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} + self.daily_loads: dict[ + date, dict[int, int] + ] = {} # date -> {courtroom_id -> load} self.allocation_changes: int = 0 # Cases that switched courtrooms self.capacity_rejections: int = 0 # Cases that couldn't be allocated @@ -163,14 +169,14 @@ class CourtroomAllocator: def _find_type_affinity_courtroom(self, case: Case) -> int | None: """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: """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 @@ -205,7 +211,9 @@ class CourtroomAllocator: courtroom_totals[cid] += load num_days = len(self.daily_loads) - courtroom_avgs = {cid: total / num_days for cid, total in courtroom_totals.items()} + courtroom_avgs = { + cid: total / num_days for cid, total in courtroom_totals.items() + } # Calculate Gini coefficient for fairness sorted_totals = sorted(courtroom_totals.values()) diff --git a/scheduler/simulation/engine.py b/src/simulation/engine.py similarity index 91% rename from scheduler/simulation/engine.py rename to src/simulation/engine.py index 4702f855daf67443738e23f376ce8efd86f186c1..af8bd22b7b17e56bbee52585485455e5eb71bb9f 100644 --- a/scheduler/simulation/engine.py +++ b/src/simulation/engine.py @@ -19,11 +19,11 @@ from datetime import date, timedelta 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 -from scheduler.data.config import ( +from src.core.algorithm import SchedulingAlgorithm, SchedulingResult +from src.core.case import Case, CaseStatus +from src.core.courtroom import Courtroom +from src.core.ripeness import RipenessClassifier +from src.data.config import ( ANNUAL_FILING_RATE, COURTROOMS, DEFAULT_DAILY_CAPACITY, @@ -31,11 +31,11 @@ from scheduler.data.config import ( 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 +from src.data.param_loader import load_parameters +from src.simulation.allocator import AllocationStrategy, CourtroomAllocator +from src.simulation.events import EventWriter +from src.simulation.policies import get_policy +from src.utils.calendar import CourtCalendar @dataclass @@ -110,7 +110,9 @@ class CourtSim: # resources self.rooms = [ Courtroom( - courtroom_id=i + 1, judge_id=f"J{i + 1:03d}", daily_capacity=self.cfg.daily_capacity + courtroom_id=i + 1, + judge_id=f"J{i + 1:03d}", + daily_capacity=self.cfg.daily_capacity, ) for i in range(self.cfg.courtrooms) ] @@ -135,7 +137,9 @@ class CourtSim: ) # 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 ------------------------------------------------------------- @@ -145,7 +149,11 @@ class CourtSim: # 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)) + 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 @@ -181,7 +189,12 @@ class CourtSim: """ # 1. Must be in a stage where disposal is possible # Historical data shows 90% disposals happen in ADMISSION or ORDERS - disposal_capable_stages = ["ORDERS / JUDGMENT", "ARGUMENTS", "ADMISSION", "FINAL DISPOSAL"] + disposal_capable_stages = [ + "ORDERS / JUDGMENT", + "ARGUMENTS", + "ADMISSION", + "FINAL DISPOSAL", + ] if case.current_stage not in disposal_capable_stages: return False @@ -331,7 +344,9 @@ class CourtSim: # stage gating for new case dur = int( round( - self.params.get_stage_duration(case.current_stage, self.cfg.duration_percentile) + self.params.get_stage_duration( + case.current_stage, self.cfg.duration_percentile + ) ) ) dur = max(1, dur) @@ -424,12 +439,16 @@ class CourtSim: int(case.is_urgent), case.current_stage, case.days_since_last_hearing, - self._stage_ready.get(case.case_id, current).isoformat(), + 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") + case.record_hearing( + current, was_heard=False, outcome="adjourned" + ) self._events.write( current, "outcome", @@ -470,7 +489,9 @@ class CourtSim: ) disposed = True - if not disposed and current >= self._stage_ready.get(case.case_id, current): + 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 @@ -485,7 +506,8 @@ class CourtSim: ) # Explicit stage-based disposal (rare but possible) if not disposed and ( - case.status == CaseStatus.DISPOSED or next_stage in TERMINAL_STAGES + case.status == CaseStatus.DISPOSED + or next_stage in TERMINAL_STAGES ): self._disposals += 1 self._events.write( @@ -502,12 +524,15 @@ class CourtSim: dur = int( round( self.params.get_stage_duration( - case.current_stage, self.cfg.duration_percentile + case.current_stage, + self.cfg.duration_percentile, ) ) ) dur = max(1, dur) - self._stage_ready[case.case_id] = current + timedelta(days=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( @@ -546,7 +571,9 @@ 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 + 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 ] @@ -554,7 +581,11 @@ class CourtSim: 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 + 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] @@ -577,7 +608,9 @@ class CourtSim: # 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] + 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 @@ -606,9 +639,9 @@ class CourtSim: 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_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) diff --git a/scheduler/simulation/events.py b/src/simulation/events.py similarity index 100% rename from scheduler/simulation/events.py rename to src/simulation/events.py diff --git a/scheduler/simulation/policies/__init__.py b/src/simulation/policies/__init__.py similarity index 63% rename from scheduler/simulation/policies/__init__.py rename to src/simulation/policies/__init__.py index 089d3d5ce5251c9e1c3049a4633d644f5abc6934..671bc09b00af4fcedad496eba570e9115545d3a8 100644 --- a/scheduler/simulation/policies/__init__.py +++ b/src/simulation/policies/__init__.py @@ -1,9 +1,9 @@ """Scheduling policy implementations.""" -from scheduler.core.policy import SchedulerPolicy -from scheduler.simulation.policies.age import AgeBasedPolicy -from scheduler.simulation.policies.fifo import FIFOPolicy -from scheduler.simulation.policies.readiness import ReadinessPolicy +from src.core.policy import SchedulerPolicy +from src.simulation.policies.age import AgeBasedPolicy +from src.simulation.policies.fifo import FIFOPolicy +from src.simulation.policies.readiness import ReadinessPolicy # Registry of supported policies (RL removed) POLICY_REGISTRY = { @@ -26,4 +26,10 @@ def get_policy(name: str, **kwargs): return POLICY_REGISTRY[name_lower](**kwargs) -__all__ = ["SchedulerPolicy", "FIFOPolicy", "AgeBasedPolicy", "ReadinessPolicy", "get_policy"] +__all__ = [ + "SchedulerPolicy", + "FIFOPolicy", + "AgeBasedPolicy", + "ReadinessPolicy", + "get_policy", +] diff --git a/scheduler/simulation/policies/age.py b/src/simulation/policies/age.py similarity index 91% rename from scheduler/simulation/policies/age.py rename to src/simulation/policies/age.py index 36bdc5a04c2a047cf0fad8dc34a24fc8ba2d4770..ef07721349ee4a28adc33ffd8d56397d21619063 100644 --- a/scheduler/simulation/policies/age.py +++ b/src/simulation/policies/age.py @@ -3,13 +3,14 @@ Prioritizes older cases to reduce maximum age and prevent starvation. Uses case age (days since filing) as primary criterion. """ + from __future__ import annotations from datetime import date from typing import List -from scheduler.core.case import Case -from scheduler.core.policy import SchedulerPolicy +from src.core.case import Case +from src.core.policy import SchedulerPolicy class AgeBasedPolicy(SchedulerPolicy): diff --git a/scheduler/simulation/policies/fifo.py b/src/simulation/policies/fifo.py similarity index 90% rename from scheduler/simulation/policies/fifo.py rename to src/simulation/policies/fifo.py index e6fe4ead5a73699ed16a4d7882b310ebf37c1898..97535419f8d1481917449f5a9f5cc5e4ade587eb 100644 --- a/scheduler/simulation/policies/fifo.py +++ b/src/simulation/policies/fifo.py @@ -3,13 +3,14 @@ Schedules cases in the order they were filed, treating all cases equally. This is the simplest baseline policy. """ + from __future__ import annotations from datetime import date from typing import List -from scheduler.core.case import Case -from scheduler.core.policy import SchedulerPolicy +from src.core.case import Case +from src.core.policy import SchedulerPolicy class FIFOPolicy(SchedulerPolicy): diff --git a/scheduler/simulation/policies/readiness.py b/src/simulation/policies/readiness.py similarity index 93% rename from scheduler/simulation/policies/readiness.py rename to src/simulation/policies/readiness.py index c758d0429a61075b8f77afa51a79d3ce26bcb85d..a325fbcf8840c9f5c2512e75d370a2fde1dbaa5a 100644 --- a/scheduler/simulation/policies/readiness.py +++ b/src/simulation/policies/readiness.py @@ -6,13 +6,14 @@ This is the most sophisticated policy, balancing fairness with efficiency. Priority formula: priority = (age/2000) * 0.4 + readiness * 0.3 + urgent * 0.3 """ + from __future__ import annotations from datetime import date from typing import List -from scheduler.core.case import Case -from scheduler.core.policy import SchedulerPolicy +from src.core.case import Case +from src.core.policy import SchedulerPolicy class ReadinessPolicy(SchedulerPolicy): diff --git a/scheduler/utils/__init__.py b/src/utils/__init__.py similarity index 100% rename from scheduler/utils/__init__.py rename to src/utils/__init__.py diff --git a/scheduler/utils/calendar.py b/src/utils/calendar.py similarity index 99% rename from scheduler/utils/calendar.py rename to src/utils/calendar.py index 531a2f901ea4fc4c2306f9ba2bae95c8d564bb2b..a8c09505a13af898b9cfafb5559c2b5f39e47eb6 100644 --- a/scheduler/utils/calendar.py +++ b/src/utils/calendar.py @@ -7,7 +7,7 @@ court holidays, seasonality, and Karnataka High Court calendar. from datetime import date, timedelta from typing import List, Set -from scheduler.data.config import ( +from src.data.config import ( SEASONALITY_FACTORS, WORKING_DAYS_PER_YEAR, ) diff --git a/tests/conftest.py b/tests/conftest.py index cd68663921a3213732b62434004d788289b2d2a8..580df6bf68760529ff07bc16567dd9d0794ccdce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,20 +15,24 @@ from typing import List import pytest -from scheduler.core.case import Case, CaseStatus -from scheduler.core.courtroom import Courtroom -from scheduler.data.case_generator import CaseGenerator -from scheduler.data.param_loader import ParameterLoader +from src.core.case import Case, CaseStatus +from src.core.courtroom import Courtroom +from src.data.case_generator import CaseGenerator +from src.data.param_loader import ParameterLoader # Test markers def pytest_configure(config): """Configure custom pytest markers.""" config.addinivalue_line("markers", "unit: Unit tests for individual components") - config.addinivalue_line("markers", "integration: Integration tests for multi-component workflows") + config.addinivalue_line( + "markers", "integration: Integration tests for multi-component workflows" + ) config.addinivalue_line("markers", "rl: Reinforcement learning tests") config.addinivalue_line("markers", "simulation: Simulation engine tests") - config.addinivalue_line("markers", "edge_case: Edge case and boundary condition tests") + config.addinivalue_line( + "markers", "edge_case: Edge case and boundary condition tests" + ) config.addinivalue_line("markers", "failure: Failure scenario tests") config.addinivalue_line("markers", "slow: Slow-running tests (>5 seconds)") @@ -40,11 +44,7 @@ def sample_cases() -> List[Case]: Returns: List of 100 cases with diverse types, stages, and ages """ - generator = CaseGenerator( - start=date(2024, 1, 1), - end=date(2024, 3, 31), - seed=42 - ) + generator = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 3, 31), seed=42) cases = generator.generate(100, stage_mix_auto=True) return cases @@ -56,11 +56,7 @@ def small_case_set() -> List[Case]: Returns: List of 10 cases """ - generator = CaseGenerator( - start=date(2024, 1, 1), - end=date(2024, 1, 10), - seed=42 - ) + generator = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42) cases = generator.generate(10) return cases @@ -80,7 +76,7 @@ def single_case() -> Case: last_hearing_date=None, age_days=30, hearing_count=0, - status=CaseStatus.PENDING + status=CaseStatus.PENDING, ) @@ -99,12 +95,12 @@ def ripe_case() -> Case: last_hearing_date=date(2024, 2, 1), age_days=90, hearing_count=5, - status=CaseStatus.ACTIVE + status=CaseStatus.ACTIVE, ) # Set additional attributes that may be needed - if hasattr(case, 'service_status'): + if hasattr(case, "service_status"): case.service_status = "SERVED" - if hasattr(case, 'compliance_status'): + if hasattr(case, "compliance_status"): case.compliance_status = "COMPLIED" return case @@ -124,12 +120,12 @@ def unripe_case() -> Case: last_hearing_date=None, age_days=15, hearing_count=1, - status=CaseStatus.PENDING + status=CaseStatus.PENDING, ) # Set additional attributes - if hasattr(case, 'service_status'): + if hasattr(case, "service_status"): case.service_status = "PENDING" - if hasattr(case, 'last_hearing_purpose'): + if hasattr(case, "last_hearing_purpose"): case.last_hearing_purpose = "FOR ISSUE OF SUMMONS" return case @@ -216,7 +212,7 @@ def disposed_case() -> Case: last_hearing_date=date(2024, 3, 15), age_days=180, hearing_count=8, - status=CaseStatus.DISPOSED + status=CaseStatus.DISPOSED, ) return case @@ -236,7 +232,7 @@ def aged_case() -> Case: last_hearing_date=date(2024, 5, 1), age_days=800, hearing_count=25, - status=CaseStatus.ACTIVE + status=CaseStatus.ACTIVE, ) return case @@ -257,13 +253,14 @@ def urgent_case() -> Case: age_days=5, hearing_count=0, status=CaseStatus.PENDING, - is_urgent=True + is_urgent=True, ) return case # Helper functions for tests + def assert_valid_case(case: Case): """Assert that a case has all required fields and valid values. @@ -294,7 +291,7 @@ def create_case_with_hearings(n_hearings: int, days_between: int = 30) -> Case: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ARGUMENTS", - status=CaseStatus.ACTIVE + status=CaseStatus.ACTIVE, ) current_date = date(2024, 1, 1) diff --git a/tests/integration/test_simulation.py b/tests/integration/test_simulation.py index 8afe00dd7798abbf129f198790c54c08ea107588..50201927be836aa69c224f252ddcc7175c315c6e 100644 --- a/tests/integration/test_simulation.py +++ b/tests/integration/test_simulation.py @@ -7,8 +7,8 @@ from datetime import date import pytest -from scheduler.data.case_generator import CaseGenerator -from scheduler.simulation.engine import CourtSim, CourtSimConfig +from src.data.case_generator import CaseGenerator +from src.simulation.engine import CourtSim, CourtSimConfig @pytest.mark.integration @@ -25,7 +25,7 @@ class TestSimulationBasics: courtrooms=2, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, small_case_set) @@ -44,7 +44,7 @@ class TestSimulationBasics: courtrooms=3, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, sample_cases) @@ -64,14 +64,16 @@ class TestSimulationBasics: courtrooms=5, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, sample_cases) result = sim.run() assert result.hearings_total > 0 - assert result.hearings_heard + result.hearings_adjourned == result.hearings_total + assert ( + result.hearings_heard + result.hearings_adjourned == result.hearings_total + ) # Check disposal rate is reasonable if result.hearings_total > 0: disposal_rate = result.disposals / len(sample_cases) @@ -92,7 +94,7 @@ class TestOutcomeTracking: courtrooms=2, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, small_case_set) @@ -113,7 +115,7 @@ class TestOutcomeTracking: courtrooms=5, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, sample_cases) @@ -133,7 +135,7 @@ class TestOutcomeTracking: courtrooms=3, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, sample_cases) @@ -157,7 +159,7 @@ class TestStageProgression: courtrooms=5, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) # Record initial stages @@ -168,7 +170,8 @@ class TestStageProgression: # Check if any cases progressed progressed = sum( - 1 for case in sample_cases + 1 + for case in sample_cases if case.current_stage != initial_stages.get(case.case_id) ) @@ -184,14 +187,15 @@ class TestStageProgression: courtrooms=5, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, sample_cases) sim.run() # Check disposed cases are in terminal stages - from scheduler.data.config import TERMINAL_STAGES + from src.data.config import TERMINAL_STAGES + for case in sample_cases: if case.is_disposed(): assert case.current_stage in TERMINAL_STAGES @@ -211,7 +215,7 @@ class TestRipenessIntegration: courtrooms=5, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, sample_cases) @@ -223,7 +227,9 @@ class TestRipenessIntegration: def test_unripe_filtering(self, temp_output_dir): """Test that unripe cases are filtered from scheduling.""" # Create mix of ripe and unripe cases - generator = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42) + generator = CaseGenerator( + start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42 + ) cases = generator.generate(50) # Mark some as unripe @@ -239,7 +245,7 @@ class TestRipenessIntegration: courtrooms=3, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, cases) @@ -263,7 +269,7 @@ class TestSimulationEdgeCases: courtrooms=2, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, []) @@ -291,7 +297,7 @@ class TestSimulationEdgeCases: courtrooms=2, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, cases) @@ -311,7 +317,7 @@ class TestSimulationEdgeCases: courtrooms=2, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) @pytest.mark.failure @@ -325,7 +331,7 @@ class TestSimulationEdgeCases: courtrooms=2, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) @@ -343,7 +349,7 @@ class TestEventLogging: courtrooms=2, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, small_case_set) @@ -354,6 +360,7 @@ class TestEventLogging: if events_file.exists(): # Verify it's readable import pandas as pd + df = pd.read_csv(events_file) assert len(df) >= 0 @@ -366,7 +373,7 @@ class TestEventLogging: courtrooms=2, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir + log_dir=temp_output_dir, ) sim = CourtSim(config, small_case_set) @@ -376,6 +383,7 @@ class TestEventLogging: events_file = temp_output_dir / "events.csv" if events_file.exists(): import pandas as pd + pd.read_csv(events_file) # Event count should match or be close to hearings_total # (may have additional events for filings, etc.) @@ -395,7 +403,7 @@ class TestPolicyComparison: courtrooms=3, daily_capacity=50, policy="fifo", - log_dir=temp_output_dir / "fifo" + log_dir=temp_output_dir / "fifo", ) sim = CourtSim(config, sample_cases.copy()) @@ -412,7 +420,7 @@ class TestPolicyComparison: courtrooms=3, daily_capacity=50, policy="age", - log_dir=temp_output_dir / "age" + log_dir=temp_output_dir / "age", ) sim = CourtSim(config, sample_cases.copy()) @@ -429,11 +437,10 @@ class TestPolicyComparison: courtrooms=3, daily_capacity=50, policy="readiness", - log_dir=temp_output_dir / "readiness" + log_dir=temp_output_dir / "readiness", ) sim = CourtSim(config, sample_cases.copy()) result = sim.run() assert result.hearings_total > 0 - diff --git a/tests/test_enhancements.py b/tests/test_enhancements.py index 7a5dd5dc1ead7232153f757f15343aa6a5cb0c60..ad59d71eba85d4ee5cebc25d3ff850402a2d8cfd 100644 --- a/tests/test_enhancements.py +++ b/tests/test_enhancements.py @@ -34,12 +34,12 @@ def log_test(name: str, passed: bool, details: str = ""): 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.data.case_generator import CaseGenerator - from scheduler.simulation.allocator import CourtroomAllocator - from scheduler.simulation.policies.readiness import ReadinessPolicy + from src.control.overrides import Override, OverrideType + from src.core.algorithm import SchedulingAlgorithm + from src.core.courtroom import Courtroom + from src.data.case_generator import CaseGenerator + from src.simulation.allocator import CourtroomAllocator + from src.simulation.policies.readiness import ReadinessPolicy try: # Generate test cases @@ -54,7 +54,7 @@ def test_pr2_override_validation(): case_id=cases[0].case_id, judge_id="TEST-JUDGE", timestamp=datetime.now(), - new_priority=0.95 + new_priority=0.95, ), Override( override_id="test-2", @@ -62,8 +62,8 @@ def test_pr2_override_validation(): case_id="INVALID-CASE-ID", # Invalid case judge_id="TEST-JUDGE", timestamp=datetime.now(), - new_priority=0.85 - ) + new_priority=0.85, + ), ] original_count = len(test_overrides) @@ -79,20 +79,25 @@ def test_pr2_override_validation(): cases=cases, courtrooms=courtrooms, current_date=date(2024, 1, 15), - overrides=test_overrides + overrides=test_overrides, ) # Verify original list unchanged - assert len(test_overrides) == original_count, "Original override list was mutated" + 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" + assert hasattr(result, "override_rejections"), "No override_rejections field" # Verify applied overrides tracked - assert hasattr(result, 'applied_overrides'), "No applied_overrides field" + assert hasattr(result, "applied_overrides"), "No applied_overrides field" - log_test("PR #2: Override validation", True, - f"Applied: {len(result.applied_overrides)}, Rejected: {len(result.override_rejections)}") + log_test( + "PR #2: Override validation", + True, + f"Applied: {len(result.applied_overrides)}, Rejected: {len(result.override_rejections)}", + ) return True except Exception as e: @@ -102,11 +107,11 @@ def test_pr2_override_validation(): def test_pr2_flag_cleanup(): """Test PR #2: Temporary case flags are cleared after scheduling.""" - from scheduler.core.algorithm import SchedulingAlgorithm - from scheduler.core.courtroom import Courtroom - from scheduler.data.case_generator import CaseGenerator - from scheduler.simulation.allocator import CourtroomAllocator - from scheduler.simulation.policies.readiness import ReadinessPolicy + from src.core.algorithm import SchedulingAlgorithm + from src.core.courtroom import Courtroom + from src.data.case_generator import CaseGenerator + from src.simulation.allocator import CourtroomAllocator + from src.simulation.policies.readiness import ReadinessPolicy try: gen = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42) @@ -125,10 +130,14 @@ def test_pr2_flag_cleanup(): 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" + 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") + log_test( + "PR #2: Flag cleanup", True, "Temporary flags cleared after scheduling" + ) return True except Exception as e: @@ -138,12 +147,12 @@ 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 RipenessClassifier, RipenessStatus - from scheduler.data.case_generator import CaseGenerator + from src.core.ripeness import RipenessClassifier, RipenessStatus + from src.data.case_generator import CaseGenerator try: # Verify UNKNOWN status exists - assert hasattr(RipenessStatus, 'UNKNOWN'), "RipenessStatus.UNKNOWN not found" + 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) @@ -159,8 +168,9 @@ def test_pr3_unknown_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(), \ + 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 @@ -184,15 +194,18 @@ def test_pr6_parameter_fallback(): "adjournment_proxies.csv", "court_capacity_global.json", "stage_transition_entropy.csv", - "case_type_summary.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, - f"Found {len(expected_files)} default parameter files") + log_test( + "PR #6: Parameter fallback", + True, + f"Found {len(expected_files)} default parameter files", + ) return True except Exception as e: @@ -204,7 +217,7 @@ 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 scheduler.data.case_generator import CaseGenerator + from src.data.case_generator import CaseGenerator try: # Create training environment @@ -219,21 +232,20 @@ def test_pr4_rl_constraints(): daily_capacity_per_courtroom=50, enforce_min_gap=True, cap_daily_allocations=True, - apply_judge_preferences=True + apply_judge_preferences=True, ) env = RLTrainingEnvironment( - cases=cases, - start_date=date(2024, 1, 1), - horizon_days=10, - rl_config=config + 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" + 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} @@ -241,8 +253,11 @@ def test_pr4_rl_constraints(): assert len(rewards) >= 0, "No rewards returned from step" - log_test("PR #4: RL constraints", True, - f"Environment has algorithm, courtrooms, allocator. Capacity enforced: {config.cap_daily_allocations}") + 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: @@ -254,21 +269,24 @@ def test_pr5_shared_rewards(): """Test PR #5: Shared reward helper exists and is used.""" from rl.rewards import EpisodeRewardHelper from rl.training import RLTrainingEnvironment - from scheduler.data.case_generator import CaseGenerator + from src.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" + 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), \ + 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] @@ -277,12 +295,16 @@ def test_pr5_shared_rewards(): was_scheduled=True, hearing_outcome="PROGRESS", current_date=date(2024, 1, 15), - previous_gap_days=30 + 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}") + log_test( + "PR #5: Shared rewards", + True, + f"Helper integrated, sample reward: {reward:.2f}", + ) return True except Exception as e: @@ -292,7 +314,7 @@ def test_pr5_shared_rewards(): def test_pr7_metadata_tracking(): """Test PR #7: Output metadata tracking.""" - from scheduler.utils.output_manager import OutputManager + from src.utils.output_manager import OutputManager try: # Create output manager @@ -300,10 +322,14 @@ def test_pr7_metadata_tracking(): 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" + 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" @@ -313,19 +339,23 @@ def test_pr7_metadata_tracking(): version="test_v1", used_cached=False, params_path=Path("test_params"), - figures_path=Path("test_figures") + figures_path=Path("test_figures"), ) # Verify metadata was written import json - with open(output.run_record_file, 'r') as f: + + 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" + 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, - f"Run record created with {len(record['sections'])} sections") + log_test( + "PR #7: Metadata tracking", + True, + f"Run record created with {len(record['sections'])} sections", + ) return True except Exception as e: diff --git a/tests/test_gap_fixes.py b/tests/test_gap_fixes.py index e2912a8f4bf1a1abe31edc627be0b1768f78ecec..9d3eb22c8bad5c9b0cde580606fe3861509246aa 100644 --- a/tests/test_gap_fixes.py +++ b/tests/test_gap_fixes.py @@ -10,11 +10,11 @@ from datetime import date, datetime 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.monitoring.ripeness_calibrator import RipenessCalibrator -from scheduler.monitoring.ripeness_metrics import RipenessMetrics +from src.core.ripeness import RipenessClassifier, RipenessStatus +from src.data.case_generator import CaseGenerator +from src.data.param_loader import ParameterLoader +from src.monitoring.ripeness_calibrator import RipenessCalibrator +from src.monitoring.ripeness_metrics import RipenessMetrics def test_gap1_eda_alignment(): @@ -39,8 +39,10 @@ def test_gap1_eda_alignment(): ) # 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" + 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") @@ -64,7 +66,9 @@ def test_gap1_eda_alignment(): 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%}" + 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") @@ -88,9 +92,13 @@ def test_gap2_ripeness_feedback(): elif i % 4 == 1: test_cases.append((f"case{i}", RipenessStatus.RIPE, True)) # False positive elif i % 4 == 2: - test_cases.append((f"case{i}", RipenessStatus.UNRIPE_SUMMONS, True)) # Correct UNRIPE + 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 + test_cases.append( + (f"case{i}", RipenessStatus.UNRIPE_SUMMONS, False) + ) # False negative prediction_date = datetime(2024, 1, 1) outcome_date = datetime(2024, 1, 2) @@ -111,8 +119,8 @@ def test_gap2_ripeness_feedback(): 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" + assert accuracy["false_positive_rate"] > 0.4, "Should detect false positives" + assert accuracy["false_negative_rate"] > 0.4, "Should detect false negatives" print("\nRipenessMetrics successfully tracks classification accuracy") @@ -121,7 +129,9 @@ def test_gap2_ripeness_feedback(): 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" - {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" diff --git a/tests/test_invariants.py b/tests/test_invariants.py index 2473ca8c8168b503fb22331b0c38accadeb7ac15..6504c73e6c78786a458f246675af85c446055ca8 100644 --- a/tests/test_invariants.py +++ b/tests/test_invariants.py @@ -1,8 +1,8 @@ from datetime import date -from scheduler.core.case import Case -from scheduler.core.courtroom import Courtroom -from scheduler.utils.calendar import CourtCalendar +from src.core.case import Case +from src.core.courtroom import Courtroom +from src.utils.calendar import CourtCalendar def test_calendar_excludes_weekends(): diff --git a/tests/unit/policies/test_fifo_policy.py b/tests/unit/policies/test_fifo_policy.py index c4a774bd228f2cf1a8fa53957939289039d9210e..1f7708104046ab429bb76c690f9a392024c54e66 100644 --- a/tests/unit/policies/test_fifo_policy.py +++ b/tests/unit/policies/test_fifo_policy.py @@ -7,8 +7,8 @@ from datetime import date import pytest -from scheduler.core.case import Case -from scheduler.simulation.policies.fifo import FIFOPolicy +from src.core.case import Case +from src.simulation.policies.fifo import FIFOPolicy @pytest.mark.unit @@ -21,9 +21,24 @@ class TestFIFOPolicy: # Create cases with different filing dates cases = [ - Case(case_id="C3", case_type="RSA", filed_date=date(2024, 3, 1), current_stage="ADMISSION"), - Case(case_id="C1", case_type="CRP", filed_date=date(2024, 1, 1), current_stage="ADMISSION"), - Case(case_id="C2", case_type="CA", filed_date=date(2024, 2, 1), current_stage="ADMISSION"), + Case( + case_id="C3", + case_type="RSA", + filed_date=date(2024, 3, 1), + current_stage="ADMISSION", + ), + Case( + case_id="C1", + case_type="CRP", + filed_date=date(2024, 1, 1), + current_stage="ADMISSION", + ), + Case( + case_id="C2", + case_type="CA", + filed_date=date(2024, 2, 1), + current_stage="ADMISSION", + ), ] prioritized = policy.prioritize(cases, current_date=date(2024, 4, 1)) @@ -38,9 +53,24 @@ class TestFIFOPolicy: policy = FIFOPolicy() cases = [ - Case(case_id="C-B", case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION"), - Case(case_id="C-A", case_type="CRP", filed_date=date(2024, 1, 1), current_stage="ADMISSION"), - Case(case_id="C-C", case_type="CA", filed_date=date(2024, 1, 1), current_stage="ADMISSION"), + Case( + case_id="C-B", + case_type="RSA", + filed_date=date(2024, 1, 1), + current_stage="ADMISSION", + ), + Case( + case_id="C-A", + case_type="CRP", + filed_date=date(2024, 1, 1), + current_stage="ADMISSION", + ), + Case( + case_id="C-C", + case_type="CA", + filed_date=date(2024, 1, 1), + current_stage="ADMISSION", + ), ] prioritized = policy.prioritize(cases, current_date=date(2024, 2, 1)) @@ -61,7 +91,14 @@ class TestFIFOPolicy: """Test FIFO with single case.""" policy = FIFOPolicy() - cases = [Case(case_id="ONLY", case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION")] + cases = [ + Case( + case_id="ONLY", + case_type="RSA", + filed_date=date(2024, 1, 1), + current_stage="ADMISSION", + ) + ] prioritized = policy.prioritize(cases, current_date=date(2024, 2, 1)) @@ -73,9 +110,24 @@ class TestFIFOPolicy: policy = FIFOPolicy() cases = [ - Case(case_id="C1", case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION"), - Case(case_id="C2", case_type="CRP", filed_date=date(2024, 2, 1), current_stage="ADMISSION"), - Case(case_id="C3", case_type="CA", filed_date=date(2024, 3, 1), current_stage="ADMISSION"), + Case( + case_id="C1", + case_type="RSA", + filed_date=date(2024, 1, 1), + current_stage="ADMISSION", + ), + Case( + case_id="C2", + case_type="CRP", + filed_date=date(2024, 2, 1), + current_stage="ADMISSION", + ), + Case( + case_id="C3", + case_type="CA", + filed_date=date(2024, 3, 1), + current_stage="ADMISSION", + ), ] prioritized = policy.prioritize(cases, current_date=date(2024, 4, 1)) @@ -90,9 +142,24 @@ class TestFIFOPolicy: policy = FIFOPolicy() cases = [ - Case(case_id="C3", case_type="RSA", filed_date=date(2024, 3, 1), current_stage="ADMISSION"), - Case(case_id="C2", case_type="CRP", filed_date=date(2024, 2, 1), current_stage="ADMISSION"), - Case(case_id="C1", case_type="CA", filed_date=date(2024, 1, 1), current_stage="ADMISSION"), + Case( + case_id="C3", + case_type="RSA", + filed_date=date(2024, 3, 1), + current_stage="ADMISSION", + ), + Case( + case_id="C2", + case_type="CRP", + filed_date=date(2024, 2, 1), + current_stage="ADMISSION", + ), + Case( + case_id="C1", + case_type="CA", + filed_date=date(2024, 1, 1), + current_stage="ADMISSION", + ), ] prioritized = policy.prioritize(cases, current_date=date(2024, 4, 1)) @@ -104,10 +171,12 @@ class TestFIFOPolicy: def test_large_case_set(self): """Test FIFO with large number of cases.""" - from scheduler.data.case_generator import CaseGenerator + from src.data.case_generator import CaseGenerator policy = FIFOPolicy() - generator = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 12, 31), seed=42) + generator = CaseGenerator( + start=date(2024, 1, 1), end=date(2024, 12, 31), seed=42 + ) cases = generator.generate(1000) prioritized = policy.prioritize(cases, current_date=date(2025, 1, 1)) @@ -115,5 +184,3 @@ class TestFIFOPolicy: # Verify ordering (first should be oldest) for i in range(len(prioritized) - 1): assert prioritized[i].filed_date <= prioritized[i + 1].filed_date - - diff --git a/tests/unit/policies/test_readiness_policy.py b/tests/unit/policies/test_readiness_policy.py index 351d0b9fcc83630e3b4360b526c9ff746f8377b2..7472ff9587beeed24179fc7160849df2c7421702 100644 --- a/tests/unit/policies/test_readiness_policy.py +++ b/tests/unit/policies/test_readiness_policy.py @@ -7,8 +7,8 @@ from datetime import date, timedelta import pytest -from scheduler.core.case import Case -from scheduler.simulation.policies.readiness import ReadinessPolicy +from src.core.case import Case +from src.simulation.policies.readiness import ReadinessPolicy @pytest.mark.unit @@ -28,7 +28,7 @@ class TestReadinessPolicy: case_type="RSA", filed_date=date(2024, 3, 1), current_stage="PRE-ADMISSION", - hearing_count=0 + hearing_count=0, ) # Medium readiness: some hearings, moderate age @@ -37,11 +37,17 @@ class TestReadinessPolicy: case_type="CRP", filed_date=date(2024, 1, 15), current_stage="ADMISSION", - hearing_count=3 + hearing_count=3, + ) + medium_readiness.record_hearing( + date(2024, 2, 1), was_heard=True, outcome="HEARD" + ) + medium_readiness.record_hearing( + date(2024, 2, 15), was_heard=True, outcome="HEARD" + ) + medium_readiness.record_hearing( + date(2024, 3, 1), was_heard=True, outcome="HEARD" ) - medium_readiness.record_hearing(date(2024, 2, 1), was_heard=True, outcome="HEARD") - medium_readiness.record_hearing(date(2024, 2, 15), was_heard=True, outcome="HEARD") - medium_readiness.record_hearing(date(2024, 3, 1), was_heard=True, outcome="HEARD") # High readiness: many hearings, advanced stage high_readiness = Case( @@ -49,13 +55,13 @@ class TestReadinessPolicy: case_type="RSA", filed_date=date(2023, 6, 1), current_stage="ARGUMENTS", - hearing_count=10 + hearing_count=10, ) for i in range(10): high_readiness.record_hearing( date(2023, 7, 1) + timedelta(days=30 * i), was_heard=True, - outcome="HEARD" + outcome="HEARD", ) cases = [low_readiness, medium_readiness, high_readiness] @@ -82,20 +88,24 @@ class TestReadinessPolicy: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=5 + hearing_count=5, ), Case( case_id="CASE-B", case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=5 + hearing_count=5, ), ] for case in cases: for i in range(5): - case.record_hearing(date(2024, 2, 1) + timedelta(days=30 * i), was_heard=True, outcome="HEARD") + case.record_hearing( + date(2024, 2, 1) + timedelta(days=30 * i), + was_heard=True, + outcome="HEARD", + ) case.update_age(date(2024, 12, 1)) prioritized = policy.prioritize(cases, current_date=date(2024, 12, 1)) @@ -121,7 +131,7 @@ class TestReadinessPolicy: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=3 + hearing_count=3, ) ] @@ -135,7 +145,12 @@ class TestReadinessPolicy: # Create brand new cases cases = [ - Case(case_id=f"NEW-{i}", case_type="RSA", filed_date=date(2024, 1, 1), current_stage="PRE-ADMISSION") + Case( + case_id=f"NEW-{i}", + case_type="RSA", + filed_date=date(2024, 1, 1), + current_stage="PRE-ADMISSION", + ) for i in range(5) ] @@ -156,10 +171,14 @@ class TestReadinessPolicy: case_type="RSA", filed_date=date(2023, 1, 1), current_stage="ARGUMENTS", - hearing_count=20 + hearing_count=20, ) for j in range(20): - case.record_hearing(date(2023, 2, 1) + timedelta(days=30 * j), was_heard=True, outcome="HEARD") + case.record_hearing( + date(2023, 2, 1) + timedelta(days=30 * j), + was_heard=True, + outcome="HEARD", + ) case.update_age(date(2024, 4, 1)) cases.append(case) @@ -178,13 +197,13 @@ class TestReadinessPolicy: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=10 + hearing_count=10, ) for i in range(10): adjourned_case.record_hearing( date(2024, 2, 1) + timedelta(days=30 * i), was_heard=False, - outcome="ADJOURNED" + outcome="ADJOURNED", ) # Case with productive hearings (higher readiness expected) @@ -193,13 +212,13 @@ class TestReadinessPolicy: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ARGUMENTS", - hearing_count=10 + hearing_count=10, ) for i in range(10): productive_case.record_hearing( date(2024, 2, 1) + timedelta(days=30 * i), was_heard=True, - outcome="ARGUMENTS" + outcome="ARGUMENTS", ) cases = [adjourned_case, productive_case] @@ -213,10 +232,12 @@ class TestReadinessPolicy: def test_large_case_set(self): """Test readiness policy with large dataset.""" - from scheduler.data.case_generator import CaseGenerator + from src.data.case_generator import CaseGenerator policy = ReadinessPolicy() - generator = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 12, 31), seed=42) + generator = CaseGenerator( + start=date(2024, 1, 1), end=date(2024, 12, 31), seed=42 + ) cases = generator.generate(500, stage_mix_auto=True) # Update ages @@ -233,5 +254,3 @@ class TestReadinessPolicy: # readiness_scores = [case.compute_readiness_score() for case in prioritized] # for i in range(len(readiness_scores) - 1): # assert readiness_scores[i] >= readiness_scores[i + 1] - - diff --git a/tests/unit/test_algorithm.py b/tests/unit/test_algorithm.py index 746bbd00a2e0786c0250dfd98b27f128590cfcad..79b75408e3f507df361b2e8a30d03beae1e1f6cd 100644 --- a/tests/unit/test_algorithm.py +++ b/tests/unit/test_algorithm.py @@ -7,10 +7,10 @@ from datetime import date import pytest -from scheduler.control.overrides import Override, OverrideType -from scheduler.core.algorithm import SchedulingAlgorithm -from scheduler.simulation.allocator import CourtroomAllocator -from scheduler.simulation.policies.readiness import ReadinessPolicy +from src.control.overrides import Override, OverrideType +from src.core.algorithm import SchedulingAlgorithm +from src.simulation.allocator import CourtroomAllocator +from src.simulation.policies.readiness import ReadinessPolicy @pytest.mark.unit @@ -30,17 +30,17 @@ class TestAlgorithmBasics: def test_schedule_simple_day(self, small_case_set, courtrooms): """Test scheduling a simple day with 10 cases.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) result = algorithm.schedule_day( - cases=small_case_set, - courtrooms=courtrooms, - current_date=date(2024, 2, 1) + cases=small_case_set, courtrooms=courtrooms, current_date=date(2024, 2, 1) ) assert result is not None - assert hasattr(result, 'scheduled_cases') + assert hasattr(result, "scheduled_cases") assert len(result.scheduled_cases) > 0 @@ -51,7 +51,9 @@ class TestOverrideHandling: def test_valid_priority_override(self, small_case_set, courtrooms): """Test applying valid priority override.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) # Create priority override for first case @@ -61,24 +63,26 @@ class TestOverrideHandling: case_id=small_case_set[0].case_id, judge_id="J001", timestamp=date(2024, 1, 31), - new_priority=0.95 + new_priority=0.95, ) result = algorithm.schedule_day( cases=small_case_set, courtrooms=courtrooms, current_date=date(2024, 2, 1), - overrides=[override] + overrides=[override], ) # Verify override was applied - assert hasattr(result, 'applied_overrides') + assert hasattr(result, "applied_overrides") assert len(result.applied_overrides) >= 0 def test_invalid_override_rejection(self, small_case_set, courtrooms): """Test that invalid overrides are rejected.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) # Create override for non-existent case @@ -88,24 +92,26 @@ class TestOverrideHandling: case_id="NONEXISTENT-CASE", judge_id="J001", timestamp=date(2024, 1, 31), - new_priority=0.95 + new_priority=0.95, ) result = algorithm.schedule_day( cases=small_case_set, courtrooms=courtrooms, current_date=date(2024, 2, 1), - overrides=[override] + overrides=[override], ) # Verify rejection tracking - assert hasattr(result, 'override_rejections') + assert hasattr(result, "override_rejections") # Invalid override should be rejected def test_mixed_valid_invalid_overrides(self, small_case_set, courtrooms): """Test handling mix of valid and invalid overrides.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) overrides = [ @@ -115,14 +121,14 @@ class TestOverrideHandling: case_id=small_case_set[0].case_id, judge_id="J001", timestamp=date(2024, 1, 31), - new_priority=0.95 + new_priority=0.95, ), Override( override_id="INVALID-001", override_type=OverrideType.EXCLUDE, case_id="NONEXISTENT", judge_id="J001", - timestamp=date(2024, 1, 31) + timestamp=date(2024, 1, 31), ), Override( override_id="VALID-002", @@ -130,25 +136,27 @@ class TestOverrideHandling: case_id=small_case_set[1].case_id, judge_id="J002", timestamp=date(2024, 1, 31), - preferred_date=date(2024, 2, 5) - ) + preferred_date=date(2024, 2, 5), + ), ] result = algorithm.schedule_day( cases=small_case_set, courtrooms=courtrooms, current_date=date(2024, 2, 1), - overrides=overrides + overrides=overrides, ) # Valid overrides should be applied, invalid rejected - assert hasattr(result, 'applied_overrides') - assert hasattr(result, 'override_rejections') + assert hasattr(result, "applied_overrides") + assert hasattr(result, "override_rejections") def test_override_list_not_mutated(self, small_case_set, courtrooms): """Test that original override list is not mutated.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) overrides = [ @@ -158,7 +166,7 @@ class TestOverrideHandling: case_id=small_case_set[0].case_id, judge_id="J001", timestamp=date(2024, 1, 31), - new_priority=0.95 + new_priority=0.95, ) ] @@ -168,7 +176,7 @@ class TestOverrideHandling: cases=small_case_set, courtrooms=courtrooms, current_date=date(2024, 2, 1), - overrides=overrides + overrides=overrides, ) # Original list should remain unchanged @@ -182,17 +190,19 @@ class TestConstraintEnforcement: def test_min_gap_enforcement(self, sample_cases, courtrooms): """Test that minimum gap between hearings is enforced.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) # Record recent hearing for a case - sample_cases[0].record_hearing(date(2024, 1, 28), was_heard=True, outcome="HEARD") + sample_cases[0].record_hearing( + date(2024, 1, 28), was_heard=True, outcome="HEARD" + ) sample_cases[0].update_age(date(2024, 2, 1)) algorithm.schedule_day( - cases=sample_cases, - courtrooms=courtrooms, - current_date=date(2024, 2, 1) + cases=sample_cases, courtrooms=courtrooms, current_date=date(2024, 2, 1) ) # Case with recent hearing (4 days ago) should not be scheduled if min_gap=7 @@ -207,7 +217,7 @@ class TestConstraintEnforcement: result = algorithm.schedule_day( cases=sample_cases, courtrooms=[single_courtroom], - current_date=date(2024, 2, 1) + current_date=date(2024, 2, 1), ) # Should not schedule more than capacity @@ -216,16 +226,16 @@ class TestConstraintEnforcement: def test_working_days_only(self, small_case_set, courtrooms): """Test scheduling only happens on working days.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) # Try scheduling on a weekend (if enforced) saturday = date(2024, 6, 15) # Assume Saturday algorithm.schedule_day( - cases=small_case_set, - courtrooms=courtrooms, - current_date=saturday + cases=small_case_set, courtrooms=courtrooms, current_date=saturday ) # Implementation may allow or prevent weekend scheduling @@ -238,13 +248,13 @@ class TestRipenessFiltering: def test_ripe_cases_scheduled(self, ripe_case, courtrooms): """Test that RIPE cases are scheduled.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) result = algorithm.schedule_day( - cases=[ripe_case], - courtrooms=courtrooms, - current_date=date(2024, 3, 1) + cases=[ripe_case], courtrooms=courtrooms, current_date=date(2024, 3, 1) ) # RIPE case should be scheduled @@ -253,13 +263,13 @@ class TestRipenessFiltering: def test_unripe_cases_filtered(self, unripe_case, courtrooms): """Test that UNRIPE cases are not scheduled.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) algorithm.schedule_day( - cases=[unripe_case], - courtrooms=courtrooms, - current_date=date(2024, 2, 1) + cases=[unripe_case], courtrooms=courtrooms, current_date=date(2024, 2, 1) ) # UNRIPE case should not be scheduled @@ -273,17 +283,17 @@ class TestLoadBalancing: def test_balanced_allocation(self, sample_cases, courtrooms): """Test that cases are distributed evenly across courtrooms.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) result = algorithm.schedule_day( - cases=sample_cases, - courtrooms=courtrooms, - current_date=date(2024, 2, 1) + cases=sample_cases, courtrooms=courtrooms, current_date=date(2024, 2, 1) ) # Check Gini coefficient for balance - if hasattr(result, 'gini_coefficient'): + if hasattr(result, "gini_coefficient"): # Low Gini = good balance assert result.gini_coefficient < 0.3 @@ -296,7 +306,7 @@ class TestLoadBalancing: result = algorithm.schedule_day( cases=small_case_set, courtrooms=[single_courtroom], - current_date=date(2024, 2, 1) + current_date=date(2024, 2, 1), ) # All scheduled cases should go to single courtroom @@ -310,13 +320,13 @@ class TestAlgorithmEdgeCases: def test_empty_case_list(self, courtrooms): """Test scheduling with no cases.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) result = algorithm.schedule_day( - cases=[], - courtrooms=courtrooms, - current_date=date(2024, 2, 1) + cases=[], courtrooms=courtrooms, current_date=date(2024, 2, 1) ) # Should handle gracefully @@ -325,18 +335,21 @@ class TestAlgorithmEdgeCases: def test_all_cases_unripe(self, courtrooms): """Test when all cases are unripe.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) # Create unripe cases - from scheduler.core.case import Case + from src.core.case import Case + unripe_cases = [ Case( case_id=f"UNRIPE-{i}", case_type="RSA", filed_date=date(2024, 1, 1), current_stage="PRE-ADMISSION", - hearing_count=0 + hearing_count=0, ) for i in range(10) ] @@ -345,9 +358,7 @@ class TestAlgorithmEdgeCases: case.service_status = "PENDING" result = algorithm.schedule_day( - cases=unripe_cases, - courtrooms=courtrooms, - current_date=date(2024, 2, 1) + cases=unripe_cases, courtrooms=courtrooms, current_date=date(2024, 2, 1) ) # Should schedule few or no cases @@ -355,20 +366,22 @@ class TestAlgorithmEdgeCases: def test_more_cases_than_capacity(self, courtrooms): """Test with more eligible cases than total capacity.""" - from scheduler.data.case_generator import CaseGenerator + from src.data.case_generator import CaseGenerator policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) algorithm = SchedulingAlgorithm(policy=policy, allocator=allocator) # Generate 500 cases (capacity is 5*50=250) - generator = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 31), seed=42) + generator = CaseGenerator( + start=date(2024, 1, 1), end=date(2024, 1, 31), seed=42 + ) many_cases = generator.generate(500) result = algorithm.schedule_day( - cases=many_cases, - courtrooms=courtrooms, - current_date=date(2024, 2, 1) + cases=many_cases, courtrooms=courtrooms, current_date=date(2024, 2, 1) ) # Should not exceed total capacity @@ -384,7 +397,7 @@ class TestAlgorithmEdgeCases: result = algorithm.schedule_day( cases=[single_case], courtrooms=[single_courtroom], - current_date=date(2024, 2, 1) + current_date=date(2024, 2, 1), ) # Should schedule the single case (if eligible) @@ -408,7 +421,9 @@ class TestAlgorithmFailureScenarios: def test_invalid_override_type(self, small_case_set, courtrooms): """Test with invalid override type.""" policy = ReadinessPolicy() - allocator = CourtroomAllocator(num_courtrooms=len(courtrooms), per_courtroom_capacity=50) + allocator = CourtroomAllocator( + num_courtrooms=len(courtrooms), per_courtroom_capacity=50 + ) SchedulingAlgorithm(policy=policy, allocator=allocator) # Create override with invalid type @@ -418,11 +433,9 @@ class TestAlgorithmFailureScenarios: override_type="INVALID_TYPE", # Not a valid OverrideType case_id=small_case_set[0].case_id, judge_id="J001", - timestamp=date(2024, 1, 31) + timestamp=date(2024, 1, 31), ) # May fail at creation or during processing except (ValueError, TypeError): # Expected for strict validation pass - - diff --git a/tests/unit/test_case.py b/tests/unit/test_case.py index 749fb39465dc50bc7ddd1b59ab5a6827f7c95f39..6c9d538ae744775f17b9cf5199bbc6fa3c42b87a 100644 --- a/tests/unit/test_case.py +++ b/tests/unit/test_case.py @@ -7,7 +7,7 @@ from datetime import date, timedelta import pytest -from scheduler.core.case import Case, CaseStatus +from src.core.case import Case, CaseStatus @pytest.mark.unit @@ -20,7 +20,7 @@ class TestCaseCreation: case_id="TEST-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) assert case.case_id == "TEST-001" @@ -42,7 +42,7 @@ class TestCaseCreation: age_days=100, hearing_count=5, status=CaseStatus.ACTIVE, - is_urgent=True + is_urgent=True, ) assert case.last_hearing_date == date(2024, 2, 15) @@ -59,7 +59,7 @@ class TestCaseCreation: case_id="NEW-001", case_type="CP", filed_date=today, - current_stage="PRE-ADMISSION" + current_stage="PRE-ADMISSION", ) case.update_age(today) @@ -74,7 +74,7 @@ class TestCaseCreation: case_id="INVALID-001", case_type="INVALID_TYPE", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) # Case is created but type validation could be added in future assert case.case_type == "INVALID_TYPE" @@ -90,7 +90,7 @@ class TestCaseAgeCalculation: case_id="AGE-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) # Update age to Feb 1 (31 days later) @@ -103,7 +103,7 @@ class TestCaseAgeCalculation: case_id="OLD-001", case_type="RSA", filed_date=date(2022, 1, 1), - current_stage="EVIDENCE" + current_stage="EVIDENCE", ) case.update_age(date(2024, 1, 1)) @@ -115,7 +115,7 @@ class TestCaseAgeCalculation: case_id="GAP-001", case_type="CRP", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) # Record hearing on Jan 15 @@ -136,7 +136,7 @@ class TestHearingManagement: case_id="HEAR-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) case.record_hearing(date(2024, 1, 15), was_heard=True, outcome="ARGUMENTS") @@ -155,7 +155,7 @@ class TestStageProgression: case_id="PROG-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) case.progress_to_stage("EVIDENCE", date(2024, 2, 1)) @@ -168,7 +168,7 @@ class TestStageProgression: case_id="TERM-001", case_type="CP", filed_date=date(2024, 1, 1), - current_stage="ARGUMENTS" + current_stage="ARGUMENTS", ) case.progress_to_stage("ORDERS", date(2024, 3, 1)) @@ -181,7 +181,7 @@ class TestStageProgression: case_id="SEQ-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="PRE-ADMISSION" + current_stage="PRE-ADMISSION", ) stages = ["ADMISSION", "EVIDENCE", "ARGUMENTS", "ORDERS"] @@ -203,7 +203,7 @@ class TestCaseScoring: case_id="SCORE-001", case_type="RSA", filed_date=date(2023, 1, 1), - current_stage="ARGUMENTS" + current_stage="ARGUMENTS", ) case.update_age(date(2024, 1, 1)) # 1 year old @@ -221,7 +221,7 @@ class TestCaseScoring: case_id="READY-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ARGUMENTS" + current_stage="ARGUMENTS", ) # Add some hearings @@ -229,7 +229,7 @@ class TestCaseScoring: case.record_hearing( date(2024, 1, 1) + timedelta(days=30 * i), was_heard=True, - outcome="HEARD" + outcome="HEARD", ) readiness = case.compute_readiness_score() @@ -244,7 +244,7 @@ class TestCaseScoring: case_type="CP", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - is_urgent=False + is_urgent=False, ) urgent_case = Case( @@ -252,7 +252,7 @@ class TestCaseScoring: case_type="CP", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - is_urgent=True + is_urgent=True, ) # Update ages to same date @@ -269,7 +269,7 @@ class TestCaseScoring: case_id="ADJ-BOOST-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ARGUMENTS" + current_stage="ARGUMENTS", ) # Record adjourned hearing @@ -297,7 +297,7 @@ class TestCaseReadiness: case_id="READY-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ARGUMENTS" + current_stage="ARGUMENTS", ) # Record hearing 30 days ago @@ -313,7 +313,7 @@ class TestCaseReadiness: case_id="NOT-READY-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) # Record hearing 3 days ago @@ -329,7 +329,7 @@ class TestCaseReadiness: case_id="FIRST-001", case_type="CP", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) case.update_age(date(2024, 1, 15)) @@ -348,7 +348,7 @@ class TestCaseStatus: case_id="STATUS-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="PRE-ADMISSION" + current_stage="PRE-ADMISSION", ) assert case.status == CaseStatus.PENDING @@ -359,7 +359,7 @@ class TestCaseStatus: case_id="DISPOSE-001", case_type="CP", filed_date=date(2024, 1, 1), - current_stage="ORDERS" + current_stage="ORDERS", ) case.status = CaseStatus.DISPOSED @@ -387,7 +387,7 @@ class TestCaseSerialization: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=3 + hearing_count=3, ) case_dict = case.to_dict() @@ -404,7 +404,7 @@ class TestCaseSerialization: case_id="REPR-001", case_type="CRP", filed_date=date(2024, 1, 1), - current_stage="ARGUMENTS" + current_stage="ARGUMENTS", ) repr_str = repr(case) @@ -425,7 +425,7 @@ class TestCaseEdgeCases: filed_date=date(2024, 1, 1), current_stage="ADMISSION", last_hearing_date=None, - is_urgent=None + is_urgent=None, ) assert case.last_hearing_date is None @@ -437,7 +437,7 @@ class TestCaseEdgeCases: case_id="BOUNDARY-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) # Exactly 0 days @@ -460,7 +460,7 @@ class TestCaseEdgeCases: case_id="SAME-DAY-001", case_type="CP", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) # Record hearing on filed date @@ -482,7 +482,7 @@ class TestCaseFailureScenarios: case_id="FUTURE-001", case_type="RSA", filed_date=future_date, - current_stage="ADMISSION" + current_stage="ADMISSION", ) # Case is created but update_age should handle gracefully @@ -496,7 +496,7 @@ class TestCaseFailureScenarios: case_type="CP", filed_date=date(2024, 1, 1), current_stage="ORDERS", - status=CaseStatus.DISPOSED + status=CaseStatus.DISPOSED, ) # Should still be able to query properties @@ -504,6 +504,3 @@ class TestCaseFailureScenarios: # Recording hearing on disposed case (implementation dependent) # Some implementations might allow, others might not - - - diff --git a/tests/unit/test_courtroom.py b/tests/unit/test_courtroom.py index d9215c64f949e78d583c80878e06bfe884b82b4e..5fe5808fe5607cd9ae295421531af269a61d9ca4 100644 --- a/tests/unit/test_courtroom.py +++ b/tests/unit/test_courtroom.py @@ -7,7 +7,7 @@ from datetime import date, timedelta import pytest -from scheduler.core.courtroom import Courtroom +from src.core.courtroom import Courtroom @pytest.mark.unit @@ -28,7 +28,7 @@ class TestCourtroomCreation: courtroom = Courtroom( courtroom_id=1, judge_id="J001,J002", # Multi-judge notation - daily_capacity=60 + daily_capacity=60, ) assert courtroom.judge_id == "J001,J002" @@ -150,7 +150,7 @@ class TestCourtroomScheduling: single_courtroom.schedule_case(test_date, f"CASE-{i}") # If clear method exists - if hasattr(single_courtroom, 'clear_schedule'): + if hasattr(single_courtroom, "clear_schedule"): single_courtroom.clear_schedule(test_date) schedule = single_courtroom.get_daily_schedule(test_date) assert len(schedule) == 0 @@ -181,7 +181,7 @@ class TestCourtroomScheduling: single_courtroom.schedule_case(test_date, case_id) # Remove if method exists - if hasattr(single_courtroom, 'remove_case'): + if hasattr(single_courtroom, "remove_case"): single_courtroom.remove_case(test_date, case_id) schedule = single_courtroom.get_daily_schedule(test_date) assert case_id not in schedule @@ -246,7 +246,7 @@ class TestJudgeAssignment: courtroom = Courtroom(courtroom_id=1, judge_id="J001", daily_capacity=50) # If preferences supported - if hasattr(courtroom, 'judge_preferences'): + if hasattr(courtroom, "judge_preferences"): # Test preference setting/getting pass @@ -331,5 +331,3 @@ class TestCourtroomFailureScenarios: except (ValueError, TypeError): # May fail pass - - diff --git a/tests/unit/test_ripeness.py b/tests/unit/test_ripeness.py index 1bdc48d759d7291998bd62963d436fc6b6548ed5..63d6400bd25061fcb5c71f12e6ce1c7285c5acda 100644 --- a/tests/unit/test_ripeness.py +++ b/tests/unit/test_ripeness.py @@ -8,8 +8,8 @@ from datetime import date, datetime, timedelta import pytest -from scheduler.core.case import Case -from scheduler.core.ripeness import RipenessClassifier, RipenessStatus +from src.core.case import Case +from src.core.ripeness import RipenessClassifier, RipenessStatus @pytest.mark.unit @@ -39,7 +39,7 @@ class TestRipenessClassification: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=2 + hearing_count=2, ) case.purpose_of_hearing = "STAY APPLICATION PENDING" case.service_status = "SERVED" @@ -56,7 +56,7 @@ class TestRipenessClassification: case_type="CRP", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=3 + hearing_count=3, ) case.purpose_of_hearing = "APPEARANCE OF PARTIES" case.service_status = "SERVED" @@ -73,7 +73,7 @@ class TestRipenessClassification: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="EVIDENCE", - hearing_count=5 + hearing_count=5, ) case.purpose_of_hearing = "FOR PRODUCTION OF DOCUMENTS" case.service_status = "SERVED" @@ -90,7 +90,7 @@ class TestRipenessClassification: case_type="MISC.CVL", filed_date=date(2024, 1, 1), current_stage="OTHER", - hearing_count=0 + hearing_count=0, ) # No clear indicators case.service_status = None @@ -116,7 +116,7 @@ class TestRipenessKeywords: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="PRE-ADMISSION", - hearing_count=1 + hearing_count=1, ) case.purpose_of_hearing = f"FOR {keyword}" @@ -133,7 +133,7 @@ class TestRipenessKeywords: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ARGUMENTS", - hearing_count=5 + hearing_count=5, ) case.service_status = "SERVED" case.purpose_of_hearing = keyword @@ -149,7 +149,7 @@ class TestRipenessKeywords: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ARGUMENTS", - hearing_count=3 + hearing_count=3, ) case.purpose_of_hearing = "ARGUMENTS - PENDING SUMMONS" case.service_status = "PARTIAL" @@ -176,7 +176,7 @@ class TestRipenessThresholds: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=min_hearings - 1 + hearing_count=min_hearings - 1, ) case_below.service_status = "SERVED" @@ -186,7 +186,7 @@ class TestRipenessThresholds: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ARGUMENTS", - hearing_count=min_hearings + hearing_count=min_hearings, ) case_at.service_status = "SERVED" case_at.purpose_of_hearing = "ARGUMENTS" @@ -218,10 +218,7 @@ class TestRipenessThresholds: """Test updating multiple thresholds at once.""" original_thresholds = RipenessClassifier.get_current_thresholds() - new_thresholds = { - "MIN_SERVICE_HEARINGS": 4, - "MIN_STAGE_DAYS": 10 - } + new_thresholds = {"MIN_SERVICE_HEARINGS": 4, "MIN_STAGE_DAYS": 10} RipenessClassifier.set_thresholds(new_thresholds) updated = RipenessClassifier.get_current_thresholds() @@ -243,7 +240,7 @@ class TestRipenessPriority: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ARGUMENTS", - hearing_count=5 + hearing_count=5, ) case.service_status = "SERVED" case.purpose_of_hearing = "ARGUMENTS" @@ -260,7 +257,7 @@ class TestRipenessPriority: case_type="CRP", filed_date=date(2024, 1, 1), current_stage="PRE-ADMISSION", - hearing_count=1 + hearing_count=1, ) case.service_status = "PENDING" case.purpose_of_hearing = "FOR SUMMONS" @@ -283,13 +280,17 @@ class TestRipenessSchedulability: def test_unripe_case_not_schedulable(self, unripe_case): """Test that UNRIPE case is not schedulable.""" - schedulable = RipenessClassifier.is_schedulable(unripe_case, datetime(2024, 2, 1)) + schedulable = RipenessClassifier.is_schedulable( + unripe_case, datetime(2024, 2, 1) + ) assert schedulable is False def test_disposed_case_not_schedulable(self, disposed_case): """Test that disposed case is not schedulable.""" - schedulable = RipenessClassifier.is_schedulable(disposed_case, datetime(2024, 6, 1)) + schedulable = RipenessClassifier.is_schedulable( + disposed_case, datetime(2024, 6, 1) + ) assert schedulable is False @@ -300,7 +301,7 @@ class TestRipenessSchedulability: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ARGUMENTS", - hearing_count=5 + hearing_count=5, ) case.service_status = "SERVED" @@ -337,7 +338,11 @@ class TestRipenessExplanations: reason = RipenessClassifier.get_ripeness_reason(RipenessStatus.UNRIPE_DEPENDENT) assert isinstance(reason, str) - assert "dependent" in reason.lower() or "stay" in reason.lower() or "pending" in reason.lower() + assert ( + "dependent" in reason.lower() + or "stay" in reason.lower() + or "pending" in reason.lower() + ) def test_unknown_reason(self): """Test explanation for UNKNOWN status.""" @@ -354,8 +359,7 @@ class TestRipeningTimeEstimation: def test_already_ripe_no_estimation(self, ripe_case): """Test that RIPE cases return None for ripening time.""" estimate = RipenessClassifier.estimate_ripening_time( - ripe_case, - datetime(2024, 3, 1) + ripe_case, datetime(2024, 3, 1) ) assert estimate is None @@ -367,7 +371,7 @@ class TestRipeningTimeEstimation: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="PRE-ADMISSION", - hearing_count=1 + hearing_count=1, ) case.purpose_of_hearing = "FOR SUMMONS" @@ -385,7 +389,7 @@ class TestRipeningTimeEstimation: case_type="CRP", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=2 + hearing_count=2, ) case.purpose_of_hearing = "STAY APPLICATION" case.service_status = "SERVED" @@ -409,7 +413,7 @@ class TestRipenessEdgeCases: case_type="CP", filed_date=date(2024, 1, 1), current_stage="PRE-ADMISSION", - hearing_count=0 + hearing_count=0, ) status = RipenessClassifier.classify(case, datetime(2024, 2, 1)) @@ -424,7 +428,7 @@ class TestRipenessEdgeCases: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=3 + hearing_count=3, ) case.service_status = None @@ -440,7 +444,7 @@ class TestRipenessEdgeCases: case_type="MISC.CVL", filed_date=date(2024, 1, 1), current_stage="UNKNOWN_STAGE", - hearing_count=5 + hearing_count=5, ) case.service_status = "SERVED" @@ -456,7 +460,7 @@ class TestRipenessEdgeCases: case_type="RSA", filed_date=date(2019, 1, 1), current_stage="EVIDENCE", - hearing_count=50 + hearing_count=50, ) case.service_status = "SERVED" case.purpose_of_hearing = "EVIDENCE" @@ -496,15 +500,15 @@ class TestRipenessFailureScenarios: case_type="RSA", filed_date=date(2024, 1, 1), current_stage="ADMISSION", - hearing_count=3 + hearing_count=3, ) status = RipenessClassifier.classify(case, datetime(2024, 2, 1)) # Should be a valid RipenessStatus enum value assert status in list(RipenessStatus) - assert hasattr(status, 'is_ripe') - assert hasattr(status, 'is_unripe') + assert hasattr(status, "is_ripe") + assert hasattr(status, "is_unripe") def test_threshold_invalid_type(self): """Test setting thresholds with invalid types.""" @@ -527,7 +531,7 @@ class TestRipenessFailureScenarios: case_id="MINIMAL-001", case_type="RSA", filed_date=date(2024, 1, 1), - current_stage="ADMISSION" + current_stage="ADMISSION", ) # Don't set any optional fields @@ -535,5 +539,3 @@ class TestRipenessFailureScenarios: # Should handle gracefully and return some status assert status in list(RipenessStatus) - -