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