RoyAalekh commited on
Commit
6a28f91
Β·
1 Parent(s): d3a967e

refactored project structure. renamed scheduler dir to src

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitignore +0 -17
  2. cli/main.py +7 -7
  3. eda/exploration.py +77 -37
  4. eda/load_clean.py +3 -2
  5. {scheduler β†’ src}/__init__.py +0 -0
  6. {scheduler/dashboard β†’ src}/app.py +1 -1
  7. {scheduler β†’ src}/control/__init__.py +0 -0
  8. {scheduler β†’ src}/control/explainability.py +27 -12
  9. {scheduler β†’ src}/control/overrides.py +0 -0
  10. {scheduler β†’ src}/core/__init__.py +0 -0
  11. {scheduler β†’ src}/core/algorithm.py +80 -45
  12. {scheduler β†’ src}/core/case.py +69 -42
  13. {scheduler β†’ src}/core/courtroom.py +30 -18
  14. {scheduler β†’ src}/core/hearing.py +0 -0
  15. {scheduler β†’ src}/core/judge.py +0 -0
  16. {scheduler β†’ src}/core/policy.py +2 -1
  17. {scheduler β†’ src}/core/ripeness.py +15 -12
  18. {scheduler β†’ src}/dashboard/__init__.py +0 -0
  19. {scheduler β†’ src}/dashboard/pages/1_Data_And_Insights.py +5 -5
  20. {scheduler β†’ src}/dashboard/pages/2_Ripeness_Classifier.py +3 -3
  21. {scheduler β†’ src}/dashboard/pages/3_Simulation_Workflow.py +5 -5
  22. {scheduler β†’ src}/dashboard/pages/4_Cause_Lists_And_Overrides.py +0 -0
  23. {scheduler β†’ src}/dashboard/pages/6_Analytics_And_Reports.py +0 -0
  24. {scheduler β†’ src}/dashboard/utils/__init__.py +0 -0
  25. {scheduler β†’ src}/dashboard/utils/data_loader.py +34 -13
  26. {scheduler β†’ src}/dashboard/utils/simulation_runner.py +4 -4
  27. {scheduler β†’ src}/dashboard/utils/ui_input_parser.py +0 -0
  28. {scheduler β†’ src}/data/__init__.py +0 -0
  29. {scheduler β†’ src}/data/case_generator.py +12 -6
  30. {scheduler β†’ src}/data/config.py +0 -0
  31. {scheduler β†’ src}/data/param_loader.py +22 -14
  32. {scheduler β†’ src}/metrics/__init__.py +0 -0
  33. {scheduler β†’ src}/metrics/basic.py +0 -0
  34. {scheduler β†’ src}/monitoring/__init__.py +2 -2
  35. {scheduler β†’ src}/monitoring/ripeness_calibrator.py +118 -88
  36. {scheduler β†’ src}/monitoring/ripeness_metrics.py +65 -30
  37. {scheduler β†’ src}/output/__init__.py +0 -0
  38. {scheduler β†’ src}/output/cause_list.py +0 -0
  39. {scheduler β†’ src}/simulation/__init__.py +0 -0
  40. {scheduler β†’ src}/simulation/allocator.py +15 -7
  41. {scheduler β†’ src}/simulation/engine.py +60 -27
  42. {scheduler β†’ src}/simulation/events.py +0 -0
  43. {scheduler β†’ src}/simulation/policies/__init__.py +11 -5
  44. {scheduler β†’ src}/simulation/policies/age.py +3 -2
  45. {scheduler β†’ src}/simulation/policies/fifo.py +3 -2
  46. {scheduler β†’ src}/simulation/policies/readiness.py +3 -2
  47. {scheduler β†’ src}/utils/__init__.py +0 -0
  48. {scheduler β†’ src}/utils/calendar.py +1 -1
  49. tests/conftest.py +24 -27
  50. 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 scheduler.data.case_generator import CaseGenerator
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 scheduler.core.case import CaseStatus
251
- from scheduler.data.case_generator import CaseGenerator
252
- from scheduler.metrics.basic import gini
253
- from scheduler.simulation.engine import CourtSim, CourtSimConfig
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 scheduler.data.case_generator import CaseGenerator
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 scheduler.simulation.engine import CourtSim, CourtSimConfig
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
- cases_pd = cases.to_pandas()
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
- cases_pd.groupby("CASE_TYPE")["CNR_NUMBER"]
56
- .count()
57
- .reset_index(name="COUNT")
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 cases_pd.columns:
81
- year_counts = cases_pd.groupby("YEAR_FILED")["CNR_NUMBER"].count().reset_index(name="Count")
82
  fig2 = px.line(
83
- year_counts, x="YEAR_FILED", y="Count", markers=True, title="Cases Filed by Year"
 
 
 
 
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 cases_pd.columns:
94
  fig3 = px.histogram(
95
- cases_pd,
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(cases_pd.columns):
 
 
 
 
109
  fig4 = px.scatter(
110
- cases_pd,
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
- cases_pd,
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 hearings_pd.columns:
139
- stage_counts = hearings_pd["Remappedstages"].value_counts().reset_index()
140
- stage_counts.columns = ["Stage", "Count"]
 
 
 
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 cases_pd.columns:
163
  fig_gap = px.box(
164
- cases_pd,
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 if s in STAGE_ORDER else ("OTHER" if s is not None else "NA")
 
 
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("RUN_DAYS")
 
 
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[tr_df["STAGE_FROM"].isin(labels) & tr_df["STAGE_TO"].isin(labels)].copy()
285
- tr_df = tr_df.sort_values(by=["STAGE_FROM", "STAGE_TO"], key=lambda c: c.map(idx))
 
 
 
 
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 = m_hear.group_by("YM").agg(pl.len().alias("N_HEARINGS")).sort("YM")
 
 
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("DELTA"),
 
 
362
  ]
363
  )
364
  ml_pd = ml.to_pandas()
365
- ml_pd["ROLL_MEAN"] = ml_pd["N_HEARINGS"].rolling(window=12, min_periods=6).mean()
366
- ml_pd["ROLL_STD"] = ml_pd["N_HEARINGS"].rolling(window=12, min_periods=6).std()
 
 
 
 
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).cast(pl.Utf8).str.strip_chars().str.to_uppercase().alias("PURPOSE_TXT")
 
 
 
 
459
  )
460
- async_kw = ["NON-COMPLIANCE", "OFFICE OBJECTION", "COMPLIANCE", "NOTICE", "SERVICE"]
461
- subs_kw = ["EVIDENCE", "ARGUMENT", "FINAL HEARING", "JUDGMENT", "ORDER", "DISPOSAL"]
 
 
 
 
 
 
 
 
 
 
 
 
 
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((pl.col("N") / pl.col("N").sum().over("CASE_TYPE")).alias("SHARE"))
 
 
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 scheduler.dashboard.utils import get_data_status
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 scheduler.core.case import Case
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 = [f"Case {self.case_id}: {'SCHEDULED' if self.scheduled else 'NOT SCHEDULED'}"]
 
 
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"] = "Wait for summons service confirmation"
 
 
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"] = "Wait for party availability confirmation"
 
 
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 = {"days_since_last_hearing": days_since, "minimum_required": min_gap_days}
 
 
 
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() if case.last_hearing_date else "unknown"
 
 
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={"reason": "Algorithm determined case should be scheduled"},
 
 
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 if priority_breakdown is not None else None,
 
 
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 if priority_breakdown is not None else None,
 
 
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 scheduler.control.explainability import ExplainabilityEngine, SchedulingExplanation
18
- from scheduler.control.overrides import (
19
  JudgePreferences,
20
  Override,
21
  OverrideType,
22
  OverrideValidator,
23
  )
24
- from scheduler.core.case import Case, CaseStatus
25
- from scheduler.core.courtroom import Courtroom
26
- from scheduler.core.policy import SchedulerPolicy
27
- from scheduler.core.ripeness import RipenessClassifier, RipenessStatus
28
- from scheduler.data.config import MIN_GAP_BETWEEN_HEARINGS
29
- from scheduler.simulation.allocator import CourtroomAllocator
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(len(cases) for cases in self.scheduled_cases.values())
 
 
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 = "; ".join(errors) if errors else "Validation failed"
149
- override_rejections.append({
150
- "judge": override.judge_id,
151
- "context": override.override_type.value,
152
- "reason": rejection_reason
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(self._get_preference_overrides(preferences, courtrooms))
 
 
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, validated_overrides, applied_overrides, unscheduled, active_cases
 
 
 
 
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(o for o in overrides if o.case_id == case.case_id and o.override_type == OverrideType.RIPENESS)
 
 
 
 
 
267
  applied_overrides.append(override)
268
  else:
269
- case.mark_unripe(RipenessStatus.UNRIPE_DEPENDENT, "Judge override", current_date)
 
 
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 = [o for o in overrides if o.override_type == OverrideType.ADD_CASE]
 
 
344
  for override in add_overrides:
345
  # Find case in full case list
346
- case_to_add = next((c for c in all_cases if c.case_id == override.case_id), None)
 
 
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 = override.new_position if override.new_position is not None else 0
 
 
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 = [o for o in overrides if o.override_type == OverrideType.REMOVE_CASE]
 
 
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 = [o for o in overrides if o.override_type == OverrideType.PRIORITY]
 
 
364
  for override in priority_overrides:
365
- case_to_adjust = next((c for c in result if c.case_id == override.case_id), None)
 
 
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(key=lambda c: getattr(c, '_priority_override', c.get_priority_score()), reverse=True)
 
 
 
377
 
378
  # Apply REORDER overrides (explicit positioning)
379
- reorder_overrides = [o for o in overrides if o.override_type == OverrideType.REORDER]
 
 
380
  for override in reorder_overrides:
381
  if override.case_id and override.new_position is not None:
382
- case_to_move = next((c for c in result if c.case_id == override.case_id), None)
 
 
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 scheduler.data.config import TERMINAL_STAGES
15
 
16
  if TYPE_CHECKING:
17
- from scheduler.core.ripeness import RipenessStatus
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
- PENDING = "pending" # Filed, awaiting first hearing
26
- ACTIVE = "active" # Has had at least one hearing
27
- ADJOURNED = "adjourned" # Last hearing was adjourned
28
- DISPOSED = "disposed" # Final disposal/settlement reached
 
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] = None # Purpose of last hearing (for classification)
 
 
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
- "date": current_date,
97
- "event": "stage_change",
98
- "stage": new_stage,
99
- })
100
-
101
- def record_hearing(self, hearing_date: date, was_heard: bool, outcome: str = "") -> None:
 
 
 
 
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
- "date": hearing_date,
120
- "event": "hearing",
121
- "was_heard": was_heard,
122
- "outcome": outcome,
123
- "stage": self.current_stage,
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 = (current_date - self.last_scheduled_date).days
 
 
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 age_component + readiness_component + urgency_component + adjournment_boost
 
 
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, 'value') else str(status)
259
  self.bottleneck_reason = reason
260
  self.ripeness_updated_at = current_date
261
 
262
  # Record in history
263
- self.history.append({
264
- "date": current_date,
265
- "event": "ripeness_change",
266
- "status": self.ripeness_status,
267
- "reason": reason,
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
- "date": current_date,
283
- "event": "ripeness_change",
284
- "status": "RIPE",
285
- "reason": "Case became ripe",
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 (f"Case(id={self.case_id}, type={self.case_type}, "
306
- f"stage={self.current_stage}, status={self.status.value}, "
307
- f"hearings={self.hearing_count})")
 
 
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() if self.last_hearing_date else None,
 
 
322
  "days_since_last_hearing": self.days_since_last_hearing,
323
  "age_days": self.age_days,
324
- "disposal_date": self.disposal_date.isoformat() if self.disposal_date else None,
 
 
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() if self.last_scheduled_date else None,
 
 
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 scheduler.data.config import DEFAULT_DAILY_CAPACITY
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(self, hearing_date: date, actual_hearings: int) -> None:
 
 
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 = actual_hearings / self.daily_capacity if self.daily_capacity > 0 else 0.0
161
-
162
- self.utilization_history.append({
163
- "date": hearing_date,
164
- "scheduled": scheduled,
165
- "actual": actual_hearings,
166
- "capacity": self.daily_capacity,
167
- "utilization": utilization,
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 if days_with_hearings > 0 else 0,
 
 
203
  "total_capacity": days_with_hearings * self.daily_capacity,
204
- "utilization_rate": total_scheduled / (days_with_hearings * self.daily_capacity)
205
- if days_with_hearings > 0 else 0,
 
 
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 (f"Courtroom(id={self.courtroom_id}, judge={self.judge_id}, "
216
- f"capacity={self.daily_capacity}, types={self.case_types})")
 
 
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 scheduler.core.case import Case
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 scheduler.core.case import Case
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 or days_in_stage >= cls.MIN_STAGE_DAYS
 
95
  )
96
 
97
  # Age-based maturity requirement
@@ -118,7 +115,9 @@ class RipenessClassifier:
118
  return False
119
 
120
  @classmethod
121
- def classify(cls, case: Case, current_date: datetime | None = None) -> RipenessStatus:
 
 
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(cls, case: Case, current_date: datetime | None = None) -> float:
 
 
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(cls, case: Case, current_date: datetime) -> timedelta | None:
 
 
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 scheduler.dashboard.utils import (
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 scheduler.core.ripeness import RipenessClassifier
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 scheduler.data.config import CASE_TYPE_DISTRIBUTION
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 scheduler.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 scheduler.data.config import MONTHLY_SEASONALITY
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 scheduler.core.case import Case, CaseStatus
16
- from scheduler.core.ripeness import RipenessClassifier, RipenessStatus
17
- from scheduler.dashboard.utils.data_loader import (
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 scheduler.output.cause_list import CauseListGenerator
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 scheduler.dashboard.utils.ui_input_parser import (
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 scheduler.data.case_generator import CaseGenerator
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 scheduler.dashboard.utils.ui_input_parser import (
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 scheduler.dashboard.utils.simulation_runner import (
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 scheduler.data.case_generator import CaseGenerator
17
- from scheduler.data.param_loader import ParameterLoader
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 = [d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")]
 
 
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 = [d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")]
 
 
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 = [d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")]
 
 
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(hearings_path: str = "data/generated/hearings.csv") -> pd.DataFrame:
 
 
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(columns=["case_id", "date", "stage", "purpose", "was_heard", "event"])
 
 
363
 
364
  try:
365
  df = pd.read_csv(chosen)
366
  except Exception:
367
- return pd.DataFrame(columns=["case_id", "date", "stage", "purpose", "was_heard", "event"])
 
 
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, key=lambda e: (e.get("date") or getattr(c, "filed_date", None) or 0)
 
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() if "CaseType" in df else {},
437
- "stages": df["Remappedstages"].value_counts().to_dict() if "Remappedstages" in df else {},
 
 
 
 
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"] = adjourned / total_hearings if total_hearings > 0 else 0
 
 
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 = [d for d in figures_dir.iterdir() if d.is_dir() and d.name.startswith("v")]
 
 
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 scheduler.data.case_generator import CaseGenerator
7
- from scheduler.simulation.engine import CourtSim, CourtSimConfig
8
- from scheduler.core.case import CaseStatus
9
- from scheduler.metrics.basic import gini
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 scheduler.core.case import Case
22
- from scheduler.data.config import (
23
  CASE_TYPE_DISTRIBUTION,
24
  MONTHLY_SEASONALITY,
25
  URGENT_CASE_PERCENTAGE,
26
  )
27
- from scheduler.data.param_loader import load_parameters
28
- from scheduler.utils.calendar import CourtCalendar
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 ["ARGUMENTS", "ORDERS / JUDGMENT", "FINAL DISPOSAL"]:
 
 
 
 
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 = c.history[-1]["purpose"] if c.history else None
 
 
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 scheduler.data.config import get_latest_params_dir
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]]] = None # stage -> {"median": x, "p90": y}
40
- self._transitions_map: Optional[Dict[str, List[tuple]]] = None # stage_from -> [(stage_to, cum_p), ...]
41
- self._adj_map: Optional[Dict[str, Dict[str, float]]] = None # stage -> {case_type: p_adj}
 
 
 
 
 
 
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(drop=True)
 
 
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] += (1.0 - s)
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 scheduler.monitoring.ripeness_calibrator import RipenessCalibrator, ThresholdAdjustment
4
- from scheduler.monitoring.ripeness_metrics import RipenessMetrics, RipenessPrediction
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 scheduler.monitoring.ripeness_metrics import RipenessMetrics
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 scheduler.core.ripeness import RipenessClassifier
 
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("Warning: Insufficient data for calibration (need at least 50 predictions)")
 
 
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(ThresholdAdjustment(
73
- threshold_name="MIN_SERVICE_HEARINGS",
74
- current_value=current_hearings,
75
- suggested_value=suggested_hearings,
76
- reason=(
77
- f"False positive rate {accuracy['false_positive_rate']:.1%} exceeds "
78
- f"{cls.HIGH_FALSE_POSITIVE_THRESHOLD:.0%}. Cases marked RIPE are adjourning. "
79
- f"Require more hearings as evidence of readiness."
80
- ),
81
- confidence="high",
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(ThresholdAdjustment(
89
- threshold_name="MIN_STAGE_DAYS",
90
- current_value=current_days,
91
- suggested_value=suggested_days,
92
- reason=(
93
- f"False negative rate {accuracy['false_negative_rate']:.1%} exceeds "
94
- f"{cls.HIGH_FALSE_NEGATIVE_THRESHOLD:.0%}. UNRIPE cases are progressing. "
95
- f"Relax stage maturity requirement."
96
- ),
97
- confidence="medium",
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(ThresholdAdjustment(
105
- threshold_name="MIN_CASE_AGE_DAYS",
106
- current_value=current_age,
107
- suggested_value=suggested_age,
108
- reason=(
109
- f"UNKNOWN rate {accuracy['unknown_rate']:.1%} below "
110
- f"{cls.LOW_UNKNOWN_THRESHOLD:.0%}. System is overconfident. "
111
- f"Increase case age requirement to add uncertainty for immature cases."
112
- ),
113
- confidence="medium",
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(ThresholdAdjustment(
121
- threshold_name="MIN_SERVICE_HEARINGS",
122
- current_value=current_hearings,
123
- suggested_value=suggested_hearings,
124
- reason=(
125
- f"RIPE precision {accuracy['ripe_precision']:.1%} below "
126
- f"{cls.LOW_RIPE_PRECISION_THRESHOLD:.0%}. Too many RIPE predictions fail. "
127
- f"Be more conservative in marking cases RIPE."
128
- ),
129
- confidence="high",
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(ThresholdAdjustment(
137
- threshold_name="MIN_STAGE_DAYS",
138
- current_value=current_days,
139
- suggested_value=suggested_days,
140
- reason=(
141
- f"UNRIPE recall {accuracy['unripe_recall']:.1%} below "
142
- f"{cls.LOW_UNRIPE_RECALL_THRESHOLD:.0%}. Missing many bottlenecks. "
143
- f"Increase stage maturity requirement to catch more unripe cases."
144
- ),
145
- confidence="medium",
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 confidence_order[adj.confidence] > confidence_order[existing.confidence]:
 
 
 
169
  threshold_map[adj.threshold_name] = adj
170
- elif confidence_order[adj.confidence] == confidence_order[existing.confidence]:
 
 
 
171
  # Same confidence - keep larger adjustment magnitude
172
- existing_delta = abs(existing.suggested_value - existing.current_value)
 
 
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
- "Recommended Adjustments:",
216
- " No adjustments needed - performance is within acceptable ranges.",
217
- "",
218
- "Current thresholds are performing well. Continue monitoring.",
219
- ])
 
 
220
  else:
221
- lines.extend([
222
- "Recommended Adjustments:",
223
- "",
224
- ])
 
 
225
 
226
  for i, adj in enumerate(adjustments, 1):
227
- lines.extend([
228
- f"{i}. {adj.threshold_name}",
229
- f" Current: {adj.current_value}",
230
- f" Suggested: {adj.suggested_value}",
231
- f" Confidence: {adj.confidence.upper()}",
232
- f" Reason: {adj.reason}",
 
 
 
 
 
 
 
 
 
 
 
 
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 scheduler.core.ripeness import RipenessClassifier
 
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 scheduler.core.ripeness import RipenessStatus
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 = [p for p in self.completed_predictions if p.predicted_status == RipenessStatus.RIPE]
112
- unripe_predictions = [p for p in self.completed_predictions if p.predicted_status.is_unripe()]
113
- unknown_predictions = [p for p in self.completed_predictions if p.predicted_status == RipenessStatus.UNKNOWN]
 
 
 
 
 
 
 
 
 
 
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 = len(false_positives) / len(ripe_predictions) if ripe_predictions else 0.0
 
 
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 = len(false_negatives) / len(unripe_predictions) if unripe_predictions else 0.0
 
 
 
 
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 = len(ripe_correct) / len(ripe_predictions) if ripe_predictions else 0.0
 
 
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 = len(unripe_correct) / len(adjourned_cases) if adjourned_cases else 0.0
 
 
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
- "case_id": pred.case_id,
181
- "predicted_status": pred.predicted_status.value,
182
- "prediction_date": pred.prediction_date,
183
- "actual_outcome": pred.actual_outcome,
184
- "was_adjourned": pred.was_adjourned,
185
- "outcome_date": pred.outcome_date,
186
- "correct_prediction": (
187
- (pred.predicted_status == RipenessStatus.RIPE and not pred.was_adjourned)
188
- or (pred.predicted_status.is_unripe() and pred.was_adjourned)
189
- ),
190
- })
 
 
 
 
 
191
 
192
  return pd.DataFrame(records)
193
 
@@ -237,16 +262,26 @@ class RipenessMetrics:
237
  ]
238
 
239
  # Add interpretation
240
- if metrics['false_positive_rate'] > 0.20:
241
- report_lines.append(" - HIGH false positive rate: Consider increasing MIN_SERVICE_HEARINGS")
242
- if metrics['false_negative_rate'] > 0.15:
243
- report_lines.append(" - HIGH false negative rate: Consider decreasing MIN_STAGE_DAYS")
244
- if metrics['unknown_rate'] < 0.05:
245
- report_lines.append(" - LOW UNKNOWN rate: System may be overconfident, add uncertainty")
246
- if metrics['ripe_precision'] > 0.85:
247
- report_lines.append(" - GOOD RIPE precision: Most RIPE predictions are correct")
248
- if metrics['unripe_recall'] < 0.60:
249
- report_lines.append(" - LOW UNRIPE recall: Missing many bottlenecks, refine detection")
 
 
 
 
 
 
 
 
 
 
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 scheduler.core.case import Case
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(default_factory=dict) # Type -> count
 
 
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 = {i: CourtroomState(courtroom_id=i) for i in range(1, num_courtrooms + 1)}
 
 
81
 
82
  # Metrics tracking
83
- self.daily_loads: dict[date, dict[int, int]] = {} # date -> {courtroom_id -> load}
 
 
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 = {cid: total / num_days for cid, total in courtroom_totals.items()}
 
 
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 scheduler.core.algorithm import SchedulingAlgorithm, SchedulingResult
23
- from scheduler.core.case import Case, CaseStatus
24
- from scheduler.core.courtroom import Courtroom
25
- from scheduler.core.ripeness import RipenessClassifier
26
- from scheduler.data.config import (
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 scheduler.data.param_loader import load_parameters
35
- from scheduler.simulation.allocator import AllocationStrategy, CourtroomAllocator
36
- from scheduler.simulation.events import EventWriter
37
- from scheduler.simulation.policies import get_policy
38
- from scheduler.utils.calendar import CourtCalendar
39
 
40
 
41
  @dataclass
@@ -110,7 +110,9 @@ class CourtSim:
110
  # resources
111
  self.rooms = [
112
  Courtroom(
113
- courtroom_id=i + 1, judge_id=f"J{i + 1:03d}", daily_capacity=self.cfg.daily_capacity
 
 
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, allocator=self.allocator, min_gap_days=MIN_GAP_BETWEEN_HEARINGS
 
 
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(self.params.get_stage_duration(c.current_stage, self.cfg.duration_percentile))
 
 
 
 
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 = ["ORDERS / JUDGMENT", "ARGUMENTS", "ADMISSION", "FINAL DISPOSAL"]
 
 
 
 
 
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(case.current_stage, self.cfg.duration_percentile)
 
 
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(case.case_id, current).isoformat(),
 
 
428
  ]
429
  )
430
  # outcome
431
  if self._sample_adjournment(case.current_stage, case.case_type):
432
- case.record_hearing(current, was_heard=False, outcome="adjourned")
 
 
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(case.case_id, current):
 
 
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 or next_stage in TERMINAL_STAGES
 
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, self.cfg.duration_percentile
 
506
  )
507
  )
508
  )
509
  dur = max(1, dur)
510
- self._stage_ready[case.case_id] = current + timedelta(days=dur)
 
 
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(days=self.cfg.days + 60) # pad for weekends/holidays
 
 
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 = (self._hearings_total / self._capacity_offered) if self._capacity_offered else 0.0
 
 
 
 
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 = [c for c in self.cases if c.last_scheduled_date is not None]
 
 
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(c.hearing_count for c in disposed_cases) / len(
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 scheduler.core.policy import SchedulerPolicy
4
- from scheduler.simulation.policies.age import AgeBasedPolicy
5
- from scheduler.simulation.policies.fifo import FIFOPolicy
6
- from scheduler.simulation.policies.readiness import ReadinessPolicy
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__ = ["SchedulerPolicy", "FIFOPolicy", "AgeBasedPolicy", "ReadinessPolicy", "get_policy"]
 
 
 
 
 
 
 
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 scheduler.core.case import Case
12
- from scheduler.core.policy import SchedulerPolicy
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 scheduler.core.case import Case
12
- from scheduler.core.policy import SchedulerPolicy
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 scheduler.core.case import Case
15
- from scheduler.core.policy import SchedulerPolicy
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 scheduler.data.config import (
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 scheduler.core.case import Case, CaseStatus
19
- from scheduler.core.courtroom import Courtroom
20
- from scheduler.data.case_generator import CaseGenerator
21
- from scheduler.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("markers", "integration: Integration tests for multi-component workflows")
 
 
29
  config.addinivalue_line("markers", "rl: Reinforcement learning tests")
30
  config.addinivalue_line("markers", "simulation: Simulation engine tests")
31
- config.addinivalue_line("markers", "edge_case: Edge case and boundary condition tests")
 
 
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, 'service_status'):
106
  case.service_status = "SERVED"
107
- if hasattr(case, 'compliance_status'):
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, 'service_status'):
131
  case.service_status = "PENDING"
132
- if hasattr(case, 'last_hearing_purpose'):
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 scheduler.data.case_generator import CaseGenerator
11
- from scheduler.simulation.engine import CourtSim, CourtSimConfig
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 result.hearings_heard + result.hearings_adjourned == result.hearings_total
 
 
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 for case in sample_cases
 
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 scheduler.data.config import TERMINAL_STAGES
 
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(start=date(2024, 1, 1), end=date(2024, 1, 10), seed=42)
 
 
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