Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- app/environment/core.py +51 -65
- app/environment/graders.py +14 -16
- app/environment/validation.py +24 -36
- app/models/observation.py +4 -19
- app/models/reward.py +73 -75
- app/models/state.py +8 -28
- app/server/app.py +6 -14
- app/utils/calculations.py +20 -29
- client.py +3 -3
- inference.py +79 -99
- openenv.yaml +2 -2
- server/app.py +1 -2
app/environment/core.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
from pathlib import Path
|
| 3 |
-
from typing import Any,
|
| 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 |
-
|
| 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.
|
| 44 |
-
MAX_REWARD = 0.
|
| 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
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 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:
|
| 1071 |
-
generators
|
| 1072 |
(generate_medical_case, "medical"),
|
| 1073 |
(generate_accident_case, "accident"),
|
| 1074 |
(generate_fire_case, "fire"),
|
| 1075 |
]
|
| 1076 |
-
|
| 1077 |
-
|
| 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
|
| 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 =
|
| 1119 |
else:
|
| 1120 |
-
normalized_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
|
| 1132 |
|
| 1133 |
def _failure_score(self) -> float:
|
| 1134 |
assert self.state_data is not None
|
| 1135 |
progress_component = self._progress_score()
|
| 1136 |
-
reward_component =
|
| 1137 |
score = 0.15 + (0.35 * reward_component) + (0.25 * progress_component)
|
| 1138 |
-
return
|
| 1139 |
|
| 1140 |
def _success_score(self) -> float:
|
| 1141 |
assert self.state_data is not None
|
| 1142 |
progress_component = self._progress_score()
|
| 1143 |
-
reward_component =
|
| 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 =
|
| 1147 |
score = (0.45 * reward_component) + (0.40 * progress_component) + (0.15 * route_quality)
|
| 1148 |
-
return
|
| 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 |
-
|
| 2 |
|
| 3 |
from app.models.reward import GraderResult
|
| 4 |
|
| 5 |
|
| 6 |
-
MIN_SCORE = 0.
|
| 7 |
-
MAX_SCORE = 0.
|
| 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:
|
| 18 |
-
difficulty:
|
| 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 |
-
|
| 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) ->
|
| 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:
|
| 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 =
|
| 206 |
|
| 207 |
-
return
|
| 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[
|
| 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 |
-
|
| 2 |
|
| 3 |
-
from pydantic import BaseModel, Field
|
| 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=
|
| 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 |
-
|
| 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.
|
| 9 |
-
_FLOOR = 0.
|
| 10 |
-
_CEIL = 0.
|
| 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 |
-
|
| 2 |
|
| 3 |
-
from pydantic import BaseModel, Field
|
| 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=
|
| 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=
|
| 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=
|
| 78 |
-
reward: float = Field(default=
|
| 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 |
-
|
| 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.
|
| 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 |
-
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 =
|
| 73 |
-
time_efficiency =
|
| 74 |
-
specialization_component =
|
| 75 |
-
delay_penalty =
|
| 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
|
| 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 |
-
|
| 97 |
if survival_score is not None
|
| 98 |
-
else
|
| 99 |
)
|
| 100 |
-
time_efficiency =
|
| 101 |
specialization_component = (
|
| 102 |
-
|
| 103 |
if capability_score is not None
|
| 104 |
-
else
|
| 105 |
)
|
| 106 |
-
delay_penalty =
|
| 107 |
adapt_component = (
|
| 108 |
-
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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.
|
| 51 |
-
STRICT_SCORE_MAX = 0.
|
| 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":
|
| 96 |
}
|
| 97 |
|
| 98 |
|
| 99 |
-
def require_llm_config() -> tuple[
|
| 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 |
-
+
|
| 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:
|
| 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
|
| 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 =
|
| 382 |
-
icu_score =
|
| 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 =
|
| 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 =
|
| 492 |
if confidence_score < 0.2:
|
| 493 |
-
effective_memory_score =
|
| 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":
|
| 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"] =
|
| 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"] =
|
| 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"] =
|
| 562 |
else:
|
| 563 |
for item in scored:
|
| 564 |
-
item["policy_score"] =
|
| 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.
|
| 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 =
|
| 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.
|
| 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 |
-
|
| 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"]
|
|
|