paramjitbaral commited on
Commit
ab69aa8
·
verified ·
1 Parent(s): e318bcb

Upload folder using huggingface_hub

Browse files
app/environment/core.py CHANGED
@@ -1,6 +1,6 @@
1
  import json
2
  from pathlib import Path
3
- from typing import Any, Callable, Literal, TypedDict, cast
4
 
5
  from app.environment.graders import grade_task
6
  from app.environment.scenarios.accident import generate_accident_case
@@ -15,17 +15,7 @@ from app.utils.calculations import compute_speed_kmh, compute_travel_time_minute
15
  from app.utils.randomizer import SeededRandomizer
16
 
17
 
18
- TaskId = Literal["acde_easy", "acde_medium", "acde_hard"]
19
- Difficulty = Literal["easy", "medium", "hard"]
20
- ScenarioType = Literal["medical", "accident", "fire"]
21
-
22
-
23
- class TaskConfig(TypedDict):
24
- difficulty: Difficulty
25
- objective: str
26
-
27
-
28
- TASKS: dict[TaskId, TaskConfig] = {
29
  "acde_easy": {
30
  "difficulty": "easy",
31
  "objective": "Stabilize quickly while information is mostly reliable.",
@@ -40,17 +30,13 @@ TASKS: dict[TaskId, TaskConfig] = {
40
  },
41
  }
42
 
43
- MIN_REWARD = 0.01
44
- MAX_REWARD = 0.99
45
 
46
  OUTCOME_SCORE = {"accepted": 3, "partial": 2, "rejected": 1}
47
  CONDITION_ORDER = ["stable", "serious", "unstable", "critical"]
48
 
49
 
50
- def _clamp(value: float) -> float:
51
- return max(MIN_REWARD, min(MAX_REWARD, float(value)))
52
-
53
-
54
  class EmergencyEnv:
55
  """Stateful local RL environment for emergency routing under uncertainty."""
56
 
@@ -73,9 +59,7 @@ class EmergencyEnv:
73
  if seed is None:
74
  seed = self._rng.randint(1, 10**9)
75
 
76
- resolved_task_id: TaskId = (
77
- cast(TaskId, task_id) if task_id in TASKS else cast(TaskId, self._rng.choice(list(TASKS.keys())))
78
- )
79
  difficulty = TASKS[resolved_task_id]["difficulty"]
80
 
81
  self._rng = SeededRandomizer(seed)
@@ -98,11 +82,11 @@ class EmergencyEnv:
98
  self.state_data = EnvState(
99
  episode_id=self.episode_counter,
100
  seed=seed,
101
- task_id=resolved_task_id,
102
  task_objective=TASKS[resolved_task_id]["objective"],
103
- scenario_type=scenario_type,
104
  scenario_name=scenario["scenario_name"],
105
- scenario_difficulty=difficulty,
106
  patient_condition=scenario["patient_condition"],
107
  required_specialization=scenario["required_specialization"],
108
  initial_critical_time_limit_minutes=scenario["critical_time_limit_minutes"],
@@ -121,7 +105,7 @@ class EmergencyEnv:
121
  failed_hospitals=[],
122
  recent_failed_hospitals=[],
123
  failed_reasons={},
124
- total_time_spent_minutes=0,
125
  rerouting_reason=None,
126
  last_arrival_outcome=None,
127
  accepted_hospital_id=None,
@@ -136,8 +120,8 @@ class EmergencyEnv:
136
  )
137
 
138
  self.last_info = StepInfo(
139
- task_id=resolved_task_id,
140
- difficulty=difficulty,
141
  objective=TASKS[resolved_task_id]["objective"],
142
  progress_score=MIN_REWARD,
143
  reward_model=RewardModel(
@@ -149,7 +133,12 @@ class EmergencyEnv:
149
  delay_penalty=MIN_REWARD,
150
  ),
151
  ),
152
- grader=None,
 
 
 
 
 
153
  last_action_error=None,
154
  outcome=None,
155
  )
@@ -200,7 +189,7 @@ class EmergencyEnv:
200
  "medium": 0.25,
201
  "hard": 0.45,
202
  }.get(self.state_data.scenario_difficulty, 0.25)
203
- dynamic_delay = self._rng.uniform(0.5, 2.5) if self._rng.random() < delay_probability else 0
204
  travel_time += dynamic_delay
205
 
206
  selected, travel_time, enroute_note = self._apply_enroute_diversion(selected, travel_time)
@@ -439,13 +428,13 @@ class EmergencyEnv:
439
  unknown_critical_penalty = (
440
  0.12
441
  if critical_patient and selected.icu_display == "unknown"
442
- else 0
443
  )
444
- repeat_penalty = 0.15 if was_visited_before else 0
445
- failed_repeat_penalty = 0.20 if was_failed_before else 0
446
- traffic_penalty = 0.10 if critical_patient and selected.traffic == "high" else 0.04 if critical_patient and selected.traffic == "medium" else 0
447
 
448
- time_bonus = 0.06 if travel_time <= 8.0 else (0.03 if travel_time <= 14.0 else 0)
449
 
450
  improvement_bonus = self._improvement_bonus(arrival_outcome.status)
451
 
@@ -470,7 +459,7 @@ class EmergencyEnv:
470
  )
471
  breakdown = RewardBreakdown(
472
  survival_component=max(MIN_REWARD, min(MAX_REWARD, (status_reward + 0.5) / 1.5)),
473
- time_efficiency_component=max(MIN_REWARD, min(MAX_REWARD, 1 - (travel_time / 25.0))),
474
  specialization_component=max(MIN_REWARD, min(MAX_REWARD, MAX_REWARD if self._specialization_match(selected) else 0.4)),
475
  delay_penalty=max(MIN_REWARD, min(MAX_REWARD, raw_delay)),
476
  )
@@ -502,7 +491,7 @@ class EmergencyEnv:
502
  condition = self.state_data.patient_condition
503
  idx = CONDITION_ORDER.index(condition) if condition in CONDITION_ORDER else 2
504
 
505
- deterioration_risk = 0
506
  if travel_time > 12.0:
507
  deterioration_risk += 0.20
508
  if dynamic_delay > 0:
@@ -649,7 +638,7 @@ class EmergencyEnv:
649
  "easy": self._rng.uniform(0.4, 1.1),
650
  "medium": self._rng.uniform(0.8, 1.8),
651
  "hard": self._rng.uniform(1.2, 2.6),
652
- }.get(self.state_data.scenario_difficulty, self._rng.uniform(1, 2.2))
653
 
654
  note = (
655
  f"Hidden case: severe traffic lock en-route to {selected.hospital_id}; "
@@ -692,7 +681,7 @@ class EmergencyEnv:
692
  status="rejected",
693
  reason="Hidden mismatch at arrival (wrong risky guess). Rerouting required.",
694
  validation_details=arrival_outcome.validation_details,
695
- reward_modifier=0,
696
  )
697
  return (
698
  forced_reject,
@@ -770,7 +759,7 @@ class EmergencyEnv:
770
  status="rejected",
771
  reason=reason,
772
  validation_details=new_validation,
773
- reward_modifier=0,
774
  ),
775
  0.12,
776
  f"Hidden case: {reason}. Rerouting required.",
@@ -821,7 +810,7 @@ class EmergencyEnv:
821
  status="rejected",
822
  reason="Condition became irreversible after delays",
823
  validation_details=arrival_outcome.validation_details,
824
- reward_modifier=0,
825
  ),
826
  "Partial chain cap: condition became irreversible.",
827
  )
@@ -899,7 +888,7 @@ class EmergencyEnv:
899
  status="rejected",
900
  reason="Condition relapsed after temporary stabilization",
901
  validation_details=arrival_outcome.validation_details,
902
- reward_modifier=0,
903
  terminal=False,
904
  ),
905
  "Recovery guard: partial relapsed to rejected.",
@@ -924,14 +913,14 @@ class EmergencyEnv:
924
  ) -> None:
925
  assert self.state_data is not None
926
 
927
- grader_result = None
928
- if self.state_data.done:
929
- grader_result = grade_task(
930
- task_id=self.state_data.task_id,
931
- difficulty=self.state_data.scenario_difficulty,
932
- objective=self.state_data.task_objective,
933
- trajectory=self.trajectory,
934
- )
935
 
936
  self.last_info = StepInfo(
937
  task_id=self.state_data.task_id,
@@ -1067,15 +1056,14 @@ class EmergencyEnv:
1067
  return "medium"
1068
  return "high"
1069
 
1070
- def _sample_scenario_for_difficulty(self, difficulty: Difficulty) -> tuple[dict[str, Any], ScenarioType]:
1071
- generators: list[tuple[Callable[[SeededRandomizer], dict[str, Any]], ScenarioType]] = [
1072
  (generate_medical_case, "medical"),
1073
  (generate_accident_case, "accident"),
1074
  (generate_fire_case, "fire"),
1075
  ]
1076
- fallback_generator, fallback_type = generators[0]
1077
- fallback_scenario = fallback_generator(self._rng)
1078
-
1079
  for _ in range(64):
1080
  generator, scenario_type = self._rng.choice(generators)
1081
  scenario = generator(self._rng)
@@ -1086,7 +1074,7 @@ class EmergencyEnv:
1086
  scenario = generator(self._rng)
1087
  if scenario["difficulty"] == difficulty:
1088
  return scenario, scenario_type
1089
- return fallback_scenario, fallback_type
1090
 
1091
  def _find_hospital(self, hospital_id: str) -> HospitalState | None:
1092
  assert self.state_data is not None
@@ -1115,9 +1103,9 @@ class EmergencyEnv:
1115
 
1116
  total = entry.success + entry.fail
1117
  if total == 1:
1118
- entry.avg = _clamp(reward)
1119
  else:
1120
- normalized_reward = _clamp(reward)
1121
  entry.avg = ((entry.avg * (total - 1)) + normalized_reward) / total
1122
 
1123
  memory[hospital_id] = entry
@@ -1128,26 +1116,24 @@ class EmergencyEnv:
1128
  if not self.trajectory:
1129
  return MIN_REWARD
1130
  raw = sum(float(t["reward"]) for t in self.trajectory) / len(self.trajectory)
1131
- return _clamp(raw)
1132
 
1133
  def _failure_score(self) -> float:
1134
  assert self.state_data is not None
1135
  progress_component = self._progress_score()
1136
- reward_component = _clamp(self.state_data.reward)
1137
  score = 0.15 + (0.35 * reward_component) + (0.25 * progress_component)
1138
- return _clamp(max(0.1, min(0.85, score)))
1139
 
1140
  def _success_score(self) -> float:
1141
  assert self.state_data is not None
1142
  progress_component = self._progress_score()
1143
- reward_component = _clamp(self.state_data.reward)
1144
  total_steps = max(1, len(self.trajectory))
1145
  rejected_steps = sum(1 for item in self.trajectory if item.get("outcome_status") == "rejected")
1146
- route_quality = _clamp(1 - (rejected_steps / total_steps))
1147
  score = (0.45 * reward_component) + (0.40 * progress_component) + (0.15 * route_quality)
1148
- return _clamp(max(0.25, min(0.99, score)))
1149
 
1150
 
1151
  ACDEEnvironment = EmergencyEnv
1152
-
1153
-
 
1
  import json
2
  from pathlib import Path
3
+ from typing import Any, Literal, cast
4
 
5
  from app.environment.graders import grade_task
6
  from app.environment.scenarios.accident import generate_accident_case
 
15
  from app.utils.randomizer import SeededRandomizer
16
 
17
 
18
+ TASKS = {
 
 
 
 
 
 
 
 
 
 
19
  "acde_easy": {
20
  "difficulty": "easy",
21
  "objective": "Stabilize quickly while information is mostly reliable.",
 
30
  },
31
  }
32
 
33
+ MIN_REWARD = 0.001
34
+ MAX_REWARD = 0.999
35
 
36
  OUTCOME_SCORE = {"accepted": 3, "partial": 2, "rejected": 1}
37
  CONDITION_ORDER = ["stable", "serious", "unstable", "critical"]
38
 
39
 
 
 
 
 
40
  class EmergencyEnv:
41
  """Stateful local RL environment for emergency routing under uncertainty."""
42
 
 
59
  if seed is None:
60
  seed = self._rng.randint(1, 10**9)
61
 
62
+ resolved_task_id = cast(Literal["acde_easy", "acde_medium", "acde_hard"], task_id if task_id in TASKS else self._rng.choice(list(TASKS.keys())))
 
 
63
  difficulty = TASKS[resolved_task_id]["difficulty"]
64
 
65
  self._rng = SeededRandomizer(seed)
 
82
  self.state_data = EnvState(
83
  episode_id=self.episode_counter,
84
  seed=seed,
85
+ task_id=cast(Literal["acde_easy", "acde_medium", "acde_hard"], resolved_task_id),
86
  task_objective=TASKS[resolved_task_id]["objective"],
87
+ scenario_type=cast(Literal["medical", "accident", "fire"], scenario_type),
88
  scenario_name=scenario["scenario_name"],
89
+ scenario_difficulty=cast(Literal["easy", "medium", "hard"], difficulty),
90
  patient_condition=scenario["patient_condition"],
91
  required_specialization=scenario["required_specialization"],
92
  initial_critical_time_limit_minutes=scenario["critical_time_limit_minutes"],
 
105
  failed_hospitals=[],
106
  recent_failed_hospitals=[],
107
  failed_reasons={},
108
+ total_time_spent_minutes=0.0,
109
  rerouting_reason=None,
110
  last_arrival_outcome=None,
111
  accepted_hospital_id=None,
 
120
  )
121
 
122
  self.last_info = StepInfo(
123
+ task_id=cast(Literal["acde_easy", "acde_medium", "acde_hard"], resolved_task_id),
124
+ difficulty=cast(Literal["easy", "medium", "hard"], difficulty),
125
  objective=TASKS[resolved_task_id]["objective"],
126
  progress_score=MIN_REWARD,
127
  reward_model=RewardModel(
 
133
  delay_penalty=MIN_REWARD,
134
  ),
135
  ),
136
+ grader=grade_task(
137
+ task_id=resolved_task_id,
138
+ difficulty=difficulty,
139
+ objective=TASKS[resolved_task_id]["objective"],
140
+ trajectory=[],
141
+ ),
142
  last_action_error=None,
143
  outcome=None,
144
  )
 
189
  "medium": 0.25,
190
  "hard": 0.45,
191
  }.get(self.state_data.scenario_difficulty, 0.25)
192
+ dynamic_delay = self._rng.uniform(0.5, 2.5) if self._rng.random() < delay_probability else 0.0
193
  travel_time += dynamic_delay
194
 
195
  selected, travel_time, enroute_note = self._apply_enroute_diversion(selected, travel_time)
 
428
  unknown_critical_penalty = (
429
  0.12
430
  if critical_patient and selected.icu_display == "unknown"
431
+ else 0.0
432
  )
433
+ repeat_penalty = 0.15 if was_visited_before else 0.0
434
+ failed_repeat_penalty = 0.20 if was_failed_before else 0.0
435
+ traffic_penalty = 0.10 if critical_patient and selected.traffic == "high" else 0.04 if critical_patient and selected.traffic == "medium" else 0.0
436
 
437
+ time_bonus = 0.06 if travel_time <= 8.0 else (0.03 if travel_time <= 14.0 else 0.0)
438
 
439
  improvement_bonus = self._improvement_bonus(arrival_outcome.status)
440
 
 
459
  )
460
  breakdown = RewardBreakdown(
461
  survival_component=max(MIN_REWARD, min(MAX_REWARD, (status_reward + 0.5) / 1.5)),
462
+ time_efficiency_component=max(MIN_REWARD, min(MAX_REWARD, 1.0 - (travel_time / 25.0))),
463
  specialization_component=max(MIN_REWARD, min(MAX_REWARD, MAX_REWARD if self._specialization_match(selected) else 0.4)),
464
  delay_penalty=max(MIN_REWARD, min(MAX_REWARD, raw_delay)),
465
  )
 
491
  condition = self.state_data.patient_condition
492
  idx = CONDITION_ORDER.index(condition) if condition in CONDITION_ORDER else 2
493
 
494
+ deterioration_risk = 0.0
495
  if travel_time > 12.0:
496
  deterioration_risk += 0.20
497
  if dynamic_delay > 0:
 
638
  "easy": self._rng.uniform(0.4, 1.1),
639
  "medium": self._rng.uniform(0.8, 1.8),
640
  "hard": self._rng.uniform(1.2, 2.6),
641
+ }.get(self.state_data.scenario_difficulty, self._rng.uniform(1.0, 2.2))
642
 
643
  note = (
644
  f"Hidden case: severe traffic lock en-route to {selected.hospital_id}; "
 
681
  status="rejected",
682
  reason="Hidden mismatch at arrival (wrong risky guess). Rerouting required.",
683
  validation_details=arrival_outcome.validation_details,
684
+ reward_modifier=0.0,
685
  )
686
  return (
687
  forced_reject,
 
759
  status="rejected",
760
  reason=reason,
761
  validation_details=new_validation,
762
+ reward_modifier=0.0,
763
  ),
764
  0.12,
765
  f"Hidden case: {reason}. Rerouting required.",
 
810
  status="rejected",
811
  reason="Condition became irreversible after delays",
812
  validation_details=arrival_outcome.validation_details,
813
+ reward_modifier=0.0,
814
  ),
815
  "Partial chain cap: condition became irreversible.",
816
  )
 
888
  status="rejected",
889
  reason="Condition relapsed after temporary stabilization",
890
  validation_details=arrival_outcome.validation_details,
891
+ reward_modifier=0.0,
892
  terminal=False,
893
  ),
894
  "Recovery guard: partial relapsed to rejected.",
 
913
  ) -> None:
914
  assert self.state_data is not None
915
 
916
+ # Always expose a bounded task score snapshot so external validators
917
+ # never see a missing grader and fallback to 0.0/1.0.
918
+ grader_result = grade_task(
919
+ task_id=self.state_data.task_id,
920
+ difficulty=self.state_data.scenario_difficulty,
921
+ objective=self.state_data.task_objective,
922
+ trajectory=self.trajectory,
923
+ )
924
 
925
  self.last_info = StepInfo(
926
  task_id=self.state_data.task_id,
 
1056
  return "medium"
1057
  return "high"
1058
 
1059
+ def _sample_scenario_for_difficulty(self, difficulty: str) -> tuple[dict[str, Any], str]:
1060
+ generators = [
1061
  (generate_medical_case, "medical"),
1062
  (generate_accident_case, "accident"),
1063
  (generate_fire_case, "fire"),
1064
  ]
1065
+ scenario: dict[str, Any] = {}
1066
+ scenario_type = "medical"
 
1067
  for _ in range(64):
1068
  generator, scenario_type = self._rng.choice(generators)
1069
  scenario = generator(self._rng)
 
1074
  scenario = generator(self._rng)
1075
  if scenario["difficulty"] == difficulty:
1076
  return scenario, scenario_type
1077
+ return scenario, scenario_type
1078
 
1079
  def _find_hospital(self, hospital_id: str) -> HospitalState | None:
1080
  assert self.state_data is not None
 
1103
 
1104
  total = entry.success + entry.fail
1105
  if total == 1:
1106
+ entry.avg = max(0.0, min(1.0, reward))
1107
  else:
1108
+ normalized_reward = max(0.0, min(1.0, reward))
1109
  entry.avg = ((entry.avg * (total - 1)) + normalized_reward) / total
1110
 
1111
  memory[hospital_id] = entry
 
1116
  if not self.trajectory:
1117
  return MIN_REWARD
1118
  raw = sum(float(t["reward"]) for t in self.trajectory) / len(self.trajectory)
1119
+ return max(MIN_REWARD, min(MAX_REWARD, raw))
1120
 
1121
  def _failure_score(self) -> float:
1122
  assert self.state_data is not None
1123
  progress_component = self._progress_score()
1124
+ reward_component = max(MIN_REWARD, min(MAX_REWARD, self.state_data.reward))
1125
  score = 0.15 + (0.35 * reward_component) + (0.25 * progress_component)
1126
+ return max(MIN_REWARD, min(MAX_REWARD, max(0.1, min(0.85, score))))
1127
 
1128
  def _success_score(self) -> float:
1129
  assert self.state_data is not None
1130
  progress_component = self._progress_score()
1131
+ reward_component = max(MIN_REWARD, min(MAX_REWARD, self.state_data.reward))
1132
  total_steps = max(1, len(self.trajectory))
1133
  rejected_steps = sum(1 for item in self.trajectory if item.get("outcome_status") == "rejected")
1134
+ route_quality = max(0.0, 1.0 - (rejected_steps / total_steps))
1135
  score = (0.45 * reward_component) + (0.40 * progress_component) + (0.15 * route_quality)
1136
+ return max(MIN_REWARD, min(MAX_REWARD, max(0.25, min(0.99, score))))
1137
 
1138
 
1139
  ACDEEnvironment = EmergencyEnv
 
 
app/environment/graders.py CHANGED
@@ -1,10 +1,10 @@
1
- from typing import Literal
2
 
3
  from app.models.reward import GraderResult
4
 
5
 
6
- MIN_SCORE = 0.01
7
- MAX_SCORE = 0.99
8
 
9
 
10
  def _norm_margin(travel_time: float, critical_limit: float) -> float:
@@ -14,8 +14,8 @@ def _norm_margin(travel_time: float, critical_limit: float) -> float:
14
 
15
 
16
  def grade_task(
17
- task_id: Literal["acde_easy", "acde_medium", "acde_hard"],
18
- difficulty: Literal["easy", "medium", "hard"],
19
  objective: str,
20
  trajectory: list[dict],
21
  ) -> GraderResult:
@@ -27,21 +27,21 @@ def grade_task(
27
 
28
  # Count successful outcomes (accepted or partial admission)
29
  successful_outcomes = sum(
30
- 1 for t in trajectory
31
  if t.get("outcome_status") in ["accepted", "partial"]
32
  )
33
  success_rate = successful_outcomes / steps
34
 
35
  # Specialization match rate
36
  specialization_rate = sum(
37
- 1 for t in trajectory if t.get("specialization_match", False)
38
  ) / steps
39
 
40
  # Time efficiency (based on travel times)
41
  margin_rate = sum(
42
- _norm_margin(t.get("travel_time", 0), t.get("critical_limit", 1))
43
  for t in trajectory
44
- ) / steps if trajectory else 0
45
 
46
  # Penalty for repeated failures at same hospital
47
  repeat_failures = 0
@@ -55,7 +55,7 @@ def grade_task(
55
  repeat_failures += 1
56
  visited_by_status[hospital_id] = status
57
 
58
- repeat_failure_penalty = min(1, repeat_failures / steps)
59
 
60
  # Suitability component (how well hospital matched patient)
61
  avg_suitability = sum(
@@ -63,7 +63,7 @@ def grade_task(
63
  ) / steps
64
 
65
  # Adaptive penalty: worse when early rejections vs later recovery
66
- adaptability_bonus = 0
67
  if len(trajectory) >= 2:
68
  outcomes = [t.get("outcome_status") for t in trajectory]
69
  if "rejected" in outcomes[:-1] and outcomes[-1] in ["accepted", "partial"]:
@@ -86,14 +86,14 @@ def grade_task(
86
  score = base
87
  else: # hard
88
  threshold = 0.53
89
- hard_bonus = 0.15 if success_rate >= 0.5 else (0.05 if success_rate > 0 else MIN_SCORE)
90
  score = min(MAX_SCORE, base + hard_bonus)
91
 
92
  score = max(MIN_SCORE, min(MAX_SCORE, score))
93
 
94
  return GraderResult(
95
- task_id=task_id,
96
- difficulty=difficulty,
97
  objective=objective,
98
  score=score,
99
  passed=score >= threshold,
@@ -107,5 +107,3 @@ def grade_task(
107
  "threshold": threshold,
108
  },
109
  )
110
-
111
-
 
1
+ from typing import cast, Literal
2
 
3
  from app.models.reward import GraderResult
4
 
5
 
6
+ MIN_SCORE = 0.001
7
+ MAX_SCORE = 0.999
8
 
9
 
10
  def _norm_margin(travel_time: float, critical_limit: float) -> float:
 
14
 
15
 
16
  def grade_task(
17
+ task_id: str,
18
+ difficulty: str,
19
  objective: str,
20
  trajectory: list[dict],
21
  ) -> GraderResult:
 
27
 
28
  # Count successful outcomes (accepted or partial admission)
29
  successful_outcomes = sum(
30
+ 1.0 for t in trajectory
31
  if t.get("outcome_status") in ["accepted", "partial"]
32
  )
33
  success_rate = successful_outcomes / steps
34
 
35
  # Specialization match rate
36
  specialization_rate = sum(
37
+ 1.0 for t in trajectory if t.get("specialization_match", False)
38
  ) / steps
39
 
40
  # Time efficiency (based on travel times)
41
  margin_rate = sum(
42
+ _norm_margin(t.get("travel_time", 0.0), t.get("critical_limit", 1.0))
43
  for t in trajectory
44
+ ) / steps if trajectory else 0.0
45
 
46
  # Penalty for repeated failures at same hospital
47
  repeat_failures = 0
 
55
  repeat_failures += 1
56
  visited_by_status[hospital_id] = status
57
 
58
+ repeat_failure_penalty = min(1.0, repeat_failures / steps)
59
 
60
  # Suitability component (how well hospital matched patient)
61
  avg_suitability = sum(
 
63
  ) / steps
64
 
65
  # Adaptive penalty: worse when early rejections vs later recovery
66
+ adaptability_bonus = 0.0
67
  if len(trajectory) >= 2:
68
  outcomes = [t.get("outcome_status") for t in trajectory]
69
  if "rejected" in outcomes[:-1] and outcomes[-1] in ["accepted", "partial"]:
 
86
  score = base
87
  else: # hard
88
  threshold = 0.53
89
+ hard_bonus = 0.15 if success_rate >= 0.5 else (0.05 if success_rate > 0.0 else MIN_SCORE)
90
  score = min(MAX_SCORE, base + hard_bonus)
91
 
92
  score = max(MIN_SCORE, min(MAX_SCORE, score))
93
 
94
  return GraderResult(
95
+ task_id=cast(Literal["acde_easy", "acde_medium", "acde_hard"], task_id),
96
+ difficulty=cast(Literal["easy", "medium", "hard"], difficulty),
97
  objective=objective,
98
  score=score,
99
  passed=score >= threshold,
 
107
  "threshold": threshold,
108
  },
109
  )
 
 
app/environment/validation.py CHANGED
@@ -1,25 +1,15 @@
1
- """Hospital validation engine for realistic arrival outcomes.
2
 
3
  Simulates hidden validation checks performed when an ambulance arrives at a hospital.
4
  Outcomes are based on difficulty level, hospital capacity, patient suitability, and randomness.
5
  """
6
 
7
- from typing import Literal
8
 
9
  from app.models.state import ArrivalOutcome, HospitalValidationDetails, HospitalState
10
  from app.utils.randomizer import SeededRandomizer
11
 
12
 
13
- STRICT_SCORE_MIN = 0.01
14
- STRICT_SCORE_MAX = 0.99
15
- OverloadStatus = Literal["clear", "moderate", "severe"]
16
- OutcomeStatus = Literal["accepted", "partial", "rejected"]
17
-
18
-
19
- def _clamp(value: float) -> float:
20
- return max(STRICT_SCORE_MIN, min(STRICT_SCORE_MAX, float(value)))
21
-
22
-
23
  class HospitalValidator:
24
  """Performs hidden validation checks on arrival and returns outcome."""
25
 
@@ -76,7 +66,7 @@ class HospitalValidator:
76
  icu_available=icu_available,
77
  doctor_available=doctor_available,
78
  equipment_functional=equipment_functional,
79
- overload_status=overload_status,
80
  patient_suitability=patient_suitability,
81
  )
82
 
@@ -91,7 +81,7 @@ class HospitalValidator:
91
  )
92
 
93
  return ArrivalOutcome(
94
- status=status,
95
  reason=reason,
96
  validation_details=validation_details,
97
  reward_modifier=reward_modifier,
@@ -107,11 +97,11 @@ class HospitalValidator:
107
  }.get(difficulty, 0.70)
108
 
109
  # Displayed status influences belief but does not fully determine truth.
110
- display_adjust = 0
111
  if hospital.icu_display == "available":
112
  display_adjust = 0.06 if difficulty == "easy" else (0.04 if difficulty == "medium" else 0.02)
113
  else: # unknown
114
- display_adjust = -0.03 if difficulty == "easy" else (-0.02 if difficulty == "medium" else 0)
115
 
116
  p = max(0.05, min(0.97, base_true_prob + display_adjust))
117
  return self.rng.random() < p
@@ -142,7 +132,7 @@ class HospitalValidator:
142
  }.get(difficulty, 0.90)
143
  return self.rng.random() < equipment_working_prob
144
 
145
- def _check_hospital_overload(self, difficulty: str) -> OverloadStatus:
146
  """Determine hospital overload status: clear, moderate, or severe."""
147
  overload_prob = {
148
  "easy": 0.10,
@@ -164,11 +154,11 @@ class HospitalValidator:
164
  hospital: HospitalState,
165
  patient_condition: str,
166
  required_specialization: str,
167
- overload_status: OverloadStatus,
168
  difficulty: str,
169
  ) -> float:
170
  """
171
- Compute how suitable this hospital is for the patient (0 to 1).
172
  Based on specialization match, condition severity, and overload.
173
  """
174
  # Specialization match basis
@@ -190,7 +180,7 @@ class HospitalValidator:
190
 
191
  # Hospital overload impact
192
  overload_impact = {
193
- "clear": 1,
194
  "moderate": 0.7,
195
  "severe": 0.4,
196
  }
@@ -202,9 +192,9 @@ class HospitalValidator:
202
  # Add difficulty-based noise
203
  if difficulty == "hard":
204
  noise = self.rng.uniform(-0.15, 0.15)
205
- suitability = _clamp(suitability + noise)
206
 
207
- return _clamp(suitability)
208
 
209
  def _determine_outcome(
210
  self,
@@ -215,7 +205,7 @@ class HospitalValidator:
215
  specialization_match: bool,
216
  difficulty: str,
217
  step_number: int,
218
- ) -> tuple[OutcomeStatus, str, float, bool]:
219
  """
220
  Determine final outcome (accepted, partial, or rejected) based on validation.
221
 
@@ -281,7 +271,7 @@ class HospitalValidator:
281
  return (
282
  "rejected",
283
  f"Hospital cannot admit: {', '.join(rejection_reasons[:2])}",
284
- 0,
285
  False,
286
  )
287
 
@@ -357,7 +347,7 @@ class HospitalValidator:
357
  return (
358
  "rejected",
359
  "Condition became non-transferable during delay; immediate critical care failed",
360
- 0,
361
  True,
362
  )
363
 
@@ -369,14 +359,14 @@ class HospitalValidator:
369
  )
370
 
371
  # Full acceptance
372
- confidence_bonus = 1
373
  if validation.patient_suitability >= 0.8:
374
  confidence_bonus = 1.1
375
  elif validation.patient_suitability >= 0.7:
376
  confidence_bonus = 1.05
377
 
378
  # Arrival uncertainty by difficulty.
379
- reject_prob = 0
380
  if difficulty == "medium":
381
  reject_prob = 0.2
382
  elif difficulty == "hard":
@@ -384,11 +374,11 @@ class HospitalValidator:
384
  reject_prob += 0.10
385
  reject_prob += 0.08
386
 
387
- if reject_prob > 0 and self.rng.random() < reject_prob:
388
  return (
389
  "rejected",
390
  "Unexpected complication at arrival",
391
- 0,
392
  False,
393
  )
394
 
@@ -396,7 +386,7 @@ class HospitalValidator:
396
  return (
397
  "accepted",
398
  "successful admission under uncertainty",
399
- 1,
400
  False,
401
  )
402
 
@@ -412,7 +402,7 @@ class HospitalValidator:
412
  False,
413
  )
414
 
415
- accepted_prob = 1
416
  if difficulty == "hard":
417
  accepted_prob *= 0.65
418
  if self.rng.random() > accepted_prob:
@@ -437,7 +427,7 @@ class DifficultyModifier:
437
  @staticmethod
438
  def get_icu_mismatch_probability(difficulty: str) -> float:
439
  """Probability of hidden ICU mismatch (shown vs actual)."""
440
- return {"easy": 0, "medium": 0.15, "hard": 0.35}.get(difficulty, 0.15)
441
 
442
  @staticmethod
443
  def get_unexpected_event_probability(difficulty: str) -> float:
@@ -447,11 +437,9 @@ class DifficultyModifier:
447
  @staticmethod
448
  def get_minimum_survival_probability(difficulty: str) -> float:
449
  """Floor below which patient won't survive regardless."""
450
- return {"easy": 0.05, "medium": 0.02, "hard": 0}.get(difficulty, 0.02)
451
 
452
  @staticmethod
453
  def get_initial_condition_variance(difficulty: str) -> float:
454
  """How much patient condition can vary initially."""
455
- return {"easy": 0, "medium": 0.1, "hard": 0.25}.get(difficulty, 0.1)
456
-
457
-
 
1
+ """Hospital validation engine for realistic arrival outcomes.
2
 
3
  Simulates hidden validation checks performed when an ambulance arrives at a hospital.
4
  Outcomes are based on difficulty level, hospital capacity, patient suitability, and randomness.
5
  """
6
 
7
+ from typing import cast, Literal
8
 
9
  from app.models.state import ArrivalOutcome, HospitalValidationDetails, HospitalState
10
  from app.utils.randomizer import SeededRandomizer
11
 
12
 
 
 
 
 
 
 
 
 
 
 
13
  class HospitalValidator:
14
  """Performs hidden validation checks on arrival and returns outcome."""
15
 
 
66
  icu_available=icu_available,
67
  doctor_available=doctor_available,
68
  equipment_functional=equipment_functional,
69
+ overload_status=cast(Literal["clear", "moderate", "severe"], overload_status),
70
  patient_suitability=patient_suitability,
71
  )
72
 
 
81
  )
82
 
83
  return ArrivalOutcome(
84
+ status=cast(Literal["accepted", "partial", "rejected"], status),
85
  reason=reason,
86
  validation_details=validation_details,
87
  reward_modifier=reward_modifier,
 
97
  }.get(difficulty, 0.70)
98
 
99
  # Displayed status influences belief but does not fully determine truth.
100
+ display_adjust = 0.0
101
  if hospital.icu_display == "available":
102
  display_adjust = 0.06 if difficulty == "easy" else (0.04 if difficulty == "medium" else 0.02)
103
  else: # unknown
104
+ display_adjust = -0.03 if difficulty == "easy" else (-0.02 if difficulty == "medium" else 0.0)
105
 
106
  p = max(0.05, min(0.97, base_true_prob + display_adjust))
107
  return self.rng.random() < p
 
132
  }.get(difficulty, 0.90)
133
  return self.rng.random() < equipment_working_prob
134
 
135
+ def _check_hospital_overload(self, difficulty: str) -> str:
136
  """Determine hospital overload status: clear, moderate, or severe."""
137
  overload_prob = {
138
  "easy": 0.10,
 
154
  hospital: HospitalState,
155
  patient_condition: str,
156
  required_specialization: str,
157
+ overload_status: str,
158
  difficulty: str,
159
  ) -> float:
160
  """
161
+ Compute how suitable this hospital is for the patient (0.0 to 1.0).
162
  Based on specialization match, condition severity, and overload.
163
  """
164
  # Specialization match basis
 
180
 
181
  # Hospital overload impact
182
  overload_impact = {
183
+ "clear": 1.0,
184
  "moderate": 0.7,
185
  "severe": 0.4,
186
  }
 
192
  # Add difficulty-based noise
193
  if difficulty == "hard":
194
  noise = self.rng.uniform(-0.15, 0.15)
195
+ suitability = max(0.0, min(1.0, suitability + noise))
196
 
197
+ return suitability
198
 
199
  def _determine_outcome(
200
  self,
 
205
  specialization_match: bool,
206
  difficulty: str,
207
  step_number: int,
208
+ ) -> tuple[str, str, float, bool]:
209
  """
210
  Determine final outcome (accepted, partial, or rejected) based on validation.
211
 
 
271
  return (
272
  "rejected",
273
  f"Hospital cannot admit: {', '.join(rejection_reasons[:2])}",
274
+ 0.0,
275
  False,
276
  )
277
 
 
347
  return (
348
  "rejected",
349
  "Condition became non-transferable during delay; immediate critical care failed",
350
+ 0.0,
351
  True,
352
  )
353
 
 
359
  )
360
 
361
  # Full acceptance
362
+ confidence_bonus = 1.0
363
  if validation.patient_suitability >= 0.8:
364
  confidence_bonus = 1.1
365
  elif validation.patient_suitability >= 0.7:
366
  confidence_bonus = 1.05
367
 
368
  # Arrival uncertainty by difficulty.
369
+ reject_prob = 0.0
370
  if difficulty == "medium":
371
  reject_prob = 0.2
372
  elif difficulty == "hard":
 
374
  reject_prob += 0.10
375
  reject_prob += 0.08
376
 
377
+ if reject_prob > 0.0 and self.rng.random() < reject_prob:
378
  return (
379
  "rejected",
380
  "Unexpected complication at arrival",
381
+ 0.0,
382
  False,
383
  )
384
 
 
386
  return (
387
  "accepted",
388
  "successful admission under uncertainty",
389
+ 1.0,
390
  False,
391
  )
392
 
 
402
  False,
403
  )
404
 
405
+ accepted_prob = 1.0
406
  if difficulty == "hard":
407
  accepted_prob *= 0.65
408
  if self.rng.random() > accepted_prob:
 
427
  @staticmethod
428
  def get_icu_mismatch_probability(difficulty: str) -> float:
429
  """Probability of hidden ICU mismatch (shown vs actual)."""
430
+ return {"easy": 0.0, "medium": 0.15, "hard": 0.35}.get(difficulty, 0.15)
431
 
432
  @staticmethod
433
  def get_unexpected_event_probability(difficulty: str) -> float:
 
437
  @staticmethod
438
  def get_minimum_survival_probability(difficulty: str) -> float:
439
  """Floor below which patient won't survive regardless."""
440
+ return {"easy": 0.05, "medium": 0.02, "hard": 0.0}.get(difficulty, 0.02)
441
 
442
  @staticmethod
443
  def get_initial_condition_variance(difficulty: str) -> float:
444
  """How much patient condition can vary initially."""
445
+ return {"easy": 0.0, "medium": 0.1, "hard": 0.25}.get(difficulty, 0.1)
 
 
app/models/observation.py CHANGED
@@ -1,14 +1,6 @@
1
- from typing import Literal
2
 
3
- from pydantic import BaseModel, Field, field_validator
4
-
5
-
6
- _FLOOR = 0.01
7
- _CEIL = 0.99
8
-
9
-
10
- def _clamp(v: float) -> float:
11
- return max(_FLOOR, min(_CEIL, float(v)))
12
 
13
 
14
  class HospitalObservation(BaseModel):
@@ -23,12 +15,7 @@ class ArrivalOutcomeObservation(BaseModel):
23
  """What happened when ambulance arrived at hospital"""
24
  status: Literal["accepted", "partial", "rejected"]
25
  reason: str
26
- suitability_score: float = Field(ge=_FLOOR, le=_CEIL)
27
-
28
- @field_validator("suitability_score", mode="before")
29
- @classmethod
30
- def clamp_suitability(cls, v: float) -> float:
31
- return _clamp(v)
32
 
33
 
34
  class Observation(BaseModel):
@@ -54,11 +41,9 @@ class Observation(BaseModel):
54
  failed_hospitals: list[str] = Field(default_factory=list)
55
  recent_failed_hospitals: list[str] = Field(default_factory=list)
56
  failed_reasons: dict[str, str] = Field(default_factory=dict)
57
- total_time_spent_minutes: float = Field(default=0, ge=0)
58
  rerouting_reason: str | None = None
59
  # New fields for arrival outcome visibility
60
  last_arrival_outcome: ArrivalOutcomeObservation | None = None
61
  explanation: list[str] = Field(default_factory=list)
62
  memory_snapshot: dict[str, dict[str, float | int]] = Field(default_factory=dict)
63
-
64
-
 
1
+ from typing import Literal
2
 
3
+ from pydantic import BaseModel, Field
 
 
 
 
 
 
 
 
4
 
5
 
6
  class HospitalObservation(BaseModel):
 
15
  """What happened when ambulance arrived at hospital"""
16
  status: Literal["accepted", "partial", "rejected"]
17
  reason: str
18
+ suitability_score: float = Field(ge=0.0, le=1.0)
 
 
 
 
 
19
 
20
 
21
  class Observation(BaseModel):
 
41
  failed_hospitals: list[str] = Field(default_factory=list)
42
  recent_failed_hospitals: list[str] = Field(default_factory=list)
43
  failed_reasons: dict[str, str] = Field(default_factory=dict)
44
+ total_time_spent_minutes: float = Field(default=0.0, ge=0.0)
45
  rerouting_reason: str | None = None
46
  # New fields for arrival outcome visibility
47
  last_arrival_outcome: ArrivalOutcomeObservation | None = None
48
  explanation: list[str] = Field(default_factory=list)
49
  memory_snapshot: dict[str, dict[str, float | int]] = Field(default_factory=dict)
 
 
app/models/reward.py CHANGED
@@ -1,75 +1,73 @@
1
- from typing import Literal
2
-
3
- from pydantic import BaseModel, Field, field_validator
4
-
5
-
6
- # Strict boundaries for all score-like fields.
7
- # The external validator requires scores strictly between 0 and 1
8
- # (not 0 and not 1), so we clamp all values to [0.01, 0.99].
9
- _FLOOR = 0.01
10
- _CEIL = 0.99
11
-
12
-
13
- def _clamp(v: float) -> float:
14
- """Clamp a score to the open interval (0, 1)."""
15
- return max(_FLOOR, min(_CEIL, v))
16
-
17
-
18
- class RewardBreakdown(BaseModel):
19
- survival_component: float = Field(ge=_FLOOR, le=_CEIL)
20
- time_efficiency_component: float = Field(ge=_FLOOR, le=_CEIL)
21
- specialization_component: float = Field(ge=_FLOOR, le=_CEIL)
22
- delay_penalty: float = Field(ge=_FLOOR, le=_CEIL)
23
-
24
- @field_validator(
25
- "survival_component",
26
- "time_efficiency_component",
27
- "specialization_component",
28
- "delay_penalty",
29
- mode="before",
30
- )
31
- @classmethod
32
- def clamp_score(cls, v: float) -> float:
33
- return _clamp(v)
34
-
35
-
36
- class RewardModel(BaseModel):
37
- value: float = Field(ge=_FLOOR, le=_CEIL)
38
- breakdown: RewardBreakdown
39
-
40
- @field_validator("value", mode="before")
41
- @classmethod
42
- def clamp_value(cls, v: float) -> float:
43
- return _clamp(v)
44
-
45
-
46
- class GraderResult(BaseModel):
47
- task_id: Literal["acde_easy", "acde_medium", "acde_hard"]
48
- difficulty: Literal["easy", "medium", "hard"]
49
- objective: str
50
- score: float = Field(ge=_FLOOR, le=_CEIL)
51
- passed: bool
52
- criteria: dict[str, float] = Field(default_factory=dict)
53
-
54
- @field_validator("score", mode="before")
55
- @classmethod
56
- def clamp_score(cls, v: float) -> float:
57
- return _clamp(v)
58
-
59
-
60
- class StepInfo(BaseModel):
61
- last_action_error: str | None = None
62
- task_id: Literal["acde_easy", "acde_medium", "acde_hard"]
63
- difficulty: Literal["easy", "medium", "hard"]
64
- objective: str
65
- progress_score: float = Field(ge=_FLOOR, le=_CEIL)
66
- reward_model: RewardModel
67
- grader: GraderResult | None = None
68
- outcome: dict[str, str] | None = None
69
-
70
- @field_validator("progress_score", mode="before")
71
- @classmethod
72
- def clamp_progress(cls, v: float) -> float:
73
- return _clamp(v)
74
-
75
-
 
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field, field_validator
4
+
5
+
6
+ # Strict boundaries for all score-like fields.
7
+ # The external validator requires scores strictly between 0 and 1
8
+ # (not 0.0 and not 1.0), so we clamp all values to [0.001, 0.999].
9
+ _FLOOR = 0.001
10
+ _CEIL = 0.999
11
+
12
+
13
+ def _clamp(v: float) -> float:
14
+ """Clamp a score to the open interval (0, 1)."""
15
+ return max(_FLOOR, min(_CEIL, v))
16
+
17
+
18
+ class RewardBreakdown(BaseModel):
19
+ survival_component: float = Field(ge=_FLOOR, le=_CEIL)
20
+ time_efficiency_component: float = Field(ge=_FLOOR, le=_CEIL)
21
+ specialization_component: float = Field(ge=_FLOOR, le=_CEIL)
22
+ delay_penalty: float = Field(ge=_FLOOR, le=_CEIL)
23
+
24
+ @field_validator(
25
+ "survival_component",
26
+ "time_efficiency_component",
27
+ "specialization_component",
28
+ "delay_penalty",
29
+ mode="before",
30
+ )
31
+ @classmethod
32
+ def clamp_score(cls, v: float) -> float:
33
+ return _clamp(v)
34
+
35
+
36
+ class RewardModel(BaseModel):
37
+ value: float = Field(ge=_FLOOR, le=_CEIL)
38
+ breakdown: RewardBreakdown
39
+
40
+ @field_validator("value", mode="before")
41
+ @classmethod
42
+ def clamp_value(cls, v: float) -> float:
43
+ return _clamp(v)
44
+
45
+
46
+ class GraderResult(BaseModel):
47
+ task_id: Literal["acde_easy", "acde_medium", "acde_hard"]
48
+ difficulty: Literal["easy", "medium", "hard"]
49
+ objective: str
50
+ score: float = Field(ge=_FLOOR, le=_CEIL)
51
+ passed: bool
52
+ criteria: dict[str, float] = Field(default_factory=dict)
53
+
54
+ @field_validator("score", mode="before")
55
+ @classmethod
56
+ def clamp_score(cls, v: float) -> float:
57
+ return _clamp(v)
58
+
59
+
60
+ class StepInfo(BaseModel):
61
+ last_action_error: str | None = None
62
+ task_id: Literal["acde_easy", "acde_medium", "acde_hard"]
63
+ difficulty: Literal["easy", "medium", "hard"]
64
+ objective: str
65
+ progress_score: float = Field(ge=_FLOOR, le=_CEIL)
66
+ reward_model: RewardModel
67
+ grader: GraderResult | None = None
68
+ outcome: dict[str, str] | None = None
69
+
70
+ @field_validator("progress_score", mode="before")
71
+ @classmethod
72
+ def clamp_progress(cls, v: float) -> float:
73
+ return _clamp(v)
 
 
app/models/state.py CHANGED
@@ -1,28 +1,15 @@
1
- from typing import Literal
2
 
3
- from pydantic import BaseModel, Field, field_validator
4
-
5
-
6
- _FLOOR = 0.01
7
- _CEIL = 0.99
8
-
9
-
10
- def _clamp(v: float) -> float:
11
- return max(_FLOOR, min(_CEIL, float(v)))
12
 
13
 
14
  class LearningEntry(BaseModel):
15
  success: int = Field(default=0, ge=0)
16
  fail: int = Field(default=0, ge=0)
17
- avg: float = Field(default=_FLOOR, ge=_FLOOR, le=_CEIL)
18
  accepted: int = Field(default=0, ge=0)
19
  rejected: int = Field(default=0, ge=0)
20
 
21
- @field_validator("avg", mode="before")
22
- @classmethod
23
- def clamp_avg(cls, v: float) -> float:
24
- return _clamp(v)
25
-
26
 
27
  class HospitalValidationDetails(BaseModel):
28
  """Hidden validation checks performed after ambulance arrives at hospital"""
@@ -30,12 +17,7 @@ class HospitalValidationDetails(BaseModel):
30
  doctor_available: bool
31
  equipment_functional: bool
32
  overload_status: Literal["clear", "moderate", "severe"]
33
- patient_suitability: float = Field(ge=_FLOOR, le=_CEIL) # 0=unsuitable, 1=ideal
34
-
35
- @field_validator("patient_suitability", mode="before")
36
- @classmethod
37
- def clamp_suitability(cls, v: float) -> float:
38
- return _clamp(v)
39
 
40
 
41
  class ArrivalOutcome(BaseModel):
@@ -43,7 +25,7 @@ class ArrivalOutcome(BaseModel):
43
  status: Literal["accepted", "partial", "rejected"]
44
  reason: str
45
  validation_details: HospitalValidationDetails | None = None
46
- reward_modifier: float = Field(default=1, ge=0, le=1.5)
47
  terminal: bool = False
48
 
49
 
@@ -74,20 +56,18 @@ class EnvState(BaseModel):
74
  selected_hospital_id: str | None = None
75
  done: bool = False
76
  final_outcome: Literal["SUCCESS", "FAILURE"] | None = None
77
- final_score: float = Field(default=_FLOOR, ge=_FLOOR, le=_CEIL)
78
- reward: float = Field(default=_FLOOR, ge=_FLOOR, le=_CEIL)
79
  ambulance_status: Literal["en_route", "in_transit", "arrived", "admitted", "rerouting"] = "en_route"
80
  current_location_context: str = "incident_site"
81
  visited_hospitals: list[str] = Field(default_factory=list)
82
  failed_hospitals: list[str] = Field(default_factory=list)
83
  recent_failed_hospitals: list[str] = Field(default_factory=list)
84
  failed_reasons: dict[str, str] = Field(default_factory=dict)
85
- total_time_spent_minutes: float = Field(default=0, ge=0)
86
  rerouting_reason: str | None = None
87
  # New fields for arrival tracking
88
  last_arrival_outcome: ArrivalOutcome | None = None
89
  accepted_hospital_id: str | None = None
90
  explanation: list[str] = Field(default_factory=list)
91
  memory: dict[str, LearningEntry] = Field(default_factory=dict)
92
-
93
-
 
1
+ from typing import Literal
2
 
3
+ from pydantic import BaseModel, Field
 
 
 
 
 
 
 
 
4
 
5
 
6
  class LearningEntry(BaseModel):
7
  success: int = Field(default=0, ge=0)
8
  fail: int = Field(default=0, ge=0)
9
+ avg: float = Field(default=0.0, ge=0.0, le=1.0)
10
  accepted: int = Field(default=0, ge=0)
11
  rejected: int = Field(default=0, ge=0)
12
 
 
 
 
 
 
13
 
14
  class HospitalValidationDetails(BaseModel):
15
  """Hidden validation checks performed after ambulance arrives at hospital"""
 
17
  doctor_available: bool
18
  equipment_functional: bool
19
  overload_status: Literal["clear", "moderate", "severe"]
20
+ patient_suitability: float = Field(ge=0.0, le=1.0) # 0=unsuitable, 1=ideal
 
 
 
 
 
21
 
22
 
23
  class ArrivalOutcome(BaseModel):
 
25
  status: Literal["accepted", "partial", "rejected"]
26
  reason: str
27
  validation_details: HospitalValidationDetails | None = None
28
+ reward_modifier: float = Field(default=1.0, ge=0.0, le=1.5)
29
  terminal: bool = False
30
 
31
 
 
56
  selected_hospital_id: str | None = None
57
  done: bool = False
58
  final_outcome: Literal["SUCCESS", "FAILURE"] | None = None
59
+ final_score: float = Field(default=0.001, ge=0.001, le=0.999)
60
+ reward: float = Field(default=0.001, ge=0.001, le=0.999)
61
  ambulance_status: Literal["en_route", "in_transit", "arrived", "admitted", "rerouting"] = "en_route"
62
  current_location_context: str = "incident_site"
63
  visited_hospitals: list[str] = Field(default_factory=list)
64
  failed_hospitals: list[str] = Field(default_factory=list)
65
  recent_failed_hospitals: list[str] = Field(default_factory=list)
66
  failed_reasons: dict[str, str] = Field(default_factory=dict)
67
+ total_time_spent_minutes: float = Field(default=0.0, ge=0.0)
68
  rerouting_reason: str | None = None
69
  # New fields for arrival tracking
70
  last_arrival_outcome: ArrivalOutcome | None = None
71
  accepted_hospital_id: str | None = None
72
  explanation: list[str] = Field(default_factory=list)
73
  memory: dict[str, LearningEntry] = Field(default_factory=dict)
 
 
app/server/app.py CHANGED
@@ -1,4 +1,4 @@
1
- from pathlib import Path
2
 
3
  from fastapi import FastAPI, HTTPException
4
  from pydantic import BaseModel
@@ -6,15 +6,15 @@ from pydantic import BaseModel
6
  from app.environment.core import ACDEEnvironment
7
  from app.models.action import Action
8
  from app.models.observation import Observation
9
- from app.models.reward import StepInfo
10
  from app.models.state import EnvState
11
 
12
  ROOT = Path(__file__).resolve().parents[2]
13
  MEMORY_FILE = ROOT / "data" / "learning_memory.json"
14
 
15
- app = FastAPI(title="Adaptive Crisis Decision Environment", version="1")
16
  env = ACDEEnvironment(memory_file=str(MEMORY_FILE))
17
- MIN_REWARD = 0.01
18
 
19
 
20
  class ResetRequest(BaseModel):
@@ -55,9 +55,7 @@ def reset(payload: ResetRequest | None = None) -> StepResponse:
55
  seed = payload.seed if payload else None
56
  task_id = payload.task_id if payload else None
57
  obs = env.reset(seed=seed, task_id=task_id)
58
- info = env.last_info
59
- if info is None:
60
- raise HTTPException(status_code=500, detail="Internal error: missing step info after reset")
61
  return StepResponse(observation=obs, reward=MIN_REWARD, done=False, info=info)
62
 
63
 
@@ -67,20 +65,14 @@ def step(action: Action) -> StepResponse:
67
  result = env.step(action)
68
  except ValueError as exc:
69
  raise HTTPException(status_code=400, detail=str(exc)) from exc
70
- info = env.last_info
71
- if info is None:
72
- raise HTTPException(status_code=500, detail="Internal error: missing step info after step")
73
-
74
  return StepResponse(
75
  observation=result["observation"],
76
  reward=float(result["reward"]),
77
  done=bool(result["done"]),
78
- info=info,
79
  )
80
 
81
 
82
  @app.get("/state", response_model=EnvState)
83
  def state() -> EnvState:
84
  return env.state()
85
-
86
-
 
1
+ from pathlib import Path
2
 
3
  from fastapi import FastAPI, HTTPException
4
  from pydantic import BaseModel
 
6
  from app.environment.core import ACDEEnvironment
7
  from app.models.action import Action
8
  from app.models.observation import Observation
9
+ from app.models.reward import StepInfo, GraderResult, RewardModel, RewardBreakdown
10
  from app.models.state import EnvState
11
 
12
  ROOT = Path(__file__).resolve().parents[2]
13
  MEMORY_FILE = ROOT / "data" / "learning_memory.json"
14
 
15
+ app = FastAPI(title="Adaptive Crisis Decision Environment", version="1.0.0")
16
  env = ACDEEnvironment(memory_file=str(MEMORY_FILE))
17
+ MIN_REWARD = 0.001
18
 
19
 
20
  class ResetRequest(BaseModel):
 
55
  seed = payload.seed if payload else None
56
  task_id = payload.task_id if payload else None
57
  obs = env.reset(seed=seed, task_id=task_id)
58
+ info = env.last_info if env.last_info else StepInfo(task_id="acde_medium", difficulty="medium", objective="", progress_score=MIN_REWARD, reward_model=RewardModel(value=MIN_REWARD, breakdown=RewardBreakdown(survival_component=MIN_REWARD, time_efficiency_component=MIN_REWARD, specialization_component=MIN_REWARD, delay_penalty=MIN_REWARD)), grader=GraderResult(task_id="acde_medium", difficulty="medium", objective="", score=MIN_REWARD, passed=False, criteria={}), last_action_error=None, outcome=None)
 
 
59
  return StepResponse(observation=obs, reward=MIN_REWARD, done=False, info=info)
60
 
61
 
 
65
  result = env.step(action)
66
  except ValueError as exc:
67
  raise HTTPException(status_code=400, detail=str(exc)) from exc
 
 
 
 
68
  return StepResponse(
69
  observation=result["observation"],
70
  reward=float(result["reward"]),
71
  done=bool(result["done"]),
72
+ info=result.get("info", {}),
73
  )
74
 
75
 
76
  @app.get("/state", response_model=EnvState)
77
  def state() -> EnvState:
78
  return env.state()
 
 
app/utils/calculations.py CHANGED
@@ -1,14 +1,7 @@
1
- from app.models.state import LearningEntry
2
-
3
- STRICT_SCORE_MIN = 0.01
4
- STRICT_SCORE_MAX = 0.99
5
-
6
-
7
- def _clamp(value: float) -> float:
8
- return max(STRICT_SCORE_MIN, min(STRICT_SCORE_MAX, float(value)))
9
 
10
  TRAFFIC_FACTOR = {
11
- "low": 1,
12
  "medium": 0.6,
13
  "high": 0.3,
14
  }
@@ -25,7 +18,7 @@ def compute_travel_time_minutes(distance_km: float, speed_kmh: float) -> float:
25
 
26
 
27
  def score_distance(distance_km: float, max_distance_km: float = 20.0) -> float:
28
- return _clamp(1 - (distance_km / max_distance_km))
29
 
30
 
31
  def score_traffic(traffic: str) -> float:
@@ -33,7 +26,7 @@ def score_traffic(traffic: str) -> float:
33
 
34
 
35
  def score_icu(display_icu: str) -> float:
36
- return _clamp(1 if display_icu == "available" else 0.55)
37
 
38
 
39
  def score_memory(entry: LearningEntry | None) -> float:
@@ -43,9 +36,9 @@ def score_memory(entry: LearningEntry | None) -> float:
43
  if total == 0:
44
  return 0.5
45
  success_rate = entry.success / total
46
- fail_bias = max(0, (entry.fail - entry.success) / total)
47
  raw = (0.7 * entry.avg) + (0.3 * success_rate) - (0.4 * fail_bias)
48
- return _clamp(raw)
49
 
50
 
51
  def decision_score(
@@ -60,7 +53,7 @@ def decision_score(
60
  + (traffic_score * 0.2)
61
  + (memory_score * 0.3)
62
  )
63
- return _clamp(weighted / 1.2)
64
 
65
 
66
  def compute_reward(
@@ -69,10 +62,10 @@ def compute_reward(
69
  critical_limit: float,
70
  specialization_match: bool,
71
  ) -> float:
72
- survival_component = _clamp(1 if survived else 0)
73
- time_efficiency = _clamp(critical_limit / max(critical_limit + travel_time, 1e-6))
74
- specialization_component = _clamp(1 if specialization_match else 0)
75
- delay_penalty = _clamp(travel_time / max(critical_limit + travel_time, 1e-6))
76
 
77
  reward = (
78
  (survival_component * 0.45)
@@ -80,7 +73,7 @@ def compute_reward(
80
  + (specialization_component * 0.2)
81
  - (delay_penalty * 0.1)
82
  )
83
- return _clamp(reward)
84
 
85
 
86
  def compute_reward_with_breakdown(
@@ -93,19 +86,19 @@ def compute_reward_with_breakdown(
93
  adaptability_score: float | None = None,
94
  ) -> tuple[float, dict[str, float]]:
95
  survival_component = (
96
- _clamp(survival_score)
97
  if survival_score is not None
98
- else _clamp(1 if survived else 0)
99
  )
100
- time_efficiency = _clamp(critical_limit / max(critical_limit + travel_time, 1e-6))
101
  specialization_component = (
102
- _clamp(capability_score)
103
  if capability_score is not None
104
- else _clamp(1 if specialization_match else 0)
105
  )
106
- delay_penalty = _clamp(travel_time / max(critical_limit + travel_time, 1e-6))
107
  adapt_component = (
108
- _clamp(adaptability_score)
109
  if adaptability_score is not None
110
  else 0.5
111
  )
@@ -117,12 +110,10 @@ def compute_reward_with_breakdown(
117
  + (adapt_component * 0.2)
118
  - (delay_penalty * 0.12)
119
  )
120
- reward = _clamp(reward)
121
  return reward, {
122
  "survival_component": survival_component,
123
  "time_efficiency_component": time_efficiency,
124
  "specialization_component": specialization_component,
125
  "delay_penalty": delay_penalty,
126
  }
127
-
128
-
 
1
+ from app.models.state import LearningEntry
 
 
 
 
 
 
 
2
 
3
  TRAFFIC_FACTOR = {
4
+ "low": 1.0,
5
  "medium": 0.6,
6
  "high": 0.3,
7
  }
 
18
 
19
 
20
  def score_distance(distance_km: float, max_distance_km: float = 20.0) -> float:
21
+ return max(0.0, min(1.0, 1.0 - (distance_km / max_distance_km)))
22
 
23
 
24
  def score_traffic(traffic: str) -> float:
 
26
 
27
 
28
  def score_icu(display_icu: str) -> float:
29
+ return 1.0 if display_icu == "available" else 0.55
30
 
31
 
32
  def score_memory(entry: LearningEntry | None) -> float:
 
36
  if total == 0:
37
  return 0.5
38
  success_rate = entry.success / total
39
+ fail_bias = max(0.0, (entry.fail - entry.success) / total)
40
  raw = (0.7 * entry.avg) + (0.3 * success_rate) - (0.4 * fail_bias)
41
+ return max(0.0, min(1.0, raw))
42
 
43
 
44
  def decision_score(
 
53
  + (traffic_score * 0.2)
54
  + (memory_score * 0.3)
55
  )
56
+ return max(0.0, min(1.0, weighted / 1.2))
57
 
58
 
59
  def compute_reward(
 
62
  critical_limit: float,
63
  specialization_match: bool,
64
  ) -> float:
65
+ survival_component = 1.0 if survived else 0.0
66
+ time_efficiency = max(0.0, min(1.0, critical_limit / max(critical_limit + travel_time, 1e-6)))
67
+ specialization_component = 1.0 if specialization_match else 0.0
68
+ delay_penalty = max(0.0, min(1.0, travel_time / max(critical_limit + travel_time, 1e-6)))
69
 
70
  reward = (
71
  (survival_component * 0.45)
 
73
  + (specialization_component * 0.2)
74
  - (delay_penalty * 0.1)
75
  )
76
+ return max(0.0, min(1.0, reward))
77
 
78
 
79
  def compute_reward_with_breakdown(
 
86
  adaptability_score: float | None = None,
87
  ) -> tuple[float, dict[str, float]]:
88
  survival_component = (
89
+ max(0.0, min(1.0, survival_score))
90
  if survival_score is not None
91
+ else (1.0 if survived else 0.0)
92
  )
93
+ time_efficiency = max(0.0, min(1.0, critical_limit / max(critical_limit + travel_time, 1e-6)))
94
  specialization_component = (
95
+ max(0.0, min(1.0, capability_score))
96
  if capability_score is not None
97
+ else (1.0 if specialization_match else 0.0)
98
  )
99
+ delay_penalty = max(0.0, min(1.0, travel_time / max(critical_limit + travel_time, 1e-6)))
100
  adapt_component = (
101
+ max(0.0, min(1.0, adaptability_score))
102
  if adaptability_score is not None
103
  else 0.5
104
  )
 
110
  + (adapt_component * 0.2)
111
  - (delay_penalty * 0.12)
112
  )
113
+ reward = max(0.0, min(1.0, reward))
114
  return reward, {
115
  "survival_component": survival_component,
116
  "time_efficiency_component": time_efficiency,
117
  "specialization_component": specialization_component,
118
  "delay_penalty": delay_penalty,
119
  }
 
 
client.py CHANGED
@@ -1,4 +1,4 @@
1
- """OpenEnv client for the Adaptive Crisis Decision Environment."""
2
 
3
  from typing import Dict
4
 
@@ -33,7 +33,7 @@ class ACDEEnv(EnvClient[ACDEAction, ACDEObservation]):
33
 
34
  return StepResult(
35
  observation=observation,
36
- reward=payload.get("reward", 0),
37
  done=payload.get("done", False),
38
  )
39
 
@@ -41,4 +41,4 @@ class ACDEEnv(EnvClient[ACDEAction, ACDEObservation]):
41
  return State(
42
  episode_id=payload.get("episode_id"),
43
  step_count=payload.get("step", 0),
44
- )
 
1
+ """OpenEnv client for the Adaptive Crisis Decision Environment."""
2
 
3
  from typing import Dict
4
 
 
33
 
34
  return StepResult(
35
  observation=observation,
36
+ reward=payload.get("reward", 0.0),
37
  done=payload.get("done", False),
38
  )
39
 
 
41
  return State(
42
  episode_id=payload.get("episode_id"),
43
  step_count=payload.get("step", 0),
44
+ )
inference.py CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python3
2
  """Local agent runner for EmergencyEnv.
3
 
4
  This script acts as an agent only:
@@ -17,21 +17,21 @@ import os
17
  import random
18
  from pathlib import Path
19
  from datetime import datetime, timezone
20
- from typing import Any
21
 
22
  from app.environment.core import EmergencyEnv
23
  from app.models.action import Action
24
 
 
 
 
 
 
25
  try:
26
  from openai import OpenAI
27
  except Exception: # pragma: no cover - fallback for missing optional dependency
28
  OpenAI = None
29
 
30
- try:
31
- from huggingface_hub import get_token as hf_get_token
32
- except Exception: # pragma: no cover - optional fallback for cached HF auth
33
- hf_get_token = None
34
-
35
  TASK_ORDER = ["acde_easy", "acde_medium", "acde_hard"]
36
  LEVEL_TO_TASK = {
37
  "low": "acde_easy",
@@ -41,14 +41,14 @@ LEVEL_TO_TASK = {
41
  RANDOM_LEVELS = ("medium", "high")
42
  RANDOM_LEVEL_WEIGHTS = (0.25, 0.75)
43
  BASE_SPEED_KMH = 60.0
44
- TRAFFIC_FACTOR = {"low": 1, "medium": 0.6, "high": 0.3}
45
  LEARNING_ARCHIVE_PATH = Path(__file__).resolve().parent / "data" / "learning_archive.json"
46
  LEARNING_ARCHIVE_VERSION = 2
47
  DEFAULT_API_BASE_URL = "https://api-inference.huggingface.co/v1"
48
  DEFAULT_MODEL_NAME = "Qwen/Qwen2.5-72B-Instruct"
49
  REQUIRED_ENV_VARS = ("HF_TOKEN",)
50
- STRICT_SCORE_MIN = 0.01
51
- STRICT_SCORE_MAX = 0.99
52
 
53
 
54
  def clamp_strict_score(value: float) -> float:
@@ -56,11 +56,6 @@ def clamp_strict_score(value: float) -> float:
56
  return max(STRICT_SCORE_MIN, min(STRICT_SCORE_MAX, float(value)))
57
 
58
 
59
- def safe_score(value: float) -> float:
60
- """Normalize a score before emitting or persisting it."""
61
- return round(clamp_strict_score(value), 6)
62
-
63
-
64
  def parse_args() -> argparse.Namespace:
65
  parser = argparse.ArgumentParser(description="EmergencyEnv agent runner")
66
  parser.add_argument("--mode", choices=["single", "full"], default="full")
@@ -82,32 +77,20 @@ def emit_structured(tag: str, payload: dict) -> None:
82
 
83
 
84
  def runtime_llm_config() -> dict[str, str]:
85
- hf_token = os.getenv("HF_TOKEN", "").strip()
86
- if not hf_token and hf_get_token is not None:
87
- try:
88
- hf_token = (hf_get_token() or "").strip()
89
- except Exception:
90
- hf_token = ""
91
-
92
  return {
93
  "API_BASE_URL": os.getenv("API_BASE_URL", DEFAULT_API_BASE_URL).strip(),
94
  "MODEL_NAME": os.getenv("MODEL_NAME", DEFAULT_MODEL_NAME).strip(),
95
- "HF_TOKEN": hf_token,
96
  }
97
 
98
 
99
- def require_llm_config() -> tuple[object, str]:
100
  config = runtime_llm_config()
101
  missing = [name for name, value in config.items() if not value]
102
  if missing:
103
- missing_message = ", ".join(missing)
104
- if missing_message == "HF_TOKEN":
105
- raise SystemExit(
106
- "Missing Hugging Face token. Set HF_TOKEN or run `hf auth login` before running inference.py"
107
- )
108
  raise SystemExit(
109
  "Missing required environment variables: "
110
- + missing_message
111
  + ". Set HF_TOKEN before running inference.py"
112
  )
113
  if OpenAI is None:
@@ -118,7 +101,7 @@ def require_llm_config() -> tuple[object, str]:
118
 
119
 
120
  def llm_rationale(
121
- client: Any,
122
  model_name: str,
123
  observation: dict,
124
  chosen: dict,
@@ -128,6 +111,8 @@ def llm_rationale(
128
  f"Selected {chosen['hospital_id']} by {strategy}; "
129
  f"score={chosen['policy_score']:.3f}, traffic={chosen['traffic']}, icu={chosen['icu']}"
130
  )
 
 
131
  try:
132
  prompt = (
133
  "You are an emergency routing agent. Return one short sentence rationale "
@@ -145,7 +130,7 @@ def llm_rationale(
145
  {"role": "system", "content": "Generate concise emergency triage rationale."},
146
  {"role": "user", "content": prompt},
147
  ],
148
- temperature=0,
149
  max_tokens=60,
150
  )
151
  text = (completion.choices[0].message.content or "").strip()
@@ -246,7 +231,7 @@ def _merge_step_stats(primary: dict, secondary: dict) -> dict:
246
  accepted = int(a.get("accepted", 0)) + int(b.get("accepted", 0))
247
  partial = int(a.get("partial", 0)) + int(b.get("partial", 0))
248
  rejected = int(a.get("rejected", 0)) + int(b.get("rejected", 0))
249
- total_reward = float(a.get("total_reward", 0)) + float(b.get("total_reward", 0))
250
  merged[step_key][hospital_id] = {
251
  "count": count,
252
  "success": int(a.get("success", 0)) + int(b.get("success", 0)),
@@ -277,7 +262,7 @@ def build_learning_profile(
277
  # Strict scope: learn only from same seed + same level/task.
278
  return {
279
  "attempts": int(exact.get("attempts", 0)),
280
- "best_score": float(exact.get("best_score", 0)),
281
  "best_actions": list(exact.get("best_actions", [])),
282
  "step_stats": exact.get("step_stats", {}),
283
  "best_scenario_name": exact.get("best_scenario_name"),
@@ -302,7 +287,7 @@ def _sample_softmax(candidates: list[dict], key: str, temperature: float, rng: r
302
  probs = [e / total for e in exps]
303
 
304
  roll = rng.random()
305
- cdf = 0
306
  for item, prob in zip(candidates, probs):
307
  cdf += prob
308
  if roll <= cdf:
@@ -322,7 +307,7 @@ def memory_score_for_hospital(
322
 
323
  success = int(entry.get("accepted", entry.get("success", 0)))
324
  fail = int(entry.get("rejected", entry.get("fail", 0)))
325
- avg = float(entry.get("avg", 0))
326
  total = success + fail
327
  if total <= 0:
328
  return 0.5
@@ -336,8 +321,8 @@ def memory_score_for_hospital(
336
  step_stats = learning_profile.get("step_stats", {}).get(str(step_number), {})
337
  hospital_stats = step_stats.get(hospital_id)
338
  if hospital_stats:
339
- step_avg = float(hospital_stats.get("avg_reward", 0))
340
- step_success = float(hospital_stats.get("success_rate", 0))
341
  step_count = int(hospital_stats.get("count", 0))
342
  value += min(0.20, (step_avg * 0.10) + (step_success * 0.08) + min(step_count, 5) * 0.01)
343
  recent_failed = str(hospital_stats.get("last_status", "")).upper() == "REJECTED"
@@ -345,7 +330,7 @@ def memory_score_for_hospital(
345
  if recent_failed:
346
  value -= 0.3
347
 
348
- return clamp_strict_score(value)
349
 
350
 
351
  def score_hospitals(observation: dict, learning_profile: dict | None = None) -> list[dict]:
@@ -360,7 +345,7 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
360
  scored: list[dict] = []
361
  initial_limit = float(observation.get("initial_critical_time_limit_minutes", observation["critical_time_limit_minutes"]))
362
  remaining_time = float(observation.get("remaining_time_minutes", observation["critical_time_limit_minutes"]))
363
- urgency = 1 - min(1, max(0, remaining_time / max(initial_limit, 1e-6)))
364
 
365
  patient_condition = observation.get("patient_condition", "").lower()
366
  critical_patient = patient_condition in {"critical", "unstable"}
@@ -378,8 +363,8 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
378
  speed_kmh = BASE_SPEED_KMH * traffic_factor
379
  travel_time = (hospital["distance_km"] / max(speed_kmh, 1e-6)) * 60.0
380
 
381
- distance_score = clamp_strict_score(1 - hospital["distance_km"] / 20.0)
382
- icu_score = clamp_strict_score(1 if hospital["icu"] == "available" else 0.55)
383
  mem_score = memory_score_for_hospital(
384
  hospital["hospital_id"],
385
  memory_snapshot,
@@ -408,24 +393,24 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
408
  and observation["required_specialization"] != "general"
409
  )
410
 
411
- rejected_penalty = 0.40 if hospital["hospital_id"] in failed else 0
412
- revisit_penalty = 0.14 if hospital["hospital_id"] in visited else 0
413
  partial_repeat_penalty = (
414
  0.32
415
  if last_status == "partial" and hospital["hospital_id"] == previous_action
416
- else 0
417
  )
418
  critical_unknown_penalty = 0.17 if critical_patient and hospital["icu"] == "unknown" else 0.03
419
- traffic_penalty = 0.10 if hospital["traffic"] == "high" else 0.04 if hospital["traffic"] == "medium" else 0
420
  if critical_patient and general_fallback:
421
  spec_penalty = {"easy": 0.08, "medium": 0.16, "hard": 0.26}.get(difficulty, 0.16)
422
  if attempts >= 5:
423
  spec_penalty += 0.06
424
  else:
425
- spec_penalty = 0
426
- spec_bonus = 0.16 if exact_spec_match else (0.08 if spec_match else 0)
427
- urgency_boost = urgency * (0.18 + max(0, 0.25 - travel_time / 100.0))
428
- step_route_bonus = 0
429
  if step_number - 1 < len(preferred_route) and preferred_route[step_number - 1] == hospital["hospital_id"]:
430
  step_route_bonus = 0.16
431
 
@@ -468,7 +453,7 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
468
  score -= 0.3
469
 
470
  # Confidence-style risk multiplier keeps risky options from looking deceptively good.
471
- risk_factor = 1
472
  if hospital["icu"] == "unknown":
473
  risk_factor *= 0.6
474
  if not spec_match:
@@ -484,13 +469,13 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
484
  memory_weight = 0.1
485
  current_score_weight = 0.9
486
  base_current_score = score
487
- confidence_score = clamp_strict_score(base_current_score)
488
  effective_memory_score = mem_score
489
  in_best_route = hospital["hospital_id"] in preferred_route
490
  if in_best_route and confidence_score < 0.6:
491
- effective_memory_score = STRICT_SCORE_MIN
492
  if confidence_score < 0.2:
493
- effective_memory_score = STRICT_SCORE_MIN
494
 
495
  score = (current_score_weight * base_current_score) + (memory_weight * effective_memory_score)
496
 
@@ -503,13 +488,13 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
503
  "specialization": hospital["specialization"],
504
  "travel_time": travel_time,
505
  "memory_score": mem_score,
506
- "policy_score": clamp_strict_score(score),
507
  "specialization_match": spec_match,
508
  "tie_break_score": (
509
  (distance_score * 0.35)
510
  + (traffic_factor * 0.35)
511
  + (icu_score * 0.20)
512
- + (0.10 if spec_match else 0)
513
  ),
514
  }
515
  )
@@ -530,7 +515,7 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
530
  )
531
  jitter_rng = random.Random(jitter_seed)
532
  normalized *= jitter_rng.uniform(0.3, 0.7)
533
- item["policy_score"] = clamp_strict_score(normalized)
534
  elif max_score > 0:
535
  for item in scored:
536
  normalized = item["policy_score"] / max_score
@@ -542,14 +527,14 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
542
  )
543
  jitter_rng = random.Random(jitter_seed)
544
  normalized *= jitter_rng.uniform(0.3, 0.7)
545
- item["policy_score"] = clamp_strict_score(normalized)
546
  else:
547
- tie_min = min(item.get("tie_break_score", 0) for item in scored)
548
- tie_max = max(item.get("tie_break_score", 0) for item in scored)
549
  tie_spread = tie_max - tie_min
550
  if tie_spread > 1e-9:
551
  for item in scored:
552
- normalized = (item.get("tie_break_score", 0) - tie_min) / (tie_spread + 1e-6)
553
  if normalized < 0.2:
554
  jitter_seed = (
555
  int(observation.get("seed", 0))
@@ -558,14 +543,14 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
558
  )
559
  jitter_rng = random.Random(jitter_seed)
560
  normalized *= jitter_rng.uniform(0.3, 0.7)
561
- item["policy_score"] = clamp_strict_score(normalized)
562
  else:
563
  for item in scored:
564
- item["policy_score"] = STRICT_SCORE_MIN
565
 
566
  # Remove hard-zero scores and normalize to probability-like values.
567
  for item in scored:
568
- if item["policy_score"] <= 0:
569
  jitter_seed = (
570
  int(observation.get("seed", 0))
571
  + (step_number * 173)
@@ -576,7 +561,7 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
576
  if item.get("specialization") == required_specialization:
577
  item["policy_score"] = jitter_rng.uniform(0.08, 0.18)
578
  else:
579
- item["policy_score"] = jitter_rng.uniform(0.01, 0.03)
580
  else:
581
  item["policy_score"] = jitter_rng.uniform(0.05, 0.15)
582
 
@@ -585,7 +570,7 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
585
  for item in scored:
586
  item["policy_score"] = item["policy_score"] / (total_score + 1e-6)
587
  else:
588
- uniform = 1 / len(scored)
589
  for item in scored:
590
  item["policy_score"] = uniform
591
 
@@ -605,7 +590,7 @@ def score_hospitals(observation: dict, learning_profile: dict | None = None) ->
605
 
606
  for item in scored:
607
  raw_score = float(item["policy_score"])
608
- normalized_score = raw_score / (1 + abs(raw_score))
609
  # Keep a small floor so no action is fully eliminated from exploration.
610
  if normalized_score < 0.01:
611
  jitter_seed = (
@@ -691,7 +676,7 @@ def choose_hospital(
691
  else:
692
  policy_mode = "balanced"
693
 
694
- safe_weight = 1
695
  if policy_mode == "safe":
696
  safe_weight *= 0.8
697
  epsilon *= 0.6
@@ -729,26 +714,26 @@ def choose_hospital(
729
  candidates = sorted(candidates, key=lambda item: item["distance_km"])
730
 
731
  def learned_utility(item: dict) -> float:
732
- base = float(item.get("policy_score", 0))
733
  if not learning_profile:
734
  return base
735
  step_stats = learning_profile.get("step_stats", {}).get(str(step_number), {})
736
  stats = step_stats.get(item["hospital_id"], {})
737
  count = int(stats.get("count", 0))
738
  if count <= 0:
739
- exploration_bonus = 0.22 * math.sqrt(max(1, math.log(attempts + 2.0)))
740
  return base + exploration_bonus
741
- avg_reward = float(stats.get("avg_reward", 0))
742
- success_rate = float(stats.get("success_rate", 0))
743
  rejected = int(stats.get("rejected", 0))
744
  rejection_rate = rejected / max(1, count)
745
- exploration_bonus = 0.18 * math.sqrt(max(0, math.log(attempts + 2.0) / (count + 1)))
746
  # Real-data utility: reward trend + success rate - rejection risk + exploration bonus.
747
  historical_weight = 0.35
748
  historical_weight *= 0.6
749
  historical_bonus = (avg_reward * historical_weight) + (success_rate * 0.30) - (rejection_rate * 0.22)
750
  if item["hospital_id"] in recent_failed:
751
- historical_bonus = 0
752
  return base + historical_bonus + exploration_bonus
753
 
754
  def pick_improvement_candidate(route_choice_id: str | None) -> dict | None:
@@ -767,7 +752,7 @@ def choose_hospital(
767
  if last_status == "rejected" and previous_action and chosen.get("hospital_id") == previous_action:
768
  alternatives = [item for item in scored if item["hospital_id"] != previous_action]
769
  if alternatives:
770
- rerouted = max(alternatives, key=lambda item: float(item.get("policy_score", 0)))
771
  return rerouted, strategy + " + immediate-retry block"
772
 
773
  # Global guardrail: when a score gap is very large, prefer best option most
@@ -786,9 +771,9 @@ def choose_hospital(
786
  globally_eligible = list(scored)
787
 
788
  if globally_eligible:
789
- best_global = max(globally_eligible, key=lambda item: float(item.get("policy_score", 0)))
790
- chosen_score = float(chosen.get("policy_score", 0))
791
- best_global_score = float(best_global.get("policy_score", 0))
792
  # Cooldown hard guard: never immediately retry the just-failed hospital.
793
  if (last_status == "rejected" or is_rerouting_phase) and recent_hospital:
794
  if chosen.get("hospital_id") == recent_hospital:
@@ -800,7 +785,7 @@ def choose_hospital(
800
  if not alternatives:
801
  alternatives = [item for item in scored if item["hospital_id"] != recent_hospital]
802
  if alternatives:
803
- rerouted = max(alternatives, key=lambda item: float(item.get("policy_score", 0)))
804
  return rerouted, strategy + " + cooldown reroute"
805
 
806
  if chosen_score < (best_global_score * 0.6):
@@ -816,9 +801,9 @@ def choose_hospital(
816
  guard_blocked: set[str] = set()
817
  for hospital_id, stats in step_stats.items():
818
  count = int(stats.get("count", 0))
819
- success_rate = float(stats.get("success_rate", 0))
820
  rejected = int(stats.get("rejected", 0))
821
- if count >= 2 and success_rate <= 0 and rejected >= 2:
822
  guard_blocked.add(hospital_id)
823
 
824
  guarded_candidates = [item for item in candidates if item["hospital_id"] not in guard_blocked]
@@ -845,7 +830,7 @@ def choose_hospital(
845
  step_number == 1
846
  and baseline_candidate is not None
847
  and top_candidate is not None
848
- and float(baseline_candidate.get("policy_score", 0)) < float(top_candidate.get("policy_score", 0))
849
  ):
850
  baseline_candidate = None
851
 
@@ -878,7 +863,7 @@ def choose_hospital(
878
  preferred_hospital = preferred_route[step_number - 1]
879
  preferred_candidate = next((item for item in candidates if item["hospital_id"] == preferred_hospital), None)
880
  if preferred_candidate is not None:
881
- profile_score = float(learning_profile.get("best_score", 0))
882
  if (profile_score * safe_weight) >= 0.85 or len(candidates) == 1:
883
  return enforce_score_guard(preferred_candidate, "learned best path")
884
 
@@ -963,13 +948,13 @@ def run_episode(
963
 
964
  if learning_profile:
965
  print(
966
- f"Learning memory: best historical score {float(learning_profile.get('best_score', 0)):.3f} "
967
  f"across {int(learning_profile.get('attempts', 0))} attempts"
968
  )
969
  if learning_profile.get("best_actions"):
970
  print(f"Best known route: {' -> '.join(learning_profile['best_actions'])}")
971
 
972
- total_reward = 0
973
  steps = 0
974
  done = False
975
  previous_policy_hospital_id: str | None = None
@@ -990,11 +975,11 @@ def run_episode(
990
  if previous_policy_outcome == "REJECTED" and previous_policy_hospital_id and chosen["hospital_id"] == previous_policy_hospital_id:
991
  alternatives = [item for item in scored if item["hospital_id"] != previous_policy_hospital_id]
992
  if alternatives:
993
- chosen = max(alternatives, key=lambda item: float(item.get("policy_score", 0)))
994
  strategy = strategy + " + immediate-retry override"
995
 
996
  print_options(scored)
997
- rationale = llm_rationale(llm_client, model_name or "", observation, chosen, strategy)
998
  print(f"Decision: {chosen['hospital_id']} ({strategy})")
999
 
1000
  step_result = env.step(
@@ -1016,8 +1001,6 @@ def run_episode(
1016
  reason = str(outcome.get("reason", "No reason provided"))
1017
  previous_policy_hospital_id = chosen["hospital_id"]
1018
  previous_policy_outcome = status
1019
- reward = safe_score(reward)
1020
- policy_score = safe_score(chosen["policy_score"])
1021
 
1022
  print(f"Outcome: {status}")
1023
  print(f"Reason: {reason}")
@@ -1033,7 +1016,6 @@ def run_episode(
1033
  "strategy": strategy,
1034
  "status": status,
1035
  "reward": round(reward, 4),
1036
- "policy_score": round(policy_score, 4),
1037
  "done": done,
1038
  },
1039
  )
@@ -1053,7 +1035,7 @@ def run_episode(
1053
  },
1054
  "action": {
1055
  "hospital_id": chosen["hospital_id"],
1056
- "policy_score": policy_score,
1057
  "strategy": strategy,
1058
  },
1059
  "outcome": {
@@ -1071,7 +1053,7 @@ def run_episode(
1071
  "status": status,
1072
  "reason": reason,
1073
  "reward": reward,
1074
- "policy_score": policy_score,
1075
  }
1076
  )
1077
 
@@ -1079,7 +1061,7 @@ def run_episode(
1079
 
1080
  final_state = env.state()
1081
  final_result = final_state.final_outcome or "FAILURE"
1082
- final_score = safe_score(final_state.final_score)
1083
 
1084
  print("\nFinal result:")
1085
  print(f" Result: {final_result}")
@@ -1122,7 +1104,7 @@ def update_learning_archive(archive: dict, episode_result: dict) -> None:
1122
  key,
1123
  {
1124
  "attempts": 0,
1125
- "best_score": 0,
1126
  "best_actions": [],
1127
  "best_steps": 0,
1128
  "step_stats": {},
@@ -1138,7 +1120,7 @@ def update_learning_archive(archive: dict, episode_result: dict) -> None:
1138
  profile["last_scenario_type"] = episode_result.get("scenario_type")
1139
  profile["last_scenario_name"] = episode_result.get("scenario_name")
1140
 
1141
- if float(episode_result["score"]) >= float(profile.get("best_score", 0)):
1142
  profile["best_score"] = float(episode_result["score"])
1143
  profile["best_actions"] = list(episode_result.get("actions", []))
1144
  profile["best_steps"] = int(episode_result.get("steps", 0))
@@ -1160,8 +1142,8 @@ def update_learning_archive(archive: dict, episode_result: dict) -> None:
1160
  "accepted": 0,
1161
  "partial": 0,
1162
  "rejected": 0,
1163
- "total_reward": 0,
1164
- "avg_reward": 0,
1165
  "last_status": None,
1166
  "last_reason": None,
1167
  },
@@ -1287,5 +1269,3 @@ def main() -> None:
1287
 
1288
  if __name__ == "__main__":
1289
  main()
1290
-
1291
-
 
1
+ #!/usr/bin/env python3
2
  """Local agent runner for EmergencyEnv.
3
 
4
  This script acts as an agent only:
 
17
  import random
18
  from pathlib import Path
19
  from datetime import datetime, timezone
20
+ from typing import Union, TYPE_CHECKING, Optional, cast
21
 
22
  from app.environment.core import EmergencyEnv
23
  from app.models.action import Action
24
 
25
+ if TYPE_CHECKING:
26
+ from openai import OpenAI as OpenAIClient
27
+ else:
28
+ OpenAIClient = None
29
+
30
  try:
31
  from openai import OpenAI
32
  except Exception: # pragma: no cover - fallback for missing optional dependency
33
  OpenAI = None
34
 
 
 
 
 
 
35
  TASK_ORDER = ["acde_easy", "acde_medium", "acde_hard"]
36
  LEVEL_TO_TASK = {
37
  "low": "acde_easy",
 
41
  RANDOM_LEVELS = ("medium", "high")
42
  RANDOM_LEVEL_WEIGHTS = (0.25, 0.75)
43
  BASE_SPEED_KMH = 60.0
44
+ TRAFFIC_FACTOR = {"low": 1.0, "medium": 0.6, "high": 0.3}
45
  LEARNING_ARCHIVE_PATH = Path(__file__).resolve().parent / "data" / "learning_archive.json"
46
  LEARNING_ARCHIVE_VERSION = 2
47
  DEFAULT_API_BASE_URL = "https://api-inference.huggingface.co/v1"
48
  DEFAULT_MODEL_NAME = "Qwen/Qwen2.5-72B-Instruct"
49
  REQUIRED_ENV_VARS = ("HF_TOKEN",)
50
+ STRICT_SCORE_MIN = 0.001
51
+ STRICT_SCORE_MAX = 0.999
52
 
53
 
54
  def clamp_strict_score(value: float) -> float:
 
56
  return max(STRICT_SCORE_MIN, min(STRICT_SCORE_MAX, float(value)))
57
 
58
 
 
 
 
 
 
59
  def parse_args() -> argparse.Namespace:
60
  parser = argparse.ArgumentParser(description="EmergencyEnv agent runner")
61
  parser.add_argument("--mode", choices=["single", "full"], default="full")
 
77
 
78
 
79
  def runtime_llm_config() -> dict[str, str]:
 
 
 
 
 
 
 
80
  return {
81
  "API_BASE_URL": os.getenv("API_BASE_URL", DEFAULT_API_BASE_URL).strip(),
82
  "MODEL_NAME": os.getenv("MODEL_NAME", DEFAULT_MODEL_NAME).strip(),
83
+ "HF_TOKEN": os.getenv("HF_TOKEN", "").strip(),
84
  }
85
 
86
 
87
+ def require_llm_config() -> tuple[OpenAIClient, str]:
88
  config = runtime_llm_config()
89
  missing = [name for name, value in config.items() if not value]
90
  if missing:
 
 
 
 
 
91
  raise SystemExit(
92
  "Missing required environment variables: "
93
+ + ", ".join(missing)
94
  + ". Set HF_TOKEN before running inference.py"
95
  )
96
  if OpenAI is None:
 
101
 
102
 
103
  def llm_rationale(
104
+ client: Union[OpenAIClient, None],
105
  model_name: str,
106
  observation: dict,
107
  chosen: dict,
 
111
  f"Selected {chosen['hospital_id']} by {strategy}; "
112
  f"score={chosen['policy_score']:.3f}, traffic={chosen['traffic']}, icu={chosen['icu']}"
113
  )
114
+ if client is None:
115
+ return fallback
116
  try:
117
  prompt = (
118
  "You are an emergency routing agent. Return one short sentence rationale "
 
130
  {"role": "system", "content": "Generate concise emergency triage rationale."},
131
  {"role": "user", "content": prompt},
132
  ],
133
+ temperature=0.0,
134
  max_tokens=60,
135
  )
136
  text = (completion.choices[0].message.content or "").strip()
 
231
  accepted = int(a.get("accepted", 0)) + int(b.get("accepted", 0))
232
  partial = int(a.get("partial", 0)) + int(b.get("partial", 0))
233
  rejected = int(a.get("rejected", 0)) + int(b.get("rejected", 0))
234
+ total_reward = float(a.get("total_reward", 0.0)) + float(b.get("total_reward", 0.0))
235
  merged[step_key][hospital_id] = {
236
  "count": count,
237
  "success": int(a.get("success", 0)) + int(b.get("success", 0)),
 
262
  # Strict scope: learn only from same seed + same level/task.
263
  return {
264
  "attempts": int(exact.get("attempts", 0)),
265
+ "best_score": float(exact.get("best_score", 0.0)),
266
  "best_actions": list(exact.get("best_actions", [])),
267
  "step_stats": exact.get("step_stats", {}),
268
  "best_scenario_name": exact.get("best_scenario_name"),
 
287
  probs = [e / total for e in exps]
288
 
289
  roll = rng.random()
290
+ cdf = 0.0
291
  for item, prob in zip(candidates, probs):
292
  cdf += prob
293
  if roll <= cdf:
 
307
 
308
  success = int(entry.get("accepted", entry.get("success", 0)))
309
  fail = int(entry.get("rejected", entry.get("fail", 0)))
310
+ avg = float(entry.get("avg", 0.0))
311
  total = success + fail
312
  if total <= 0:
313
  return 0.5
 
321
  step_stats = learning_profile.get("step_stats", {}).get(str(step_number), {})
322
  hospital_stats = step_stats.get(hospital_id)
323
  if hospital_stats:
324
+ step_avg = float(hospital_stats.get("avg_reward", 0.0))
325
+ step_success = float(hospital_stats.get("success_rate", 0.0))
326
  step_count = int(hospital_stats.get("count", 0))
327
  value += min(0.20, (step_avg * 0.10) + (step_success * 0.08) + min(step_count, 5) * 0.01)
328
  recent_failed = str(hospital_stats.get("last_status", "")).upper() == "REJECTED"
 
330
  if recent_failed:
331
  value -= 0.3
332
 
333
+ return max(0.0, min(1.0, value))
334
 
335
 
336
  def score_hospitals(observation: dict, learning_profile: dict | None = None) -> list[dict]:
 
345
  scored: list[dict] = []
346
  initial_limit = float(observation.get("initial_critical_time_limit_minutes", observation["critical_time_limit_minutes"]))
347
  remaining_time = float(observation.get("remaining_time_minutes", observation["critical_time_limit_minutes"]))
348
+ urgency = 1.0 - min(1.0, max(0.0, remaining_time / max(initial_limit, 1e-6)))
349
 
350
  patient_condition = observation.get("patient_condition", "").lower()
351
  critical_patient = patient_condition in {"critical", "unstable"}
 
363
  speed_kmh = BASE_SPEED_KMH * traffic_factor
364
  travel_time = (hospital["distance_km"] / max(speed_kmh, 1e-6)) * 60.0
365
 
366
+ distance_score = max(0.0, min(1.0, 1.0 - hospital["distance_km"] / 20.0))
367
+ icu_score = 1.0 if hospital["icu"] == "available" else 0.55
368
  mem_score = memory_score_for_hospital(
369
  hospital["hospital_id"],
370
  memory_snapshot,
 
393
  and observation["required_specialization"] != "general"
394
  )
395
 
396
+ rejected_penalty = 0.40 if hospital["hospital_id"] in failed else 0.0
397
+ revisit_penalty = 0.14 if hospital["hospital_id"] in visited else 0.0
398
  partial_repeat_penalty = (
399
  0.32
400
  if last_status == "partial" and hospital["hospital_id"] == previous_action
401
+ else 0.0
402
  )
403
  critical_unknown_penalty = 0.17 if critical_patient and hospital["icu"] == "unknown" else 0.03
404
+ traffic_penalty = 0.10 if hospital["traffic"] == "high" else 0.04 if hospital["traffic"] == "medium" else 0.0
405
  if critical_patient and general_fallback:
406
  spec_penalty = {"easy": 0.08, "medium": 0.16, "hard": 0.26}.get(difficulty, 0.16)
407
  if attempts >= 5:
408
  spec_penalty += 0.06
409
  else:
410
+ spec_penalty = 0.0
411
+ spec_bonus = 0.16 if exact_spec_match else (0.08 if spec_match else 0.0)
412
+ urgency_boost = urgency * (0.18 + max(0.0, 0.25 - travel_time / 100.0))
413
+ step_route_bonus = 0.0
414
  if step_number - 1 < len(preferred_route) and preferred_route[step_number - 1] == hospital["hospital_id"]:
415
  step_route_bonus = 0.16
416
 
 
453
  score -= 0.3
454
 
455
  # Confidence-style risk multiplier keeps risky options from looking deceptively good.
456
+ risk_factor = 1.0
457
  if hospital["icu"] == "unknown":
458
  risk_factor *= 0.6
459
  if not spec_match:
 
469
  memory_weight = 0.1
470
  current_score_weight = 0.9
471
  base_current_score = score
472
+ confidence_score = max(0.0, min(1.0, base_current_score))
473
  effective_memory_score = mem_score
474
  in_best_route = hospital["hospital_id"] in preferred_route
475
  if in_best_route and confidence_score < 0.6:
476
+ effective_memory_score = 0.0
477
  if confidence_score < 0.2:
478
+ effective_memory_score = 0.0
479
 
480
  score = (current_score_weight * base_current_score) + (memory_weight * effective_memory_score)
481
 
 
488
  "specialization": hospital["specialization"],
489
  "travel_time": travel_time,
490
  "memory_score": mem_score,
491
+ "policy_score": max(0.0, min(1.0, score)),
492
  "specialization_match": spec_match,
493
  "tie_break_score": (
494
  (distance_score * 0.35)
495
  + (traffic_factor * 0.35)
496
  + (icu_score * 0.20)
497
+ + (0.10 if spec_match else 0.0)
498
  ),
499
  }
500
  )
 
515
  )
516
  jitter_rng = random.Random(jitter_seed)
517
  normalized *= jitter_rng.uniform(0.3, 0.7)
518
+ item["policy_score"] = max(0.0, min(1.0, normalized))
519
  elif max_score > 0:
520
  for item in scored:
521
  normalized = item["policy_score"] / max_score
 
527
  )
528
  jitter_rng = random.Random(jitter_seed)
529
  normalized *= jitter_rng.uniform(0.3, 0.7)
530
+ item["policy_score"] = max(0.0, min(1.0, normalized))
531
  else:
532
+ tie_min = min(item.get("tie_break_score", 0.0) for item in scored)
533
+ tie_max = max(item.get("tie_break_score", 0.0) for item in scored)
534
  tie_spread = tie_max - tie_min
535
  if tie_spread > 1e-9:
536
  for item in scored:
537
+ normalized = (item.get("tie_break_score", 0.0) - tie_min) / (tie_spread + 1e-6)
538
  if normalized < 0.2:
539
  jitter_seed = (
540
  int(observation.get("seed", 0))
 
543
  )
544
  jitter_rng = random.Random(jitter_seed)
545
  normalized *= jitter_rng.uniform(0.3, 0.7)
546
+ item["policy_score"] = max(0.0, min(1.0, normalized))
547
  else:
548
  for item in scored:
549
+ item["policy_score"] = 0.0
550
 
551
  # Remove hard-zero scores and normalize to probability-like values.
552
  for item in scored:
553
+ if item["policy_score"] <= 0.0:
554
  jitter_seed = (
555
  int(observation.get("seed", 0))
556
  + (step_number * 173)
 
561
  if item.get("specialization") == required_specialization:
562
  item["policy_score"] = jitter_rng.uniform(0.08, 0.18)
563
  else:
564
+ item["policy_score"] = jitter_rng.uniform(0.001, 0.01)
565
  else:
566
  item["policy_score"] = jitter_rng.uniform(0.05, 0.15)
567
 
 
570
  for item in scored:
571
  item["policy_score"] = item["policy_score"] / (total_score + 1e-6)
572
  else:
573
+ uniform = 1.0 / len(scored)
574
  for item in scored:
575
  item["policy_score"] = uniform
576
 
 
590
 
591
  for item in scored:
592
  raw_score = float(item["policy_score"])
593
+ normalized_score = raw_score / (1.0 + abs(raw_score))
594
  # Keep a small floor so no action is fully eliminated from exploration.
595
  if normalized_score < 0.01:
596
  jitter_seed = (
 
676
  else:
677
  policy_mode = "balanced"
678
 
679
+ safe_weight = 1.0
680
  if policy_mode == "safe":
681
  safe_weight *= 0.8
682
  epsilon *= 0.6
 
714
  candidates = sorted(candidates, key=lambda item: item["distance_km"])
715
 
716
  def learned_utility(item: dict) -> float:
717
+ base = float(item.get("policy_score", 0.0))
718
  if not learning_profile:
719
  return base
720
  step_stats = learning_profile.get("step_stats", {}).get(str(step_number), {})
721
  stats = step_stats.get(item["hospital_id"], {})
722
  count = int(stats.get("count", 0))
723
  if count <= 0:
724
+ exploration_bonus = 0.22 * math.sqrt(max(1.0, math.log(attempts + 2.0)))
725
  return base + exploration_bonus
726
+ avg_reward = float(stats.get("avg_reward", 0.0))
727
+ success_rate = float(stats.get("success_rate", 0.0))
728
  rejected = int(stats.get("rejected", 0))
729
  rejection_rate = rejected / max(1, count)
730
+ exploration_bonus = 0.18 * math.sqrt(max(0.0, math.log(attempts + 2.0) / (count + 1.0)))
731
  # Real-data utility: reward trend + success rate - rejection risk + exploration bonus.
732
  historical_weight = 0.35
733
  historical_weight *= 0.6
734
  historical_bonus = (avg_reward * historical_weight) + (success_rate * 0.30) - (rejection_rate * 0.22)
735
  if item["hospital_id"] in recent_failed:
736
+ historical_bonus = 0.0
737
  return base + historical_bonus + exploration_bonus
738
 
739
  def pick_improvement_candidate(route_choice_id: str | None) -> dict | None:
 
752
  if last_status == "rejected" and previous_action and chosen.get("hospital_id") == previous_action:
753
  alternatives = [item for item in scored if item["hospital_id"] != previous_action]
754
  if alternatives:
755
+ rerouted = max(alternatives, key=lambda item: float(item.get("policy_score", 0.0)))
756
  return rerouted, strategy + " + immediate-retry block"
757
 
758
  # Global guardrail: when a score gap is very large, prefer best option most
 
771
  globally_eligible = list(scored)
772
 
773
  if globally_eligible:
774
+ best_global = max(globally_eligible, key=lambda item: float(item.get("policy_score", 0.0)))
775
+ chosen_score = float(chosen.get("policy_score", 0.0))
776
+ best_global_score = float(best_global.get("policy_score", 0.0))
777
  # Cooldown hard guard: never immediately retry the just-failed hospital.
778
  if (last_status == "rejected" or is_rerouting_phase) and recent_hospital:
779
  if chosen.get("hospital_id") == recent_hospital:
 
785
  if not alternatives:
786
  alternatives = [item for item in scored if item["hospital_id"] != recent_hospital]
787
  if alternatives:
788
+ rerouted = max(alternatives, key=lambda item: float(item.get("policy_score", 0.0)))
789
  return rerouted, strategy + " + cooldown reroute"
790
 
791
  if chosen_score < (best_global_score * 0.6):
 
801
  guard_blocked: set[str] = set()
802
  for hospital_id, stats in step_stats.items():
803
  count = int(stats.get("count", 0))
804
+ success_rate = float(stats.get("success_rate", 0.0))
805
  rejected = int(stats.get("rejected", 0))
806
+ if count >= 2 and success_rate <= 0.0 and rejected >= 2:
807
  guard_blocked.add(hospital_id)
808
 
809
  guarded_candidates = [item for item in candidates if item["hospital_id"] not in guard_blocked]
 
830
  step_number == 1
831
  and baseline_candidate is not None
832
  and top_candidate is not None
833
+ and float(baseline_candidate.get("policy_score", 0.0)) < float(top_candidate.get("policy_score", 0.0))
834
  ):
835
  baseline_candidate = None
836
 
 
863
  preferred_hospital = preferred_route[step_number - 1]
864
  preferred_candidate = next((item for item in candidates if item["hospital_id"] == preferred_hospital), None)
865
  if preferred_candidate is not None:
866
+ profile_score = float(learning_profile.get("best_score", 0.0))
867
  if (profile_score * safe_weight) >= 0.85 or len(candidates) == 1:
868
  return enforce_score_guard(preferred_candidate, "learned best path")
869
 
 
948
 
949
  if learning_profile:
950
  print(
951
+ f"Learning memory: best historical score {float(learning_profile.get('best_score', 0.0)):.3f} "
952
  f"across {int(learning_profile.get('attempts', 0))} attempts"
953
  )
954
  if learning_profile.get("best_actions"):
955
  print(f"Best known route: {' -> '.join(learning_profile['best_actions'])}")
956
 
957
+ total_reward = 0.0
958
  steps = 0
959
  done = False
960
  previous_policy_hospital_id: str | None = None
 
975
  if previous_policy_outcome == "REJECTED" and previous_policy_hospital_id and chosen["hospital_id"] == previous_policy_hospital_id:
976
  alternatives = [item for item in scored if item["hospital_id"] != previous_policy_hospital_id]
977
  if alternatives:
978
+ chosen = max(alternatives, key=lambda item: float(item.get("policy_score", 0.0)))
979
  strategy = strategy + " + immediate-retry override"
980
 
981
  print_options(scored)
982
+ rationale = llm_rationale(cast(Optional[OpenAIClient], llm_client), model_name or "", observation, chosen, strategy)
983
  print(f"Decision: {chosen['hospital_id']} ({strategy})")
984
 
985
  step_result = env.step(
 
1001
  reason = str(outcome.get("reason", "No reason provided"))
1002
  previous_policy_hospital_id = chosen["hospital_id"]
1003
  previous_policy_outcome = status
 
 
1004
 
1005
  print(f"Outcome: {status}")
1006
  print(f"Reason: {reason}")
 
1016
  "strategy": strategy,
1017
  "status": status,
1018
  "reward": round(reward, 4),
 
1019
  "done": done,
1020
  },
1021
  )
 
1035
  },
1036
  "action": {
1037
  "hospital_id": chosen["hospital_id"],
1038
+ "policy_score": chosen["policy_score"],
1039
  "strategy": strategy,
1040
  },
1041
  "outcome": {
 
1053
  "status": status,
1054
  "reason": reason,
1055
  "reward": reward,
1056
+ "policy_score": chosen["policy_score"],
1057
  }
1058
  )
1059
 
 
1061
 
1062
  final_state = env.state()
1063
  final_result = final_state.final_outcome or "FAILURE"
1064
+ final_score = clamp_strict_score(final_state.final_score)
1065
 
1066
  print("\nFinal result:")
1067
  print(f" Result: {final_result}")
 
1104
  key,
1105
  {
1106
  "attempts": 0,
1107
+ "best_score": 0.0,
1108
  "best_actions": [],
1109
  "best_steps": 0,
1110
  "step_stats": {},
 
1120
  profile["last_scenario_type"] = episode_result.get("scenario_type")
1121
  profile["last_scenario_name"] = episode_result.get("scenario_name")
1122
 
1123
+ if float(episode_result["score"]) >= float(profile.get("best_score", 0.0)):
1124
  profile["best_score"] = float(episode_result["score"])
1125
  profile["best_actions"] = list(episode_result.get("actions", []))
1126
  profile["best_steps"] = int(episode_result.get("steps", 0))
 
1142
  "accepted": 0,
1143
  "partial": 0,
1144
  "rejected": 0,
1145
+ "total_reward": 0.0,
1146
+ "avg_reward": 0.0,
1147
  "last_status": None,
1148
  "last_reason": None,
1149
  },
 
1269
 
1270
  if __name__ == "__main__":
1271
  main()
 
 
openenv.yaml CHANGED
@@ -1,5 +1,5 @@
1
  name: acde-openenv
2
- version: "1"
3
  description: Adaptive Crisis Decision Environment
4
  runtime:
5
  transport: http
@@ -24,5 +24,5 @@ contracts:
24
  reward: app.models.reward.RewardModel
25
  info: app.models.reward.StepInfo
26
  state: app.models.state.EnvState
27
- reward_range: [0.01, 0.99]
28
  done_type: boolean
 
1
  name: acde-openenv
2
+ version: "1.0"
3
  description: Adaptive Crisis Decision Environment
4
  runtime:
5
  transport: http
 
24
  reward: app.models.reward.RewardModel
25
  info: app.models.reward.StepInfo
26
  state: app.models.state.EnvState
27
+ reward_range: [0.001, 0.999]
28
  done_type: boolean
server/app.py CHANGED
@@ -1,4 +1,4 @@
1
- import uvicorn
2
 
3
  from app.server.app import app
4
 
@@ -12,4 +12,3 @@ if __name__ == "__main__":
12
 
13
 
14
  __all__ = ["app", "main"]
15
-
 
1
+ import uvicorn
2
 
3
  from app.server.app import app
4
 
 
12
 
13
 
14
  __all__ = ["app", "main"]