Spaces:
Sleeping
Sleeping
Upload 18 files
Browse files- Dockerfile +0 -0
- app.py +0 -0
- pyproject.toml +25 -0
- src/disaster_grid.egg-info/PKG-INFO +10 -0
- src/disaster_grid.egg-info/SOURCES.txt +12 -0
- src/disaster_grid.egg-info/dependency_links.txt +1 -0
- src/disaster_grid.egg-info/requires.txt +8 -0
- src/disaster_grid.egg-info/top_level.txt +1 -0
- src/disaster_grid/__init__.py +0 -0
- src/disaster_grid/__pycache__/__init__.cpython-314.pyc +0 -0
- src/disaster_grid/__pycache__/environment.cpython-314.pyc +0 -0
- src/disaster_grid/__pycache__/models.cpython-314.pyc +0 -0
- src/disaster_grid/__pycache__/rewards.cpython-314.pyc +0 -0
- src/disaster_grid/__pycache__/utils.cpython-314.pyc +0 -0
- src/disaster_grid/environment.py +522 -0
- src/disaster_grid/models.py +446 -0
- src/disaster_grid/rewards.py +341 -0
- src/disaster_grid/utils.py +404 -0
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()
|