sanjithp-dev commited on
Commit
6eb95ff
Β·
verified Β·
1 Parent(s): 2c34a47

Upload 18 files

Browse files
Dockerfile ADDED
File without changes
app.py ADDED
File without changes
pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "disaster_grid"
3
+ version = "0.1.0"
4
+ dependencies = [
5
+ "openenv-core",
6
+ "fastapi",
7
+ "uvicorn",
8
+ "pydantic",
9
+ "rich",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ dev = [
14
+ "pytest",
15
+ ]
16
+
17
+ [build-system]
18
+ requires = ["setuptools", "wheel"]
19
+ build-backend = "setuptools.build_meta"
20
+
21
+ [tool.setuptools]
22
+ package-dir = {"" = "src"}
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
src/disaster_grid.egg-info/PKG-INFO ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: disaster_grid
3
+ Version: 0.1.0
4
+ Requires-Dist: openenv-core
5
+ Requires-Dist: fastapi
6
+ Requires-Dist: uvicorn
7
+ Requires-Dist: pydantic
8
+ Requires-Dist: rich
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
src/disaster_grid.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pyproject.toml
2
+ src/disaster_grid/__init__.py
3
+ src/disaster_grid/environment.py
4
+ src/disaster_grid/models.py
5
+ src/disaster_grid/rewards.py
6
+ src/disaster_grid/utils.py
7
+ src/disaster_grid.egg-info/PKG-INFO
8
+ src/disaster_grid.egg-info/SOURCES.txt
9
+ src/disaster_grid.egg-info/dependency_links.txt
10
+ src/disaster_grid.egg-info/requires.txt
11
+ src/disaster_grid.egg-info/top_level.txt
12
+ tests/test_env.py
src/disaster_grid.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/disaster_grid.egg-info/requires.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ openenv-core
2
+ fastapi
3
+ uvicorn
4
+ pydantic
5
+ rich
6
+
7
+ [dev]
8
+ pytest
src/disaster_grid.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ disaster_grid
src/disaster_grid/__init__.py ADDED
File without changes
src/disaster_grid/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (167 Bytes). View file
 
src/disaster_grid/__pycache__/environment.cpython-314.pyc ADDED
Binary file (21.6 kB). View file
 
src/disaster_grid/__pycache__/models.cpython-314.pyc ADDED
Binary file (17.6 kB). View file
 
src/disaster_grid/__pycache__/rewards.cpython-314.pyc ADDED
Binary file (13.7 kB). View file
 
src/disaster_grid/__pycache__/utils.cpython-314.pyc ADDED
Binary file (17.1 kB). View file
 
src/disaster_grid/environment.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/disaster_grid/environment.py
3
+ =================================
4
+ Core OpenEnv environment for the disaster_grid hackathon project.
5
+
6
+ Architecture overview
7
+ ---------------------
8
+ ``CityGrid`` inherits from ``openenv.AutoEnv`` and implements the standard
9
+ ``reset`` / ``step`` interface. It owns the full physics simulation:
10
+
11
+ * **Grid state** – 25 sector health values mutated by entropy and REPAIR.
12
+ * **Agent state** – position and energy, mutated by movement, repair, and
13
+ recharge actions.
14
+ * **Observation factory** – ``_get_observation()`` distils the raw grid into
15
+ the token-efficient ``GridObservation`` schema the LLM sees.
16
+ * **Receipt factory** – ``step()`` assembles a ``StepResult`` that the
17
+ external reward verifiers in ``rewards.py`` consume. The environment
18
+ itself always returns ``reward=0.0``; actual reward computation is
19
+ deliberately decoupled so verifier weights can be tuned without touching
20
+ environment logic.
21
+
22
+ Step contract
23
+ -------------
24
+ ``step(action)`` accepts either a raw JSON string (as emitted by the LLM) or
25
+ a pre-parsed ``dict``. If parsing or Pydantic validation fails the step is
26
+ treated as a ``WAIT`` with ``is_error=True`` recorded in the receipt, giving
27
+ the R3 format verifier a clean signal without crashing the episode.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import random
34
+ from typing import Any
35
+ import openenv
36
+
37
+ from pydantic import ValidationError
38
+
39
+ from .models import ActionType, AgentAction, GridObservation, SectorState, StepResult
40
+
41
+ # ── Grid constants ────────────────────────────────────────────────────────────
42
+
43
+ _GRID_SIZE: int = 5 # edge length of the square grid
44
+ _NUM_SECTORS: int = _GRID_SIZE ** 2 # 25 total sectors
45
+
46
+ # ── Action energy costs and effects ──────────────────────────────────────────
47
+
48
+ _MOVE_COST: int = 2 # energy deducted per move attempt (wall or not)
49
+ _REPAIR_COST: int = 15 # energy deducted per REPAIR
50
+ _REPAIR_GAIN: int = 25 # health added to current sector per REPAIR
51
+ _RECHARGE_STATION: int = 12 # center sector where RECHARGE is valid
52
+ _RECHARGE_GAIN: int = 20 # energy added per valid RECHARGE (at sector 12)
53
+ _RECHARGE_WRONG_COST: int = 1 # energy penalty for RECHARGE outside sector 12
54
+ _WAIT_COST: int = 1 # energy deducted per WAIT
55
+
56
+ # ── Entropy constants ─────────────────────────────────────────────────────────
57
+
58
+ _ENTROPY_SECTORS_PER_STEP: int = 2 # sectors degraded each step
59
+ _ENTROPY_DAMAGE: int = 5 # health lost per entropy tick
60
+
61
+ # ── Initial state constants ───────────────────────────────────────────────────
62
+
63
+ _INIT_HEALTH_LOW: int = 50 # minimum random starting health
64
+ _INIT_HEALTH_HIGH: int = 100 # maximum random starting health
65
+ _CRISIS_HEALTH: int = 20 # health value forced onto crisis sectors at reset
66
+ _NUM_CRISIS_SECTORS: int = 5 # sectors forced into crisis at reset
67
+
68
+ # ── Episode limit ─────────────────────────────────────────────────────────────
69
+
70
+ _MAX_STEPS: int = 50
71
+
72
+
73
+ class CityGrid(openenv.AutoEnv):
74
+ """
75
+ A 5 Γ— 5 disaster-recovery grid environment compliant with the OpenEnv API.
76
+
77
+ The city is partitioned into 25 sectors indexed 0–24 in row-major order::
78
+
79
+ (0,0) (1,0) (2,0) (3,0) (4,0) β†’ indices 0– 4
80
+ (0,1) (1,1) (2,1) (3,1) (4,1) β†’ indices 5– 9
81
+ (0,2) (1,2) (2,2) (3,2) (4,2) β†’ indices 10–14
82
+ (0,3) (1,3) (2,3) (3,3) (4,3) β†’ indices 15–19
83
+ (0,4) (1,4) (2,4) (3,4) (4,4) β†’ indices 20–24
84
+
85
+ Sector 12 is the center cell and serves as the only recharge station.
86
+
87
+ Episode dynamics
88
+ ----------------
89
+ Each call to ``step()`` executes the following sequence in strict order:
90
+
91
+ 1. Snapshot agent and grid state *before* any mutation.
92
+ 2. Parse and validate the action string / dict from the LLM.
93
+ 3. Execute the action (energy deduction, position update, health change).
94
+ 4. Apply entropy (2 random sectors lose 5 health).
95
+ 5. Snapshot agent and grid state *after* mutations.
96
+ 6. Increment step counter; check termination.
97
+ 7. Build and return the ``StepResult`` receipt in ``info``.
98
+
99
+ The reward returned by ``step()`` is always ``0.0``. Real rewards are
100
+ computed externally by the three verifiers in ``rewards.py`` using the
101
+ ``StepResult`` embedded in the ``info`` dict. This separation means
102
+ verifier weights and reward shaping can be changed without modifying or
103
+ re-testing environment physics.
104
+
105
+ Attributes
106
+ ----------
107
+ grid_health : list[int]
108
+ Current health values for all 25 sectors. Index ``i`` corresponds to
109
+ the sector at coordinate ``_index_to_coord(i)``.
110
+ agent_pos : int
111
+ Flat sector index (0–24) of the agent's current position.
112
+ agent_energy : int
113
+ Current energy reserve (0–100). The episode terminates immediately
114
+ when this reaches 0.
115
+ step_count : int
116
+ Number of completed steps in the current episode.
117
+ """
118
+
119
+ # ── Initialisation ────────────────────────────────────────────────────────
120
+
121
+ def __init__(self) -> None:
122
+ """
123
+ Instantiate the environment without starting an episode.
124
+
125
+ State variables are set to sentinel values (all zeros / empty list)
126
+ until the caller invokes ``reset()``. This mirrors the convention used
127
+ by Gymnasium and OpenEnv so the environment can be constructed cheaply
128
+ inside a trainer worker without triggering random-number generation.
129
+ """
130
+
131
+ self.grid_health: list[int] = [0] * _NUM_SECTORS
132
+ self.agent_pos: int = 0
133
+ self.agent_energy: int = 0
134
+ self.step_count: int = 0
135
+
136
+ # ── Private helpers ───────────────────────────────────────────────────────
137
+
138
+ @staticmethod
139
+ def _index_to_coord(index: int) -> tuple[int, int]:
140
+ """
141
+ Convert a flat row-major sector index to an (x, y) coordinate pair.
142
+
143
+ The coordinate system places (0, 0) at the top-left corner:
144
+
145
+ * ``x`` is the column (0 = leftmost, 4 = rightmost).
146
+ * ``y`` is the row (0 = topmost, 4 = bottommost).
147
+
148
+ Parameters
149
+ ----------
150
+ index : int
151
+ Flat sector index in [0, 24].
152
+
153
+ Returns
154
+ -------
155
+ tuple[int, int]
156
+ ``(x, y)`` where ``x = index % 5`` and ``y = index // 5``.
157
+
158
+ Examples
159
+ --------
160
+ >>> CityGrid._index_to_coord(0)
161
+ (0, 0)
162
+ >>> CityGrid._index_to_coord(7)
163
+ (2, 1)
164
+ >>> CityGrid._index_to_coord(24)
165
+ (4, 4)
166
+ """
167
+ return index % _GRID_SIZE, index // _GRID_SIZE
168
+
169
+ @staticmethod
170
+ def _coord_to_index(x: int, y: int) -> int:
171
+ """
172
+ Convert an (x, y) coordinate pair to a flat row-major sector index.
173
+
174
+ Parameters
175
+ ----------
176
+ x : int
177
+ Column index in [0, 4].
178
+ y : int
179
+ Row index in [0, 4].
180
+
181
+ Returns
182
+ -------
183
+ int
184
+ Flat sector index ``y * 5 + x`` in [0, 24].
185
+
186
+ Examples
187
+ --------
188
+ >>> CityGrid._coord_to_index(0, 0)
189
+ 0
190
+ >>> CityGrid._coord_to_index(2, 1)
191
+ 7
192
+ >>> CityGrid._coord_to_index(4, 4)
193
+ 24
194
+ """
195
+ return y * _GRID_SIZE + x
196
+
197
+ def _get_observation(self) -> GridObservation:
198
+ """
199
+ Distil current world state into the token-efficient ``GridObservation``
200
+ schema consumed by the LLM.
201
+
202
+ This method intentionally omits the full ``grid_health`` list from the
203
+ observation. Passing 25 integers per step at a 50-step horizon costs
204
+ approximately 1 250 extra tokens per episode β€” nearly doubling prompt
205
+ length in a GRPO batch. Instead the method computes:
206
+
207
+ * **average_city_health** – a single float that summarises the global
208
+ state and correlates with the R1 verifier's reward signal.
209
+ * **critical_sectors** – a sorted list of indices whose health is below
210
+ 30, giving the agent the minimum routing information needed to plan
211
+ an optimal repair trajectory.
212
+
213
+ Returns
214
+ -------
215
+ GridObservation
216
+ A validated Pydantic model ready for ``model_dump()`` or
217
+ ``model_dump_json()``.
218
+ """
219
+ average_health: float = round(sum(self.grid_health) / _NUM_SECTORS, 2)
220
+ critical: list[int] = sorted(
221
+ i for i, h in enumerate(self.grid_health) if h < 30
222
+ )
223
+ return GridObservation(
224
+ step_number=self.step_count,
225
+ agent_position=self.agent_pos,
226
+ agent_energy=self.agent_energy,
227
+ current_sector_health=self.grid_health[self.agent_pos],
228
+ critical_sectors=critical,
229
+ average_city_health=average_health,
230
+ )
231
+
232
+ def _apply_entropy(self) -> None:
233
+ """
234
+ Degrade two randomly chosen distinct sectors by ``_ENTROPY_DAMAGE``
235
+ (5) health points, floored at 0.
236
+
237
+ Entropy is applied **after** the agent's action each step. This
238
+ ordering means a REPAIR action's +25 gain is always partially offset by
239
+ entropy before the post-step snapshot is taken, preventing the agent
240
+ from achieving a perfect +25 net gain on a single step and thus
241
+ maintaining a meaningful resource-management challenge across the full
242
+ 50-step horizon.
243
+
244
+ The two sectors are chosen without replacement so the same sector
245
+ cannot be hit twice in a single entropy tick (which would amount to
246
+ -10 health and skew the difficulty distribution).
247
+ """
248
+ targets: list[int] = random.sample(
249
+ range(_NUM_SECTORS), _ENTROPY_SECTORS_PER_STEP
250
+ )
251
+ for idx in targets:
252
+ self.grid_health[idx] = max(0, self.grid_health[idx] - _ENTROPY_DAMAGE)
253
+
254
+ # ── OpenEnv API ───────────────────────────────────────────────────────────
255
+
256
+ def reset(
257
+ self,
258
+ seed: int | None = None,
259
+ options: dict | None = None,
260
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
261
+ """
262
+ Begin a new episode and return the initial observation.
263
+
264
+ The reset procedure deliberately creates an adversarial starting state:
265
+ five sectors are forced to health 20 (below the critical threshold of
266
+ 30) so the agent faces immediate triage decisions rather than deferring
267
+ all repair work to the second half of the episode. This biases the
268
+ training distribution toward high-action-density rollouts, which
269
+ produce richer GRPO gradient signal than episodes where the agent
270
+ coasts on a healthy grid.
271
+
272
+ Parameters
273
+ ----------
274
+ seed : int | None
275
+ If provided, seeds Python's ``random`` module before any stochastic
276
+ operation. Pass an integer for reproducible evaluation episodes;
277
+ leave as ``None`` during training to sample diverse rollouts.
278
+ options : dict | None
279
+ Reserved for future configuration (e.g. custom entropy rates or
280
+ forced agent starting positions). Ignored in the current version.
281
+
282
+ Returns
283
+ -------
284
+ observation : dict
285
+ ``GridObservation.model_dump()`` representing the initial world
286
+ state. The agent starts at sector 12 with full energy and faces
287
+ at least five critical sectors.
288
+ info : dict
289
+ Empty dict. Provided for API compatibility with OpenEnv / Gym.
290
+ """
291
+ if seed is not None:
292
+ random.seed(seed)
293
+
294
+ # ── Reset counters ─────────────────────────────────────────────────
295
+ self.step_count = 0
296
+ self.agent_energy = 100
297
+ self.agent_pos = _RECHARGE_STATION
298
+
299
+ # ── Randomise grid health ──────────────────────────────────────────
300
+ self.grid_health = [
301
+ random.randint(_INIT_HEALTH_LOW, _INIT_HEALTH_HIGH)
302
+ for _ in range(_NUM_SECTORS)
303
+ ]
304
+
305
+ # ── Force five crisis sectors, excluding sector 12 so the recharge
306
+ # station always starts accessible and reachable ─────────────────
307
+ crisis_candidates: list[int] = random.sample(
308
+ [idx for idx in range(_NUM_SECTORS) if idx != _RECHARGE_STATION],
309
+ _NUM_CRISIS_SECTORS,
310
+ )
311
+ for idx in crisis_candidates:
312
+ self.grid_health[idx] = _CRISIS_HEALTH
313
+
314
+ return self._get_observation().model_dump(), {}
315
+
316
+ def step(
317
+ self,
318
+ action: str | dict,
319
+ ) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
320
+ """
321
+ Advance the environment by one step and return the standard 5-tuple.
322
+
323
+ The method follows a strict execution pipeline to ensure that the
324
+ ``StepResult`` receipt always contains consistent before/after
325
+ snapshots regardless of whether the action was valid:
326
+
327
+ 1. **Pre-snapshot** – capture ``energy_before`` and
328
+ ``city_health_before`` before any mutation.
329
+ 2. **Parse** – attempt to deserialise ``action`` into ``AgentAction``.
330
+ On failure, mark ``is_error=True`` and skip to step 5. The step
331
+ acts as a free turn: no energy is deducted for an invalid action,
332
+ but entropy still applies and the step counter still increments.
333
+ The error is surfaced to the R3 verifier via ``StepResult``.
334
+ 3. **Execute** – apply the validated action's physics (energy cost,
335
+ position update, health change).
336
+ 4. **Entropy** – call ``_apply_entropy()`` to degrade two random
337
+ sectors.
338
+ 5. **Post-snapshot** – capture ``energy_after`` and
339
+ ``city_health_after``.
340
+ 6. **Bookkeeping** – increment ``step_count``; evaluate termination.
341
+ 7. **Receipt** – build ``StepResult`` and embed in ``info``.
342
+
343
+ Parameters
344
+ ----------
345
+ action : str | dict
346
+ The LLM's response, either as a raw JSON string or a pre-parsed
347
+ dict (the latter is used by the synthetic data generator and unit
348
+ tests).
349
+
350
+ Returns
351
+ -------
352
+ observation : dict
353
+ ``GridObservation.model_dump()`` for the state *after* this step.
354
+ reward : float
355
+ Always ``0.0``. Real rewards are computed by ``rewards.py`` using
356
+ ``info["step_result"]``.
357
+ terminated : bool
358
+ ``True`` when the episode has ended due to energy depletion
359
+ (``agent_energy <= 0``) or the 50-step limit (``step_count >= 50``).
360
+ truncated : bool
361
+ Always ``False``. Time-limit termination is modelled via
362
+ ``terminated`` so the GRPO trainer does not need to handle the
363
+ truncated case separately.
364
+ info : dict
365
+ Contains ``"step_result"`` β†’ ``StepResult.model_dump()``, the
366
+ verifier receipt used by ``rewards.py`` to compute R1, R2, and R3.
367
+
368
+ Notes
369
+ -----
370
+ **Wall collision** – a move that would take the agent off the grid is
371
+ silently blocked (position unchanged) but the energy cost is still
372
+ applied. This punishes the agent for poor pathing without requiring
373
+ boundary-check logic inside the reward verifiers.
374
+
375
+ **RECHARGE outside sector 12** – deducts ``_RECHARGE_WRONG_COST`` (1)
376
+ energy and sets ``is_error=True``. The deliberately small penalty
377
+ keeps the training signal proportional; a large penalty would dominate
378
+ the R2 efficiency term and cause the agent to avoid RECHARGE entirely.
379
+ """
380
+ # ── 1. Pre-snapshot ───────────────────────────────────────────────
381
+ energy_before: int = self.agent_energy
382
+ city_health_before: float = sum(self.grid_health) / _NUM_SECTORS
383
+
384
+ # ── 2. Parse action ───────────────────────────────────────────────
385
+ parsed_action: AgentAction | None = None
386
+ is_error: bool = False
387
+ error_message: str = ""
388
+ if isinstance(action, str):
389
+ action_attempted = action
390
+ else:
391
+ try:
392
+ action_attempted = json.dumps(action)
393
+ except (TypeError, ValueError):
394
+ action_attempted = repr(action)
395
+
396
+ try:
397
+ raw: dict = json.loads(action) if isinstance(action, str) else action
398
+ parsed_action = AgentAction(**raw)
399
+ except (json.JSONDecodeError, ValidationError, TypeError) as exc:
400
+ is_error = True
401
+ error_message = (
402
+ f"Action parse failed ({type(exc).__name__}): {exc!s}"
403
+ )
404
+
405
+ # ── 3. Execute action (no-op on parse error) ──────────────────────
406
+ if not is_error and parsed_action is not None:
407
+ action_type: ActionType = parsed_action.action
408
+ x, y = self._index_to_coord(self.agent_pos)
409
+
410
+ if action_type in (
411
+ ActionType.MOVE_N,
412
+ ActionType.MOVE_S,
413
+ ActionType.MOVE_E,
414
+ ActionType.MOVE_W,
415
+ ):
416
+ # Energy cost is always applied, even on wall collision.
417
+ self.agent_energy = max(0, self.agent_energy - _MOVE_COST)
418
+
419
+ new_x, new_y = x, y
420
+ if action_type is ActionType.MOVE_N:
421
+ new_y = y - 1
422
+ elif action_type is ActionType.MOVE_S:
423
+ new_y = y + 1
424
+ elif action_type is ActionType.MOVE_E:
425
+ new_x = x + 1
426
+ elif action_type is ActionType.MOVE_W:
427
+ new_x = x - 1
428
+
429
+ if 0 <= new_x < _GRID_SIZE and 0 <= new_y < _GRID_SIZE:
430
+ self.agent_pos = self._coord_to_index(new_x, new_y)
431
+ # else: position unchanged; collision already penalised above.
432
+
433
+ elif action_type is ActionType.REPAIR:
434
+ self.agent_energy = max(0, self.agent_energy - _REPAIR_COST)
435
+ self.grid_health[self.agent_pos] = min(
436
+ 100, self.grid_health[self.agent_pos] + _REPAIR_GAIN
437
+ )
438
+
439
+ elif action_type is ActionType.RECHARGE:
440
+ if self.agent_pos == _RECHARGE_STATION:
441
+ self.agent_energy = min(100, self.agent_energy + _RECHARGE_GAIN)
442
+ else:
443
+ self.agent_energy = max(
444
+ 0, self.agent_energy - _RECHARGE_WRONG_COST
445
+ )
446
+ is_error = True
447
+ error_message = (
448
+ f"RECHARGE attempted at sector {self.agent_pos} "
449
+ f"(valid only at sector 12). "
450
+ f"Penalty: -{_RECHARGE_WRONG_COST} energy."
451
+ )
452
+
453
+ elif action_type is ActionType.WAIT:
454
+ self.agent_energy = max(0, self.agent_energy - _WAIT_COST)
455
+
456
+ # ── 4. Apply entropy ──────────────────────────────────────────────
457
+ self._apply_entropy()
458
+
459
+ # ── 5. Post-snapshot ──────────────────────────────────────────────
460
+ energy_after: int = self.agent_energy
461
+ city_health_after: float = sum(self.grid_health) / _NUM_SECTORS
462
+
463
+ # ── 6. Bookkeeping ────────────────────────────────────────────────
464
+ self.step_count += 1
465
+ terminated: bool = (
466
+ self.agent_energy <= 0 or self.step_count >= _MAX_STEPS
467
+ )
468
+
469
+ # ── 7. Assemble receipt ───────────────────────────────────────────
470
+ step_result = StepResult(
471
+ action_attempted=action_attempted,
472
+ action_parsed=parsed_action,
473
+ energy_before=energy_before,
474
+ energy_after=energy_after,
475
+ city_health_before=round(city_health_before, 2),
476
+ city_health_after=round(city_health_after, 2),
477
+ is_error=is_error,
478
+ error_message=error_message,
479
+ )
480
+
481
+ return (
482
+ self._get_observation().model_dump(),
483
+ 0.0,
484
+ terminated,
485
+ False,
486
+ {"step_result": step_result.model_dump()},
487
+ )
488
+
489
+ # ── Utility ───────────────────────────────────────────────────────────────
490
+
491
+ def render(self) -> list[SectorState]:
492
+ """
493
+ Return the current grid as a list of ``SectorState`` objects.
494
+
495
+ This method is consumed by ``utils.py``'s emoji-grid visualiser during
496
+ debugging and live demo sessions. It is not called during training.
497
+
498
+ Returns
499
+ -------
500
+ list[SectorState]
501
+ One ``SectorState`` per sector (indices 0–24), in row-major order.
502
+ """
503
+ return [
504
+ SectorState(index=i, health=h)
505
+ for i, h in enumerate(self.grid_health)
506
+ ]
507
+
508
+ def get_action_space(self) -> list[str]:
509
+ """
510
+ Return the sorted list of valid action strings.
511
+
512
+ Mirrors the ``ActionType`` enum values so external tooling (e.g. the
513
+ synthetic data generator in ``train/synthetic_data.json``) can
514
+ enumerate valid actions without importing ``models.py`` directly.
515
+
516
+ Returns
517
+ -------
518
+ list[str]
519
+ Sorted list of action name strings, e.g.
520
+ ``["MOVE_E", "MOVE_N", "MOVE_S", "MOVE_W", "RECHARGE", "REPAIR", "WAIT"]``.
521
+ """
522
+ return sorted(a.value for a in ActionType)
src/disaster_grid/models.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/disaster_grid/models.py
3
+ ============================
4
+ Pydantic schemas and enumerations for the disaster_grid OpenEnv environment.
5
+
6
+ Design philosophy
7
+ -----------------
8
+ All data structures that cross a module boundary are defined here so that
9
+ `environment.py`, `rewards.py`, and `grpo_trainer.py` share a single source
10
+ of truth. Pydantic is chosen over `dataclasses` for three reasons:
11
+
12
+ 1. **FastAPI integration** – every model serialises to / deserialises from
13
+ JSON with zero extra work, enabling a `/step` endpoint with no adapter
14
+ layer.
15
+ 2. **Runtime validation** – `Field` constraints (ge, le, min_length …) are
16
+ enforced at construction time, so an out-of-range energy value raises a
17
+ clear `ValidationError` rather than silently corrupting training data.
18
+ 3. **LLM prompt hygiene** – `model.model_dump_json()` produces the exact JSON
19
+ string we can embed in a system prompt, keeping the schema the LLM is
20
+ trained on perfectly in sync with the schema the environment enforces.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from enum import Enum
26
+ from typing import Optional
27
+
28
+ from pydantic import BaseModel, Field, computed_field, model_validator
29
+
30
+
31
+ # ─────────────────────────────────────────────────────────────────────────────
32
+ # Enumerations
33
+ # ─────────────────────────────────────────────────────────────────────────────
34
+
35
+
36
+ class ActionType(str, Enum):
37
+ """
38
+ The complete set of actions available to the disaster-recovery agent.
39
+
40
+ Inheriting from ``str`` means Pydantic serialises these as plain strings
41
+ (e.g. ``"MOVE_N"``), which is what the LLM will emit and what FastAPI
42
+ will accept in a JSON body without any custom encoder.
43
+
44
+ Members
45
+ -------
46
+ MOVE_N
47
+ Move the agent one row upward (row -= 1).
48
+ Energy cost: -2. Invalid if the agent is already on row 0.
49
+ MOVE_S
50
+ Move the agent one row downward (row += 1).
51
+ Energy cost: -2. Invalid if the agent is already on row 4.
52
+ MOVE_E
53
+ Move the agent one column rightward (col += 1).
54
+ Energy cost: -2. Invalid if the agent is already on col 4.
55
+ MOVE_W
56
+ Move the agent one column leftward (col -= 1).
57
+ Energy cost: -2. Invalid if the agent is already on col 0.
58
+ REPAIR
59
+ Repair the sector the agent currently occupies.
60
+ Energy cost: -15. Sector health gain: +25 (capped at 100).
61
+ Has no effect if sector health is already 100.
62
+ RECHARGE
63
+ Replenish the agent's energy reserve.
64
+ Energy gain: +20 (capped at 100).
65
+ **Only valid at sector index 12** (the recharge station at the center).
66
+ Attempting RECHARGE elsewhere is treated as a no-op with an error flag.
67
+ WAIT
68
+ The agent stays in place and takes no action.
69
+ No energy cost. Entropy still applies.
70
+ Useful when the agent is awaiting a recharge opportunity or has no
71
+ repair targets within reach.
72
+ """
73
+
74
+ MOVE_N = "MOVE_N"
75
+ MOVE_S = "MOVE_S"
76
+ MOVE_E = "MOVE_E"
77
+ MOVE_W = "MOVE_W"
78
+ REPAIR = "REPAIR"
79
+ RECHARGE = "RECHARGE"
80
+ WAIT = "WAIT"
81
+
82
+
83
+ # ─────────────────────────────────────────────────────────────────────────────
84
+ # Primitive domain models
85
+ # ─────────────────────────────────────────────────────────────────────────────
86
+
87
+
88
+ class SectorState(BaseModel):
89
+ """
90
+ The state of a single cell in the 5 Γ— 5 city grid.
91
+
92
+ The grid uses row-major indexing: sector ``index = row * 5 + col``.
93
+ Sector 0 is the top-left corner.
94
+ Sector 24 is the bottom-right corner.
95
+
96
+ ``SectorState`` objects are held in ``environment.py``'s internal grid
97
+ list and are **not** passed directly to the LLM – they are too verbose
98
+ for a 50-step prompt budget. Only derived summaries (average health,
99
+ critical indices) appear in ``GridObservation``.
100
+
101
+ Fields
102
+ ------
103
+ index : int
104
+ Flat grid index in [0, 24]. Immutable after construction.
105
+ health : int
106
+ Current health of the sector in [0, 100].
107
+ 0 β†’ sector is completely degraded (city infrastructure lost).
108
+ 100 β†’ sector is at full operational capacity.
109
+ Mutated by the environment's entropy step and by REPAIR actions.
110
+ """
111
+
112
+ index: int = Field(
113
+ ...,
114
+ ge=0,
115
+ le=24,
116
+ description="Flat row-major index of this sector within the 5Γ—5 grid.",
117
+ )
118
+ health: int = Field(
119
+ ...,
120
+ ge=0,
121
+ le=100,
122
+ description="Current sector health. Degraded by entropy; restored by REPAIR.",
123
+ )
124
+
125
+ @computed_field # type: ignore[misc]
126
+ @property
127
+ def is_critical(self) -> bool:
128
+ """
129
+ Return ``True`` when this sector requires urgent intervention.
130
+
131
+ A sector is *critical* when its health drops below 30. Critical
132
+ sectors are surfaced to the LLM in ``GridObservation.critical_sectors``
133
+ so the agent can prioritise routing decisions without scanning the
134
+ full grid.
135
+
136
+ The threshold (30) is a domain constant chosen so that a single
137
+ entropy tick of -5 would push a critical sector to health 25, giving
138
+ the agent at most ~5 additional steps before the sector reaches 0.
139
+ """
140
+ return self.health < 30
141
+
142
+ @model_validator(mode="after")
143
+ def _clamp_health(self) -> "SectorState":
144
+ """Silently clamp health into [0, 100] after any mutation helper."""
145
+ self.health = max(0, min(100, self.health))
146
+ return self
147
+
148
+
149
+ # ─────────────────────────────────────────────────────────────────────────────
150
+ # LLM-facing observation schema
151
+ # ─────────────────────────────────────────────────────────────────────────────
152
+
153
+
154
+ class GridObservation(BaseModel):
155
+ """
156
+ The structured observation delivered to the LLM at the start of each step.
157
+
158
+ **Token budget rationale** – a naΓ―ve approach would serialise all 25
159
+ ``SectorState`` objects (~200 tokens per step Γ— 50 steps = 10 000 tokens
160
+ per episode). Instead this model passes:
161
+
162
+ * The agent's current coordinates and energy.
163
+ * The health of only the sector the agent stands on (immediately
164
+ actionable information).
165
+ * The *average* city health (global signal for the R1 verifier proxy).
166
+ * The flat indices of *critical* sectors (health < 30) so the agent can
167
+ plan efficient routes without enumerating the whole grid.
168
+
169
+ This reduces observation size to ~60 tokens per step while preserving all
170
+ information required for rational decision-making.
171
+
172
+ The environment serialises this as a JSON string and injects it into the
173
+ LLM prompt as the ``<observation>`` block.
174
+
175
+ Fields
176
+ ------
177
+ step_number : int
178
+ Current step in [0, 50]. Lets the agent reason about remaining
179
+ horizon (e.g. "I have 10 steps left, is a recharge trip worth it?").
180
+ agent_position : int
181
+ Flat index of the agent's current sector. Combined with the 5-wide
182
+ grid, the agent can derive (row, col) via ``divmod(position, 5)``.
183
+ agent_energy : int
184
+ Current energy in [0, 100]. The done condition fires at 0.
185
+ current_sector_health : int
186
+ Health of the sector at ``agent_position``. Included so the agent
187
+ can decide *immediately* whether to REPAIR here or move elsewhere.
188
+ critical_sectors : list[int]
189
+ Sorted list of sector indices whose health is below 30. May be
190
+ empty. The agent should prefer routing to the nearest element of
191
+ this list. Presented in ascending index order so prompt content is
192
+ deterministic across identical states (useful for reproducible evals).
193
+ average_city_health : float
194
+ Mean health across all 25 sectors, rounded to two decimal places.
195
+ Acts as the agent's running score proxy. A strategic agent should
196
+ try to keep this above 70.0.
197
+ """
198
+
199
+ step_number: int = Field(
200
+ ...,
201
+ ge=0,
202
+ le=50,
203
+ description="Current environment step. Episode ends at step 50.",
204
+ )
205
+ agent_position: int = Field(
206
+ ...,
207
+ ge=0,
208
+ le=24,
209
+ description="Flat index of the agent's current grid position.",
210
+ )
211
+ agent_energy: int = Field(
212
+ ...,
213
+ ge=0,
214
+ le=100,
215
+ description="Remaining energy units. Episode ends immediately if this reaches 0.",
216
+ )
217
+ current_sector_health: int = Field(
218
+ ...,
219
+ ge=0,
220
+ le=100,
221
+ description="Health of the sector currently occupied by the agent.",
222
+ )
223
+ critical_sectors: list[int] = Field(
224
+ default_factory=list,
225
+ description=(
226
+ "Sorted indices of sectors with health < 30. "
227
+ "Empty list means no sectors are currently critical."
228
+ ),
229
+ )
230
+ average_city_health: float = Field(
231
+ ...,
232
+ ge=0.0,
233
+ le=100.0,
234
+ description="Mean health across all 25 sectors, rounded to 2 d.p.",
235
+ )
236
+
237
+ @model_validator(mode="after")
238
+ def _validate_critical_sectors(self) -> "GridObservation":
239
+ """Ensure all critical sector indices are within the valid grid range."""
240
+ for idx in self.critical_sectors:
241
+ if not (0 <= idx <= 24):
242
+ raise ValueError(
243
+ f"critical_sectors contains out-of-range index {idx!r}. "
244
+ "All indices must be in [0, 24]."
245
+ )
246
+ return self
247
+
248
+
249
+ # ─────────────────────────────────────────────────────────────────────────────
250
+ # LLM output schema (action payload)
251
+ # ─────────────────────────────────────────────────────────────────────────────
252
+
253
+
254
+ class AgentAction(BaseModel):
255
+ """
256
+ The structured JSON payload the LLM must emit in response to each
257
+ ``GridObservation``.
258
+
259
+ **Two-field design** – separating ``reasoning`` from ``action`` is a
260
+ deliberate inductive bias:
261
+
262
+ * ``reasoning`` is placed *first* in the schema so that the LLM is
263
+ forced to produce a chain-of-thought (CoT) token sequence before
264
+ committing to an ``action`` token. Empirically, CoT prefix generation
265
+ significantly improves action quality on multi-step planning tasks.
266
+ * ``action`` is validated against ``ActionType`` at parse time, so any
267
+ hallucinated action string (e.g. ``"MOVE_NE"``) raises a
268
+ ``ValidationError`` that the environment catches and converts into a
269
+ ``StepResult`` with ``is_error=True``. This error signal flows through
270
+ to the R3 (format) verifier.
271
+
272
+ The GRPO trainer embeds the ``model_json_schema()`` of this class in the
273
+ system prompt so the LLM always sees the exact schema it is being graded
274
+ against.
275
+
276
+ Fields
277
+ ------
278
+ reasoning : str
279
+ A free-text explanation (minimum 10 characters) of *why* the agent
280
+ chose this action given the current observation. Enforcing a minimum
281
+ length discourages degenerate single-token rationales. The ``rewards``
282
+ module does **not** score reasoning quality – it is included purely as
283
+ a CoT generation mechanism during GRPO fine-tuning.
284
+ action : ActionType
285
+ The chosen action from the ``ActionType`` enum. Must be one of the
286
+ seven valid strings; anything else will fail Pydantic validation.
287
+ """
288
+
289
+ reasoning: str = Field(
290
+ ...,
291
+ min_length=10,
292
+ description=(
293
+ "Chain-of-thought rationale produced *before* the action token. "
294
+ "Forces the model to reason about position, energy, and critical "
295
+ "sectors prior to committing to a move. Not scored by verifiers."
296
+ ),
297
+ )
298
+ action: ActionType = Field(
299
+ ...,
300
+ description=(
301
+ "The discrete action to execute this step. Must be a member of "
302
+ "ActionType. Invalid strings cause a ValidationError that is "
303
+ "captured as is_error=True in StepResult and penalised by R3."
304
+ ),
305
+ )
306
+
307
+
308
+ # ─────────────────────────────────────────────────────────────────────────────
309
+ # Verifier receipt (internal – never seen by the LLM)
310
+ # ─────────────────────────────────────────────────────────────────────────────
311
+
312
+
313
+ class StepResult(BaseModel):
314
+ """
315
+ A complete record of everything that happened during one environment step.
316
+
317
+ ``StepResult`` is the *contract* between ``environment.py`` and
318
+ ``rewards.py``. It is constructed by ``CityGrid.step()`` and passed
319
+ directly to the three reward verifiers – neither module needs to
320
+ re-execute environment logic to calculate its score.
321
+
322
+ **Why a separate receipt model?** Each verifier reads a different subset
323
+ of fields:
324
+
325
+ * R1 (health) reads ``city_health_before`` / ``city_health_after``.
326
+ * R2 (efficiency) reads ``energy_before`` / ``energy_after`` plus the
327
+ city health delta.
328
+ * R3 (format) reads ``action_attempted`` and ``action_parsed``
329
+ (``None`` means parse failed β†’ score 0.0).
330
+
331
+ Passing a single rich object avoids fragile argument lists and keeps
332
+ verifier signatures stable as the environment evolves.
333
+
334
+ This model is **never serialised into the LLM prompt**. It is used
335
+ exclusively within the Python training loop.
336
+
337
+ Fields
338
+ ------
339
+ action_attempted : str
340
+ The raw string the LLM emitted (or the trainer injected for synthetic
341
+ rollouts). Preserved verbatim so R3 can detect subtle schema
342
+ violations even when Pydantic parsing partially succeeds.
343
+ action_parsed : AgentAction | None
344
+ The validated ``AgentAction`` object if ``action_attempted`` was valid
345
+ JSON conforming to the schema; ``None`` otherwise. ``None`` is a
346
+ direct penalty signal for R3.
347
+ energy_before : int
348
+ Agent energy at the *start* of this step, before the action was
349
+ applied. Used by R2 to compute energy expenditure.
350
+ energy_after : int
351
+ Agent energy at the *end* of this step, after the action (and any
352
+ recharge) was applied. R2 uses ``energy_before - energy_after`` as
353
+ the cost denominator.
354
+ city_health_before : float
355
+ Average city health across all 25 sectors *before* this step's
356
+ entropy tick and repair. R1 and R2 use this as the baseline.
357
+ city_health_after : float
358
+ Average city health *after* entropy and the agent's action.
359
+ ``city_health_after - city_health_before`` is the net health delta
360
+ for this step; positive values indicate the agent added more value
361
+ than entropy removed.
362
+ is_error : bool
363
+ ``True`` when the action could not be executed for any reason:
364
+ invalid JSON, invalid ``ActionType``, illegal move (e.g. MOVE_N from
365
+ row 0), or RECHARGE attempted outside sector 12. An error step is a
366
+ no-op for the environment but still incurs entropy.
367
+ error_message : str
368
+ Human-readable description of the error. Empty string when
369
+ ``is_error`` is ``False``. Surfaced in training logs and unit tests
370
+ to aid debugging without re-running the full episode.
371
+ """
372
+
373
+ action_attempted: str = Field(
374
+ ...,
375
+ description=(
376
+ "Raw LLM output string, preserved verbatim for R3 format scoring "
377
+ "and debugging. Never parsed or executed after this field is set."
378
+ ),
379
+ )
380
+ action_parsed: Optional[AgentAction] = Field(
381
+ default=None,
382
+ description=(
383
+ "Validated AgentAction if action_attempted was schema-compliant; "
384
+ "None if JSON parsing or Pydantic validation failed. "
385
+ "None is the primary R3 penalty signal."
386
+ ),
387
+ )
388
+ energy_before: int = Field(
389
+ ...,
390
+ ge=0,
391
+ le=100,
392
+ description="Agent energy at step start, used as R2 cost baseline.",
393
+ )
394
+ energy_after: int = Field(
395
+ ...,
396
+ ge=0,
397
+ le=100,
398
+ description="Agent energy at step end, after action execution.",
399
+ )
400
+ city_health_before: float = Field(
401
+ ...,
402
+ ge=0.0,
403
+ le=100.0,
404
+ description="Mean sector health before this step's entropy + repair.",
405
+ )
406
+ city_health_after: float = Field(
407
+ ...,
408
+ ge=0.0,
409
+ le=100.0,
410
+ description="Mean sector health after this step's entropy + repair.",
411
+ )
412
+ is_error: bool = Field(
413
+ default=False,
414
+ description=(
415
+ "True when the action was invalid or illegal. "
416
+ "The environment applies entropy but skips action execution."
417
+ ),
418
+ )
419
+ error_message: str = Field(
420
+ default="",
421
+ description=(
422
+ "Diagnostic message when is_error is True. "
423
+ "Empty string on successful steps."
424
+ ),
425
+ )
426
+
427
+ @model_validator(mode="after")
428
+ def _error_message_consistency(self) -> "StepResult":
429
+ """
430
+ Enforce that ``error_message`` is non-empty iff ``is_error`` is True.
431
+
432
+ This prevents silent failures where an error condition is set but the
433
+ message is accidentally cleared (or vice-versa), which would make
434
+ training logs misleading.
435
+ """
436
+ if self.is_error and not self.error_message:
437
+ raise ValueError(
438
+ "is_error is True but error_message is empty. "
439
+ "Provide a diagnostic string so training logs are actionable."
440
+ )
441
+ if not self.is_error and self.error_message:
442
+ raise ValueError(
443
+ "error_message is set but is_error is False. "
444
+ "Either set is_error=True or clear error_message."
445
+ )
446
+ return self
src/disaster_grid/rewards.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/disaster_grid/rewards.py
3
+ =============================
4
+ Three independent reward verifiers for the disaster_grid GRPO training loop.
5
+
6
+ Design philosophy β€” why three verifiers?
7
+ -----------------------------------------
8
+ A single monolithic reward function is trivially hackable: the agent learns
9
+ whatever shortcut maximises the scalar without learning the intended behaviour.
10
+ Splitting the reward across three *orthogonal* verifiers forces the agent to
11
+ satisfy all three constraints simultaneously, which is exponentially harder to
12
+ game than any one of them in isolation.
13
+
14
+ * **R1 (health)** β€” *did the city actually get better?* Anchors the signal
15
+ to observable world outcomes. An agent cannot inflate R1 by taking busy
16
+ actions that look purposeful but leave entropy unchecked.
17
+ * **R2 (efficiency)** β€” *was the improvement worth the energy cost?* Prevents
18
+ the "lucky repair" failure mode where the agent stumbles onto a broken sector
19
+ after 30 wasted moves and still earns a high health delta.
20
+ * **R3 (format)** β€” *did the agent communicate in the expected schema?* Acts
21
+ as a hard prerequisite: an agent that cannot reliably emit valid JSON cannot
22
+ earn positive rewards from R1 or R2, because its actions never execute. The
23
+ harsh ``-2.0`` penalty (larger in magnitude than any single-step R1 or R2
24
+ gain) ensures that schema compliance is always the dominant priority during
25
+ early GRPO training.
26
+
27
+ The three verifiers are kept as module-level functions rather than methods on a
28
+ class so that the GRPO trainer can compose or replace individual verifiers
29
+ without subclassing β€” a critical property for rapid hackathon iteration.
30
+
31
+ Reward scale reference (approximate single-step ranges)
32
+ ---------------------------------------------------------
33
+ +-----------+----------------------------------------------+-------------------+
34
+ | Verifier | Scenario | Score |
35
+ +===========+==============================================+===================+
36
+ | R1 health | Perfect repair, no entropy hits repaired sec | β‰ˆ +1.0 |
37
+ | | Entropy hits two unrepaired sectors | β‰ˆ -0.4 |
38
+ | | Net-zero (repair cancelled by entropy) | 0.0 |
39
+ +-----------+----------------------------------------------+-------------------+
40
+ | R2 eff. | Repair after direct 1-move approach | β‰ˆ +1.47 |
41
+ | | Repair after 10 wasted moves | β‰ˆ +0.21 |
42
+ | | Recharge / WAIT / error (no energy spent) | 0.0 |
43
+ +-----------+----------------------------------------------+-------------------+
44
+ | R3 format | Valid JSON, valid ActionType | +1.0 |
45
+ | | Malformed JSON or hallucinated action key | -2.0 |
46
+ +-----------+----------------------------------------------+-------------------+
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ from typing import Any
52
+
53
+ from .models import StepResult
54
+
55
+ # ── Verifier weights (hackathon tuning dials) ─────────────────────────────────
56
+ #
57
+ # Adjust these at the top of the file rather than hunting through the
58
+ # ``compute_reward`` body. Standard starting point for GRPO on this task:
59
+ #
60
+ # w_health = 1.0 β€” primary objective signal
61
+ # w_efficiency = 0.5 β€” secondary shaping signal (half weight to avoid
62
+ # over-penalising legitimate recharge trips)
63
+ # w_format = 1.0 β€” schema compliance; equal weight to health because
64
+ # an agent that cannot communicate cannot act
65
+ #
66
+ _W_HEALTH: float = 1.0
67
+ _W_EFFICIENCY: float = 0.5
68
+ _W_FORMAT: float = 1.0
69
+
70
+ # ── R2 scaling constant ───────────────────────────────────────────────────────
71
+ #
72
+ # ``city_health_after`` and ``city_health_before`` are *averages* over 25
73
+ # sectors (range 0–100). Multiplying the positive delta by 25 converts the
74
+ # average improvement back into a total health-points figure, making the
75
+ # numerator and denominator of the efficiency ratio dimensionally consistent:
76
+ # numerator β†’ total health points gained (0–25 per REPAIR in isolation)
77
+ # denominator β†’ energy points spent (1–100 per step)
78
+ #
79
+ _HEALTH_SCALE: float = 25.0
80
+
81
+
82
+ # ─────────────────────────────────────────────────────────────────────────────
83
+ # Verifier 1 β€” Objective (Health)
84
+ # ─────────────────────────────────────────────────────────────────────────────
85
+
86
+
87
+ def get_health_reward(step_result: StepResult) -> float:
88
+ """
89
+ Measure whether the city is objectively healthier after this step.
90
+
91
+ The reward is the raw delta of average city health across all 25 sectors::
92
+
93
+ R1 = city_health_after βˆ’ city_health_before
94
+
95
+ Range
96
+ -----
97
+ * Positive (β‰ˆ 0.0 to +1.0) when a REPAIR outweighs entropy.
98
+ * Negative (β‰ˆ -0.4 per step) when the agent does nothing and entropy
99
+ degrades two sectors by 5 points each (net average loss β‰ˆ 0.4).
100
+ * Near-zero when the agent's repair precisely cancels entropy damage.
101
+
102
+ Why this alone is insufficient (reward-hacking risk)
103
+ -----------------------------------------------------
104
+ R1 does not care *how* the health gain was achieved. A lucky agent could
105
+ wander randomly, occasionally stumble onto a critical sector, issue a
106
+ REPAIR, and earn a positive R1 without ever demonstrating strategic
107
+ routing. R2 closes this gap by penalising the energy cost of reaching
108
+ the repaired sector.
109
+
110
+ Parameters
111
+ ----------
112
+ step_result : StepResult
113
+ The verifier receipt produced by ``CityGrid.step()``.
114
+
115
+ Returns
116
+ -------
117
+ float
118
+ Signed health delta. Negative values are valid and expected on steps
119
+ where the agent recharged, moved without repairing, or issued an
120
+ invalid action.
121
+ """
122
+ return step_result.city_health_after - step_result.city_health_before
123
+
124
+
125
+ # ─────────────────────────────────────────────────────────────────────────────
126
+ # Verifier 2 β€” Strategy (Efficiency)
127
+ # ─────────────────────────────────────────────────────────────────────────────
128
+
129
+
130
+ def get_efficiency_reward(step_result: StepResult) -> float:
131
+ """
132
+ Measure whether the health improvement was worth its energy cost.
133
+
134
+ The reward is the ratio of total health points gained to energy points
135
+ spent::
136
+
137
+ health_gained = max(0, city_health_after βˆ’ city_health_before) Γ— 25
138
+ energy_spent = energy_before βˆ’ energy_after
139
+ R2 = health_gained / energy_spent (if energy_spent > 0)
140
+ = 0.0 (otherwise)
141
+
142
+ Why multiply the health delta by 25?
143
+ -------------------------------------
144
+ ``city_health_before`` and ``city_health_after`` are *averages* over 25
145
+ sectors (domain [0, 100]). Multiplying the positive delta by 25 converts
146
+ the mean improvement back into a total health-points figure so the
147
+ numerator and denominator are dimensionally comparable:
148
+
149
+ * Numerator: total health points added this step (max β‰ˆ 25 per REPAIR)
150
+ * Denominator: energy points consumed this step (1 for WAIT, 15 for
151
+ REPAIR, 2 per MOVE, etc.)
152
+
153
+ Edge cases
154
+ ----------
155
+ ``energy_spent <= 0`` occurs on three legitimate step types:
156
+
157
+ 1. **RECHARGE at sector 12** – energy increased; ``energy_after >
158
+ energy_before``. No efficiency score is appropriate because the agent
159
+ gained a resource rather than spending one.
160
+ 2. **Parse error** – no energy was deducted; the step was a no-op.
161
+ Penalising efficiency here would double-penalise the agent on top of
162
+ R3's format penalty.
163
+ 3. **Energy already at 0** – the episode is terminating; the ratio would
164
+ be undefined.
165
+
166
+ In all three cases the function returns ``0.0`` (neutral) rather than an
167
+ error, keeping R3 as the sole penalty signal for errors and R1 as the
168
+ health signal for recharge steps.
169
+
170
+ Why this prevents the "lucky repair" failure mode
171
+ --------------------------------------------------
172
+ An agent that spends 20 energy units on ``MOVE`` actions to reach a
173
+ single critical sector and then REPAIRs it earns roughly
174
+ ``(+25 health) / (20 + 15 energy) β‰ˆ 0.71`` in R2 β€” significantly lower
175
+ than an agent that routes directly (2 moves + REPAIR):
176
+ ``(+25 health) / (4 + 15 energy) β‰ˆ 1.32``. GRPO will push the policy
177
+ toward the direct-routing strategy even if both agents produce the same
178
+ R1 health delta.
179
+
180
+ Parameters
181
+ ----------
182
+ step_result : StepResult
183
+ The verifier receipt produced by ``CityGrid.step()``.
184
+
185
+ Returns
186
+ -------
187
+ float
188
+ Non-negative efficiency ratio, or ``0.0`` when no energy was spent.
189
+ """
190
+ health_gained: float = (
191
+ max(0.0, step_result.city_health_after - step_result.city_health_before)
192
+ * _HEALTH_SCALE
193
+ )
194
+ energy_spent: int = step_result.energy_before - step_result.energy_after
195
+
196
+ if energy_spent <= 0:
197
+ return 0.0
198
+
199
+ # Prevent the agent from getting a massive score multiplier by repairing
200
+ # with less than 15 energy remaining right before the episode terminates.
201
+ if health_gained > 0 and energy_spent < 15:
202
+ energy_spent = 15
203
+
204
+ return health_gained / energy_spent
205
+
206
+ # ─────────────────────────────────────────────────────────────────────────────
207
+ # Verifier 3 β€” Guardrail (Format & Syntax)
208
+ # ─────────────────────────────────────────────────────────────────────────────
209
+
210
+
211
+ def get_format_reward(step_result: StepResult) -> float:
212
+ """
213
+ Enforce strict adherence to the ``AgentAction`` JSON schema.
214
+
215
+ The binary reward is asymmetric by design::
216
+
217
+ R3 = +1.0 if action_parsed is not None AND is_error is False
218
+ = -2.0 otherwise
219
+
220
+ Why ``-2.0`` rather than ``0.0`` for a failed parse?
221
+ ------------------------------------------------------
222
+ A neutral penalty (``0.0``) would allow the agent to achieve a positive
223
+ total reward purely from occasional lucky health improvements (R1) while
224
+ ignoring the schema entirely. Setting the failure penalty to ``-2.0``
225
+ (larger in magnitude than the maximum single-step R1 gain of β‰ˆ +1.0)
226
+ ensures that schema non-compliance *always* produces a negative total
227
+ reward, even when entropy happens not to degrade any sector on that step.
228
+ This makes format compliance the lexicographically dominant priority
229
+ during early GRPO training, before the policy has learned to generate
230
+ valid JSON reliably.
231
+
232
+ What counts as a format failure?
233
+ ---------------------------------
234
+ * ``is_error=True``: the environment flagged this step as invalid. This
235
+ covers malformed JSON, unrecognised ``ActionType`` strings (e.g.
236
+ ``"MOVE_NE"``), missing required fields (``action`` or ``reasoning``),
237
+ and ``reasoning`` strings shorter than 10 characters.
238
+ * ``action_parsed is None``: parsing raised an exception that set
239
+ ``action_parsed`` to ``None`` in the ``StepResult``. This is redundant
240
+ with ``is_error`` for parse failures but provides a belt-and-suspenders
241
+ check for any future code path that sets ``action_parsed=None`` without
242
+ explicitly setting ``is_error=True``.
243
+
244
+ Note: RECHARGE-outside-sector-0 sets ``is_error=True`` even though the
245
+ JSON was syntactically valid. This is intentional β€” the agent is
246
+ penalised for spatial reasoning errors that produce semantically invalid
247
+ actions, not just syntactic ones.
248
+
249
+ Parameters
250
+ ----------
251
+ step_result : StepResult
252
+ The verifier receipt produced by ``CityGrid.step()``.
253
+
254
+ Returns
255
+ -------
256
+ float
257
+ ``+1.0`` for schema-compliant steps, ``-2.0`` for any violation.
258
+ """
259
+ # Use local bindings so static analyzers always see a concrete symbol flow.
260
+ is_error = step_result.is_error
261
+ parsed_action = step_result.action_parsed
262
+ if is_error or parsed_action is None:
263
+ return -2.0
264
+ return 1.0
265
+
266
+
267
+ # ─────────────────────────────────────────────────────────────────────────────
268
+ # Master verifier β€” Aggregation
269
+ # ─────────────────────────────────────────────────────────────────────────────
270
+
271
+
272
+ def compute_reward(info: dict[str, Any]) -> float:
273
+ """
274
+ Aggregate the three independent verifier scores into a single GRPO reward.
275
+
276
+ This is the only function called by ``grpo_trainer.py``. It extracts the
277
+ ``StepResult`` receipt from the environment's ``info`` dict, calls the
278
+ three verifiers, and returns the weighted sum::
279
+
280
+ reward = w_health Γ— R1(step_result)
281
+ + w_efficiency Γ— R2(step_result)
282
+ + w_format Γ— R3(step_result)
283
+
284
+ With the default weights ``(1.0, 0.5, 1.0)`` the approximate reward range
285
+ per step is roughly ``[βˆ’3.4, +3.7]``:
286
+
287
+ * **Best case** (direct repair, valid schema): R1 β‰ˆ +1.0, R2 β‰ˆ +1.47,
288
+ R3 = +1.0 β†’ total β‰ˆ **+3.24**.
289
+ * **Worst case** (bad JSON, heavy entropy): R1 β‰ˆ βˆ’0.4, R2 = 0.0,
290
+ R3 = βˆ’2.0 β†’ total β‰ˆ **βˆ’2.4**.
291
+
292
+ This spread gives GRPO a clear gradient signal to differentiate between
293
+ rollouts β€” a signal-to-noise ratio that would collapse if the three
294
+ verifiers were merged into a single hand-crafted formula.
295
+
296
+ Robustness contract
297
+ -------------------
298
+ * If ``"step_result"`` is absent from ``info`` (e.g. the environment
299
+ returned early due to an internal error), the function returns ``0.0``
300
+ rather than raising β€” the GRPO trainer can safely continue sampling
301
+ without crashing the batch.
302
+ * ``StepResult(**info["step_result"])`` re-validates the receipt through
303
+ Pydantic at aggregation time. If the environment somehow produced an
304
+ out-of-range value (e.g. ``energy_before=150``), this raises a
305
+ ``ValidationError`` that surfaces immediately rather than corrupting the
306
+ gradient computation silently.
307
+
308
+ Tuning guidance
309
+ ---------------
310
+ Modify ``_W_HEALTH``, ``_W_EFFICIENCY``, and ``_W_FORMAT`` at the top of
311
+ this module rather than editing this function body. Suggested schedule for
312
+ a 30-hour hackathon:
313
+
314
+ * Hours 0–10: ``w_format=2.0`` β€” prioritise schema compliance above all.
315
+ * Hours 10–20: ``w_format=1.0, w_health=1.0`` β€” balanced regime once the
316
+ model reliably emits valid JSON.
317
+ * Hours 20–30: ``w_efficiency=1.0`` β€” maximise strategic routing now that
318
+ the policy is schema-stable.
319
+
320
+ Parameters
321
+ ----------
322
+ info : dict[str, Any]
323
+ The ``info`` dictionary returned by ``CityGrid.step()``. Expected to
324
+ contain a ``"step_result"`` key whose value is a ``StepResult``
325
+ serialised by ``model_dump()``.
326
+
327
+ Returns
328
+ -------
329
+ float
330
+ Weighted sum of R1, R2, and R3. Returns ``0.0`` if ``"step_result"``
331
+ is absent from ``info``.
332
+ """
333
+ if "step_result" not in info:
334
+ return 0.0
335
+
336
+ step_result = StepResult(**info["step_result"])
337
+ r1: float = get_health_reward(step_result)
338
+ r2: float = get_efficiency_reward(step_result)
339
+ r3: float = get_format_reward(step_result)
340
+
341
+ return (_W_HEALTH * r1) + (_W_EFFICIENCY * r2) + (_W_FORMAT * r3)
src/disaster_grid/utils.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/disaster_grid/utils.py
3
+ ===========================
4
+ Debug-focused terminal UI and emoji-grid visualiser for manual playtesting
5
+ of the ``CityGrid`` environment.
6
+
7
+ Running this module directly launches an interactive session::
8
+
9
+ python -m src.disaster_grid.utils
10
+
11
+ Controls
12
+ --------
13
+ +-------+------------+-----------------------------------------+
14
+ | Key | Action | Notes |
15
+ +=======+============+=========================================+
16
+ | w | MOVE_N | Move agent one row upward |
17
+ | s | MOVE_S | Move agent one row downward |
18
+ | d | MOVE_E | Move agent one column right |
19
+ | a | MOVE_W | Move agent one column left |
20
+ | r | REPAIR | Repair current sector (+25 health, -15) |
21
+ | c | RECHARGE | Recharge energy (+20, only at sector 12) |
22
+ | q | WAIT | Skip turn (-1 energy) |
23
+ | x | Quit | End the session immediately |
24
+ +-------+------------+-----------------------------------------+
25
+
26
+ Debug receipt
27
+ -------------
28
+ After every step the terminal prints the raw ``StepResult`` fields so the
29
+ developer can immediately diagnose Pydantic validation failures, wall
30
+ collisions, or wrong-location RECHARGE attempts without reading log files.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import sys
37
+ from typing import Any
38
+
39
+ from .environment import CityGrid
40
+ from .models import ActionType
41
+
42
+ # ── Display constants ─────────────────────────────────────────────────────────
43
+
44
+ _EMOJI_AGENT: str = "πŸ€–"
45
+ _EMOJI_BASE: str = "🏒"
46
+ _EMOJI_FIRE: str = "πŸ”₯"
47
+ _EMOJI_HEALTHY: str = "🟩"
48
+ _CRITICAL_THRESHOLD: int = 30
49
+
50
+ _SEPARATOR_THICK: str = "═" * 52
51
+ _SEPARATOR_THIN: str = "─" * 52
52
+
53
+ # ── Key β†’ ActionType mapping ──────────────────────────────────────────────────
54
+
55
+ _KEY_MAP: dict[str, str] = {
56
+ "w": ActionType.MOVE_N.value,
57
+ "s": ActionType.MOVE_S.value,
58
+ "d": ActionType.MOVE_E.value,
59
+ "a": ActionType.MOVE_W.value,
60
+ "r": ActionType.REPAIR.value,
61
+ "c": ActionType.RECHARGE.value,
62
+ "q": ActionType.WAIT.value,
63
+ }
64
+
65
+ _QUIT_KEY: str = "x"
66
+
67
+
68
+ # ── Terminal helpers ──────────────────────────────────────────────────────────
69
+
70
+
71
+ def _clear() -> None:
72
+ """Clear the terminal screen on both Windows and POSIX systems."""
73
+ os.system("cls" if os.name == "nt" else "clear")
74
+
75
+
76
+ def _header(text: str) -> str:
77
+ """Return a centred header line padded to the separator width."""
78
+ return text.center(52)
79
+
80
+
81
+ # ── Grid renderer ─────────────────────────────────────────────────────────────
82
+
83
+
84
+ def render_grid(env: CityGrid) -> str:
85
+ """
86
+ Render the 5 Γ— 5 grid as a multi-line emoji string.
87
+
88
+ Each cell is exactly one emoji wrapped in square brackets so columns
89
+ align consistently across UTF-8 terminals. The agent overrides all
90
+ other cell states β€” if the agent is standing on the base or a fire
91
+ sector the robot emoji takes precedence to avoid ambiguity during play.
92
+
93
+ Parameters
94
+ ----------
95
+ env : CityGrid
96
+ A live environment instance. Reads ``env.agent_pos`` and
97
+ ``env.grid_health`` directly.
98
+
99
+ Returns
100
+ -------
101
+ str
102
+ A five-line string ready for ``print()``.
103
+ """
104
+ rows: list[str] = []
105
+ for row in range(5):
106
+ cells: list[str] = []
107
+ for col in range(5):
108
+ idx = row * 5 + col
109
+ if env.agent_pos == idx:
110
+ emoji = _EMOJI_AGENT
111
+ elif idx == 12:
112
+ emoji = _EMOJI_BASE
113
+ elif env.grid_health[idx] < _CRITICAL_THRESHOLD:
114
+ emoji = _EMOJI_FIRE
115
+ else:
116
+ emoji = _EMOJI_HEALTHY
117
+ cells.append(f"[{emoji}]")
118
+ rows.append(" " + " ".join(cells))
119
+ return "\n".join(rows)
120
+
121
+
122
+ # ── Health bar ────────────────────────────────────────────────────────────────
123
+
124
+
125
+ def _health_bar(value: float, width: int = 20) -> str:
126
+ """
127
+ Return a compact ASCII progress bar for a health / energy value.
128
+
129
+ Parameters
130
+ ----------
131
+ value : float
132
+ Current value in [0, 100].
133
+ width : int
134
+ Total number of bar characters (default 20).
135
+
136
+ Returns
137
+ -------
138
+ str
139
+ e.g. ``[β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆοΏ½οΏ½οΏ½β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 60.0``
140
+ """
141
+ filled = int(round((value / 100.0) * width))
142
+ bar = "β–ˆ" * filled + "β–‘" * (width - filled)
143
+ return f"[{bar}] {value:5.1f}"
144
+
145
+
146
+ # ── HUD panels ────────────────────────────────────────────────────────────────
147
+
148
+
149
+ def _render_hud(env: CityGrid, step_number: int) -> str:
150
+ """Return the agent-status panel string."""
151
+ x, y = env.agent_pos % 5, env.agent_pos // 5
152
+ avg_health = sum(env.grid_health) / 25
153
+ critical = sorted(i for i, h in enumerate(env.grid_health) if h < _CRITICAL_THRESHOLD)
154
+ critical_str = str(critical) if critical else "none πŸŽ‰"
155
+
156
+ lines = [
157
+ _SEPARATOR_THICK,
158
+ _header("πŸ™οΈ DISASTER RECOVERY GRID πŸ™οΈ"),
159
+ _SEPARATOR_THICK,
160
+ f" Step : {step_number:>3} / 50",
161
+ f" Position : sector {env.agent_pos:>2} (col={x}, row={y})",
162
+ f" Energy : {_health_bar(env.agent_energy)}",
163
+ f" City Health: {_health_bar(avg_health)}",
164
+ f" πŸ”₯ Critical: {critical_str}",
165
+ _SEPARATOR_THIN,
166
+ ]
167
+ return "\n".join(lines)
168
+
169
+
170
+ def _render_controls() -> str:
171
+ """Return the controls reference panel string."""
172
+ lines = [
173
+ _SEPARATOR_THIN,
174
+ _header("β€” CONTROLS β€”"),
175
+ " [W] Move North [S] Move South",
176
+ " [A] Move West [D] Move East",
177
+ " [R] Repair [C] Recharge (sector 12 only)",
178
+ " [Q] Wait [X] Quit",
179
+ _SEPARATOR_THIN,
180
+ ]
181
+ return "\n".join(lines)
182
+
183
+
184
+ def _render_receipt(step_result: dict[str, Any]) -> str:
185
+ """
186
+ Format the ``StepResult`` debug receipt.
187
+
188
+ All fields are printed explicitly so the developer can immediately see
189
+ whether Pydantic parsed the action, whether an error was flagged, and
190
+ the exact health delta β€” without needing to inspect raw dicts.
191
+
192
+ Parameters
193
+ ----------
194
+ step_result : dict[str, Any]
195
+ The ``info["step_result"]`` dict from the last ``env.step()`` call.
196
+
197
+ Returns
198
+ -------
199
+ str
200
+ Multi-line receipt string.
201
+ """
202
+ action_parsed = step_result.get("action_parsed")
203
+ is_error = step_result.get("is_error", False)
204
+ error_msg = step_result.get("error_message", "")
205
+ health_before = step_result.get("city_health_before", 0.0)
206
+ health_after = step_result.get("city_health_after", 0.0)
207
+ health_delta = health_after - health_before
208
+ energy_before = step_result.get("energy_before", 0)
209
+ energy_after = step_result.get("energy_after", 0)
210
+ energy_delta = energy_after - energy_before
211
+ action_attempted = step_result.get("action_attempted", "β€”")
212
+
213
+ # Colour-code the health delta with arrows
214
+ if health_delta > 0:
215
+ delta_str = f"β–² +{health_delta:.4f}"
216
+ elif health_delta < 0:
217
+ delta_str = f"β–Ό {health_delta:.4f}"
218
+ else:
219
+ delta_str = f" {health_delta:.4f}"
220
+
221
+ # Error status badge
222
+ error_badge = "❌ YES" if is_error else "βœ… NO"
223
+
224
+ # Parsed action summary
225
+ if action_parsed is None:
226
+ parsed_str = "None ← ⚠️ Pydantic validation FAILED"
227
+ else:
228
+ parsed_action_val = (
229
+ action_parsed.get("action", "?")
230
+ if isinstance(action_parsed, dict)
231
+ else str(action_parsed)
232
+ )
233
+ parsed_reasoning = (
234
+ action_parsed.get("reasoning", "")[:40]
235
+ if isinstance(action_parsed, dict)
236
+ else ""
237
+ )
238
+ parsed_str = f"{parsed_action_val!r} (reasoning: {parsed_reasoning!r})"
239
+
240
+ lines = [
241
+ _SEPARATOR_THICK,
242
+ _header("β€” STEP RECEIPT (StepResult) β€”"),
243
+ _SEPARATOR_THIN,
244
+ f" Attempted : {action_attempted}",
245
+ f" Parsed : {parsed_str}",
246
+ _SEPARATOR_THIN,
247
+ f" Error? : {error_badge}",
248
+ f" Error Msg : {error_msg if error_msg else 'β€”'}",
249
+ _SEPARATOR_THIN,
250
+ f" Energy : {energy_before:>4} β†’ {energy_after:>4} "
251
+ f"(delta: {energy_delta:+d})",
252
+ f" City Health : {health_before:>8.4f} β†’ {health_after:>8.4f} "
253
+ f"(delta: {delta_str})",
254
+ _SEPARATOR_THICK,
255
+ ]
256
+ return "\n".join(lines)
257
+
258
+
259
+ # ── Main playtest loop ────────────────────────────────────────────────────────
260
+
261
+
262
+ def play_manual(seed: int | None = None) -> None:
263
+ """
264
+ Launch an interactive manual playtest session in the terminal.
265
+
266
+ The loop renders the full game state, waits for a keypress, constructs
267
+ a valid ``AgentAction``-compatible payload, calls ``env.step()``, then
268
+ prints the ``StepResult`` debug receipt before pausing so the developer
269
+ can read the output before the screen clears.
270
+
271
+ Parameters
272
+ ----------
273
+ seed : int | None
274
+ Optional RNG seed passed to ``env.reset()``. Set to a fixed integer
275
+ for reproducible debug sessions.
276
+
277
+ Payload construction note
278
+ -------------------------
279
+ The payload is built as a plain ``dict`` (not a JSON string) because
280
+ ``CityGrid.step()`` accepts both formats. Using a dict skips the
281
+ ``json.dumps`` / ``json.loads`` round-trip and surfaces Pydantic
282
+ validation errors more directly. The ``reasoning`` value is a fixed
283
+ string; the ``action`` value is the exact ``.value`` of the enum member,
284
+ which is what ``AgentAction`` expects::
285
+
286
+ action_payload = {
287
+ "action": key_map[user_input], # e.g. "MOVE_N"
288
+ "reasoning": "Manual playtest",
289
+ }
290
+ """
291
+ env = CityGrid()
292
+ obs, _ = env.reset(seed=seed)
293
+
294
+ # ── Welcome splash ────────────────────────────────────────────────────
295
+ _clear()
296
+ print(_SEPARATOR_THICK)
297
+ print(_header("🚨 DISASTER GRID β€” MANUAL PLAYTEST 🚨"))
298
+ print(_header("debug mode Β· all StepResult fields visible"))
299
+ print(_SEPARATOR_THICK)
300
+ print()
301
+ print(" Legend:")
302
+ print(f" [{_EMOJI_AGENT}] Agent position")
303
+ print(f" [{_EMOJI_BASE}] Base / Recharge station (sector 12)")
304
+ print(f" [{_EMOJI_FIRE}] Critical sector (health < 30)")
305
+ print(f" [{_EMOJI_HEALTHY}] Healthy sector (health β‰₯ 30)")
306
+ print()
307
+ print(" Press Enter to begin...")
308
+ input()
309
+
310
+ last_receipt: dict[str, Any] = {}
311
+ episode_over: bool = False
312
+
313
+ while not episode_over:
314
+ _clear()
315
+
316
+ # ── Render HUD ────────────────────────────────────────────────────
317
+ print(_render_hud(env, env.step_count))
318
+ print()
319
+
320
+ # ── Render grid ───────────────────────────────────────────────────
321
+ print(render_grid(env))
322
+ print()
323
+
324
+ # ── Print last receipt (empty on first turn) ──────────────────────
325
+ if last_receipt:
326
+ print(_render_receipt(last_receipt))
327
+ else:
328
+ print(_SEPARATOR_THIN)
329
+ print(_header("β€” no action taken yet β€”"))
330
+ print(_SEPARATOR_THIN)
331
+
332
+ print()
333
+ print(_render_controls())
334
+ print()
335
+
336
+ # ── Prompt ────────────────────────────────────────────────────────
337
+ raw = input(" Your move β€Ί ").strip().lower()
338
+
339
+ if raw == _QUIT_KEY:
340
+ print()
341
+ print(" Exiting playtest session. Goodbye! πŸ‘‹")
342
+ sys.exit(0)
343
+
344
+ if raw not in _KEY_MAP:
345
+ print()
346
+ print(f" ⚠️ Unknown key {raw!r}. Valid keys: "
347
+ f"{sorted(_KEY_MAP)} or '{_QUIT_KEY}' to quit.")
348
+ input(" Press Enter to continue...")
349
+ continue
350
+
351
+ # ── Build the exact payload the AgentAction schema expects ────────
352
+ action_payload: dict[str, str] = {
353
+ "action": _KEY_MAP[raw],
354
+ "reasoning": "Manual playtest",
355
+ }
356
+
357
+ print()
358
+ print(f" Sending payload β†’ {action_payload}")
359
+
360
+ # ── Step the environment ──────────────────────────────────────────
361
+ try:
362
+ obs, reward, done, truncated, info = env.step(action_payload)
363
+ except Exception as exc: # noqa: BLE001
364
+ # Surface any unexpected environment crash without losing the
365
+ # session β€” developer can inspect the state and continue.
366
+ print()
367
+ print(f" 🚨 UNEXPECTED ENVIRONMENT ERROR: {type(exc).__name__}: {exc}")
368
+ input(" Press Enter to continue...")
369
+ continue
370
+
371
+ # ── Extract StepResult receipt ────────────────────────────────────
372
+ last_receipt = info.get("step_result", {})
373
+
374
+ episode_over = bool(done or truncated)
375
+
376
+ # ── Episode over screen ───────────────────────────────────────────────
377
+ _clear()
378
+ avg_health = sum(env.grid_health) / 25
379
+ print(_SEPARATOR_THICK)
380
+ print(_header("🏁 EPISODE COMPLETE 🏁"))
381
+ print(_SEPARATOR_THICK)
382
+ print(f" Steps taken : {env.step_count}")
383
+ print(f" Final energy : {env.agent_energy}")
384
+ print(f" Final avg health: {avg_health:.2f} / 100.00")
385
+ print()
386
+ if avg_health >= 70:
387
+ print(_header("βœ… CITY SAVED β€” excellent management!"))
388
+ elif avg_health >= 40:
389
+ print(_header("⚠️ CITY DAMAGED β€” recovery possible."))
390
+ else:
391
+ print(_header("πŸ’€ CITY LOST β€” better luck next time."))
392
+ print(_SEPARATOR_THICK)
393
+
394
+ # ── Final receipt ─────────────────────────────────────────────────────
395
+ if last_receipt:
396
+ print()
397
+ print(_render_receipt(last_receipt))
398
+
399
+ print()
400
+ input(" Press Enter to exit...")
401
+
402
+
403
+ if __name__ == "__main__":
404
+ play_manual()