paramjitbaral commited on
Commit
4e42a7f
·
verified ·
1 Parent(s): 792704c

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,205 +1,300 @@
1
  ---
2
- title: Acde-Openenv Environment Server
3
- emoji: 🚑
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: docker
7
  pinned: false
8
- app_port: 8000
9
  base_path: /web
10
- tags:
11
- - openenv
12
  ---
13
 
14
- # Adaptive Crisis Decision Environment (ACDE)
15
 
16
- ACDE is an OpenEnv-compliant, real-world emergency dispatch environment for evaluating decision-making under uncertainty. The task models ambulance routing to hospitals under changing traffic, hidden ICU truth, and patient deterioration.
17
 
18
- ## Why This Is Real-World
 
 
 
 
 
19
 
20
- This environment simulates a practical operation used by emergency coordinators:
21
 
22
- - choosing destination hospitals under incomplete information
23
- - trading off travel time vs ICU availability vs specialization
24
- - handling a non-stationary world where traffic and capacity shift
25
 
26
- ## OpenEnv API
27
 
28
- Implemented in [app/environment/core.py](app/environment/core.py) and exposed via [app/server/app.py](app/server/app.py).
 
 
 
29
 
30
- - reset(seed, task_id) -> observation
31
- - step(action) -> observation, reward, done, info
32
- - state() -> state
33
 
34
- HTTP endpoints:
35
 
36
- - GET /health
37
- - POST /reset
38
- - POST /step
39
- - GET /state
40
 
41
- ## Action Space
 
 
 
 
 
 
 
 
 
42
 
43
- Action model: [app/models/action.py](app/models/action.py)
44
 
45
- - step: int (1..3)
46
- - hospital_id: string (H1/H2/H3)
47
- - rationale: optional string
48
 
49
- ## Observation Space
 
 
 
 
50
 
51
- Observation model: [app/models/observation.py](app/models/observation.py)
 
 
 
52
 
53
- - task_id, task_objective
54
- - scenario metadata (name, difficulty, condition)
55
- - required_specialization
56
- - critical_time_limit_minutes
57
- - current step and max_steps
58
- - hospital list:
59
- - hospital_id
60
- - distance_km
61
- - icu (available/unknown)
62
- - specialization
63
- - traffic (low/medium/high)
64
 
65
- ## Reward and Info Models
66
 
67
- Reward and grader metadata: [app/models/reward.py](app/models/reward.py)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
- - RewardModel.value in [0, 1]
70
- - RewardBreakdown:
71
- - survival_component
72
- - time_efficiency_component
73
- - specialization_component
74
- - delay_penalty
75
- - StepInfo includes:
76
- - last_action_error
77
- - task_id, difficulty, objective
78
- - progress_score
79
- - reward_model
80
- - grader (final step)
81
 
82
- ## Tasks and Agent Graders
83
 
84
- Three deterministic tasks are supported:
 
 
 
85
 
86
- 1. acde_easy
87
- 2. acde_medium
88
- 3. acde_hard
89
 
90
- Task objective and difficulty are fixed by task_id and drive scenario selection.
91
 
92
- Deterministic grader implementation: [app/environment/graders.py](app/environment/graders.py)
 
 
 
 
 
 
93
 
94
- Final grader score in [0, 1] is based on:
 
 
 
95
 
96
- - survival_rate
97
- - margin_rate (time margin vs critical limit)
98
- - specialization_rate
99
- - repeat_failure_penalty
100
 
101
- Difficulty-specific pass thresholds:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
- - easy: 0.75
104
- - medium: 0.65
105
- - hard: 0.50
106
 
107
- ## Physics and Dynamics
108
 
109
- Travel model:
 
110
 
111
- - speed = base_speed * traffic_factor
112
- - traffic_factor: low=1.0, medium=0.6, high=0.3
113
- - travel_time_minutes = (distance_km / speed_kmh) * 60
114
 
115
- Hidden truth:
 
116
 
117
- - displayed ICU can be available/unknown
118
- - actual ICU true/false is hidden and used for survival logic
119
 
120
- Environment changes between steps:
121
 
122
- - traffic can change
123
- - ICU truth can flip
124
- - displayed ICU can drift from truth
125
- - patient critical time window shrinks
126
 
127
- ## Reward Function (Partial Signals)
128
 
129
- Per-step reward in [0, 1] combines:
 
 
 
130
 
131
- - survival (primary)
132
- - time efficiency
133
- - specialization match
134
- - delay penalty
135
 
136
- This gives dense trajectory feedback and penalizes poor choices.
 
 
137
 
138
- ## Reproducibility
139
 
140
- Seed controls scenario generation, hospital parameters, traffic, and hidden ICU state. Same seed + task_id yields deterministic episode construction.
 
 
141
 
142
- ## Inference Baseline (Required Format)
143
 
144
- Script: [inference.py](inference.py)
145
 
146
- - uses OpenAI client for model calls
147
- - reads API_BASE_URL, MODEL_NAME, HF_TOKEN (or OPENAI_API_KEY)
148
- - emits strict structured logs:
149
- - [START]
150
- - [STEP]
151
- - [END]
152
 
153
- Log format example:
 
 
154
 
155
- ```text
156
- [START] task=acde_easy env=acde-openenv model=Qwen/Qwen2.5-72B-Instruct
157
- [STEP] step=1 action=route('H1') reward=0.83 done=false error=null
158
- [STEP] step=2 action=route('H2') reward=0.54 done=false error=null
159
- [STEP] step=3 action=route('H1') reward=0.92 done=true error=null
160
- [END] success=true steps=3 score=0.84 rewards=0.83,0.54,0.92
161
  ```
162
 
163
- ## Local Setup
 
 
 
 
 
 
 
 
 
 
164
 
165
  ```bash
166
- pip install -e .
167
  uvicorn app.server.app:app --host 0.0.0.0 --port 7860
168
  ```
169
 
170
- Run inference:
171
 
172
  ```bash
173
- set API_BASE_URL=https://router.huggingface.co/v1
174
- set MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
175
- set HF_TOKEN=***
176
- set ENV_BASE_URL=http://127.0.0.1:7860
177
- python inference.py
178
  ```
179
 
180
- ## Docker
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  ```bash
183
- docker build -t acde-openenv .
184
- docker run --rm -p 7860:7860 acde-openenv
185
  ```
186
 
187
- Target runtime envelope: 2 vCPU, 8 GB RAM.
188
 
189
- ## Hugging Face Spaces Deployment
190
 
191
- 1. Create a new Docker Space.
192
- 2. Push this repository as the Space source.
193
- 3. Set Space metadata tag to openenv.
194
- 4. Add Secrets/Variables:
195
- - API_BASE_URL
196
- - MODEL_NAME
197
- - HF_TOKEN
198
- 5. Space should expose port 7860 and respond to /health and /reset.
 
 
 
199
 
200
- ## Validation Checklist
201
 
202
- - openenv validate passes
203
- - docker build succeeds
204
- - /health, /reset, /step, /state respond
205
- - inference.py runs end-to-end and prints required structured logs
 
1
  ---
2
+ title: ACDE OpenEnv
3
+ emoji: "🚑"
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: docker
7
  pinned: false
 
8
  base_path: /web
 
 
9
  ---
10
 
11
+ # Emergency Routing Simulation (ACDE)
12
 
13
+ This project is a simulation environment for emergency ambulance routing.
14
 
15
+ In simple terms:
16
+ - A patient needs urgent care.
17
+ - Several hospitals are available.
18
+ - Each hospital has trade-offs (distance, traffic, ICU certainty, specialization).
19
+ - Conditions can change while the ambulance is moving.
20
+ - The agent must decide where to go, step by step.
21
 
22
+ The goal is not to be perfect every time. The goal is to make realistic decisions under uncertainty.
23
 
24
+ ## What This Project Does
 
 
25
 
26
+ This environment helps you test decision logic in situations where information is incomplete and time is limited.
27
 
28
+ It supports three difficulty levels:
29
+ - `acde_easy`
30
+ - `acde_medium`
31
+ - `acde_hard`
32
 
33
+ As difficulty increases, uncertainty and penalties increase too.
 
 
34
 
35
+ ## How It Works (Simple Flow)
36
 
37
+ Every episode follows this loop:
 
 
 
38
 
39
+ 1. The environment is reset with a seed and task.
40
+ 2. You get an observation:
41
+ - Patient condition
42
+ - Required specialization
43
+ - Hospital list with visible signals
44
+ 3. The policy scores hospitals.
45
+ 4. One hospital is selected.
46
+ 5. The environment validates arrival using hidden checks.
47
+ 6. You receive outcome + reward.
48
+ 7. If not done, repeat until success or failure.
49
 
50
+ ## What Makes It Realistic
51
 
52
+ This is not a static lookup problem. It includes realistic uncertainty:
 
 
53
 
54
+ - Displayed ICU status can differ from actual ICU status.
55
+ - Traffic can change between steps.
56
+ - Hospital overload can change outcomes.
57
+ - Specialist availability can fail at arrival.
58
+ - A hospital that failed once may become usable later.
59
 
60
+ The policy includes safety rules such as:
61
+ - Immediate retry protection after rejection.
62
+ - Cooldown handling for recently failed hospitals.
63
+ - Exploration among top options (not blind random picks).
64
 
65
+ ## Project Layout
 
 
 
 
 
 
 
 
 
 
66
 
67
+ Key files:
68
 
69
+ - `app/environment/core.py`
70
+ - Main environment loop (`reset`, `step`, transition logic)
71
+ - `app/environment/validation.py`
72
+ - Hidden validation checks (ICU, specialist, overload, outcome)
73
+ - `app/environment/graders.py`
74
+ - Final scoring and pass/fail grading
75
+ - `app/models/`
76
+ - Pydantic models for state, observation, reward, action
77
+ - `app/server/app.py`
78
+ - FastAPI server endpoints
79
+ - `inference.py`
80
+ - Local policy runner (CLI episodes)
81
+ - `data/learning_memory.json`
82
+ - Rolling policy memory
83
+ - `data/trajectory_history.jsonl`
84
+ - Per-step trajectory logs
85
 
86
+ ## API Endpoints
 
 
 
 
 
 
 
 
 
 
 
87
 
88
+ When server mode is running:
89
 
90
+ - `GET /health`
91
+ - `POST /reset`
92
+ - `POST /step`
93
+ - `GET /state`
94
 
95
+ ## Action Space
 
 
96
 
97
+ The agent sends one action per step as JSON:
98
 
99
+ ```json
100
+ {
101
+ "step": 1,
102
+ "hospital_id": "H3",
103
+ "rationale": "short decision reason"
104
+ }
105
+ ```
106
 
107
+ Action fields:
108
+ - `step` (int, >=1): must match current environment step
109
+ - `hospital_id` (str): target hospital identifier
110
+ - `rationale` (str, optional): policy explanation
111
 
112
+ ## Observation Space
 
 
 
113
 
114
+ Each `reset()` and `step()` returns an observation with:
115
+ - episode metadata: `episode_id`, `seed`, `task_id`, `scenario_name`, `scenario_difficulty`
116
+ - patient state: `patient_condition`, `required_specialization`, remaining time fields
117
+ - hospital list: `hospital_id`, `distance_km`, `icu`, `specialization`, `traffic`
118
+ - routing history: visited/failed hospitals and failure reasons
119
+ - hidden-state feedback: `last_arrival_outcome` summary (status/reason/suitability)
120
+ - memory snapshot used by the baseline policy
121
+
122
+ Core schema is defined by Pydantic models in:
123
+ - `app/models/action.py`
124
+ - `app/models/observation.py`
125
+ - `app/models/state.py`
126
+ - `app/models/reward.py`
127
+
128
+ ## Required Environment Variables
129
+
130
+ Before running `inference.py`, define:
131
+ - `API_BASE_URL`: API base URL for the OpenAI-compatible endpoint
132
+ - `MODEL_NAME`: model name used for rationale generation
133
+ - `HF_TOKEN`: API key/token
134
+
135
+ Windows PowerShell example:
136
+
137
+ ```powershell
138
+ $env:API_BASE_URL = "https://api-inference.huggingface.co/v1"
139
+ $env:MODEL_NAME = "your-model-id"
140
+ $env:HF_TOKEN = "your-token"
141
+ ```
142
 
143
+ ## Installation
 
 
144
 
145
+ ## 1) Prerequisites
146
 
147
+ - Python 3.10+ (3.12 works)
148
+ - `pip`
149
 
150
+ ## 2) Open a terminal in this folder
 
 
151
 
152
+ Folder should be:
153
+ - `my_env`
154
 
155
+ ## 3) Create and activate a virtual environment (recommended)
 
156
 
157
+ Windows PowerShell:
158
 
159
+ ```powershell
160
+ python -m venv .venv
161
+ .\.venv\Scripts\Activate.ps1
162
+ ```
163
 
164
+ macOS/Linux:
165
 
166
+ ```bash
167
+ python -m venv .venv
168
+ source .venv/bin/activate
169
+ ```
170
 
171
+ ## 4) Install dependencies
 
 
 
172
 
173
+ ```bash
174
+ pip install -e .
175
+ ```
176
 
177
+ If editable install is not needed:
178
 
179
+ ```bash
180
+ pip install .
181
+ ```
182
 
183
+ ## Running the Project
184
 
185
+ ## Option A: Run policy episodes directly (most common)
186
 
187
+ Run one medium episode:
 
 
 
 
 
188
 
189
+ ```bash
190
+ python inference.py --mode single --task acde_medium --episodes 1 --seed 555
191
+ ```
192
 
193
+ Run 10 hard episodes:
194
+
195
+ ```bash
196
+ python inference.py --mode single --task acde_hard --episodes 10 --seed 555
 
 
197
  ```
198
 
199
+ Run all levels in sequence:
200
+
201
+ ```bash
202
+ python inference.py --mode full --episodes 3 --seed 555
203
+ ```
204
+
205
+ If you run without `--task`, the script asks for level interactively.
206
+
207
+ ## Option B: Run as HTTP service
208
+
209
+ Start API server:
210
 
211
  ```bash
 
212
  uvicorn app.server.app:app --host 0.0.0.0 --port 7860
213
  ```
214
 
215
+ Health check:
216
 
217
  ```bash
218
+ curl http://127.0.0.1:7860/health
 
 
 
 
219
  ```
220
 
221
+ ## Understanding Output
222
+
223
+ During `inference.py` runs, you will see:
224
+
225
+ - Scenario details
226
+ - Hospital options and scores
227
+ - Decision strategy text
228
+ - Outcome per step (`ACCEPTED`, `PARTIAL`, `REJECTED`)
229
+ - Final episode summary
230
+ - Batch summary (success rate, average score, average steps)
231
+
232
+ Example summary:
233
+
234
+ ```text
235
+ Batch summary:
236
+ Success rate: 20.0%
237
+ Average score: 0.39
238
+ Average steps: 3.6
239
+ ```
240
+
241
+ ## Data Files
242
+
243
+ The simulation writes data to `data/`:
244
+
245
+ - `learning_memory.json`
246
+ - Long-term policy memory
247
+ - `trajectory_history.jsonl`
248
+ - One JSON object per step
249
+ - `learning_archive.json`
250
+ - Aggregate run history and profiles
251
+
252
+ If you want a clean run baseline, back up and clear these files.
253
+
254
+ ## Typical Targets (Guideline)
255
+
256
+ These are practical targets, not strict rules:
257
+
258
+ - Easy: usually high success, often fewer steps
259
+ - Medium: mixed outcomes with meaningful rerouting
260
+ - Hard: lower success, more failures, more steps
261
+
262
+ If hard success is too high, increase uncertainty or rejection pressure.
263
+ If hard success is too low, ease one or two hard-only probabilities.
264
+
265
+ ## Troubleshooting
266
+
267
+ ## "NameError" or model field errors
268
+
269
+ Make sure model fields and observation fields match after logic changes.
270
+ If you added new state keys, also add them in observation models.
271
+
272
+ ## Script asks for seed/level unexpectedly
273
+
274
+ Pass flags explicitly:
275
 
276
  ```bash
277
+ python inference.py --mode single --task acde_hard --episodes 10 --seed 555
 
278
  ```
279
 
280
+ ## No module named app
281
 
282
+ Run commands from inside `my_env` folder, and ensure install succeeded:
283
 
284
+ ```bash
285
+ pip install -e .
286
+ ```
287
+
288
+ ## Uvicorn command not found
289
+
290
+ Install server deps in your active environment:
291
+
292
+ ```bash
293
+ pip install uvicorn fastapi
294
+ ```
295
 
296
+ ## Notes
297
 
298
+ - This project is designed for iterative policy tuning.
299
+ - Small changes in hard-mode probabilities can noticeably shift success rates.
300
+ - Always test with at least 10-30 episodes before concluding behavior changes.
 
app/environment/core.py CHANGED
@@ -1,1132 +1,1132 @@
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
7
- from app.environment.scenarios.fire import generate_fire_case
8
- from app.environment.scenarios.medical import generate_medical_case
9
- from app.environment.validation import DifficultyModifier, HospitalValidator
10
- from app.models.action import Action
11
- from app.models.observation import ArrivalOutcomeObservation, HospitalObservation, Observation
12
- from app.models.reward import RewardBreakdown, RewardModel, StepInfo
13
- from app.models.state import ArrivalOutcome, EnvState, HospitalState, HospitalValidationDetails, LearningEntry
14
- from app.utils.calculations import compute_speed_kmh, compute_travel_time_minutes
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.",
22
- },
23
- "acde_medium": {
24
- "difficulty": "medium",
25
- "objective": "Balance speed, uncertainty, and specialization constraints.",
26
- },
27
- "acde_hard": {
28
- "difficulty": "hard",
29
- "objective": "Make least-bad decisions when every hospital has trade-offs.",
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
-
43
- def __init__(self, memory_file: str):
44
- self.memory_path = Path(memory_file)
45
- self.memory_path.parent.mkdir(parents=True, exist_ok=True)
46
- if not self.memory_path.exists():
47
- self.memory_path.write_text("{}", encoding="utf-8")
48
-
49
- self.episode_counter = 0
50
- self._rng = SeededRandomizer(seed=42)
51
- self.state_data: EnvState | None = None
52
- self.validator = HospitalValidator(self._rng)
53
- self.trajectory: list[dict[str, Any]] = []
54
- self.last_info: StepInfo | None = None
55
- self.last_outcome_status: str | None = None
56
- self.base_speed_kmh = 60.0
57
-
58
- def reset(self, seed: int | None = None, task_id: str | None = None) -> Observation:
59
- if seed is None:
60
- seed = self._rng.randint(1, 10**9)
61
-
62
- resolved_task_id = 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)
66
- self.validator = HospitalValidator(self._rng)
67
- self.episode_counter += 1
68
- self.trajectory = []
69
- self.last_outcome_status = None
70
-
71
- scenario, scenario_type = self._sample_scenario_for_difficulty(difficulty)
72
- hospitals = self._build_hospital_states(scenario)
73
- hospitals = self._augment_hospital_options(
74
- hospitals,
75
- difficulty,
76
- required_specialization=scenario["required_specialization"],
77
- )
78
- hospitals = self._inject_no_perfect_option(hospitals, difficulty)
79
-
80
- max_steps = {"easy": 3, "medium": 4, "hard": 4}.get(difficulty, 4)
81
-
82
- self.state_data = EnvState(
83
- episode_id=self.episode_counter,
84
- seed=seed,
85
- task_id=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"],
93
- critical_time_limit_minutes=scenario["critical_time_limit_minutes"],
94
- step=1,
95
- max_steps=max_steps,
96
- hospitals=hospitals,
97
- selected_hospital_id=None,
98
- done=False,
99
- final_outcome=None,
100
- reward=MIN_REWARD,
101
- final_score=MIN_REWARD,
102
- ambulance_status="en_route",
103
- current_location_context="incident_site",
104
- visited_hospitals=[],
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,
112
- explanation=[
113
- "Episode initialized with seeded uncertainty.",
114
- f"Difficulty: {difficulty}. Hidden hospital state can change during transit.",
115
- f"Patient condition: {scenario['patient_condition']}.",
116
- f"Required specialization: {scenario['required_specialization']}.",
117
- "Primary objective: admit patient successfully under uncertainty.",
118
- ],
119
- memory=self._load_memory(),
120
- )
121
-
122
- self.last_info = StepInfo(
123
- task_id=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(
128
- value=MIN_REWARD,
129
- breakdown=RewardBreakdown(
130
- survival_component=MIN_REWARD,
131
- time_efficiency_component=MIN_REWARD,
132
- specialization_component=MIN_REWARD,
133
- delay_penalty=MIN_REWARD,
134
- ),
135
- ),
136
- grader=None,
137
- last_action_error=None,
138
- outcome=None,
139
- )
140
-
141
- return self._build_observation()
142
-
143
- def state(self) -> EnvState:
144
- if self.state_data is None:
145
- self.reset(seed=42, task_id="acde_medium")
146
- assert self.state_data is not None
147
- return self.state_data
148
-
149
- def step(self, action: Action | str | dict[str, Any]) -> dict[str, Any]:
150
- if self.state_data is None:
151
- self.reset(seed=42, task_id="acde_medium")
152
- assert self.state_data is not None
153
-
154
- if self.state_data.done:
155
- info = self.last_info.model_dump() if self.last_info else {}
156
- return {
157
- "observation": self._build_observation(),
158
- "reward": MIN_REWARD,
159
- "done": True,
160
- "info": info,
161
- }
162
-
163
- normalized_action = self._normalize_action(action)
164
- if normalized_action.step != self.state_data.step:
165
- raise ValueError(
166
- f"Action step {normalized_action.step} does not match environment step {self.state_data.step}."
167
- )
168
-
169
- selected = self._find_hospital(normalized_action.hospital_id)
170
- if selected is None:
171
- raise ValueError(f"Unknown hospital id: {normalized_action.hospital_id}")
172
-
173
- was_visited_before = selected.hospital_id in self.state_data.visited_hospitals
174
- was_failed_before = selected.hospital_id in self.state_data.failed_hospitals
175
-
176
- original_traffic = selected.traffic
177
- selected.traffic = cast(Literal["low", "medium", "high"], self._traffic_shift(selected.traffic, self.state_data.scenario_difficulty))
178
-
179
- speed = compute_speed_kmh(self.base_speed_kmh, selected.traffic)
180
- travel_time = compute_travel_time_minutes(selected.distance_km, speed)
181
-
182
- delay_probability = {
183
- "easy": 0.10,
184
- "medium": 0.25,
185
- "hard": 0.45,
186
- }.get(self.state_data.scenario_difficulty, 0.25)
187
- dynamic_delay = self._rng.uniform(0.5, 2.5) if self._rng.random() < delay_probability else 0.0
188
- travel_time += dynamic_delay
189
-
190
- selected, travel_time, enroute_note = self._apply_enroute_diversion(selected, travel_time)
191
-
192
- self.state_data.total_time_spent_minutes += travel_time
193
-
194
- if selected.hospital_id not in self.state_data.visited_hospitals:
195
- self.state_data.visited_hospitals.append(selected.hospital_id)
196
-
197
- self.state_data.ambulance_status = "arrived"
198
- self.state_data.current_location_context = f"arrived_at_{selected.hospital_id}"
199
-
200
- arrival_outcome = self.validator.validate_arrival(
201
- hospital=selected,
202
- difficulty=self.state_data.scenario_difficulty,
203
- patient_condition=self.state_data.patient_condition,
204
- required_specialization=self.state_data.required_specialization,
205
- total_time_spent=self.state_data.total_time_spent_minutes,
206
- critical_time_limit=self.state_data.critical_time_limit_minutes,
207
- step_number=self.state_data.step,
208
- )
209
-
210
- # Hidden-case guess: selecting uncertain ICU may lead to wrong guess at arrival.
211
- arrival_outcome, hidden_case_penalty, hidden_case_note = self._apply_hidden_guess_case(
212
- selected,
213
- arrival_outcome,
214
- )
215
-
216
- # Late-arrival shocks: on arrival, resources may suddenly become unavailable.
217
- arrival_outcome, shock_penalty, shock_note = self._apply_arrival_hidden_shock(
218
- arrival_outcome,
219
- difficulty=self.state_data.scenario_difficulty,
220
- )
221
-
222
- # Fix 1: cap partial chains so they resolve after repeated delays.
223
- arrival_outcome, partial_cap_note = self._apply_partial_chain_cap(arrival_outcome)
224
-
225
- # Critical polish: early hard rejections can degrade to partial to preserve recoverability.
226
- arrival_outcome, early_reject_note = self._apply_early_reject_protection(arrival_outcome)
227
-
228
- # Critical polish: partial outcomes after step 2 can recover into acceptance.
229
- arrival_outcome, late_partial_note = self._apply_late_partial_recovery(arrival_outcome)
230
-
231
- # Fix 3: final-attempt pressure can produce emergency stabilization.
232
- arrival_outcome, last_chance_note = self._apply_last_chance_outcome(arrival_outcome)
233
-
234
- reward, breakdown = self._calculate_reward(
235
- selected=selected,
236
- arrival_outcome=arrival_outcome,
237
- travel_time=travel_time,
238
- was_visited_before=was_visited_before,
239
- was_failed_before=was_failed_before,
240
- hidden_case_penalty=hidden_case_penalty + shock_penalty,
241
- )
242
-
243
- success = arrival_outcome.status in {"accepted", "partial"}
244
- self._update_learning_memory(selected.hospital_id, success, reward)
245
- self.state_data.memory = self._load_memory()
246
-
247
- self._record_trajectory(
248
- selected=selected,
249
- arrival_outcome=arrival_outcome,
250
- reward=reward,
251
- travel_time=travel_time,
252
- dynamic_delay=dynamic_delay,
253
- original_traffic=original_traffic,
254
- )
255
-
256
- self.state_data.selected_hospital_id = selected.hospital_id
257
- self.state_data.reward = reward
258
- self.state_data.last_arrival_outcome = arrival_outcome
259
-
260
- self._advance_patient_state(arrival_outcome.status, travel_time, dynamic_delay)
261
-
262
- self._resolve_transition(selected, arrival_outcome)
263
-
264
- self._build_last_info(reward, breakdown, arrival_outcome)
265
-
266
- if not self.state_data.done:
267
- self._evolve_hospital_uncertainty()
268
-
269
- self._set_explanation(
270
- selected,
271
- arrival_outcome,
272
- travel_time,
273
- dynamic_delay,
274
- original_traffic,
275
- [
276
- note
277
- for note in [
278
- enroute_note,
279
- hidden_case_note,
280
- shock_note,
281
- partial_cap_note,
282
- early_reject_note,
283
- late_partial_note,
284
- last_chance_note,
285
- ]
286
- if note
287
- ],
288
- )
289
-
290
- info = self.last_info.model_dump() if self.last_info else {}
291
- # Clamp reward into the strict open interval (0, 1) for the external validator.
292
- clamped_reward = max(MIN_REWARD, min(MAX_REWARD, reward))
293
- return {
294
- "observation": self._build_observation(),
295
- "reward": clamped_reward,
296
- "done": self.state_data.done,
297
- "info": info,
298
- }
299
-
300
- def _normalize_action(self, action: Action | str | dict[str, Any]) -> Action:
301
- if isinstance(action, Action):
302
- return action
303
- if isinstance(action, str):
304
- assert self.state_data is not None
305
- return Action(step=self.state_data.step, hospital_id=action, rationale="policy selection")
306
- if isinstance(action, dict):
307
- assert self.state_data is not None
308
- return Action(
309
- step=action.get("step", self.state_data.step),
310
- hospital_id=str(action.get("hospital_id", "")),
311
- rationale=action.get("rationale"),
312
- )
313
- raise ValueError("Action must be Action, hospital_id string, or action dict.")
314
-
315
- def _build_hospital_states(self, scenario: dict[str, Any]) -> list[HospitalState]:
316
- hospitals: list[HospitalState] = []
317
- for template in scenario["hospitals"]:
318
- distance = round(
319
- self._rng.uniform(template["distance_range"][0], template["distance_range"][1]),
320
- 1,
321
- )
322
- traffic = self._rng.choice(template["traffic_options"])
323
- icu_actual = self._rng.random() < template["icu_true_probability"]
324
-
325
- if icu_actual:
326
- icu_display = "available" if self._rng.random() < 0.85 else "unknown"
327
- else:
328
- icu_display = "available" if self._rng.random() < 0.2 else "unknown"
329
-
330
- hospitals.append(
331
- HospitalState(
332
- hospital_id=template["hospital_id"],
333
- distance_km=distance,
334
- icu_display=icu_display,
335
- icu_actual=icu_actual,
336
- specialization=template["specialization"],
337
- traffic=traffic,
338
- )
339
- )
340
- return hospitals
341
-
342
- def _inject_no_perfect_option(self, hospitals: list[HospitalState], difficulty: str) -> list[HospitalState]:
343
- trigger = {"easy": 0.06, "medium": 0.30, "hard": 0.42}.get(difficulty, 0.30)
344
- if self._rng.random() >= trigger:
345
- return hospitals
346
-
347
- if len(hospitals) < 3:
348
- return hospitals
349
-
350
- hospitals[0].traffic = "high"
351
- hospitals[1].icu_display = "unknown"
352
- hospitals[2].specialization = "general" if hospitals[2].specialization != "general" else "trauma"
353
- hospitals[2].icu_display = "unknown"
354
- return hospitals
355
-
356
- def _augment_hospital_options(
357
- self,
358
- hospitals: list[HospitalState],
359
- difficulty: str,
360
- required_specialization: str,
361
- ) -> list[HospitalState]:
362
- """Add extra decoy/alternative hospitals to increase decision ambiguity."""
363
- target_extra = {"easy": 1, "medium": 1, "hard": 2}.get(difficulty, 1)
364
- extra_count = 0
365
- while extra_count < target_extra:
366
- new_id = f"H{len(hospitals) + 1}"
367
- # Keep options plausible but uncertain: mixed specialization and variable traffic.
368
- spec_roll = self._rng.random()
369
- if spec_roll < 0.45:
370
- specialization = required_specialization
371
- elif spec_roll < 0.75:
372
- specialization = "general"
373
- else:
374
- specialization = "trauma" if required_specialization != "trauma" else "cardiac"
375
-
376
- distance = round(self._rng.uniform(4.0, 13.5), 1)
377
- traffic = self._rng.choice(["low", "medium", "high"])
378
-
379
- icu_prob = {"easy": 0.62, "medium": 0.52, "hard": 0.42}.get(difficulty, 0.52)
380
- icu_actual = self._rng.random() < icu_prob
381
- if icu_actual:
382
- icu_display = "available" if self._rng.random() < 0.74 else "unknown"
383
- else:
384
- icu_display = "available" if self._rng.random() < 0.18 else "unknown"
385
-
386
- hospitals.append(
387
- HospitalState(
388
- hospital_id=new_id,
389
- distance_km=distance,
390
- icu_display=icu_display,
391
- icu_actual=icu_actual,
392
- specialization=cast(Literal["cardiac", "trauma", "general"], specialization),
393
- traffic=cast(Literal["low", "medium", "high"], traffic),
394
- )
395
- )
396
- extra_count += 1
397
- return hospitals
398
-
399
- def _calculate_reward(
400
- self,
401
- selected: HospitalState,
402
- arrival_outcome: ArrivalOutcome,
403
- travel_time: float,
404
- was_visited_before: bool,
405
- was_failed_before: bool,
406
- hidden_case_penalty: float,
407
- ) -> tuple[float, RewardBreakdown]:
408
- assert self.state_data is not None
409
-
410
- base_status_reward = {
411
- "accepted": 0.92,
412
- "partial": 0.55,
413
- "rejected": 0.08,
414
- }[arrival_outcome.status]
415
-
416
- if arrival_outcome.status == "rejected":
417
- status_reward = base_status_reward
418
- else:
419
- outcome_modifier = max(0.5, min(1.2, float(arrival_outcome.reward_modifier)))
420
- status_reward = base_status_reward * outcome_modifier
421
-
422
- critical_patient = self.state_data.patient_condition in {"critical", "unstable"}
423
- unknown_critical_penalty = (
424
- 0.12
425
- if critical_patient and selected.icu_display == "unknown"
426
- else 0.0
427
- )
428
- repeat_penalty = 0.15 if was_visited_before else 0.0
429
- failed_repeat_penalty = 0.20 if was_failed_before else 0.0
430
- traffic_penalty = 0.10 if critical_patient and selected.traffic == "high" else 0.04 if critical_patient and selected.traffic == "medium" else 0.0
431
-
432
- time_bonus = 0.06 if travel_time <= 8.0 else (0.03 if travel_time <= 14.0 else 0.0)
433
-
434
- improvement_bonus = self._improvement_bonus(arrival_outcome.status)
435
-
436
- reward = (
437
- status_reward
438
- + time_bonus
439
- + improvement_bonus
440
- - unknown_critical_penalty
441
- - repeat_penalty
442
- - failed_repeat_penalty
443
- - traffic_penalty
444
- - hidden_case_penalty
445
- )
446
- reward = max(MIN_REWARD, min(MAX_REWARD, reward))
447
-
448
- raw_delay = (
449
- unknown_critical_penalty
450
- + repeat_penalty
451
- + failed_repeat_penalty
452
- + traffic_penalty
453
- + hidden_case_penalty
454
- )
455
- breakdown = RewardBreakdown(
456
- survival_component=max(MIN_REWARD, min(MAX_REWARD, (status_reward + 0.5) / 1.5)),
457
- time_efficiency_component=max(MIN_REWARD, min(MAX_REWARD, 1.0 - (travel_time / 25.0))),
458
- specialization_component=max(MIN_REWARD, min(MAX_REWARD, MAX_REWARD if self._specialization_match(selected) else 0.4)),
459
- delay_penalty=max(MIN_REWARD, min(MAX_REWARD, raw_delay)),
460
- )
461
-
462
- return reward, breakdown
463
-
464
- def _improvement_bonus(self, status: str) -> float:
465
- if self.last_outcome_status is None:
466
- self.last_outcome_status = status
467
- return MIN_REWARD
468
-
469
- delta = OUTCOME_SCORE[status] - OUTCOME_SCORE[self.last_outcome_status]
470
- self.last_outcome_status = status
471
- if delta > 0:
472
- return 0.04
473
- return MIN_REWARD
474
-
475
- def _specialization_match(self, hospital: HospitalState) -> bool:
476
- assert self.state_data is not None
477
- return (
478
- hospital.specialization == self.state_data.required_specialization
479
- or hospital.specialization == "general"
480
- or self.state_data.required_specialization == "general"
481
- )
482
-
483
- def _advance_patient_state(self, outcome_status: str, travel_time: float, dynamic_delay: float) -> None:
484
- assert self.state_data is not None
485
-
486
- condition = self.state_data.patient_condition
487
- idx = CONDITION_ORDER.index(condition) if condition in CONDITION_ORDER else 2
488
-
489
- deterioration_risk = 0.0
490
- if travel_time > 12.0:
491
- deterioration_risk += 0.20
492
- if dynamic_delay > 0:
493
- deterioration_risk += 0.15
494
- if outcome_status == "rejected":
495
- deterioration_risk += 0.20
496
-
497
- if self._rng.random() < min(0.95, deterioration_risk):
498
- idx = min(len(CONDITION_ORDER) - 1, idx + 1)
499
-
500
- if outcome_status == "partial":
501
- stabilize_prob = {"easy": 0.35, "medium": 0.22, "hard": 0.12}.get(
502
- self.state_data.scenario_difficulty,
503
- 0.22,
504
- )
505
- if self._rng.random() < stabilize_prob:
506
- idx = max(0, idx - 1)
507
-
508
- self.state_data.patient_condition = CONDITION_ORDER[idx]
509
-
510
- def _resolve_transition(self, selected: HospitalState, arrival_outcome: ArrivalOutcome) -> None:
511
- assert self.state_data is not None
512
-
513
- if arrival_outcome.status == "accepted":
514
- self.state_data.accepted_hospital_id = selected.hospital_id
515
- self.state_data.ambulance_status = "admitted"
516
- self.state_data.current_location_context = selected.hospital_id
517
- self.state_data.done = True
518
- self.state_data.final_outcome = "SUCCESS"
519
- self.state_data.final_score = self._success_score()
520
- return
521
-
522
- if arrival_outcome.status == "rejected":
523
- if selected.hospital_id not in self.state_data.failed_hospitals:
524
- self.state_data.failed_hospitals.append(selected.hospital_id)
525
-
526
- # Cooldown memory: block immediate retries, but allow later reconsideration.
527
- self.state_data.recent_failed_hospitals.append(selected.hospital_id)
528
- if len(self.state_data.recent_failed_hospitals) > 3:
529
- self.state_data.recent_failed_hospitals.pop(0)
530
-
531
- self.state_data.failed_reasons[selected.hospital_id] = arrival_outcome.reason
532
-
533
- if arrival_outcome.terminal:
534
- self.state_data.done = True
535
- self.state_data.final_outcome = "FAILURE"
536
- self.state_data.final_score = self._failure_score()
537
- self.state_data.rerouting_reason = arrival_outcome.reason
538
- self.state_data.ambulance_status = "arrived"
539
- self.state_data.current_location_context = f"terminal_failure_at_{selected.hospital_id}"
540
- return
541
-
542
- self.state_data.rerouting_reason = arrival_outcome.reason
543
- self.state_data.ambulance_status = "rerouting"
544
- self.state_data.current_location_context = f"rejected_at_{selected.hospital_id}"
545
- else:
546
- self.state_data.ambulance_status = "in_transit"
547
- self.state_data.current_location_context = "post_partial_treatment"
548
-
549
- if self._critical_failure():
550
- self.state_data.done = True
551
- self.state_data.final_outcome = "FAILURE"
552
- self.state_data.final_score = self._failure_score()
553
- return
554
-
555
- if self.state_data.step >= self.state_data.max_steps:
556
- self.state_data.done = True
557
- self.state_data.final_outcome = "FAILURE"
558
- self.state_data.final_score = self._failure_score()
559
- return
560
-
561
- self.state_data.step += 1
562
- self.state_data.done = False
563
- self.state_data.final_outcome = None
564
-
565
- def _critical_failure(self) -> bool:
566
- # Time-window based failure is disabled. Episodes end by acceptance or max steps.
567
- return False
568
-
569
- def _set_explanation(
570
- self,
571
- selected: HospitalState,
572
- arrival_outcome: ArrivalOutcome,
573
- travel_time: float,
574
- dynamic_delay: float,
575
- original_traffic: str,
576
- hidden_case_notes: list[str],
577
- ) -> None:
578
- assert self.state_data is not None
579
- v = arrival_outcome.validation_details
580
- assert v is not None
581
- self.state_data.explanation = [
582
- f"Step {self.state_data.step}: selected {selected.hospital_id}.",
583
- f"Traffic changed {original_traffic} -> {selected.traffic} before arrival.",
584
- f"Travel time: {travel_time:.2f} min (delay {dynamic_delay:.2f} min).",
585
- f"Validation checks: ICU={v.icu_available}, doctor={v.doctor_available}, equipment={v.equipment_functional}, overload={v.overload_status}",
586
- f"Patient suitability score = {v.patient_suitability:.2f}",
587
- f"Arrival outcome = {arrival_outcome.status.upper()}",
588
- f"Arrival reason = {arrival_outcome.reason}",
589
- f"Patient condition now = {self.state_data.patient_condition}",
590
- f"Total time spent = {self.state_data.total_time_spent_minutes:.2f} min",
591
- ]
592
- for note in hidden_case_notes:
593
- self.state_data.explanation.append(note)
594
-
595
- def _apply_enroute_diversion(
596
- self,
597
- selected: HospitalState,
598
- travel_time: float,
599
- ) -> tuple[HospitalState, float, str | None]:
600
- """Sometimes traffic collapses mid-route and ambulance diverts before arrival."""
601
- assert self.state_data is not None
602
-
603
- base_diversion_prob = {
604
- "easy": 0.04,
605
- "medium": 0.12,
606
- "hard": 0.18,
607
- }.get(self.state_data.scenario_difficulty, 0.20)
608
-
609
- if selected.traffic == "high":
610
- base_diversion_prob += 0.08
611
- elif selected.traffic == "medium":
612
- base_diversion_prob += 0.04
613
-
614
- if self._rng.random() >= min(0.85, base_diversion_prob):
615
- return selected, travel_time, None
616
-
617
- alternatives = [
618
- h
619
- for h in self.state_data.hospitals
620
- if h.hospital_id != selected.hospital_id and h.hospital_id not in self.state_data.failed_hospitals
621
- ]
622
- if not alternatives:
623
- return selected, travel_time, None
624
-
625
- def _rank(h: HospitalState) -> tuple[int, float]:
626
- traffic_rank = {"low": 0, "medium": 1, "high": 2}.get(h.traffic, 1)
627
- return (traffic_rank, h.distance_km)
628
-
629
- diverted = sorted(alternatives, key=_rank)[0]
630
- diverted_speed = compute_speed_kmh(self.base_speed_kmh, diverted.traffic)
631
- diverted_time = compute_travel_time_minutes(diverted.distance_km, diverted_speed)
632
- diversion_overhead = {
633
- "easy": self._rng.uniform(0.4, 1.1),
634
- "medium": self._rng.uniform(0.8, 1.8),
635
- "hard": self._rng.uniform(1.2, 2.6),
636
- }.get(self.state_data.scenario_difficulty, self._rng.uniform(1.0, 2.2))
637
-
638
- note = (
639
- f"Hidden case: severe traffic lock en-route to {selected.hospital_id}; "
640
- f"ambulance diverted to {diverted.hospital_id}."
641
- )
642
- return diverted, diverted_time + diversion_overhead, note
643
-
644
- def _apply_hidden_guess_case(
645
- self,
646
- selected: HospitalState,
647
- arrival_outcome: ArrivalOutcome,
648
- ) -> tuple[ArrivalOutcome, float, str | None]:
649
- """Resolve hidden guess cases for uncertain hospitals.
650
-
651
- If ICU is shown as unknown, the agent is effectively guessing.
652
- Wrong guess triggers stronger penalty and forced reroute.
653
- """
654
- assert self.state_data is not None
655
-
656
- if selected.icu_display != "unknown":
657
- return arrival_outcome, MIN_REWARD, None
658
-
659
- difficulty = self.state_data.scenario_difficulty
660
- guess_success_prob = {
661
- "easy": 0.82,
662
- "medium": 0.72,
663
- "hard": 0.58,
664
- }.get(difficulty, 0.52)
665
- guess_correct = self._rng.random() < guess_success_prob
666
-
667
- if guess_correct:
668
- return (
669
- arrival_outcome,
670
- MIN_REWARD,
671
- "Hidden case: risky ICU-unknown guess was correct this time.",
672
- )
673
-
674
- # Wrong hidden guess: downgrade to rejected and enforce rerouting signal.
675
- forced_reject = ArrivalOutcome(
676
- status="rejected",
677
- reason="Hidden mismatch at arrival (wrong risky guess). Rerouting required.",
678
- validation_details=arrival_outcome.validation_details,
679
- reward_modifier=0.001,
680
- )
681
- return (
682
- forced_reject,
683
- 0.14,
684
- "Hidden case: risky ICU-unknown guess failed; penalty applied.",
685
- )
686
-
687
- def _apply_arrival_hidden_shock(
688
- self,
689
- arrival_outcome: ArrivalOutcome,
690
- difficulty: str,
691
- ) -> tuple[ArrivalOutcome, float, str | None]:
692
- """Late-arrival operational shocks: ICU/doctor/bed/equipment can fail at handover."""
693
- if arrival_outcome.status == "rejected":
694
- return arrival_outcome, MIN_REWARD, None
695
-
696
- shock_prob = {
697
- "easy": 0.03,
698
- "medium": 0.05,
699
- "hard": 0.10,
700
- }.get(difficulty, 0.14)
701
- if self._rng.random() >= shock_prob:
702
- return arrival_outcome, MIN_REWARD, None
703
-
704
- v = arrival_outcome.validation_details
705
- if v is None:
706
- return arrival_outcome, MIN_REWARD, None
707
-
708
- shock = self._rng.choice([
709
- "doctor_unavailable",
710
- "icu_full",
711
- "beds_full",
712
- "machine_failed",
713
- ])
714
-
715
- if shock == "doctor_unavailable":
716
- reason = "Doctor was reassigned to another emergency at arrival"
717
- new_validation = HospitalValidationDetails(
718
- icu_available=v.icu_available,
719
- doctor_available=False,
720
- equipment_functional=v.equipment_functional,
721
- overload_status=v.overload_status,
722
- patient_suitability=v.patient_suitability,
723
- )
724
- elif shock == "icu_full":
725
- reason = "ICU got full moments before handover"
726
- new_validation = HospitalValidationDetails(
727
- icu_available=False,
728
- doctor_available=v.doctor_available,
729
- equipment_functional=v.equipment_functional,
730
- overload_status=v.overload_status,
731
- patient_suitability=v.patient_suitability,
732
- )
733
- elif shock == "beds_full":
734
- reason = "Emergency beds became unavailable during arrival"
735
- new_validation = HospitalValidationDetails(
736
- icu_available=v.icu_available,
737
- doctor_available=v.doctor_available,
738
- equipment_functional=v.equipment_functional,
739
- overload_status="severe",
740
- patient_suitability=v.patient_suitability,
741
- )
742
- else:
743
- reason = "Critical treatment machine failed at admission"
744
- new_validation = HospitalValidationDetails(
745
- icu_available=v.icu_available,
746
- doctor_available=v.doctor_available,
747
- equipment_functional=False,
748
- overload_status=v.overload_status,
749
- patient_suitability=v.patient_suitability,
750
- )
751
-
752
- return (
753
- ArrivalOutcome(
754
- status="rejected",
755
- reason=reason,
756
- validation_details=new_validation,
757
- reward_modifier=0.001,
758
- ),
759
- 0.12,
760
- f"Hidden case: {reason}. Rerouting required.",
761
- )
762
-
763
- def _apply_partial_chain_cap(self, arrival_outcome: ArrivalOutcome) -> tuple[ArrivalOutcome, str | None]:
764
- """Fix 1: after repeated partials, force resolution to accepted or rejected."""
765
- assert self.state_data is not None
766
- if arrival_outcome.status != "partial":
767
- return arrival_outcome, None
768
-
769
- prior_partials = sum(1 for t in self.trajectory if t.get("outcome_status") == "partial")
770
- partial_count = prior_partials + 1
771
- if partial_count < 2:
772
- return arrival_outcome, None
773
-
774
- stabilize_chance = {
775
- "easy": 0.45,
776
- "medium": 0.28,
777
- "hard": 0.05,
778
- }.get(self.state_data.scenario_difficulty, 0.28)
779
-
780
- if self._rng.random() < stabilize_chance:
781
- return (
782
- ArrivalOutcome(
783
- status="accepted",
784
- reason="Patient stabilized after critical delay",
785
- validation_details=arrival_outcome.validation_details,
786
- reward_modifier=0.78 if self.state_data.scenario_difficulty == "easy" else 0.68,
787
- ),
788
- "Partial chain cap: resolved as emergency stabilization.",
789
- )
790
-
791
- carry_partial_chance = 0.3 if self.state_data.scenario_difficulty != "hard" else 0.15
792
- if self._rng.random() < carry_partial_chance:
793
- return (
794
- ArrivalOutcome(
795
- status="partial",
796
- reason="Condition worsened but remains temporarily transferable",
797
- validation_details=arrival_outcome.validation_details,
798
- reward_modifier=0.44,
799
- ),
800
- "Partial chain cap: temporary recovery preserved rerouting chance.",
801
- )
802
-
803
- return (
804
- ArrivalOutcome(
805
- status="rejected",
806
- reason="Condition became irreversible after delays",
807
- validation_details=arrival_outcome.validation_details,
808
- reward_modifier=0.001,
809
- ),
810
- "Partial chain cap: condition became irreversible.",
811
- )
812
-
813
- def _apply_last_chance_outcome(self, arrival_outcome: ArrivalOutcome) -> tuple[ArrivalOutcome, str | None]:
814
- """Fix 3: near final attempt, allow emergency stabilization chance."""
815
- assert self.state_data is not None
816
- if arrival_outcome.status == "accepted":
817
- return arrival_outcome, None
818
- # Apply only on the literal final step, not one step earlier.
819
- if self.state_data.step != self.state_data.max_steps:
820
- return arrival_outcome, None
821
-
822
- chance = {
823
- "easy": 0.35,
824
- "medium": 0.18,
825
- "hard": 0.02,
826
- }.get(self.state_data.scenario_difficulty, 0.18)
827
-
828
- reward_modifier = {
829
- "easy": 0.82,
830
- "medium": 0.70,
831
- "hard": 0.58,
832
- }.get(self.state_data.scenario_difficulty, 0.70)
833
-
834
- if self._rng.random() < chance:
835
- return (
836
- ArrivalOutcome(
837
- status="accepted",
838
- reason="Emergency stabilization at last attempt",
839
- validation_details=arrival_outcome.validation_details,
840
- reward_modifier=reward_modifier,
841
- ),
842
- "Last-chance rule: emergency stabilization triggered.",
843
- )
844
- return arrival_outcome, None
845
-
846
- def _apply_early_reject_protection(self, arrival_outcome: ArrivalOutcome) -> tuple[ArrivalOutcome, str | None]:
847
- """Avoid excessive instant dead-ends by softening some step-1 rejections."""
848
- assert self.state_data is not None
849
- if arrival_outcome.status != "rejected":
850
- return arrival_outcome, None
851
- if self.state_data.step >= 2:
852
- return arrival_outcome, None
853
- soften_reject_chance = 0.3 if self.state_data.scenario_difficulty != "hard" else 0.05
854
- if self._rng.random() >= soften_reject_chance:
855
- return arrival_outcome, None
856
-
857
- return (
858
- ArrivalOutcome(
859
- status="partial",
860
- reason="Early rejection mitigated by emergency field stabilization",
861
- validation_details=arrival_outcome.validation_details,
862
- reward_modifier=0.50,
863
- terminal=False,
864
- ),
865
- "Recovery guard: early rejection softened to partial.",
866
- )
867
-
868
- def _apply_late_partial_recovery(self, arrival_outcome: ArrivalOutcome) -> tuple[ArrivalOutcome, str | None]:
869
- """Allow realistic comeback from partial outcomes after initial stabilization attempts."""
870
- assert self.state_data is not None
871
- if arrival_outcome.status != "partial":
872
- return arrival_outcome, None
873
- if self.state_data.step < 2:
874
- return arrival_outcome, None
875
- recovery_trigger = 0.5 if self.state_data.scenario_difficulty != "hard" else 0.25
876
- if self._rng.random() >= recovery_trigger:
877
- return arrival_outcome, None
878
-
879
- reject_from_partial = 0.5 if self.state_data.scenario_difficulty != "hard" else 0.8
880
- if self._rng.random() < reject_from_partial:
881
- return (
882
- ArrivalOutcome(
883
- status="rejected",
884
- reason="Condition relapsed after temporary stabilization",
885
- validation_details=arrival_outcome.validation_details,
886
- reward_modifier=0.001,
887
- terminal=False,
888
- ),
889
- "Recovery guard: partial relapsed to rejected.",
890
- )
891
-
892
- return (
893
- ArrivalOutcome(
894
- status="accepted",
895
- reason="Condition stabilized after progressive treatment",
896
- validation_details=arrival_outcome.validation_details,
897
- reward_modifier=max(0.7, float(arrival_outcome.reward_modifier)),
898
- terminal=False,
899
- ),
900
- "Recovery guard: partial upgraded to accepted after continued care.",
901
- )
902
-
903
- def _build_last_info(
904
- self,
905
- reward: float,
906
- breakdown: RewardBreakdown,
907
- arrival_outcome: ArrivalOutcome,
908
- ) -> None:
909
- assert self.state_data is not None
910
-
911
- grader_result = None
912
- if self.state_data.done:
913
- grader_result = grade_task(
914
- task_id=self.state_data.task_id,
915
- difficulty=self.state_data.scenario_difficulty,
916
- objective=self.state_data.task_objective,
917
- trajectory=self.trajectory,
918
- )
919
-
920
- self.last_info = StepInfo(
921
- task_id=self.state_data.task_id,
922
- difficulty=self.state_data.scenario_difficulty,
923
- objective=self.state_data.task_objective,
924
- progress_score=self._progress_score(),
925
- reward_model=RewardModel(value=reward, breakdown=breakdown),
926
- grader=grader_result,
927
- last_action_error=None,
928
- outcome={
929
- "status": arrival_outcome.status,
930
- "reason": arrival_outcome.reason,
931
- },
932
- )
933
-
934
- def _record_trajectory(
935
- self,
936
- selected: HospitalState,
937
- arrival_outcome: ArrivalOutcome,
938
- reward: float,
939
- travel_time: float,
940
- dynamic_delay: float,
941
- original_traffic: str,
942
- ) -> None:
943
- assert self.state_data is not None
944
- self.trajectory.append(
945
- {
946
- "step": self.state_data.step,
947
- "state": {
948
- "patient_condition": self.state_data.patient_condition,
949
- "remaining_time_minutes": self.state_data.critical_time_limit_minutes,
950
- "visited_hospitals": list(self.state_data.visited_hospitals),
951
- "failed_hospitals": list(self.state_data.failed_hospitals),
952
- },
953
- "action": {
954
- "hospital_id": selected.hospital_id,
955
- "traffic_before": original_traffic,
956
- "traffic_at_arrival": selected.traffic,
957
- },
958
- "outcome_status": arrival_outcome.status,
959
- "outcome_reason": arrival_outcome.reason,
960
- "reward": reward,
961
- "travel_time": travel_time,
962
- "dynamic_delay": dynamic_delay,
963
- "critical_limit": self.state_data.critical_time_limit_minutes,
964
- "specialization_match": self._specialization_match(selected),
965
- "suitability_score": arrival_outcome.validation_details.patient_suitability if arrival_outcome.validation_details else 0.5,
966
- }
967
- )
968
-
969
- def _build_observation(self) -> Observation:
970
- assert self.state_data is not None
971
-
972
- last_outcome_obs = None
973
- if self.state_data.last_arrival_outcome and self.state_data.last_arrival_outcome.validation_details:
974
- last_outcome_obs = ArrivalOutcomeObservation(
975
- status=self.state_data.last_arrival_outcome.status,
976
- reason=self.state_data.last_arrival_outcome.reason,
977
- suitability_score=self.state_data.last_arrival_outcome.validation_details.patient_suitability,
978
- )
979
-
980
- return Observation(
981
- episode_id=self.state_data.episode_id,
982
- seed=self.state_data.seed,
983
- task_id=self.state_data.task_id,
984
- task_objective=self.state_data.task_objective,
985
- scenario_type=self.state_data.scenario_type,
986
- scenario_name=self.state_data.scenario_name,
987
- scenario_difficulty=self.state_data.scenario_difficulty,
988
- patient_condition=self.state_data.patient_condition,
989
- required_specialization=self.state_data.required_specialization,
990
- initial_critical_time_limit_minutes=self.state_data.initial_critical_time_limit_minutes,
991
- critical_time_limit_minutes=self.state_data.critical_time_limit_minutes,
992
- remaining_time_minutes=self.state_data.critical_time_limit_minutes,
993
- step=self.state_data.step,
994
- max_steps=self.state_data.max_steps,
995
- hospitals=[
996
- HospitalObservation(
997
- hospital_id=h.hospital_id,
998
- distance_km=h.distance_km,
999
- icu=h.icu_display,
1000
- specialization=h.specialization,
1001
- traffic=h.traffic,
1002
- )
1003
- for h in self.state_data.hospitals
1004
- ],
1005
- previous_action=self.state_data.selected_hospital_id,
1006
- ambulance_status=self.state_data.ambulance_status,
1007
- current_location_context=self.state_data.current_location_context,
1008
- visited_hospitals=list(self.state_data.visited_hospitals),
1009
- failed_hospitals=list(self.state_data.failed_hospitals),
1010
- recent_failed_hospitals=list(self.state_data.recent_failed_hospitals),
1011
- failed_reasons=dict(self.state_data.failed_reasons),
1012
- total_time_spent_minutes=self.state_data.total_time_spent_minutes,
1013
- rerouting_reason=self.state_data.rerouting_reason,
1014
- last_arrival_outcome=last_outcome_obs,
1015
- explanation=list(self.state_data.explanation),
1016
- memory_snapshot={k: v.model_dump() for k, v in self.state_data.memory.items()},
1017
- )
1018
-
1019
- def _evolve_hospital_uncertainty(self) -> None:
1020
- assert self.state_data is not None
1021
- for hospital in self.state_data.hospitals:
1022
- if self._rng.random() < 0.40:
1023
- hospital.traffic = cast(Literal["low", "medium", "high"], self._traffic_shift(hospital.traffic, self.state_data.scenario_difficulty))
1024
-
1025
- if self._rng.random() < DifficultyModifier.get_icu_mismatch_probability(self.state_data.scenario_difficulty):
1026
- hospital.icu_actual = not hospital.icu_actual
1027
-
1028
- if hospital.icu_actual:
1029
- hospital.icu_display = "available" if self._rng.random() < 0.80 else "unknown"
1030
- else:
1031
- hospital.icu_display = "available" if self._rng.random() < 0.2 else "unknown"
1032
-
1033
- def _traffic_shift(self, current: str, difficulty: str) -> str:
1034
- worsening_prob = {"easy": 0.12, "medium": 0.25, "hard": 0.38}.get(difficulty, 0.25)
1035
- improving_prob = {"easy": 0.18, "medium": 0.10, "hard": 0.06}.get(difficulty, 0.10)
1036
-
1037
- if current == "low":
1038
- if self._rng.random() < worsening_prob:
1039
- return "medium"
1040
- return "low"
1041
-
1042
- if current == "medium":
1043
- roll = self._rng.random()
1044
- if roll < worsening_prob:
1045
- return "high"
1046
- if roll < worsening_prob + improving_prob:
1047
- return "low"
1048
- return "medium"
1049
-
1050
- if self._rng.random() < improving_prob:
1051
- return "medium"
1052
- return "high"
1053
-
1054
- def _sample_scenario_for_difficulty(self, difficulty: str) -> tuple[dict[str, Any], str]:
1055
- generators = [
1056
- (generate_medical_case, "medical"),
1057
- (generate_accident_case, "accident"),
1058
- (generate_fire_case, "fire"),
1059
- ]
1060
- for _ in range(64):
1061
- generator, scenario_type = self._rng.choice(generators)
1062
- scenario = generator(self._rng)
1063
- if scenario["difficulty"] == difficulty:
1064
- return scenario, scenario_type
1065
-
1066
- for generator, scenario_type in generators:
1067
- scenario = generator(self._rng)
1068
- if scenario["difficulty"] == difficulty:
1069
- return scenario, scenario_type
1070
- return scenario, scenario_type
1071
-
1072
- def _find_hospital(self, hospital_id: str) -> HospitalState | None:
1073
- assert self.state_data is not None
1074
- for hospital in self.state_data.hospitals:
1075
- if hospital.hospital_id == hospital_id:
1076
- return hospital
1077
- return None
1078
-
1079
- def _load_memory(self) -> dict[str, LearningEntry]:
1080
- text = self.memory_path.read_text(encoding="utf-8-sig").strip()
1081
- raw = json.loads(text) if text else {}
1082
- return {k: LearningEntry(**v) for k, v in raw.items()}
1083
-
1084
- def _update_learning_memory(self, hospital_id: str, success: bool, reward: float) -> None:
1085
- memory = self._load_memory()
1086
- entry = memory.get(hospital_id)
1087
- if entry is None:
1088
- entry = LearningEntry()
1089
-
1090
- if success:
1091
- entry.success += 1
1092
- entry.accepted += 1
1093
- else:
1094
- entry.fail += 1
1095
- entry.rejected += 1
1096
-
1097
- total = entry.success + entry.fail
1098
- if total == 1:
1099
- entry.avg = max(0.0, min(1.0, reward))
1100
- else:
1101
- normalized_reward = max(0.0, min(1.0, reward))
1102
- entry.avg = ((entry.avg * (total - 1)) + normalized_reward) / total
1103
-
1104
- memory[hospital_id] = entry
1105
- serialized = {k: v.model_dump() for k, v in memory.items()}
1106
- self.memory_path.write_text(json.dumps(serialized, indent=2), encoding="utf-8")
1107
-
1108
- def _progress_score(self) -> float:
1109
- if not self.trajectory:
1110
- return MIN_REWARD
1111
- raw = sum(float(t["reward"]) for t in self.trajectory) / len(self.trajectory)
1112
- return max(MIN_REWARD, min(MAX_REWARD, raw))
1113
-
1114
- def _failure_score(self) -> float:
1115
- assert self.state_data is not None
1116
- progress_component = self._progress_score()
1117
- reward_component = max(MIN_REWARD, min(MAX_REWARD, self.state_data.reward))
1118
- score = 0.15 + (0.35 * reward_component) + (0.25 * progress_component)
1119
- return max(MIN_REWARD, min(MAX_REWARD, max(0.1, min(0.85, score))))
1120
-
1121
- def _success_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
- total_steps = max(1, len(self.trajectory))
1126
- rejected_steps = sum(1 for item in self.trajectory if item.get("outcome_status") == "rejected")
1127
- route_quality = max(0.0, 1.0 - (rejected_steps / total_steps))
1128
- score = (0.45 * reward_component) + (0.40 * progress_component) + (0.15 * route_quality)
1129
- return max(MIN_REWARD, min(MAX_REWARD, max(0.25, min(0.99, score))))
1130
-
1131
-
1132
- ACDEEnvironment = EmergencyEnv
 
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
7
+ from app.environment.scenarios.fire import generate_fire_case
8
+ from app.environment.scenarios.medical import generate_medical_case
9
+ from app.environment.validation import DifficultyModifier, HospitalValidator
10
+ from app.models.action import Action
11
+ from app.models.observation import ArrivalOutcomeObservation, HospitalObservation, Observation
12
+ from app.models.reward import RewardBreakdown, RewardModel, StepInfo
13
+ from app.models.state import ArrivalOutcome, EnvState, HospitalState, HospitalValidationDetails, LearningEntry
14
+ from app.utils.calculations import compute_speed_kmh, compute_travel_time_minutes
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.",
22
+ },
23
+ "acde_medium": {
24
+ "difficulty": "medium",
25
+ "objective": "Balance speed, uncertainty, and specialization constraints.",
26
+ },
27
+ "acde_hard": {
28
+ "difficulty": "hard",
29
+ "objective": "Make least-bad decisions when every hospital has trade-offs.",
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
+
43
+ def __init__(self, memory_file: str):
44
+ self.memory_path = Path(memory_file)
45
+ self.memory_path.parent.mkdir(parents=True, exist_ok=True)
46
+ if not self.memory_path.exists():
47
+ self.memory_path.write_text("{}", encoding="utf-8")
48
+
49
+ self.episode_counter = 0
50
+ self._rng = SeededRandomizer(seed=42)
51
+ self.state_data: EnvState | None = None
52
+ self.validator = HospitalValidator(self._rng)
53
+ self.trajectory: list[dict[str, Any]] = []
54
+ self.last_info: StepInfo | None = None
55
+ self.last_outcome_status: str | None = None
56
+ self.base_speed_kmh = 60.0
57
+
58
+ def reset(self, seed: int | None = None, task_id: str | None = None) -> Observation:
59
+ if seed is None:
60
+ seed = self._rng.randint(1, 10**9)
61
+
62
+ resolved_task_id = 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)
66
+ self.validator = HospitalValidator(self._rng)
67
+ self.episode_counter += 1
68
+ self.trajectory = []
69
+ self.last_outcome_status = None
70
+
71
+ scenario, scenario_type = self._sample_scenario_for_difficulty(difficulty)
72
+ hospitals = self._build_hospital_states(scenario)
73
+ hospitals = self._augment_hospital_options(
74
+ hospitals,
75
+ difficulty,
76
+ required_specialization=scenario["required_specialization"],
77
+ )
78
+ hospitals = self._inject_no_perfect_option(hospitals, difficulty)
79
+
80
+ max_steps = {"easy": 3, "medium": 4, "hard": 4}.get(difficulty, 4)
81
+
82
+ self.state_data = EnvState(
83
+ episode_id=self.episode_counter,
84
+ seed=seed,
85
+ task_id=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"],
93
+ critical_time_limit_minutes=scenario["critical_time_limit_minutes"],
94
+ step=1,
95
+ max_steps=max_steps,
96
+ hospitals=hospitals,
97
+ selected_hospital_id=None,
98
+ done=False,
99
+ final_outcome=None,
100
+ reward=MIN_REWARD,
101
+ final_score=MIN_REWARD,
102
+ ambulance_status="en_route",
103
+ current_location_context="incident_site",
104
+ visited_hospitals=[],
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,
112
+ explanation=[
113
+ "Episode initialized with seeded uncertainty.",
114
+ f"Difficulty: {difficulty}. Hidden hospital state can change during transit.",
115
+ f"Patient condition: {scenario['patient_condition']}.",
116
+ f"Required specialization: {scenario['required_specialization']}.",
117
+ "Primary objective: admit patient successfully under uncertainty.",
118
+ ],
119
+ memory=self._load_memory(),
120
+ )
121
+
122
+ self.last_info = StepInfo(
123
+ task_id=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(
128
+ value=MIN_REWARD,
129
+ breakdown=RewardBreakdown(
130
+ survival_component=MIN_REWARD,
131
+ time_efficiency_component=MIN_REWARD,
132
+ specialization_component=MIN_REWARD,
133
+ delay_penalty=MIN_REWARD,
134
+ ),
135
+ ),
136
+ grader=None,
137
+ last_action_error=None,
138
+ outcome=None,
139
+ )
140
+
141
+ return self._build_observation()
142
+
143
+ def state(self) -> EnvState:
144
+ if self.state_data is None:
145
+ self.reset(seed=42, task_id="acde_medium")
146
+ assert self.state_data is not None
147
+ return self.state_data
148
+
149
+ def step(self, action: Action | str | dict[str, Any]) -> dict[str, Any]:
150
+ if self.state_data is None:
151
+ self.reset(seed=42, task_id="acde_medium")
152
+ assert self.state_data is not None
153
+
154
+ if self.state_data.done:
155
+ info = self.last_info.model_dump() if self.last_info else {}
156
+ return {
157
+ "observation": self._build_observation(),
158
+ "reward": MIN_REWARD,
159
+ "done": True,
160
+ "info": info,
161
+ }
162
+
163
+ normalized_action = self._normalize_action(action)
164
+ if normalized_action.step != self.state_data.step:
165
+ raise ValueError(
166
+ f"Action step {normalized_action.step} does not match environment step {self.state_data.step}."
167
+ )
168
+
169
+ selected = self._find_hospital(normalized_action.hospital_id)
170
+ if selected is None:
171
+ raise ValueError(f"Unknown hospital id: {normalized_action.hospital_id}")
172
+
173
+ was_visited_before = selected.hospital_id in self.state_data.visited_hospitals
174
+ was_failed_before = selected.hospital_id in self.state_data.failed_hospitals
175
+
176
+ original_traffic = selected.traffic
177
+ selected.traffic = cast(Literal["low", "medium", "high"], self._traffic_shift(selected.traffic, self.state_data.scenario_difficulty))
178
+
179
+ speed = compute_speed_kmh(self.base_speed_kmh, selected.traffic)
180
+ travel_time = compute_travel_time_minutes(selected.distance_km, speed)
181
+
182
+ delay_probability = {
183
+ "easy": 0.10,
184
+ "medium": 0.25,
185
+ "hard": 0.45,
186
+ }.get(self.state_data.scenario_difficulty, 0.25)
187
+ dynamic_delay = self._rng.uniform(0.5, 2.5) if self._rng.random() < delay_probability else 0.0
188
+ travel_time += dynamic_delay
189
+
190
+ selected, travel_time, enroute_note = self._apply_enroute_diversion(selected, travel_time)
191
+
192
+ self.state_data.total_time_spent_minutes += travel_time
193
+
194
+ if selected.hospital_id not in self.state_data.visited_hospitals:
195
+ self.state_data.visited_hospitals.append(selected.hospital_id)
196
+
197
+ self.state_data.ambulance_status = "arrived"
198
+ self.state_data.current_location_context = f"arrived_at_{selected.hospital_id}"
199
+
200
+ arrival_outcome = self.validator.validate_arrival(
201
+ hospital=selected,
202
+ difficulty=self.state_data.scenario_difficulty,
203
+ patient_condition=self.state_data.patient_condition,
204
+ required_specialization=self.state_data.required_specialization,
205
+ total_time_spent=self.state_data.total_time_spent_minutes,
206
+ critical_time_limit=self.state_data.critical_time_limit_minutes,
207
+ step_number=self.state_data.step,
208
+ )
209
+
210
+ # Hidden-case guess: selecting uncertain ICU may lead to wrong guess at arrival.
211
+ arrival_outcome, hidden_case_penalty, hidden_case_note = self._apply_hidden_guess_case(
212
+ selected,
213
+ arrival_outcome,
214
+ )
215
+
216
+ # Late-arrival shocks: on arrival, resources may suddenly become unavailable.
217
+ arrival_outcome, shock_penalty, shock_note = self._apply_arrival_hidden_shock(
218
+ arrival_outcome,
219
+ difficulty=self.state_data.scenario_difficulty,
220
+ )
221
+
222
+ # Fix 1: cap partial chains so they resolve after repeated delays.
223
+ arrival_outcome, partial_cap_note = self._apply_partial_chain_cap(arrival_outcome)
224
+
225
+ # Critical polish: early hard rejections can degrade to partial to preserve recoverability.
226
+ arrival_outcome, early_reject_note = self._apply_early_reject_protection(arrival_outcome)
227
+
228
+ # Critical polish: partial outcomes after step 2 can recover into acceptance.
229
+ arrival_outcome, late_partial_note = self._apply_late_partial_recovery(arrival_outcome)
230
+
231
+ # Fix 3: final-attempt pressure can produce emergency stabilization.
232
+ arrival_outcome, last_chance_note = self._apply_last_chance_outcome(arrival_outcome)
233
+
234
+ reward, breakdown = self._calculate_reward(
235
+ selected=selected,
236
+ arrival_outcome=arrival_outcome,
237
+ travel_time=travel_time,
238
+ was_visited_before=was_visited_before,
239
+ was_failed_before=was_failed_before,
240
+ hidden_case_penalty=hidden_case_penalty + shock_penalty,
241
+ )
242
+
243
+ success = arrival_outcome.status in {"accepted", "partial"}
244
+ self._update_learning_memory(selected.hospital_id, success, reward)
245
+ self.state_data.memory = self._load_memory()
246
+
247
+ self._record_trajectory(
248
+ selected=selected,
249
+ arrival_outcome=arrival_outcome,
250
+ reward=reward,
251
+ travel_time=travel_time,
252
+ dynamic_delay=dynamic_delay,
253
+ original_traffic=original_traffic,
254
+ )
255
+
256
+ self.state_data.selected_hospital_id = selected.hospital_id
257
+ self.state_data.reward = reward
258
+ self.state_data.last_arrival_outcome = arrival_outcome
259
+
260
+ self._advance_patient_state(arrival_outcome.status, travel_time, dynamic_delay)
261
+
262
+ self._resolve_transition(selected, arrival_outcome)
263
+
264
+ self._build_last_info(reward, breakdown, arrival_outcome)
265
+
266
+ if not self.state_data.done:
267
+ self._evolve_hospital_uncertainty()
268
+
269
+ self._set_explanation(
270
+ selected,
271
+ arrival_outcome,
272
+ travel_time,
273
+ dynamic_delay,
274
+ original_traffic,
275
+ [
276
+ note
277
+ for note in [
278
+ enroute_note,
279
+ hidden_case_note,
280
+ shock_note,
281
+ partial_cap_note,
282
+ early_reject_note,
283
+ late_partial_note,
284
+ last_chance_note,
285
+ ]
286
+ if note
287
+ ],
288
+ )
289
+
290
+ info = self.last_info.model_dump() if self.last_info else {}
291
+ # Clamp reward into the strict open interval (0, 1) for the external validator.
292
+ clamped_reward = max(MIN_REWARD, min(MAX_REWARD, reward))
293
+ return {
294
+ "observation": self._build_observation(),
295
+ "reward": clamped_reward,
296
+ "done": self.state_data.done,
297
+ "info": info,
298
+ }
299
+
300
+ def _normalize_action(self, action: Action | str | dict[str, Any]) -> Action:
301
+ if isinstance(action, Action):
302
+ return action
303
+ if isinstance(action, str):
304
+ assert self.state_data is not None
305
+ return Action(step=self.state_data.step, hospital_id=action, rationale="policy selection")
306
+ if isinstance(action, dict):
307
+ assert self.state_data is not None
308
+ return Action(
309
+ step=action.get("step", self.state_data.step),
310
+ hospital_id=str(action.get("hospital_id", "")),
311
+ rationale=action.get("rationale"),
312
+ )
313
+ raise ValueError("Action must be Action, hospital_id string, or action dict.")
314
+
315
+ def _build_hospital_states(self, scenario: dict[str, Any]) -> list[HospitalState]:
316
+ hospitals: list[HospitalState] = []
317
+ for template in scenario["hospitals"]:
318
+ distance = round(
319
+ self._rng.uniform(template["distance_range"][0], template["distance_range"][1]),
320
+ 1,
321
+ )
322
+ traffic = self._rng.choice(template["traffic_options"])
323
+ icu_actual = self._rng.random() < template["icu_true_probability"]
324
+
325
+ if icu_actual:
326
+ icu_display = "available" if self._rng.random() < 0.85 else "unknown"
327
+ else:
328
+ icu_display = "available" if self._rng.random() < 0.2 else "unknown"
329
+
330
+ hospitals.append(
331
+ HospitalState(
332
+ hospital_id=template["hospital_id"],
333
+ distance_km=distance,
334
+ icu_display=icu_display,
335
+ icu_actual=icu_actual,
336
+ specialization=template["specialization"],
337
+ traffic=traffic,
338
+ )
339
+ )
340
+ return hospitals
341
+
342
+ def _inject_no_perfect_option(self, hospitals: list[HospitalState], difficulty: str) -> list[HospitalState]:
343
+ trigger = {"easy": 0.06, "medium": 0.30, "hard": 0.42}.get(difficulty, 0.30)
344
+ if self._rng.random() >= trigger:
345
+ return hospitals
346
+
347
+ if len(hospitals) < 3:
348
+ return hospitals
349
+
350
+ hospitals[0].traffic = "high"
351
+ hospitals[1].icu_display = "unknown"
352
+ hospitals[2].specialization = "general" if hospitals[2].specialization != "general" else "trauma"
353
+ hospitals[2].icu_display = "unknown"
354
+ return hospitals
355
+
356
+ def _augment_hospital_options(
357
+ self,
358
+ hospitals: list[HospitalState],
359
+ difficulty: str,
360
+ required_specialization: str,
361
+ ) -> list[HospitalState]:
362
+ """Add extra decoy/alternative hospitals to increase decision ambiguity."""
363
+ target_extra = {"easy": 1, "medium": 1, "hard": 2}.get(difficulty, 1)
364
+ extra_count = 0
365
+ while extra_count < target_extra:
366
+ new_id = f"H{len(hospitals) + 1}"
367
+ # Keep options plausible but uncertain: mixed specialization and variable traffic.
368
+ spec_roll = self._rng.random()
369
+ if spec_roll < 0.45:
370
+ specialization = required_specialization
371
+ elif spec_roll < 0.75:
372
+ specialization = "general"
373
+ else:
374
+ specialization = "trauma" if required_specialization != "trauma" else "cardiac"
375
+
376
+ distance = round(self._rng.uniform(4.0, 13.5), 1)
377
+ traffic = self._rng.choice(["low", "medium", "high"])
378
+
379
+ icu_prob = {"easy": 0.62, "medium": 0.52, "hard": 0.42}.get(difficulty, 0.52)
380
+ icu_actual = self._rng.random() < icu_prob
381
+ if icu_actual:
382
+ icu_display = "available" if self._rng.random() < 0.74 else "unknown"
383
+ else:
384
+ icu_display = "available" if self._rng.random() < 0.18 else "unknown"
385
+
386
+ hospitals.append(
387
+ HospitalState(
388
+ hospital_id=new_id,
389
+ distance_km=distance,
390
+ icu_display=icu_display,
391
+ icu_actual=icu_actual,
392
+ specialization=cast(Literal["cardiac", "trauma", "general"], specialization),
393
+ traffic=cast(Literal["low", "medium", "high"], traffic),
394
+ )
395
+ )
396
+ extra_count += 1
397
+ return hospitals
398
+
399
+ def _calculate_reward(
400
+ self,
401
+ selected: HospitalState,
402
+ arrival_outcome: ArrivalOutcome,
403
+ travel_time: float,
404
+ was_visited_before: bool,
405
+ was_failed_before: bool,
406
+ hidden_case_penalty: float,
407
+ ) -> tuple[float, RewardBreakdown]:
408
+ assert self.state_data is not None
409
+
410
+ base_status_reward = {
411
+ "accepted": 0.92,
412
+ "partial": 0.55,
413
+ "rejected": 0.08,
414
+ }[arrival_outcome.status]
415
+
416
+ if arrival_outcome.status == "rejected":
417
+ status_reward = base_status_reward
418
+ else:
419
+ outcome_modifier = max(0.5, min(1.2, float(arrival_outcome.reward_modifier)))
420
+ status_reward = base_status_reward * outcome_modifier
421
+
422
+ critical_patient = self.state_data.patient_condition in {"critical", "unstable"}
423
+ unknown_critical_penalty = (
424
+ 0.12
425
+ if critical_patient and selected.icu_display == "unknown"
426
+ else 0.0
427
+ )
428
+ repeat_penalty = 0.15 if was_visited_before else 0.0
429
+ failed_repeat_penalty = 0.20 if was_failed_before else 0.0
430
+ traffic_penalty = 0.10 if critical_patient and selected.traffic == "high" else 0.04 if critical_patient and selected.traffic == "medium" else 0.0
431
+
432
+ time_bonus = 0.06 if travel_time <= 8.0 else (0.03 if travel_time <= 14.0 else 0.0)
433
+
434
+ improvement_bonus = self._improvement_bonus(arrival_outcome.status)
435
+
436
+ reward = (
437
+ status_reward
438
+ + time_bonus
439
+ + improvement_bonus
440
+ - unknown_critical_penalty
441
+ - repeat_penalty
442
+ - failed_repeat_penalty
443
+ - traffic_penalty
444
+ - hidden_case_penalty
445
+ )
446
+ reward = max(MIN_REWARD, min(MAX_REWARD, reward))
447
+
448
+ raw_delay = (
449
+ unknown_critical_penalty
450
+ + repeat_penalty
451
+ + failed_repeat_penalty
452
+ + traffic_penalty
453
+ + hidden_case_penalty
454
+ )
455
+ breakdown = RewardBreakdown(
456
+ survival_component=max(MIN_REWARD, min(MAX_REWARD, (status_reward + 0.5) / 1.5)),
457
+ time_efficiency_component=max(MIN_REWARD, min(MAX_REWARD, 1.0 - (travel_time / 25.0))),
458
+ specialization_component=max(MIN_REWARD, min(MAX_REWARD, MAX_REWARD if self._specialization_match(selected) else 0.4)),
459
+ delay_penalty=max(MIN_REWARD, min(MAX_REWARD, raw_delay)),
460
+ )
461
+
462
+ return reward, breakdown
463
+
464
+ def _improvement_bonus(self, status: str) -> float:
465
+ if self.last_outcome_status is None:
466
+ self.last_outcome_status = status
467
+ return MIN_REWARD
468
+
469
+ delta = OUTCOME_SCORE[status] - OUTCOME_SCORE[self.last_outcome_status]
470
+ self.last_outcome_status = status
471
+ if delta > 0:
472
+ return 0.04
473
+ return MIN_REWARD
474
+
475
+ def _specialization_match(self, hospital: HospitalState) -> bool:
476
+ assert self.state_data is not None
477
+ return (
478
+ hospital.specialization == self.state_data.required_specialization
479
+ or hospital.specialization == "general"
480
+ or self.state_data.required_specialization == "general"
481
+ )
482
+
483
+ def _advance_patient_state(self, outcome_status: str, travel_time: float, dynamic_delay: float) -> None:
484
+ assert self.state_data is not None
485
+
486
+ condition = self.state_data.patient_condition
487
+ idx = CONDITION_ORDER.index(condition) if condition in CONDITION_ORDER else 2
488
+
489
+ deterioration_risk = 0.0
490
+ if travel_time > 12.0:
491
+ deterioration_risk += 0.20
492
+ if dynamic_delay > 0:
493
+ deterioration_risk += 0.15
494
+ if outcome_status == "rejected":
495
+ deterioration_risk += 0.20
496
+
497
+ if self._rng.random() < min(0.95, deterioration_risk):
498
+ idx = min(len(CONDITION_ORDER) - 1, idx + 1)
499
+
500
+ if outcome_status == "partial":
501
+ stabilize_prob = {"easy": 0.35, "medium": 0.22, "hard": 0.12}.get(
502
+ self.state_data.scenario_difficulty,
503
+ 0.22,
504
+ )
505
+ if self._rng.random() < stabilize_prob:
506
+ idx = max(0, idx - 1)
507
+
508
+ self.state_data.patient_condition = CONDITION_ORDER[idx]
509
+
510
+ def _resolve_transition(self, selected: HospitalState, arrival_outcome: ArrivalOutcome) -> None:
511
+ assert self.state_data is not None
512
+
513
+ if arrival_outcome.status == "accepted":
514
+ self.state_data.accepted_hospital_id = selected.hospital_id
515
+ self.state_data.ambulance_status = "admitted"
516
+ self.state_data.current_location_context = selected.hospital_id
517
+ self.state_data.done = True
518
+ self.state_data.final_outcome = "SUCCESS"
519
+ self.state_data.final_score = self._success_score()
520
+ return
521
+
522
+ if arrival_outcome.status == "rejected":
523
+ if selected.hospital_id not in self.state_data.failed_hospitals:
524
+ self.state_data.failed_hospitals.append(selected.hospital_id)
525
+
526
+ # Cooldown memory: block immediate retries, but allow later reconsideration.
527
+ self.state_data.recent_failed_hospitals.append(selected.hospital_id)
528
+ if len(self.state_data.recent_failed_hospitals) > 3:
529
+ self.state_data.recent_failed_hospitals.pop(0)
530
+
531
+ self.state_data.failed_reasons[selected.hospital_id] = arrival_outcome.reason
532
+
533
+ if arrival_outcome.terminal:
534
+ self.state_data.done = True
535
+ self.state_data.final_outcome = "FAILURE"
536
+ self.state_data.final_score = self._failure_score()
537
+ self.state_data.rerouting_reason = arrival_outcome.reason
538
+ self.state_data.ambulance_status = "arrived"
539
+ self.state_data.current_location_context = f"terminal_failure_at_{selected.hospital_id}"
540
+ return
541
+
542
+ self.state_data.rerouting_reason = arrival_outcome.reason
543
+ self.state_data.ambulance_status = "rerouting"
544
+ self.state_data.current_location_context = f"rejected_at_{selected.hospital_id}"
545
+ else:
546
+ self.state_data.ambulance_status = "in_transit"
547
+ self.state_data.current_location_context = "post_partial_treatment"
548
+
549
+ if self._critical_failure():
550
+ self.state_data.done = True
551
+ self.state_data.final_outcome = "FAILURE"
552
+ self.state_data.final_score = self._failure_score()
553
+ return
554
+
555
+ if self.state_data.step >= self.state_data.max_steps:
556
+ self.state_data.done = True
557
+ self.state_data.final_outcome = "FAILURE"
558
+ self.state_data.final_score = self._failure_score()
559
+ return
560
+
561
+ self.state_data.step += 1
562
+ self.state_data.done = False
563
+ self.state_data.final_outcome = None
564
+
565
+ def _critical_failure(self) -> bool:
566
+ # Time-window based failure is disabled. Episodes end by acceptance or max steps.
567
+ return False
568
+
569
+ def _set_explanation(
570
+ self,
571
+ selected: HospitalState,
572
+ arrival_outcome: ArrivalOutcome,
573
+ travel_time: float,
574
+ dynamic_delay: float,
575
+ original_traffic: str,
576
+ hidden_case_notes: list[str],
577
+ ) -> None:
578
+ assert self.state_data is not None
579
+ v = arrival_outcome.validation_details
580
+ assert v is not None
581
+ self.state_data.explanation = [
582
+ f"Step {self.state_data.step}: selected {selected.hospital_id}.",
583
+ f"Traffic changed {original_traffic} -> {selected.traffic} before arrival.",
584
+ f"Travel time: {travel_time:.2f} min (delay {dynamic_delay:.2f} min).",
585
+ f"Validation checks: ICU={v.icu_available}, doctor={v.doctor_available}, equipment={v.equipment_functional}, overload={v.overload_status}",
586
+ f"Patient suitability score = {v.patient_suitability:.2f}",
587
+ f"Arrival outcome = {arrival_outcome.status.upper()}",
588
+ f"Arrival reason = {arrival_outcome.reason}",
589
+ f"Patient condition now = {self.state_data.patient_condition}",
590
+ f"Total time spent = {self.state_data.total_time_spent_minutes:.2f} min",
591
+ ]
592
+ for note in hidden_case_notes:
593
+ self.state_data.explanation.append(note)
594
+
595
+ def _apply_enroute_diversion(
596
+ self,
597
+ selected: HospitalState,
598
+ travel_time: float,
599
+ ) -> tuple[HospitalState, float, str | None]:
600
+ """Sometimes traffic collapses mid-route and ambulance diverts before arrival."""
601
+ assert self.state_data is not None
602
+
603
+ base_diversion_prob = {
604
+ "easy": 0.04,
605
+ "medium": 0.12,
606
+ "hard": 0.18,
607
+ }.get(self.state_data.scenario_difficulty, 0.20)
608
+
609
+ if selected.traffic == "high":
610
+ base_diversion_prob += 0.08
611
+ elif selected.traffic == "medium":
612
+ base_diversion_prob += 0.04
613
+
614
+ if self._rng.random() >= min(0.85, base_diversion_prob):
615
+ return selected, travel_time, None
616
+
617
+ alternatives = [
618
+ h
619
+ for h in self.state_data.hospitals
620
+ if h.hospital_id != selected.hospital_id and h.hospital_id not in self.state_data.failed_hospitals
621
+ ]
622
+ if not alternatives:
623
+ return selected, travel_time, None
624
+
625
+ def _rank(h: HospitalState) -> tuple[int, float]:
626
+ traffic_rank = {"low": 0, "medium": 1, "high": 2}.get(h.traffic, 1)
627
+ return (traffic_rank, h.distance_km)
628
+
629
+ diverted = sorted(alternatives, key=_rank)[0]
630
+ diverted_speed = compute_speed_kmh(self.base_speed_kmh, diverted.traffic)
631
+ diverted_time = compute_travel_time_minutes(diverted.distance_km, diverted_speed)
632
+ diversion_overhead = {
633
+ "easy": self._rng.uniform(0.4, 1.1),
634
+ "medium": self._rng.uniform(0.8, 1.8),
635
+ "hard": self._rng.uniform(1.2, 2.6),
636
+ }.get(self.state_data.scenario_difficulty, self._rng.uniform(1.0, 2.2))
637
+
638
+ note = (
639
+ f"Hidden case: severe traffic lock en-route to {selected.hospital_id}; "
640
+ f"ambulance diverted to {diverted.hospital_id}."
641
+ )
642
+ return diverted, diverted_time + diversion_overhead, note
643
+
644
+ def _apply_hidden_guess_case(
645
+ self,
646
+ selected: HospitalState,
647
+ arrival_outcome: ArrivalOutcome,
648
+ ) -> tuple[ArrivalOutcome, float, str | None]:
649
+ """Resolve hidden guess cases for uncertain hospitals.
650
+
651
+ If ICU is shown as unknown, the agent is effectively guessing.
652
+ Wrong guess triggers stronger penalty and forced reroute.
653
+ """
654
+ assert self.state_data is not None
655
+
656
+ if selected.icu_display != "unknown":
657
+ return arrival_outcome, MIN_REWARD, None
658
+
659
+ difficulty = self.state_data.scenario_difficulty
660
+ guess_success_prob = {
661
+ "easy": 0.82,
662
+ "medium": 0.72,
663
+ "hard": 0.58,
664
+ }.get(difficulty, 0.52)
665
+ guess_correct = self._rng.random() < guess_success_prob
666
+
667
+ if guess_correct:
668
+ return (
669
+ arrival_outcome,
670
+ MIN_REWARD,
671
+ "Hidden case: risky ICU-unknown guess was correct this time.",
672
+ )
673
+
674
+ # Wrong hidden guess: downgrade to rejected and enforce rerouting signal.
675
+ forced_reject = ArrivalOutcome(
676
+ status="rejected",
677
+ reason="Hidden mismatch at arrival (wrong risky guess). Rerouting required.",
678
+ validation_details=arrival_outcome.validation_details,
679
+ reward_modifier=0.0,
680
+ )
681
+ return (
682
+ forced_reject,
683
+ 0.14,
684
+ "Hidden case: risky ICU-unknown guess failed; penalty applied.",
685
+ )
686
+
687
+ def _apply_arrival_hidden_shock(
688
+ self,
689
+ arrival_outcome: ArrivalOutcome,
690
+ difficulty: str,
691
+ ) -> tuple[ArrivalOutcome, float, str | None]:
692
+ """Late-arrival operational shocks: ICU/doctor/bed/equipment can fail at handover."""
693
+ if arrival_outcome.status == "rejected":
694
+ return arrival_outcome, MIN_REWARD, None
695
+
696
+ shock_prob = {
697
+ "easy": 0.03,
698
+ "medium": 0.05,
699
+ "hard": 0.10,
700
+ }.get(difficulty, 0.14)
701
+ if self._rng.random() >= shock_prob:
702
+ return arrival_outcome, MIN_REWARD, None
703
+
704
+ v = arrival_outcome.validation_details
705
+ if v is None:
706
+ return arrival_outcome, MIN_REWARD, None
707
+
708
+ shock = self._rng.choice([
709
+ "doctor_unavailable",
710
+ "icu_full",
711
+ "beds_full",
712
+ "machine_failed",
713
+ ])
714
+
715
+ if shock == "doctor_unavailable":
716
+ reason = "Doctor was reassigned to another emergency at arrival"
717
+ new_validation = HospitalValidationDetails(
718
+ icu_available=v.icu_available,
719
+ doctor_available=False,
720
+ equipment_functional=v.equipment_functional,
721
+ overload_status=v.overload_status,
722
+ patient_suitability=v.patient_suitability,
723
+ )
724
+ elif shock == "icu_full":
725
+ reason = "ICU got full moments before handover"
726
+ new_validation = HospitalValidationDetails(
727
+ icu_available=False,
728
+ doctor_available=v.doctor_available,
729
+ equipment_functional=v.equipment_functional,
730
+ overload_status=v.overload_status,
731
+ patient_suitability=v.patient_suitability,
732
+ )
733
+ elif shock == "beds_full":
734
+ reason = "Emergency beds became unavailable during arrival"
735
+ new_validation = HospitalValidationDetails(
736
+ icu_available=v.icu_available,
737
+ doctor_available=v.doctor_available,
738
+ equipment_functional=v.equipment_functional,
739
+ overload_status="severe",
740
+ patient_suitability=v.patient_suitability,
741
+ )
742
+ else:
743
+ reason = "Critical treatment machine failed at admission"
744
+ new_validation = HospitalValidationDetails(
745
+ icu_available=v.icu_available,
746
+ doctor_available=v.doctor_available,
747
+ equipment_functional=False,
748
+ overload_status=v.overload_status,
749
+ patient_suitability=v.patient_suitability,
750
+ )
751
+
752
+ return (
753
+ ArrivalOutcome(
754
+ status="rejected",
755
+ reason=reason,
756
+ validation_details=new_validation,
757
+ reward_modifier=0.0,
758
+ ),
759
+ 0.12,
760
+ f"Hidden case: {reason}. Rerouting required.",
761
+ )
762
+
763
+ def _apply_partial_chain_cap(self, arrival_outcome: ArrivalOutcome) -> tuple[ArrivalOutcome, str | None]:
764
+ """Fix 1: after repeated partials, force resolution to accepted or rejected."""
765
+ assert self.state_data is not None
766
+ if arrival_outcome.status != "partial":
767
+ return arrival_outcome, None
768
+
769
+ prior_partials = sum(1 for t in self.trajectory if t.get("outcome_status") == "partial")
770
+ partial_count = prior_partials + 1
771
+ if partial_count < 2:
772
+ return arrival_outcome, None
773
+
774
+ stabilize_chance = {
775
+ "easy": 0.45,
776
+ "medium": 0.28,
777
+ "hard": 0.05,
778
+ }.get(self.state_data.scenario_difficulty, 0.28)
779
+
780
+ if self._rng.random() < stabilize_chance:
781
+ return (
782
+ ArrivalOutcome(
783
+ status="accepted",
784
+ reason="Patient stabilized after critical delay",
785
+ validation_details=arrival_outcome.validation_details,
786
+ reward_modifier=0.78 if self.state_data.scenario_difficulty == "easy" else 0.68,
787
+ ),
788
+ "Partial chain cap: resolved as emergency stabilization.",
789
+ )
790
+
791
+ carry_partial_chance = 0.3 if self.state_data.scenario_difficulty != "hard" else 0.15
792
+ if self._rng.random() < carry_partial_chance:
793
+ return (
794
+ ArrivalOutcome(
795
+ status="partial",
796
+ reason="Condition worsened but remains temporarily transferable",
797
+ validation_details=arrival_outcome.validation_details,
798
+ reward_modifier=0.44,
799
+ ),
800
+ "Partial chain cap: temporary recovery preserved rerouting chance.",
801
+ )
802
+
803
+ return (
804
+ ArrivalOutcome(
805
+ status="rejected",
806
+ reason="Condition became irreversible after delays",
807
+ validation_details=arrival_outcome.validation_details,
808
+ reward_modifier=0.0,
809
+ ),
810
+ "Partial chain cap: condition became irreversible.",
811
+ )
812
+
813
+ def _apply_last_chance_outcome(self, arrival_outcome: ArrivalOutcome) -> tuple[ArrivalOutcome, str | None]:
814
+ """Fix 3: near final attempt, allow emergency stabilization chance."""
815
+ assert self.state_data is not None
816
+ if arrival_outcome.status == "accepted":
817
+ return arrival_outcome, None
818
+ # Apply only on the literal final step, not one step earlier.
819
+ if self.state_data.step != self.state_data.max_steps:
820
+ return arrival_outcome, None
821
+
822
+ chance = {
823
+ "easy": 0.35,
824
+ "medium": 0.18,
825
+ "hard": 0.02,
826
+ }.get(self.state_data.scenario_difficulty, 0.18)
827
+
828
+ reward_modifier = {
829
+ "easy": 0.82,
830
+ "medium": 0.70,
831
+ "hard": 0.58,
832
+ }.get(self.state_data.scenario_difficulty, 0.70)
833
+
834
+ if self._rng.random() < chance:
835
+ return (
836
+ ArrivalOutcome(
837
+ status="accepted",
838
+ reason="Emergency stabilization at last attempt",
839
+ validation_details=arrival_outcome.validation_details,
840
+ reward_modifier=reward_modifier,
841
+ ),
842
+ "Last-chance rule: emergency stabilization triggered.",
843
+ )
844
+ return arrival_outcome, None
845
+
846
+ def _apply_early_reject_protection(self, arrival_outcome: ArrivalOutcome) -> tuple[ArrivalOutcome, str | None]:
847
+ """Avoid excessive instant dead-ends by softening some step-1 rejections."""
848
+ assert self.state_data is not None
849
+ if arrival_outcome.status != "rejected":
850
+ return arrival_outcome, None
851
+ if self.state_data.step >= 2:
852
+ return arrival_outcome, None
853
+ soften_reject_chance = 0.3 if self.state_data.scenario_difficulty != "hard" else 0.05
854
+ if self._rng.random() >= soften_reject_chance:
855
+ return arrival_outcome, None
856
+
857
+ return (
858
+ ArrivalOutcome(
859
+ status="partial",
860
+ reason="Early rejection mitigated by emergency field stabilization",
861
+ validation_details=arrival_outcome.validation_details,
862
+ reward_modifier=0.50,
863
+ terminal=False,
864
+ ),
865
+ "Recovery guard: early rejection softened to partial.",
866
+ )
867
+
868
+ def _apply_late_partial_recovery(self, arrival_outcome: ArrivalOutcome) -> tuple[ArrivalOutcome, str | None]:
869
+ """Allow realistic comeback from partial outcomes after initial stabilization attempts."""
870
+ assert self.state_data is not None
871
+ if arrival_outcome.status != "partial":
872
+ return arrival_outcome, None
873
+ if self.state_data.step < 2:
874
+ return arrival_outcome, None
875
+ recovery_trigger = 0.5 if self.state_data.scenario_difficulty != "hard" else 0.25
876
+ if self._rng.random() >= recovery_trigger:
877
+ return arrival_outcome, None
878
+
879
+ reject_from_partial = 0.5 if self.state_data.scenario_difficulty != "hard" else 0.8
880
+ if self._rng.random() < reject_from_partial:
881
+ return (
882
+ ArrivalOutcome(
883
+ status="rejected",
884
+ reason="Condition relapsed after temporary stabilization",
885
+ validation_details=arrival_outcome.validation_details,
886
+ reward_modifier=0.0,
887
+ terminal=False,
888
+ ),
889
+ "Recovery guard: partial relapsed to rejected.",
890
+ )
891
+
892
+ return (
893
+ ArrivalOutcome(
894
+ status="accepted",
895
+ reason="Condition stabilized after progressive treatment",
896
+ validation_details=arrival_outcome.validation_details,
897
+ reward_modifier=max(0.7, float(arrival_outcome.reward_modifier)),
898
+ terminal=False,
899
+ ),
900
+ "Recovery guard: partial upgraded to accepted after continued care.",
901
+ )
902
+
903
+ def _build_last_info(
904
+ self,
905
+ reward: float,
906
+ breakdown: RewardBreakdown,
907
+ arrival_outcome: ArrivalOutcome,
908
+ ) -> None:
909
+ assert self.state_data is not None
910
+
911
+ grader_result = None
912
+ if self.state_data.done:
913
+ grader_result = grade_task(
914
+ task_id=self.state_data.task_id,
915
+ difficulty=self.state_data.scenario_difficulty,
916
+ objective=self.state_data.task_objective,
917
+ trajectory=self.trajectory,
918
+ )
919
+
920
+ self.last_info = StepInfo(
921
+ task_id=self.state_data.task_id,
922
+ difficulty=self.state_data.scenario_difficulty,
923
+ objective=self.state_data.task_objective,
924
+ progress_score=self._progress_score(),
925
+ reward_model=RewardModel(value=reward, breakdown=breakdown),
926
+ grader=grader_result,
927
+ last_action_error=None,
928
+ outcome={
929
+ "status": arrival_outcome.status,
930
+ "reason": arrival_outcome.reason,
931
+ },
932
+ )
933
+
934
+ def _record_trajectory(
935
+ self,
936
+ selected: HospitalState,
937
+ arrival_outcome: ArrivalOutcome,
938
+ reward: float,
939
+ travel_time: float,
940
+ dynamic_delay: float,
941
+ original_traffic: str,
942
+ ) -> None:
943
+ assert self.state_data is not None
944
+ self.trajectory.append(
945
+ {
946
+ "step": self.state_data.step,
947
+ "state": {
948
+ "patient_condition": self.state_data.patient_condition,
949
+ "remaining_time_minutes": self.state_data.critical_time_limit_minutes,
950
+ "visited_hospitals": list(self.state_data.visited_hospitals),
951
+ "failed_hospitals": list(self.state_data.failed_hospitals),
952
+ },
953
+ "action": {
954
+ "hospital_id": selected.hospital_id,
955
+ "traffic_before": original_traffic,
956
+ "traffic_at_arrival": selected.traffic,
957
+ },
958
+ "outcome_status": arrival_outcome.status,
959
+ "outcome_reason": arrival_outcome.reason,
960
+ "reward": reward,
961
+ "travel_time": travel_time,
962
+ "dynamic_delay": dynamic_delay,
963
+ "critical_limit": self.state_data.critical_time_limit_minutes,
964
+ "specialization_match": self._specialization_match(selected),
965
+ "suitability_score": arrival_outcome.validation_details.patient_suitability if arrival_outcome.validation_details else 0.5,
966
+ }
967
+ )
968
+
969
+ def _build_observation(self) -> Observation:
970
+ assert self.state_data is not None
971
+
972
+ last_outcome_obs = None
973
+ if self.state_data.last_arrival_outcome and self.state_data.last_arrival_outcome.validation_details:
974
+ last_outcome_obs = ArrivalOutcomeObservation(
975
+ status=self.state_data.last_arrival_outcome.status,
976
+ reason=self.state_data.last_arrival_outcome.reason,
977
+ suitability_score=self.state_data.last_arrival_outcome.validation_details.patient_suitability,
978
+ )
979
+
980
+ return Observation(
981
+ episode_id=self.state_data.episode_id,
982
+ seed=self.state_data.seed,
983
+ task_id=self.state_data.task_id,
984
+ task_objective=self.state_data.task_objective,
985
+ scenario_type=self.state_data.scenario_type,
986
+ scenario_name=self.state_data.scenario_name,
987
+ scenario_difficulty=self.state_data.scenario_difficulty,
988
+ patient_condition=self.state_data.patient_condition,
989
+ required_specialization=self.state_data.required_specialization,
990
+ initial_critical_time_limit_minutes=self.state_data.initial_critical_time_limit_minutes,
991
+ critical_time_limit_minutes=self.state_data.critical_time_limit_minutes,
992
+ remaining_time_minutes=self.state_data.critical_time_limit_minutes,
993
+ step=self.state_data.step,
994
+ max_steps=self.state_data.max_steps,
995
+ hospitals=[
996
+ HospitalObservation(
997
+ hospital_id=h.hospital_id,
998
+ distance_km=h.distance_km,
999
+ icu=h.icu_display,
1000
+ specialization=h.specialization,
1001
+ traffic=h.traffic,
1002
+ )
1003
+ for h in self.state_data.hospitals
1004
+ ],
1005
+ previous_action=self.state_data.selected_hospital_id,
1006
+ ambulance_status=self.state_data.ambulance_status,
1007
+ current_location_context=self.state_data.current_location_context,
1008
+ visited_hospitals=list(self.state_data.visited_hospitals),
1009
+ failed_hospitals=list(self.state_data.failed_hospitals),
1010
+ recent_failed_hospitals=list(self.state_data.recent_failed_hospitals),
1011
+ failed_reasons=dict(self.state_data.failed_reasons),
1012
+ total_time_spent_minutes=self.state_data.total_time_spent_minutes,
1013
+ rerouting_reason=self.state_data.rerouting_reason,
1014
+ last_arrival_outcome=last_outcome_obs,
1015
+ explanation=list(self.state_data.explanation),
1016
+ memory_snapshot={k: v.model_dump() for k, v in self.state_data.memory.items()},
1017
+ )
1018
+
1019
+ def _evolve_hospital_uncertainty(self) -> None:
1020
+ assert self.state_data is not None
1021
+ for hospital in self.state_data.hospitals:
1022
+ if self._rng.random() < 0.40:
1023
+ hospital.traffic = cast(Literal["low", "medium", "high"], self._traffic_shift(hospital.traffic, self.state_data.scenario_difficulty))
1024
+
1025
+ if self._rng.random() < DifficultyModifier.get_icu_mismatch_probability(self.state_data.scenario_difficulty):
1026
+ hospital.icu_actual = not hospital.icu_actual
1027
+
1028
+ if hospital.icu_actual:
1029
+ hospital.icu_display = "available" if self._rng.random() < 0.80 else "unknown"
1030
+ else:
1031
+ hospital.icu_display = "available" if self._rng.random() < 0.2 else "unknown"
1032
+
1033
+ def _traffic_shift(self, current: str, difficulty: str) -> str:
1034
+ worsening_prob = {"easy": 0.12, "medium": 0.25, "hard": 0.38}.get(difficulty, 0.25)
1035
+ improving_prob = {"easy": 0.18, "medium": 0.10, "hard": 0.06}.get(difficulty, 0.10)
1036
+
1037
+ if current == "low":
1038
+ if self._rng.random() < worsening_prob:
1039
+ return "medium"
1040
+ return "low"
1041
+
1042
+ if current == "medium":
1043
+ roll = self._rng.random()
1044
+ if roll < worsening_prob:
1045
+ return "high"
1046
+ if roll < worsening_prob + improving_prob:
1047
+ return "low"
1048
+ return "medium"
1049
+
1050
+ if self._rng.random() < improving_prob:
1051
+ return "medium"
1052
+ return "high"
1053
+
1054
+ def _sample_scenario_for_difficulty(self, difficulty: str) -> tuple[dict[str, Any], str]:
1055
+ generators = [
1056
+ (generate_medical_case, "medical"),
1057
+ (generate_accident_case, "accident"),
1058
+ (generate_fire_case, "fire"),
1059
+ ]
1060
+ for _ in range(64):
1061
+ generator, scenario_type = self._rng.choice(generators)
1062
+ scenario = generator(self._rng)
1063
+ if scenario["difficulty"] == difficulty:
1064
+ return scenario, scenario_type
1065
+
1066
+ for generator, scenario_type in generators:
1067
+ scenario = generator(self._rng)
1068
+ if scenario["difficulty"] == difficulty:
1069
+ return scenario, scenario_type
1070
+ return scenario, scenario_type
1071
+
1072
+ def _find_hospital(self, hospital_id: str) -> HospitalState | None:
1073
+ assert self.state_data is not None
1074
+ for hospital in self.state_data.hospitals:
1075
+ if hospital.hospital_id == hospital_id:
1076
+ return hospital
1077
+ return None
1078
+
1079
+ def _load_memory(self) -> dict[str, LearningEntry]:
1080
+ text = self.memory_path.read_text(encoding="utf-8-sig").strip()
1081
+ raw = json.loads(text) if text else {}
1082
+ return {k: LearningEntry(**v) for k, v in raw.items()}
1083
+
1084
+ def _update_learning_memory(self, hospital_id: str, success: bool, reward: float) -> None:
1085
+ memory = self._load_memory()
1086
+ entry = memory.get(hospital_id)
1087
+ if entry is None:
1088
+ entry = LearningEntry()
1089
+
1090
+ if success:
1091
+ entry.success += 1
1092
+ entry.accepted += 1
1093
+ else:
1094
+ entry.fail += 1
1095
+ entry.rejected += 1
1096
+
1097
+ total = entry.success + entry.fail
1098
+ if total == 1:
1099
+ entry.avg = max(0.0, min(1.0, reward))
1100
+ else:
1101
+ normalized_reward = max(0.0, min(1.0, reward))
1102
+ entry.avg = ((entry.avg * (total - 1)) + normalized_reward) / total
1103
+
1104
+ memory[hospital_id] = entry
1105
+ serialized = {k: v.model_dump() for k, v in memory.items()}
1106
+ self.memory_path.write_text(json.dumps(serialized, indent=2), encoding="utf-8")
1107
+
1108
+ def _progress_score(self) -> float:
1109
+ if not self.trajectory:
1110
+ return MIN_REWARD
1111
+ raw = sum(float(t["reward"]) for t in self.trajectory) / len(self.trajectory)
1112
+ return max(MIN_REWARD, min(MAX_REWARD, raw))
1113
+
1114
+ def _failure_score(self) -> float:
1115
+ assert self.state_data is not None
1116
+ progress_component = self._progress_score()
1117
+ reward_component = max(MIN_REWARD, min(MAX_REWARD, self.state_data.reward))
1118
+ score = 0.15 + (0.35 * reward_component) + (0.25 * progress_component)
1119
+ return max(MIN_REWARD, min(MAX_REWARD, max(0.1, min(0.85, score))))
1120
+
1121
+ def _success_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
+ total_steps = max(1, len(self.trajectory))
1126
+ rejected_steps = sum(1 for item in self.trajectory if item.get("outcome_status") == "rejected")
1127
+ route_quality = max(0.0, 1.0 - (rejected_steps / total_steps))
1128
+ score = (0.45 * reward_component) + (0.40 * progress_component) + (0.15 * route_quality)
1129
+ return max(MIN_REWARD, min(MAX_REWARD, max(0.25, min(0.99, score))))
1130
+
1131
+
1132
+ ACDEEnvironment = EmergencyEnv
app/environment/graders.py CHANGED
@@ -1,4 +1,3 @@
1
- from typing import Literal, cast
2
  from app.models.reward import GraderResult
3
 
4
 
@@ -40,7 +39,7 @@ def grade_task(
40
  margin_rate = sum(
41
  _norm_margin(t.get("travel_time", 0.0), t.get("critical_limit", 1.0))
42
  for t in trajectory
43
- ) / steps if trajectory else MIN_SCORE
44
 
45
  # Penalty for repeated failures at same hospital
46
  repeat_failures = 0
@@ -91,8 +90,8 @@ def grade_task(
91
  score = max(MIN_SCORE, min(MAX_SCORE, score))
92
 
93
  return GraderResult(
94
- task_id=cast(Literal["acde_easy", "acde_hard", "acde_medium"], task_id),
95
- difficulty=cast(Literal["easy", "hard", "medium"], difficulty),
96
  objective=objective,
97
  score=score,
98
  passed=score >= threshold,
 
 
1
  from app.models.reward import GraderResult
2
 
3
 
 
39
  margin_rate = sum(
40
  _norm_margin(t.get("travel_time", 0.0), t.get("critical_limit", 1.0))
41
  for t in trajectory
42
+ ) / steps if trajectory else 0.0
43
 
44
  # Penalty for repeated failures at same hospital
45
  repeat_failures = 0
 
90
  score = max(MIN_SCORE, min(MAX_SCORE, score))
91
 
92
  return GraderResult(
93
+ task_id=task_id,
94
+ difficulty=difficulty,
95
  objective=objective,
96
  score=score,
97
  passed=score >= threshold,
app/environment/validation.py CHANGED
@@ -4,7 +4,6 @@ Simulates hidden validation checks performed when an ambulance arrives at a hosp
4
  Outcomes are based on difficulty level, hospital capacity, patient suitability, and randomness.
5
  """
6
 
7
- from typing import Literal, cast
8
  from app.models.state import ArrivalOutcome, HospitalValidationDetails, HospitalState
9
  from app.utils.randomizer import SeededRandomizer
10
 
@@ -65,7 +64,7 @@ class HospitalValidator:
65
  icu_available=icu_available,
66
  doctor_available=doctor_available,
67
  equipment_functional=equipment_functional,
68
- overload_status=cast(Literal["clear", "moderate", "severe"], overload_status),
69
  patient_suitability=patient_suitability,
70
  )
71
 
@@ -80,7 +79,7 @@ class HospitalValidator:
80
  )
81
 
82
  return ArrivalOutcome(
83
- status=cast(Literal["accepted", "partial", "rejected"], status),
84
  reason=reason,
85
  validation_details=validation_details,
86
  reward_modifier=reward_modifier,
@@ -191,7 +190,7 @@ class HospitalValidator:
191
  # Add difficulty-based noise
192
  if difficulty == "hard":
193
  noise = self.rng.uniform(-0.15, 0.15)
194
- suitability = max(0.001, min(0.999, suitability + noise))
195
 
196
  return suitability
197
 
@@ -270,7 +269,7 @@ class HospitalValidator:
270
  return (
271
  "rejected",
272
  f"Hospital cannot admit: {', '.join(rejection_reasons[:2])}",
273
- 0.001,
274
  False,
275
  )
276
 
@@ -346,7 +345,7 @@ class HospitalValidator:
346
  return (
347
  "rejected",
348
  "Condition became non-transferable during delay; immediate critical care failed",
349
- 0.001,
350
  True,
351
  )
352
 
@@ -377,7 +376,7 @@ class HospitalValidator:
377
  return (
378
  "rejected",
379
  "Unexpected complication at arrival",
380
- 0.001,
381
  False,
382
  )
383
 
 
4
  Outcomes are based on difficulty level, hospital capacity, patient suitability, and randomness.
5
  """
6
 
 
7
  from app.models.state import ArrivalOutcome, HospitalValidationDetails, HospitalState
8
  from app.utils.randomizer import SeededRandomizer
9
 
 
64
  icu_available=icu_available,
65
  doctor_available=doctor_available,
66
  equipment_functional=equipment_functional,
67
+ overload_status=overload_status,
68
  patient_suitability=patient_suitability,
69
  )
70
 
 
79
  )
80
 
81
  return ArrivalOutcome(
82
+ status=status,
83
  reason=reason,
84
  validation_details=validation_details,
85
  reward_modifier=reward_modifier,
 
190
  # Add difficulty-based noise
191
  if difficulty == "hard":
192
  noise = self.rng.uniform(-0.15, 0.15)
193
+ suitability = max(0.0, min(1.0, suitability + noise))
194
 
195
  return suitability
196
 
 
269
  return (
270
  "rejected",
271
  f"Hospital cannot admit: {', '.join(rejection_reasons[:2])}",
272
+ 0.0,
273
  False,
274
  )
275
 
 
345
  return (
346
  "rejected",
347
  "Condition became non-transferable during delay; immediate critical care failed",
348
+ 0.0,
349
  True,
350
  )
351
 
 
376
  return (
377
  "rejected",
378
  "Unexpected complication at arrival",
379
+ 0.0,
380
  False,
381
  )
382
 
app/models/observation.py CHANGED
@@ -15,7 +15,7 @@ class ArrivalOutcomeObservation(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.001, le=0.999)
19
 
20
 
21
  class Observation(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):
app/models/state.py CHANGED
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
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.001, ge=0.001, le=0.999)
10
  accepted: int = Field(default=0, ge=0)
11
  rejected: int = Field(default=0, ge=0)
12
 
@@ -17,7 +17,7 @@ class HospitalValidationDetails(BaseModel):
17
  doctor_available: bool
18
  equipment_functional: bool
19
  overload_status: Literal["clear", "moderate", "severe"]
20
- patient_suitability: float = Field(ge=0.001, le=0.999) # near-0=unsuitable, near-1=ideal
21
 
22
 
23
  class ArrivalOutcome(BaseModel):
@@ -25,7 +25,7 @@ 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.001, le=1.5)
29
  terminal: bool = False
30
 
31
 
 
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
 
 
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
 
app/server/app.py CHANGED
@@ -3,7 +3,7 @@ from pathlib import Path
3
  from fastapi import FastAPI, HTTPException
4
  from pydantic import BaseModel
5
 
6
- from app.environment.core import EmergencyEnv
7
  from app.models.action import Action
8
  from app.models.observation import Observation
9
  from app.models.reward import StepInfo
@@ -13,7 +13,7 @@ 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 = EmergencyEnv(memory_file=str(MEMORY_FILE))
17
  MIN_REWARD = 0.001
18
 
19
 
 
3
  from fastapi import FastAPI, HTTPException
4
  from pydantic import BaseModel
5
 
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
 
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
 
inference.py CHANGED
The diff for this file is too large to render. See raw diff