Spaces:
Sleeping
Sleeping
refactored project structure. renamed scheduler dir to src
Browse filesThis view is limited to 50 files because it contains too many changes. Β
See raw diff
- .gitignore +0 -17
- cli/main.py +7 -7
- eda/exploration.py +77 -37
- eda/load_clean.py +3 -2
- {scheduler β src}/__init__.py +0 -0
- {scheduler/dashboard β src}/app.py +1 -1
- {scheduler β src}/control/__init__.py +0 -0
- {scheduler β src}/control/explainability.py +27 -12
- {scheduler β src}/control/overrides.py +0 -0
- {scheduler β src}/core/__init__.py +0 -0
- {scheduler β src}/core/algorithm.py +80 -45
- {scheduler β src}/core/case.py +69 -42
- {scheduler β src}/core/courtroom.py +30 -18
- {scheduler β src}/core/hearing.py +0 -0
- {scheduler β src}/core/judge.py +0 -0
- {scheduler β src}/core/policy.py +2 -1
- {scheduler β src}/core/ripeness.py +15 -12
- {scheduler β src}/dashboard/__init__.py +0 -0
- {scheduler β src}/dashboard/pages/1_Data_And_Insights.py +5 -5
- {scheduler β src}/dashboard/pages/2_Ripeness_Classifier.py +3 -3
- {scheduler β src}/dashboard/pages/3_Simulation_Workflow.py +5 -5
- {scheduler β src}/dashboard/pages/4_Cause_Lists_And_Overrides.py +0 -0
- {scheduler β src}/dashboard/pages/6_Analytics_And_Reports.py +0 -0
- {scheduler β src}/dashboard/utils/__init__.py +0 -0
- {scheduler β src}/dashboard/utils/data_loader.py +34 -13
- {scheduler β src}/dashboard/utils/simulation_runner.py +4 -4
- {scheduler β src}/dashboard/utils/ui_input_parser.py +0 -0
- {scheduler β src}/data/__init__.py +0 -0
- {scheduler β src}/data/case_generator.py +12 -6
- {scheduler β src}/data/config.py +0 -0
- {scheduler β src}/data/param_loader.py +22 -14
- {scheduler β src}/metrics/__init__.py +0 -0
- {scheduler β src}/metrics/basic.py +0 -0
- {scheduler β src}/monitoring/__init__.py +2 -2
- {scheduler β src}/monitoring/ripeness_calibrator.py +118 -88
- {scheduler β src}/monitoring/ripeness_metrics.py +65 -30
- {scheduler β src}/output/__init__.py +0 -0
- {scheduler β src}/output/cause_list.py +0 -0
- {scheduler β src}/simulation/__init__.py +0 -0
- {scheduler β src}/simulation/allocator.py +15 -7
- {scheduler β src}/simulation/engine.py +60 -27
- {scheduler β src}/simulation/events.py +0 -0
- {scheduler β src}/simulation/policies/__init__.py +11 -5
- {scheduler β src}/simulation/policies/age.py +3 -2
- {scheduler β src}/simulation/policies/fifo.py +3 -2
- {scheduler β src}/simulation/policies/readiness.py +3 -2
- {scheduler β src}/utils/__init__.py +0 -0
- {scheduler β src}/utils/calendar.py +1 -1
- tests/conftest.py +24 -27
- tests/integration/test_simulation.py +33 -26
.gitignore
CHANGED
|
@@ -17,20 +17,3 @@ __pylintrc__
|
|
| 17 |
.html
|
| 18 |
.docx
|
| 19 |
|
| 20 |
-
# Large data files and simulation outputs
|
| 21 |
-
Data/comprehensive_sweep*/
|
| 22 |
-
Data/sim_runs/
|
| 23 |
-
Data/config_test/
|
| 24 |
-
Data/test_verification/
|
| 25 |
-
*.csv
|
| 26 |
-
*.png
|
| 27 |
-
*.json
|
| 28 |
-
|
| 29 |
-
# Keep essential data
|
| 30 |
-
!Data/README.md
|
| 31 |
-
!pyproject.toml
|
| 32 |
-
!Data/court_data.duckdb
|
| 33 |
-
|
| 34 |
-
# Bundled baseline parameters for scheduler
|
| 35 |
-
!scheduler/data/defaults/*.csv
|
| 36 |
-
!scheduler/data/defaults/*.json
|
|
|
|
| 17 |
.html
|
| 18 |
.docx
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
cli/main.py
CHANGED
|
@@ -127,7 +127,7 @@ def generate(
|
|
| 127 |
from datetime import date as date_cls
|
| 128 |
|
| 129 |
from cli.config import GenerateConfig, load_generate_config
|
| 130 |
-
from
|
| 131 |
|
| 132 |
# Resolve parameters: config -> interactive -> flags
|
| 133 |
if config:
|
|
@@ -247,10 +247,10 @@ def simulate(
|
|
| 247 |
from datetime import date as date_cls
|
| 248 |
|
| 249 |
from cli.config import SimulateConfig, load_simulate_config
|
| 250 |
-
from
|
| 251 |
-
from
|
| 252 |
-
from
|
| 253 |
-
from
|
| 254 |
|
| 255 |
# Resolve parameters: config -> interactive -> flags
|
| 256 |
if config:
|
|
@@ -394,7 +394,7 @@ def workflow(
|
|
| 394 |
cases_file = output_path / "cases.csv"
|
| 395 |
from datetime import date as date_cls
|
| 396 |
|
| 397 |
-
from
|
| 398 |
|
| 399 |
start = date_cls(2022, 1, 1)
|
| 400 |
end = date_cls(2023, 12, 31)
|
|
@@ -406,7 +406,7 @@ def workflow(
|
|
| 406 |
|
| 407 |
# Step 3: Run simulation
|
| 408 |
console.print("[bold]Step 3/3:[/bold] Run Simulation")
|
| 409 |
-
from
|
| 410 |
|
| 411 |
sim_start = max(c.filed_date for c in cases)
|
| 412 |
cfg = CourtSimConfig(
|
|
|
|
| 127 |
from datetime import date as date_cls
|
| 128 |
|
| 129 |
from cli.config import GenerateConfig, load_generate_config
|
| 130 |
+
from src.data.case_generator import CaseGenerator
|
| 131 |
|
| 132 |
# Resolve parameters: config -> interactive -> flags
|
| 133 |
if config:
|
|
|
|
| 247 |
from datetime import date as date_cls
|
| 248 |
|
| 249 |
from cli.config import SimulateConfig, load_simulate_config
|
| 250 |
+
from src.core.case import CaseStatus
|
| 251 |
+
from src.data.case_generator import CaseGenerator
|
| 252 |
+
from src.metrics.basic import gini
|
| 253 |
+
from src.simulation.engine import CourtSim, CourtSimConfig
|
| 254 |
|
| 255 |
# Resolve parameters: config -> interactive -> flags
|
| 256 |
if config:
|
|
|
|
| 394 |
cases_file = output_path / "cases.csv"
|
| 395 |
from datetime import date as date_cls
|
| 396 |
|
| 397 |
+
from src.data.case_generator import CaseGenerator
|
| 398 |
|
| 399 |
start = date_cls(2022, 1, 1)
|
| 400 |
end = date_cls(2023, 12, 31)
|
|
|
|
| 406 |
|
| 407 |
# Step 3: Run simulation
|
| 408 |
console.print("[bold]Step 3/3:[/bold] Run Simulation")
|
| 409 |
+
from src.simulation.engine import CourtSim, CourtSimConfig
|
| 410 |
|
| 411 |
sim_start = max(c.filed_date for c in cases)
|
| 412 |
cfg = CourtSimConfig(
|
eda/exploration.py
CHANGED
|
@@ -21,7 +21,6 @@ from datetime import timedelta
|
|
| 21 |
|
| 22 |
import plotly.express as px
|
| 23 |
import plotly.graph_objects as go
|
| 24 |
-
import plotly.io as pio
|
| 25 |
import polars as pl
|
| 26 |
|
| 27 |
from eda.config import (
|
|
@@ -31,8 +30,6 @@ from eda.config import (
|
|
| 31 |
safe_write_figure,
|
| 32 |
)
|
| 33 |
|
| 34 |
-
pio.renderers.default = "browser"
|
| 35 |
-
|
| 36 |
|
| 37 |
def load_cleaned():
|
| 38 |
cases = pl.read_parquet(_get_cases_parquet())
|
|
@@ -44,21 +41,19 @@ def load_cleaned():
|
|
| 44 |
|
| 45 |
def run_exploration() -> None:
|
| 46 |
cases, hearings = load_cleaned()
|
| 47 |
-
|
| 48 |
-
hearings_pd = hearings.to_pandas()
|
| 49 |
|
| 50 |
# --------------------------------------------------
|
| 51 |
# 1. Case Type Distribution (aggregated to reduce plot data size)
|
| 52 |
# --------------------------------------------------
|
| 53 |
try:
|
| 54 |
ct_counts = (
|
| 55 |
-
|
| 56 |
-
.
|
| 57 |
-
.
|
| 58 |
-
.sort_values("COUNT", ascending=False)
|
| 59 |
)
|
| 60 |
fig1 = px.bar(
|
| 61 |
-
ct_counts,
|
| 62 |
x="CASE_TYPE",
|
| 63 |
y="COUNT",
|
| 64 |
color="CASE_TYPE",
|
|
@@ -77,10 +72,14 @@ def run_exploration() -> None:
|
|
| 77 |
# --------------------------------------------------
|
| 78 |
# 2. Filing Trends by Year
|
| 79 |
# --------------------------------------------------
|
| 80 |
-
if "YEAR_FILED" in
|
| 81 |
-
year_counts =
|
| 82 |
fig2 = px.line(
|
| 83 |
-
year_counts,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
)
|
| 85 |
fig2.update_traces(line_color="royalblue")
|
| 86 |
fig2.update_layout(xaxis=dict(rangeslider=dict(visible=True)))
|
|
@@ -90,10 +89,9 @@ def run_exploration() -> None:
|
|
| 90 |
# --------------------------------------------------
|
| 91 |
# 3. Disposal Duration Distribution
|
| 92 |
# --------------------------------------------------
|
| 93 |
-
if "DISPOSALTIME_ADJ" in
|
| 94 |
fig3 = px.histogram(
|
| 95 |
-
|
| 96 |
-
x="DISPOSALTIME_ADJ",
|
| 97 |
nbins=50,
|
| 98 |
title="Distribution of Disposal Time (Adjusted Days)",
|
| 99 |
color_discrete_sequence=["indianred"],
|
|
@@ -105,9 +103,13 @@ def run_exploration() -> None:
|
|
| 105 |
# --------------------------------------------------
|
| 106 |
# 4. Hearings vs Disposal Time
|
| 107 |
# --------------------------------------------------
|
| 108 |
-
if {"N_HEARINGS", "DISPOSALTIME_ADJ"}.issubset(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
fig4 = px.scatter(
|
| 110 |
-
|
| 111 |
x="N_HEARINGS",
|
| 112 |
y="DISPOSALTIME_ADJ",
|
| 113 |
color="CASE_TYPE",
|
|
@@ -122,7 +124,7 @@ def run_exploration() -> None:
|
|
| 122 |
# 5. Boxplot by Case Type
|
| 123 |
# --------------------------------------------------
|
| 124 |
fig5 = px.box(
|
| 125 |
-
|
| 126 |
x="CASE_TYPE",
|
| 127 |
y="DISPOSALTIME_ADJ",
|
| 128 |
color="CASE_TYPE",
|
|
@@ -135,11 +137,14 @@ def run_exploration() -> None:
|
|
| 135 |
# --------------------------------------------------
|
| 136 |
# 6. Stage Frequency
|
| 137 |
# --------------------------------------------------
|
| 138 |
-
if "Remappedstages" in
|
| 139 |
-
stage_counts =
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
fig6 = px.bar(
|
| 142 |
-
stage_counts,
|
| 143 |
x="Stage",
|
| 144 |
y="Count",
|
| 145 |
color="Stage",
|
|
@@ -159,9 +164,9 @@ def run_exploration() -> None:
|
|
| 159 |
# --------------------------------------------------
|
| 160 |
# 7. Gap median by case type
|
| 161 |
# --------------------------------------------------
|
| 162 |
-
if "GAP_MEDIAN" in
|
| 163 |
fig_gap = px.box(
|
| 164 |
-
|
| 165 |
x="CASE_TYPE",
|
| 166 |
y="GAP_MEDIAN",
|
| 167 |
points=False,
|
|
@@ -201,7 +206,9 @@ def run_exploration() -> None:
|
|
| 201 |
pl.col(stage_col)
|
| 202 |
.fill_null("NA")
|
| 203 |
.map_elements(
|
| 204 |
-
lambda s: s
|
|
|
|
|
|
|
| 205 |
)
|
| 206 |
.alias("STAGE"),
|
| 207 |
pl.col("BusinessOnDate").alias("DT"),
|
|
@@ -255,7 +262,9 @@ def run_exploration() -> None:
|
|
| 255 |
]
|
| 256 |
)
|
| 257 |
.with_columns(
|
| 258 |
-
((pl.col("RUN_END") - pl.col("RUN_START")) / timedelta(days=1)).alias(
|
|
|
|
|
|
|
| 259 |
)
|
| 260 |
)
|
| 261 |
stage_duration = (
|
|
@@ -281,8 +290,12 @@ def run_exploration() -> None:
|
|
| 281 |
if s in set(tr_df["STAGE_FROM"]).union(set(tr_df["STAGE_TO"]))
|
| 282 |
]
|
| 283 |
idx = {label: i for i, label in enumerate(labels)}
|
| 284 |
-
tr_df = tr_df[
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
sankey = go.Figure(
|
| 287 |
data=[
|
| 288 |
go.Sankey(
|
|
@@ -337,7 +350,9 @@ def run_exploration() -> None:
|
|
| 337 |
)
|
| 338 |
.with_columns(pl.date(pl.col("Y"), pl.col("M"), pl.lit(1)).alias("YM"))
|
| 339 |
)
|
| 340 |
-
monthly_listings =
|
|
|
|
|
|
|
| 341 |
monthly_listings.write_csv(str(_get_run_dir() / "monthly_hearings.csv"))
|
| 342 |
|
| 343 |
try:
|
|
@@ -358,12 +373,18 @@ def run_exploration() -> None:
|
|
| 358 |
ml = monthly_listings.with_columns(
|
| 359 |
[
|
| 360 |
pl.col("N_HEARINGS").shift(1).alias("PREV"),
|
| 361 |
-
(pl.col("N_HEARINGS") - pl.col("N_HEARINGS").shift(1)).alias(
|
|
|
|
|
|
|
| 362 |
]
|
| 363 |
)
|
| 364 |
ml_pd = ml.to_pandas()
|
| 365 |
-
ml_pd["ROLL_MEAN"] =
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
ml_pd["Z"] = (ml_pd["N_HEARINGS"] - ml_pd["ROLL_MEAN"]) / ml_pd["ROLL_STD"]
|
| 368 |
ml_pd["ANOM"] = ml_pd["Z"].abs() >= 3.0
|
| 369 |
|
|
@@ -455,10 +476,27 @@ def run_exploration() -> None:
|
|
| 455 |
|
| 456 |
if text_col:
|
| 457 |
hear_txt = hearings.with_columns(
|
| 458 |
-
pl.col(text_col)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
)
|
| 460 |
-
async_kw = [
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
hear_txt = hear_txt.with_columns(
|
| 463 |
pl.when(_has_kw_expr("PURPOSE_TXT", async_kw))
|
| 464 |
.then(pl.lit("ASYNC_OR_ADMIN"))
|
|
@@ -470,7 +508,9 @@ def run_exploration() -> None:
|
|
| 470 |
tag_share = (
|
| 471 |
hear_txt.group_by(["CASE_TYPE", "PURPOSE_TAG"])
|
| 472 |
.agg(pl.len().alias("N"))
|
| 473 |
-
.with_columns(
|
|
|
|
|
|
|
| 474 |
.sort(["CASE_TYPE", "SHARE"], descending=[False, True])
|
| 475 |
)
|
| 476 |
tag_share.write_csv(str(_get_run_dir() / "purpose_tag_shares.csv"))
|
|
|
|
| 21 |
|
| 22 |
import plotly.express as px
|
| 23 |
import plotly.graph_objects as go
|
|
|
|
| 24 |
import polars as pl
|
| 25 |
|
| 26 |
from eda.config import (
|
|
|
|
| 30 |
safe_write_figure,
|
| 31 |
)
|
| 32 |
|
|
|
|
|
|
|
| 33 |
|
| 34 |
def load_cleaned():
|
| 35 |
cases = pl.read_parquet(_get_cases_parquet())
|
|
|
|
| 41 |
|
| 42 |
def run_exploration() -> None:
|
| 43 |
cases, hearings = load_cleaned()
|
| 44 |
+
# Keep transformations in Polars; convert only small, final results for plotting
|
|
|
|
| 45 |
|
| 46 |
# --------------------------------------------------
|
| 47 |
# 1. Case Type Distribution (aggregated to reduce plot data size)
|
| 48 |
# --------------------------------------------------
|
| 49 |
try:
|
| 50 |
ct_counts = (
|
| 51 |
+
cases.group_by("CASE_TYPE")
|
| 52 |
+
.agg(pl.len().alias("COUNT"))
|
| 53 |
+
.sort("COUNT", descending=True)
|
|
|
|
| 54 |
)
|
| 55 |
fig1 = px.bar(
|
| 56 |
+
ct_counts.to_pandas(),
|
| 57 |
x="CASE_TYPE",
|
| 58 |
y="COUNT",
|
| 59 |
color="CASE_TYPE",
|
|
|
|
| 72 |
# --------------------------------------------------
|
| 73 |
# 2. Filing Trends by Year
|
| 74 |
# --------------------------------------------------
|
| 75 |
+
if "YEAR_FILED" in cases.columns:
|
| 76 |
+
year_counts = cases.group_by("YEAR_FILED").agg(pl.len().alias("Count"))
|
| 77 |
fig2 = px.line(
|
| 78 |
+
year_counts.to_pandas(),
|
| 79 |
+
x="YEAR_FILED",
|
| 80 |
+
y="Count",
|
| 81 |
+
markers=True,
|
| 82 |
+
title="Cases Filed by Year",
|
| 83 |
)
|
| 84 |
fig2.update_traces(line_color="royalblue")
|
| 85 |
fig2.update_layout(xaxis=dict(rangeslider=dict(visible=True)))
|
|
|
|
| 89 |
# --------------------------------------------------
|
| 90 |
# 3. Disposal Duration Distribution
|
| 91 |
# --------------------------------------------------
|
| 92 |
+
if "DISPOSALTIME_ADJ" in cases.columns:
|
| 93 |
fig3 = px.histogram(
|
| 94 |
+
x=cases["DISPOSALTIME_ADJ"].to_list(),
|
|
|
|
| 95 |
nbins=50,
|
| 96 |
title="Distribution of Disposal Time (Adjusted Days)",
|
| 97 |
color_discrete_sequence=["indianred"],
|
|
|
|
| 103 |
# --------------------------------------------------
|
| 104 |
# 4. Hearings vs Disposal Time
|
| 105 |
# --------------------------------------------------
|
| 106 |
+
if {"N_HEARINGS", "DISPOSALTIME_ADJ"}.issubset(set(cases.columns)):
|
| 107 |
+
# Convert only necessary columns for plotting with color/hover metadata
|
| 108 |
+
cases_scatter = cases.select(
|
| 109 |
+
["N_HEARINGS", "DISPOSALTIME_ADJ", "CASE_TYPE", "CNR_NUMBER", "YEAR_FILED"]
|
| 110 |
+
).to_pandas()
|
| 111 |
fig4 = px.scatter(
|
| 112 |
+
cases_scatter,
|
| 113 |
x="N_HEARINGS",
|
| 114 |
y="DISPOSALTIME_ADJ",
|
| 115 |
color="CASE_TYPE",
|
|
|
|
| 124 |
# 5. Boxplot by Case Type
|
| 125 |
# --------------------------------------------------
|
| 126 |
fig5 = px.box(
|
| 127 |
+
cases.select(["CASE_TYPE", "DISPOSALTIME_ADJ"]).to_pandas(),
|
| 128 |
x="CASE_TYPE",
|
| 129 |
y="DISPOSALTIME_ADJ",
|
| 130 |
color="CASE_TYPE",
|
|
|
|
| 137 |
# --------------------------------------------------
|
| 138 |
# 6. Stage Frequency
|
| 139 |
# --------------------------------------------------
|
| 140 |
+
if "Remappedstages" in hearings.columns:
|
| 141 |
+
stage_counts = (
|
| 142 |
+
hearings["Remappedstages"]
|
| 143 |
+
.value_counts()
|
| 144 |
+
.rename({"Remappedstages": "Stage", "count": "Count"})
|
| 145 |
+
)
|
| 146 |
fig6 = px.bar(
|
| 147 |
+
stage_counts.to_pandas(),
|
| 148 |
x="Stage",
|
| 149 |
y="Count",
|
| 150 |
color="Stage",
|
|
|
|
| 164 |
# --------------------------------------------------
|
| 165 |
# 7. Gap median by case type
|
| 166 |
# --------------------------------------------------
|
| 167 |
+
if "GAP_MEDIAN" in cases.columns:
|
| 168 |
fig_gap = px.box(
|
| 169 |
+
cases.select(["CASE_TYPE", "GAP_MEDIAN"]).to_pandas(),
|
| 170 |
x="CASE_TYPE",
|
| 171 |
y="GAP_MEDIAN",
|
| 172 |
points=False,
|
|
|
|
| 206 |
pl.col(stage_col)
|
| 207 |
.fill_null("NA")
|
| 208 |
.map_elements(
|
| 209 |
+
lambda s: s
|
| 210 |
+
if s in STAGE_ORDER
|
| 211 |
+
else ("OTHER" if s is not None else "NA")
|
| 212 |
)
|
| 213 |
.alias("STAGE"),
|
| 214 |
pl.col("BusinessOnDate").alias("DT"),
|
|
|
|
| 262 |
]
|
| 263 |
)
|
| 264 |
.with_columns(
|
| 265 |
+
((pl.col("RUN_END") - pl.col("RUN_START")) / timedelta(days=1)).alias(
|
| 266 |
+
"RUN_DAYS"
|
| 267 |
+
)
|
| 268 |
)
|
| 269 |
)
|
| 270 |
stage_duration = (
|
|
|
|
| 290 |
if s in set(tr_df["STAGE_FROM"]).union(set(tr_df["STAGE_TO"]))
|
| 291 |
]
|
| 292 |
idx = {label: i for i, label in enumerate(labels)}
|
| 293 |
+
tr_df = tr_df[
|
| 294 |
+
tr_df["STAGE_FROM"].isin(labels) & tr_df["STAGE_TO"].isin(labels)
|
| 295 |
+
].copy()
|
| 296 |
+
tr_df = tr_df.sort_values(
|
| 297 |
+
by=["STAGE_FROM", "STAGE_TO"], key=lambda c: c.map(idx)
|
| 298 |
+
)
|
| 299 |
sankey = go.Figure(
|
| 300 |
data=[
|
| 301 |
go.Sankey(
|
|
|
|
| 350 |
)
|
| 351 |
.with_columns(pl.date(pl.col("Y"), pl.col("M"), pl.lit(1)).alias("YM"))
|
| 352 |
)
|
| 353 |
+
monthly_listings = (
|
| 354 |
+
m_hear.group_by("YM").agg(pl.len().alias("N_HEARINGS")).sort("YM")
|
| 355 |
+
)
|
| 356 |
monthly_listings.write_csv(str(_get_run_dir() / "monthly_hearings.csv"))
|
| 357 |
|
| 358 |
try:
|
|
|
|
| 373 |
ml = monthly_listings.with_columns(
|
| 374 |
[
|
| 375 |
pl.col("N_HEARINGS").shift(1).alias("PREV"),
|
| 376 |
+
(pl.col("N_HEARINGS") - pl.col("N_HEARINGS").shift(1)).alias(
|
| 377 |
+
"DELTA"
|
| 378 |
+
),
|
| 379 |
]
|
| 380 |
)
|
| 381 |
ml_pd = ml.to_pandas()
|
| 382 |
+
ml_pd["ROLL_MEAN"] = (
|
| 383 |
+
ml_pd["N_HEARINGS"].rolling(window=12, min_periods=6).mean()
|
| 384 |
+
)
|
| 385 |
+
ml_pd["ROLL_STD"] = (
|
| 386 |
+
ml_pd["N_HEARINGS"].rolling(window=12, min_periods=6).std()
|
| 387 |
+
)
|
| 388 |
ml_pd["Z"] = (ml_pd["N_HEARINGS"] - ml_pd["ROLL_MEAN"]) / ml_pd["ROLL_STD"]
|
| 389 |
ml_pd["ANOM"] = ml_pd["Z"].abs() >= 3.0
|
| 390 |
|
|
|
|
| 476 |
|
| 477 |
if text_col:
|
| 478 |
hear_txt = hearings.with_columns(
|
| 479 |
+
pl.col(text_col)
|
| 480 |
+
.cast(pl.Utf8)
|
| 481 |
+
.str.strip_chars()
|
| 482 |
+
.str.to_uppercase()
|
| 483 |
+
.alias("PURPOSE_TXT")
|
| 484 |
)
|
| 485 |
+
async_kw = [
|
| 486 |
+
"NON-COMPLIANCE",
|
| 487 |
+
"OFFICE OBJECTION",
|
| 488 |
+
"COMPLIANCE",
|
| 489 |
+
"NOTICE",
|
| 490 |
+
"SERVICE",
|
| 491 |
+
]
|
| 492 |
+
subs_kw = [
|
| 493 |
+
"EVIDENCE",
|
| 494 |
+
"ARGUMENT",
|
| 495 |
+
"FINAL HEARING",
|
| 496 |
+
"JUDGMENT",
|
| 497 |
+
"ORDER",
|
| 498 |
+
"DISPOSAL",
|
| 499 |
+
]
|
| 500 |
hear_txt = hear_txt.with_columns(
|
| 501 |
pl.when(_has_kw_expr("PURPOSE_TXT", async_kw))
|
| 502 |
.then(pl.lit("ASYNC_OR_ADMIN"))
|
|
|
|
| 508 |
tag_share = (
|
| 509 |
hear_txt.group_by(["CASE_TYPE", "PURPOSE_TAG"])
|
| 510 |
.agg(pl.len().alias("N"))
|
| 511 |
+
.with_columns(
|
| 512 |
+
(pl.col("N") / pl.col("N").sum().over("CASE_TYPE")).alias("SHARE")
|
| 513 |
+
)
|
| 514 |
.sort(["CASE_TYPE", "SHARE"], descending=[False, True])
|
| 515 |
)
|
| 516 |
tag_share.write_csv(str(_get_run_dir() / "purpose_tag_shares.csv"))
|
eda/load_clean.py
CHANGED
|
@@ -64,8 +64,8 @@ def load_raw() -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
| 64 |
|
| 65 |
print(f"Loading Parquet files:\n- {cases_path}\n- {hearings_path}")
|
| 66 |
|
| 67 |
-
cases = pl.read_parquet(cases_path)
|
| 68 |
-
hearings = pl.read_parquet(hearings_path)
|
| 69 |
|
| 70 |
print(f"Cases shape: {cases.shape}")
|
| 71 |
print(f"Hearings shape: {hearings.shape}")
|
|
@@ -240,6 +240,7 @@ def save_clean(cases: pl.DataFrame, hearings: pl.DataFrame) -> None:
|
|
| 240 |
def run_load_and_clean() -> None:
|
| 241 |
cases_raw, hearings_raw = load_raw()
|
| 242 |
cases_clean, hearings_clean = clean_and_augment(cases_raw, hearings_raw)
|
|
|
|
| 243 |
save_clean(cases_clean, hearings_clean)
|
| 244 |
|
| 245 |
|
|
|
|
| 64 |
|
| 65 |
print(f"Loading Parquet files:\n- {cases_path}\n- {hearings_path}")
|
| 66 |
|
| 67 |
+
cases = pl.read_parquet(cases_path, low_memory=True)
|
| 68 |
+
hearings = pl.read_parquet(hearings_path, low_memory=True)
|
| 69 |
|
| 70 |
print(f"Cases shape: {cases.shape}")
|
| 71 |
print(f"Hearings shape: {hearings.shape}")
|
|
|
|
| 240 |
def run_load_and_clean() -> None:
|
| 241 |
cases_raw, hearings_raw = load_raw()
|
| 242 |
cases_clean, hearings_clean = clean_and_augment(cases_raw, hearings_raw)
|
| 243 |
+
del cases_raw, hearings_raw
|
| 244 |
save_clean(cases_clean, hearings_clean)
|
| 245 |
|
| 246 |
|
{scheduler β src}/__init__.py
RENAMED
|
File without changes
|
{scheduler/dashboard β src}/app.py
RENAMED
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
| 9 |
|
| 10 |
import streamlit as st
|
| 11 |
|
| 12 |
-
from
|
| 13 |
|
| 14 |
# Page configuration
|
| 15 |
st.set_page_config(
|
|
|
|
| 9 |
|
| 10 |
import streamlit as st
|
| 11 |
|
| 12 |
+
from src.dashboard.utils import get_data_status
|
| 13 |
|
| 14 |
# Page configuration
|
| 15 |
st.set_page_config(
|
{scheduler β src}/control/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/control/explainability.py
RENAMED
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
| 7 |
from datetime import date
|
| 8 |
from typing import Optional
|
| 9 |
|
| 10 |
-
from
|
| 11 |
|
| 12 |
|
| 13 |
def _fmt_score(score: Optional[float]) -> str:
|
|
@@ -42,7 +42,9 @@ class SchedulingExplanation:
|
|
| 42 |
|
| 43 |
def to_readable_text(self) -> str:
|
| 44 |
"""Convert to human-readable explanation."""
|
| 45 |
-
lines = [
|
|
|
|
|
|
|
| 46 |
lines.append("=" * 60)
|
| 47 |
|
| 48 |
for i, step in enumerate(self.decision_steps, 1):
|
|
@@ -132,13 +134,17 @@ class ExplainabilityEngine:
|
|
| 132 |
if not is_ripe:
|
| 133 |
if "SUMMONS" in ripeness_status:
|
| 134 |
ripeness_detail["bottleneck"] = "Summons not yet served"
|
| 135 |
-
ripeness_detail["action_needed"] =
|
|
|
|
|
|
|
| 136 |
elif "DEPENDENT" in ripeness_status:
|
| 137 |
ripeness_detail["bottleneck"] = "Dependent on another case"
|
| 138 |
ripeness_detail["action_needed"] = "Wait for dependent case resolution"
|
| 139 |
elif "PARTY" in ripeness_status:
|
| 140 |
ripeness_detail["bottleneck"] = "Party unavailable or unresponsive"
|
| 141 |
-
ripeness_detail["action_needed"] =
|
|
|
|
|
|
|
| 142 |
else:
|
| 143 |
ripeness_detail["bottleneck"] = ripeness_status
|
| 144 |
else:
|
|
@@ -176,7 +182,10 @@ class ExplainabilityEngine:
|
|
| 176 |
days_since = case.days_since_last_hearing
|
| 177 |
meets_gap = case.last_hearing_date is None or days_since >= min_gap_days
|
| 178 |
|
| 179 |
-
gap_details = {
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
if case.last_hearing_date:
|
| 182 |
gap_details["last_hearing_date"] = str(case.last_hearing_date)
|
|
@@ -192,7 +201,9 @@ class ExplainabilityEngine:
|
|
| 192 |
|
| 193 |
if not meets_gap and not scheduled:
|
| 194 |
next_eligible = (
|
| 195 |
-
case.last_hearing_date.isoformat()
|
|
|
|
|
|
|
| 196 |
)
|
| 197 |
return SchedulingExplanation(
|
| 198 |
case_id=case.case_id,
|
|
@@ -254,7 +265,9 @@ class ExplainabilityEngine:
|
|
| 254 |
step_name="Policy Selection",
|
| 255 |
passed=True,
|
| 256 |
reason="Selected by policy despite being below typical threshold",
|
| 257 |
-
details={
|
|
|
|
|
|
|
| 258 |
)
|
| 259 |
)
|
| 260 |
else:
|
|
@@ -297,7 +310,9 @@ class ExplainabilityEngine:
|
|
| 297 |
scheduled=True,
|
| 298 |
decision_steps=steps,
|
| 299 |
final_reason=final_reason,
|
| 300 |
-
priority_breakdown=priority_breakdown
|
|
|
|
|
|
|
| 301 |
courtroom_assignment_reason=courtroom_reason,
|
| 302 |
)
|
| 303 |
|
|
@@ -342,7 +357,9 @@ class ExplainabilityEngine:
|
|
| 342 |
scheduled=False,
|
| 343 |
decision_steps=steps,
|
| 344 |
final_reason=final_reason,
|
| 345 |
-
priority_breakdown=priority_breakdown
|
|
|
|
|
|
|
| 346 |
)
|
| 347 |
|
| 348 |
@staticmethod
|
|
@@ -370,9 +387,7 @@ class ExplainabilityEngine:
|
|
| 370 |
return f"UNRIPE: {reason}"
|
| 371 |
|
| 372 |
if case.last_hearing_date and case.days_since_last_hearing < 7:
|
| 373 |
-
return (
|
| 374 |
-
f"Too recent (last hearing {case.days_since_last_hearing} days ago, minimum 7 days)"
|
| 375 |
-
)
|
| 376 |
|
| 377 |
# If ripe and meets gap, then it's priority-based
|
| 378 |
priority = case.get_priority_score()
|
|
|
|
| 7 |
from datetime import date
|
| 8 |
from typing import Optional
|
| 9 |
|
| 10 |
+
from src.core.case import Case
|
| 11 |
|
| 12 |
|
| 13 |
def _fmt_score(score: Optional[float]) -> str:
|
|
|
|
| 42 |
|
| 43 |
def to_readable_text(self) -> str:
|
| 44 |
"""Convert to human-readable explanation."""
|
| 45 |
+
lines = [
|
| 46 |
+
f"Case {self.case_id}: {'SCHEDULED' if self.scheduled else 'NOT SCHEDULED'}"
|
| 47 |
+
]
|
| 48 |
lines.append("=" * 60)
|
| 49 |
|
| 50 |
for i, step in enumerate(self.decision_steps, 1):
|
|
|
|
| 134 |
if not is_ripe:
|
| 135 |
if "SUMMONS" in ripeness_status:
|
| 136 |
ripeness_detail["bottleneck"] = "Summons not yet served"
|
| 137 |
+
ripeness_detail["action_needed"] = (
|
| 138 |
+
"Wait for summons service confirmation"
|
| 139 |
+
)
|
| 140 |
elif "DEPENDENT" in ripeness_status:
|
| 141 |
ripeness_detail["bottleneck"] = "Dependent on another case"
|
| 142 |
ripeness_detail["action_needed"] = "Wait for dependent case resolution"
|
| 143 |
elif "PARTY" in ripeness_status:
|
| 144 |
ripeness_detail["bottleneck"] = "Party unavailable or unresponsive"
|
| 145 |
+
ripeness_detail["action_needed"] = (
|
| 146 |
+
"Wait for party availability confirmation"
|
| 147 |
+
)
|
| 148 |
else:
|
| 149 |
ripeness_detail["bottleneck"] = ripeness_status
|
| 150 |
else:
|
|
|
|
| 182 |
days_since = case.days_since_last_hearing
|
| 183 |
meets_gap = case.last_hearing_date is None or days_since >= min_gap_days
|
| 184 |
|
| 185 |
+
gap_details = {
|
| 186 |
+
"days_since_last_hearing": days_since,
|
| 187 |
+
"minimum_required": min_gap_days,
|
| 188 |
+
}
|
| 189 |
|
| 190 |
if case.last_hearing_date:
|
| 191 |
gap_details["last_hearing_date"] = str(case.last_hearing_date)
|
|
|
|
| 201 |
|
| 202 |
if not meets_gap and not scheduled:
|
| 203 |
next_eligible = (
|
| 204 |
+
case.last_hearing_date.isoformat()
|
| 205 |
+
if case.last_hearing_date
|
| 206 |
+
else "unknown"
|
| 207 |
)
|
| 208 |
return SchedulingExplanation(
|
| 209 |
case_id=case.case_id,
|
|
|
|
| 265 |
step_name="Policy Selection",
|
| 266 |
passed=True,
|
| 267 |
reason="Selected by policy despite being below typical threshold",
|
| 268 |
+
details={
|
| 269 |
+
"reason": "Algorithm determined case should be scheduled"
|
| 270 |
+
},
|
| 271 |
)
|
| 272 |
)
|
| 273 |
else:
|
|
|
|
| 310 |
scheduled=True,
|
| 311 |
decision_steps=steps,
|
| 312 |
final_reason=final_reason,
|
| 313 |
+
priority_breakdown=priority_breakdown
|
| 314 |
+
if priority_breakdown is not None
|
| 315 |
+
else None,
|
| 316 |
courtroom_assignment_reason=courtroom_reason,
|
| 317 |
)
|
| 318 |
|
|
|
|
| 357 |
scheduled=False,
|
| 358 |
decision_steps=steps,
|
| 359 |
final_reason=final_reason,
|
| 360 |
+
priority_breakdown=priority_breakdown
|
| 361 |
+
if priority_breakdown is not None
|
| 362 |
+
else None,
|
| 363 |
)
|
| 364 |
|
| 365 |
@staticmethod
|
|
|
|
| 387 |
return f"UNRIPE: {reason}"
|
| 388 |
|
| 389 |
if case.last_hearing_date and case.days_since_last_hearing < 7:
|
| 390 |
+
return f"Too recent (last hearing {case.days_since_last_hearing} days ago, minimum 7 days)"
|
|
|
|
|
|
|
| 391 |
|
| 392 |
# If ripe and meets gap, then it's priority-based
|
| 393 |
priority = case.get_priority_score()
|
{scheduler β src}/control/overrides.py
RENAMED
|
File without changes
|
{scheduler β src}/core/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/core/algorithm.py
RENAMED
|
@@ -8,25 +8,26 @@ This module provides the standalone scheduling algorithm that can be used by:
|
|
| 8 |
The algorithm accepts cases, courtrooms, date, policy, and optional overrides,
|
| 9 |
then returns scheduled cause list with explanations and audit trail.
|
| 10 |
"""
|
|
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
from dataclasses import dataclass, field
|
| 14 |
from datetime import date
|
| 15 |
from typing import Dict, List, Optional, Tuple
|
| 16 |
|
| 17 |
-
from
|
| 18 |
-
from
|
| 19 |
JudgePreferences,
|
| 20 |
Override,
|
| 21 |
OverrideType,
|
| 22 |
OverrideValidator,
|
| 23 |
)
|
| 24 |
-
from
|
| 25 |
-
from
|
| 26 |
-
from
|
| 27 |
-
from
|
| 28 |
-
from
|
| 29 |
-
from
|
| 30 |
|
| 31 |
|
| 32 |
@dataclass
|
|
@@ -66,7 +67,9 @@ class SchedulingResult:
|
|
| 66 |
|
| 67 |
def __post_init__(self):
|
| 68 |
"""Calculate derived fields."""
|
| 69 |
-
self.total_scheduled = sum(
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
class SchedulingAlgorithm:
|
|
@@ -94,7 +97,7 @@ class SchedulingAlgorithm:
|
|
| 94 |
self,
|
| 95 |
policy: SchedulerPolicy,
|
| 96 |
allocator: Optional[CourtroomAllocator] = None,
|
| 97 |
-
min_gap_days: int = MIN_GAP_BETWEEN_HEARINGS
|
| 98 |
):
|
| 99 |
"""Initialize algorithm with policy and allocator.
|
| 100 |
|
|
@@ -115,7 +118,7 @@ class SchedulingAlgorithm:
|
|
| 115 |
current_date: date,
|
| 116 |
overrides: Optional[List[Override]] = None,
|
| 117 |
preferences: Optional[JudgePreferences] = None,
|
| 118 |
-
max_explanations_unscheduled: int = 100
|
| 119 |
) -> SchedulingResult:
|
| 120 |
"""Schedule cases for a single day with override support.
|
| 121 |
|
|
@@ -145,17 +148,21 @@ class SchedulingAlgorithm:
|
|
| 145 |
validated_overrides.append(override)
|
| 146 |
else:
|
| 147 |
errors = validator.get_errors()
|
| 148 |
-
rejection_reason =
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
unscheduled.append(
|
| 155 |
(
|
| 156 |
None,
|
| 157 |
f"Invalid override rejected (judge {override.judge_id}): "
|
| 158 |
-
f"{override.override_type.value} - {rejection_reason}"
|
| 159 |
)
|
| 160 |
)
|
| 161 |
|
|
@@ -177,7 +184,9 @@ class SchedulingAlgorithm:
|
|
| 177 |
|
| 178 |
# CHECKPOINT 3: Apply judge preferences (capacity overrides tracked)
|
| 179 |
if preferences:
|
| 180 |
-
applied_overrides.extend(
|
|
|
|
|
|
|
| 181 |
|
| 182 |
# CHECKPOINT 4: Prioritize using policy
|
| 183 |
prioritized = self.policy.prioritize(eligible_cases, current_date)
|
|
@@ -185,7 +194,11 @@ class SchedulingAlgorithm:
|
|
| 185 |
# CHECKPOINT 5: Apply manual overrides (add/remove/reorder/priority)
|
| 186 |
if validated_overrides:
|
| 187 |
prioritized = self._apply_manual_overrides(
|
| 188 |
-
prioritized,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
)
|
| 190 |
|
| 191 |
# CHECKPOINT 6: Allocate to courtrooms
|
|
@@ -207,7 +220,7 @@ class SchedulingAlgorithm:
|
|
| 207 |
scheduled=True,
|
| 208 |
ripeness_status=case.ripeness_status,
|
| 209 |
priority_score=case.get_priority_score(),
|
| 210 |
-
courtroom_id=courtroom_id
|
| 211 |
)
|
| 212 |
explanations[case.case_id] = explanation
|
| 213 |
|
|
@@ -220,7 +233,7 @@ class SchedulingAlgorithm:
|
|
| 220 |
scheduled=False,
|
| 221 |
ripeness_status=case.ripeness_status,
|
| 222 |
capacity_full=("Capacity" in reason),
|
| 223 |
-
below_threshold=False
|
| 224 |
)
|
| 225 |
explanations[case.case_id] = explanation
|
| 226 |
|
|
@@ -235,7 +248,7 @@ class SchedulingAlgorithm:
|
|
| 235 |
ripeness_filtered=ripeness_filtered,
|
| 236 |
capacity_limited=capacity_limited,
|
| 237 |
scheduling_date=current_date,
|
| 238 |
-
policy_used=self.policy.get_name()
|
| 239 |
)
|
| 240 |
|
| 241 |
def _filter_by_ripeness(
|
|
@@ -243,7 +256,7 @@ class SchedulingAlgorithm:
|
|
| 243 |
cases: List[Case],
|
| 244 |
current_date: date,
|
| 245 |
overrides: Optional[List[Override]],
|
| 246 |
-
applied_overrides: List[Override]
|
| 247 |
) -> Tuple[List[Case], int]:
|
| 248 |
"""Filter cases by ripeness with override support."""
|
| 249 |
# Build override lookup
|
|
@@ -263,10 +276,17 @@ class SchedulingAlgorithm:
|
|
| 263 |
case.mark_ripe(current_date)
|
| 264 |
ripe_cases.append(case)
|
| 265 |
# Track override application
|
| 266 |
-
override = next(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
applied_overrides.append(override)
|
| 268 |
else:
|
| 269 |
-
case.mark_unripe(
|
|
|
|
|
|
|
| 270 |
filtered_count += 1
|
| 271 |
continue
|
| 272 |
|
|
@@ -288,10 +308,7 @@ class SchedulingAlgorithm:
|
|
| 288 |
return ripe_cases, filtered_count
|
| 289 |
|
| 290 |
def _filter_eligible(
|
| 291 |
-
self,
|
| 292 |
-
cases: List[Case],
|
| 293 |
-
current_date: date,
|
| 294 |
-
unscheduled: List[Tuple[Case, str]]
|
| 295 |
) -> List[Case]:
|
| 296 |
"""Filter cases that meet minimum gap requirement."""
|
| 297 |
eligible = []
|
|
@@ -304,15 +321,14 @@ class SchedulingAlgorithm:
|
|
| 304 |
return eligible
|
| 305 |
|
| 306 |
def _get_preference_overrides(
|
| 307 |
-
self,
|
| 308 |
-
preferences: JudgePreferences,
|
| 309 |
-
courtrooms: List[Courtroom]
|
| 310 |
) -> List[Override]:
|
| 311 |
"""Extract overrides from judge preferences for audit trail."""
|
| 312 |
overrides = []
|
| 313 |
|
| 314 |
if preferences.capacity_overrides:
|
| 315 |
from datetime import datetime
|
|
|
|
| 316 |
for courtroom_id, new_capacity in preferences.capacity_overrides.items():
|
| 317 |
override = Override(
|
| 318 |
override_id=f"pref-capacity-{courtroom_id}-{preferences.judge_id}",
|
|
@@ -322,7 +338,7 @@ class SchedulingAlgorithm:
|
|
| 322 |
timestamp=datetime.now(),
|
| 323 |
courtroom_id=courtroom_id,
|
| 324 |
new_capacity=new_capacity,
|
| 325 |
-
reason="Judge preference"
|
| 326 |
)
|
| 327 |
overrides.append(override)
|
| 328 |
|
|
@@ -334,24 +350,32 @@ class SchedulingAlgorithm:
|
|
| 334 |
overrides: List[Override],
|
| 335 |
applied_overrides: List[Override],
|
| 336 |
unscheduled: List[Tuple[Case, str]],
|
| 337 |
-
all_cases: List[Case]
|
| 338 |
) -> List[Case]:
|
| 339 |
"""Apply manual overrides (ADD_CASE, REMOVE_CASE, PRIORITY, REORDER)."""
|
| 340 |
result = prioritized.copy()
|
| 341 |
|
| 342 |
# Apply ADD_CASE overrides (insert at high priority)
|
| 343 |
-
add_overrides = [
|
|
|
|
|
|
|
| 344 |
for override in add_overrides:
|
| 345 |
# Find case in full case list
|
| 346 |
-
case_to_add = next(
|
|
|
|
|
|
|
| 347 |
if case_to_add and case_to_add not in result:
|
| 348 |
# Insert at position 0 (highest priority) or specified position
|
| 349 |
-
insert_pos =
|
|
|
|
|
|
|
| 350 |
result.insert(min(insert_pos, len(result)), case_to_add)
|
| 351 |
applied_overrides.append(override)
|
| 352 |
|
| 353 |
# Apply REMOVE_CASE overrides
|
| 354 |
-
remove_overrides = [
|
|
|
|
|
|
|
| 355 |
for override in remove_overrides:
|
| 356 |
removed = [c for c in result if c.case_id == override.case_id]
|
| 357 |
result = [c for c in result if c.case_id != override.case_id]
|
|
@@ -360,9 +384,13 @@ class SchedulingAlgorithm:
|
|
| 360 |
unscheduled.append((removed[0], f"Judge override: {override.reason}"))
|
| 361 |
|
| 362 |
# Apply PRIORITY overrides (adjust priority scores)
|
| 363 |
-
priority_overrides = [
|
|
|
|
|
|
|
| 364 |
for override in priority_overrides:
|
| 365 |
-
case_to_adjust = next(
|
|
|
|
|
|
|
| 366 |
if case_to_adjust and override.new_priority is not None:
|
| 367 |
# Store original priority for reference
|
| 368 |
case_to_adjust.get_priority_score()
|
|
@@ -373,13 +401,20 @@ class SchedulingAlgorithm:
|
|
| 373 |
|
| 374 |
# Re-sort if priority overrides were applied
|
| 375 |
if priority_overrides:
|
| 376 |
-
result.sort(
|
|
|
|
|
|
|
|
|
|
| 377 |
|
| 378 |
# Apply REORDER overrides (explicit positioning)
|
| 379 |
-
reorder_overrides = [
|
|
|
|
|
|
|
| 380 |
for override in reorder_overrides:
|
| 381 |
if override.case_id and override.new_position is not None:
|
| 382 |
-
case_to_move = next(
|
|
|
|
|
|
|
| 383 |
if case_to_move and 0 <= override.new_position < len(result):
|
| 384 |
result.remove(case_to_move)
|
| 385 |
result.insert(override.new_position, case_to_move)
|
|
@@ -392,7 +427,7 @@ class SchedulingAlgorithm:
|
|
| 392 |
prioritized: List[Case],
|
| 393 |
courtrooms: List[Courtroom],
|
| 394 |
current_date: date,
|
| 395 |
-
preferences: Optional[JudgePreferences]
|
| 396 |
) -> Tuple[Dict[int, List[Case]], int]:
|
| 397 |
"""Allocate prioritized cases to courtrooms."""
|
| 398 |
# Calculate total capacity (with preference overrides)
|
|
|
|
| 8 |
The algorithm accepts cases, courtrooms, date, policy, and optional overrides,
|
| 9 |
then returns scheduled cause list with explanations and audit trail.
|
| 10 |
"""
|
| 11 |
+
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
from dataclasses import dataclass, field
|
| 15 |
from datetime import date
|
| 16 |
from typing import Dict, List, Optional, Tuple
|
| 17 |
|
| 18 |
+
from src.control.explainability import ExplainabilityEngine, SchedulingExplanation
|
| 19 |
+
from src.control.overrides import (
|
| 20 |
JudgePreferences,
|
| 21 |
Override,
|
| 22 |
OverrideType,
|
| 23 |
OverrideValidator,
|
| 24 |
)
|
| 25 |
+
from src.core.case import Case, CaseStatus
|
| 26 |
+
from src.core.courtroom import Courtroom
|
| 27 |
+
from src.core.policy import SchedulerPolicy
|
| 28 |
+
from src.core.ripeness import RipenessClassifier, RipenessStatus
|
| 29 |
+
from src.data.config import MIN_GAP_BETWEEN_HEARINGS
|
| 30 |
+
from src.simulation.allocator import CourtroomAllocator
|
| 31 |
|
| 32 |
|
| 33 |
@dataclass
|
|
|
|
| 67 |
|
| 68 |
def __post_init__(self):
|
| 69 |
"""Calculate derived fields."""
|
| 70 |
+
self.total_scheduled = sum(
|
| 71 |
+
len(cases) for cases in self.scheduled_cases.values()
|
| 72 |
+
)
|
| 73 |
|
| 74 |
|
| 75 |
class SchedulingAlgorithm:
|
|
|
|
| 97 |
self,
|
| 98 |
policy: SchedulerPolicy,
|
| 99 |
allocator: Optional[CourtroomAllocator] = None,
|
| 100 |
+
min_gap_days: int = MIN_GAP_BETWEEN_HEARINGS,
|
| 101 |
):
|
| 102 |
"""Initialize algorithm with policy and allocator.
|
| 103 |
|
|
|
|
| 118 |
current_date: date,
|
| 119 |
overrides: Optional[List[Override]] = None,
|
| 120 |
preferences: Optional[JudgePreferences] = None,
|
| 121 |
+
max_explanations_unscheduled: int = 100,
|
| 122 |
) -> SchedulingResult:
|
| 123 |
"""Schedule cases for a single day with override support.
|
| 124 |
|
|
|
|
| 148 |
validated_overrides.append(override)
|
| 149 |
else:
|
| 150 |
errors = validator.get_errors()
|
| 151 |
+
rejection_reason = (
|
| 152 |
+
"; ".join(errors) if errors else "Validation failed"
|
| 153 |
+
)
|
| 154 |
+
override_rejections.append(
|
| 155 |
+
{
|
| 156 |
+
"judge": override.judge_id,
|
| 157 |
+
"context": override.override_type.value,
|
| 158 |
+
"reason": rejection_reason,
|
| 159 |
+
}
|
| 160 |
+
)
|
| 161 |
unscheduled.append(
|
| 162 |
(
|
| 163 |
None,
|
| 164 |
f"Invalid override rejected (judge {override.judge_id}): "
|
| 165 |
+
f"{override.override_type.value} - {rejection_reason}",
|
| 166 |
)
|
| 167 |
)
|
| 168 |
|
|
|
|
| 184 |
|
| 185 |
# CHECKPOINT 3: Apply judge preferences (capacity overrides tracked)
|
| 186 |
if preferences:
|
| 187 |
+
applied_overrides.extend(
|
| 188 |
+
self._get_preference_overrides(preferences, courtrooms)
|
| 189 |
+
)
|
| 190 |
|
| 191 |
# CHECKPOINT 4: Prioritize using policy
|
| 192 |
prioritized = self.policy.prioritize(eligible_cases, current_date)
|
|
|
|
| 194 |
# CHECKPOINT 5: Apply manual overrides (add/remove/reorder/priority)
|
| 195 |
if validated_overrides:
|
| 196 |
prioritized = self._apply_manual_overrides(
|
| 197 |
+
prioritized,
|
| 198 |
+
validated_overrides,
|
| 199 |
+
applied_overrides,
|
| 200 |
+
unscheduled,
|
| 201 |
+
active_cases,
|
| 202 |
)
|
| 203 |
|
| 204 |
# CHECKPOINT 6: Allocate to courtrooms
|
|
|
|
| 220 |
scheduled=True,
|
| 221 |
ripeness_status=case.ripeness_status,
|
| 222 |
priority_score=case.get_priority_score(),
|
| 223 |
+
courtroom_id=courtroom_id,
|
| 224 |
)
|
| 225 |
explanations[case.case_id] = explanation
|
| 226 |
|
|
|
|
| 233 |
scheduled=False,
|
| 234 |
ripeness_status=case.ripeness_status,
|
| 235 |
capacity_full=("Capacity" in reason),
|
| 236 |
+
below_threshold=False,
|
| 237 |
)
|
| 238 |
explanations[case.case_id] = explanation
|
| 239 |
|
|
|
|
| 248 |
ripeness_filtered=ripeness_filtered,
|
| 249 |
capacity_limited=capacity_limited,
|
| 250 |
scheduling_date=current_date,
|
| 251 |
+
policy_used=self.policy.get_name(),
|
| 252 |
)
|
| 253 |
|
| 254 |
def _filter_by_ripeness(
|
|
|
|
| 256 |
cases: List[Case],
|
| 257 |
current_date: date,
|
| 258 |
overrides: Optional[List[Override]],
|
| 259 |
+
applied_overrides: List[Override],
|
| 260 |
) -> Tuple[List[Case], int]:
|
| 261 |
"""Filter cases by ripeness with override support."""
|
| 262 |
# Build override lookup
|
|
|
|
| 276 |
case.mark_ripe(current_date)
|
| 277 |
ripe_cases.append(case)
|
| 278 |
# Track override application
|
| 279 |
+
override = next(
|
| 280 |
+
o
|
| 281 |
+
for o in overrides
|
| 282 |
+
if o.case_id == case.case_id
|
| 283 |
+
and o.override_type == OverrideType.RIPENESS
|
| 284 |
+
)
|
| 285 |
applied_overrides.append(override)
|
| 286 |
else:
|
| 287 |
+
case.mark_unripe(
|
| 288 |
+
RipenessStatus.UNRIPE_DEPENDENT, "Judge override", current_date
|
| 289 |
+
)
|
| 290 |
filtered_count += 1
|
| 291 |
continue
|
| 292 |
|
|
|
|
| 308 |
return ripe_cases, filtered_count
|
| 309 |
|
| 310 |
def _filter_eligible(
|
| 311 |
+
self, cases: List[Case], current_date: date, unscheduled: List[Tuple[Case, str]]
|
|
|
|
|
|
|
|
|
|
| 312 |
) -> List[Case]:
|
| 313 |
"""Filter cases that meet minimum gap requirement."""
|
| 314 |
eligible = []
|
|
|
|
| 321 |
return eligible
|
| 322 |
|
| 323 |
def _get_preference_overrides(
|
| 324 |
+
self, preferences: JudgePreferences, courtrooms: List[Courtroom]
|
|
|
|
|
|
|
| 325 |
) -> List[Override]:
|
| 326 |
"""Extract overrides from judge preferences for audit trail."""
|
| 327 |
overrides = []
|
| 328 |
|
| 329 |
if preferences.capacity_overrides:
|
| 330 |
from datetime import datetime
|
| 331 |
+
|
| 332 |
for courtroom_id, new_capacity in preferences.capacity_overrides.items():
|
| 333 |
override = Override(
|
| 334 |
override_id=f"pref-capacity-{courtroom_id}-{preferences.judge_id}",
|
|
|
|
| 338 |
timestamp=datetime.now(),
|
| 339 |
courtroom_id=courtroom_id,
|
| 340 |
new_capacity=new_capacity,
|
| 341 |
+
reason="Judge preference",
|
| 342 |
)
|
| 343 |
overrides.append(override)
|
| 344 |
|
|
|
|
| 350 |
overrides: List[Override],
|
| 351 |
applied_overrides: List[Override],
|
| 352 |
unscheduled: List[Tuple[Case, str]],
|
| 353 |
+
all_cases: List[Case],
|
| 354 |
) -> List[Case]:
|
| 355 |
"""Apply manual overrides (ADD_CASE, REMOVE_CASE, PRIORITY, REORDER)."""
|
| 356 |
result = prioritized.copy()
|
| 357 |
|
| 358 |
# Apply ADD_CASE overrides (insert at high priority)
|
| 359 |
+
add_overrides = [
|
| 360 |
+
o for o in overrides if o.override_type == OverrideType.ADD_CASE
|
| 361 |
+
]
|
| 362 |
for override in add_overrides:
|
| 363 |
# Find case in full case list
|
| 364 |
+
case_to_add = next(
|
| 365 |
+
(c for c in all_cases if c.case_id == override.case_id), None
|
| 366 |
+
)
|
| 367 |
if case_to_add and case_to_add not in result:
|
| 368 |
# Insert at position 0 (highest priority) or specified position
|
| 369 |
+
insert_pos = (
|
| 370 |
+
override.new_position if override.new_position is not None else 0
|
| 371 |
+
)
|
| 372 |
result.insert(min(insert_pos, len(result)), case_to_add)
|
| 373 |
applied_overrides.append(override)
|
| 374 |
|
| 375 |
# Apply REMOVE_CASE overrides
|
| 376 |
+
remove_overrides = [
|
| 377 |
+
o for o in overrides if o.override_type == OverrideType.REMOVE_CASE
|
| 378 |
+
]
|
| 379 |
for override in remove_overrides:
|
| 380 |
removed = [c for c in result if c.case_id == override.case_id]
|
| 381 |
result = [c for c in result if c.case_id != override.case_id]
|
|
|
|
| 384 |
unscheduled.append((removed[0], f"Judge override: {override.reason}"))
|
| 385 |
|
| 386 |
# Apply PRIORITY overrides (adjust priority scores)
|
| 387 |
+
priority_overrides = [
|
| 388 |
+
o for o in overrides if o.override_type == OverrideType.PRIORITY
|
| 389 |
+
]
|
| 390 |
for override in priority_overrides:
|
| 391 |
+
case_to_adjust = next(
|
| 392 |
+
(c for c in result if c.case_id == override.case_id), None
|
| 393 |
+
)
|
| 394 |
if case_to_adjust and override.new_priority is not None:
|
| 395 |
# Store original priority for reference
|
| 396 |
case_to_adjust.get_priority_score()
|
|
|
|
| 401 |
|
| 402 |
# Re-sort if priority overrides were applied
|
| 403 |
if priority_overrides:
|
| 404 |
+
result.sort(
|
| 405 |
+
key=lambda c: getattr(c, "_priority_override", c.get_priority_score()),
|
| 406 |
+
reverse=True,
|
| 407 |
+
)
|
| 408 |
|
| 409 |
# Apply REORDER overrides (explicit positioning)
|
| 410 |
+
reorder_overrides = [
|
| 411 |
+
o for o in overrides if o.override_type == OverrideType.REORDER
|
| 412 |
+
]
|
| 413 |
for override in reorder_overrides:
|
| 414 |
if override.case_id and override.new_position is not None:
|
| 415 |
+
case_to_move = next(
|
| 416 |
+
(c for c in result if c.case_id == override.case_id), None
|
| 417 |
+
)
|
| 418 |
if case_to_move and 0 <= override.new_position < len(result):
|
| 419 |
result.remove(case_to_move)
|
| 420 |
result.insert(override.new_position, case_to_move)
|
|
|
|
| 427 |
prioritized: List[Case],
|
| 428 |
courtrooms: List[Courtroom],
|
| 429 |
current_date: date,
|
| 430 |
+
preferences: Optional[JudgePreferences],
|
| 431 |
) -> Tuple[Dict[int, List[Case]], int]:
|
| 432 |
"""Allocate prioritized cases to courtrooms."""
|
| 433 |
# Calculate total capacity (with preference overrides)
|
{scheduler β src}/core/case.py
RENAMED
|
@@ -11,10 +11,10 @@ from datetime import date, datetime
|
|
| 11 |
from enum import Enum
|
| 12 |
from typing import TYPE_CHECKING, List, Optional
|
| 13 |
|
| 14 |
-
from
|
| 15 |
|
| 16 |
if TYPE_CHECKING:
|
| 17 |
-
from
|
| 18 |
else:
|
| 19 |
# Import at runtime
|
| 20 |
RipenessStatus = None
|
|
@@ -22,10 +22,11 @@ else:
|
|
| 22 |
|
| 23 |
class CaseStatus(Enum):
|
| 24 |
"""Status of a case in the system."""
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass
|
|
@@ -48,6 +49,7 @@ class Case:
|
|
| 48 |
disposal_date: Date of disposal (if disposed)
|
| 49 |
history: List of hearing dates and outcomes
|
| 50 |
"""
|
|
|
|
| 51 |
case_id: str
|
| 52 |
case_type: str
|
| 53 |
filed_date: date
|
|
@@ -69,7 +71,9 @@ class Case:
|
|
| 69 |
ripeness_status: str = "UNKNOWN" # RipenessStatus enum value (stored as string to avoid circular import)
|
| 70 |
bottleneck_reason: Optional[str] = None
|
| 71 |
ripeness_updated_at: Optional[datetime] = None
|
| 72 |
-
last_hearing_purpose: Optional[str] =
|
|
|
|
|
|
|
| 73 |
|
| 74 |
# No-case-left-behind tracking (NEW)
|
| 75 |
last_scheduled_date: Optional[date] = None
|
|
@@ -92,13 +96,17 @@ class Case:
|
|
| 92 |
self.disposal_date = current_date
|
| 93 |
|
| 94 |
# Record in history
|
| 95 |
-
self.history.append(
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
"""Record a hearing event.
|
| 103 |
|
| 104 |
Args:
|
|
@@ -115,13 +123,15 @@ class Case:
|
|
| 115 |
self.status = CaseStatus.ADJOURNED
|
| 116 |
|
| 117 |
# Record in history
|
| 118 |
-
self.history.append(
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
|
| 126 |
def update_age(self, current_date: date) -> None:
|
| 127 |
"""Update age and days since last hearing.
|
|
@@ -143,7 +153,9 @@ class Case:
|
|
| 143 |
|
| 144 |
# Update days since last scheduled (for no-case-left-behind tracking)
|
| 145 |
if self.last_scheduled_date:
|
| 146 |
-
self.days_since_last_scheduled = (
|
|
|
|
|
|
|
| 147 |
else:
|
| 148 |
self.days_since_last_scheduled = self.age_days
|
| 149 |
|
|
@@ -240,11 +252,14 @@ class Case:
|
|
| 240 |
# At 21 days: ~0.37 (weak boost)
|
| 241 |
# At 28 days: ~0.26 (very weak boost)
|
| 242 |
import math
|
|
|
|
| 243 |
decay_factor = 21 # Half-life of boost
|
| 244 |
adjournment_boost = math.exp(-self.days_since_last_hearing / decay_factor)
|
| 245 |
adjournment_boost *= 0.15
|
| 246 |
|
| 247 |
-
return
|
|
|
|
|
|
|
| 248 |
|
| 249 |
def mark_unripe(self, status, reason: str, current_date: datetime) -> None:
|
| 250 |
"""Mark case as unripe with bottleneck reason.
|
|
@@ -255,17 +270,19 @@ class Case:
|
|
| 255 |
current_date: Current simulation date
|
| 256 |
"""
|
| 257 |
# Store as string to avoid circular import
|
| 258 |
-
self.ripeness_status = status.value if hasattr(status,
|
| 259 |
self.bottleneck_reason = reason
|
| 260 |
self.ripeness_updated_at = current_date
|
| 261 |
|
| 262 |
# Record in history
|
| 263 |
-
self.history.append(
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
| 269 |
|
| 270 |
def mark_ripe(self, current_date: datetime) -> None:
|
| 271 |
"""Mark case as ripe (ready for hearing).
|
|
@@ -278,12 +295,14 @@ class Case:
|
|
| 278 |
self.ripeness_updated_at = current_date
|
| 279 |
|
| 280 |
# Record in history
|
| 281 |
-
self.history.append(
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
|
| 288 |
def mark_scheduled(self, scheduled_date: date) -> None:
|
| 289 |
"""Mark case as scheduled for a hearing.
|
|
@@ -302,9 +321,11 @@ class Case:
|
|
| 302 |
return self.status == CaseStatus.DISPOSED
|
| 303 |
|
| 304 |
def __repr__(self) -> str:
|
| 305 |
-
return (
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
| 308 |
|
| 309 |
def to_dict(self) -> dict:
|
| 310 |
"""Convert case to dictionary for serialization."""
|
|
@@ -318,14 +339,20 @@ class Case:
|
|
| 318 |
"is_urgent": self.is_urgent,
|
| 319 |
"readiness_score": self.readiness_score,
|
| 320 |
"hearing_count": self.hearing_count,
|
| 321 |
-
"last_hearing_date": self.last_hearing_date.isoformat()
|
|
|
|
|
|
|
| 322 |
"days_since_last_hearing": self.days_since_last_hearing,
|
| 323 |
"age_days": self.age_days,
|
| 324 |
-
"disposal_date": self.disposal_date.isoformat()
|
|
|
|
|
|
|
| 325 |
"ripeness_status": self.ripeness_status,
|
| 326 |
"bottleneck_reason": self.bottleneck_reason,
|
| 327 |
"last_hearing_purpose": self.last_hearing_purpose,
|
| 328 |
-
"last_scheduled_date": self.last_scheduled_date.isoformat()
|
|
|
|
|
|
|
| 329 |
"days_since_last_scheduled": self.days_since_last_scheduled,
|
| 330 |
"history": self.history,
|
| 331 |
}
|
|
|
|
| 11 |
from enum import Enum
|
| 12 |
from typing import TYPE_CHECKING, List, Optional
|
| 13 |
|
| 14 |
+
from src.data.config import TERMINAL_STAGES
|
| 15 |
|
| 16 |
if TYPE_CHECKING:
|
| 17 |
+
from src.core.ripeness import RipenessStatus
|
| 18 |
else:
|
| 19 |
# Import at runtime
|
| 20 |
RipenessStatus = None
|
|
|
|
| 22 |
|
| 23 |
class CaseStatus(Enum):
|
| 24 |
"""Status of a case in the system."""
|
| 25 |
+
|
| 26 |
+
PENDING = "pending" # Filed, awaiting first hearing
|
| 27 |
+
ACTIVE = "active" # Has had at least one hearing
|
| 28 |
+
ADJOURNED = "adjourned" # Last hearing was adjourned
|
| 29 |
+
DISPOSED = "disposed" # Final disposal/settlement reached
|
| 30 |
|
| 31 |
|
| 32 |
@dataclass
|
|
|
|
| 49 |
disposal_date: Date of disposal (if disposed)
|
| 50 |
history: List of hearing dates and outcomes
|
| 51 |
"""
|
| 52 |
+
|
| 53 |
case_id: str
|
| 54 |
case_type: str
|
| 55 |
filed_date: date
|
|
|
|
| 71 |
ripeness_status: str = "UNKNOWN" # RipenessStatus enum value (stored as string to avoid circular import)
|
| 72 |
bottleneck_reason: Optional[str] = None
|
| 73 |
ripeness_updated_at: Optional[datetime] = None
|
| 74 |
+
last_hearing_purpose: Optional[str] = (
|
| 75 |
+
None # Purpose of last hearing (for classification)
|
| 76 |
+
)
|
| 77 |
|
| 78 |
# No-case-left-behind tracking (NEW)
|
| 79 |
last_scheduled_date: Optional[date] = None
|
|
|
|
| 96 |
self.disposal_date = current_date
|
| 97 |
|
| 98 |
# Record in history
|
| 99 |
+
self.history.append(
|
| 100 |
+
{
|
| 101 |
+
"date": current_date,
|
| 102 |
+
"event": "stage_change",
|
| 103 |
+
"stage": new_stage,
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
def record_hearing(
|
| 108 |
+
self, hearing_date: date, was_heard: bool, outcome: str = ""
|
| 109 |
+
) -> None:
|
| 110 |
"""Record a hearing event.
|
| 111 |
|
| 112 |
Args:
|
|
|
|
| 123 |
self.status = CaseStatus.ADJOURNED
|
| 124 |
|
| 125 |
# Record in history
|
| 126 |
+
self.history.append(
|
| 127 |
+
{
|
| 128 |
+
"date": hearing_date,
|
| 129 |
+
"event": "hearing",
|
| 130 |
+
"was_heard": was_heard,
|
| 131 |
+
"outcome": outcome,
|
| 132 |
+
"stage": self.current_stage,
|
| 133 |
+
}
|
| 134 |
+
)
|
| 135 |
|
| 136 |
def update_age(self, current_date: date) -> None:
|
| 137 |
"""Update age and days since last hearing.
|
|
|
|
| 153 |
|
| 154 |
# Update days since last scheduled (for no-case-left-behind tracking)
|
| 155 |
if self.last_scheduled_date:
|
| 156 |
+
self.days_since_last_scheduled = (
|
| 157 |
+
current_date - self.last_scheduled_date
|
| 158 |
+
).days
|
| 159 |
else:
|
| 160 |
self.days_since_last_scheduled = self.age_days
|
| 161 |
|
|
|
|
| 252 |
# At 21 days: ~0.37 (weak boost)
|
| 253 |
# At 28 days: ~0.26 (very weak boost)
|
| 254 |
import math
|
| 255 |
+
|
| 256 |
decay_factor = 21 # Half-life of boost
|
| 257 |
adjournment_boost = math.exp(-self.days_since_last_hearing / decay_factor)
|
| 258 |
adjournment_boost *= 0.15
|
| 259 |
|
| 260 |
+
return (
|
| 261 |
+
age_component + readiness_component + urgency_component + adjournment_boost
|
| 262 |
+
)
|
| 263 |
|
| 264 |
def mark_unripe(self, status, reason: str, current_date: datetime) -> None:
|
| 265 |
"""Mark case as unripe with bottleneck reason.
|
|
|
|
| 270 |
current_date: Current simulation date
|
| 271 |
"""
|
| 272 |
# Store as string to avoid circular import
|
| 273 |
+
self.ripeness_status = status.value if hasattr(status, "value") else str(status)
|
| 274 |
self.bottleneck_reason = reason
|
| 275 |
self.ripeness_updated_at = current_date
|
| 276 |
|
| 277 |
# Record in history
|
| 278 |
+
self.history.append(
|
| 279 |
+
{
|
| 280 |
+
"date": current_date,
|
| 281 |
+
"event": "ripeness_change",
|
| 282 |
+
"status": self.ripeness_status,
|
| 283 |
+
"reason": reason,
|
| 284 |
+
}
|
| 285 |
+
)
|
| 286 |
|
| 287 |
def mark_ripe(self, current_date: datetime) -> None:
|
| 288 |
"""Mark case as ripe (ready for hearing).
|
|
|
|
| 295 |
self.ripeness_updated_at = current_date
|
| 296 |
|
| 297 |
# Record in history
|
| 298 |
+
self.history.append(
|
| 299 |
+
{
|
| 300 |
+
"date": current_date,
|
| 301 |
+
"event": "ripeness_change",
|
| 302 |
+
"status": "RIPE",
|
| 303 |
+
"reason": "Case became ripe",
|
| 304 |
+
}
|
| 305 |
+
)
|
| 306 |
|
| 307 |
def mark_scheduled(self, scheduled_date: date) -> None:
|
| 308 |
"""Mark case as scheduled for a hearing.
|
|
|
|
| 321 |
return self.status == CaseStatus.DISPOSED
|
| 322 |
|
| 323 |
def __repr__(self) -> str:
|
| 324 |
+
return (
|
| 325 |
+
f"Case(id={self.case_id}, type={self.case_type}, "
|
| 326 |
+
f"stage={self.current_stage}, status={self.status.value}, "
|
| 327 |
+
f"hearings={self.hearing_count})"
|
| 328 |
+
)
|
| 329 |
|
| 330 |
def to_dict(self) -> dict:
|
| 331 |
"""Convert case to dictionary for serialization."""
|
|
|
|
| 339 |
"is_urgent": self.is_urgent,
|
| 340 |
"readiness_score": self.readiness_score,
|
| 341 |
"hearing_count": self.hearing_count,
|
| 342 |
+
"last_hearing_date": self.last_hearing_date.isoformat()
|
| 343 |
+
if self.last_hearing_date
|
| 344 |
+
else None,
|
| 345 |
"days_since_last_hearing": self.days_since_last_hearing,
|
| 346 |
"age_days": self.age_days,
|
| 347 |
+
"disposal_date": self.disposal_date.isoformat()
|
| 348 |
+
if self.disposal_date
|
| 349 |
+
else None,
|
| 350 |
"ripeness_status": self.ripeness_status,
|
| 351 |
"bottleneck_reason": self.bottleneck_reason,
|
| 352 |
"last_hearing_purpose": self.last_hearing_purpose,
|
| 353 |
+
"last_scheduled_date": self.last_scheduled_date.isoformat()
|
| 354 |
+
if self.last_scheduled_date
|
| 355 |
+
else None,
|
| 356 |
"days_since_last_scheduled": self.days_since_last_scheduled,
|
| 357 |
"history": self.history,
|
| 358 |
}
|
{scheduler β src}/core/courtroom.py
RENAMED
|
@@ -8,7 +8,7 @@ from dataclasses import dataclass, field
|
|
| 8 |
from datetime import date
|
| 9 |
from typing import Dict, List, Optional, Set
|
| 10 |
|
| 11 |
-
from
|
| 12 |
|
| 13 |
|
| 14 |
@dataclass
|
|
@@ -24,6 +24,7 @@ class Courtroom:
|
|
| 24 |
hearings_held: Count of hearings held
|
| 25 |
utilization_history: Track daily utilization rates
|
| 26 |
"""
|
|
|
|
| 27 |
courtroom_id: int
|
| 28 |
judge_id: Optional[str] = None
|
| 29 |
daily_capacity: int = DEFAULT_DAILY_CAPACITY
|
|
@@ -149,7 +150,9 @@ class Courtroom:
|
|
| 149 |
scheduled_count = len(self.get_daily_schedule(hearing_date))
|
| 150 |
return scheduled_count / self.daily_capacity if self.daily_capacity > 0 else 0.0
|
| 151 |
|
| 152 |
-
def record_daily_utilization(
|
|
|
|
|
|
|
| 153 |
"""Record actual utilization for a day.
|
| 154 |
|
| 155 |
Args:
|
|
@@ -157,15 +160,19 @@ class Courtroom:
|
|
| 157 |
actual_hearings: Number of hearings actually held (not adjourned)
|
| 158 |
"""
|
| 159 |
scheduled = len(self.get_daily_schedule(hearing_date))
|
| 160 |
-
utilization =
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
def get_average_utilization(self) -> float:
|
| 171 |
"""Calculate average utilization rate across all recorded days.
|
|
@@ -189,8 +196,7 @@ class Courtroom:
|
|
| 189 |
Returns:
|
| 190 |
Dict with counts and utilization stats
|
| 191 |
"""
|
| 192 |
-
days_in_range = [d for d in self.schedule.keys()
|
| 193 |
-
if start_date <= d <= end_date]
|
| 194 |
|
| 195 |
total_scheduled = sum(len(self.schedule[d]) for d in days_in_range)
|
| 196 |
days_with_hearings = len(days_in_range)
|
|
@@ -199,10 +205,14 @@ class Courtroom:
|
|
| 199 |
"courtroom_id": self.courtroom_id,
|
| 200 |
"days_with_hearings": days_with_hearings,
|
| 201 |
"total_cases_scheduled": total_scheduled,
|
| 202 |
-
"avg_cases_per_day": total_scheduled / days_with_hearings
|
|
|
|
|
|
|
| 203 |
"total_capacity": days_with_hearings * self.daily_capacity,
|
| 204 |
-
"utilization_rate": total_scheduled
|
| 205 |
-
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
def clear_schedule(self) -> None:
|
|
@@ -212,8 +222,10 @@ class Courtroom:
|
|
| 212 |
self.hearings_held = 0
|
| 213 |
|
| 214 |
def __repr__(self) -> str:
|
| 215 |
-
return (
|
| 216 |
-
|
|
|
|
|
|
|
| 217 |
|
| 218 |
def to_dict(self) -> dict:
|
| 219 |
"""Convert courtroom to dictionary for serialization."""
|
|
|
|
| 8 |
from datetime import date
|
| 9 |
from typing import Dict, List, Optional, Set
|
| 10 |
|
| 11 |
+
from src.data.config import DEFAULT_DAILY_CAPACITY
|
| 12 |
|
| 13 |
|
| 14 |
@dataclass
|
|
|
|
| 24 |
hearings_held: Count of hearings held
|
| 25 |
utilization_history: Track daily utilization rates
|
| 26 |
"""
|
| 27 |
+
|
| 28 |
courtroom_id: int
|
| 29 |
judge_id: Optional[str] = None
|
| 30 |
daily_capacity: int = DEFAULT_DAILY_CAPACITY
|
|
|
|
| 150 |
scheduled_count = len(self.get_daily_schedule(hearing_date))
|
| 151 |
return scheduled_count / self.daily_capacity if self.daily_capacity > 0 else 0.0
|
| 152 |
|
| 153 |
+
def record_daily_utilization(
|
| 154 |
+
self, hearing_date: date, actual_hearings: int
|
| 155 |
+
) -> None:
|
| 156 |
"""Record actual utilization for a day.
|
| 157 |
|
| 158 |
Args:
|
|
|
|
| 160 |
actual_hearings: Number of hearings actually held (not adjourned)
|
| 161 |
"""
|
| 162 |
scheduled = len(self.get_daily_schedule(hearing_date))
|
| 163 |
+
utilization = (
|
| 164 |
+
actual_hearings / self.daily_capacity if self.daily_capacity > 0 else 0.0
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
self.utilization_history.append(
|
| 168 |
+
{
|
| 169 |
+
"date": hearing_date,
|
| 170 |
+
"scheduled": scheduled,
|
| 171 |
+
"actual": actual_hearings,
|
| 172 |
+
"capacity": self.daily_capacity,
|
| 173 |
+
"utilization": utilization,
|
| 174 |
+
}
|
| 175 |
+
)
|
| 176 |
|
| 177 |
def get_average_utilization(self) -> float:
|
| 178 |
"""Calculate average utilization rate across all recorded days.
|
|
|
|
| 196 |
Returns:
|
| 197 |
Dict with counts and utilization stats
|
| 198 |
"""
|
| 199 |
+
days_in_range = [d for d in self.schedule.keys() if start_date <= d <= end_date]
|
|
|
|
| 200 |
|
| 201 |
total_scheduled = sum(len(self.schedule[d]) for d in days_in_range)
|
| 202 |
days_with_hearings = len(days_in_range)
|
|
|
|
| 205 |
"courtroom_id": self.courtroom_id,
|
| 206 |
"days_with_hearings": days_with_hearings,
|
| 207 |
"total_cases_scheduled": total_scheduled,
|
| 208 |
+
"avg_cases_per_day": total_scheduled / days_with_hearings
|
| 209 |
+
if days_with_hearings > 0
|
| 210 |
+
else 0,
|
| 211 |
"total_capacity": days_with_hearings * self.daily_capacity,
|
| 212 |
+
"utilization_rate": total_scheduled
|
| 213 |
+
/ (days_with_hearings * self.daily_capacity)
|
| 214 |
+
if days_with_hearings > 0
|
| 215 |
+
else 0,
|
| 216 |
}
|
| 217 |
|
| 218 |
def clear_schedule(self) -> None:
|
|
|
|
| 222 |
self.hearings_held = 0
|
| 223 |
|
| 224 |
def __repr__(self) -> str:
|
| 225 |
+
return (
|
| 226 |
+
f"Courtroom(id={self.courtroom_id}, judge={self.judge_id}, "
|
| 227 |
+
f"capacity={self.daily_capacity}, types={self.case_types})"
|
| 228 |
+
)
|
| 229 |
|
| 230 |
def to_dict(self) -> dict:
|
| 231 |
"""Convert courtroom to dictionary for serialization."""
|
{scheduler β src}/core/hearing.py
RENAMED
|
File without changes
|
{scheduler β src}/core/judge.py
RENAMED
|
File without changes
|
{scheduler β src}/core/policy.py
RENAMED
|
@@ -3,13 +3,14 @@
|
|
| 3 |
This module defines the abstract interface that all scheduling policies must implement.
|
| 4 |
Moved to core to avoid circular dependency between core.algorithm and simulation.policies.
|
| 5 |
"""
|
|
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
from abc import ABC, abstractmethod
|
| 9 |
from datetime import date
|
| 10 |
from typing import List
|
| 11 |
|
| 12 |
-
from
|
| 13 |
|
| 14 |
|
| 15 |
class SchedulerPolicy(ABC):
|
|
|
|
| 3 |
This module defines the abstract interface that all scheduling policies must implement.
|
| 4 |
Moved to core to avoid circular dependency between core.algorithm and simulation.policies.
|
| 5 |
"""
|
| 6 |
+
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
from abc import ABC, abstractmethod
|
| 10 |
from datetime import date
|
| 11 |
from typing import List
|
| 12 |
|
| 13 |
+
from src.core.case import Case
|
| 14 |
|
| 15 |
|
| 16 |
class SchedulerPolicy(ABC):
|
{scheduler β src}/core/ripeness.py
RENAMED
|
@@ -5,6 +5,7 @@ Unripe cases have bottlenecks (summons, dependencies, parties, documents).
|
|
| 5 |
|
| 6 |
Based on analysis of historical PurposeOfHearing patterns (see scripts/analyze_ripeness_patterns.py).
|
| 7 |
"""
|
|
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
from datetime import datetime, timedelta
|
|
@@ -12,7 +13,7 @@ from enum import Enum
|
|
| 12 |
from typing import TYPE_CHECKING
|
| 13 |
|
| 14 |
if TYPE_CHECKING:
|
| 15 |
-
from
|
| 16 |
|
| 17 |
|
| 18 |
class RipenessStatus(Enum):
|
|
@@ -59,19 +60,14 @@ class RipenessClassifier:
|
|
| 59 |
"""
|
| 60 |
|
| 61 |
# Stages that indicate case is ready for substantive hearing
|
| 62 |
-
RIPE_STAGES = [
|
| 63 |
-
"ARGUMENTS",
|
| 64 |
-
"EVIDENCE",
|
| 65 |
-
"ORDERS / JUDGMENT",
|
| 66 |
-
"FINAL DISPOSAL"
|
| 67 |
-
]
|
| 68 |
|
| 69 |
# Stages that indicate administrative/preliminary work
|
| 70 |
UNRIPE_STAGES = [
|
| 71 |
"PRE-ADMISSION",
|
| 72 |
"ADMISSION", # Most cases stuck here waiting for compliance
|
| 73 |
"FRAMING OF CHARGES",
|
| 74 |
-
"INTERLOCUTORY APPLICATION"
|
| 75 |
]
|
| 76 |
|
| 77 |
# Minimum evidence thresholds before declaring a case RIPE
|
|
@@ -91,7 +87,8 @@ class RipenessClassifier:
|
|
| 91 |
# Evidence the case has progressed in its current stage
|
| 92 |
days_in_stage = getattr(case, "days_in_stage", 0)
|
| 93 |
compliance_confirmed = (
|
| 94 |
-
case.current_stage not in cls.UNRIPE_STAGES
|
|
|
|
| 95 |
)
|
| 96 |
|
| 97 |
# Age-based maturity requirement
|
|
@@ -118,7 +115,9 @@ class RipenessClassifier:
|
|
| 118 |
return False
|
| 119 |
|
| 120 |
@classmethod
|
| 121 |
-
def classify(
|
|
|
|
|
|
|
| 122 |
"""Classify case ripeness status with bottleneck type.
|
| 123 |
|
| 124 |
Args:
|
|
@@ -177,7 +176,9 @@ class RipenessClassifier:
|
|
| 177 |
return RipenessStatus.UNKNOWN
|
| 178 |
|
| 179 |
@classmethod
|
| 180 |
-
def get_ripeness_priority(
|
|
|
|
|
|
|
| 181 |
"""Get priority adjustment based on ripeness.
|
| 182 |
|
| 183 |
Ripe cases should get judicial time priority over unripe cases
|
|
@@ -238,7 +239,9 @@ class RipenessClassifier:
|
|
| 238 |
return reasons.get(ripeness_status, "Unknown status")
|
| 239 |
|
| 240 |
@classmethod
|
| 241 |
-
def estimate_ripening_time(
|
|
|
|
|
|
|
| 242 |
"""Estimate time until case becomes ripe.
|
| 243 |
|
| 244 |
This is a heuristic based on bottleneck type and historical data.
|
|
|
|
| 5 |
|
| 6 |
Based on analysis of historical PurposeOfHearing patterns (see scripts/analyze_ripeness_patterns.py).
|
| 7 |
"""
|
| 8 |
+
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
from datetime import datetime, timedelta
|
|
|
|
| 13 |
from typing import TYPE_CHECKING
|
| 14 |
|
| 15 |
if TYPE_CHECKING:
|
| 16 |
+
from src.core.case import Case
|
| 17 |
|
| 18 |
|
| 19 |
class RipenessStatus(Enum):
|
|
|
|
| 60 |
"""
|
| 61 |
|
| 62 |
# Stages that indicate case is ready for substantive hearing
|
| 63 |
+
RIPE_STAGES = ["ARGUMENTS", "EVIDENCE", "ORDERS / JUDGMENT", "FINAL DISPOSAL"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
# Stages that indicate administrative/preliminary work
|
| 66 |
UNRIPE_STAGES = [
|
| 67 |
"PRE-ADMISSION",
|
| 68 |
"ADMISSION", # Most cases stuck here waiting for compliance
|
| 69 |
"FRAMING OF CHARGES",
|
| 70 |
+
"INTERLOCUTORY APPLICATION",
|
| 71 |
]
|
| 72 |
|
| 73 |
# Minimum evidence thresholds before declaring a case RIPE
|
|
|
|
| 87 |
# Evidence the case has progressed in its current stage
|
| 88 |
days_in_stage = getattr(case, "days_in_stage", 0)
|
| 89 |
compliance_confirmed = (
|
| 90 |
+
case.current_stage not in cls.UNRIPE_STAGES
|
| 91 |
+
or days_in_stage >= cls.MIN_STAGE_DAYS
|
| 92 |
)
|
| 93 |
|
| 94 |
# Age-based maturity requirement
|
|
|
|
| 115 |
return False
|
| 116 |
|
| 117 |
@classmethod
|
| 118 |
+
def classify(
|
| 119 |
+
cls, case: Case, current_date: datetime | None = None
|
| 120 |
+
) -> RipenessStatus:
|
| 121 |
"""Classify case ripeness status with bottleneck type.
|
| 122 |
|
| 123 |
Args:
|
|
|
|
| 176 |
return RipenessStatus.UNKNOWN
|
| 177 |
|
| 178 |
@classmethod
|
| 179 |
+
def get_ripeness_priority(
|
| 180 |
+
cls, case: Case, current_date: datetime | None = None
|
| 181 |
+
) -> float:
|
| 182 |
"""Get priority adjustment based on ripeness.
|
| 183 |
|
| 184 |
Ripe cases should get judicial time priority over unripe cases
|
|
|
|
| 239 |
return reasons.get(ripeness_status, "Unknown status")
|
| 240 |
|
| 241 |
@classmethod
|
| 242 |
+
def estimate_ripening_time(
|
| 243 |
+
cls, case: Case, current_date: datetime
|
| 244 |
+
) -> timedelta | None:
|
| 245 |
"""Estimate time until case becomes ripe.
|
| 246 |
|
| 247 |
This is a heuristic based on bottleneck type and historical data.
|
{scheduler β src}/dashboard/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/dashboard/pages/1_Data_And_Insights.py
RENAMED
|
@@ -17,7 +17,7 @@ import plotly.graph_objects as go
|
|
| 17 |
import streamlit as st
|
| 18 |
import streamlit.components.v1 as components
|
| 19 |
|
| 20 |
-
from
|
| 21 |
get_case_statistics,
|
| 22 |
load_cleaned_data,
|
| 23 |
load_cleaned_hearings,
|
|
@@ -798,7 +798,7 @@ If hearing_gap > 1.3 * stage_median_gap:
|
|
| 798 |
|
| 799 |
with col1:
|
| 800 |
st.markdown("**Classification Thresholds**")
|
| 801 |
-
from
|
| 802 |
|
| 803 |
thresholds = RipenessClassifier.get_current_thresholds()
|
| 804 |
|
|
@@ -895,7 +895,7 @@ UNRIPE cases: 0.7x priority
|
|
| 895 |
|
| 896 |
with col1:
|
| 897 |
st.markdown("**Default Case Type Distribution**")
|
| 898 |
-
from
|
| 899 |
|
| 900 |
dist_df = pd.DataFrame(
|
| 901 |
[
|
|
@@ -907,13 +907,13 @@ UNRIPE cases: 0.7x priority
|
|
| 907 |
st.caption("Based on historical distribution from EDA")
|
| 908 |
|
| 909 |
st.markdown("**Urgent Case Percentage**")
|
| 910 |
-
from
|
| 911 |
|
| 912 |
st.metric("Urgent Cases", f"{URGENT_CASE_PERCENTAGE * 100:.1f}%")
|
| 913 |
|
| 914 |
with col2:
|
| 915 |
st.markdown("**Monthly Seasonality Factors**")
|
| 916 |
-
from
|
| 917 |
|
| 918 |
season_df = pd.DataFrame(
|
| 919 |
[
|
|
|
|
| 17 |
import streamlit as st
|
| 18 |
import streamlit.components.v1 as components
|
| 19 |
|
| 20 |
+
from src.dashboard.utils import (
|
| 21 |
get_case_statistics,
|
| 22 |
load_cleaned_data,
|
| 23 |
load_cleaned_hearings,
|
|
|
|
| 798 |
|
| 799 |
with col1:
|
| 800 |
st.markdown("**Classification Thresholds**")
|
| 801 |
+
from src.core.ripeness import RipenessClassifier
|
| 802 |
|
| 803 |
thresholds = RipenessClassifier.get_current_thresholds()
|
| 804 |
|
|
|
|
| 895 |
|
| 896 |
with col1:
|
| 897 |
st.markdown("**Default Case Type Distribution**")
|
| 898 |
+
from src.data.config import CASE_TYPE_DISTRIBUTION
|
| 899 |
|
| 900 |
dist_df = pd.DataFrame(
|
| 901 |
[
|
|
|
|
| 907 |
st.caption("Based on historical distribution from EDA")
|
| 908 |
|
| 909 |
st.markdown("**Urgent Case Percentage**")
|
| 910 |
+
from src.data.config import URGENT_CASE_PERCENTAGE
|
| 911 |
|
| 912 |
st.metric("Urgent Cases", f"{URGENT_CASE_PERCENTAGE * 100:.1f}%")
|
| 913 |
|
| 914 |
with col2:
|
| 915 |
st.markdown("**Monthly Seasonality Factors**")
|
| 916 |
+
from src.data.config import MONTHLY_SEASONALITY
|
| 917 |
|
| 918 |
season_df = pd.DataFrame(
|
| 919 |
[
|
{scheduler β src}/dashboard/pages/2_Ripeness_Classifier.py
RENAMED
|
@@ -12,9 +12,9 @@ import pandas as pd
|
|
| 12 |
import plotly.express as px
|
| 13 |
import streamlit as st
|
| 14 |
|
| 15 |
-
from
|
| 16 |
-
from
|
| 17 |
-
from
|
| 18 |
attach_history_to_cases,
|
| 19 |
load_generated_cases,
|
| 20 |
load_generated_hearings,
|
|
|
|
| 12 |
import plotly.express as px
|
| 13 |
import streamlit as st
|
| 14 |
|
| 15 |
+
from src.core.case import Case, CaseStatus
|
| 16 |
+
from src.core.ripeness import RipenessClassifier, RipenessStatus
|
| 17 |
+
from src.dashboard.utils.data_loader import (
|
| 18 |
attach_history_to_cases,
|
| 19 |
load_generated_cases,
|
| 20 |
load_generated_hearings,
|
{scheduler β src}/dashboard/pages/3_Simulation_Workflow.py
RENAMED
|
@@ -17,7 +17,7 @@ import plotly.express as px
|
|
| 17 |
import streamlit as st
|
| 18 |
|
| 19 |
from cli import __version__ as CLI_VERSION
|
| 20 |
-
from
|
| 21 |
|
| 22 |
# Page configuration
|
| 23 |
st.set_page_config(
|
|
@@ -184,7 +184,7 @@ if st.session_state.workflow_step == 1:
|
|
| 184 |
st.success(f"Total: {total_pct}%")
|
| 185 |
else:
|
| 186 |
st.info("Using default distribution from historical data")
|
| 187 |
-
from
|
| 188 |
build_case_type_distribution,
|
| 189 |
merge_with_default_config,
|
| 190 |
)
|
|
@@ -205,7 +205,7 @@ if st.session_state.workflow_step == 1:
|
|
| 205 |
with st.spinner(f"Generating {n_cases:,} cases..."):
|
| 206 |
try:
|
| 207 |
from cli.config import load_generate_config
|
| 208 |
-
from
|
| 209 |
|
| 210 |
DEFAULT_GENERATE_CFG_PATH = Path("configs/generate.sample.toml")
|
| 211 |
config_from_file = None
|
|
@@ -228,7 +228,7 @@ if st.session_state.workflow_step == 1:
|
|
| 228 |
|
| 229 |
case_type_dist_dict = None
|
| 230 |
if use_custom_dist:
|
| 231 |
-
from
|
| 232 |
build_case_type_distribution,
|
| 233 |
)
|
| 234 |
|
|
@@ -484,7 +484,7 @@ elif st.session_state.workflow_step == 3:
|
|
| 484 |
with st.spinner("Running simulation... This may take several minutes."):
|
| 485 |
try:
|
| 486 |
from cli.config import load_simulate_config
|
| 487 |
-
from
|
| 488 |
merge_simulation_config,
|
| 489 |
run_simulation_dashboard,
|
| 490 |
)
|
|
|
|
| 17 |
import streamlit as st
|
| 18 |
|
| 19 |
from cli import __version__ as CLI_VERSION
|
| 20 |
+
from src.output.cause_list import CauseListGenerator
|
| 21 |
|
| 22 |
# Page configuration
|
| 23 |
st.set_page_config(
|
|
|
|
| 184 |
st.success(f"Total: {total_pct}%")
|
| 185 |
else:
|
| 186 |
st.info("Using default distribution from historical data")
|
| 187 |
+
from src.dashboard.utils.ui_input_parser import (
|
| 188 |
build_case_type_distribution,
|
| 189 |
merge_with_default_config,
|
| 190 |
)
|
|
|
|
| 205 |
with st.spinner(f"Generating {n_cases:,} cases..."):
|
| 206 |
try:
|
| 207 |
from cli.config import load_generate_config
|
| 208 |
+
from src.data.case_generator import CaseGenerator
|
| 209 |
|
| 210 |
DEFAULT_GENERATE_CFG_PATH = Path("configs/generate.sample.toml")
|
| 211 |
config_from_file = None
|
|
|
|
| 228 |
|
| 229 |
case_type_dist_dict = None
|
| 230 |
if use_custom_dist:
|
| 231 |
+
from src.dashboard.utils.ui_input_parser import (
|
| 232 |
build_case_type_distribution,
|
| 233 |
)
|
| 234 |
|
|
|
|
| 484 |
with st.spinner("Running simulation... This may take several minutes."):
|
| 485 |
try:
|
| 486 |
from cli.config import load_simulate_config
|
| 487 |
+
from src.dashboard.utils.simulation_runner import (
|
| 488 |
merge_simulation_config,
|
| 489 |
run_simulation_dashboard,
|
| 490 |
)
|
{scheduler β src}/dashboard/pages/4_Cause_Lists_And_Overrides.py
RENAMED
|
File without changes
|
{scheduler β src}/dashboard/pages/6_Analytics_And_Reports.py
RENAMED
|
File without changes
|
{scheduler β src}/dashboard/utils/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/dashboard/utils/data_loader.py
RENAMED
|
@@ -13,8 +13,8 @@ import pandas as pd
|
|
| 13 |
import polars as pl
|
| 14 |
import streamlit as st
|
| 15 |
|
| 16 |
-
from
|
| 17 |
-
from
|
| 18 |
|
| 19 |
|
| 20 |
@st.cache_data(ttl=3600)
|
|
@@ -30,7 +30,9 @@ def load_param_loader(params_dir: str = None) -> dict[str, Any]:
|
|
| 30 |
if params_dir is None:
|
| 31 |
# Find latest EDA output directory
|
| 32 |
figures_dir = Path("reports/figures")
|
| 33 |
-
version_dirs = [
|
|
|
|
|
|
|
| 34 |
if version_dirs:
|
| 35 |
latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime)
|
| 36 |
params_dir = str(latest_dir / "params")
|
|
@@ -105,7 +107,9 @@ def load_cleaned_hearings(data_path: str = None) -> pd.DataFrame:
|
|
| 105 |
if data_path is None:
|
| 106 |
# Find latest EDA output directory
|
| 107 |
figures_dir = Path("reports/figures")
|
| 108 |
-
version_dirs = [
|
|
|
|
|
|
|
| 109 |
if version_dirs:
|
| 110 |
latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime)
|
| 111 |
# Try parquet first, then CSV
|
|
@@ -149,7 +153,9 @@ def load_cleaned_data(data_path: str = None) -> pd.DataFrame:
|
|
| 149 |
if data_path is None:
|
| 150 |
# Find latest EDA output directory
|
| 151 |
figures_dir = Path("reports/figures")
|
| 152 |
-
version_dirs = [
|
|
|
|
|
|
|
| 153 |
if version_dirs:
|
| 154 |
latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime)
|
| 155 |
# Try parquet first, then CSV
|
|
@@ -282,7 +288,9 @@ def load_generated_cases(cases_path: str = "data/generated/cases.csv") -> list:
|
|
| 282 |
|
| 283 |
|
| 284 |
@st.cache_data(ttl=3600)
|
| 285 |
-
def load_generated_hearings(
|
|
|
|
|
|
|
| 286 |
"""Load generated hearings history as a flat DataFrame.
|
| 287 |
|
| 288 |
Args:
|
|
@@ -359,12 +367,16 @@ def load_generated_hearings(hearings_path: str = "data/generated/hearings.csv")
|
|
| 359 |
chosen = next((c for c in candidates if c.exists()), None)
|
| 360 |
if chosen is None:
|
| 361 |
# Don't warn loudly; simply return empty frame for graceful fallback
|
| 362 |
-
return pd.DataFrame(
|
|
|
|
|
|
|
| 363 |
|
| 364 |
try:
|
| 365 |
df = pd.read_csv(chosen)
|
| 366 |
except Exception:
|
| 367 |
-
return pd.DataFrame(
|
|
|
|
|
|
|
| 368 |
|
| 369 |
# Normalize columns
|
| 370 |
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:
|
|
| 405 |
if hist:
|
| 406 |
# sort by date just in case
|
| 407 |
hist_sorted = sorted(
|
| 408 |
-
hist,
|
|
|
|
| 409 |
)
|
| 410 |
c.history = hist_sorted
|
| 411 |
# Update aggregates from history if missing
|
|
@@ -433,15 +446,21 @@ def get_case_statistics(df: pd.DataFrame) -> dict[str, Any]:
|
|
| 433 |
|
| 434 |
stats = {
|
| 435 |
"total_cases": len(df),
|
| 436 |
-
"case_types": df["CaseType"].value_counts().to_dict()
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
}
|
| 439 |
|
| 440 |
# Adjournment rate if applicable
|
| 441 |
if "Outcome" in df.columns:
|
| 442 |
total_hearings = len(df)
|
| 443 |
adjourned = len(df[df["Outcome"] == "ADJOURNED"])
|
| 444 |
-
stats["adjournment_rate"] =
|
|
|
|
|
|
|
| 445 |
|
| 446 |
return stats
|
| 447 |
|
|
@@ -458,7 +477,9 @@ def get_data_status() -> dict[str, bool]:
|
|
| 458 |
# Find latest EDA output directory
|
| 459 |
figures_dir = Path("reports/figures")
|
| 460 |
if figures_dir.exists():
|
| 461 |
-
version_dirs = [
|
|
|
|
|
|
|
| 462 |
if version_dirs:
|
| 463 |
latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime)
|
| 464 |
cleaned_data_exists = (latest_dir / "cases_clean.parquet").exists()
|
|
|
|
| 13 |
import polars as pl
|
| 14 |
import streamlit as st
|
| 15 |
|
| 16 |
+
from src.data.case_generator import CaseGenerator
|
| 17 |
+
from src.data.param_loader import ParameterLoader
|
| 18 |
|
| 19 |
|
| 20 |
@st.cache_data(ttl=3600)
|
|
|
|
| 30 |
if params_dir is None:
|
| 31 |
# Find latest EDA output directory
|
| 32 |
figures_dir = Path("reports/figures")
|
| 33 |
+
version_dirs = [
|
| 34 |
+
d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")
|
| 35 |
+
]
|
| 36 |
if version_dirs:
|
| 37 |
latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime)
|
| 38 |
params_dir = str(latest_dir / "params")
|
|
|
|
| 107 |
if data_path is None:
|
| 108 |
# Find latest EDA output directory
|
| 109 |
figures_dir = Path("reports/figures")
|
| 110 |
+
version_dirs = [
|
| 111 |
+
d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")
|
| 112 |
+
]
|
| 113 |
if version_dirs:
|
| 114 |
latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime)
|
| 115 |
# Try parquet first, then CSV
|
|
|
|
| 153 |
if data_path is None:
|
| 154 |
# Find latest EDA output directory
|
| 155 |
figures_dir = Path("reports/figures")
|
| 156 |
+
version_dirs = [
|
| 157 |
+
d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")
|
| 158 |
+
]
|
| 159 |
if version_dirs:
|
| 160 |
latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime)
|
| 161 |
# Try parquet first, then CSV
|
|
|
|
| 288 |
|
| 289 |
|
| 290 |
@st.cache_data(ttl=3600)
|
| 291 |
+
def load_generated_hearings(
|
| 292 |
+
hearings_path: str = "data/generated/hearings.csv",
|
| 293 |
+
) -> pd.DataFrame:
|
| 294 |
"""Load generated hearings history as a flat DataFrame.
|
| 295 |
|
| 296 |
Args:
|
|
|
|
| 367 |
chosen = next((c for c in candidates if c.exists()), None)
|
| 368 |
if chosen is None:
|
| 369 |
# Don't warn loudly; simply return empty frame for graceful fallback
|
| 370 |
+
return pd.DataFrame(
|
| 371 |
+
columns=["case_id", "date", "stage", "purpose", "was_heard", "event"]
|
| 372 |
+
)
|
| 373 |
|
| 374 |
try:
|
| 375 |
df = pd.read_csv(chosen)
|
| 376 |
except Exception:
|
| 377 |
+
return pd.DataFrame(
|
| 378 |
+
columns=["case_id", "date", "stage", "purpose", "was_heard", "event"]
|
| 379 |
+
)
|
| 380 |
|
| 381 |
# Normalize columns
|
| 382 |
expected_cols = ["case_id", "date", "stage", "purpose", "was_heard", "event"]
|
|
|
|
| 417 |
if hist:
|
| 418 |
# sort by date just in case
|
| 419 |
hist_sorted = sorted(
|
| 420 |
+
hist,
|
| 421 |
+
key=lambda e: (e.get("date") or getattr(c, "filed_date", None) or 0),
|
| 422 |
)
|
| 423 |
c.history = hist_sorted
|
| 424 |
# Update aggregates from history if missing
|
|
|
|
| 446 |
|
| 447 |
stats = {
|
| 448 |
"total_cases": len(df),
|
| 449 |
+
"case_types": df["CaseType"].value_counts().to_dict()
|
| 450 |
+
if "CaseType" in df
|
| 451 |
+
else {},
|
| 452 |
+
"stages": df["Remappedstages"].value_counts().to_dict()
|
| 453 |
+
if "Remappedstages" in df
|
| 454 |
+
else {},
|
| 455 |
}
|
| 456 |
|
| 457 |
# Adjournment rate if applicable
|
| 458 |
if "Outcome" in df.columns:
|
| 459 |
total_hearings = len(df)
|
| 460 |
adjourned = len(df[df["Outcome"] == "ADJOURNED"])
|
| 461 |
+
stats["adjournment_rate"] = (
|
| 462 |
+
adjourned / total_hearings if total_hearings > 0 else 0
|
| 463 |
+
)
|
| 464 |
|
| 465 |
return stats
|
| 466 |
|
|
|
|
| 477 |
# Find latest EDA output directory
|
| 478 |
figures_dir = Path("reports/figures")
|
| 479 |
if figures_dir.exists():
|
| 480 |
+
version_dirs = [
|
| 481 |
+
d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")
|
| 482 |
+
]
|
| 483 |
if version_dirs:
|
| 484 |
latest_dir = max(version_dirs, key=lambda p: p.stat().st_mtime)
|
| 485 |
cleaned_data_exists = (latest_dir / "cases_clean.parquet").exists()
|
{scheduler β src}/dashboard/utils/simulation_runner.py
RENAMED
|
@@ -3,10 +3,10 @@ from pathlib import Path
|
|
| 3 |
from datetime import date
|
| 4 |
|
| 5 |
from cli.config import SimulateConfig
|
| 6 |
-
from
|
| 7 |
-
from
|
| 8 |
-
from
|
| 9 |
-
from
|
| 10 |
|
| 11 |
|
| 12 |
def merge_simulation_config(
|
|
|
|
| 3 |
from datetime import date
|
| 4 |
|
| 5 |
from cli.config import SimulateConfig
|
| 6 |
+
from src.data.case_generator import CaseGenerator
|
| 7 |
+
from src.simulation.engine import CourtSim, CourtSimConfig
|
| 8 |
+
from src.core.case import CaseStatus
|
| 9 |
+
from src.metrics.basic import gini
|
| 10 |
|
| 11 |
|
| 12 |
def merge_simulation_config(
|
{scheduler β src}/dashboard/utils/ui_input_parser.py
RENAMED
|
File without changes
|
{scheduler β src}/data/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/data/case_generator.py
RENAMED
|
@@ -18,14 +18,14 @@ from datetime import date, timedelta
|
|
| 18 |
from pathlib import Path
|
| 19 |
from typing import Iterable, List, Tuple
|
| 20 |
|
| 21 |
-
from
|
| 22 |
-
from
|
| 23 |
CASE_TYPE_DISTRIBUTION,
|
| 24 |
MONTHLY_SEASONALITY,
|
| 25 |
URGENT_CASE_PERCENTAGE,
|
| 26 |
)
|
| 27 |
-
from
|
| 28 |
-
from
|
| 29 |
|
| 30 |
|
| 31 |
def _month_iter(start: date, end: date) -> Iterable[Tuple[int, int]]:
|
|
@@ -254,7 +254,11 @@ class CaseGenerator:
|
|
| 254 |
if random.random() < 0.4
|
| 255 |
else random.choice(ripe_purposes)
|
| 256 |
)
|
| 257 |
-
elif init_stage in [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
purpose = random.choice(ripe_purposes)
|
| 259 |
else:
|
| 260 |
purpose = (
|
|
@@ -284,7 +288,9 @@ class CaseGenerator:
|
|
| 284 |
# Update aggregates from generated history
|
| 285 |
c.last_hearing_date = last_hearing_date
|
| 286 |
c.days_since_last_hearing = days_before_end
|
| 287 |
-
c.last_hearing_purpose =
|
|
|
|
|
|
|
| 288 |
|
| 289 |
cases.append(c)
|
| 290 |
|
|
|
|
| 18 |
from pathlib import Path
|
| 19 |
from typing import Iterable, List, Tuple
|
| 20 |
|
| 21 |
+
from src.core.case import Case
|
| 22 |
+
from src.data.config import (
|
| 23 |
CASE_TYPE_DISTRIBUTION,
|
| 24 |
MONTHLY_SEASONALITY,
|
| 25 |
URGENT_CASE_PERCENTAGE,
|
| 26 |
)
|
| 27 |
+
from src.data.param_loader import load_parameters
|
| 28 |
+
from src.utils.calendar import CourtCalendar
|
| 29 |
|
| 30 |
|
| 31 |
def _month_iter(start: date, end: date) -> Iterable[Tuple[int, int]]:
|
|
|
|
| 254 |
if random.random() < 0.4
|
| 255 |
else random.choice(ripe_purposes)
|
| 256 |
)
|
| 257 |
+
elif init_stage in [
|
| 258 |
+
"ARGUMENTS",
|
| 259 |
+
"ORDERS / JUDGMENT",
|
| 260 |
+
"FINAL DISPOSAL",
|
| 261 |
+
]:
|
| 262 |
purpose = random.choice(ripe_purposes)
|
| 263 |
else:
|
| 264 |
purpose = (
|
|
|
|
| 288 |
# Update aggregates from generated history
|
| 289 |
c.last_hearing_date = last_hearing_date
|
| 290 |
c.days_since_last_hearing = days_before_end
|
| 291 |
+
c.last_hearing_purpose = (
|
| 292 |
+
c.history[-1]["purpose"] if c.history else None
|
| 293 |
+
)
|
| 294 |
|
| 295 |
cases.append(c)
|
| 296 |
|
{scheduler β src}/data/config.py
RENAMED
|
File without changes
|
{scheduler β src}/data/param_loader.py
RENAMED
|
@@ -10,7 +10,7 @@ from typing import Dict, List, Optional
|
|
| 10 |
|
| 11 |
import pandas as pd
|
| 12 |
|
| 13 |
-
from
|
| 14 |
|
| 15 |
|
| 16 |
class ParameterLoader:
|
|
@@ -36,9 +36,15 @@ class ParameterLoader:
|
|
| 36 |
self._case_type_summary: Optional[pd.DataFrame] = None
|
| 37 |
self._transition_entropy: Optional[pd.DataFrame] = None
|
| 38 |
# caches
|
| 39 |
-
self._duration_map: Optional[Dict[str, Dict[str, float]]] =
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
@property
|
| 44 |
def transition_probs(self) -> pd.DataFrame:
|
|
@@ -98,7 +104,9 @@ class ParameterLoader:
|
|
| 98 |
DataFrame with STAGE_TO and p columns
|
| 99 |
"""
|
| 100 |
df = self.transition_probs
|
| 101 |
-
return df[df["STAGE_FROM"] == stage_from][["STAGE_TO", "p"]].reset_index(
|
|
|
|
|
|
|
| 102 |
|
| 103 |
def get_stage_transitions_fast(self, stage_from: str) -> List[tuple]:
|
| 104 |
"""Fast lookup: returns list of (stage_to, cum_p)."""
|
|
@@ -287,11 +295,11 @@ class ParameterLoader:
|
|
| 287 |
df = df[df["STAGE_FROM"].notna() & df["STAGE_TO"].notna()]
|
| 288 |
df["STAGE_FROM"] = df["STAGE_FROM"].astype(str)
|
| 289 |
df["STAGE_TO"] = df["STAGE_TO"].astype(str)
|
| 290 |
-
stages = sorted(set(df["STAGE_FROM"]).union(set(df["STAGE_TO"]))
|
| 291 |
idx = {s: i for i, s in enumerate(stages)}
|
| 292 |
n = len(stages)
|
| 293 |
# build dense row-stochastic matrix
|
| 294 |
-
P = [[0.0]*n for _ in range(n)]
|
| 295 |
for _, row in df.iterrows():
|
| 296 |
i = idx[str(row["STAGE_FROM"])]
|
| 297 |
j = idx[str(row["STAGE_TO"])]
|
|
@@ -300,26 +308,26 @@ class ParameterLoader:
|
|
| 300 |
for i in range(n):
|
| 301 |
s = sum(P[i])
|
| 302 |
if s < 0.999:
|
| 303 |
-
P[i][i] +=
|
| 304 |
elif s > 1.001:
|
| 305 |
# normalize if slightly over
|
| 306 |
-
P[i] = [v/s for v in P[i]]
|
| 307 |
# power iteration
|
| 308 |
-
pi = [1.0/n]*n
|
| 309 |
for _ in range(200):
|
| 310 |
-
new = [0.0]*n
|
| 311 |
for j in range(n):
|
| 312 |
acc = 0.0
|
| 313 |
for i in range(n):
|
| 314 |
-
acc += pi[i]*P[i][j]
|
| 315 |
new[j] = acc
|
| 316 |
# normalize
|
| 317 |
z = sum(new)
|
| 318 |
if z == 0:
|
| 319 |
break
|
| 320 |
-
new = [v/z for v in new]
|
| 321 |
# check convergence
|
| 322 |
-
if sum(abs(new[k]-pi[k]) for k in range(n)) < 1e-9:
|
| 323 |
pi = new
|
| 324 |
break
|
| 325 |
pi = new
|
|
|
|
| 10 |
|
| 11 |
import pandas as pd
|
| 12 |
|
| 13 |
+
from src.data.config import get_latest_params_dir
|
| 14 |
|
| 15 |
|
| 16 |
class ParameterLoader:
|
|
|
|
| 36 |
self._case_type_summary: Optional[pd.DataFrame] = None
|
| 37 |
self._transition_entropy: Optional[pd.DataFrame] = None
|
| 38 |
# caches
|
| 39 |
+
self._duration_map: Optional[Dict[str, Dict[str, float]]] = (
|
| 40 |
+
None # stage -> {"median": x, "p90": y}
|
| 41 |
+
)
|
| 42 |
+
self._transitions_map: Optional[Dict[str, List[tuple]]] = (
|
| 43 |
+
None # stage_from -> [(stage_to, cum_p), ...]
|
| 44 |
+
)
|
| 45 |
+
self._adj_map: Optional[Dict[str, Dict[str, float]]] = (
|
| 46 |
+
None # stage -> {case_type: p_adj}
|
| 47 |
+
)
|
| 48 |
|
| 49 |
@property
|
| 50 |
def transition_probs(self) -> pd.DataFrame:
|
|
|
|
| 104 |
DataFrame with STAGE_TO and p columns
|
| 105 |
"""
|
| 106 |
df = self.transition_probs
|
| 107 |
+
return df[df["STAGE_FROM"] == stage_from][["STAGE_TO", "p"]].reset_index(
|
| 108 |
+
drop=True
|
| 109 |
+
)
|
| 110 |
|
| 111 |
def get_stage_transitions_fast(self, stage_from: str) -> List[tuple]:
|
| 112 |
"""Fast lookup: returns list of (stage_to, cum_p)."""
|
|
|
|
| 295 |
df = df[df["STAGE_FROM"].notna() & df["STAGE_TO"].notna()]
|
| 296 |
df["STAGE_FROM"] = df["STAGE_FROM"].astype(str)
|
| 297 |
df["STAGE_TO"] = df["STAGE_TO"].astype(str)
|
| 298 |
+
stages = sorted(set(df["STAGE_FROM"]).union(set(df["STAGE_TO"])))
|
| 299 |
idx = {s: i for i, s in enumerate(stages)}
|
| 300 |
n = len(stages)
|
| 301 |
# build dense row-stochastic matrix
|
| 302 |
+
P = [[0.0] * n for _ in range(n)]
|
| 303 |
for _, row in df.iterrows():
|
| 304 |
i = idx[str(row["STAGE_FROM"])]
|
| 305 |
j = idx[str(row["STAGE_TO"])]
|
|
|
|
| 308 |
for i in range(n):
|
| 309 |
s = sum(P[i])
|
| 310 |
if s < 0.999:
|
| 311 |
+
P[i][i] += 1.0 - s
|
| 312 |
elif s > 1.001:
|
| 313 |
# normalize if slightly over
|
| 314 |
+
P[i] = [v / s for v in P[i]]
|
| 315 |
# power iteration
|
| 316 |
+
pi = [1.0 / n] * n
|
| 317 |
for _ in range(200):
|
| 318 |
+
new = [0.0] * n
|
| 319 |
for j in range(n):
|
| 320 |
acc = 0.0
|
| 321 |
for i in range(n):
|
| 322 |
+
acc += pi[i] * P[i][j]
|
| 323 |
new[j] = acc
|
| 324 |
# normalize
|
| 325 |
z = sum(new)
|
| 326 |
if z == 0:
|
| 327 |
break
|
| 328 |
+
new = [v / z for v in new]
|
| 329 |
# check convergence
|
| 330 |
+
if sum(abs(new[k] - pi[k]) for k in range(n)) < 1e-9:
|
| 331 |
pi = new
|
| 332 |
break
|
| 333 |
pi = new
|
{scheduler β src}/metrics/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/metrics/basic.py
RENAMED
|
File without changes
|
{scheduler β src}/monitoring/__init__.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Monitoring and feedback loop components."""
|
| 2 |
|
| 3 |
-
from
|
| 4 |
-
from
|
| 5 |
|
| 6 |
__all__ = [
|
| 7 |
"RipenessMetrics",
|
|
|
|
| 1 |
"""Monitoring and feedback loop components."""
|
| 2 |
|
| 3 |
+
from src.monitoring.ripeness_calibrator import RipenessCalibrator, ThresholdAdjustment
|
| 4 |
+
from src.monitoring.ripeness_metrics import RipenessMetrics, RipenessPrediction
|
| 5 |
|
| 6 |
__all__ = [
|
| 7 |
"RipenessMetrics",
|
{scheduler β src}/monitoring/ripeness_calibrator.py
RENAMED
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from typing import Optional
|
| 11 |
|
| 12 |
-
from
|
| 13 |
|
| 14 |
|
| 15 |
@dataclass
|
|
@@ -53,7 +53,8 @@ class RipenessCalibrator:
|
|
| 53 |
|
| 54 |
# Default current thresholds if not provided
|
| 55 |
if current_thresholds is None:
|
| 56 |
-
from
|
|
|
|
| 57 |
current_thresholds = {
|
| 58 |
"MIN_SERVICE_HEARINGS": RipenessClassifier.MIN_SERVICE_HEARINGS,
|
| 59 |
"MIN_STAGE_DAYS": RipenessClassifier.MIN_STAGE_DAYS,
|
|
@@ -62,88 +63,100 @@ class RipenessCalibrator:
|
|
| 62 |
|
| 63 |
# Check if we have enough data
|
| 64 |
if accuracy["completed_predictions"] < 50:
|
| 65 |
-
print(
|
|
|
|
|
|
|
| 66 |
return adjustments
|
| 67 |
|
| 68 |
# Rule 1: High false positive rate -> increase MIN_SERVICE_HEARINGS
|
| 69 |
if accuracy["false_positive_rate"] > cls.HIGH_FALSE_POSITIVE_THRESHOLD:
|
| 70 |
current_hearings = current_thresholds.get("MIN_SERVICE_HEARINGS", 1)
|
| 71 |
suggested_hearings = current_hearings + 1
|
| 72 |
-
adjustments.append(
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
| 83 |
|
| 84 |
# Rule 2: High false negative rate -> decrease MIN_STAGE_DAYS
|
| 85 |
if accuracy["false_negative_rate"] > cls.HIGH_FALSE_NEGATIVE_THRESHOLD:
|
| 86 |
current_days = current_thresholds.get("MIN_STAGE_DAYS", 7)
|
| 87 |
suggested_days = max(3, current_days - 2) # Don't go below 3 days
|
| 88 |
-
adjustments.append(
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
|
| 100 |
# Rule 3: Low UNKNOWN rate -> system too confident, add uncertainty
|
| 101 |
if accuracy["unknown_rate"] < cls.LOW_UNKNOWN_THRESHOLD:
|
| 102 |
current_age = current_thresholds.get("MIN_CASE_AGE_DAYS", 14)
|
| 103 |
suggested_age = current_age + 7
|
| 104 |
-
adjustments.append(
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
| 115 |
|
| 116 |
# Rule 4: Low RIPE precision -> more conservative RIPE classification
|
| 117 |
if accuracy["ripe_precision"] < cls.LOW_RIPE_PRECISION_THRESHOLD:
|
| 118 |
current_hearings = current_thresholds.get("MIN_SERVICE_HEARINGS", 1)
|
| 119 |
suggested_hearings = current_hearings + 1
|
| 120 |
-
adjustments.append(
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
|
| 132 |
# Rule 5: Low UNRIPE recall -> missing bottlenecks
|
| 133 |
if accuracy["unripe_recall"] < cls.LOW_UNRIPE_RECALL_THRESHOLD:
|
| 134 |
current_days = current_thresholds.get("MIN_STAGE_DAYS", 7)
|
| 135 |
suggested_days = current_days + 3
|
| 136 |
-
adjustments.append(
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
| 147 |
|
| 148 |
# Deduplicate adjustments (same threshold suggested multiple times)
|
| 149 |
deduplicated = cls._deduplicate_adjustments(adjustments)
|
|
@@ -165,11 +178,19 @@ class RipenessCalibrator:
|
|
| 165 |
existing = threshold_map[adj.threshold_name]
|
| 166 |
confidence_order = {"high": 3, "medium": 2, "low": 1}
|
| 167 |
|
| 168 |
-
if
|
|
|
|
|
|
|
|
|
|
| 169 |
threshold_map[adj.threshold_name] = adj
|
| 170 |
-
elif
|
|
|
|
|
|
|
|
|
|
| 171 |
# Same confidence - keep larger adjustment magnitude
|
| 172 |
-
existing_delta = abs(
|
|
|
|
|
|
|
| 173 |
new_delta = abs(adj.suggested_value - adj.current_value)
|
| 174 |
if new_delta > existing_delta:
|
| 175 |
threshold_map[adj.threshold_name] = adj
|
|
@@ -211,36 +232,44 @@ class RipenessCalibrator:
|
|
| 211 |
]
|
| 212 |
|
| 213 |
if not adjustments:
|
| 214 |
-
lines.extend(
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
| 220 |
else:
|
| 221 |
-
lines.extend(
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
| 225 |
|
| 226 |
for i, adj in enumerate(adjustments, 1):
|
| 227 |
-
lines.extend(
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
"",
|
| 234 |
-
]
|
| 235 |
-
|
| 236 |
-
lines.extend([
|
| 237 |
-
"Implementation:",
|
| 238 |
-
" 1. Review suggested adjustments",
|
| 239 |
-
" 2. Apply using: RipenessClassifier.set_thresholds(new_values)",
|
| 240 |
-
" 3. Re-run simulation to validate improvements",
|
| 241 |
-
" 4. Compare new metrics with baseline",
|
| 242 |
-
"",
|
| 243 |
-
])
|
| 244 |
|
| 245 |
report = "\n".join(lines)
|
| 246 |
|
|
@@ -272,7 +301,8 @@ class RipenessCalibrator:
|
|
| 272 |
new_thresholds[adj.threshold_name] = adj.suggested_value
|
| 273 |
|
| 274 |
if auto_apply:
|
| 275 |
-
from
|
|
|
|
| 276 |
RipenessClassifier.set_thresholds(new_thresholds)
|
| 277 |
print(f"Applied {len(adjustments)} threshold adjustments")
|
| 278 |
|
|
|
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from typing import Optional
|
| 11 |
|
| 12 |
+
from src.monitoring.ripeness_metrics import RipenessMetrics
|
| 13 |
|
| 14 |
|
| 15 |
@dataclass
|
|
|
|
| 53 |
|
| 54 |
# Default current thresholds if not provided
|
| 55 |
if current_thresholds is None:
|
| 56 |
+
from src.core.ripeness import RipenessClassifier
|
| 57 |
+
|
| 58 |
current_thresholds = {
|
| 59 |
"MIN_SERVICE_HEARINGS": RipenessClassifier.MIN_SERVICE_HEARINGS,
|
| 60 |
"MIN_STAGE_DAYS": RipenessClassifier.MIN_STAGE_DAYS,
|
|
|
|
| 63 |
|
| 64 |
# Check if we have enough data
|
| 65 |
if accuracy["completed_predictions"] < 50:
|
| 66 |
+
print(
|
| 67 |
+
"Warning: Insufficient data for calibration (need at least 50 predictions)"
|
| 68 |
+
)
|
| 69 |
return adjustments
|
| 70 |
|
| 71 |
# Rule 1: High false positive rate -> increase MIN_SERVICE_HEARINGS
|
| 72 |
if accuracy["false_positive_rate"] > cls.HIGH_FALSE_POSITIVE_THRESHOLD:
|
| 73 |
current_hearings = current_thresholds.get("MIN_SERVICE_HEARINGS", 1)
|
| 74 |
suggested_hearings = current_hearings + 1
|
| 75 |
+
adjustments.append(
|
| 76 |
+
ThresholdAdjustment(
|
| 77 |
+
threshold_name="MIN_SERVICE_HEARINGS",
|
| 78 |
+
current_value=current_hearings,
|
| 79 |
+
suggested_value=suggested_hearings,
|
| 80 |
+
reason=(
|
| 81 |
+
f"False positive rate {accuracy['false_positive_rate']:.1%} exceeds "
|
| 82 |
+
f"{cls.HIGH_FALSE_POSITIVE_THRESHOLD:.0%}. Cases marked RIPE are adjourning. "
|
| 83 |
+
f"Require more hearings as evidence of readiness."
|
| 84 |
+
),
|
| 85 |
+
confidence="high",
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
|
| 89 |
# Rule 2: High false negative rate -> decrease MIN_STAGE_DAYS
|
| 90 |
if accuracy["false_negative_rate"] > cls.HIGH_FALSE_NEGATIVE_THRESHOLD:
|
| 91 |
current_days = current_thresholds.get("MIN_STAGE_DAYS", 7)
|
| 92 |
suggested_days = max(3, current_days - 2) # Don't go below 3 days
|
| 93 |
+
adjustments.append(
|
| 94 |
+
ThresholdAdjustment(
|
| 95 |
+
threshold_name="MIN_STAGE_DAYS",
|
| 96 |
+
current_value=current_days,
|
| 97 |
+
suggested_value=suggested_days,
|
| 98 |
+
reason=(
|
| 99 |
+
f"False negative rate {accuracy['false_negative_rate']:.1%} exceeds "
|
| 100 |
+
f"{cls.HIGH_FALSE_NEGATIVE_THRESHOLD:.0%}. UNRIPE cases are progressing. "
|
| 101 |
+
f"Relax stage maturity requirement."
|
| 102 |
+
),
|
| 103 |
+
confidence="medium",
|
| 104 |
+
)
|
| 105 |
+
)
|
| 106 |
|
| 107 |
# Rule 3: Low UNKNOWN rate -> system too confident, add uncertainty
|
| 108 |
if accuracy["unknown_rate"] < cls.LOW_UNKNOWN_THRESHOLD:
|
| 109 |
current_age = current_thresholds.get("MIN_CASE_AGE_DAYS", 14)
|
| 110 |
suggested_age = current_age + 7
|
| 111 |
+
adjustments.append(
|
| 112 |
+
ThresholdAdjustment(
|
| 113 |
+
threshold_name="MIN_CASE_AGE_DAYS",
|
| 114 |
+
current_value=current_age,
|
| 115 |
+
suggested_value=suggested_age,
|
| 116 |
+
reason=(
|
| 117 |
+
f"UNKNOWN rate {accuracy['unknown_rate']:.1%} below "
|
| 118 |
+
f"{cls.LOW_UNKNOWN_THRESHOLD:.0%}. System is overconfident. "
|
| 119 |
+
f"Increase case age requirement to add uncertainty for immature cases."
|
| 120 |
+
),
|
| 121 |
+
confidence="medium",
|
| 122 |
+
)
|
| 123 |
+
)
|
| 124 |
|
| 125 |
# Rule 4: Low RIPE precision -> more conservative RIPE classification
|
| 126 |
if accuracy["ripe_precision"] < cls.LOW_RIPE_PRECISION_THRESHOLD:
|
| 127 |
current_hearings = current_thresholds.get("MIN_SERVICE_HEARINGS", 1)
|
| 128 |
suggested_hearings = current_hearings + 1
|
| 129 |
+
adjustments.append(
|
| 130 |
+
ThresholdAdjustment(
|
| 131 |
+
threshold_name="MIN_SERVICE_HEARINGS",
|
| 132 |
+
current_value=current_hearings,
|
| 133 |
+
suggested_value=suggested_hearings,
|
| 134 |
+
reason=(
|
| 135 |
+
f"RIPE precision {accuracy['ripe_precision']:.1%} below "
|
| 136 |
+
f"{cls.LOW_RIPE_PRECISION_THRESHOLD:.0%}. Too many RIPE predictions fail. "
|
| 137 |
+
f"Be more conservative in marking cases RIPE."
|
| 138 |
+
),
|
| 139 |
+
confidence="high",
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
|
| 143 |
# Rule 5: Low UNRIPE recall -> missing bottlenecks
|
| 144 |
if accuracy["unripe_recall"] < cls.LOW_UNRIPE_RECALL_THRESHOLD:
|
| 145 |
current_days = current_thresholds.get("MIN_STAGE_DAYS", 7)
|
| 146 |
suggested_days = current_days + 3
|
| 147 |
+
adjustments.append(
|
| 148 |
+
ThresholdAdjustment(
|
| 149 |
+
threshold_name="MIN_STAGE_DAYS",
|
| 150 |
+
current_value=current_days,
|
| 151 |
+
suggested_value=suggested_days,
|
| 152 |
+
reason=(
|
| 153 |
+
f"UNRIPE recall {accuracy['unripe_recall']:.1%} below "
|
| 154 |
+
f"{cls.LOW_UNRIPE_RECALL_THRESHOLD:.0%}. Missing many bottlenecks. "
|
| 155 |
+
f"Increase stage maturity requirement to catch more unripe cases."
|
| 156 |
+
),
|
| 157 |
+
confidence="medium",
|
| 158 |
+
)
|
| 159 |
+
)
|
| 160 |
|
| 161 |
# Deduplicate adjustments (same threshold suggested multiple times)
|
| 162 |
deduplicated = cls._deduplicate_adjustments(adjustments)
|
|
|
|
| 178 |
existing = threshold_map[adj.threshold_name]
|
| 179 |
confidence_order = {"high": 3, "medium": 2, "low": 1}
|
| 180 |
|
| 181 |
+
if (
|
| 182 |
+
confidence_order[adj.confidence]
|
| 183 |
+
> confidence_order[existing.confidence]
|
| 184 |
+
):
|
| 185 |
threshold_map[adj.threshold_name] = adj
|
| 186 |
+
elif (
|
| 187 |
+
confidence_order[adj.confidence]
|
| 188 |
+
== confidence_order[existing.confidence]
|
| 189 |
+
):
|
| 190 |
# Same confidence - keep larger adjustment magnitude
|
| 191 |
+
existing_delta = abs(
|
| 192 |
+
existing.suggested_value - existing.current_value
|
| 193 |
+
)
|
| 194 |
new_delta = abs(adj.suggested_value - adj.current_value)
|
| 195 |
if new_delta > existing_delta:
|
| 196 |
threshold_map[adj.threshold_name] = adj
|
|
|
|
| 232 |
]
|
| 233 |
|
| 234 |
if not adjustments:
|
| 235 |
+
lines.extend(
|
| 236 |
+
[
|
| 237 |
+
"Recommended Adjustments:",
|
| 238 |
+
" No adjustments needed - performance is within acceptable ranges.",
|
| 239 |
+
"",
|
| 240 |
+
"Current thresholds are performing well. Continue monitoring.",
|
| 241 |
+
]
|
| 242 |
+
)
|
| 243 |
else:
|
| 244 |
+
lines.extend(
|
| 245 |
+
[
|
| 246 |
+
"Recommended Adjustments:",
|
| 247 |
+
"",
|
| 248 |
+
]
|
| 249 |
+
)
|
| 250 |
|
| 251 |
for i, adj in enumerate(adjustments, 1):
|
| 252 |
+
lines.extend(
|
| 253 |
+
[
|
| 254 |
+
f"{i}. {adj.threshold_name}",
|
| 255 |
+
f" Current: {adj.current_value}",
|
| 256 |
+
f" Suggested: {adj.suggested_value}",
|
| 257 |
+
f" Confidence: {adj.confidence.upper()}",
|
| 258 |
+
f" Reason: {adj.reason}",
|
| 259 |
+
"",
|
| 260 |
+
]
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
lines.extend(
|
| 264 |
+
[
|
| 265 |
+
"Implementation:",
|
| 266 |
+
" 1. Review suggested adjustments",
|
| 267 |
+
" 2. Apply using: RipenessClassifier.set_thresholds(new_values)",
|
| 268 |
+
" 3. Re-run simulation to validate improvements",
|
| 269 |
+
" 4. Compare new metrics with baseline",
|
| 270 |
"",
|
| 271 |
+
]
|
| 272 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
report = "\n".join(lines)
|
| 275 |
|
|
|
|
| 301 |
new_thresholds[adj.threshold_name] = adj.suggested_value
|
| 302 |
|
| 303 |
if auto_apply:
|
| 304 |
+
from src.core.ripeness import RipenessClassifier
|
| 305 |
+
|
| 306 |
RipenessClassifier.set_thresholds(new_thresholds)
|
| 307 |
print(f"Applied {len(adjustments)} threshold adjustments")
|
| 308 |
|
{scheduler β src}/monitoring/ripeness_metrics.py
RENAMED
|
@@ -13,7 +13,7 @@ from typing import Optional
|
|
| 13 |
|
| 14 |
import pandas as pd
|
| 15 |
|
| 16 |
-
from
|
| 17 |
|
| 18 |
|
| 19 |
@dataclass
|
|
@@ -108,9 +108,19 @@ class RipenessMetrics:
|
|
| 108 |
total = len(self.completed_predictions)
|
| 109 |
|
| 110 |
# Count predictions by status
|
| 111 |
-
ripe_predictions = [
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
# Count actual outcomes
|
| 116 |
adjourned_cases = [p for p in self.completed_predictions if p.was_adjourned]
|
|
@@ -118,19 +128,29 @@ class RipenessMetrics:
|
|
| 118 |
|
| 119 |
# False positives: predicted RIPE but adjourned
|
| 120 |
false_positives = [p for p in ripe_predictions if p.was_adjourned]
|
| 121 |
-
false_positive_rate =
|
|
|
|
|
|
|
| 122 |
|
| 123 |
# False negatives: predicted UNRIPE but progressed
|
| 124 |
false_negatives = [p for p in unripe_predictions if not p.was_adjourned]
|
| 125 |
-
false_negative_rate =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
# Precision: of predicted RIPE, how many progressed?
|
| 128 |
ripe_correct = [p for p in ripe_predictions if not p.was_adjourned]
|
| 129 |
-
ripe_precision =
|
|
|
|
|
|
|
| 130 |
|
| 131 |
# Recall: of actually adjourned cases, how many did we predict UNRIPE?
|
| 132 |
unripe_correct = [p for p in unripe_predictions if p.was_adjourned]
|
| 133 |
-
unripe_recall =
|
|
|
|
|
|
|
| 134 |
|
| 135 |
return {
|
| 136 |
"total_predictions": total + len(self.predictions),
|
|
@@ -176,18 +196,23 @@ class RipenessMetrics:
|
|
| 176 |
"""
|
| 177 |
records = []
|
| 178 |
for pred in self.completed_predictions:
|
| 179 |
-
records.append(
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
(
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
return pd.DataFrame(records)
|
| 193 |
|
|
@@ -237,16 +262,26 @@ class RipenessMetrics:
|
|
| 237 |
]
|
| 238 |
|
| 239 |
# Add interpretation
|
| 240 |
-
if metrics[
|
| 241 |
-
report_lines.append(
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
if metrics[
|
| 245 |
-
report_lines.append(
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
if metrics[
|
| 249 |
-
report_lines.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
report_text = "\n".join(report_lines)
|
| 252 |
(output_path / "ripeness_report.txt").write_text(report_text)
|
|
|
|
| 13 |
|
| 14 |
import pandas as pd
|
| 15 |
|
| 16 |
+
from src.core.ripeness import RipenessStatus
|
| 17 |
|
| 18 |
|
| 19 |
@dataclass
|
|
|
|
| 108 |
total = len(self.completed_predictions)
|
| 109 |
|
| 110 |
# Count predictions by status
|
| 111 |
+
ripe_predictions = [
|
| 112 |
+
p
|
| 113 |
+
for p in self.completed_predictions
|
| 114 |
+
if p.predicted_status == RipenessStatus.RIPE
|
| 115 |
+
]
|
| 116 |
+
unripe_predictions = [
|
| 117 |
+
p for p in self.completed_predictions if p.predicted_status.is_unripe()
|
| 118 |
+
]
|
| 119 |
+
unknown_predictions = [
|
| 120 |
+
p
|
| 121 |
+
for p in self.completed_predictions
|
| 122 |
+
if p.predicted_status == RipenessStatus.UNKNOWN
|
| 123 |
+
]
|
| 124 |
|
| 125 |
# Count actual outcomes
|
| 126 |
adjourned_cases = [p for p in self.completed_predictions if p.was_adjourned]
|
|
|
|
| 128 |
|
| 129 |
# False positives: predicted RIPE but adjourned
|
| 130 |
false_positives = [p for p in ripe_predictions if p.was_adjourned]
|
| 131 |
+
false_positive_rate = (
|
| 132 |
+
len(false_positives) / len(ripe_predictions) if ripe_predictions else 0.0
|
| 133 |
+
)
|
| 134 |
|
| 135 |
# False negatives: predicted UNRIPE but progressed
|
| 136 |
false_negatives = [p for p in unripe_predictions if not p.was_adjourned]
|
| 137 |
+
false_negative_rate = (
|
| 138 |
+
len(false_negatives) / len(unripe_predictions)
|
| 139 |
+
if unripe_predictions
|
| 140 |
+
else 0.0
|
| 141 |
+
)
|
| 142 |
|
| 143 |
# Precision: of predicted RIPE, how many progressed?
|
| 144 |
ripe_correct = [p for p in ripe_predictions if not p.was_adjourned]
|
| 145 |
+
ripe_precision = (
|
| 146 |
+
len(ripe_correct) / len(ripe_predictions) if ripe_predictions else 0.0
|
| 147 |
+
)
|
| 148 |
|
| 149 |
# Recall: of actually adjourned cases, how many did we predict UNRIPE?
|
| 150 |
unripe_correct = [p for p in unripe_predictions if p.was_adjourned]
|
| 151 |
+
unripe_recall = (
|
| 152 |
+
len(unripe_correct) / len(adjourned_cases) if adjourned_cases else 0.0
|
| 153 |
+
)
|
| 154 |
|
| 155 |
return {
|
| 156 |
"total_predictions": total + len(self.predictions),
|
|
|
|
| 196 |
"""
|
| 197 |
records = []
|
| 198 |
for pred in self.completed_predictions:
|
| 199 |
+
records.append(
|
| 200 |
+
{
|
| 201 |
+
"case_id": pred.case_id,
|
| 202 |
+
"predicted_status": pred.predicted_status.value,
|
| 203 |
+
"prediction_date": pred.prediction_date,
|
| 204 |
+
"actual_outcome": pred.actual_outcome,
|
| 205 |
+
"was_adjourned": pred.was_adjourned,
|
| 206 |
+
"outcome_date": pred.outcome_date,
|
| 207 |
+
"correct_prediction": (
|
| 208 |
+
(
|
| 209 |
+
pred.predicted_status == RipenessStatus.RIPE
|
| 210 |
+
and not pred.was_adjourned
|
| 211 |
+
)
|
| 212 |
+
or (pred.predicted_status.is_unripe() and pred.was_adjourned)
|
| 213 |
+
),
|
| 214 |
+
}
|
| 215 |
+
)
|
| 216 |
|
| 217 |
return pd.DataFrame(records)
|
| 218 |
|
|
|
|
| 262 |
]
|
| 263 |
|
| 264 |
# Add interpretation
|
| 265 |
+
if metrics["false_positive_rate"] > 0.20:
|
| 266 |
+
report_lines.append(
|
| 267 |
+
" - HIGH false positive rate: Consider increasing MIN_SERVICE_HEARINGS"
|
| 268 |
+
)
|
| 269 |
+
if metrics["false_negative_rate"] > 0.15:
|
| 270 |
+
report_lines.append(
|
| 271 |
+
" - HIGH false negative rate: Consider decreasing MIN_STAGE_DAYS"
|
| 272 |
+
)
|
| 273 |
+
if metrics["unknown_rate"] < 0.05:
|
| 274 |
+
report_lines.append(
|
| 275 |
+
" - LOW UNKNOWN rate: System may be overconfident, add uncertainty"
|
| 276 |
+
)
|
| 277 |
+
if metrics["ripe_precision"] > 0.85:
|
| 278 |
+
report_lines.append(
|
| 279 |
+
" - GOOD RIPE precision: Most RIPE predictions are correct"
|
| 280 |
+
)
|
| 281 |
+
if metrics["unripe_recall"] < 0.60:
|
| 282 |
+
report_lines.append(
|
| 283 |
+
" - LOW UNRIPE recall: Missing many bottlenecks, refine detection"
|
| 284 |
+
)
|
| 285 |
|
| 286 |
report_text = "\n".join(report_lines)
|
| 287 |
(output_path / "ripeness_report.txt").write_text(report_text)
|
{scheduler β src}/output/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/output/cause_list.py
RENAMED
|
File without changes
|
{scheduler β src}/simulation/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/simulation/allocator.py
RENAMED
|
@@ -14,7 +14,7 @@ from enum import Enum
|
|
| 14 |
from typing import TYPE_CHECKING
|
| 15 |
|
| 16 |
if TYPE_CHECKING:
|
| 17 |
-
from
|
| 18 |
|
| 19 |
|
| 20 |
class AllocationStrategy(Enum):
|
|
@@ -32,7 +32,9 @@ class CourtroomState:
|
|
| 32 |
courtroom_id: int
|
| 33 |
daily_load: int = 0 # Number of cases scheduled today
|
| 34 |
total_cases_handled: int = 0 # Lifetime count
|
| 35 |
-
case_type_distribution: dict[str, int] = field(
|
|
|
|
|
|
|
| 36 |
|
| 37 |
def add_case(self, case: Case) -> None:
|
| 38 |
"""Register a case assigned to this courtroom."""
|
|
@@ -77,10 +79,14 @@ class CourtroomAllocator:
|
|
| 77 |
self.strategy = strategy
|
| 78 |
|
| 79 |
# Initialize courtroom states
|
| 80 |
-
self.courtrooms = {
|
|
|
|
|
|
|
| 81 |
|
| 82 |
# Metrics tracking
|
| 83 |
-
self.daily_loads: dict[
|
|
|
|
|
|
|
| 84 |
self.allocation_changes: int = 0 # Cases that switched courtrooms
|
| 85 |
self.capacity_rejections: int = 0 # Cases that couldn't be allocated
|
| 86 |
|
|
@@ -163,14 +169,14 @@ class CourtroomAllocator:
|
|
| 163 |
|
| 164 |
def _find_type_affinity_courtroom(self, case: Case) -> int | None:
|
| 165 |
"""Find courtroom with most similar case type history.
|
| 166 |
-
|
| 167 |
Currently uses load balancing. Can be enhanced with case type distribution scoring.
|
| 168 |
"""
|
| 169 |
return self._find_least_loaded_courtroom()
|
| 170 |
|
| 171 |
def _find_continuity_courtroom(self, case: Case) -> int | None:
|
| 172 |
"""Keep case in same courtroom as previous hearing when possible.
|
| 173 |
-
|
| 174 |
Maintains courtroom continuity if capacity available, otherwise uses load balancing.
|
| 175 |
"""
|
| 176 |
# If case already has courtroom assignment and it has capacity, keep it there
|
|
@@ -205,7 +211,9 @@ class CourtroomAllocator:
|
|
| 205 |
courtroom_totals[cid] += load
|
| 206 |
|
| 207 |
num_days = len(self.daily_loads)
|
| 208 |
-
courtroom_avgs = {
|
|
|
|
|
|
|
| 209 |
|
| 210 |
# Calculate Gini coefficient for fairness
|
| 211 |
sorted_totals = sorted(courtroom_totals.values())
|
|
|
|
| 14 |
from typing import TYPE_CHECKING
|
| 15 |
|
| 16 |
if TYPE_CHECKING:
|
| 17 |
+
from src.core.case import Case
|
| 18 |
|
| 19 |
|
| 20 |
class AllocationStrategy(Enum):
|
|
|
|
| 32 |
courtroom_id: int
|
| 33 |
daily_load: int = 0 # Number of cases scheduled today
|
| 34 |
total_cases_handled: int = 0 # Lifetime count
|
| 35 |
+
case_type_distribution: dict[str, int] = field(
|
| 36 |
+
default_factory=dict
|
| 37 |
+
) # Type -> count
|
| 38 |
|
| 39 |
def add_case(self, case: Case) -> None:
|
| 40 |
"""Register a case assigned to this courtroom."""
|
|
|
|
| 79 |
self.strategy = strategy
|
| 80 |
|
| 81 |
# Initialize courtroom states
|
| 82 |
+
self.courtrooms = {
|
| 83 |
+
i: CourtroomState(courtroom_id=i) for i in range(1, num_courtrooms + 1)
|
| 84 |
+
}
|
| 85 |
|
| 86 |
# Metrics tracking
|
| 87 |
+
self.daily_loads: dict[
|
| 88 |
+
date, dict[int, int]
|
| 89 |
+
] = {} # date -> {courtroom_id -> load}
|
| 90 |
self.allocation_changes: int = 0 # Cases that switched courtrooms
|
| 91 |
self.capacity_rejections: int = 0 # Cases that couldn't be allocated
|
| 92 |
|
|
|
|
| 169 |
|
| 170 |
def _find_type_affinity_courtroom(self, case: Case) -> int | None:
|
| 171 |
"""Find courtroom with most similar case type history.
|
| 172 |
+
|
| 173 |
Currently uses load balancing. Can be enhanced with case type distribution scoring.
|
| 174 |
"""
|
| 175 |
return self._find_least_loaded_courtroom()
|
| 176 |
|
| 177 |
def _find_continuity_courtroom(self, case: Case) -> int | None:
|
| 178 |
"""Keep case in same courtroom as previous hearing when possible.
|
| 179 |
+
|
| 180 |
Maintains courtroom continuity if capacity available, otherwise uses load balancing.
|
| 181 |
"""
|
| 182 |
# If case already has courtroom assignment and it has capacity, keep it there
|
|
|
|
| 211 |
courtroom_totals[cid] += load
|
| 212 |
|
| 213 |
num_days = len(self.daily_loads)
|
| 214 |
+
courtroom_avgs = {
|
| 215 |
+
cid: total / num_days for cid, total in courtroom_totals.items()
|
| 216 |
+
}
|
| 217 |
|
| 218 |
# Calculate Gini coefficient for fairness
|
| 219 |
sorted_totals = sorted(courtroom_totals.values())
|
{scheduler β src}/simulation/engine.py
RENAMED
|
@@ -19,11 +19,11 @@ from datetime import date, timedelta
|
|
| 19 |
from pathlib import Path
|
| 20 |
from typing import Dict, List
|
| 21 |
|
| 22 |
-
from
|
| 23 |
-
from
|
| 24 |
-
from
|
| 25 |
-
from
|
| 26 |
-
from
|
| 27 |
ANNUAL_FILING_RATE,
|
| 28 |
COURTROOMS,
|
| 29 |
DEFAULT_DAILY_CAPACITY,
|
|
@@ -31,11 +31,11 @@ from scheduler.data.config import (
|
|
| 31 |
MONTHLY_SEASONALITY,
|
| 32 |
TERMINAL_STAGES,
|
| 33 |
)
|
| 34 |
-
from
|
| 35 |
-
from
|
| 36 |
-
from
|
| 37 |
-
from
|
| 38 |
-
from
|
| 39 |
|
| 40 |
|
| 41 |
@dataclass
|
|
@@ -110,7 +110,9 @@ class CourtSim:
|
|
| 110 |
# resources
|
| 111 |
self.rooms = [
|
| 112 |
Courtroom(
|
| 113 |
-
courtroom_id=i + 1,
|
|
|
|
|
|
|
| 114 |
)
|
| 115 |
for i in range(self.cfg.courtrooms)
|
| 116 |
]
|
|
@@ -135,7 +137,9 @@ class CourtSim:
|
|
| 135 |
)
|
| 136 |
# scheduling algorithm (NEW - replaces inline logic)
|
| 137 |
self.algorithm = SchedulingAlgorithm(
|
| 138 |
-
policy=self.policy,
|
|
|
|
|
|
|
| 139 |
)
|
| 140 |
|
| 141 |
# --- helpers -------------------------------------------------------------
|
|
@@ -145,7 +149,11 @@ class CourtSim:
|
|
| 145 |
# This allows cases to progress naturally from simulation start
|
| 146 |
for c in self.cases:
|
| 147 |
dur = int(
|
| 148 |
-
round(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
)
|
| 150 |
dur = max(1, dur)
|
| 151 |
# If case has hearing history, use last hearing date as reference
|
|
@@ -181,7 +189,12 @@ class CourtSim:
|
|
| 181 |
"""
|
| 182 |
# 1. Must be in a stage where disposal is possible
|
| 183 |
# Historical data shows 90% disposals happen in ADMISSION or ORDERS
|
| 184 |
-
disposal_capable_stages = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
if case.current_stage not in disposal_capable_stages:
|
| 186 |
return False
|
| 187 |
|
|
@@ -331,7 +344,9 @@ class CourtSim:
|
|
| 331 |
# stage gating for new case
|
| 332 |
dur = int(
|
| 333 |
round(
|
| 334 |
-
self.params.get_stage_duration(
|
|
|
|
|
|
|
| 335 |
)
|
| 336 |
)
|
| 337 |
dur = max(1, dur)
|
|
@@ -424,12 +439,16 @@ class CourtSim:
|
|
| 424 |
int(case.is_urgent),
|
| 425 |
case.current_stage,
|
| 426 |
case.days_since_last_hearing,
|
| 427 |
-
self._stage_ready.get(
|
|
|
|
|
|
|
| 428 |
]
|
| 429 |
)
|
| 430 |
# outcome
|
| 431 |
if self._sample_adjournment(case.current_stage, case.case_type):
|
| 432 |
-
case.record_hearing(
|
|
|
|
|
|
|
| 433 |
self._events.write(
|
| 434 |
current,
|
| 435 |
"outcome",
|
|
@@ -470,7 +489,9 @@ class CourtSim:
|
|
| 470 |
)
|
| 471 |
disposed = True
|
| 472 |
|
| 473 |
-
if not disposed and current >= self._stage_ready.get(
|
|
|
|
|
|
|
| 474 |
next_stage = self._sample_next_stage(case.current_stage)
|
| 475 |
# apply transition
|
| 476 |
prev_stage = case.current_stage
|
|
@@ -485,7 +506,8 @@ class CourtSim:
|
|
| 485 |
)
|
| 486 |
# Explicit stage-based disposal (rare but possible)
|
| 487 |
if not disposed and (
|
| 488 |
-
case.status == CaseStatus.DISPOSED
|
|
|
|
| 489 |
):
|
| 490 |
self._disposals += 1
|
| 491 |
self._events.write(
|
|
@@ -502,12 +524,15 @@ class CourtSim:
|
|
| 502 |
dur = int(
|
| 503 |
round(
|
| 504 |
self.params.get_stage_duration(
|
| 505 |
-
case.current_stage,
|
|
|
|
| 506 |
)
|
| 507 |
)
|
| 508 |
)
|
| 509 |
dur = max(1, dur)
|
| 510 |
-
self._stage_ready[case.case_id] = current + timedelta(
|
|
|
|
|
|
|
| 511 |
elif not disposed:
|
| 512 |
# not allowed to leave stage yet; extend readiness window to avoid perpetual eligibility
|
| 513 |
dur = int(
|
|
@@ -546,7 +571,9 @@ class CourtSim:
|
|
| 546 |
|
| 547 |
def run(self) -> CourtSimResult:
|
| 548 |
# derive working days sequence
|
| 549 |
-
end_guess = self.cfg.start + timedelta(
|
|
|
|
|
|
|
| 550 |
working_days = self.calendar.generate_court_calendar(self.cfg.start, end_guess)[
|
| 551 |
: self.cfg.days
|
| 552 |
]
|
|
@@ -554,7 +581,11 @@ class CourtSim:
|
|
| 554 |
self._day_process(d)
|
| 555 |
# final flush (should be no-op if flushed daily) to ensure buffers are empty
|
| 556 |
self._events.flush()
|
| 557 |
-
util = (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
|
| 559 |
# Generate ripeness summary
|
| 560 |
active_cases = [c for c in self.cases if c.status != CaseStatus.DISPOSED]
|
|
@@ -577,7 +608,9 @@ class CourtSim:
|
|
| 577 |
# Generate comprehensive case status breakdown
|
| 578 |
total_cases = len(self.cases)
|
| 579 |
disposed_cases = [c for c in self.cases if c.status == CaseStatus.DISPOSED]
|
| 580 |
-
scheduled_at_least_once = [
|
|
|
|
|
|
|
| 581 |
never_scheduled = [c for c in self.cases if c.last_scheduled_date is None]
|
| 582 |
scheduled_but_not_disposed = [
|
| 583 |
c for c in scheduled_at_least_once if c.status != CaseStatus.DISPOSED
|
|
@@ -606,9 +639,9 @@ class CourtSim:
|
|
| 606 |
print(f"\nAverage hearings per scheduled case: {avg_hearings:.1f}")
|
| 607 |
|
| 608 |
if disposed_cases:
|
| 609 |
-
avg_hearings_to_disposal = sum(
|
| 610 |
-
disposed_cases
|
| 611 |
-
)
|
| 612 |
avg_days_to_disposal = sum(
|
| 613 |
(c.disposal_date - c.filed_date).days for c in disposed_cases
|
| 614 |
) / len(disposed_cases)
|
|
|
|
| 19 |
from pathlib import Path
|
| 20 |
from typing import Dict, List
|
| 21 |
|
| 22 |
+
from src.core.algorithm import SchedulingAlgorithm, SchedulingResult
|
| 23 |
+
from src.core.case import Case, CaseStatus
|
| 24 |
+
from src.core.courtroom import Courtroom
|
| 25 |
+
from src.core.ripeness import RipenessClassifier
|
| 26 |
+
from src.data.config import (
|
| 27 |
ANNUAL_FILING_RATE,
|
| 28 |
COURTROOMS,
|
| 29 |
DEFAULT_DAILY_CAPACITY,
|
|
|
|
| 31 |
MONTHLY_SEASONALITY,
|
| 32 |
TERMINAL_STAGES,
|
| 33 |
)
|
| 34 |
+
from src.data.param_loader import load_parameters
|
| 35 |
+
from src.simulation.allocator import AllocationStrategy, CourtroomAllocator
|
| 36 |
+
from src.simulation.events import EventWriter
|
| 37 |
+
from src.simulation.policies import get_policy
|
| 38 |
+
from src.utils.calendar import CourtCalendar
|
| 39 |
|
| 40 |
|
| 41 |
@dataclass
|
|
|
|
| 110 |
# resources
|
| 111 |
self.rooms = [
|
| 112 |
Courtroom(
|
| 113 |
+
courtroom_id=i + 1,
|
| 114 |
+
judge_id=f"J{i + 1:03d}",
|
| 115 |
+
daily_capacity=self.cfg.daily_capacity,
|
| 116 |
)
|
| 117 |
for i in range(self.cfg.courtrooms)
|
| 118 |
]
|
|
|
|
| 137 |
)
|
| 138 |
# scheduling algorithm (NEW - replaces inline logic)
|
| 139 |
self.algorithm = SchedulingAlgorithm(
|
| 140 |
+
policy=self.policy,
|
| 141 |
+
allocator=self.allocator,
|
| 142 |
+
min_gap_days=MIN_GAP_BETWEEN_HEARINGS,
|
| 143 |
)
|
| 144 |
|
| 145 |
# --- helpers -------------------------------------------------------------
|
|
|
|
| 149 |
# This allows cases to progress naturally from simulation start
|
| 150 |
for c in self.cases:
|
| 151 |
dur = int(
|
| 152 |
+
round(
|
| 153 |
+
self.params.get_stage_duration(
|
| 154 |
+
c.current_stage, self.cfg.duration_percentile
|
| 155 |
+
)
|
| 156 |
+
)
|
| 157 |
)
|
| 158 |
dur = max(1, dur)
|
| 159 |
# If case has hearing history, use last hearing date as reference
|
|
|
|
| 189 |
"""
|
| 190 |
# 1. Must be in a stage where disposal is possible
|
| 191 |
# Historical data shows 90% disposals happen in ADMISSION or ORDERS
|
| 192 |
+
disposal_capable_stages = [
|
| 193 |
+
"ORDERS / JUDGMENT",
|
| 194 |
+
"ARGUMENTS",
|
| 195 |
+
"ADMISSION",
|
| 196 |
+
"FINAL DISPOSAL",
|
| 197 |
+
]
|
| 198 |
if case.current_stage not in disposal_capable_stages:
|
| 199 |
return False
|
| 200 |
|
|
|
|
| 344 |
# stage gating for new case
|
| 345 |
dur = int(
|
| 346 |
round(
|
| 347 |
+
self.params.get_stage_duration(
|
| 348 |
+
case.current_stage, self.cfg.duration_percentile
|
| 349 |
+
)
|
| 350 |
)
|
| 351 |
)
|
| 352 |
dur = max(1, dur)
|
|
|
|
| 439 |
int(case.is_urgent),
|
| 440 |
case.current_stage,
|
| 441 |
case.days_since_last_hearing,
|
| 442 |
+
self._stage_ready.get(
|
| 443 |
+
case.case_id, current
|
| 444 |
+
).isoformat(),
|
| 445 |
]
|
| 446 |
)
|
| 447 |
# outcome
|
| 448 |
if self._sample_adjournment(case.current_stage, case.case_type):
|
| 449 |
+
case.record_hearing(
|
| 450 |
+
current, was_heard=False, outcome="adjourned"
|
| 451 |
+
)
|
| 452 |
self._events.write(
|
| 453 |
current,
|
| 454 |
"outcome",
|
|
|
|
| 489 |
)
|
| 490 |
disposed = True
|
| 491 |
|
| 492 |
+
if not disposed and current >= self._stage_ready.get(
|
| 493 |
+
case.case_id, current
|
| 494 |
+
):
|
| 495 |
next_stage = self._sample_next_stage(case.current_stage)
|
| 496 |
# apply transition
|
| 497 |
prev_stage = case.current_stage
|
|
|
|
| 506 |
)
|
| 507 |
# Explicit stage-based disposal (rare but possible)
|
| 508 |
if not disposed and (
|
| 509 |
+
case.status == CaseStatus.DISPOSED
|
| 510 |
+
or next_stage in TERMINAL_STAGES
|
| 511 |
):
|
| 512 |
self._disposals += 1
|
| 513 |
self._events.write(
|
|
|
|
| 524 |
dur = int(
|
| 525 |
round(
|
| 526 |
self.params.get_stage_duration(
|
| 527 |
+
case.current_stage,
|
| 528 |
+
self.cfg.duration_percentile,
|
| 529 |
)
|
| 530 |
)
|
| 531 |
)
|
| 532 |
dur = max(1, dur)
|
| 533 |
+
self._stage_ready[case.case_id] = current + timedelta(
|
| 534 |
+
days=dur
|
| 535 |
+
)
|
| 536 |
elif not disposed:
|
| 537 |
# not allowed to leave stage yet; extend readiness window to avoid perpetual eligibility
|
| 538 |
dur = int(
|
|
|
|
| 571 |
|
| 572 |
def run(self) -> CourtSimResult:
|
| 573 |
# derive working days sequence
|
| 574 |
+
end_guess = self.cfg.start + timedelta(
|
| 575 |
+
days=self.cfg.days + 60
|
| 576 |
+
) # pad for weekends/holidays
|
| 577 |
working_days = self.calendar.generate_court_calendar(self.cfg.start, end_guess)[
|
| 578 |
: self.cfg.days
|
| 579 |
]
|
|
|
|
| 581 |
self._day_process(d)
|
| 582 |
# final flush (should be no-op if flushed daily) to ensure buffers are empty
|
| 583 |
self._events.flush()
|
| 584 |
+
util = (
|
| 585 |
+
(self._hearings_total / self._capacity_offered)
|
| 586 |
+
if self._capacity_offered
|
| 587 |
+
else 0.0
|
| 588 |
+
)
|
| 589 |
|
| 590 |
# Generate ripeness summary
|
| 591 |
active_cases = [c for c in self.cases if c.status != CaseStatus.DISPOSED]
|
|
|
|
| 608 |
# Generate comprehensive case status breakdown
|
| 609 |
total_cases = len(self.cases)
|
| 610 |
disposed_cases = [c for c in self.cases if c.status == CaseStatus.DISPOSED]
|
| 611 |
+
scheduled_at_least_once = [
|
| 612 |
+
c for c in self.cases if c.last_scheduled_date is not None
|
| 613 |
+
]
|
| 614 |
never_scheduled = [c for c in self.cases if c.last_scheduled_date is None]
|
| 615 |
scheduled_but_not_disposed = [
|
| 616 |
c for c in scheduled_at_least_once if c.status != CaseStatus.DISPOSED
|
|
|
|
| 639 |
print(f"\nAverage hearings per scheduled case: {avg_hearings:.1f}")
|
| 640 |
|
| 641 |
if disposed_cases:
|
| 642 |
+
avg_hearings_to_disposal = sum(
|
| 643 |
+
c.hearing_count for c in disposed_cases
|
| 644 |
+
) / len(disposed_cases)
|
| 645 |
avg_days_to_disposal = sum(
|
| 646 |
(c.disposal_date - c.filed_date).days for c in disposed_cases
|
| 647 |
) / len(disposed_cases)
|
{scheduler β src}/simulation/events.py
RENAMED
|
File without changes
|
{scheduler β src}/simulation/policies/__init__.py
RENAMED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
"""Scheduling policy implementations."""
|
| 2 |
|
| 3 |
-
from
|
| 4 |
-
from
|
| 5 |
-
from
|
| 6 |
-
from
|
| 7 |
|
| 8 |
# Registry of supported policies (RL removed)
|
| 9 |
POLICY_REGISTRY = {
|
|
@@ -26,4 +26,10 @@ def get_policy(name: str, **kwargs):
|
|
| 26 |
return POLICY_REGISTRY[name_lower](**kwargs)
|
| 27 |
|
| 28 |
|
| 29 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Scheduling policy implementations."""
|
| 2 |
|
| 3 |
+
from src.core.policy import SchedulerPolicy
|
| 4 |
+
from src.simulation.policies.age import AgeBasedPolicy
|
| 5 |
+
from src.simulation.policies.fifo import FIFOPolicy
|
| 6 |
+
from src.simulation.policies.readiness import ReadinessPolicy
|
| 7 |
|
| 8 |
# Registry of supported policies (RL removed)
|
| 9 |
POLICY_REGISTRY = {
|
|
|
|
| 26 |
return POLICY_REGISTRY[name_lower](**kwargs)
|
| 27 |
|
| 28 |
|
| 29 |
+
__all__ = [
|
| 30 |
+
"SchedulerPolicy",
|
| 31 |
+
"FIFOPolicy",
|
| 32 |
+
"AgeBasedPolicy",
|
| 33 |
+
"ReadinessPolicy",
|
| 34 |
+
"get_policy",
|
| 35 |
+
]
|
{scheduler β src}/simulation/policies/age.py
RENAMED
|
@@ -3,13 +3,14 @@
|
|
| 3 |
Prioritizes older cases to reduce maximum age and prevent starvation.
|
| 4 |
Uses case age (days since filing) as primary criterion.
|
| 5 |
"""
|
|
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
from datetime import date
|
| 9 |
from typing import List
|
| 10 |
|
| 11 |
-
from
|
| 12 |
-
from
|
| 13 |
|
| 14 |
|
| 15 |
class AgeBasedPolicy(SchedulerPolicy):
|
|
|
|
| 3 |
Prioritizes older cases to reduce maximum age and prevent starvation.
|
| 4 |
Uses case age (days since filing) as primary criterion.
|
| 5 |
"""
|
| 6 |
+
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
from datetime import date
|
| 10 |
from typing import List
|
| 11 |
|
| 12 |
+
from src.core.case import Case
|
| 13 |
+
from src.core.policy import SchedulerPolicy
|
| 14 |
|
| 15 |
|
| 16 |
class AgeBasedPolicy(SchedulerPolicy):
|
{scheduler β src}/simulation/policies/fifo.py
RENAMED
|
@@ -3,13 +3,14 @@
|
|
| 3 |
Schedules cases in the order they were filed, treating all cases equally.
|
| 4 |
This is the simplest baseline policy.
|
| 5 |
"""
|
|
|
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
from datetime import date
|
| 9 |
from typing import List
|
| 10 |
|
| 11 |
-
from
|
| 12 |
-
from
|
| 13 |
|
| 14 |
|
| 15 |
class FIFOPolicy(SchedulerPolicy):
|
|
|
|
| 3 |
Schedules cases in the order they were filed, treating all cases equally.
|
| 4 |
This is the simplest baseline policy.
|
| 5 |
"""
|
| 6 |
+
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
from datetime import date
|
| 10 |
from typing import List
|
| 11 |
|
| 12 |
+
from src.core.case import Case
|
| 13 |
+
from src.core.policy import SchedulerPolicy
|
| 14 |
|
| 15 |
|
| 16 |
class FIFOPolicy(SchedulerPolicy):
|
{scheduler β src}/simulation/policies/readiness.py
RENAMED
|
@@ -6,13 +6,14 @@ This is the most sophisticated policy, balancing fairness with efficiency.
|
|
| 6 |
Priority formula:
|
| 7 |
priority = (age/2000) * 0.4 + readiness * 0.3 + urgent * 0.3
|
| 8 |
"""
|
|
|
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
from datetime import date
|
| 12 |
from typing import List
|
| 13 |
|
| 14 |
-
from
|
| 15 |
-
from
|
| 16 |
|
| 17 |
|
| 18 |
class ReadinessPolicy(SchedulerPolicy):
|
|
|
|
| 6 |
Priority formula:
|
| 7 |
priority = (age/2000) * 0.4 + readiness * 0.3 + urgent * 0.3
|
| 8 |
"""
|
| 9 |
+
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
from datetime import date
|
| 13 |
from typing import List
|
| 14 |
|
| 15 |
+
from src.core.case import Case
|
| 16 |
+
from src.core.policy import SchedulerPolicy
|
| 17 |
|
| 18 |
|
| 19 |
class ReadinessPolicy(SchedulerPolicy):
|
{scheduler β src}/utils/__init__.py
RENAMED
|
File without changes
|
{scheduler β src}/utils/calendar.py
RENAMED
|
@@ -7,7 +7,7 @@ court holidays, seasonality, and Karnataka High Court calendar.
|
|
| 7 |
from datetime import date, timedelta
|
| 8 |
from typing import List, Set
|
| 9 |
|
| 10 |
-
from
|
| 11 |
SEASONALITY_FACTORS,
|
| 12 |
WORKING_DAYS_PER_YEAR,
|
| 13 |
)
|
|
|
|
| 7 |
from datetime import date, timedelta
|
| 8 |
from typing import List, Set
|
| 9 |
|
| 10 |
+
from src.data.config import (
|
| 11 |
SEASONALITY_FACTORS,
|
| 12 |
WORKING_DAYS_PER_YEAR,
|
| 13 |
)
|
tests/conftest.py
CHANGED
|
@@ -15,20 +15,24 @@ from typing import List
|
|
| 15 |
|
| 16 |
import pytest
|
| 17 |
|
| 18 |
-
from
|
| 19 |
-
from
|
| 20 |
-
from
|
| 21 |
-
from
|
| 22 |
|
| 23 |
|
| 24 |
# Test markers
|
| 25 |
def pytest_configure(config):
|
| 26 |
"""Configure custom pytest markers."""
|
| 27 |
config.addinivalue_line("markers", "unit: Unit tests for individual components")
|
| 28 |
-
config.addinivalue_line(
|
|
|
|
|
|
|
| 29 |
config.addinivalue_line("markers", "rl: Reinforcement learning tests")
|
| 30 |
config.addinivalue_line("markers", "simulation: Simulation engine tests")
|
| 31 |
-
config.addinivalue_line(
|
|
|
|
|
|
|
| 32 |
config.addinivalue_line("markers", "failure: Failure scenario tests")
|
| 33 |
config.addinivalue_line("markers", "slow: Slow-running tests (>5 seconds)")
|
| 34 |
|
|
@@ -40,11 +44,7 @@ def sample_cases() -> List[Case]:
|
|
| 40 |
Returns:
|
| 41 |
List of 100 cases with diverse types, stages, and ages
|
| 42 |
"""
|
| 43 |
-
generator = CaseGenerator(
|
| 44 |
-
start=date(2024, 1, 1),
|
| 45 |
-
end=date(2024, 3, 31),
|
| 46 |
-
seed=42
|
| 47 |
-
)
|
| 48 |
cases = generator.generate(100, stage_mix_auto=True)
|
| 49 |
return cases
|
| 50 |
|
|
@@ -56,11 +56,7 @@ def small_case_set() -> List[Case]:
|
|
| 56 |
Returns:
|
| 57 |
List of 10 cases
|
| 58 |
"""
|
| 59 |
-
generator = CaseGenerator(
|
| 60 |
-
start=date(2024, 1, 1),
|
| 61 |
-
end=date(2024, 1, 10),
|
| 62 |
-
seed=42
|
| 63 |
-
)
|
| 64 |
cases = generator.generate(10)
|
| 65 |
return cases
|
| 66 |
|
|
@@ -80,7 +76,7 @@ def single_case() -> Case:
|
|
| 80 |
last_hearing_date=None,
|
| 81 |
age_days=30,
|
| 82 |
hearing_count=0,
|
| 83 |
-
status=CaseStatus.PENDING
|
| 84 |
)
|
| 85 |
|
| 86 |
|
|
@@ -99,12 +95,12 @@ def ripe_case() -> Case:
|
|
| 99 |
last_hearing_date=date(2024, 2, 1),
|
| 100 |
age_days=90,
|
| 101 |
hearing_count=5,
|
| 102 |
-
status=CaseStatus.ACTIVE
|
| 103 |
)
|
| 104 |
# Set additional attributes that may be needed
|
| 105 |
-
if hasattr(case,
|
| 106 |
case.service_status = "SERVED"
|
| 107 |
-
if hasattr(case,
|
| 108 |
case.compliance_status = "COMPLIED"
|
| 109 |
return case
|
| 110 |
|
|
@@ -124,12 +120,12 @@ def unripe_case() -> Case:
|
|
| 124 |
last_hearing_date=None,
|
| 125 |
age_days=15,
|
| 126 |
hearing_count=1,
|
| 127 |
-
status=CaseStatus.PENDING
|
| 128 |
)
|
| 129 |
# Set additional attributes
|
| 130 |
-
if hasattr(case,
|
| 131 |
case.service_status = "PENDING"
|
| 132 |
-
if hasattr(case,
|
| 133 |
case.last_hearing_purpose = "FOR ISSUE OF SUMMONS"
|
| 134 |
return case
|
| 135 |
|
|
@@ -216,7 +212,7 @@ def disposed_case() -> Case:
|
|
| 216 |
last_hearing_date=date(2024, 3, 15),
|
| 217 |
age_days=180,
|
| 218 |
hearing_count=8,
|
| 219 |
-
status=CaseStatus.DISPOSED
|
| 220 |
)
|
| 221 |
return case
|
| 222 |
|
|
@@ -236,7 +232,7 @@ def aged_case() -> Case:
|
|
| 236 |
last_hearing_date=date(2024, 5, 1),
|
| 237 |
age_days=800,
|
| 238 |
hearing_count=25,
|
| 239 |
-
status=CaseStatus.ACTIVE
|
| 240 |
)
|
| 241 |
return case
|
| 242 |
|
|
@@ -257,13 +253,14 @@ def urgent_case() -> Case:
|
|
| 257 |
age_days=5,
|
| 258 |
hearing_count=0,
|
| 259 |
status=CaseStatus.PENDING,
|
| 260 |
-
is_urgent=True
|
| 261 |
)
|
| 262 |
return case
|
| 263 |
|
| 264 |
|
| 265 |
# Helper functions for tests
|
| 266 |
|
|
|
|
| 267 |
def assert_valid_case(case: Case):
|
| 268 |
"""Assert that a case has all required fields and valid values.
|
| 269 |
|
|
@@ -294,7 +291,7 @@ def create_case_with_hearings(n_hearings: int, days_between: int = 30) -> Case:
|
|
| 294 |
case_type="RSA",
|
| 295 |
filed_date=date(2024, 1, 1),
|
| 296 |
current_stage="ARGUMENTS",
|
| 297 |
-
status=CaseStatus.ACTIVE
|
| 298 |
)
|
| 299 |
|
| 300 |
current_date = date(2024, 1, 1)
|
|
|
|
| 15 |
|
| 16 |
import pytest
|
| 17 |
|
| 18 |
+
from src.core.case import Case, CaseStatus
|
| 19 |
+
from src.core.courtroom import Courtroom
|
| 20 |
+
from src.data.case_generator import CaseGenerator
|
| 21 |
+
from src.data.param_loader import ParameterLoader
|
| 22 |
|
| 23 |
|
| 24 |
# Test markers
|
| 25 |
def pytest_configure(config):
|
| 26 |
"""Configure custom pytest markers."""
|
| 27 |
config.addinivalue_line("markers", "unit: Unit tests for individual components")
|
| 28 |
+
config.addinivalue_line(
|
| 29 |
+
"markers", "integration: Integration tests for multi-component workflows"
|
| 30 |
+
)
|
| 31 |
config.addinivalue_line("markers", "rl: Reinforcement learning tests")
|
| 32 |
config.addinivalue_line("markers", "simulation: Simulation engine tests")
|
| 33 |
+
config.addinivalue_line(
|
| 34 |
+
"markers", "edge_case: Edge case and boundary condition tests"
|
| 35 |
+
)
|
| 36 |
config.addinivalue_line("markers", "failure: Failure scenario tests")
|
| 37 |
config.addinivalue_line("markers", "slow: Slow-running tests (>5 seconds)")
|
| 38 |
|
|
|
|
| 44 |
Returns:
|
| 45 |
List of 100 cases with diverse types, stages, and ages
|
| 46 |
"""
|
| 47 |
+
generator = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 3, 31), seed=42)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
cases = generator.generate(100, stage_mix_auto=True)
|
| 49 |
return cases
|
| 50 |
|
|
|
|
| 56 |
Returns:
|
| 57 |
List of 10 cases
|
| 58 |
"""
|
| 59 |
+
generator = CaseGenerator(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
cases = generator.generate(10)
|
| 61 |
return cases
|
| 62 |
|
|
|
|
| 76 |
last_hearing_date=None,
|
| 77 |
age_days=30,
|
| 78 |
hearing_count=0,
|
| 79 |
+
status=CaseStatus.PENDING,
|
| 80 |
)
|
| 81 |
|
| 82 |
|
|
|
|
| 95 |
last_hearing_date=date(2024, 2, 1),
|
| 96 |
age_days=90,
|
| 97 |
hearing_count=5,
|
| 98 |
+
status=CaseStatus.ACTIVE,
|
| 99 |
)
|
| 100 |
# Set additional attributes that may be needed
|
| 101 |
+
if hasattr(case, "service_status"):
|
| 102 |
case.service_status = "SERVED"
|
| 103 |
+
if hasattr(case, "compliance_status"):
|
| 104 |
case.compliance_status = "COMPLIED"
|
| 105 |
return case
|
| 106 |
|
|
|
|
| 120 |
last_hearing_date=None,
|
| 121 |
age_days=15,
|
| 122 |
hearing_count=1,
|
| 123 |
+
status=CaseStatus.PENDING,
|
| 124 |
)
|
| 125 |
# Set additional attributes
|
| 126 |
+
if hasattr(case, "service_status"):
|
| 127 |
case.service_status = "PENDING"
|
| 128 |
+
if hasattr(case, "last_hearing_purpose"):
|
| 129 |
case.last_hearing_purpose = "FOR ISSUE OF SUMMONS"
|
| 130 |
return case
|
| 131 |
|
|
|
|
| 212 |
last_hearing_date=date(2024, 3, 15),
|
| 213 |
age_days=180,
|
| 214 |
hearing_count=8,
|
| 215 |
+
status=CaseStatus.DISPOSED,
|
| 216 |
)
|
| 217 |
return case
|
| 218 |
|
|
|
|
| 232 |
last_hearing_date=date(2024, 5, 1),
|
| 233 |
age_days=800,
|
| 234 |
hearing_count=25,
|
| 235 |
+
status=CaseStatus.ACTIVE,
|
| 236 |
)
|
| 237 |
return case
|
| 238 |
|
|
|
|
| 253 |
age_days=5,
|
| 254 |
hearing_count=0,
|
| 255 |
status=CaseStatus.PENDING,
|
| 256 |
+
is_urgent=True,
|
| 257 |
)
|
| 258 |
return case
|
| 259 |
|
| 260 |
|
| 261 |
# Helper functions for tests
|
| 262 |
|
| 263 |
+
|
| 264 |
def assert_valid_case(case: Case):
|
| 265 |
"""Assert that a case has all required fields and valid values.
|
| 266 |
|
|
|
|
| 291 |
case_type="RSA",
|
| 292 |
filed_date=date(2024, 1, 1),
|
| 293 |
current_stage="ARGUMENTS",
|
| 294 |
+
status=CaseStatus.ACTIVE,
|
| 295 |
)
|
| 296 |
|
| 297 |
current_date = date(2024, 1, 1)
|
tests/integration/test_simulation.py
CHANGED
|
@@ -7,8 +7,8 @@ from datetime import date
|
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
|
| 10 |
-
from
|
| 11 |
-
from
|
| 12 |
|
| 13 |
|
| 14 |
@pytest.mark.integration
|
|
@@ -25,7 +25,7 @@ class TestSimulationBasics:
|
|
| 25 |
courtrooms=2,
|
| 26 |
daily_capacity=50,
|
| 27 |
policy="readiness",
|
| 28 |
-
log_dir=temp_output_dir
|
| 29 |
)
|
| 30 |
|
| 31 |
sim = CourtSim(config, small_case_set)
|
|
@@ -44,7 +44,7 @@ class TestSimulationBasics:
|
|
| 44 |
courtrooms=3,
|
| 45 |
daily_capacity=50,
|
| 46 |
policy="readiness",
|
| 47 |
-
log_dir=temp_output_dir
|
| 48 |
)
|
| 49 |
|
| 50 |
sim = CourtSim(config, sample_cases)
|
|
@@ -64,14 +64,16 @@ class TestSimulationBasics:
|
|
| 64 |
courtrooms=5,
|
| 65 |
daily_capacity=50,
|
| 66 |
policy="readiness",
|
| 67 |
-
log_dir=temp_output_dir
|
| 68 |
)
|
| 69 |
|
| 70 |
sim = CourtSim(config, sample_cases)
|
| 71 |
result = sim.run()
|
| 72 |
|
| 73 |
assert result.hearings_total > 0
|
| 74 |
-
assert
|
|
|
|
|
|
|
| 75 |
# Check disposal rate is reasonable
|
| 76 |
if result.hearings_total > 0:
|
| 77 |
disposal_rate = result.disposals / len(sample_cases)
|
|
@@ -92,7 +94,7 @@ class TestOutcomeTracking:
|
|
| 92 |
courtrooms=2,
|
| 93 |
daily_capacity=50,
|
| 94 |
policy="readiness",
|
| 95 |
-
log_dir=temp_output_dir
|
| 96 |
)
|
| 97 |
|
| 98 |
sim = CourtSim(config, small_case_set)
|
|
@@ -113,7 +115,7 @@ class TestOutcomeTracking:
|
|
| 113 |
courtrooms=5,
|
| 114 |
daily_capacity=50,
|
| 115 |
policy="readiness",
|
| 116 |
-
log_dir=temp_output_dir
|
| 117 |
)
|
| 118 |
|
| 119 |
sim = CourtSim(config, sample_cases)
|
|
@@ -133,7 +135,7 @@ class TestOutcomeTracking:
|
|
| 133 |
courtrooms=3,
|
| 134 |
daily_capacity=50,
|
| 135 |
policy="readiness",
|
| 136 |
-
log_dir=temp_output_dir
|
| 137 |
)
|
| 138 |
|
| 139 |
sim = CourtSim(config, sample_cases)
|
|
@@ -157,7 +159,7 @@ class TestStageProgression:
|
|
| 157 |
courtrooms=5,
|
| 158 |
daily_capacity=50,
|
| 159 |
policy="readiness",
|
| 160 |
-
log_dir=temp_output_dir
|
| 161 |
)
|
| 162 |
|
| 163 |
# Record initial stages
|
|
@@ -168,7 +170,8 @@ class TestStageProgression:
|
|
| 168 |
|
| 169 |
# Check if any cases progressed
|
| 170 |
progressed = sum(
|
| 171 |
-
1
|
|
|
|
| 172 |
if case.current_stage != initial_stages.get(case.case_id)
|
| 173 |
)
|
| 174 |
|
|
@@ -184,14 +187,15 @@ class TestStageProgression:
|
|
| 184 |
courtrooms=5,
|
| 185 |
daily_capacity=50,
|
| 186 |
policy="readiness",
|
| 187 |
-
log_dir=temp_output_dir
|
| 188 |
)
|
| 189 |
|
| 190 |
sim = CourtSim(config, sample_cases)
|
| 191 |
sim.run()
|
| 192 |
|
| 193 |
# Check disposed cases are in terminal stages
|
| 194 |
-
from
|
|
|
|
| 195 |
for case in sample_cases:
|
| 196 |
if case.is_disposed():
|
| 197 |
assert case.current_stage in TERMINAL_STAGES
|
|
@@ -211,7 +215,7 @@ class TestRipenessIntegration:
|
|
| 211 |
courtrooms=5,
|
| 212 |
daily_capacity=50,
|
| 213 |
policy="readiness",
|
| 214 |
-
log_dir=temp_output_dir
|
| 215 |
)
|
| 216 |
|
| 217 |
sim = CourtSim(config, sample_cases)
|
|
@@ -223,7 +227,9 @@ class TestRipenessIntegration:
|
|
| 223 |
def test_unripe_filtering(self, temp_output_dir):
|
| 224 |
"""Test that unripe cases are filtered from scheduling."""
|
| 225 |
# Create mix of ripe and unripe cases
|
| 226 |
-
generator = CaseGenerator(
|
|
|
|
|
|
|
| 227 |
cases = generator.generate(50)
|
| 228 |
|
| 229 |
# Mark some as unripe
|
|
@@ -239,7 +245,7 @@ class TestRipenessIntegration:
|
|
| 239 |
courtrooms=3,
|
| 240 |
daily_capacity=50,
|
| 241 |
policy="readiness",
|
| 242 |
-
log_dir=temp_output_dir
|
| 243 |
)
|
| 244 |
|
| 245 |
sim = CourtSim(config, cases)
|
|
@@ -263,7 +269,7 @@ class TestSimulationEdgeCases:
|
|
| 263 |
courtrooms=2,
|
| 264 |
daily_capacity=50,
|
| 265 |
policy="readiness",
|
| 266 |
-
log_dir=temp_output_dir
|
| 267 |
)
|
| 268 |
|
| 269 |
sim = CourtSim(config, [])
|
|
@@ -291,7 +297,7 @@ class TestSimulationEdgeCases:
|
|
| 291 |
courtrooms=2,
|
| 292 |
daily_capacity=50,
|
| 293 |
policy="readiness",
|
| 294 |
-
log_dir=temp_output_dir
|
| 295 |
)
|
| 296 |
|
| 297 |
sim = CourtSim(config, cases)
|
|
@@ -311,7 +317,7 @@ class TestSimulationEdgeCases:
|
|
| 311 |
courtrooms=2,
|
| 312 |
daily_capacity=50,
|
| 313 |
policy="readiness",
|
| 314 |
-
log_dir=temp_output_dir
|
| 315 |
)
|
| 316 |
|
| 317 |
@pytest.mark.failure
|
|
@@ -325,7 +331,7 @@ class TestSimulationEdgeCases:
|
|
| 325 |
courtrooms=2,
|
| 326 |
daily_capacity=50,
|
| 327 |
policy="readiness",
|
| 328 |
-
log_dir=temp_output_dir
|
| 329 |
)
|
| 330 |
|
| 331 |
|
|
@@ -343,7 +349,7 @@ class TestEventLogging:
|
|
| 343 |
courtrooms=2,
|
| 344 |
daily_capacity=50,
|
| 345 |
policy="readiness",
|
| 346 |
-
log_dir=temp_output_dir
|
| 347 |
)
|
| 348 |
|
| 349 |
sim = CourtSim(config, small_case_set)
|
|
@@ -354,6 +360,7 @@ class TestEventLogging:
|
|
| 354 |
if events_file.exists():
|
| 355 |
# Verify it's readable
|
| 356 |
import pandas as pd
|
|
|
|
| 357 |
df = pd.read_csv(events_file)
|
| 358 |
assert len(df) >= 0
|
| 359 |
|
|
@@ -366,7 +373,7 @@ class TestEventLogging:
|
|
| 366 |
courtrooms=2,
|
| 367 |
daily_capacity=50,
|
| 368 |
policy="readiness",
|
| 369 |
-
log_dir=temp_output_dir
|
| 370 |
)
|
| 371 |
|
| 372 |
sim = CourtSim(config, small_case_set)
|
|
@@ -376,6 +383,7 @@ class TestEventLogging:
|
|
| 376 |
events_file = temp_output_dir / "events.csv"
|
| 377 |
if events_file.exists():
|
| 378 |
import pandas as pd
|
|
|
|
| 379 |
pd.read_csv(events_file)
|
| 380 |
# Event count should match or be close to hearings_total
|
| 381 |
# (may have additional events for filings, etc.)
|
|
@@ -395,7 +403,7 @@ class TestPolicyComparison:
|
|
| 395 |
courtrooms=3,
|
| 396 |
daily_capacity=50,
|
| 397 |
policy="fifo",
|
| 398 |
-
log_dir=temp_output_dir / "fifo"
|
| 399 |
)
|
| 400 |
|
| 401 |
sim = CourtSim(config, sample_cases.copy())
|
|
@@ -412,7 +420,7 @@ class TestPolicyComparison:
|
|
| 412 |
courtrooms=3,
|
| 413 |
daily_capacity=50,
|
| 414 |
policy="age",
|
| 415 |
-
log_dir=temp_output_dir / "age"
|
| 416 |
)
|
| 417 |
|
| 418 |
sim = CourtSim(config, sample_cases.copy())
|
|
@@ -429,11 +437,10 @@ class TestPolicyComparison:
|
|
| 429 |
courtrooms=3,
|
| 430 |
daily_capacity=50,
|
| 431 |
policy="readiness",
|
| 432 |
-
log_dir=temp_output_dir / "readiness"
|
| 433 |
)
|
| 434 |
|
| 435 |
sim = CourtSim(config, sample_cases.copy())
|
| 436 |
result = sim.run()
|
| 437 |
|
| 438 |
assert result.hearings_total > 0
|
| 439 |
-
|
|
|
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
|
| 10 |
+
from src.data.case_generator import CaseGenerator
|
| 11 |
+
from src.simulation.engine import CourtSim, CourtSimConfig
|
| 12 |
|
| 13 |
|
| 14 |
@pytest.mark.integration
|
|
|
|
| 25 |
courtrooms=2,
|
| 26 |
daily_capacity=50,
|
| 27 |
policy="readiness",
|
| 28 |
+
log_dir=temp_output_dir,
|
| 29 |
)
|
| 30 |
|
| 31 |
sim = CourtSim(config, small_case_set)
|
|
|
|
| 44 |
courtrooms=3,
|
| 45 |
daily_capacity=50,
|
| 46 |
policy="readiness",
|
| 47 |
+
log_dir=temp_output_dir,
|
| 48 |
)
|
| 49 |
|
| 50 |
sim = CourtSim(config, sample_cases)
|
|
|
|
| 64 |
courtrooms=5,
|
| 65 |
daily_capacity=50,
|
| 66 |
policy="readiness",
|
| 67 |
+
log_dir=temp_output_dir,
|
| 68 |
)
|
| 69 |
|
| 70 |
sim = CourtSim(config, sample_cases)
|
| 71 |
result = sim.run()
|
| 72 |
|
| 73 |
assert result.hearings_total > 0
|
| 74 |
+
assert (
|
| 75 |
+
result.hearings_heard + result.hearings_adjourned == result.hearings_total
|
| 76 |
+
)
|
| 77 |
# Check disposal rate is reasonable
|
| 78 |
if result.hearings_total > 0:
|
| 79 |
disposal_rate = result.disposals / len(sample_cases)
|
|
|
|
| 94 |
courtrooms=2,
|
| 95 |
daily_capacity=50,
|
| 96 |
policy="readiness",
|
| 97 |
+
log_dir=temp_output_dir,
|
| 98 |
)
|
| 99 |
|
| 100 |
sim = CourtSim(config, small_case_set)
|
|
|
|
| 115 |
courtrooms=5,
|
| 116 |
daily_capacity=50,
|
| 117 |
policy="readiness",
|
| 118 |
+
log_dir=temp_output_dir,
|
| 119 |
)
|
| 120 |
|
| 121 |
sim = CourtSim(config, sample_cases)
|
|
|
|
| 135 |
courtrooms=3,
|
| 136 |
daily_capacity=50,
|
| 137 |
policy="readiness",
|
| 138 |
+
log_dir=temp_output_dir,
|
| 139 |
)
|
| 140 |
|
| 141 |
sim = CourtSim(config, sample_cases)
|
|
|
|
| 159 |
courtrooms=5,
|
| 160 |
daily_capacity=50,
|
| 161 |
policy="readiness",
|
| 162 |
+
log_dir=temp_output_dir,
|
| 163 |
)
|
| 164 |
|
| 165 |
# Record initial stages
|
|
|
|
| 170 |
|
| 171 |
# Check if any cases progressed
|
| 172 |
progressed = sum(
|
| 173 |
+
1
|
| 174 |
+
for case in sample_cases
|
| 175 |
if case.current_stage != initial_stages.get(case.case_id)
|
| 176 |
)
|
| 177 |
|
|
|
|
| 187 |
courtrooms=5,
|
| 188 |
daily_capacity=50,
|
| 189 |
policy="readiness",
|
| 190 |
+
log_dir=temp_output_dir,
|
| 191 |
)
|
| 192 |
|
| 193 |
sim = CourtSim(config, sample_cases)
|
| 194 |
sim.run()
|
| 195 |
|
| 196 |
# Check disposed cases are in terminal stages
|
| 197 |
+
from src.data.config import TERMINAL_STAGES
|
| 198 |
+
|
| 199 |
for case in sample_cases:
|
| 200 |
if case.is_disposed():
|
| 201 |
assert case.current_stage in TERMINAL_STAGES
|
|
|
|
| 215 |
courtrooms=5,
|
| 216 |
daily_capacity=50,
|
| 217 |
policy="readiness",
|
| 218 |
+
log_dir=temp_output_dir,
|
| 219 |
)
|
| 220 |
|
| 221 |
sim = CourtSim(config, sample_cases)
|
|
|
|
| 227 |
def test_unripe_filtering(self, temp_output_dir):
|
| 228 |
"""Test that unripe cases are filtered from scheduling."""
|
| 229 |
# Create mix of ripe and unripe cases
|
| 230 |
+
generator = CaseGenerator(
|
| 231 |
+
start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42
|
| 232 |
+
)
|
| 233 |
cases = generator.generate(50)
|
| 234 |
|
| 235 |
# Mark some as unripe
|
|
|
|
| 245 |
courtrooms=3,
|
| 246 |
daily_capacity=50,
|
| 247 |
policy="readiness",
|
| 248 |
+
log_dir=temp_output_dir,
|
| 249 |
)
|
| 250 |
|
| 251 |
sim = CourtSim(config, cases)
|
|
|
|
| 269 |
courtrooms=2,
|
| 270 |
daily_capacity=50,
|
| 271 |
policy="readiness",
|
| 272 |
+
log_dir=temp_output_dir,
|
| 273 |
)
|
| 274 |
|
| 275 |
sim = CourtSim(config, [])
|
|
|
|
| 297 |
courtrooms=2,
|
| 298 |
daily_capacity=50,
|
| 299 |
policy="readiness",
|
| 300 |
+
log_dir=temp_output_dir,
|
| 301 |
)
|
| 302 |
|
| 303 |
sim = CourtSim(config, cases)
|
|
|
|
| 317 |
courtrooms=2,
|
| 318 |
daily_capacity=50,
|
| 319 |
policy="readiness",
|
| 320 |
+
log_dir=temp_output_dir,
|
| 321 |
)
|
| 322 |
|
| 323 |
@pytest.mark.failure
|
|
|
|
| 331 |
courtrooms=2,
|
| 332 |
daily_capacity=50,
|
| 333 |
policy="readiness",
|
| 334 |
+
log_dir=temp_output_dir,
|
| 335 |
)
|
| 336 |
|
| 337 |
|
|
|
|
| 349 |
courtrooms=2,
|
| 350 |
daily_capacity=50,
|
| 351 |
policy="readiness",
|
| 352 |
+
log_dir=temp_output_dir,
|
| 353 |
)
|
| 354 |
|
| 355 |
sim = CourtSim(config, small_case_set)
|
|
|
|
| 360 |
if events_file.exists():
|
| 361 |
# Verify it's readable
|
| 362 |
import pandas as pd
|
| 363 |
+
|
| 364 |
df = pd.read_csv(events_file)
|
| 365 |
assert len(df) >= 0
|
| 366 |
|
|
|
|
| 373 |
courtrooms=2,
|
| 374 |
daily_capacity=50,
|
| 375 |
policy="readiness",
|
| 376 |
+
log_dir=temp_output_dir,
|
| 377 |
)
|
| 378 |
|
| 379 |
sim = CourtSim(config, small_case_set)
|
|
|
|
| 383 |
events_file = temp_output_dir / "events.csv"
|
| 384 |
if events_file.exists():
|
| 385 |
import pandas as pd
|
| 386 |
+
|
| 387 |
pd.read_csv(events_file)
|
| 388 |
# Event count should match or be close to hearings_total
|
| 389 |
# (may have additional events for filings, etc.)
|
|
|
|
| 403 |
courtrooms=3,
|
| 404 |
daily_capacity=50,
|
| 405 |
policy="fifo",
|
| 406 |
+
log_dir=temp_output_dir / "fifo",
|
| 407 |
)
|
| 408 |
|
| 409 |
sim = CourtSim(config, sample_cases.copy())
|
|
|
|
| 420 |
courtrooms=3,
|
| 421 |
daily_capacity=50,
|
| 422 |
policy="age",
|
| 423 |
+
log_dir=temp_output_dir / "age",
|
| 424 |
)
|
| 425 |
|
| 426 |
sim = CourtSim(config, sample_cases.copy())
|
|
|
|
| 437 |
courtrooms=3,
|
| 438 |
daily_capacity=50,
|
| 439 |
policy="readiness",
|
| 440 |
+
log_dir=temp_output_dir / "readiness",
|
| 441 |
)
|
| 442 |
|
| 443 |
sim = CourtSim(config, sample_cases.copy())
|
| 444 |
result = sim.run()
|
| 445 |
|
| 446 |
assert result.hearings_total > 0
|
|
|