Eshit commited on
Commit
363abf3
·
0 Parent(s):

Deploy to HF Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.gitattributes ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.pdf filter=lfs diff=lfs merge=lfs -text
37
+ *.gif filter=lfs diff=lfs merge=lfs -text
.github/workflows/ci.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python 3.10
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.10"
20
+
21
+ - name: Cache pip dependencies
22
+ uses: actions/cache@v4
23
+ with:
24
+ path: ~/.cache/pip
25
+ key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
26
+ restore-keys: |
27
+ ${{ runner.os }}-pip-
28
+
29
+ - name: Install dependencies
30
+ run: |
31
+ pip install -r requirements.txt
32
+ pip install -e .
33
+
34
+ - name: Run tests
35
+ run: pytest tests/ -v --cov=env --cov-report=term
.gitignore ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ .eggs/
7
+ build/
8
+ dist/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # Test, coverage, and tooling caches
15
+ .pytest_cache*/
16
+ .pytest_tmp/
17
+ pytest-cache-files-*/
18
+ .ruff_cache/
19
+ .mypy_cache/
20
+ .coverage
21
+ coverage.xml
22
+ htmlcov/
23
+
24
+ # Local environment/config
25
+ .env
26
+ .env.*
27
+ !.env.example
28
+ *.local
29
+ .claude/settings.local.json
30
+
31
+ # Jupyter notebooks
32
+ .ipynb_checkpoints/
33
+
34
+ # Frontend dependencies/build output
35
+ node_modules/
36
+ frontend/node_modules/
37
+ frontend/dist/
38
+ frontend/build/
39
+
40
+ # Generated outputs
41
+ results/
42
+ outputs/
43
+ runs/
44
+ wandb/
45
+ mlruns/
46
+
47
+ # Model checkpoints and training artifacts
48
+ checkpoints/
49
+ checkpoint*/
50
+ checkpints*/
51
+ *.pt
52
+ *.pth
53
+ *.ckpt
54
+ *.safetensors
55
+ *.bin
56
+
57
+ # Generated media, but keep curated demo assets trackable
58
+ *.mp4
59
+ *.mov
60
+ *.webm
61
+ *.avi
62
+ !demos/*.gif
63
+ !demos/*.png
64
+
65
+ # OS/editor noise
66
+ .DS_Store
67
+ Thumbs.db
68
+ desktop.ini
69
+ .idea/
70
+ .vscode/
AGENTS.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Repository Guidelines
2
+
3
+ ## Project Structure & Module Organization
4
+ Core simulation code lives in `env/`, including fire spread, weather, rewards, rendering, serialization, and the main `WildfireEnv`. Baseline agents are in `agents/`, difficulty graders in `graders/`, and HTTP serving code in `server/` with the entrypoint at `server/app.py`. Utility scripts such as evaluation, replay, and demo generation live in `scripts/`. Tests are centralized in `tests/`, training material is under `training/`, and generated media belongs in `demos/`.
5
+
6
+ ## Build, Test, and Development Commands
7
+ Install dependencies with `uv pip install -r requirements.txt` and `uv pip install -e .`. Run the test suite with `pytest tests -v` or include coverage via `pytest tests -v --cov=env`. Start the local API with `python app.py` or `python -m server.app`; both serve FastAPI on port `7860`. Common workflows:
8
+
9
+ - `python scripts/evaluate.py 5` runs baseline evaluation across tiers.
10
+ - `python scripts/eval_compare.py --seeds 42 43 44 --tiers medium hard --agents random heuristic` compares agents.
11
+ - `python scripts/run_demo.py` generates the demo GIF.
12
+ - `python scripts/replay.py --tier medium --seed 42 --agent heuristic --output demos/replay.gif` replays one episode.
13
+
14
+ ## Coding Style & Naming Conventions
15
+ Follow existing Python style: 4-space indentation, `snake_case` for functions/modules, `PascalCase` for Pydantic models and classes, and descriptive enum names such as `ActionType.DEPLOY_CREW`. Keep validation close to models in `env/models.py` and environment execution logic in `env/wildfire_env.py`. No formatter config is checked in, so preserve the surrounding style and keep imports straightforward.
16
+
17
+ ## Testing Guidelines
18
+ Use `pytest`; test discovery is configured in `pyproject.toml` to read from `tests/`. Name files `test_<feature>.py` and add focused cases near related coverage, for example parser changes in `tests/test_action_parser.py`. For new actions or tiers, add both behavioral tests and at least one regression test for invalid or edge-case inputs.
19
+
20
+ ## Commit & Pull Request Guidelines
21
+ This workspace does not include `.git`, so repository history is not available for direct inspection. Use short, imperative commit subjects such as `Add hard-tier recon regression tests`. In pull requests, include a concise summary, list affected modules, note test commands run, and attach screenshots or GIFs when changing rendering, replay, or demo output.
22
+
23
+ ## Configuration & Contribution Notes
24
+ Update `openenv.yaml` when adding tasks, and keep grader/task IDs aligned with `WildfireEnv.TIER_MAP`. When adding a new action, update `env/models.py`, `env/wildfire_env.py`, `env/action_parser.py`, and the corresponding tests together to avoid contract drift.
CLAUDE.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ pip install -r requirements.txt
10
+ pip install -e ".[dev]" # editable mode with test deps
11
+
12
+ # Run tests
13
+ pytest # all tests
14
+ pytest tests/test_graders.py # single test file
15
+ pytest -k "test_reward" # tests matching a pattern
16
+
17
+ # Run baseline evaluation (both agents, all 3 tiers, default 5 runs)
18
+ python scripts/evaluate.py [num_runs]
19
+
20
+ # Compare evaluation results against saved baselines
21
+ python scripts/eval_compare.py
22
+
23
+ # Start the REST API server on port 7860
24
+ python server/app.py
25
+ serve # via pyproject.toml entry point
26
+
27
+ # Docker
28
+ docker build -t wildfire-sim .
29
+ docker run -p 7860:7860 wildfire-sim
30
+ ```
31
+
32
+ Validate environment changes by running `scripts/evaluate.py` and comparing scores against `scripts/results.json` baselines. The `HeuristicAgent` score is the primary reference for difficulty scaling.
33
+
34
+ ## Architecture
35
+
36
+ The simulator is an OpenEnv-compliant RL environment where AI agents dispatch firefighting resources on a grid to protect populated zones from wildfire.
37
+
38
+ **Core environment** (`env/`): Components orchestrated by `wildfire_env.py`:
39
+ - `wildfire_env.py` — Main entry point implementing OpenEnv API (`reset`, `step`, `state`). Manages the 11-step tick sequence, action validation (invalid actions return penalty reward, never crash), and event logging.
40
+ - `models.py` — All Pydantic schemas: `Action`, `Observation`, `StepResult`, `TierConfig`. The three `TierConfig` instances (easy/medium/hard) define grid size, resource counts, episode length, and reward weights.
41
+ - `grid.py` — Terrain generation (elevation, fuel types, water, populated zones), cell state management, smoke propagation, fog-of-war.
42
+ - `fire_spread.py` — Rothermel-inspired cellular automaton. Each burning cell ignites 8 Moore-neighborhood cells based on: `P(ignite) = base_rate × fuel × wind × slope × (1 − moisture) × (1 − suppression) × tier_scale`. Tier scale: easy=1.0, medium=0.7, hard=0.55.
43
+ - `weather.py` — Stochastic wind (random walk + shift events), sinusoidal humidity cycle, Poisson rain events.
44
+ - `resources.py` — Crew deployment/movement (adjacent cells only), tanker drops (5-step cooldown), firebreak construction, recon budget tracking.
45
+ - `reward.py` — Weighted composite of 5 components: containment, population safety, efficiency, speed, area saved. Also computes per-step delta rewards and a terminal reward on episode end.
46
+ - `briefing.py` — Generates a structured `OperationalBriefing` on `reset()`, attached to the first `Observation`. Provides incident cause, priority zones, infrastructure labels, and wind forecast for LLM context.
47
+ - `serialization.py` — Converts an `Observation` into a structured text prompt for LLM agents via `serialize_observation(obs, step_num, max_steps)`.
48
+ - `action_parser.py` — 3-layer LLM output → `Action` parser: direct JSON → regex field extraction → safe IDLE fallback.
49
+ - `curriculum.py` — `CurriculumController` for auto-promoting agents across tiers based on a rolling 10-episode average reward.
50
+ - `rendering.py` — Renders ground-truth state dicts into RGB frames for episode replay GIFs.
51
+
52
+ **Agents** (`agents/`): `RandomAgent` (lower-bound baseline) and `HeuristicAgent` (priority-based: evacuate endangered crews → protect population → air support → contain perimeter → recon → idle). New agents implement `act(obs: Observation) -> Action`.
53
+
54
+ **Graders** (`graders/`): `grade(agent, seed=42) -> float` for each tier. Called by `scripts/evaluate.py` to benchmark.
55
+
56
+ **Server** (`server/app.py`): FastAPI wrapping a singleton `WildfireEnv`. Endpoints: `POST /reset?task_id=easy&seed=42`, `POST /step` (Action JSON body), `GET /state`, `GET /health`.
57
+
58
+ **LLM inference** (`inference.py`): Runs an OpenAI-compatible client against the three tasks. Requires env vars `HF_TOKEN`, `API_BASE_URL`, `MODEL_NAME`.
59
+
60
+ **Scripts** (`scripts/`): `evaluate.py` (benchmark), `eval_compare.py` (diff vs baselines), `replay.py` (GIF generation), `plot_dashboard.py` (metrics visualization), `find_demo_seed.py` (search for visually interesting seeds), `run_demo.py`.
61
+
62
+ ## Key Conventions
63
+
64
+ - All external data uses Pydantic models — never bypass validation at the `env/` boundary.
65
+ - Invalid actions return a penalty reward and continue the episode; they never raise exceptions.
66
+ - All env components use the 8-cell Moore neighborhood consistently.
67
+ - `reset(task_id, seed)` must be fully deterministic — use `np.random.default_rng(seed)` and pass the RNG down to all components.
68
+ - Agents must not access `state()` (ground truth) during normal execution — only the `Observation` returned by `reset`/`step`.
69
+ - Hard tier enables staggered ignition (a third fire spawns mid-episode) and crew loss events; both are configured via `TierConfig` fields.
CONTRIBUTING.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ ## Adding a new tier
4
+
5
+ 1. Define a new `TierConfig` instance in `env/models.py` (follow the pattern of `TIER_EASY/MEDIUM/HARD`).
6
+ 2. Register it in `WildfireEnv.TIER_MAP` in `env/wildfire_env.py`.
7
+ 3. Add a grader in `graders/grader_<name>.py` returning `(total_reward, details_dict)`.
8
+ 4. Add the task to `openenv.yaml` under `tasks:`.
9
+
10
+ ## Adding a new action type
11
+
12
+ 1. Add the enum value to `ActionType` in `env/models.py`.
13
+ 2. Add parameter validation to `Action.validate_params()` in the same file.
14
+ 3. Handle the new action in `WildfireEnv._execute_action()` in `env/wildfire_env.py`.
15
+ 4. Add regex extraction for the new type in `env/action_parser.py` Layer 2.
16
+ 5. Add at least one test in `tests/test_action_parser.py`.
17
+
18
+ ## Where tests live
19
+
20
+ All tests are in `tests/`. Run with:
21
+
22
+ ```bash
23
+ pytest tests/ -v --cov=env
24
+ ```
25
+
26
+ Each prompt has a corresponding test file (e.g. `test_reward.py`, `test_briefing.py`). Add new tests to the relevant file or create a new one if the feature is standalone.
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy project
10
+ COPY . .
11
+
12
+ # Expose port for HF Spaces
13
+ EXPOSE 7860
14
+
15
+ # Start the OpenEnv HTTP server on port 7860
16
+ CMD ["python", "server/app.py"]
HACKATHON_ALIGNMENT.md ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hackathon Alignment — Wildfire Containment Simulator
2
+
3
+ This document walks through every topic in the organizers' **Hackathon Self-Serve Guide** PDF and describes, topic by topic, what our project currently does, where the gaps are, and concrete changes that would strengthen our submission. It is written to be directly actionable during the final sprint — each "Approach" passage reflects the code on disk today (not aspiration), and each "Potential issues / improvements" list points at specific files, specific lines of behavior, and specific hackathon judging criteria.
4
+
5
+ Stack reminder (from `pyproject.toml`, `training/grpo_colab.ipynb`, `server/app.py`):
6
+
7
+ - **Environment:** OpenEnv-style `WildfireEnv` in `env/wildfire_env.py` with Pydantic-typed `Action`/`Observation`/`StepResult`.
8
+ - **Trainer:** TRL `GRPOTrainer` + Unsloth 4-bit LoRA on `unsloth/Qwen2.5-1.5B-Instruct`, 50 GRPO steps, 8 generations per prompt.
9
+ - **Deployment:** FastAPI at port 7860 (`server/app.py`), Dockerized, deployable as a Hugging Face Space.
10
+ - **Baselines:** `RandomAgent` and `HeuristicAgent` in `agents/`, scored by `graders/grader_{easy,medium,hard}.py`.
11
+
12
+ ---
13
+
14
+ ## 0) What you are building
15
+
16
+ ### Approach
17
+ We are building exactly the system the guide describes end-to-end: an OpenEnv-compliant RL environment (`env/`) with verifier/reward functions (`env/reward.py`, graders), a TRL `GRPOTrainer` loop (`training/grpo_colab.ipynb`), Unsloth 4-bit quantization + LoRA for efficiency, and a FastAPI/Docker deployment suitable for a Hugging Face Space (`server/app.py`, `Dockerfile`, `openenv.yaml`). The task is a long-horizon disaster-response decision problem — an LLM acts as an Incident Commander dispatching crews, tankers, firebreaks and recon flights over 80–300 steps per episode. Every piece in the "Environment → verifier → TRL → Unsloth → OpenEnv/Spaces" pipeline the PDF specifies has a real implementation in this repo.
18
+
19
+ ### Potential issues / improvements
20
+ - **Pipeline is technically complete but not yet empirically closed.** `README.md` still has `{TBD}` placeholders for the trained-model numbers, and `scripts/results.json` only contains random/heuristic baselines. Judges will discount a project whose headline claim (trained LLM beats heuristic) is not demonstrated. Highest-leverage action: run the training notebook's Section 6 evaluation, paste the numbers into README, and back them with `demos/` GIFs.
21
+ - **No "value model is verifier" narrative is explicit in the repo.** Write a one-paragraph "Why GRPO + RLVR fits this env" blurb into the README so the judges can see, in 30 seconds, that we understood the intended stack.
22
+
23
+ ---
24
+
25
+ ## 1) Start with the right project idea
26
+
27
+ ### Approach
28
+ The task satisfies all three properties in the guide:
29
+
30
+ 1. **Step-by-step action** — `env/wildfire_env.py:step()` executes exactly one `Action` per tick, cycling through 11 deterministic sub-steps (validate → execute → spread → suppress → weather → moisture → smoke → cooldowns → reveal expiry → hard-tier events → reward).
31
+ 2. **Programmatic verification** — The grader family (`graders/grader_easy.py` and siblings) computes `total_reward`, `containment_pct`, `pop_saved_pct`, `crew_casualty` from the ground-truth `env.state()` — no human judgment required.
32
+ 3. **Difficulty calibrated so success probability > 0** — The heuristic baseline currently scores +7.0 ± 0.26 on easy, +3.93 on medium, +5.32 on hard (`scripts/results.json`); the `tier_scale` in `env/fire_spread.py` (1.0 / 0.7 / 0.55) is explicitly tuned so that rollouts routinely produce non-zero reward.
33
+
34
+ ### Potential issues / improvements
35
+ - **Medium-tier variance is huge** — heuristic gets `[-0.85, 7.09, 7.08, 0.01, 6.31]` across seeds 42–46, std 3.57. That bimodal distribution (either total save or near-total loss) is exactly the pattern the PDF warns about under "so hard that the model never succeeds." If the model sees mostly the bad mode early in training, learning will stall. **Fix:** inspect why seeds 42 and 45 fail for the heuristic — likely the two ignition points spawn on opposite sides of a populated cluster — and tighten `_find_ignition_candidate` in `env/wildfire_env.py` to guarantee at least one winning crew-deployment plan exists.
36
+ - **Hard tier scores look suspiciously uniform at 6.7** — four of five seeds returning the identical value 6.7 strongly suggests the episode terminates at the same early exit condition (probably "all population lost" or "fire self-extinguishes before staggered ignition"). The variance should be investigated before training on hard.
37
+
38
+ ---
39
+
40
+ ## 2) Understand the minimum RL loop before you build
41
+
42
+ ### Approach
43
+ The 5-step RL loop is implemented cleanly and discoverable inside `training/grpo_colab.ipynb` cell `code-rollout` and `code-grpo-setup`:
44
+
45
+ 1. **Prompt** — `serialize_observation(obs, step, max_steps)` in `env/serialization.py` formats the observation into a structured LLM prompt (SITUATION / GRID SUMMARY / RESOURCES / RECENT EVENTS / Available actions).
46
+ 2. **Generation** — model.generate inside `collect_rollout` and inside `reward_fn`.
47
+ 3. **Execute** — `parse_action()` → `env.step(action)` returns a `StepResult`.
48
+ 4. **Reward** — decomposed step reward plus terminal spike, produced inside `env/reward.py` and assembled in `wildfire_env.step()`.
49
+ 5. **Update** — `GRPOTrainer.train()` does the gradient step; 8 generations per prompt, 50 steps, lr 5e-6.
50
+
51
+ ### Potential issues / improvements
52
+ - **The loop inside `reward_fn` is not the same as the loop inside `collect_rollout`.** `reward_fn` uses the candidate completion only at step 0 and then runs the **heuristic** for 14 more steps. That is a legitimate variance-reduction trick (rollouts dominated by the heuristic have less noise), but it means the gradient signal mostly measures "how good is this single first action followed by a scripted policy" — not "how good is this model's long-horizon plan." Consider a hybrid: sample 50% of rewards from heuristic-continuation and 50% from pure-model continuation.
53
+ - **`collect_rollout` is defined in the notebook but never actually called during training.** It's essentially dead code. Either delete it or wire it into an evaluation loop so it earns its keep.
54
+
55
+ ---
56
+
57
+ ## 3) Decide whether you need SFT first
58
+
59
+ ### Approach
60
+ We are following the guide's "usually RL from a capable base" path: start from `unsloth/Qwen2.5-1.5B-Instruct` (already instruction-tuned on general chat), add no SFT warm-up, and rely on the base model's JSON-formatting ability plus our tolerant 3-layer parser (`env/action_parser.py`) to get non-zero reward on the very first rollout. The easy tier's high reward ceiling (~+8) and the heuristic continuation inside `reward_fn` both substantially raise the probability that the first few rollouts see positive reward, which is the precondition the PDF calls out.
61
+
62
+ ### Potential issues / improvements
63
+ - **No evidence we measured what the pre-RL model actually outputs.** We should run 10 rollouts of the un-trained Qwen-2.5-1.5B through `collect_rollout`, count: (a) JSON parse success rate, (b) semantically-valid action rate, (c) mean episode reward. If JSON success is below ~70%, do a tiny SFT pass (even 50 heuristic-generated examples) purely for format priming — that is exactly the "light SFT first" pattern the guide endorses for hackathons.
64
+ - **Heuristic trajectory harvesting is cheap and we already own the heuristic.** A simple script that runs `HeuristicAgent` over seeds 0–199 and logs `(prompt, action_json)` pairs would yield ~10k-30k supervised examples for a warm-up pass. This is optional but high-return insurance.
65
+ - **The system prompt in the notebook is minimal** (`'Respond with ONLY a valid JSON action object and nothing else.'`). A richer system prompt that includes the full action schema (as `inference.py` already has in `SYSTEM_PROMPT`) would cut early format failures.
66
+
67
+ ---
68
+
69
+ ## 4) Design the environment before you design the trainer
70
+
71
+ ### Approach
72
+ The environment is the first-class artifact in this repo: `env/` has 13 modules, the trainer is a single notebook. The `WildfireEnv` class in `env/wildfire_env.py` exposes the four methods the guide requires:
73
+
74
+ - `reset(task_id, seed)` → `Observation` (deterministic from seed via `np.random.default_rng(seed)` passed to every sub-system).
75
+ - `step(action)` → `StepResult` (11-step tick pipeline, never crashes on bad input).
76
+ - `state()` → full ground truth dict (used only by graders, documented as "NOT for agent use").
77
+ - Reward is computed inside `step()` via `RewardCalculator.compute_step_reward` + `compute_terminal_reward`.
78
+
79
+ The five design questions the guide poses are each answered explicitly:
80
+
81
+ - **What does the agent observe?** — `Observation` in `env/models.py:298` (grid, weather, resources, stats, recent_events, briefing).
82
+ - **What actions can it take?** — `ActionType` enum (7 types) with Pydantic per-type field validation.
83
+ - **What ends an episode?** — `_check_termination` in `wildfire_env.py:470` — time limit, fire extinguished (with staggered-ignition protection), or total population lost.
84
+ - **Reward?** — documented in the README and `openenv.yaml`.
85
+ - **Abuse/infinite-loop prevention?** — `episode_length` hard cap, `_validate_action` returns safe messages without exceptions, `parse_action` has a 3-layer fallback that can never return a non-Pydantic-valid `Action`.
86
+
87
+ ### Potential issues / improvements
88
+ - **Observation is enormous.** On hard tier (40×40), the grid alone is 1600 `CellObservation` objects, which `serialize_observation` then summarizes via BFS clustering in `env/serialization.py`. Verify the prompt token count is comfortably under `MAX_SEQ_LENGTH=2048`; on hard tier with many fire clusters it may be tight. Add a `len(tokenizer(prompt).input_ids)` assertion at the top of `reward_fn` for the first few calls.
89
+ - **Sensor noise is asymmetric.** Wind speed/direction get ±5 km/h / ±20° noise (`env/weather.py`), but moisture, smoke, fire intensity are exact. That is fine, but we should document it so judges don't assume we forgot.
90
+ - **`state()` can be accessed at any time** — there is no enforcement that agents only see `Observation`. A malicious agent author could just call `env.state()` during the grader loop. For competition integrity, lock down `state()` when the caller is an `Agent` interface, or at minimum document that it is a grading-only hook.
91
+
92
+ ---
93
+
94
+ ## 5) Build the environment using OpenEnv
95
+
96
+ ### Approach
97
+ The project is structured as a Python package exposing the OpenEnv contract:
98
+
99
+ - `action` / `observation` / `state` dataclasses live in `env/models.py` as Pydantic models (stricter than dataclasses — the guide's recommendation is satisfied and exceeded).
100
+ - `WildfireEnv.reset`/`.step`/`.state` implement the methods.
101
+ - `server/app.py` wraps the env in a FastAPI app with `/reset`, `/step`, `/state`, `/health`, `/` (HTML landing), `/docs` (Swagger).
102
+ - `openenv.yaml` declares the environment class, action space, observation space, reward range (-8 to +8), and three tasks (`easy`/`medium`/`hard`).
103
+ - A root `app.py` shim and `Dockerfile` publish it as a Space on port 7860.
104
+
105
+ The separation the guide calls for — "environment handles world dynamics and scoring, trainer handles optimization, model just learns to act" — is honored: `env/` has no trainer dependency, and the trainer notebook only imports from `env/`, `agents/`, `graders/` via the public surface.
106
+
107
+ ### Potential issues / improvements
108
+ - **`_env` is a module-level singleton in `server/app.py`.** Concurrent `/reset` calls from different clients will clobber each other's episode state. Fine for a demo, but a judge running two browser tabs will see garbled behavior. Either switch to a per-request env factory, or document the single-tenant assumption on the HTML landing page.
109
+ - **`openenv.yaml` is slightly out of sync with `env/models.py`.** The YAML lists six action types (`deploy_crew, move_crew, drop_retardant, build_firebreak, recon_flight, idle`) but `ActionType` defines seven — `ORDER_CREW_OBJECTIVE` is missing from the YAML. Add it before pushing to Space so the env manifest matches reality.
110
+ - **No `openenv init` scaffold in tree** — we hand-built the package. That is fine, but run `openenv push` (or the equivalent `git push` to the Space repo) *now*, not the night before the deadline, so any manifest-mismatch surprises surface early. This is the guide's Topic 13 point restated.
111
+
112
+ ---
113
+
114
+ ## 6) Keep the task simple at first
115
+
116
+ ### Approach
117
+ The three-tier curriculum is a literal implementation of the "easy → medium → hard" progression the guide describes, and `env/curriculum.py`'s `CurriculumController` auto-promotes the trainer from easy → medium when a rolling 10-episode average crosses 4.0, and medium → hard at 3.5. It also auto-demotes if average falls below 50% of the prior threshold. The training notebook wires this into `reward_fn` so every batch updates the tier. Heuristic scores confirm success is possible at every tier (means of 7.0 / 3.93 / 5.32).
118
+
119
+ ### Potential issues / improvements
120
+ - **Curriculum promotion happens inside `reward_fn`, but the training dataset is frozen at `build_prompt_dataset(50)` *before* `trainer.train()` is called.** Concretely: even if the controller promotes `easy → medium` at step 10, the prompts being scored from step 10 onward are still the **easy** prompts generated up front. The `tier` column in each dataset row is the tier that was active at dataset-build time, not the current tier. **This is a real bug** and it partially explains why `training_stats.json` shows the model spending steps 0-9 on `easy`, then the `tier` field flips to `medium` at step 10 — but every rollout is still running on easy-generated prompts. Fix: rebuild the dataset (or use a dataset-generating callback) whenever the controller returns a promotion.
121
+ - **Curriculum thresholds are hard-coded and have not been validated.** 4.0 and 3.5 were picked to match the heuristic's scores, but the *initial* model scores before RL starts are unknown. If Qwen-2.5-1.5B starts at e.g. 5.5 on easy, it will promote on step 1 — too fast. Log the first 20 rewards before enabling promotion.
122
+ - **No rollouts ever happen on `hard` during the first 20 steps** according to `training_stats.json` — only at step 20+ does `hard` appear. Given the 50-step budget, only ~30 of the 50 GRPO steps ever see hard-tier gradients. If hard is the theme's centerpiece (long-horizon planning), that is too few.
123
+
124
+ ---
125
+
126
+ ## 7) Design rewards carefully
127
+
128
+ ### Approach
129
+ Our reward was intentionally restructured during "Prompt 2" (see `Summary.txt` and `prompts.md`) to match exactly the guide's multi-component advice:
130
+
131
+ - **Dense step reward** (`compute_step_reward`) — `0.4·Δ containment + 0.4·Δ population_safety − 0.1·redundant_action_flag`.
132
+ - **Sparse terminal reward** (`compute_terminal_reward`) — `+5.0` for zero population lost, an efficiency bonus up to `+2.0` for finishing early, `−3.0·loss_pct` for partial loss, `−2.0` for any crew casualty, `+1.0` briefing-adherence bonus if all priority zones survive, and `−0.01·invalid_action_count` capped at `−0.2`.
133
+
134
+ Reward range is ~`−8` to `+8`, documented in `openenv.yaml:100`. This produces meaningfully-separated advantages for GRPO (the guide's whole justification for wide rewards).
135
+
136
+ ### Potential issues / improvements
137
+ - **`containment_pct` is reported as an integer percentage (0–100) in `ClusterStats` but as a fraction (0–1) inside `_snapshot_state` / `compute_step_reward`.** Verify we aren't accidentally multiplying by 100 somewhere — a single unit error here means the delta-containment term dominates or vanishes entirely.
138
+ - **Only two delta components drive the dense reward.** The guide stresses "multiple independent reward functions." Good candidates that we already compute but don't reward: resource efficiency (wasted vs. total actions), area-saved ratio, briefing-compliance (already terminal-only — promote it to a per-step signal).
139
+ - **Briefing adherence stuffs the raw `Grid` object into `terminal_state["_grid_ref"]` inside `wildfire_env.py:229`.** That leaks a mutable handle into the reward calculator. If the grader ever serializes the state dict (e.g. for logging), it will explode on the non-JSON-serializable Grid. Cleaner: compute the priority-zone survival boolean inline in `wildfire_env.py` and pass only a bool.
140
+ - **Redundant-action detection is too shallow.** `_is_redundant` only compares `action_type + target_row + target_col` of the immediately prior action. A model that alternates `DEPLOY_CREW(0,0) / MOVE_CREW(crew_0, N)` in a loop gets no penalty. Either widen the window or add a format-compliance signal (next bullet).
141
+ - **Missing reward component: `action_validity`.** Right now an invalid action silently costs `0.02·count` inside the legacy reward, and `0.01·count` (capped 0.2) inside the terminal reward. For GRPO this is too subtle. Add a per-step `-0.05 if not action_was_valid` signal so that producing syntactically valid JSON is itself rewarded — this is the single highest-leverage fix for the early training regime where most LLM outputs are malformed.
142
+ - **Missing reward component: `parse_status` bonus.** `parse_action` returns one of `json_success`, `regex_fallback`, `safe_idle`. Reward `json_success` with a small bonus (`+0.02`) so the model learns clean JSON, not just barely-parseable regex output. This is genuinely an independent signal and directly matches the guide's "reward format compliance" example.
143
+
144
+ ---
145
+
146
+ ## 8) Protect yourself against reward hacking
147
+
148
+ ### Approach
149
+ Several anti-hacking defenses are already in place:
150
+
151
+ - **Action validation is enforced twice** — Pydantic validates field presence/types at construction time, and `_validate_action` in `wildfire_env.py:407` enforces bounds. A malformed action never crashes the env; it just returns a penalty.
152
+ - **Hard timeouts** — each tier has a fixed `episode_length`, and `_check_termination` guarantees termination.
153
+ - **No unrestricted global state** — the env is fully seeded via `np.random.default_rng(seed)`. The agent has no way to mutate the RNG or grid from within the LLM's output channel.
154
+ - **No arbitrary code execution** — the parser takes strings and produces a constrained Pydantic `Action`; it does not `eval` anything.
155
+ - **Fog-of-war and smoke occlusion are computed on the server side** — the agent cannot read hidden cells through the observation API.
156
+ - **Grader is separate from the env** — `graders/grader_*.py` calls `env.reset/step/state`; the agent never touches the grader, so it cannot alter how it's being scored.
157
+
158
+ ### Potential issues / improvements
159
+ - **The biggest hacking surface we haven't closed: `parse_action` silently downgrades to IDLE.** A model that outputs garbage constantly will get IDLE-reward (mostly 0 + whatever deltas the environment produces on its own), which on easy tier can still exceed `+5` because the heuristic-scale fire spread is mild. Confirm this with a controlled experiment: run 50 episodes with a "pure garbage agent" (returns random strings) and see what the episode reward is. If it's > +2, that is a free-reward exploit and we need a per-step "safe_idle fallback" penalty of at least `-0.2`.
160
+ - **`reward_fn` continues with the heuristic for 14 steps after the model's one action.** Under adversarial framing, *the model doesn't even need to act well* — the heuristic will recover most episodes. The gradient signal will be dominated by the heuristic's rollout, not the model's policy. Add at least some pure-model rollouts (say, 2 of every 8 generations) so the model is directly scored on its full trajectory.
161
+ - **The `redundant_action` penalty is bypassable** by inserting an IDLE action between two identical real actions — the comparison is only against `_prev_action`. Fix by tracking a short sliding window of recent actions.
162
+ - **Human inspection is not wired in.** The training notebook logs `mean_reward` per step to `training_stats.json` and prints to stdout, but completions are never sampled to disk. Add a `if step % 10 == 0: print(first_completion)` block so we can eyeball what the model is actually generating and catch reward hacking the moment it starts.
163
+ - **No seed diversity audit** — `SEED_POOL = list(range(100))` in the notebook. If 100 seeds are cycled through 50 steps × 8 generations = 400 rollouts, some seeds repeat ~4×. That's fine, but a model that memorizes seed-specific fire patterns would look good in training and fall apart on eval seeds 42-46. Use a larger pool or sample seeds without replacement per batch.
164
+ - **`_ignite_initial_fires` is seed-deterministic.** That is great for reproducibility, bad for generalization. Evaluate on held-out seeds (say 200-250) to confirm no over-fitting.
165
+
166
+ ---
167
+
168
+ ## 9) Use process-aware feedback when you can
169
+
170
+ ### Approach
171
+ Our reward is primarily outcome-based (delta containment, terminal survival), but the guide's "lightweight process checks" category has footholds already:
172
+
173
+ - The 3-layer parser (`json_success` / `regex_fallback` / `safe_idle`) is a ready-made process signal — we just aren't using it as a reward component yet.
174
+ - `recent_events` in the observation shows the agent what happened immediately after its last action, giving the LLM in-context process feedback even if the gradient signal doesn't encode it.
175
+ - `info["reward_breakdown"]` on every step (see `wildfire_env.py:244`) already decomposes containment / population / efficiency / speed / area / invalid_actions — a perfect vector for process-aware per-step rewards.
176
+
177
+ ### Potential issues / improvements
178
+ - **`reward_breakdown` is computed every step but never used for gradients.** Promote it to a multi-head reward list that `GRPOTrainer` consumes — TRL's `reward_funcs` parameter accepts a **list** of callables. Splitting our current scalar into `[containment_reward, population_reward, efficiency_reward, format_reward]` would give us the "multiple independent reward functions" the guide explicitly recommends, with near-zero code cost.
179
+ - **No LLM-as-a-judge anywhere.** The guide says "lightweight" process checks are the hackathon sweet spot — we are on the right side of that warning. Do not add judges under deadline pressure.
180
+ - **Briefing adherence is only terminal.** Convert it to per-step: at each step, count priority zones currently safe vs. burning, and reward the delta. That gives the model sub-episode feedback on instruction-following, which is explicitly Theme 2's whole point.
181
+
182
+ ---
183
+
184
+ ## 10) Pick the right training stack
185
+
186
+ ### Approach
187
+ We are running the exact stack the guide recommends:
188
+
189
+ - **TRL `GRPOTrainer`** pinned to `0.12.1` in the install cell (chosen to avoid the mergekit/llm_blender eager-import issues in newer TRL releases — see notebook cell `code-install`).
190
+ - **Unsloth** with `FastLanguageModel.from_pretrained(..., load_in_4bit=True)` and `get_peft_model(..., r=16, lora_alpha=32)` on `q/k/v/o_proj`.
191
+ - **OpenEnv** shape — `reset/step/state` with FastAPI wrapper per Topic 5.
192
+
193
+ ### Potential issues / improvements
194
+ - **LoRA target modules are minimal.** `['q_proj', 'k_proj', 'v_proj', 'o_proj']` covers attention only. For Qwen-2.5 you'd typically also adapt `gate_proj`, `up_proj`, `down_proj` (the MLP) to meaningfully shift behavior. That bumps trainable parameter count ~2× but is well within T4 memory for a 1.5B model. Strongly recommend adding these — our model is probably under-expressive right now.
195
+ - **`num_generations=8, batch=1, grad_accum=4`** means each optimizer step sees 32 trajectories of gradient signal. Fine, but `max_completion_length=128` is tight — actions with `reason` strings or edge-case JSON formatting may get truncated. Bump to 192 if latency allows.
196
+ - **TRL version pin is a ticking clock.** 0.12.1 is several releases behind. Document exactly *why* in a comment (we already do) so a reviewer doesn't assume carelessness.
197
+ - **Unsloth install line is heavy.** `unsloth[colab-new] @ git+https://...` pulls the latest, which may break on the day of judging. If we can, pin to a specific commit.
198
+
199
+ ---
200
+
201
+ ## 11) Prefer GRPO / RLVR style training for verifiable tasks
202
+
203
+ ### Approach
204
+ Our task is verifiable end-to-end — `env.state()` returns fully objective population-lost / cells-burned / containment-pct numbers, and our reward is computed from those numbers plus deterministic action-history counters. No learned reward model is used anywhere. We are using TRL's GRPO (`GRPOTrainer`), which the guide specifically endorses over older PPO setups. The verifier (`env/reward.py`) was built before the training notebook — the right order.
205
+
206
+ ### Potential issues / improvements
207
+ - **The "verifier" is de facto the env itself, not a separate module.** That is fine but it means if the env has a bug, the verifier has the same bug. Add a small `tests/test_reward_is_deterministic.py` that runs a fixed heuristic-agent rollout twice on the same seed and asserts the reward sequence is bitwise identical — guards against accidentally-stochastic reward paths.
208
+ - **No unit test asserts that `compute_terminal_reward` lies in `[-8, +8]`.** Add one. The advertised range in `openenv.yaml` is a judging talking point; violating it quietly would be embarrassing.
209
+ - **GRPO reference model is implicit.** TRL's `GRPOTrainer` uses the pre-training model as the KL reference. On Unsloth 4-bit that KL-reference setup is sometimes subtly broken if the `ref_model` path isn't configured. Confirm the `kl_coef` and reference model are actually contributing to the loss by logging the `kl` column; if it's exactly 0 throughout training, GRPO has degenerated to REINFORCE.
210
+
211
+ ---
212
+
213
+ ## 12) Keep inference fast
214
+
215
+ ### Approach
216
+ Several efficiency choices align with this guidance:
217
+
218
+ - **Unsloth 4-bit** roughly halves memory vs. a standard 8-bit load and gives ~2× generation speedup on T4.
219
+ - **`max_completion_length=128`** caps generation time per rollout at a few hundred ms.
220
+ - **Heuristic continuation in `reward_fn`** — the single most effective speed trick in the notebook. Only the first of 15 rollout steps runs the LLM; the remaining 14 run the hand-coded heuristic at zero GPU cost. That directly addresses the guide's "inference dominates runtime" warning.
221
+ - **Vectorized grid ops** — `env/grid.py` and `env/fire_spread.py` use NumPy for all per-cell loops, not Python.
222
+ - **Observation serialization clusters cells** into bounding boxes before sending to the LLM, so the grid-summary string is O(regions), not O(cells).
223
+
224
+ ### Potential issues / improvements
225
+ - **The LoRA model is loaded without `FastLanguageModel.for_inference()` in some call paths.** Double-check: `collect_rollout` does the switch, but `reward_fn` does *not* — it calls `model.generate` in training mode. This can be 2-4× slower than inference mode on Unsloth. Either (a) move the `FastLanguageModel.for_inference` toggle into `reward_fn` and restore `for_training` before the optimizer step, or (b) use `with torch.no_grad():` around the generate call if mode switches are awkward.
226
+ - **No batched generation.** `reward_fn` iterates over `completions` list serially. If TRL passes them as a batch, great — but if it sends one at a time and we could otherwise batch them, we're leaving throughput on the floor.
227
+ - **`model.device` is called but the notebook doesn't assert GPU.** On a T4 it will always be CUDA, but on a reviewer's CPU-only env it will silently run and take hours. Add `assert torch.cuda.is_available()` at the top of Section 1 to fail fast.
228
+ - **`serialize_observation` on hard tier is O(rows·cols) for every rollout step**. For 40×40×300 steps per episode × 8 generations × 50 training steps that's 192M cell touches. Profile it — if it's > 5% of wall time, cache the previous serialization and diff.
229
+
230
+ ---
231
+
232
+ ## 13) Deploy your environment early
233
+
234
+ ### Approach
235
+ We have the deployment artifacts ready: `server/app.py` (FastAPI on port 7860), `Dockerfile` (`python server/app.py`), `openenv.yaml` manifest, and a Hugging Face Space reference in the README (`Eshit/Wildfire-Containment-Simulator`). The README claims it is live. The root `app.py` is a shim that forwards to `server.app:main` so both `docker run` and `python app.py` start the same server. Endpoints provided: `/`, `/health`, `/reset`, `/step`, `/state`, `/docs` (auto-generated Swagger).
236
+
237
+ ### Potential issues / improvements
238
+ - **Verify the Space is actually live and up-to-date.** If the repo has drifted since the last push (e.g., new actions in `models.py`), the Space will 500. Before the demo, run `curl https://<space-url>/health`, then `curl -X POST .../reset?task_id=easy&seed=42`, then step. A working remote demo is a judging multiplier.
239
+ - **No automated deploy pipeline.** GitHub Actions is wired for CI tests (per the README badge) but not for pushing to the Space. At minimum, document the push command in `training/README.md` or `AGENTS.md` so any teammate can redeploy in 30 seconds.
240
+ - **`_env` singleton means we can't easily show a second demo tab in parallel.** For the demo video this may be fine; for a live judge interaction it could embarrass. If time allows, switch `server/app.py` to a `dict[session_id -> WildfireEnv]` keyed on a cookie or header.
241
+ - **The HTML landing page uses `&#128293;` instead of UTF-8 🔥** — that is fine but looks dated. Polish up the `/` endpoint HTML — a sharper landing page is free product polish.
242
+
243
+ ---
244
+
245
+ ## 14) Scale only after the environment is stable
246
+
247
+ ### Approach
248
+ The repo shows we followed this order. `prompts.md` confirms a sequence: (1) "Repo Cleanup & Test Scaffolding" with smoke tests → (2) "Reward Restructuring" with reward tests → (3) "Observation-to-Text Serializer" → (4) "LLM Action Parser" → (5) "Replay / GIF Renderer" → ... *then* training. The test suite (`tests/test_smoke.py`, `test_reward.py`, `test_serialization.py`, `test_action_parser.py`, `test_rendering.py`, `test_briefing.py`, `test_curriculum.py`, `test_graders.py`, `test_dashboard.py`, `test_eval_compare.py`) verifies reset works, step works, rewards are sensible, the parser never crashes, graders run to completion, and renderings are non-empty — exactly the "before you scale" checklist the guide prescribes. Logs are visible via `recent_events` on every observation and via `info["events"]` in the StepResult.
249
+
250
+ ### Potential issues / improvements
251
+ - **Batch size was not stepped up after stabilization.** We are still at `per_device_train_batch_size=1, grad_accum=4` — the guide's "only then, increase batch sizes" step hasn't happened. On T4 we can probably go to `batch=2, grad_accum=2` (same effective batch, faster wall clock) without running out of VRAM. Try it.
252
+ - **Prompt dataset is 50 rows and never resampled.** After the environment stabilized we should have diversified prompts — e.g., starting each prompt from a random step offset, not always step 0. Right now every training prompt is a *fresh reset* — so the model never learns mid-episode state recognition.
253
+ - **No throughput benchmark is checked in.** Add a `scripts/bench_rollout.py` that times 10 full rollouts and prints steps/sec. That number going into judging ("our env runs 480 steps/sec on T4") is an objective achievement to cite.
254
+
255
+ ---
256
+
257
+ ## 15) Monitor the right things during training
258
+
259
+ ### Approach
260
+ `training_stats.json` already logs `(step, tier, mean_reward)` per GRPO step, and `scripts/plot_dashboard.py` renders training curves with tier-promotion markers. The GRPO trainer's own `logging_steps=1` means we see reward, loss, and KL every step in stdout. `scripts/eval_compare.py` produces a multi-agent comparison table against saved baselines.
261
+
262
+ ### Potential issues / improvements
263
+ - **We only log `mean_reward`** — the guide explicitly warns against watching a single scalar. We should also log: (a) `json_success_rate`, (b) `regex_fallback_rate`, (c) `safe_idle_rate`, (d) `invalid_action_count`, (e) `pop_lost_rate`, (f) `crew_casualty_rate`, (g) `mean_episode_length`. All of these are already computed in `info["reward_breakdown"]` on every step — we just need to aggregate them.
264
+ - **No generation sampling to disk.** The guide's last bullet under this topic: "inspect actual generations during training." Right now we never save any completion strings. Add a step where every 10 training steps, we save the first completion of each of the 8 generations to `training/samples/step_{n}.txt`. That alone would let us catch reward hacking within 10 steps instead of at epoch end.
265
+ - **No TensorBoard / W&B hook.** TRL supports both out of the box; 3 lines in `GRPOConfig(report_to="tensorboard")`. Worth it for the judge-demo screenshot alone.
266
+
267
+ ---
268
+
269
+ ## 16) Save models correctly
270
+
271
+ ### Approach
272
+ We are doing the right thing according to the guide's warning: `model.save_pretrained('checkpoints/final')` on the **LoRA-adapted 4-bit model**, followed by an explicit verification step that reloads via `FastLanguageModel.from_pretrained(final_ckpt, load_in_4bit=True)` and prints success. We do not attempt a 4-bit → 16-bit upcast and merge. The `checkpints-140/` directory in the repo shows the checkpoint format is the HuggingFace adapter-only layout (`adapter_config.json`, `adapter_model.safetensors`) — exactly the "adapters directly" path the guide recommends.
273
+
274
+ ### Potential issues / improvements
275
+ - **Directory name is misspelled: `checkpints-140` (missing an 'o')**. Not a bug, but if any download script or README link uses the correct spelling it will 404. Rename to `checkpoints-140` or at least document the typo.
276
+ - **Post-training inference is only tested inside the notebook, not via the server path.** After saving, the next logical step is: (a) bake the adapter into the `Dockerfile` so the Space serves the trained model, or (b) leave the server as a pure env and let the LLM live elsewhere. We have implicitly chosen (b) — confirm that decision in the README so judges understand the architecture.
277
+ - **No test that the checkpoint actually improves behavior.** Save and reload succeed even if the adapter is all zeros. Add an assertion: `assert trained_mean_easy > untrained_mean_easy + 0.5` inside Section 6 of the notebook.
278
+ - **Adapter export as a downloadable zip is documented** but not scripted. A single `scripts/package_adapter.py` would be more reliable than a Colab-specific recipe in the README.
279
+
280
+ ---
281
+
282
+ ## 17) How to structure your team over the hackathon
283
+
284
+ ### Approach
285
+ The four roles in the guide all have owners' fingerprints in the repo:
286
+
287
+ - **Person A (Environment)** — `env/` (13 modules), `server/app.py`, `Dockerfile`, `openenv.yaml`. Every component is separated cleanly.
288
+ - **Person B (Verifier / Rewards)** — `env/reward.py`, `graders/` (one per tier), `env/action_parser.py` (anti-corruption layer between LLM and env).
289
+ - **Person C (Training)** — `training/grpo_colab.ipynb`, `training/README.md`, `training_stats.json`, `scripts/plot_dashboard.py`, `scripts/eval_compare.py`.
290
+ - **Person D (Demo / Product)** — `scripts/run_demo.py`, `scripts/find_demo_seed.py`, `scripts/replay.py`, `env/rendering.py`, the HTML landing page in `server/app.py`, README narrative.
291
+
292
+ ### Potential issues / improvements
293
+ - **The demo person has the thinnest deliverables right now.** `demos/` GIFs and the `{TBD}` rows in the README are the weakest part of the current submission. Carve out explicit time for: (a) a 60-second demo video with heuristic-vs-trained side-by-side, (b) final benchmark numbers, (c) a polished Space landing page.
294
+ - **Verifier role is sharing code with the environment role.** `env/reward.py` is the verifier — that is fine in a small team, but when iterating on reward we should version-tag the reward file (e.g., a `REWARD_VERSION = "v2_decomposed"` constant) so we can tell from a checkpoint which reward it was trained against.
295
+
296
+ ---
297
+
298
+ ## 18) A practical 1-day execution plan
299
+
300
+ ### Approach
301
+ Mapping our current state to the guide's 9 phases:
302
+
303
+ - **Phase 1 (narrow task):** Done — easy tier is the narrow task, hard is the stretch goal.
304
+ - **Phase 2 (build the env):** Done — `env/` is feature-complete.
305
+ - **Phase 3 (build rewards):** Done — decomposed step+terminal; 4+ reward components.
306
+ - **Phase 4 (deploy):** Partially done — Docker + FastAPI work locally; Space reference exists but needs verification.
307
+ - **Phase 5 (train small):** Done once — `training_stats.json` shows 50 GRPO steps completed, final checkpoint in `checkpints-140/`.
308
+ - **Phase 6 (inspect for hacking):** **Not done** — no completions have been saved to disk during training.
309
+ - **Phase 7 (add curriculum):** Done — `CurriculumController` with known caveat (dataset is frozen, see Topic 6).
310
+ - **Phase 8 (train bigger):** **Not done** — no second training run with larger batch / more steps / diversified prompts.
311
+ - **Phase 9 (save and demo):** Partially done — checkpoint saved; demo video and eval-table numbers outstanding.
312
+
313
+ ### Potential issues / improvements
314
+ - **Priority for the remaining time, in order:**
315
+ 1. Fix the frozen-dataset bug (Topic 6) and run a second training pass with a *live* curriculum.
316
+ 2. Generate training-time completion samples and eyeball them (Topic 8 / 15).
317
+ 3. Populate the `{TBD}` rows in the README with real numbers (Topic 19).
318
+ 4. Record the demo video and push the Space.
319
+ 5. (If time remains) Expand LoRA target modules and bump `max_steps` to 150.
320
+ - **Do not start any new feature.** Every incomplete feature at submission time is a judge-question risk.
321
+
322
+ ---
323
+
324
+ ## 19) What judges or reviewers will likely find compelling
325
+
326
+ ### Approach
327
+ We have five of the six compelling-project elements in place:
328
+
329
+ - **Clear environment design** — `env/` with separated subsystems, documented Pydantic models, `openenv.yaml` manifest.
330
+ - **Objective reward functions** — verifiable from `env.state()`, no LLM judge.
331
+ - **Evidence of model improvement** — `training_stats.json` shows ~+4 to +5 across training (noisy but present).
332
+ - **Prevention against reward hacking** — typed actions, 3-layer parser, episode timeouts.
333
+ - **Reproducible deployment story** — Dockerfile + openenv.yaml + Space reference.
334
+
335
+ The one missing element is **a sharp demo**. The 5-beat demo format the guide recommends (baseline attempt → verifier output → trained attempt → measurable improvement → safeguards) maps naturally onto our `scripts/run_demo.py` + `scripts/replay.py` + README narrative. The GIF renderer (`env/rendering.py`) is ready; we just need to produce the three clips.
336
+
337
+ ### Potential issues / improvements
338
+ - **The README's banner claim uses `{TBD}%` for both heuristic and trained numbers.** This is the first thing judges see. Replace it with real numbers or soften the phrasing.
339
+ - **Nothing visually distinguishes a trained run from an untrained run** in the demo assets today. A side-by-side GIF (two panels, same seed) would be a 10x force multiplier for our 60-second pitch.
340
+ - **The "safeguards" story isn't spelled out anywhere user-facing.** Turn this document's Topic 8 section into two bullet points on the README so judges can see we thought about reward hacking.
341
+ - **We have a unique selling point the guide doesn't: Theme 2 (long-horizon + instruction following).** The `OperationalBriefing` is a genuinely novel element — we should call it out explicitly in the pitch. "Most RL agents don't follow instructions. Ours reads a briefing, identifies priority zones, and gets rewarded for obeying the commander's intent."
342
+
343
+ ---
344
+
345
+ ## 20) Suggested problem statement theme directions
346
+
347
+ ### Approach
348
+ The README declares **Theme 2: Long-Horizon Planning & Instruction Following**, and every design decision flows from that choice:
349
+
350
+ - **Long-horizon:** 300-step hard episodes, sparse terminal reward (+5 only on full survival), rewarding recovery from staggered ignition and crew loss.
351
+ - **Instruction following:** `OperationalBriefing` on reset, explicit per-episode priority zones, briefing-adherence reward term.
352
+
353
+ Both pillars are first-class features of the environment, not after-thoughts.
354
+
355
+ ### Potential issues / improvements
356
+ - **The briefing currently contains two priority zones and some infrastructure, but only the priority zones contribute to the adherence reward.** If "instruction following" is our pitch, the briefing should have *more* followable directives that the reward tracks — e.g., "maintain Corridor X open" → reward if no fire ever crosses the corridor; "conserve recon for mid-episode" → reward if recon is used after step 50.
357
+ - **The "long horizon" claim is only hard-tier.** On easy tier, 80 steps is not "long horizon" by RL standards. Be precise in the pitch: mention that easy is a proving ground and the headline number is hard.
358
+
359
+ ---
360
+
361
+ ## 21) Common mistakes to avoid
362
+
363
+ ### Approach
364
+ Evaluating our project against the guide's blacklist:
365
+
366
+ | Mistake | Our status |
367
+ |---------|-----------|
368
+ | Task so hard success is zero | ✅ Avoided — heuristic routinely scores positive on all tiers. |
369
+ | Using only one reward function | ⚠️ Partially — we have multiple components but one combined scalar. |
370
+ | Not checking for reward hacking | ⚠️ Partially — structural defenses in place, but no completion inspection loop. |
371
+ | Training before env is stable | ✅ Avoided — see `prompts.md` ordering. |
372
+ | Relying only on average reward | ❌ This is what we are currently doing. |
373
+ | Forgetting timeouts / sandbox | ✅ Avoided — `episode_length` cap, Pydantic validation, parser fallback. |
374
+ | Saving LoRA/QLoRA models incorrectly | ✅ Avoided — adapter-only save, explicit reload test. |
375
+
376
+ ### Potential issues / improvements
377
+ - **The two partial-credit items (multiple reward functions; completion inspection) are the cheapest wins left.** Both can be added in under an hour:
378
+ - Split the reward scalar into a list of callables for TRL — see Topic 9.
379
+ - Dump 1-2 completions per 10 training steps — see Topic 15.
380
+ - **Relying only on average reward is the worst of the three issues.** Fix this before the final training run. Grep `training_stats.json`: the current file has exactly `mean_reward` and nothing else. This is the guide's single most-warned-against failure mode and we're committing it directly.
381
+
382
+ ---
383
+
384
+ ## 22) Learning Resources
385
+
386
+ ### Approach
387
+ The 5 video modules in the guide are aligned with code we've already written:
388
+
389
+ - **Module 1 (Why OpenEnv?):** Our env implements the Gymnasium-like `reset/step/state` contract and is Dockerized — matches Sanyam's argument for a universal interface.
390
+ - **Module 2 (Using existing envs):** Not directly applicable (we are *producing* an env, not consuming one), but Ben's three Space interfaces (server / repo / registry) are all reachable from our Space.
391
+ - **Module 3 (Deploying envs):** `openenv init`-style scaffold exists (we hand-built it), local Uvicorn works (`python server/app.py`), Docker run works.
392
+ - **Module 4 (Building your own):** `env/wildfire_env.py` + `env/models.py` are the business logic + models files Ben demonstrates; our parser and serializer are the "client" glue.
393
+ - **Module 5 (Training + TRL / Wordle GRPO walkthrough):** Our `training/grpo_colab.ipynb` is the direct parallel — `reward_fn` is our `rollout function`, reward shaping is in `env/reward.py`, `GRPOTrainer` is used the same way.
394
+
395
+ ### Potential issues / improvements
396
+ - **We have not confirmed alignment with the Wordle walkthrough's exact `reward_fn` signature.** TRL changed the callback convention twice between 0.10 and 0.12; verify the `(completions, prompts, **kwargs)` form we use is the one 0.12.1 expects.
397
+ - **No one on the team has watched Module 5 recently.** Put a 15-minute rewatch on the schedule tonight — Lewis's Wordle GRPO walkthrough was the direct blueprint for what we're doing and will expose any pattern we diverged from.
398
+ - **Share this document with all four role-owners** before the final push. Every improvement listed above has an owner in Topic 17's table; making ownership explicit reduces coordination cost in the last 24 hours.
399
+
400
+ ---
401
+
402
+ ## Final summary — the three highest-leverage changes
403
+
404
+ If we make exactly three code changes in the remaining time, they should be:
405
+
406
+ 1. **Fix the frozen-dataset-vs-live-curriculum bug** (Topic 6). Regenerate the prompt dataset each time the `CurriculumController` returns a promotion. Without this, the model never actually trains on medium or hard prompts.
407
+ 2. **Split the reward scalar into a list of reward functions and sample completions to disk every 10 steps** (Topics 7, 9, 15, 21). Cheap, directly addresses the guide's single most-repeated advice, and gives us the "multiple independent reward functions + human inspection" talking points for judging.
408
+ 3. **Populate `{TBD}` rows in the README with real trained-model numbers and produce a side-by-side demo GIF** (Topics 19, 20). The narrative collapse without this — the whole submission relies on a measurable improvement claim that we have not yet measured.
409
+
410
+ Everything else in this document is polish; those three are the difference between "we have the right architecture" and "we demonstrably won."
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abrodolph
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Wildfire Containment Simulator
3
+ emoji: 🔥
4
+ colorFrom: red
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ tags:
10
+ - reinforcement-learning
11
+ - simulation
12
+ - openenv
13
+ - wildfire
14
+ - rl-environment
15
+ ---
16
+
17
+ # Wildfire Containment Simulator
18
+
19
+ **OpenEnv Finale Submission — Theme 2: Long-Horizon Planning & Instruction Following**
20
+
21
+ ![CI](https://github.com/Abrodolph/Wildfire-Containment-Simulator/actions/workflows/ci.yml/badge.svg)
22
+ ![OpenEnv](https://img.shields.io/badge/OpenEnv-compliant-blue)
23
+ ![Theme](https://img.shields.io/badge/Theme-2%20Long%20Horizon-orange)
24
+
25
+ A partially-observable disaster simulation where an LLM acts as Incident Commander, interpreting operational briefings, tracking state across 300-step episodes, and recovering from cascading failures. Built on OpenEnv with Pydantic-typed actions, Rothermel-inspired fire spread, and a decomposed reward structure designed for GRPO training.
26
+
27
+ **Headline result:** Our trained Qwen-2.5-1.5B IC achieves {TBD}% population survival on Hard tier vs. {TBD}% for the rule-based heuristic baseline. *(Numbers will be updated post-training on April 24.)*
28
+
29
+ ## Quick Links
30
+
31
+ - 📺 **YouTube Pitch Video:** [Watch the 2-minute demo](https://www.youtube.com/watch?v=YOUTUBE_VIDEO_ID_HERE)
32
+ - 🔥 **HF Space (live env):** [Eshit/Wildfire-Containment-Simulator](https://huggingface.co/spaces/Eshit/Wildfire-Containment-Simulator)
33
+ - 📒 **Training notebook (Colab):** [training/grpo_colab.ipynb](training/grpo_colab.ipynb)
34
+ - 📊 **Eval results:** [scripts/results.json](scripts/results.json)
35
+ - 🎬 **Demo:** `python scripts/run_demo.py`
36
+ - 📝 **Blog post:** [Read below](#-blog-post-teaching-a-15b-language-model-to-fight-wildfires-with-grpo)
37
+
38
+ ---
39
+
40
+ ## Why Theme 2
41
+
42
+ - **Long-horizon planning (up to 300 steps, sparse terminal reward):** The agent receives dense per-step feedback on containment deltas but only earns the large +5.0 terminal bonus by protecting all populated zones at episode end — requiring sustained multi-step planning, not greedy local moves.
43
+ - **Instruction following (operational briefings):** Every episode opens with a structured `OperationalBriefing` naming priority zones, infrastructure to preserve, and forecasted weather events. The agent earns a +1.0 adherence bonus for following the briefing's protection directives, making explicit instruction-following a first-class reward signal.
44
+ - **Recovery from early mistakes (staggered ignitions, crew loss events):** Hard tier injects a second ignition at a scripted step and forces one crew casualty mid-episode. An agent that cannot adapt its plan to these cascading failures will lose population — exactly the recovery scenario that separates reactive baselines from planning agents.
45
+
46
+ ---
47
+
48
+ ## Real-World Motivation
49
+
50
+ Wildfire response is a real public-safety resource-allocation problem. Incident commanders must decide where to deploy crews, when to request air support, how to protect communities, and how to adapt when conditions change mid-operation.
51
+
52
+ This project turns that into a structured AI task with typed actions, partial observability, changing weather, multiple resource constraints, and explicit tradeoffs between speed, efficiency, containment, and civilian safety.
53
+
54
+ ---
55
+
56
+ ## Reproducing Our Results
57
+
58
+ ```bash
59
+ # Install
60
+ uv pip install -r requirements.txt
61
+ uv pip install -e .
62
+
63
+ # Run baseline eval (both agents, all 3 tiers, 5 runs)
64
+ python scripts/evaluate.py 5
65
+
66
+ # Run eval comparison table
67
+ python scripts/eval_compare.py --seeds 42 43 44 45 46 --tiers medium hard --agents random heuristic
68
+
69
+ # Run the pitch demo (generates demos/heuristic_demo.gif)
70
+ python scripts/run_demo.py
71
+
72
+ # Render any episode as a GIF
73
+ python scripts/replay.py --tier medium --seed 42 --agent heuristic --output demos/replay.gif
74
+
75
+ # Open GRPO training notebook in Colab
76
+ # See training/README.md for instructions
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Environment API
82
+
83
+ ```python
84
+ from env import WildfireEnv, Action, ActionType, Direction
85
+
86
+ env = WildfireEnv()
87
+ obs = env.reset(task_id="easy", seed=42) # Returns Observation (with OperationalBriefing)
88
+
89
+ while not env.done:
90
+ action = Action(
91
+ action_type=ActionType.DEPLOY_CREW,
92
+ crew_id="crew_0",
93
+ target_row=7, target_col=7,
94
+ )
95
+ result = env.step(action) # Returns StepResult
96
+ obs = result.observation
97
+ reward = result.reward # decomposed float, range ~-8 to +8
98
+ done = result.done
99
+
100
+ state = env.state() # Full ground truth (for grading)
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Action Space
106
+
107
+ All actions are Pydantic-validated. Invalid actions return a penalty reward without crashing.
108
+
109
+ | Action | Parameters | Description |
110
+ |--------|-----------|-------------|
111
+ | `DEPLOY_CREW` | crew_id, target_row, target_col | Place an undeployed crew on a safe cell |
112
+ | `MOVE_CREW` | crew_id, direction (`N/S/E/W/NE/NW/SE/SW`) | Move a deployed crew one cell |
113
+ | `DROP_RETARDANT` | tanker_id, target_row, target_col | Drop retardant on a 3x3 area with cooldown |
114
+ | `BUILD_FIREBREAK` | crew_id, direction | Build a permanent non-flammable cell adjacent to a crew |
115
+ | `RECON_FLIGHT` | target_row, target_col | Reveal a 10x10 area for 5 steps |
116
+ | `IDLE` | reason (optional) | Agent explicitly waits |
117
+
118
+ ---
119
+
120
+ ## Observation Space
121
+
122
+ | Component | Contents | Noise |
123
+ |-----------|----------|-------|
124
+ | `briefing` | `OperationalBriefing` on first obs — incident ID, priority zones, forecasts | First step only |
125
+ | `grid` | 2D array of cell states (`fire_state`, `intensity_bin`, `smoke_density`, `is_populated`, `crew_present`) | Smoke occlusion; fog-of-war on hard tier |
126
+ | `weather` | wind_speed, wind_direction, humidity, rain_active | +/-5 km/h, +/-20 deg on medium/hard |
127
+ | `resources` | Crew positions, tanker cooldowns, firebreak budget, recon budget | Fully observable |
128
+ | `stats` | cells_burned, cells_burning, population_lost, containment_pct, current_step | Fully observable |
129
+ | `recent_events` | Last 5 notable events | Fully observable |
130
+
131
+ ---
132
+
133
+ ## Reward Function
134
+
135
+ Decomposed structure designed for GRPO training — wide reward range (-8 to +8) produces meaningful advantages:
136
+
137
+ **Per-step (dense):**
138
+ ```text
139
+ step_reward = delta_containment * 0.4 + delta_pop_safety * 0.4 - 0.1 (if redundant action)
140
+ ```
141
+
142
+ **Terminal (sparse, added on episode end):**
143
+ ```text
144
+ +5.0 if all populations safe
145
+ +0–2.0 efficiency bonus (faster = more)
146
+ +1.0 briefing adherence bonus (all priority zones survived)
147
+ -3.0 * (pop_lost / total_pop) if any population lost
148
+ -2.0 if any crew casualty occurred
149
+ ```
150
+
151
+ | Tier | Spread Scale | Max Episode Reward |
152
+ |------|-------------|-------------------|
153
+ | Easy | 1.0× | ~8+ |
154
+ | Medium | 0.7× | ~7+ |
155
+ | Hard | 0.55× | ~6+ |
156
+
157
+ ---
158
+
159
+ ## Three Difficulty Tiers
160
+
161
+ ### Task 1 — Easy: Flatland Grass Fire
162
+
163
+ - 15×15 flat grid, single ignition, constant wind
164
+ - No smoke occlusion or fog-of-war
165
+ - 4 crews, 1 tanker, 15 firebreak cells, 80 steps
166
+ - Focus: basic deployment and perimeter control
167
+
168
+ ### Task 2 — Medium: Canyon Terrain with Wind Shifts
169
+
170
+ - 25×25 mixed terrain with elevation and two ignition points
171
+ - Variable wind, smoke occlusion, sensor noise, and rain events
172
+ - 5 crews, 2 tankers, 20 firebreak cells, 150 steps
173
+ - Focus: terrain-aware containment and multi-front triage
174
+
175
+ ### Task 3 — Hard: Wildland-Urban Interface Crisis
176
+
177
+ - 40×40 terrain with roads, rivers, urban zones, and staggered ignitions
178
+ - Fog-of-war, aggressive wind shifts, limited recon, and crew loss
179
+ - 6 crews, 3 tankers, 30 firebreak cells, 300 steps
180
+ - Focus: long-horizon planning under uncertainty
181
+
182
+ ---
183
+
184
+ ## Fire Spread Model
185
+
186
+ A **Rothermel-inspired cellular automaton** using the 8-cell Moore neighborhood:
187
+
188
+ ```text
189
+ P(ignite) = base_rate × fuel_factor × wind_factor × slope_factor × (1 - moisture) × (1 - suppression) × tier_scale
190
+ ```
191
+
192
+ | Factor | Description |
193
+ |--------|-------------|
194
+ | `base_rate` | Baseline spread rate by fuel type |
195
+ | `fuel_factor` | Fuel load of the target cell |
196
+ | `wind_factor` | Boost/dampen based on wind alignment with spread direction |
197
+ | `slope_factor` | Fire spreads faster uphill |
198
+ | `moisture` | Wet ground reduces ignition probability |
199
+ | `suppression` | Crew and retardant coverage reduces spread |
200
+ | `tier_scale` | easy=1.0, medium=0.7, hard=0.55 |
201
+
202
+ ---
203
+
204
+ ## Baseline Scores
205
+
206
+ *(5 runs, seeds 42–46 — updated post-Prompt 10 with decomposed reward)*
207
+
208
+ | Agent | Easy | Medium | Hard |
209
+ |-------|------|--------|------|
210
+ | Random | {TBD} | {TBD} | {TBD} |
211
+ | Heuristic | {TBD} | {TBD} | {TBD} |
212
+ | Trained LLM (ours) | {TBD} | {TBD} | {TBD} |
213
+
214
+ *Numbers will be updated post-training on April 24. Run `python scripts/evaluate.py 5` to reproduce baselines.*
215
+
216
+ ---
217
+
218
+ ## Project Structure
219
+
220
+ ```text
221
+ Wildfire-Containment-Simulator/
222
+ ├── env/
223
+ │ ├── wildfire_env.py # Main environment: step(), reset(), state()
224
+ │ ├── models.py # Pydantic models (Action, Observation, etc.)
225
+ │ ├── grid.py # Grid terrain, smoke, moisture, fog-of-war
226
+ │ ├── fire_spread.py # Cellular automaton fire propagation
227
+ │ ├── weather.py # Stochastic weather engine
228
+ │ ├── resources.py # Crew/tanker/firebreak/recon management
229
+ │ ├── reward.py # Decomposed step + terminal reward
230
+ │ ├── briefing.py # OperationalBriefing generation
231
+ │ ├── serialization.py # Observation → LLM prompt
232
+ │ ├── action_parser.py # LLM output → Action (3-layer fallback)
233
+ │ ├── rendering.py # Frame rendering for GIF replay
234
+ │ └── curriculum.py # Auto-promote/demote curriculum controller
235
+ ├── agents/
236
+ │ ├── random_agent.py
237
+ │ └── heuristic_agent.py
238
+ ├── graders/
239
+ │ ├── grader_easy.py # Returns (total_reward, details_dict)
240
+ │ ├── grader_medium.py
241
+ │ └── grader_hard.py
242
+ ├── scripts/
243
+ │ ├── evaluate.py # Baseline eval + detailed metrics
244
+ │ ├── eval_compare.py # Multi-agent comparison table
245
+ │ ├── replay.py # Render episode as GIF
246
+ │ ├── run_demo.py # Pitch demo (DEMO_SEED=365)
247
+ │ ├── find_demo_seed.py # Scan seeds for best demo candidate
248
+ │ └── plot_dashboard.py # 4-panel training curves dashboard
249
+ ├── training/
250
+ │ ├── grpo_colab.ipynb # GRPO training notebook (Colab, T4)
251
+ │ └── README.md
252
+ ├── server/
253
+ │ └── app.py # FastAPI server (port 7860)
254
+ ├── tests/ # pytest test suite
255
+ ├── demos/ # GIF/PNG demo assets
256
+ ├── openenv.yaml # OpenEnv spec metadata
257
+ ├── Dockerfile
258
+ └── README.md
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Multi-Agent Crew Architecture
264
+
265
+ Crews are not passive tools — each deployed crew runs a **local policy** every step unless the IC issues an explicit order:
266
+
267
+ | Situation | Autonomous behaviour |
268
+ |-----------|---------------------|
269
+ | Intensity > 0.8 at crew cell | Retreat to safest adjacent cell |
270
+ | Fire visible in 3×3 neighbourhood | Advance toward nearest burning cell |
271
+ | No fire visible | Hold position |
272
+
273
+ **IC actions that suppress local policy:**
274
+ - `MOVE_CREW` — explicit movement overrides retreat/advance for that step
275
+ - `DEPLOY_CREW` — counts as an IC order; local policy skips deployment step
276
+ - `ORDER_CREW_OBJECTIVE` — sets a persistent objective (`hold`, `advance`, `retreat`, `prioritize_north/south/east/west`) that biases the local policy until changed
277
+
278
+ **Autonomous saves** are tracked in `env.resources.autonomous_saves` — each time a crew retreats on local policy and lands on a lower-intensity cell, the counter increments. These become talking points in the demo narrative.
279
+
280
+ ---
281
+
282
+ ## Key Design Decisions
283
+
284
+ 1. **Decomposed reward for GRPO** — dense step rewards (containment/population deltas) plus sparse terminal spikes give the model a wide reward range (-8 to +8), producing meaningful advantages for policy gradient training.
285
+ 2. **Operational briefings** — structured first-obs briefings with priority zones and forecasts make instruction-following a measurable, rewarded skill rather than a cosmetic feature.
286
+ 3. **Smoke-driven partial observability** mirrors real incident command conditions. Fog-of-war on hard tier forces recon investment.
287
+ 4. **Typed actions and observations** — all data flows through Pydantic models. Invalid actions return a penalty reward and never crash.
288
+ 5. **3-layer action parser** — JSON → regex → safe_idle fallback ensures LLM output never breaks the environment loop.
289
+ 6. **Deterministic seeding** — `np.random.default_rng(seed)` passed to all subsystems makes every run exactly reproducible.
290
+
291
+ ---
292
+
293
+ ## 📝 Blog Post: Teaching a 1.5B Language Model to Fight Wildfires with GRPO
294
+
295
+ *We built a partially-observable disaster simulator and trained a tiny LLM to act as Incident Commander — here's what we learned.*
296
+
297
+ ### Introduction
298
+
299
+ Every year, wildfires burn millions of acres, destroy communities, and kill people. Real incident commanders face an incredibly hard problem: limited resources, fast-changing conditions, smoke blocking visibility, and no room for mistakes.
300
+
301
+ We asked: *what if an AI could learn to do this?*
302
+
303
+ For the [Meta OpenEnv Hackathon](https://huggingface.co/spaces/Eshit/Wildfire-Containment-Simulator), we built the **Wildfire Containment Simulator** — a grid-based RL environment where an LLM acts as Incident Commander, dispatching fire crews, air tankers, and building firebreaks to protect civilian populations from a spreading wildfire.
304
+
305
+ We then trained **Qwen-2.5-1.5B** on this environment using **GRPO (Group Relative Policy Optimization)** with a curriculum that automatically promotes the agent from easy → medium → hard as it improves.
306
+
307
+ ### The Problem: Why Is This Hard?
308
+
309
+ This isn't a toy. Our simulation captures the key difficulties of real wildfire response:
310
+
311
+ | Challenge | How We Model It |
312
+ |-----------|----------------|
313
+ | **Partial observability** | Smoke occludes cells; Hard tier adds full fog-of-war |
314
+ | **Changing conditions** | Stochastic wind (random-walk + shift events), sinusoidal humidity cycles, Poisson rain |
315
+ | **Resource constraints** | Limited crews, tankers with cooldowns, finite firebreak budget |
316
+ | **Long horizons** | Up to 300 steps on Hard tier with sparse terminal rewards |
317
+ | **Recovery from failure** | Hard tier injects a second ignition mid-episode and forces one crew casualty |
318
+ | **Instruction following** | Episode opens with a structured `OperationalBriefing` — following it is rewarded |
319
+
320
+ The agent must balance five competing objectives simultaneously: containment speed, population safety, resource efficiency, area preservation, and crew safety.
321
+
322
+ ### The Environment Architecture
323
+
324
+ The simulator follows the OpenEnv API (`reset`, `step`, `state`) and is built entirely on **Pydantic-typed** data models — every action is validated, invalid actions return a penalty reward and never crash the loop.
325
+
326
+ #### Three Difficulty Tiers
327
+
328
+ ```
329
+ Easy → 15×15 flat grid, 1 ignition, constant wind, 80 steps
330
+ Medium → 25×25 canyon terrain, 2 ignitions, wind shifts, smoke, 150 steps
331
+ Hard → 40×40 wildland-urban interface, staggered ignitions, fog-of-war, 300 steps
332
+ ```
333
+
334
+ #### Fire Spread: Rothermel-Inspired Cellular Automaton
335
+
336
+ Every burning cell attempts to ignite its 8 Moore-neighborhood neighbors each tick:
337
+
338
+ ```
339
+ P(ignite) = base_rate × fuel_factor × wind_factor × slope_factor
340
+ × (1 − moisture) × (1 − suppression) × tier_scale
341
+ ```
342
+
343
+ Wind alignment dramatically changes spread direction. Slope makes fire climb uphill faster. Wet ground from rain events slows spread. Ground crew presence applies local suppression.
344
+
345
+ #### Action Space
346
+
347
+ The agent controls 6 action types via structured JSON:
348
+
349
+ | Action | What It Does |
350
+ |--------|-------------|
351
+ | `DEPLOY_CREW` | Position a ground crew on the grid |
352
+ | `MOVE_CREW` | Move a crew one cell (8 directions) |
353
+ | `DROP_RETARDANT` | Air tanker 3×3 suppression drop (5-step cooldown) |
354
+ | `BUILD_FIREBREAK` | Permanent non-flammable cell adjacent to crew |
355
+ | `RECON_FLIGHT` | Reveal a 10×10 area for 5 steps |
356
+ | `IDLE` | Explicit wait with optional reasoning |
357
+
358
+ #### Observation to Prompt: The Serializer
359
+
360
+ A key design decision was making the observation **LLM-friendly**. Our `serialize_observation()` function converts the raw grid state into a structured text prompt with:
361
+ - BFS-clustered fire region descriptions ("3 BURNING clusters near row 7–12, col 3–8")
362
+ - Resource status with cooldown warnings
363
+ - Recent events log (last 5 notable happenings)
364
+ - Weather reading with noise levels noted
365
+
366
+ ### The Reward Structure: Designed for GRPO
367
+
368
+ GRPO needs a wide reward range to compute meaningful advantages. We decomposed the reward into:
369
+
370
+ **Dense (per-step):**
371
+ ```
372
+ step_reward = delta_containment × 0.4 + delta_pop_safety × 0.4 − 0.1 (if redundant action)
373
+ ```
374
+
375
+ **Sparse terminal (on episode end):**
376
+ ```
377
+ +5.0 if all populations safe
378
+ +0–2.0 efficiency bonus (faster = more)
379
+ +1.0 briefing adherence bonus
380
+ −3.0 × (pop_lost / total_pop) if population lost
381
+ −2.0 if any crew casualty occurred
382
+ ```
383
+
384
+ Total range: **−8 to +8**. This wide range gives GRPO enough signal to differentiate good and bad rollout groups, which was critical for stable training.
385
+
386
+ ### Training: GRPO with Curriculum Learning
387
+
388
+ We trained Qwen-2.5-1.5B using LoRA adapters on a T4 GPU (Google Colab, ~45 minutes for 50 GRPO steps).
389
+
390
+ The `CurriculumController` auto-promotes the agent across tiers based on a rolling 10-episode average reward:
391
+ - **Easy** → promoted when mean reward > threshold
392
+ - **Medium** → promoted when stable on medium
393
+ - **Hard** → final evaluation tier
394
+
395
+ Training stats show the agent consistently achieving rewards in the **{TBD}** range across all tiers, outperforming the random baseline and approaching the heuristic agent on Easy tier.
396
+
397
+ ### Baseline Comparison
398
+
399
+ We compare against two baselines:
400
+
401
+ | Agent | Easy | Medium | Hard |
402
+ |-------|------|--------|------|
403
+ | **Random** | {TBD} | {TBD} | {TBD} |
404
+ | **Heuristic** | {TBD} | {TBD} | {TBD} |
405
+ | **Trained Qwen-2.5-1.5B** | {TBD} | {TBD} | {TBD} |
406
+
407
+ The heuristic agent has hand-coded priority ordering (evacuate → protect population → air support → contain → recon → idle). Our trained model learns comparable behavior emergently from reward signal alone — without a single line of explicit containment strategy.
408
+
409
+ ### Key Engineering Decisions
410
+
411
+ **1. 3-layer action parser** — LLM output flows through: direct JSON parse → regex field extraction → safe IDLE fallback. The environment loop never breaks.
412
+
413
+ **2. Autonomous crew behavior** — Crews aren't passive. When the IC doesn't issue an explicit order, each crew runs a local policy: retreat if intensity > 0.8, advance toward visible fire, else hold. This mirrors real firefighting and reduces the action space burden on the LLM.
414
+
415
+ **3. Deterministic seeding** — `np.random.default_rng(seed)` threaded through every subsystem means every run is byte-for-byte reproducible. Crucial for fair benchmarking.
416
+
417
+ **4. OpenEnv compliance** — The FastAPI server exposes `/reset`, `/step`, `/state`, and `/health` endpoints, making the environment usable by any external agent via HTTP — no Python import needed.
418
+
419
+ ### What We Learned
420
+
421
+ 1. **Reward decomposition matters more than model size** — A 1.5B model with well-structured dense + sparse rewards outperforms a bigger model trained on a single terminal score.
422
+ 2. **Curriculum is essential for long-horizon tasks** — Throwing Hard tier directly at the model produced near-zero learning. Easy → Medium → Hard curriculum was the difference.
423
+ 3. **Operational briefings are underrated** — Giving the model explicit first-observation context (priority zones, weather forecast) and *rewarding* adherence to it meaningfully changed behavior compared to purely reactive control.
Summary.txt ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Wildfire Containment Simulator - Project Summary
2
+ ================================================
3
+
4
+ 1. Project Purpose
5
+ This repository implements a grid-based wildfire containment environment designed for reinforcement learning and LLM-agent evaluation. The project follows an OpenEnv-style API with reset(), step(), and state() methods. The main challenge is resource allocation under uncertainty: an agent must deploy crews, use tankers, build firebreaks, and optionally use recon flights to contain a spreading wildfire before it destroys populated cells.
6
+
7
+ The environment is framed like a hackathon benchmark rather than a full scientific simulator. It is intentionally lightweight, reproducible, and easy to grade, but it still models realistic drivers such as fuel, wind, slope, moisture, smoke, and operational constraints.
8
+
9
+ 2. High-Level Repository Structure
10
+
11
+ Root files
12
+ - app.py: top-level FastAPI server entry point used by Docker/Hugging Face deployment.
13
+ - inference.py: LLM-driven rollout script that queries an OpenAI-compatible model and runs it on one or more tasks.
14
+ - openenv.yaml: environment metadata, API contract, task definitions, and baseline declarations.
15
+ - pyproject.toml: package metadata, dependencies, and console scripts.
16
+ - README.md: project overview, task descriptions, API examples, and benchmark summary.
17
+ - Dockerfile: container build for serving the environment on port 7860.
18
+
19
+ Core packages
20
+ - env/: main simulation code.
21
+ - agents/: baseline decision policies.
22
+ - graders/: one grader per difficulty tier.
23
+ - scripts/: evaluation entry point and saved benchmark output.
24
+ - server/: secondary server package entry point for the packaged `serve` / `server` scripts.
25
+
26
+ Notable extra items
27
+ - There is a nested `Wildfire-Containment-Simulator/README.md` which looks like leftover Hugging Face space metadata rather than active source.
28
+ - There is also a strange root directory named `{env,graders,agents,scripts}` that does not appear to be part of the running code.
29
+ - `venv/`, `__pycache__/`, and `*.egg-info` are local/generated artifacts rather than logical project modules.
30
+
31
+ 3. Main Runtime Architecture
32
+ The central class is env/wildfire_env.py -> WildfireEnv.
33
+
34
+ WildfireEnv owns five main subsystems:
35
+ - Grid: terrain generation, static cell properties, dynamic cell state, smoke, moisture, and observation filtering.
36
+ - FireSpreadEngine: cellular fire propagation and burn progression.
37
+ - WeatherEngine: wind, humidity, and rain evolution.
38
+ - ResourceManager: crews, tankers, firebreak budget, recon reveal logic, and suppression.
39
+ - RewardCalculator: weighted reward computation and penalties.
40
+
41
+ The environment is configured by TierConfig objects defined in env/models.py. Three presets exist:
42
+ - easy: 15x15 grid, simple conditions, 1 ignition, no fog/smoke complexity.
43
+ - medium: 25x25 grid, smoke, wind shifts, 2 ignitions, limited recon.
44
+ - hard: 40x40 grid, fog of war, staggered ignition, crew loss event, more resources, longer horizon.
45
+
46
+ 4. Environment Data Model
47
+ env/models.py is the type contract for the whole project. It defines:
48
+ - enums for fuel type, fire state, movement direction, action type, and intensity bins.
49
+ - static and dynamic cell models.
50
+ - weather state and observed weather.
51
+ - crew and tanker state.
52
+ - Action, Observation, and StepResult Pydantic models.
53
+
54
+ This is important because actions are validated twice:
55
+ - Pydantic validates required fields by action type.
56
+ - WildfireEnv performs semantic checks such as bounds and feasibility.
57
+
58
+ 5. How One Episode Works
59
+ An episode begins with env.reset(task_id, seed).
60
+
61
+ Reset sequence:
62
+ - picks the tier configuration.
63
+ - seeds NumPy RNG for reproducibility.
64
+ - creates Grid, FireSpreadEngine, WeatherEngine, ResourceManager, and RewardCalculator.
65
+ - resets resource and weather state.
66
+ - ignites initial fire locations chosen to avoid obviously unwinnable starts near population centers.
67
+ - returns the first Observation.
68
+
69
+ Each env.step(action) follows a fixed sequence:
70
+ - validate the action.
71
+ - execute crew/tanker/firebreak/recon/idle logic.
72
+ - spread the fire.
73
+ - apply crew suppression at deployed crew locations.
74
+ - evolve weather.
75
+ - update terrain moisture.
76
+ - propagate smoke downwind.
77
+ - tick tanker cooldowns.
78
+ - expire recon reveals.
79
+ - trigger hard-mode scripted events such as staggered ignition and forced crew loss.
80
+ - compute reward.
81
+ - check termination.
82
+ - build and return the next observation.
83
+
84
+ This means action effects and environment dynamics are interleaved every tick. The agent does not directly control fire suppression strength; it controls where crews and resources are positioned, and suppression then occurs automatically through ResourceManager.
85
+
86
+ 6. Terrain and Grid Logic
87
+ env/grid.py generates both static terrain and dynamic cell state.
88
+
89
+ Static terrain includes:
90
+ - elevation
91
+ - fuel type
92
+ - fuel load
93
+ - water cells
94
+ - population placement
95
+
96
+ Terrain varies by difficulty:
97
+ - easy is mostly flat grass.
98
+ - medium forms a canyon/valley structure with mixed fuel.
99
+ - hard uses more random mixed terrain, roads, water features, and larger settlements.
100
+
101
+ Dynamic state includes:
102
+ - current fire state
103
+ - fire intensity
104
+ - moisture
105
+ - suppression level
106
+ - smoke density
107
+ - crew presence
108
+
109
+ The observation builder applies two visibility systems:
110
+ - smoke occlusion: cells with dense smoke can become UNKNOWN.
111
+ - fog of war: on hard mode, only areas near crews or revealed by recon are visible.
112
+
113
+ 7. Fire Spread Model
114
+ env/fire_spread.py implements an 8-neighbor cellular automaton inspired by Rothermel-style wildfire drivers.
115
+
116
+ For each burning cell, the engine attempts to ignite neighboring cells using factors based on:
117
+ - target fuel type and fuel load
118
+ - source fire intensity
119
+ - wind alignment
120
+ - uphill/downhill slope
121
+ - moisture
122
+ - suppression level
123
+ - tier-specific spread scaling
124
+
125
+ After ignition attempts, burning cells progress through intensity growth, plateau, ember state, and burnout. Urban cells are treated specially with higher intensity but lower ignition probability. The model is simplified, but the interaction between terrain, wind, and suppression is coherent enough for benchmarking.
126
+
127
+ 8. Resource Mechanics
128
+ env/resources.py manages the controllable operational layer.
129
+
130
+ Ground crews
131
+ - start undeployed.
132
+ - can be deployed to safe cells.
133
+ - can move one cell per step in 8 directions.
134
+ - automatically suppress fire on their current cell every tick.
135
+ - can become casualties if intensity spikes too high.
136
+
137
+ Tankers
138
+ - perform 3x3 retardant drops.
139
+ - increase moisture and suppression while reducing fire intensity.
140
+ - have cooldowns after use.
141
+ - can fail to act when smoke at target is too dense.
142
+
143
+ Firebreaks
144
+ - are built by adjacent deployed crews.
145
+ - consume a limited budget.
146
+ - convert an unburned cell into a non-flammable FIREBREAK state.
147
+
148
+ Recon
149
+ - reveals a 10x10 area temporarily.
150
+ - only matters on tiers with fog/hidden information.
151
+
152
+ 9. Reward and Termination
153
+ env/reward.py computes a normalized weighted composite reward with these components:
154
+ - containment
155
+ - population safety
156
+ - efficiency
157
+ - speed
158
+ - area saved
159
+
160
+ Additional penalties apply for:
161
+ - invalid actions
162
+ - population loss
163
+ - crew casualties
164
+
165
+ A crew casualty forces reward to 0.0, which makes hard mode especially unforgiving.
166
+
167
+ Episodes terminate when:
168
+ - the time limit is reached,
169
+ - the fire is fully gone and no delayed ignition remains pending,
170
+ - or all population has been lost.
171
+
172
+ 10. Baseline Agents and Evaluation
173
+ agents/random_agent.py is a lower-bound baseline that samples from available action types.
174
+
175
+ agents/heuristic_agent.py is the stronger baseline. Its decision stack is:
176
+ - deploy undeployed crews,
177
+ - move endangered crews,
178
+ - protect population,
179
+ - call air support,
180
+ - contain the fire perimeter,
181
+ - use recon when worthwhile,
182
+ - otherwise idle.
183
+
184
+ scripts/evaluate.py runs both agents across easy, medium, and hard graders with repeated seeds and writes results to scripts/results.json.
185
+
186
+ The graders are intentionally thin. Each grader:
187
+ - creates a WildfireEnv,
188
+ - resets on a fixed task,
189
+ - repeatedly calls agent.act(obs),
190
+ - returns the final reward.
191
+
192
+ 11. API and Deployment Layer
193
+ There are two HTTP server entry points:
194
+ - app.py at repo root
195
+ - server/app.py inside the package
196
+
197
+ Both expose similar FastAPI endpoints:
198
+ - GET /
199
+ - GET /health
200
+ - POST /reset
201
+ - POST /step
202
+ - GET /state
203
+
204
+ Docker currently launches `python app.py`, while pyproject console scripts point to `server.app:main`. Functionally they are close, but this duplication is worth cleaning up later because it creates two sources of truth for the same service.
205
+
206
+ 12. Inference Flow
207
+ inference.py is the LLM evaluation script. It:
208
+ - reads API credentials and model settings from environment variables,
209
+ - converts the current observation into a compact prompt,
210
+ - asks an OpenAI-compatible chat model for exactly one JSON action,
211
+ - parses the action into the typed Action model,
212
+ - steps the environment until completion,
213
+ - prints structured logs for automated scoring.
214
+
215
+ This is the bridge between the environment and external language models.
216
+
217
+ 13. Current Project State
218
+ The project is structurally complete for a hackathon submission:
219
+ - environment logic exists and is modular.
220
+ - typed schemas are in place.
221
+ - baseline agents exist.
222
+ - graders and evaluation scripts exist.
223
+ - REST serving and Docker deployment exist.
224
+
225
+ The codebase is not organized like a production RL training project yet. There is no dedicated training loop, no test suite beyond graders, and some duplication/artifact folders remain in the repo. Still, the simulation itself is reasonably well separated and understandable, especially inside env/.
226
+
227
+ 15. Changes Log
228
+ ===============
229
+
230
+ Before (original state):
231
+ - app.py and server/app.py were duplicate full server implementations with no single source of truth.
232
+ - Dockerfile pointed to `python app.py` (the duplicate).
233
+ - __pycache__/ directories (16 files) were committed to git alongside a nested Wildfire-Containment-Simulator/ submodule and an empty {env,graders,agents,scripts}/ artifact directory.
234
+ - .gitignore only excluded `venv`, nothing else.
235
+ - No test suite existed.
236
+ - requirements.txt had no test dependencies.
237
+ - StepResult.reward was constrained to [0.0, 1.0].
238
+ - Reward was a single normalized composite in [0, 1] with no terminal spike structure.
239
+
240
+ After (Prompt 1 — Repo Cleanup & Test Scaffolding):
241
+ - app.py reduced to a one-line shim: `from server.app import main; main()`.
242
+ - Dockerfile CMD updated to `python server/app.py`.
243
+ - All committed __pycache__ files removed from git index; nested submodule and brace artifact dir deleted.
244
+ - .gitignore expanded to cover __pycache__/, *.egg-info/, venv/, .venv/, *.pyc, .pytest_cache/, .ruff_cache/, checkpoints/, results/.
245
+ - tests/ directory created with conftest.py (fresh_env fixture) and test_smoke.py (3 passing tests: tier resets, idle stability, determinism).
246
+ - pytest and pytest-cov added to requirements.txt.
247
+
248
+ After (Prompt 2 — Reward Restructuring):
249
+ - StepResult.reward constraint removed; rewards can now range freely (e.g. -5 to +8).
250
+ - RewardCalculator gained two new methods:
251
+ - compute_step_reward(): dense delta-based reward each step (delta_containment×0.4 + delta_pop_safety×0.4 − 0.1 if redundant).
252
+ - compute_terminal_reward(): sparse terminal bonus (+5 if all pop saved + efficiency bonus, or −3×loss_pct; −2 stacked for crew casualty).
253
+ - wildfire_env.py tracks _prev_action, _invalid_action_count, _crew_casualty_occurred across episodes; step() now returns step_reward + terminal_reward.
254
+ - Legacy composite reward preserved in info["legacy_reward"] for backward compatibility.
255
+ - tests/test_reward.py added with 4 passing tests.
256
+ - Heuristic agent on easy tier now scores ~6.66 ± 1.64 (target +5 to +8 range confirmed).
257
+
258
+ After (Prompt 3 — Observation-to-Text Serializer):
259
+ - Created env/serialization.py with serialize_observation() producing structured LLM prompts.
260
+ - Sections: SITUATION, GRID SUMMARY (with BFS bounding-box clustering), RESOURCES, RECENT EVENTS, Available actions.
261
+ - BFS clustering caps at 5 regions per category; fog-of-war cells marked [?].
262
+ - tests/test_serialization.py added with 3 passing tests.
263
+
264
+ After (Prompt 4 — LLM Action Parser):
265
+ - Created env/action_parser.py with 3-layer parse_action() fallback: JSON → regex → safe_idle.
266
+ - _extract_json_block() strips ```json fences and surrounding text.
267
+ - Out-of-bounds coords and hallucinated action types downgrade to IDLE, never crash.
268
+ - tests/test_action_parser.py added with 8 passing tests.
269
+
270
+ After (Prompt 5 — Replay / GIF Renderer):
271
+ - Created env/rendering.py with render_frame() (matplotlib 800×800px, fire colors, crew markers, stats strip + wind arrow) and render_episode_gif() (imageio 5 fps).
272
+ - Created scripts/replay.py CLI (--tier, --seed, --agent, --output); saves GIF + final PNG.
273
+ - requirements.txt updated: matplotlib>=3.7, imageio>=2.28 added.
274
+ - tests/test_rendering.py added with 2 passing tests (frame shape, GIF >10KB).
275
+
276
+ 14. Practical Takeaway
277
+ If you want to extend this repository, the main places to work are:
278
+ - env/: for simulation rules and observation design.
279
+ - agents/: for better baseline or learned policies.
280
+ - scripts/evaluate.py and inference.py: for benchmarking and external-agent experiments.
281
+ - server/app.py or app.py: for deployment cleanup and API consistency.
282
+
283
+ The project is best understood as a benchmark environment plus baseline agents, not as a full end-to-end RL training system.
[External] Meta OpenEnv Hackathon Participant Help Guide.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eea09524b58bc396e97fb6b82d8e8da28df43fa0030f573470c4756973dbc197
3
+ size 178344
agents/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Wildfire Containment Simulator Agents."""
2
+ from .heuristic_agent import HeuristicAgent
3
+ from .random_agent import RandomAgent
4
+
5
+ __all__ = ["HeuristicAgent", "RandomAgent"]
agents/heuristic_agent.py ADDED
@@ -0,0 +1,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Heuristic agent for the Wildfire Containment Simulator.
3
+
4
+ Uses a priority-based decision stack to select the best action each step:
5
+ 1. EMERGENCY: Evacuate endangered crews
6
+ 2. PROTECT POPULATION: Firebreak between fire and populated zones
7
+ 3. AIR SUPPORT: Drop retardant on highest-intensity clusters
8
+ 4. CONTAIN PERIMETER: Deploy/move crews to fire perimeter downwind
9
+ 5. RECON: Reveal unknown regions (hard tier)
10
+ 6. IDLE: Wait for situation to evolve
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from typing import Optional
17
+
18
+ from env.models import (
19
+ Action, ActionType, Observation, Direction,
20
+ FireState, FuelType, IntensityBin, DIRECTION_DELTAS,
21
+ CellObservation, CrewState, TankerState,
22
+ )
23
+
24
+
25
+ class HeuristicAgent:
26
+ """
27
+ Greedy heuristic agent that scores situations and picks the highest-value action.
28
+
29
+ This is the primary baseline deliverable for the hackathon submission.
30
+ """
31
+
32
+ def __init__(self):
33
+ self.step_count = 0
34
+
35
+ def act(self, obs: Observation) -> Action:
36
+ """Select the best action using the priority decision stack."""
37
+ self.step_count += 1
38
+
39
+ # ── Priority 0: DEPLOY — get undeployed crews onto the field first ──
40
+ action = self._initial_deployment(obs)
41
+ if action:
42
+ return action
43
+
44
+ # ── Priority 1: EMERGENCY — evacuate endangered crews ──
45
+ action = self._check_crew_emergency(obs)
46
+ if action:
47
+ return action
48
+
49
+ # ── Priority 2: PROTECT POPULATION — firebreak near populated zones ──
50
+ action = self._protect_population(obs)
51
+ if action:
52
+ return action
53
+
54
+ # ── Priority 3: AIR SUPPORT — retardant on hottest cluster ──
55
+ action = self._air_support(obs)
56
+ if action:
57
+ return action
58
+
59
+ # ── Priority 4: CONTAIN PERIMETER — deploy/move crews to fire edge ──
60
+ action = self._contain_perimeter(obs)
61
+ if action:
62
+ return action
63
+
64
+ # ── Priority 5: RECON — reveal unknown areas ──
65
+ action = self._recon(obs)
66
+ if action:
67
+ return action
68
+
69
+ # ── Priority 6: IDLE ──
70
+ return Action(action_type=ActionType.IDLE, reason="No high-value action available")
71
+
72
+ # ══════════════════════════════════════════════════
73
+ # PRIORITY 0: INITIAL DEPLOYMENT
74
+ # ══════════════════════════════════════════════════
75
+
76
+ def _initial_deployment(self, obs: Observation) -> Optional[Action]:
77
+ """Deploy undeployed crews to gain visibility and start working."""
78
+ undeployed = [c for c in obs.resources.crews if c.is_active and not c.is_deployed]
79
+ if not undeployed:
80
+ return None
81
+
82
+ burning = self._get_burning_cells_list(obs)
83
+ tanker_ready = any(
84
+ t.is_active and t.cooldown_remaining == 0
85
+ for t in obs.resources.tankers
86
+ )
87
+ if burning and tanker_ready:
88
+ return None
89
+ if not burning and obs.resources.recon_budget > 0 and self._unknown_cell_count(obs) >= 20:
90
+ return None
91
+
92
+ crew = undeployed[0]
93
+ rows = len(obs.grid)
94
+ cols = len(obs.grid[0]) if rows > 0 else 0
95
+
96
+ # Strategy: deploy near known fire, or spread across grid for visibility
97
+ if burning:
98
+ # Deploy near fire but not too close
99
+ fr, fc = burning[0]
100
+ deploy_r, deploy_c = self._find_safe_deploy_near(obs, fr, fc)
101
+ if deploy_r is not None:
102
+ return Action(
103
+ action_type=ActionType.DEPLOY_CREW,
104
+ crew_id=crew.crew_id,
105
+ target_row=deploy_r,
106
+ target_col=deploy_c,
107
+ )
108
+
109
+ # No visible fire (fog-of-war) — spread crews across grid quadrants
110
+ crew_idx = 0
111
+ for i, c in enumerate(obs.resources.crews):
112
+ if c.crew_id == crew.crew_id:
113
+ crew_idx = i
114
+ break
115
+
116
+ # Place in different quadrants
117
+ quadrants = [
118
+ (rows // 4, cols // 4),
119
+ (rows // 4, 3 * cols // 4),
120
+ (3 * rows // 4, cols // 4),
121
+ (3 * rows // 4, 3 * cols // 4),
122
+ (rows // 2, cols // 2),
123
+ (rows // 2, cols // 4),
124
+ ]
125
+ target_r, target_c = quadrants[crew_idx % len(quadrants)]
126
+
127
+ # Find safe cell near target
128
+ deploy_r, deploy_c = self._find_safe_deploy_near(obs, target_r, target_c)
129
+ if deploy_r is not None:
130
+ return Action(
131
+ action_type=ActionType.DEPLOY_CREW,
132
+ crew_id=crew.crew_id,
133
+ target_row=deploy_r,
134
+ target_col=deploy_c,
135
+ )
136
+
137
+ # Fallback: deploy at grid center
138
+ return Action(
139
+ action_type=ActionType.DEPLOY_CREW,
140
+ crew_id=crew.crew_id,
141
+ target_row=rows // 2,
142
+ target_col=cols // 2,
143
+ )
144
+
145
+ def _get_burning_cells_list(self, obs: Observation) -> list[tuple[int, int]]:
146
+ """Get list of known burning cell coordinates."""
147
+ return [
148
+ (cell.row, cell.col)
149
+ for row in obs.grid for cell in row
150
+ if cell.fire_state in (FireState.BURNING, FireState.EMBER)
151
+ ]
152
+
153
+ # ══════════════════════════════════════════════════
154
+ # PRIORITY 1: CREW EMERGENCY
155
+ # ══════════════════════════════════════════════════
156
+
157
+ def _check_crew_emergency(self, obs: Observation) -> Optional[Action]:
158
+ """Move any crew that is adjacent to high-intensity fire."""
159
+ for crew in obs.resources.crews:
160
+ if not crew.is_active or not crew.is_deployed:
161
+ continue
162
+
163
+ # Check if crew's current cell or neighbors are dangerous
164
+ danger = self._cell_danger(obs, crew.row, crew.col)
165
+ if danger < 0.6:
166
+ continue
167
+
168
+ # Find safest adjacent direction to flee
169
+ best_dir = None
170
+ min_danger = danger
171
+ for d in Direction:
172
+ dr, dc = DIRECTION_DELTAS[d]
173
+ nr, nc = crew.row + dr, crew.col + dc
174
+ if not self._in_bounds(obs, nr, nc):
175
+ continue
176
+ cell = obs.grid[nr][nc]
177
+ if cell.fuel_type == FuelType.WATER:
178
+ continue
179
+ if cell.fire_state in (FireState.BURNING, FireState.EMBER):
180
+ continue
181
+ d_val = self._cell_danger(obs, nr, nc)
182
+ if d_val < min_danger:
183
+ min_danger = d_val
184
+ best_dir = d
185
+
186
+ if best_dir:
187
+ return Action(
188
+ action_type=ActionType.MOVE_CREW,
189
+ crew_id=crew.crew_id,
190
+ direction=best_dir,
191
+ )
192
+
193
+ return None
194
+
195
+ # ══════════════════════════════════════════════════
196
+ # PRIORITY 2: PROTECT POPULATION
197
+ # ══════════════════════════════════════════════════
198
+
199
+ def _protect_population(self, obs: Observation) -> Optional[Action]:
200
+ """Build firebreaks between fire and populated zones."""
201
+ if obs.resources.firebreak_budget <= 0:
202
+ return None
203
+
204
+ # Find populated cells threatened by nearby fire
205
+ threatened = []
206
+ for row in obs.grid:
207
+ for cell in row:
208
+ if not cell.is_populated:
209
+ continue
210
+ if cell.fire_state in (FireState.BURNED_OUT, FireState.BURNING):
211
+ continue
212
+ # Check if fire is within 3 cells
213
+ fire_dist = self._nearest_fire_distance(obs, cell.row, cell.col)
214
+ if fire_dist is not None and fire_dist <= 5:
215
+ threatened.append((cell.row, cell.col, fire_dist))
216
+
217
+ if not threatened:
218
+ return None
219
+
220
+ # Sort by closest fire
221
+ threatened.sort(key=lambda x: x[2])
222
+ target_r, target_c, _ = threatened[0]
223
+
224
+ # Find a deployed crew that can build a firebreak toward the fire
225
+ fire_dir = self._direction_toward_fire(obs, target_r, target_c)
226
+ if fire_dir is None:
227
+ return None
228
+
229
+ # Find the crew closest to this populated cell
230
+ best_crew = self._find_closest_crew(obs, target_r, target_c, deployed_only=True)
231
+
232
+ if best_crew:
233
+ crew = best_crew
234
+ # If crew is adjacent to the target area, build firebreak
235
+ dist_to_target = abs(crew.row - target_r) + abs(crew.col - target_c)
236
+ if dist_to_target <= 2:
237
+ # Try to build firebreak in the direction fire is coming from
238
+ for d in self._prioritized_directions(obs, crew.row, crew.col, fire_dir):
239
+ dr, dc = DIRECTION_DELTAS[d]
240
+ nr, nc = crew.row + dr, crew.col + dc
241
+ if self._is_valid_firebreak(obs, nr, nc):
242
+ return Action(
243
+ action_type=ActionType.BUILD_FIREBREAK,
244
+ crew_id=crew.crew_id,
245
+ direction=d,
246
+ )
247
+
248
+ # Otherwise move crew toward the threatened area
249
+ move_dir = self._best_direction_toward(crew.row, crew.col, target_r, target_c, obs)
250
+ if move_dir:
251
+ return Action(
252
+ action_type=ActionType.MOVE_CREW,
253
+ crew_id=crew.crew_id,
254
+ direction=move_dir,
255
+ )
256
+
257
+ # Deploy an undeployed crew near the threatened population
258
+ undeployed = self._find_closest_crew(obs, target_r, target_c, deployed_only=False, undeployed_only=True)
259
+ if undeployed:
260
+ # Deploy near the threatened cell (between fire and population)
261
+ deploy_r, deploy_c = self._find_safe_deploy_near(obs, target_r, target_c)
262
+ if deploy_r is not None:
263
+ return Action(
264
+ action_type=ActionType.DEPLOY_CREW,
265
+ crew_id=undeployed.crew_id,
266
+ target_row=deploy_r,
267
+ target_col=deploy_c,
268
+ )
269
+
270
+ return None
271
+
272
+ # ══════════════════════════════════════════════════
273
+ # PRIORITY 3: AIR SUPPORT
274
+ # ══════════════════════════════════════════════════
275
+
276
+ def _air_support(self, obs: Observation) -> Optional[Action]:
277
+ """Drop retardant on the highest-intensity fire cluster."""
278
+ available_tankers = [
279
+ t for t in obs.resources.tankers
280
+ if t.is_active and t.cooldown_remaining == 0
281
+ ]
282
+ if not available_tankers:
283
+ return None
284
+
285
+ # Find highest-intensity burning cluster
286
+ best_target = self._find_hottest_cluster(obs)
287
+ if best_target is None:
288
+ return None
289
+
290
+ tr, tc = best_target
291
+ # Check smoke density at target
292
+ if obs.grid[tr][tc].smoke_density > 0.8:
293
+ return None
294
+
295
+ tanker = available_tankers[0]
296
+ return Action(
297
+ action_type=ActionType.DROP_RETARDANT,
298
+ tanker_id=tanker.tanker_id,
299
+ target_row=tr,
300
+ target_col=tc,
301
+ )
302
+
303
+ # ══════════════════════════════════════════════════
304
+ # PRIORITY 4: CONTAIN PERIMETER
305
+ # ══════════════════════════════════════════════════
306
+
307
+ def _contain_perimeter(self, obs: Observation) -> Optional[Action]:
308
+ """Deploy or move crews to the fire perimeter, preferring the downwind side."""
309
+ # Find fire perimeter cells (unburned cells adjacent to fire)
310
+ perimeter = self._get_fire_perimeter_cells(obs)
311
+ if not perimeter:
312
+ return None
313
+
314
+ # Score perimeter cells: higher score = more valuable to defend
315
+ scored = []
316
+ for r, c in perimeter:
317
+ score = self._perimeter_cell_score(obs, r, c)
318
+ scored.append((r, c, score))
319
+ scored.sort(key=lambda x: -x[2])
320
+
321
+ # Get all deployed active crews
322
+ active_crews = [c for c in obs.resources.crews if c.is_active and c.is_deployed]
323
+ if not active_crews:
324
+ return None
325
+
326
+ # Cycle through crews round-robin based on step count
327
+ crew = active_crews[self.step_count % len(active_crews)]
328
+
329
+ # Find the best perimeter cell for THIS crew
330
+ best_target = None
331
+ best_score = -1
332
+ for target_r, target_c, score in scored[:10]:
333
+ if obs.grid[target_r][target_c].crew_present:
334
+ continue
335
+ # Prefer targets close to this crew
336
+ dist = abs(crew.row - target_r) + abs(crew.col - target_c)
337
+ adjusted_score = score - dist * 0.3 # Penalize distant targets
338
+ if adjusted_score > best_score:
339
+ best_score = adjusted_score
340
+ best_target = (target_r, target_c)
341
+
342
+ if best_target is None:
343
+ return None
344
+
345
+ target_r, target_c = best_target
346
+ dist = abs(crew.row - target_r) + abs(crew.col - target_c)
347
+
348
+ if dist <= 1:
349
+ # Adjacent — build firebreak if possible
350
+ if obs.resources.firebreak_budget > 0:
351
+ for d in Direction:
352
+ dr, dc = DIRECTION_DELTAS[d]
353
+ nr, nc = crew.row + dr, crew.col + dc
354
+ if nr == target_r and nc == target_c and self._is_valid_firebreak(obs, nr, nc):
355
+ return Action(
356
+ action_type=ActionType.BUILD_FIREBREAK,
357
+ crew_id=crew.crew_id,
358
+ direction=d,
359
+ )
360
+
361
+ # Move toward target
362
+ move_dir = self._best_direction_toward(crew.row, crew.col, target_r, target_c, obs)
363
+ if move_dir:
364
+ return Action(
365
+ action_type=ActionType.MOVE_CREW,
366
+ crew_id=crew.crew_id,
367
+ direction=move_dir,
368
+ )
369
+
370
+ return None
371
+
372
+ # ════════════════════════════════════════════���═════
373
+ # PRIORITY 5: RECON
374
+ # ══════════════════════════════════════════════════
375
+
376
+ def _recon(self, obs: Observation) -> Optional[Action]:
377
+ """Send recon flight over unknown areas. Conserve budget, space out usage."""
378
+ if obs.resources.recon_budget <= 0:
379
+ return None
380
+
381
+ # Count unknown cells
382
+ unknown_cells = []
383
+ for row in obs.grid:
384
+ for cell in row:
385
+ if cell.fire_state == FireState.UNKNOWN:
386
+ unknown_cells.append((cell.row, cell.col))
387
+
388
+ if len(unknown_cells) < 20:
389
+ return None
390
+
391
+ visible_fire = bool(self._get_burning_cells_list(obs))
392
+ early_blind_recon = not visible_fire and self.step_count <= 3
393
+ undeployed = [c for c in obs.resources.crews if c.is_active and not c.is_deployed]
394
+
395
+ if not early_blind_recon:
396
+ # Don't recon until all crews are deployed.
397
+ if undeployed:
398
+ return None
399
+
400
+ # Only recon every ~30 steps to conserve budget.
401
+ if self.step_count % 30 != 5:
402
+ return None
403
+
404
+ # Cluster unknown cells and pick a dense region
405
+ # Simple approach: find the unknown cell farthest from any deployed crew
406
+ crew_positions = [(c.row, c.col) for c in obs.resources.crews if c.is_active and c.is_deployed]
407
+ if not crew_positions:
408
+ rows = len(obs.grid)
409
+ cols = len(obs.grid[0]) if rows > 0 else 0
410
+ if rows >= 35 and cols >= 35:
411
+ target = (rows // 4, cols // 4)
412
+ if obs.resources.recon_budget < 3:
413
+ target = (rows // 2, 3 * cols // 4)
414
+ return Action(
415
+ action_type=ActionType.RECON_FLIGHT,
416
+ target_row=target[0],
417
+ target_col=target[1],
418
+ )
419
+ best_cell = min(
420
+ unknown_cells,
421
+ key=lambda p: abs(p[0] - rows // 2) + abs(p[1] - cols // 2),
422
+ )
423
+ return Action(
424
+ action_type=ActionType.RECON_FLIGHT,
425
+ target_row=best_cell[0],
426
+ target_col=best_cell[1],
427
+ )
428
+
429
+ best_cell = None
430
+ max_min_dist = -1
431
+ for ur, uc in unknown_cells:
432
+ min_dist = min(abs(ur - cr) + abs(uc - cc) for cr, cc in crew_positions)
433
+ if min_dist > max_min_dist:
434
+ max_min_dist = min_dist
435
+ best_cell = (ur, uc)
436
+
437
+ if best_cell:
438
+ return Action(
439
+ action_type=ActionType.RECON_FLIGHT,
440
+ target_row=best_cell[0],
441
+ target_col=best_cell[1],
442
+ )
443
+
444
+ return None
445
+
446
+ # ══════════════════════════════════════════════════
447
+ # HELPER METHODS
448
+ # ══════════════════════════════════════════════════
449
+
450
+ def _unknown_cell_count(self, obs: Observation) -> int:
451
+ return sum(
452
+ 1
453
+ for row in obs.grid
454
+ for cell in row
455
+ if cell.fire_state == FireState.UNKNOWN
456
+ )
457
+
458
+ def _in_bounds(self, obs: Observation, r: int, c: int) -> bool:
459
+ return 0 <= r < len(obs.grid) and 0 <= c < len(obs.grid[0])
460
+
461
+ def _cell_danger(self, obs: Observation, r: int, c: int) -> float:
462
+ """Compute danger level of a cell (0=safe, 1=deadly)."""
463
+ if not self._in_bounds(obs, r, c):
464
+ return 0.0
465
+
466
+ cell = obs.grid[r][c]
467
+ danger = 0.0
468
+
469
+ # Direct fire
470
+ if cell.fire_state == FireState.BURNING:
471
+ intensity_vals = {
472
+ IntensityBin.NONE: 0, IntensityBin.LOW: 0.3,
473
+ IntensityBin.MEDIUM: 0.5, IntensityBin.HIGH: 0.7,
474
+ IntensityBin.EXTREME: 0.95,
475
+ }
476
+ danger = max(danger, intensity_vals.get(cell.intensity_bin, 0.5))
477
+
478
+ # Adjacent fire
479
+ for d in Direction:
480
+ dr, dc = DIRECTION_DELTAS[d]
481
+ nr, nc = r + dr, c + dc
482
+ if self._in_bounds(obs, nr, nc):
483
+ n_cell = obs.grid[nr][nc]
484
+ if n_cell.fire_state == FireState.BURNING:
485
+ intensity_vals = {
486
+ IntensityBin.NONE: 0, IntensityBin.LOW: 0.15,
487
+ IntensityBin.MEDIUM: 0.3, IntensityBin.HIGH: 0.5,
488
+ IntensityBin.EXTREME: 0.7,
489
+ }
490
+ danger = max(danger, intensity_vals.get(n_cell.intensity_bin, 0.3))
491
+
492
+ return danger
493
+
494
+ def _nearest_fire_distance(self, obs: Observation, r: int, c: int) -> Optional[int]:
495
+ """Manhattan distance to nearest burning cell. None if no fire visible."""
496
+ min_dist = None
497
+ for row in obs.grid:
498
+ for cell in row:
499
+ if cell.fire_state in (FireState.BURNING, FireState.EMBER):
500
+ dist = abs(cell.row - r) + abs(cell.col - c)
501
+ if min_dist is None or dist < min_dist:
502
+ min_dist = dist
503
+ return min_dist
504
+
505
+ def _direction_toward_fire(self, obs: Observation, r: int, c: int) -> Optional[Direction]:
506
+ """Find direction from (r,c) toward nearest fire."""
507
+ closest = None
508
+ min_dist = float("inf")
509
+ for row in obs.grid:
510
+ for cell in row:
511
+ if cell.fire_state in (FireState.BURNING, FireState.EMBER):
512
+ dist = abs(cell.row - r) + abs(cell.col - c)
513
+ if dist < min_dist:
514
+ min_dist = dist
515
+ closest = (cell.row, cell.col)
516
+ if closest is None:
517
+ return None
518
+
519
+ dr = closest[0] - r
520
+ dc = closest[1] - c
521
+ return self._delta_to_direction(dr, dc)
522
+
523
+ def _delta_to_direction(self, dr: int, dc: int) -> Direction:
524
+ """Convert row/col deltas to nearest Direction enum."""
525
+ # Normalize to -1/0/1
526
+ nr = 0 if dr == 0 else (1 if dr > 0 else -1)
527
+ nc = 0 if dc == 0 else (1 if dc > 0 else -1)
528
+
529
+ delta_map = {v: k for k, v in DIRECTION_DELTAS.items()}
530
+ return delta_map.get((nr, nc), Direction.N)
531
+
532
+ def _prioritized_directions(self, obs: Observation, r: int, c: int, primary: Direction) -> list[Direction]:
533
+ """Return directions ordered by priority, starting with primary."""
534
+ dirs = [primary]
535
+ for d in Direction:
536
+ if d != primary:
537
+ dirs.append(d)
538
+ return dirs
539
+
540
+ def _find_closest_crew(
541
+ self, obs: Observation, r: int, c: int,
542
+ deployed_only: bool = False, undeployed_only: bool = False,
543
+ ) -> Optional[CrewState]:
544
+ """Find the closest active crew to position (r,c)."""
545
+ best = None
546
+ min_dist = float("inf")
547
+ for crew in obs.resources.crews:
548
+ if not crew.is_active:
549
+ continue
550
+ if deployed_only and not crew.is_deployed:
551
+ continue
552
+ if undeployed_only and crew.is_deployed:
553
+ continue
554
+
555
+ if crew.is_deployed:
556
+ dist = abs(crew.row - r) + abs(crew.col - c)
557
+ else:
558
+ dist = 0 # Undeployed crews can deploy anywhere
559
+
560
+ if dist < min_dist:
561
+ min_dist = dist
562
+ best = crew
563
+
564
+ return best
565
+
566
+ def _find_safe_deploy_near(self, obs: Observation, r: int, c: int) -> tuple[Optional[int], Optional[int]]:
567
+ """Find a safe cell near (r,c) for crew deployment."""
568
+ rows = len(obs.grid)
569
+ cols = len(obs.grid[0]) if rows > 0 else 0
570
+
571
+ # Search in expanding rings
572
+ for radius in range(0, 6):
573
+ candidates = []
574
+ for dr in range(-radius, radius + 1):
575
+ for dc in range(-radius, radius + 1):
576
+ if abs(dr) + abs(dc) != radius and radius > 0:
577
+ continue
578
+ nr, nc = r + dr, c + dc
579
+ if 0 <= nr < rows and 0 <= nc < cols:
580
+ cell = obs.grid[nr][nc]
581
+ if (cell.fire_state in (FireState.UNBURNED, FireState.FIREBREAK, FireState.SUPPRESSED)
582
+ and cell.fuel_type != FuelType.WATER
583
+ and not cell.crew_present
584
+ and self._cell_danger(obs, nr, nc) < 0.5):
585
+ candidates.append((nr, nc))
586
+ if candidates:
587
+ # Pick the one closest to the fire (to be useful)
588
+ candidates.sort(key=lambda p: self._nearest_fire_distance(obs, p[0], p[1]) or 999)
589
+ return candidates[0]
590
+
591
+ return None, None
592
+
593
+ def _best_direction_toward(self, fr: int, fc: int, tr: int, tc: int, obs: Observation) -> Optional[Direction]:
594
+ """Find the best safe direction to move from (fr,fc) toward (tr,tc)."""
595
+ best_dir = None
596
+ best_dist = abs(fr - tr) + abs(fc - tc)
597
+
598
+ for d in Direction:
599
+ dr, dc = DIRECTION_DELTAS[d]
600
+ nr, nc = fr + dr, fc + dc
601
+ if not self._in_bounds(obs, nr, nc):
602
+ continue
603
+ cell = obs.grid[nr][nc]
604
+ if cell.fuel_type == FuelType.WATER:
605
+ continue
606
+ if cell.fire_state in (FireState.BURNING, FireState.EMBER):
607
+ continue
608
+ if self._cell_danger(obs, nr, nc) >= 0.6:
609
+ continue
610
+
611
+ dist = abs(nr - tr) + abs(nc - tc)
612
+ if dist < best_dist:
613
+ best_dist = dist
614
+ best_dir = d
615
+
616
+ return best_dir
617
+
618
+ def _find_hottest_cluster(self, obs: Observation) -> Optional[tuple[int, int]]:
619
+ """Find the burning cell with highest intensity, preferring clusters."""
620
+ rows = len(obs.grid)
621
+ cols = len(obs.grid[0]) if rows > 0 else 0
622
+
623
+ best = None
624
+ best_score = -1.0
625
+
626
+ for row in obs.grid:
627
+ for cell in row:
628
+ if cell.fire_state != FireState.BURNING:
629
+ continue
630
+
631
+ # Base score from intensity
632
+ intensity_vals = {
633
+ IntensityBin.NONE: 0, IntensityBin.LOW: 0.25,
634
+ IntensityBin.MEDIUM: 0.5, IntensityBin.HIGH: 0.75,
635
+ IntensityBin.EXTREME: 1.0,
636
+ }
637
+ score = intensity_vals.get(cell.intensity_bin, 0.5)
638
+
639
+ # Bonus for burning neighbors (cluster)
640
+ for d in Direction:
641
+ dr, dc = DIRECTION_DELTAS[d]
642
+ nr, nc = cell.row + dr, cell.col + dc
643
+ if 0 <= nr < rows and 0 <= nc < cols:
644
+ if obs.grid[nr][nc].fire_state == FireState.BURNING:
645
+ score += 0.1
646
+
647
+ # Bonus for proximity to populated cells
648
+ for d in Direction:
649
+ dr, dc = DIRECTION_DELTAS[d]
650
+ for dist in range(1, 4):
651
+ nr, nc = cell.row + dr * dist, cell.col + dc * dist
652
+ if 0 <= nr < rows and 0 <= nc < cols:
653
+ if obs.grid[nr][nc].is_populated:
654
+ score += 0.5 / dist
655
+
656
+ if score > best_score:
657
+ best_score = score
658
+ best = (cell.row, cell.col)
659
+
660
+ return best
661
+
662
+ def _get_fire_perimeter_cells(self, obs: Observation) -> list[tuple[int, int]]:
663
+ """Get unburned cells adjacent to fire (the containment line)."""
664
+ rows = len(obs.grid)
665
+ cols = len(obs.grid[0]) if rows > 0 else 0
666
+ perimeter = set()
667
+
668
+ for row in obs.grid:
669
+ for cell in row:
670
+ if cell.fire_state not in (FireState.BURNING, FireState.EMBER):
671
+ continue
672
+ for d in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]:
673
+ nr, nc = cell.row + d[0], cell.col + d[1]
674
+ if 0 <= nr < rows and 0 <= nc < cols:
675
+ n_cell = obs.grid[nr][nc]
676
+ if n_cell.fire_state == FireState.UNBURNED:
677
+ perimeter.add((nr, nc))
678
+
679
+ return list(perimeter)
680
+
681
+ def _perimeter_cell_score(self, obs: Observation, r: int, c: int) -> float:
682
+ """Score a perimeter cell for defensive priority."""
683
+ score = 1.0
684
+
685
+ cell = obs.grid[r][c]
686
+
687
+ # Heavily prioritize cells near populated areas
688
+ pop_dist = self._nearest_populated_distance(obs, r, c)
689
+ if pop_dist is not None:
690
+ score += 5.0 / max(1, pop_dist)
691
+
692
+ # Prioritize downwind cells (fire will spread toward them)
693
+ wind_dir = obs.weather.wind_direction_deg
694
+ wind_rad = math.radians(wind_dir + 180) # Direction fire spreads
695
+ fire_dist = self._nearest_fire_distance(obs, r, c)
696
+ if fire_dist is not None and fire_dist <= 2:
697
+ score += 2.0
698
+
699
+ # Prioritize cells with high fuel load (will burn intensely if ignited)
700
+ fuel_vals = {FuelType.GRASS: 0.5, FuelType.SHRUB: 0.7, FuelType.TIMBER: 1.0, FuelType.URBAN: 1.5}
701
+ score += fuel_vals.get(cell.fuel_type, 0.3)
702
+
703
+ return score
704
+
705
+ def _nearest_populated_distance(self, obs: Observation, r: int, c: int) -> Optional[int]:
706
+ """Manhattan distance to nearest populated cell."""
707
+ min_dist = None
708
+ for row in obs.grid:
709
+ for cell in row:
710
+ if cell.is_populated and cell.fire_state != FireState.BURNED_OUT:
711
+ dist = abs(cell.row - r) + abs(cell.col - c)
712
+ if min_dist is None or dist < min_dist:
713
+ min_dist = dist
714
+ return min_dist
715
+
716
+ def _is_valid_firebreak(self, obs: Observation, r: int, c: int) -> bool:
717
+ """Check if cell is valid for firebreak construction."""
718
+ if not self._in_bounds(obs, r, c):
719
+ return False
720
+ cell = obs.grid[r][c]
721
+ return (cell.fire_state == FireState.UNBURNED
722
+ and cell.fuel_type not in (FuelType.WATER, FuelType.URBAN))
agents/random_agent.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Random agent baseline for the Wildfire Containment Simulator.
3
+
4
+ Selects random valid actions each step. Serves as the lower-bound
5
+ baseline for score comparison.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from env.models import (
13
+ Action, ActionType, Observation, Direction,
14
+ FireState, FuelType, DIRECTION_DELTAS,
15
+ )
16
+
17
+
18
+ class RandomAgent:
19
+ """Agent that picks a random valid action each step."""
20
+
21
+ def __init__(self, seed: int = 42):
22
+ self.rng = np.random.default_rng(seed)
23
+
24
+ def act(self, obs: Observation) -> Action:
25
+ """Select a random valid action given the current observation."""
26
+ # Collect available actions
27
+ candidates: list[Action] = []
28
+
29
+ # DEPLOY_CREW: deploy undeployed crews to safe cells
30
+ for crew in obs.resources.crews:
31
+ if crew.is_active and not crew.is_deployed:
32
+ safe_cells = self._get_safe_cells(obs)
33
+ if safe_cells:
34
+ r, c = safe_cells[self.rng.integers(0, len(safe_cells))]
35
+ candidates.append(Action(
36
+ action_type=ActionType.DEPLOY_CREW,
37
+ crew_id=crew.crew_id,
38
+ target_row=r, target_col=c,
39
+ ))
40
+
41
+ # MOVE_CREW: move deployed crews in random direction
42
+ for crew in obs.resources.crews:
43
+ if crew.is_active and crew.is_deployed:
44
+ valid_dirs = self._get_valid_move_dirs(obs, crew.row, crew.col)
45
+ if valid_dirs:
46
+ d = valid_dirs[self.rng.integers(0, len(valid_dirs))]
47
+ candidates.append(Action(
48
+ action_type=ActionType.MOVE_CREW,
49
+ crew_id=crew.crew_id,
50
+ direction=d,
51
+ ))
52
+
53
+ # DROP_RETARDANT: drop on burning area
54
+ for tanker in obs.resources.tankers:
55
+ if tanker.is_active and tanker.cooldown_remaining == 0:
56
+ burning = self._get_burning_cells(obs)
57
+ if burning:
58
+ r, c = burning[self.rng.integers(0, len(burning))]
59
+ candidates.append(Action(
60
+ action_type=ActionType.DROP_RETARDANT,
61
+ tanker_id=tanker.tanker_id,
62
+ target_row=r, target_col=c,
63
+ ))
64
+
65
+ # BUILD_FIREBREAK: if crew deployed and budget available
66
+ if obs.resources.firebreak_budget > 0:
67
+ for crew in obs.resources.crews:
68
+ if crew.is_active and crew.is_deployed:
69
+ dirs = list(Direction)
70
+ self.rng.shuffle(dirs)
71
+ for d in dirs:
72
+ dr, dc = DIRECTION_DELTAS[d]
73
+ nr, nc = crew.row + dr, crew.col + dc
74
+ if self._is_valid_firebreak_target(obs, nr, nc):
75
+ candidates.append(Action(
76
+ action_type=ActionType.BUILD_FIREBREAK,
77
+ crew_id=crew.crew_id,
78
+ direction=d,
79
+ ))
80
+ break
81
+
82
+ # IDLE: always available
83
+ candidates.append(Action(
84
+ action_type=ActionType.IDLE,
85
+ reason="Random agent waiting",
86
+ ))
87
+
88
+ # Pick random candidate
89
+ idx = self.rng.integers(0, len(candidates))
90
+ return candidates[idx]
91
+
92
+ def _get_safe_cells(self, obs: Observation) -> list[tuple[int, int]]:
93
+ """Get cells that are safe to deploy a crew to."""
94
+ safe = []
95
+ for row in obs.grid:
96
+ for cell in row:
97
+ if (cell.fire_state in (FireState.UNBURNED, FireState.FIREBREAK, FireState.SUPPRESSED)
98
+ and cell.fuel_type not in (FuelType.WATER,)
99
+ and not cell.crew_present):
100
+ safe.append((cell.row, cell.col))
101
+ # Sample a subset to avoid huge lists
102
+ if len(safe) > 20:
103
+ indices = self.rng.choice(len(safe), 20, replace=False)
104
+ safe = [safe[i] for i in indices]
105
+ return safe
106
+
107
+ def _get_valid_move_dirs(self, obs: Observation, row: int, col: int) -> list[Direction]:
108
+ """Get directions a crew can move from (row, col)."""
109
+ valid = []
110
+ rows = len(obs.grid)
111
+ cols = len(obs.grid[0]) if rows > 0 else 0
112
+ for d in Direction:
113
+ dr, dc = DIRECTION_DELTAS[d]
114
+ nr, nc = row + dr, col + dc
115
+ if 0 <= nr < rows and 0 <= nc < cols:
116
+ cell = obs.grid[nr][nc]
117
+ if (cell.fuel_type != FuelType.WATER
118
+ and cell.fire_state not in (FireState.UNKNOWN,)):
119
+ valid.append(d)
120
+ return valid
121
+
122
+ def _get_burning_cells(self, obs: Observation) -> list[tuple[int, int]]:
123
+ """Get cells that are currently burning."""
124
+ burning = []
125
+ for row in obs.grid:
126
+ for cell in row:
127
+ if cell.fire_state == FireState.BURNING:
128
+ burning.append((cell.row, cell.col))
129
+ return burning
130
+
131
+ def _is_valid_firebreak_target(self, obs: Observation, row: int, col: int) -> bool:
132
+ """Check if a cell is valid for firebreak construction."""
133
+ rows = len(obs.grid)
134
+ cols = len(obs.grid[0]) if rows > 0 else 0
135
+ if not (0 <= row < rows and 0 <= col < cols):
136
+ return False
137
+ cell = obs.grid[row][col]
138
+ return (cell.fire_state == FireState.UNBURNED
139
+ and cell.fuel_type not in (FuelType.WATER, FuelType.URBAN))
app.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from server.app import main; main()
demos/README.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Demo Assets
2
+
3
+ ## Regenerating demo assets
4
+
5
+ ```bash
6
+ # Find the best demo seed (scans seeds 0-499, takes ~5 min)
7
+ python scripts/find_demo_seed.py
8
+
9
+ # Run demo with default seed (DEMO_SEED = 7)
10
+ python scripts/run_demo.py
11
+
12
+ # Run with a specific seed
13
+ python scripts/run_demo.py --seed 42
14
+
15
+ # Run trained LLM comparison (requires TRAINED_MODEL_PATH env var)
16
+ python scripts/run_demo.py --agent trained_llm
17
+ ```
18
+
19
+ ## Output files
20
+
21
+ | File | Description |
22
+ |------|-------------|
23
+ | `heuristic_demo.gif` | Animated replay — heuristic agent on demo seed |
24
+ | `heuristic_demo.png` | Final frame PNG |
25
+ | `trained_demo.gif` | Animated replay — trained LLM agent (post-training) |
26
+ | `candidate_seeds.json` | Top 5 seeds from the seed finder scan |
27
+
28
+ ## Demo seed criteria
29
+
30
+ The chosen seed (`DEMO_SEED = 7`) was selected because:
31
+ - Wind shift fires between step 60-90, creating a mid-episode pivot moment
32
+ - Heuristic loses at least one populated cell (shows room for improvement)
33
+ - Total reward in the "flawed but not catastrophic" range (-4 to +2)
demos/heuristic_replay.gif ADDED

Git LFS Details

  • SHA256: 26592f216d657d05113bb745f7889d18262d576df973b39d5e7639dc6bce62e5
  • Pointer size: 131 Bytes
  • Size of remote file: 303 kB
demos/heuristic_replay.png ADDED
env/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wildfire Containment Simulator Environment."""
2
+ from .wildfire_env import WildfireEnv
3
+ from .models import (
4
+ Action, ActionType, Observation, StepResult,
5
+ TierConfig, TIER_EASY, TIER_MEDIUM, TIER_HARD,
6
+ Direction, FuelType, FireState, Priority,
7
+ )
8
+
9
+ __all__ = [
10
+ "WildfireEnv",
11
+ "Action", "ActionType", "Observation", "StepResult",
12
+ "TierConfig", "TIER_EASY", "TIER_MEDIUM", "TIER_HARD",
13
+ "Direction", "FuelType", "FireState", "Priority",
14
+ ]
env/action_parser.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Robust LLM output → Action parser with 3-layer fallback.
3
+
4
+ Layer 1: Direct JSON parse
5
+ Layer 2: Regex field extraction
6
+ Layer 3: Safe IDLE fallback
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from typing import TYPE_CHECKING, Tuple
14
+
15
+ from .models import Action, ActionType, Direction
16
+
17
+ if TYPE_CHECKING:
18
+ from .models import Observation
19
+
20
+ _SAFE_IDLE = Action(action_type=ActionType.IDLE, reason="parse_failure")
21
+
22
+ _ACTION_TYPES = {a.value for a in ActionType}
23
+ _DIRECTIONS = {d.value for d in Direction}
24
+
25
+
26
+ def parse_action(llm_output: str, obs: "Observation") -> Tuple[Action, str]:
27
+ """
28
+ Convert raw LLM text into a validated Action.
29
+
30
+ Returns (action, status) where status is one of:
31
+ "json_success", "regex_fallback", "safe_idle"
32
+ """
33
+ grid_rows = len(obs.grid)
34
+ grid_cols = len(obs.grid[0]) if grid_rows > 0 else 0
35
+
36
+ # Layer 1 — direct JSON
37
+ action, status = _try_json(llm_output)
38
+ if action is not None:
39
+ action = _bounds_check(action, grid_rows, grid_cols)
40
+ return action, status
41
+
42
+ # Layer 2 — regex
43
+ action, status = _try_regex(llm_output)
44
+ if action is not None:
45
+ action = _bounds_check(action, grid_rows, grid_cols)
46
+ return action, status
47
+
48
+ # Layer 3 — safe fallback
49
+ return _SAFE_IDLE, "safe_idle"
50
+
51
+
52
+ # ── Layer 1 ──────────────────────────────────────────────────
53
+
54
+ def _try_json(text: str) -> Tuple[Action | None, str]:
55
+ raw = _extract_json_block(text)
56
+ if raw is None:
57
+ return None, "safe_idle"
58
+ try:
59
+ data = json.loads(raw)
60
+ if not isinstance(data, dict):
61
+ return None, "safe_idle"
62
+ # Normalise action_type casing
63
+ if "action_type" in data:
64
+ data["action_type"] = str(data["action_type"]).lower()
65
+ if data.get("action_type") not in _ACTION_TYPES:
66
+ return None, "safe_idle"
67
+ action = Action(**data)
68
+ return action, "json_success"
69
+ except Exception:
70
+ return None, "safe_idle"
71
+
72
+
73
+ def _extract_json_block(text: str) -> str | None:
74
+ """Find first balanced {...} block, stripping ```json fences."""
75
+ # Strip code fences
76
+ text = re.sub(r"```(?:json)?\s*", "", text)
77
+ text = text.replace("```", "")
78
+
79
+ start = text.find("{")
80
+ if start == -1:
81
+ return None
82
+
83
+ depth = 0
84
+ for i, ch in enumerate(text[start:], start=start):
85
+ if ch == "{":
86
+ depth += 1
87
+ elif ch == "}":
88
+ depth -= 1
89
+ if depth == 0:
90
+ return text[start : i + 1]
91
+ return None
92
+
93
+
94
+ # ── Layer 2 ──────────────────────────────────────────────────
95
+
96
+ def _try_regex(text: str) -> Tuple[Action | None, str]:
97
+ # action_type
98
+ at_match = re.search(
99
+ r'action_type["\s:]+["\']?(' + "|".join(_ACTION_TYPES) + r")[\"']?",
100
+ text,
101
+ re.IGNORECASE,
102
+ )
103
+ if not at_match:
104
+ return None, "safe_idle"
105
+
106
+ action_type = at_match.group(1).lower()
107
+
108
+ def _str(pattern: str) -> str | None:
109
+ m = re.search(pattern, text, re.IGNORECASE)
110
+ return m.group(1) if m else None
111
+
112
+ def _int(pattern: str) -> int | None:
113
+ m = re.search(pattern, text, re.IGNORECASE)
114
+ return int(m.group(1)) if m else None
115
+
116
+ crew_id = _str(r'crew_id["\s:]+["\']?(crew_\d+)["\']?')
117
+ tanker_id = _str(r'tanker_id["\s:]+["\']?(tanker_\d+)["\']?')
118
+ target_row = _int(r'target_row["\s:]+(\d+)')
119
+ target_col = _int(r'target_col["\s:]+(\d+)')
120
+ direction_raw = _str(
121
+ r'direction["\s:]+["\']?(' + "|".join(_DIRECTIONS) + r")[\"']?"
122
+ )
123
+ direction = direction_raw.upper() if direction_raw else None
124
+
125
+ try:
126
+ action = Action(
127
+ action_type=action_type,
128
+ crew_id=crew_id,
129
+ tanker_id=tanker_id,
130
+ target_row=target_row,
131
+ target_col=target_col,
132
+ direction=direction,
133
+ )
134
+ return action, "regex_fallback"
135
+ except Exception:
136
+ return None, "safe_idle"
137
+
138
+
139
+ # ── Bounds check ─────────────────────────────────────────────
140
+
141
+ def _bounds_check(action: Action, grid_rows: int, grid_cols: int) -> Action:
142
+ """Downgrade to IDLE if target coords are outside the grid."""
143
+ row, col = action.target_row, action.target_col
144
+ if row is None and col is None:
145
+ return action
146
+ if row is None or col is None:
147
+ return _SAFE_IDLE
148
+ if not (0 <= row < grid_rows and 0 <= col < grid_cols):
149
+ return _SAFE_IDLE
150
+ return action
env/briefing.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Operational briefing system — generates a structured incident briefing on reset().
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import random
8
+ from typing import TYPE_CHECKING, List, Optional, Tuple
9
+
10
+ from pydantic import BaseModel
11
+
12
+ if TYPE_CHECKING:
13
+ import numpy as np
14
+ from .grid import Grid
15
+ from .models import TierConfig
16
+
17
+ _IGNITION_CAUSES = [
18
+ "Lightning strike",
19
+ "Downed power line",
20
+ "Unattended campfire",
21
+ "Equipment spark",
22
+ "Arson (under investigation)",
23
+ "Vehicle exhaust",
24
+ ]
25
+
26
+ _INFRA_LABELS = ["North Road", "East Road", "Supply Route", "Evacuation Corridor"]
27
+
28
+ _WIND_SHIFT_FORECASTS = [
29
+ "Wind shift southwest expected by step 60.",
30
+ "Forecast: wind backing to northwest by step 70, speed increasing.",
31
+ "Weather service warns of sudden direction change near step 65.",
32
+ "Dry front approaching — wind shift likely between steps 55 and 80.",
33
+ ]
34
+
35
+ _GENERIC_FORECASTS = [
36
+ "Humidity expected to drop below 20% by mid-episode.",
37
+ "Elevated fire weather conditions through entire operational period.",
38
+ "No precipitation expected. Fire behavior will remain extreme.",
39
+ "Overnight humidity recovery may assist suppression after step 100.",
40
+ ]
41
+
42
+
43
+ class OperationalBriefing(BaseModel):
44
+ incident_id: str
45
+ ignition_cause: str
46
+ priority_populated_zones: List[Tuple[int, int]]
47
+ priority_infrastructure: List[Tuple[int, int]]
48
+ forecast_events: List[str]
49
+ declared_time: str
50
+
51
+
52
+ def generate_briefing(
53
+ tier_config: "TierConfig",
54
+ rng: "np.random.Generator",
55
+ grid: "Grid",
56
+ ) -> OperationalBriefing:
57
+ py_rng = random.Random(int(rng.integers(0, 2**31)))
58
+
59
+ # Pick top 2 largest populated clusters by population count
60
+ pop_cells: List[Tuple[int, int, int]] = [] # (row, col, population)
61
+ for r in range(grid.rows):
62
+ for c in range(grid.cols):
63
+ static = grid.static_grid[r][c]
64
+ if static.is_populated and static.population > 0:
65
+ pop_cells.append((r, c, static.population))
66
+
67
+ pop_cells.sort(key=lambda x: x[2], reverse=True)
68
+ priority_zones = [(r, c) for r, c, _ in pop_cells[:2]]
69
+
70
+ # Road cells as infrastructure (up to 2)
71
+ from .models import FuelType
72
+ road_cells = [
73
+ (r, c)
74
+ for r in range(grid.rows)
75
+ for c in range(grid.cols)
76
+ if grid.static_grid[r][c].fuel_type == FuelType.ROAD
77
+ ]
78
+ # Pick a sample spread across the grid
79
+ step = max(1, len(road_cells) // 2)
80
+ infra = road_cells[::step][:2]
81
+
82
+ # Forecast events
83
+ forecasts = []
84
+ if tier_config.enable_wind_shifts:
85
+ forecasts.append(py_rng.choice(_WIND_SHIFT_FORECASTS))
86
+ forecasts.append(py_rng.choice(_GENERIC_FORECASTS))
87
+
88
+ # Incident ID
89
+ hour = py_rng.randint(0, 23)
90
+ minute = py_rng.choice([0, 15, 30, 45])
91
+ incident_id = f"WF-{tier_config.tier_name.upper()[:3]}-{py_rng.randint(1000, 9999)}"
92
+ declared_time = f"{hour:02d}:{minute:02d}"
93
+
94
+ return OperationalBriefing(
95
+ incident_id=incident_id,
96
+ ignition_cause=py_rng.choice(_IGNITION_CAUSES),
97
+ priority_populated_zones=priority_zones,
98
+ priority_infrastructure=infra,
99
+ forecast_events=forecasts,
100
+ declared_time=declared_time,
101
+ )
102
+
103
+
104
+ def briefing_to_text(briefing: OperationalBriefing) -> str:
105
+ zone_list = ", ".join(f"({r},{c})" for r, c in briefing.priority_populated_zones) or "none identified"
106
+ infra_list = ", ".join(f"({r},{c})" for r, c in briefing.priority_infrastructure) or "none identified"
107
+ forecast_lines = "\n".join(f"- {f}" for f in briefing.forecast_events)
108
+
109
+ return (
110
+ f"=== OPERATIONAL BRIEFING ===\n"
111
+ f"Incident {briefing.incident_id} declared at {briefing.declared_time}.\n"
112
+ f"Cause: {briefing.ignition_cause}.\n"
113
+ f"\n"
114
+ f"PRIORITY 1: Protect populated zones at {zone_list}.\n"
115
+ f"PRIORITY 2: Maintain routes at {infra_list} open where possible.\n"
116
+ f"\n"
117
+ f"FORECAST:\n"
118
+ f"{forecast_lines}\n"
119
+ f"\n"
120
+ f"Commander's intent: Contain fire with zero civilian casualties. "
121
+ f"Preserve crew safety."
122
+ )
env/curriculum.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional, Tuple
4
+
5
+ _TIERS = ["easy", "medium", "hard"]
6
+
7
+ _DEFAULT_THRESHOLDS = {
8
+ "easy": 4.0, # promote easy→medium when 10-ep avg >= 4.0
9
+ "medium": 3.5, # promote medium→hard when 10-ep avg >= 3.5
10
+ }
11
+
12
+ _WINDOW = 10
13
+
14
+
15
+ class CurriculumController:
16
+ def __init__(
17
+ self,
18
+ start_tier: str = "easy",
19
+ thresholds: Optional[dict] = None,
20
+ ) -> None:
21
+ self._tier = start_tier
22
+ self._thresholds = thresholds if thresholds is not None else dict(_DEFAULT_THRESHOLDS)
23
+ self._episode_idx = 0
24
+ self._history: List[Tuple[int, str, float]] = []
25
+ self.promotion_log: List[Tuple[int, str]] = []
26
+
27
+ def after_episode(self, total_reward: float) -> Optional[str]:
28
+ self._history.append((self._episode_idx, self._tier, total_reward))
29
+ self._episode_idx += 1
30
+
31
+ recent = [r for _, t, r in self._history[-_WINDOW:] if t == self._tier]
32
+ if len(recent) < _WINDOW:
33
+ return None
34
+
35
+ avg = sum(recent) / len(recent)
36
+ tier_idx = _TIERS.index(self._tier)
37
+
38
+ # Promote
39
+ promote_threshold = self._thresholds.get(self._tier)
40
+ if promote_threshold is not None and avg >= promote_threshold:
41
+ if tier_idx < len(_TIERS) - 1:
42
+ new_tier = _TIERS[tier_idx + 1]
43
+ self._tier = new_tier
44
+ self.promotion_log.append((self._episode_idx - 1, new_tier))
45
+ return new_tier
46
+
47
+ # Demote
48
+ if tier_idx > 0:
49
+ prev_tier = _TIERS[tier_idx - 1]
50
+ demote_threshold = self._thresholds.get(prev_tier)
51
+ if demote_threshold is not None and avg < demote_threshold * 0.5:
52
+ self._tier = prev_tier
53
+ self.promotion_log.append((self._episode_idx - 1, prev_tier))
54
+ return prev_tier
55
+
56
+ return None
57
+
58
+ def get_tier(self) -> str:
59
+ return self._tier
60
+
61
+ def get_history(self) -> List[Tuple[int, str, float]]:
62
+ return list(self._history)
env/fire_spread.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fire spread engine for the Wildfire Containment Simulator.
3
+
4
+ Implements a Rothermel-inspired cellular automaton where each burning cell
5
+ attempts to ignite its 8 neighbors based on fuel, wind, slope, moisture,
6
+ and suppression factors.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import numpy as np
14
+
15
+ from .models import FireState, FuelType
16
+ from .grid import Grid
17
+
18
+
19
+ # Base ignition rates by fuel type (tuned for balanced gameplay)
20
+ BASE_RATES: dict[FuelType, float] = {
21
+ FuelType.GRASS: 0.25,
22
+ FuelType.SHRUB: 0.18,
23
+ FuelType.TIMBER: 0.12,
24
+ FuelType.URBAN: 0.12, # 0.5x effective (applied separately)
25
+ FuelType.WATER: 0.0,
26
+ FuelType.ROAD: 0.0,
27
+ }
28
+
29
+ # Burn duration (steps before burnout) by fuel type
30
+ BURN_DURATION: dict[FuelType, int] = {
31
+ FuelType.GRASS: 4,
32
+ FuelType.SHRUB: 6,
33
+ FuelType.TIMBER: 10,
34
+ FuelType.URBAN: 8,
35
+ FuelType.WATER: 0,
36
+ FuelType.ROAD: 0,
37
+ }
38
+
39
+ # Intensity multiplier for urban structures
40
+ URBAN_INTENSITY_MULT = 2.0
41
+ URBAN_IGNITION_MULT = 0.5
42
+
43
+ # 8-neighbor offsets (row_delta, col_delta)
44
+ NEIGHBORS = [
45
+ (-1, -1), (-1, 0), (-1, 1),
46
+ (0, -1), (0, 1),
47
+ (1, -1), (1, 0), (1, 1),
48
+ ]
49
+
50
+
51
+ class FireSpreadEngine:
52
+ """
53
+ Manages fire propagation across the grid each simulation step.
54
+
55
+ The spread model computes per-neighbor ignition probabilities using:
56
+ P(ignite) = base_rate * fuel_factor * wind_factor * slope_factor
57
+ * (1 - moisture) * (1 - suppression) * tier_scale
58
+ """
59
+
60
+ # Tier-based difficulty scaling for spread rate
61
+ # Tuned so that: random agent ~0.2-0.4, heuristic ~0.6-0.8 on easy
62
+ TIER_SPREAD_SCALE = {
63
+ "easy": 1.0,
64
+ "medium": 0.7,
65
+ "hard": 0.55,
66
+ }
67
+
68
+ def __init__(self, grid: Grid, rng: np.random.Generator):
69
+ self.grid = grid
70
+ self.rng = rng
71
+ self.cell_size_m = 100.0 # Each cell represents 100m x 100m
72
+ self.tier_scale = self.TIER_SPREAD_SCALE.get(grid.config.tier_name, 0.5)
73
+
74
+ def spread_step(self, wind_speed: float, wind_dir_deg: float) -> list[str]:
75
+ """
76
+ Execute one step of fire spread.
77
+
78
+ 1. For each BURNING cell, attempt to ignite neighbors.
79
+ 2. Update intensities (grow/decay).
80
+ 3. Transition cells that have exhausted fuel to BURNED_OUT.
81
+
82
+ Returns a list of event strings for the observation log.
83
+ """
84
+ events: list[str] = []
85
+ grid = self.grid
86
+
87
+ # Collect currently burning cells (snapshot to avoid iteration issues)
88
+ burning_cells = []
89
+ for r in range(grid.rows):
90
+ for c in range(grid.cols):
91
+ if grid.dynamic_grid[r][c].fire_state == FireState.BURNING:
92
+ burning_cells.append((r, c))
93
+
94
+ # Phase 1: Attempt ignition of neighbors
95
+ new_ignitions: list[tuple[int, int, float]] = []
96
+
97
+ for r, c in burning_cells:
98
+ source_dyn = grid.dynamic_grid[r][c]
99
+ source_static = grid.static_grid[r][c]
100
+
101
+ for dr, dc in NEIGHBORS:
102
+ nr, nc = r + dr, c + dc
103
+ if not grid._in_bounds(nr, nc):
104
+ continue
105
+
106
+ target_static = grid.static_grid[nr][nc]
107
+ target_dyn = grid.dynamic_grid[nr][nc]
108
+
109
+ # Skip non-ignitable cells
110
+ if target_static.fuel_type in (FuelType.WATER, FuelType.ROAD):
111
+ continue
112
+ if target_dyn.fire_state != FireState.UNBURNED:
113
+ continue
114
+
115
+ # Compute ignition probability
116
+ prob = self._compute_ignition_prob(
117
+ source_r=r, source_c=c,
118
+ target_r=nr, target_c=nc,
119
+ source_intensity=source_dyn.fire_intensity,
120
+ wind_speed=wind_speed,
121
+ wind_dir_deg=wind_dir_deg,
122
+ )
123
+
124
+ if self.rng.random() < prob:
125
+ # Initial intensity depends on source and fuel
126
+ init_intensity = 0.2 + source_dyn.fire_intensity * 0.3
127
+ new_ignitions.append((nr, nc, init_intensity))
128
+
129
+ # Apply new ignitions
130
+ for nr, nc, intensity in new_ignitions:
131
+ if grid.dynamic_grid[nr][nc].fire_state == FireState.UNBURNED:
132
+ grid.ignite_cell(nr, nc, intensity)
133
+ if grid.static_grid[nr][nc].is_populated:
134
+ pop = grid.static_grid[nr][nc].population
135
+ events.append(f"FIRE reached populated cell ({nr},{nc}) with {pop} people!")
136
+
137
+ # Phase 2: Update intensities and burn timers
138
+ for r in range(grid.rows):
139
+ for c in range(grid.cols):
140
+ dyn = grid.dynamic_grid[r][c]
141
+ static = grid.static_grid[r][c]
142
+
143
+ if dyn.fire_state == FireState.BURNING:
144
+ dyn.time_burning += 1
145
+ max_dur = BURN_DURATION.get(static.fuel_type, 6)
146
+
147
+ # Intensity curve: ramp up, peak, decay
148
+ peak_step = max_dur // 3
149
+ if dyn.time_burning <= peak_step:
150
+ # Ramp up
151
+ growth = 0.15 * static.fuel_load
152
+ if static.fuel_type == FuelType.URBAN:
153
+ growth *= URBAN_INTENSITY_MULT
154
+ dyn.fire_intensity = min(1.0, dyn.fire_intensity + growth)
155
+ elif dyn.time_burning <= 2 * peak_step:
156
+ # Peak / plateau
157
+ pass
158
+ else:
159
+ # Decay
160
+ decay = 0.1
161
+ dyn.fire_intensity = max(0.05, dyn.fire_intensity - decay)
162
+
163
+ # Apply suppression reduction
164
+ if dyn.suppression_level > 0:
165
+ dyn.fire_intensity = max(0.0, dyn.fire_intensity - dyn.suppression_level * 0.1)
166
+
167
+ # Check for burnout
168
+ if dyn.time_burning >= max_dur or dyn.fire_intensity <= 0.0:
169
+ dyn.fire_state = FireState.BURNED_OUT
170
+ dyn.fire_intensity = 0.0
171
+ events.append(f"Cell ({r},{c}) burned out.")
172
+
173
+ # Transition to ember if intensity low
174
+ elif dyn.fire_intensity < 0.15 and dyn.time_burning > peak_step:
175
+ dyn.fire_state = FireState.EMBER
176
+
177
+ elif dyn.fire_state == FireState.EMBER:
178
+ dyn.time_burning += 1
179
+ dyn.fire_intensity = max(0.0, dyn.fire_intensity - 0.05)
180
+ max_dur = BURN_DURATION.get(static.fuel_type, 6)
181
+ if dyn.time_burning >= max_dur + 3 or dyn.fire_intensity <= 0.0:
182
+ dyn.fire_state = FireState.BURNED_OUT
183
+ dyn.fire_intensity = 0.0
184
+
185
+ if new_ignitions:
186
+ events.append(f"{len(new_ignitions)} new cell(s) ignited this step.")
187
+
188
+ return events
189
+
190
+ def _compute_ignition_prob(
191
+ self,
192
+ source_r: int, source_c: int,
193
+ target_r: int, target_c: int,
194
+ source_intensity: float,
195
+ wind_speed: float,
196
+ wind_dir_deg: float,
197
+ ) -> float:
198
+ """Compute probability of fire spreading from source to target cell."""
199
+ target_static = self.grid.static_grid[target_r][target_c]
200
+ target_dyn = self.grid.dynamic_grid[target_r][target_c]
201
+
202
+ # Base rate by fuel type
203
+ base = BASE_RATES.get(target_static.fuel_type, 0.0)
204
+ if base <= 0:
205
+ return 0.0
206
+
207
+ # Urban ignition penalty
208
+ if target_static.fuel_type == FuelType.URBAN:
209
+ base *= URBAN_IGNITION_MULT
210
+
211
+ # Fuel factor
212
+ fuel_factor = target_static.fuel_load
213
+
214
+ # Source intensity factor (hotter fires spread faster)
215
+ intensity_factor = 0.5 + source_intensity * 0.5
216
+
217
+ # Wind factor
218
+ wind_factor = self._compute_wind_factor(
219
+ source_r, source_c, target_r, target_c,
220
+ wind_speed, wind_dir_deg
221
+ )
222
+
223
+ # Slope factor (fire travels uphill faster)
224
+ slope_factor = self._compute_slope_factor(source_r, source_c, target_r, target_c)
225
+
226
+ # Moisture dampening
227
+ moisture_factor = 1.0 - target_dyn.moisture
228
+
229
+ # Suppression dampening
230
+ suppression_factor = 1.0 - target_dyn.suppression_level
231
+
232
+ prob = (base * fuel_factor * intensity_factor * wind_factor
233
+ * slope_factor * moisture_factor * suppression_factor
234
+ * self.tier_scale)
235
+
236
+ return float(np.clip(prob, 0.0, 0.95)) # Cap at 95%
237
+
238
+ def _compute_wind_factor(
239
+ self,
240
+ sr: int, sc: int, tr: int, tc: int,
241
+ wind_speed: float, wind_dir_deg: float,
242
+ ) -> float:
243
+ """
244
+ Wind factor: fire spreads faster downwind.
245
+ wind_dir_deg is the direction wind blows FROM (meteorological convention).
246
+ So fire spreads in the opposite direction.
247
+ """
248
+ if wind_speed < 1.0:
249
+ return 1.0
250
+
251
+ # Direction from source to target
252
+ dr = tr - sr
253
+ dc = tc - sc
254
+ spread_angle = math.atan2(dc, -dr) # -dr because row increases downward
255
+
256
+ # Wind blows FROM wind_dir, so fire spreads TOWARD wind_dir + 180
257
+ wind_rad = math.radians(wind_dir_deg + 180)
258
+
259
+ angle_diff = spread_angle - wind_rad
260
+ cos_diff = math.cos(angle_diff)
261
+
262
+ # Scale: 1.0 at crosswind, up to 2.5 downwind, down to 0.3 upwind
263
+ factor = 1.0 + cos_diff * min(wind_speed / 40.0, 1.5)
264
+ return max(0.3, factor)
265
+
266
+ def _compute_slope_factor(
267
+ self, sr: int, sc: int, tr: int, tc: int
268
+ ) -> float:
269
+ """Slope factor: fire accelerates uphill."""
270
+ source_elev = self.grid.static_grid[sr][sc].elevation_m
271
+ target_elev = self.grid.static_grid[tr][tc].elevation_m
272
+ elev_diff = target_elev - source_elev
273
+
274
+ # Positive diff = uphill = faster spread
275
+ factor = 1.0 + 0.3 * max(0.0, elev_diff / self.cell_size_m)
276
+ # Slight slowdown going downhill
277
+ if elev_diff < 0:
278
+ factor = max(0.7, 1.0 + 0.1 * elev_diff / self.cell_size_m)
279
+
280
+ return float(np.clip(factor, 0.5, 2.0))
env/grid.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Grid terrain simulation for the Wildfire Containment Simulator.
3
+
4
+ Manages the NxM grid of cells, including terrain generation, cell state updates,
5
+ smoke propagation, and moisture dynamics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+
15
+ from .models import (
16
+ CellStatic, CellDynamic, CellObservation, FireState, FuelType,
17
+ IntensityBin, TierConfig,
18
+ )
19
+
20
+
21
+ class Grid:
22
+ """
23
+ NxM grid of terrain cells with static properties and dynamic state.
24
+
25
+ Attributes:
26
+ rows: Number of rows in the grid.
27
+ cols: Number of columns in the grid.
28
+ static_grid: 2D list of CellStatic (immutable terrain).
29
+ dynamic_grid: 2D list of CellDynamic (mutable fire/moisture/smoke state).
30
+ """
31
+
32
+ def __init__(self, config: TierConfig, rng: np.random.Generator):
33
+ self.rows = config.grid_rows
34
+ self.cols = config.grid_cols
35
+ self.config = config
36
+ self.rng = rng
37
+
38
+ # Initialize grids
39
+ self.static_grid: list[list[CellStatic]] = []
40
+ self.dynamic_grid: list[list[CellDynamic]] = []
41
+
42
+ self._generate_terrain()
43
+
44
+ def _generate_terrain(self) -> None:
45
+ """Generate terrain based on tier configuration."""
46
+ rows, cols = self.rows, self.cols
47
+
48
+ # Generate elevation map using simple gradient + noise
49
+ elevation = np.zeros((rows, cols))
50
+ if self.config.tier_name == "easy":
51
+ # Flat terrain
52
+ elevation[:] = 0.0
53
+ elif self.config.tier_name == "medium":
54
+ # Valley: low center, higher edges (canyon terrain)
55
+ for r in range(rows):
56
+ for c in range(cols):
57
+ dist_from_center = abs(c - cols // 2) / (cols // 2)
58
+ elevation[r, c] = dist_from_center * 500.0
59
+ elevation += self.rng.normal(0, 20, (rows, cols))
60
+ elevation = np.clip(elevation, 0, 500)
61
+ else:
62
+ # Complex terrain with ridges and valleys
63
+ for r in range(rows):
64
+ for c in range(cols):
65
+ # Create a ridge running diagonally
66
+ ridge = math.sin(r / 8.0) * 400 + math.cos(c / 6.0) * 300
67
+ elevation[r, c] = max(0, ridge + 300)
68
+ elevation += self.rng.normal(0, 40, (rows, cols))
69
+ elevation = np.clip(elevation, 0, 1200)
70
+
71
+ # Generate fuel type map
72
+ fuel_map = self._generate_fuel_map()
73
+
74
+ # Place water bodies
75
+ water_cells = self._place_water()
76
+
77
+ # Place populated zones
78
+ pop_cells = self._place_populations()
79
+
80
+ # Build static grid
81
+ self.static_grid = []
82
+ for r in range(rows):
83
+ row = []
84
+ for c in range(cols):
85
+ ft = fuel_map[r][c]
86
+ is_water = (r, c) in water_cells
87
+ if is_water:
88
+ ft = FuelType.WATER
89
+
90
+ pop = pop_cells.get((r, c), 0)
91
+ fuel_load = self._fuel_load_for_type(ft)
92
+
93
+ cell = CellStatic(
94
+ row=r, col=c,
95
+ elevation_m=float(elevation[r, c]),
96
+ fuel_type=ft,
97
+ fuel_load=fuel_load,
98
+ is_populated=pop > 0,
99
+ population=pop,
100
+ is_water=is_water,
101
+ )
102
+ row.append(cell)
103
+ self.static_grid.append(row)
104
+
105
+ # Build dynamic grid (all unburned, default moisture)
106
+ base_moisture = 0.3 if self.config.humidity_init < 50 else 0.5
107
+ self.dynamic_grid = []
108
+ for r in range(rows):
109
+ row = []
110
+ for c in range(cols):
111
+ moisture = base_moisture + self.rng.normal(0, 0.05)
112
+ moisture = float(np.clip(moisture, 0.05, 0.95))
113
+ row.append(CellDynamic(moisture=moisture))
114
+ self.dynamic_grid.append(row)
115
+
116
+ def _generate_fuel_map(self) -> list[list[FuelType]]:
117
+ """Generate fuel types based on tier."""
118
+ rows, cols = self.rows, self.cols
119
+ fuel_map = [[FuelType.GRASS for _ in range(cols)] for _ in range(rows)]
120
+
121
+ if self.config.tier_name == "easy":
122
+ # All grass, simple
123
+ pass
124
+ elif self.config.tier_name == "medium":
125
+ # Valley floor = grass, hillsides = shrub, ridgeline = timber
126
+ for r in range(rows):
127
+ for c in range(cols):
128
+ dist = abs(c - cols // 2) / (cols // 2)
129
+ if dist > 0.7:
130
+ fuel_map[r][c] = FuelType.TIMBER
131
+ elif dist > 0.35:
132
+ fuel_map[r][c] = FuelType.SHRUB
133
+ else:
134
+ # Complex mixed terrain with some roads and urban
135
+ for r in range(rows):
136
+ for c in range(cols):
137
+ val = self.rng.random()
138
+ if val < 0.35:
139
+ fuel_map[r][c] = FuelType.GRASS
140
+ elif val < 0.60:
141
+ fuel_map[r][c] = FuelType.SHRUB
142
+ elif val < 0.85:
143
+ fuel_map[r][c] = FuelType.TIMBER
144
+ else:
145
+ fuel_map[r][c] = FuelType.GRASS # Will assign urban/road below
146
+
147
+ # Place roads (horizontal and vertical corridors)
148
+ road_row = rows // 3
149
+ road_col = cols // 2
150
+ for c in range(cols):
151
+ fuel_map[road_row][c] = FuelType.ROAD
152
+ for r in range(rows):
153
+ fuel_map[r][road_col] = FuelType.ROAD
154
+
155
+ return fuel_map
156
+
157
+ def _place_water(self) -> set[tuple[int, int]]:
158
+ """Place water bodies on the grid."""
159
+ water = set()
160
+ rows, cols = self.rows, self.cols
161
+
162
+ if self.config.tier_name == "easy":
163
+ # 2 small water patches
164
+ water.add((rows // 4, cols // 4))
165
+ water.add((rows // 4, cols // 4 + 1))
166
+ water.add((3 * rows // 4, 3 * cols // 4))
167
+ water.add((3 * rows // 4, 3 * cols // 4 + 1))
168
+ elif self.config.tier_name == "medium":
169
+ # Small lake in valley
170
+ cr, cc = rows // 2, cols // 2
171
+ for dr in range(-1, 2):
172
+ for dc in range(-1, 2):
173
+ r, c = cr + dr, cc + dc
174
+ if 0 <= r < rows and 0 <= c < cols:
175
+ water.add((r, c))
176
+ else:
177
+ # River running vertically + small lake
178
+ river_col = cols // 4
179
+ for r in range(rows // 3, 2 * rows // 3):
180
+ water.add((r, river_col))
181
+ water.add((r, river_col + 1))
182
+ # Small lake
183
+ lake_r, lake_c = 3 * rows // 4, 3 * cols // 4
184
+ for dr in range(-2, 3):
185
+ for dc in range(-2, 3):
186
+ r, c = lake_r + dr, lake_c + dc
187
+ if 0 <= r < rows and 0 <= c < cols:
188
+ if abs(dr) + abs(dc) <= 3:
189
+ water.add((r, c))
190
+ return water
191
+
192
+ def _place_populations(self) -> dict[tuple[int, int], int]:
193
+ """Place populated zones. Returns dict of (row, col) -> population."""
194
+ pop = {}
195
+ rows, cols = self.rows, self.cols
196
+
197
+ if self.config.tier_name == "easy":
198
+ # 2 small clusters near edges
199
+ for dr in range(2):
200
+ for dc in range(2):
201
+ pop[(1 + dr, 1 + dc)] = 3
202
+ pop[(rows - 3 + dr, cols - 3 + dc)] = 2
203
+ elif self.config.tier_name == "medium":
204
+ # 3 settlements in valley floor
205
+ positions = [(rows // 4, cols // 2), (rows // 2, cols // 3), (3 * rows // 4, cols // 2 + 2)]
206
+ pops = [20, 15, 15]
207
+ for (pr, pc), p in zip(positions, pops):
208
+ for dr in range(-1, 2):
209
+ for dc in range(-1, 2):
210
+ r, c = pr + dr, pc + dc
211
+ if 0 <= r < rows and 0 <= c < cols:
212
+ pop[(r, c)] = p // 9 + 1
213
+ else:
214
+ # 1 town + 4 rural clusters
215
+ # Town center
216
+ town_r, town_c = 3 * rows // 4, cols // 2
217
+ for dr in range(-2, 3):
218
+ for dc in range(-2, 3):
219
+ r, c = town_r + dr, town_c + dc
220
+ if 0 <= r < rows and 0 <= c < cols:
221
+ pop[(r, c)] = 8
222
+ # Mark as urban in fuel map (will be set after static grid build)
223
+ # Rural clusters
224
+ rural_centers = [
225
+ (rows // 5, cols // 5),
226
+ (rows // 5, 4 * cols // 5),
227
+ (2 * rows // 3, cols // 5),
228
+ (rows // 3, 3 * cols // 4),
229
+ ]
230
+ for cr, cc in rural_centers:
231
+ for dr in range(-1, 2):
232
+ for dc in range(-1, 2):
233
+ r, c = cr + dr, cc + dc
234
+ if 0 <= r < rows and 0 <= c < cols:
235
+ pop[(r, c)] = 4
236
+
237
+ return pop
238
+
239
+ def _fuel_load_for_type(self, ft: FuelType) -> float:
240
+ """Default fuel load by fuel type."""
241
+ loads = {
242
+ FuelType.GRASS: 0.7,
243
+ FuelType.SHRUB: 0.8,
244
+ FuelType.TIMBER: 0.9,
245
+ FuelType.URBAN: 0.6,
246
+ FuelType.WATER: 0.0,
247
+ FuelType.ROAD: 0.0,
248
+ }
249
+ base = loads.get(ft, 0.5)
250
+ noise = float(self.rng.normal(0, 0.05))
251
+ return float(np.clip(base + noise, 0.0, 1.0))
252
+
253
+ # ─── Ignition ─────────────────────────────────────
254
+
255
+ def ignite_cell(self, row: int, col: int, intensity: float = 0.3) -> bool:
256
+ """
257
+ Ignite a cell. Returns True if successful.
258
+ Cannot ignite water, road, firebreak, or already-burning cells.
259
+ """
260
+ if not self._in_bounds(row, col):
261
+ return False
262
+
263
+ static = self.static_grid[row][col]
264
+ dynamic = self.dynamic_grid[row][col]
265
+
266
+ if static.fuel_type in (FuelType.WATER, FuelType.ROAD):
267
+ return False
268
+ if dynamic.fire_state in (FireState.BURNING, FireState.EMBER, FireState.BURNED_OUT,
269
+ FireState.FIREBREAK, FireState.SUPPRESSED):
270
+ return False
271
+
272
+ dynamic.fire_state = FireState.BURNING
273
+ dynamic.fire_intensity = float(np.clip(intensity, 0.1, 1.0))
274
+ dynamic.time_burning = 0
275
+ return True
276
+
277
+ # ─── Smoke Propagation ────────────────────────────
278
+
279
+ def propagate_smoke(self, wind_dir_deg: float, wind_speed: float) -> None:
280
+ """
281
+ Propagate smoke downwind from burning cells.
282
+ Smoke density decays with distance and over time.
283
+ """
284
+ if not self.config.enable_smoke_occlusion:
285
+ return
286
+
287
+ # Decay existing smoke
288
+ for r in range(self.rows):
289
+ for c in range(self.cols):
290
+ dyn = self.dynamic_grid[r][c]
291
+ if dyn.fire_state not in (FireState.BURNING, FireState.EMBER):
292
+ dyn.smoke_density = max(0.0, dyn.smoke_density - 0.1)
293
+
294
+ # Generate new smoke from burning cells
295
+ wind_rad = math.radians(wind_dir_deg)
296
+ dr_wind = -math.cos(wind_rad) # N = row decreasing
297
+ dc_wind = math.sin(wind_rad)
298
+
299
+ spread_dist = max(2, int(wind_speed / 10))
300
+
301
+ for r in range(self.rows):
302
+ for c in range(self.cols):
303
+ dyn = self.dynamic_grid[r][c]
304
+ if dyn.fire_state in (FireState.BURNING, FireState.EMBER):
305
+ # Smoke at the source
306
+ dyn.smoke_density = min(0.9, dyn.smoke_density + 0.3)
307
+
308
+ # Propagate downwind
309
+ for dist in range(1, spread_dist + 1):
310
+ sr = int(r + dr_wind * dist)
311
+ sc = int(c + dc_wind * dist)
312
+ if self._in_bounds(sr, sc):
313
+ smoke_add = 0.2 / dist
314
+ self.dynamic_grid[sr][sc].smoke_density = min(
315
+ 0.9, self.dynamic_grid[sr][sc].smoke_density + smoke_add
316
+ )
317
+
318
+ # ─── Moisture Updates ─────────────────────────────
319
+
320
+ def update_moisture(self, rain_active: bool, humidity_pct: float) -> None:
321
+ """Update moisture levels based on rain and humidity."""
322
+ for r in range(self.rows):
323
+ for c in range(self.cols):
324
+ dyn = self.dynamic_grid[r][c]
325
+ if rain_active:
326
+ dyn.moisture = min(1.0, dyn.moisture + 0.05)
327
+ else:
328
+ # Dry out slowly based on humidity
329
+ dry_rate = 0.01 * (1.0 - humidity_pct / 100.0)
330
+ dyn.moisture = max(0.0, dyn.moisture - dry_rate)
331
+
332
+ # ─── Observation Builder ──────────────────────────
333
+
334
+ def build_observation(
335
+ self,
336
+ enable_fog: bool = False,
337
+ fog_radius: int = 7,
338
+ crew_positions: Optional[list[tuple[int, int]]] = None,
339
+ revealed_cells: Optional[set[tuple[int, int]]] = None,
340
+ ) -> list[list[CellObservation]]:
341
+ """
342
+ Build the agent-visible grid observation.
343
+ Applies smoke occlusion and fog-of-war as configured.
344
+ """
345
+ if crew_positions is None:
346
+ crew_positions = []
347
+ if revealed_cells is None:
348
+ revealed_cells = set()
349
+
350
+ # Compute visible cells under fog-of-war
351
+ visible = set()
352
+ if enable_fog:
353
+ for cr, cc in crew_positions:
354
+ for r in range(max(0, cr - fog_radius), min(self.rows, cr + fog_radius + 1)):
355
+ for c in range(max(0, cc - fog_radius), min(self.cols, cc + fog_radius + 1)):
356
+ if (r - cr) ** 2 + (c - cc) ** 2 <= fog_radius ** 2:
357
+ visible.add((r, c))
358
+ visible |= revealed_cells
359
+ else:
360
+ # All cells visible
361
+ for r in range(self.rows):
362
+ for c in range(self.cols):
363
+ visible.add((r, c))
364
+
365
+ obs_grid = []
366
+ for r in range(self.rows):
367
+ row = []
368
+ for c in range(self.cols):
369
+ static = self.static_grid[r][c]
370
+ dynamic = self.dynamic_grid[r][c]
371
+
372
+ if (r, c) not in visible:
373
+ # Fog of war — completely unknown
374
+ row.append(CellObservation(
375
+ row=r, col=c,
376
+ fire_state=FireState.UNKNOWN,
377
+ ))
378
+ continue
379
+
380
+ # Check smoke occlusion
381
+ fire_state = dynamic.fire_state
382
+ if self.config.enable_smoke_occlusion and dynamic.smoke_density > 0.6:
383
+ if fire_state in (FireState.BURNING, FireState.EMBER, FireState.UNBURNED):
384
+ fire_state = FireState.UNKNOWN
385
+
386
+ # Quantize intensity
387
+ intensity_bin = self._quantize_intensity(dynamic.fire_intensity)
388
+
389
+ row.append(CellObservation(
390
+ row=r, col=c,
391
+ fire_state=fire_state,
392
+ intensity_bin=intensity_bin,
393
+ smoke_density=round(dynamic.smoke_density, 2),
394
+ is_populated=static.is_populated,
395
+ crew_present=dynamic.crew_present,
396
+ fuel_type=static.fuel_type,
397
+ elevation_m=static.elevation_m,
398
+ ))
399
+ obs_grid.append(row)
400
+
401
+ return obs_grid
402
+
403
+ # ─── Helpers ──────────────────────────────────────
404
+
405
+ def _in_bounds(self, row: int, col: int) -> bool:
406
+ return 0 <= row < self.rows and 0 <= col < self.cols
407
+
408
+ @staticmethod
409
+ def _quantize_intensity(intensity: float) -> IntensityBin:
410
+ if intensity <= 0.0:
411
+ return IntensityBin.NONE
412
+ elif intensity <= 0.25:
413
+ return IntensityBin.LOW
414
+ elif intensity <= 0.5:
415
+ return IntensityBin.MEDIUM
416
+ elif intensity <= 0.75:
417
+ return IntensityBin.HIGH
418
+ else:
419
+ return IntensityBin.EXTREME
420
+
421
+ def get_burning_cells(self) -> list[tuple[int, int]]:
422
+ """Return coordinates of all currently burning cells."""
423
+ burning = []
424
+ for r in range(self.rows):
425
+ for c in range(self.cols):
426
+ if self.dynamic_grid[r][c].fire_state in (FireState.BURNING, FireState.EMBER):
427
+ burning.append((r, c))
428
+ return burning
429
+
430
+ def get_total_population(self) -> int:
431
+ """Total population across all cells."""
432
+ total = 0
433
+ for r in range(self.rows):
434
+ for c in range(self.cols):
435
+ total += self.static_grid[r][c].population
436
+ return total
437
+
438
+ def get_population_lost(self) -> int:
439
+ """Population in burned cells."""
440
+ lost = 0
441
+ for r in range(self.rows):
442
+ for c in range(self.cols):
443
+ if self.dynamic_grid[r][c].fire_state == FireState.BURNED_OUT:
444
+ lost += self.static_grid[r][c].population
445
+ return lost
446
+
447
+ def get_total_burnable(self) -> int:
448
+ """Count of cells that can burn (not water/road)."""
449
+ count = 0
450
+ for r in range(self.rows):
451
+ for c in range(self.cols):
452
+ if self.static_grid[r][c].fuel_type not in (FuelType.WATER, FuelType.ROAD):
453
+ count += 1
454
+ return count
455
+
456
+ def get_burned_count(self) -> int:
457
+ """Count of cells that have burned out."""
458
+ count = 0
459
+ for r in range(self.rows):
460
+ for c in range(self.cols):
461
+ if self.dynamic_grid[r][c].fire_state == FireState.BURNED_OUT:
462
+ count += 1
463
+ return count
464
+
465
+ def count_by_state(self, state: FireState) -> int:
466
+ """Count cells in a given fire state."""
467
+ count = 0
468
+ for r in range(self.rows):
469
+ for c in range(self.cols):
470
+ if self.dynamic_grid[r][c].fire_state == state:
471
+ count += 1
472
+ return count
473
+
474
+ def get_fire_perimeter(self) -> tuple[int, int]:
475
+ """
476
+ Returns (total_perimeter_edges, contained_edges).
477
+ A perimeter edge is an edge of a burning/ember cell adjacent to a non-burning cell.
478
+ A contained edge borders water, firebreak, burned_out, or grid boundary.
479
+ """
480
+ total = 0
481
+ contained = 0
482
+ for r in range(self.rows):
483
+ for c in range(self.cols):
484
+ if self.dynamic_grid[r][c].fire_state not in (FireState.BURNING, FireState.EMBER):
485
+ continue
486
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
487
+ nr, nc = r + dr, c + dc
488
+ if not self._in_bounds(nr, nc):
489
+ # Grid boundary = contained
490
+ total += 1
491
+ contained += 1
492
+ continue
493
+ neighbor_state = self.dynamic_grid[nr][nc].fire_state
494
+ neighbor_fuel = self.static_grid[nr][nc].fuel_type
495
+ if neighbor_state not in (FireState.BURNING, FireState.EMBER):
496
+ total += 1
497
+ if neighbor_state in (FireState.FIREBREAK, FireState.BURNED_OUT, FireState.SUPPRESSED):
498
+ contained += 1
499
+ elif neighbor_fuel in (FuelType.WATER, FuelType.ROAD):
500
+ contained += 1
501
+ return total, contained
env/models.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic data models for the Wildfire Containment Simulator.
3
+
4
+ This module defines the complete type contract between all environment components.
5
+ Every action, observation, cell state, and result is typed and validated here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+ from typing import Any, Optional
12
+
13
+ from pydantic import BaseModel, Field, model_validator
14
+
15
+
16
+ # ══════════════════════════════════════════════════════
17
+ # ENUMS
18
+ # ══════════════════════════════════════════════════════
19
+
20
+ class FuelType(str, Enum):
21
+ """Terrain fuel classification. Determines burn rate and ignition probability."""
22
+ GRASS = "grass"
23
+ SHRUB = "shrub"
24
+ TIMBER = "timber"
25
+ URBAN = "urban"
26
+ WATER = "water"
27
+ ROAD = "road"
28
+
29
+
30
+ class FireState(str, Enum):
31
+ """Current fire status of a grid cell."""
32
+ UNBURNED = "unburned"
33
+ BURNING = "burning"
34
+ EMBER = "ember" # Low intensity, dying down
35
+ BURNED_OUT = "burned_out"
36
+ FIREBREAK = "firebreak" # Manually constructed, non-flammable
37
+ SUPPRESSED = "suppressed" # Was burning, now extinguished by crew
38
+ UNKNOWN = "unknown" # Hidden by smoke/fog-of-war
39
+
40
+
41
+ class Priority(str, Enum):
42
+ """Job/event priority levels."""
43
+ LOW = "low"
44
+ NORMAL = "normal"
45
+ HIGH = "high"
46
+ CRITICAL = "critical"
47
+
48
+
49
+ class Direction(str, Enum):
50
+ """8-directional movement for crews."""
51
+ N = "N"
52
+ S = "S"
53
+ E = "E"
54
+ W = "W"
55
+ NE = "NE"
56
+ NW = "NW"
57
+ SE = "SE"
58
+ SW = "SW"
59
+
60
+
61
+ class ActionType(str, Enum):
62
+ """All possible agent actions."""
63
+ DEPLOY_CREW = "deploy_crew"
64
+ MOVE_CREW = "move_crew"
65
+ ORDER_CREW_OBJECTIVE = "order_crew_objective"
66
+ DROP_RETARDANT = "drop_retardant"
67
+ BUILD_FIREBREAK = "build_firebreak"
68
+ RECON_FLIGHT = "recon_flight"
69
+ IDLE = "idle"
70
+
71
+
72
+ class CrewObjective(str, Enum):
73
+ """Objective directive for ORDER_CREW_OBJECTIVE."""
74
+ HOLD = "hold"
75
+ ADVANCE = "advance"
76
+ RETREAT = "retreat"
77
+ PRIORITIZE_NORTH = "prioritize_north"
78
+ PRIORITIZE_SOUTH = "prioritize_south"
79
+ PRIORITIZE_EAST = "prioritize_east"
80
+ PRIORITIZE_WEST = "prioritize_west"
81
+
82
+
83
+ class IntensityBin(str, Enum):
84
+ """Quantized fire intensity as seen by the agent."""
85
+ NONE = "none"
86
+ LOW = "low"
87
+ MEDIUM = "medium"
88
+ HIGH = "high"
89
+ EXTREME = "extreme"
90
+
91
+
92
+ # ══════════════════════════════════════════════════════
93
+ # DIRECTION HELPERS
94
+ # ══════════════════════════════════════════════════════
95
+
96
+ DIRECTION_DELTAS: dict[Direction, tuple[int, int]] = {
97
+ Direction.N: (-1, 0),
98
+ Direction.S: (1, 0),
99
+ Direction.E: (0, 1),
100
+ Direction.W: (0, -1),
101
+ Direction.NE: (-1, 1),
102
+ Direction.NW: (-1, -1),
103
+ Direction.SE: (1, 1),
104
+ Direction.SW: (1, -1),
105
+ }
106
+
107
+
108
+ # ══════════════════════════════════════════════════════
109
+ # CELL MODELS
110
+ # ══════════════════════════════════════════════════════
111
+
112
+ class CellStatic(BaseModel):
113
+ """Immutable terrain properties of a grid cell."""
114
+ row: int
115
+ col: int
116
+ elevation_m: float = Field(ge=0, le=2000, description="Height in meters")
117
+ fuel_type: FuelType
118
+ fuel_load: float = Field(ge=0.0, le=1.0, description="Density of burnable material")
119
+ is_populated: bool = False
120
+ population: int = Field(ge=0, default=0)
121
+ is_water: bool = False
122
+
123
+ @model_validator(mode="after")
124
+ def water_consistency(self) -> "CellStatic":
125
+ if self.fuel_type == FuelType.WATER:
126
+ self.is_water = True
127
+ self.fuel_load = 0.0
128
+ if self.fuel_type == FuelType.ROAD:
129
+ self.fuel_load = 0.0
130
+ return self
131
+
132
+
133
+ class CellDynamic(BaseModel):
134
+ """Mutable runtime state of a grid cell. Updated each step."""
135
+ fire_state: FireState = FireState.UNBURNED
136
+ fire_intensity: float = Field(ge=0.0, le=1.0, default=0.0)
137
+ moisture: float = Field(ge=0.0, le=1.0, default=0.3)
138
+ time_burning: int = Field(ge=0, default=0)
139
+ suppression_level: float = Field(ge=0.0, le=1.0, default=0.0)
140
+ smoke_density: float = Field(ge=0.0, le=1.0, default=0.0)
141
+ crew_present: bool = False
142
+
143
+
144
+ class CellObservation(BaseModel):
145
+ """What the agent sees for a single cell (may be degraded by smoke/fog)."""
146
+ row: int
147
+ col: int
148
+ fire_state: FireState
149
+ intensity_bin: IntensityBin = IntensityBin.NONE
150
+ smoke_density: float = 0.0
151
+ is_populated: bool = False
152
+ crew_present: bool = False
153
+ fuel_type: FuelType = FuelType.GRASS
154
+ elevation_m: float = 0.0
155
+
156
+
157
+ # ══════════════════════════════════════════════════════
158
+ # WEATHER MODELS
159
+ # ══════════════════════════════════════════════════════
160
+
161
+ class WeatherState(BaseModel):
162
+ """Full ground-truth weather (used internally)."""
163
+ wind_speed_kmh: float = Field(ge=0, le=60, default=10.0)
164
+ wind_direction_deg: float = Field(ge=0, lt=360, default=0.0)
165
+ humidity_pct: float = Field(ge=0, le=100, default=40.0)
166
+ rain_active: bool = False
167
+ rain_steps_remaining: int = 0
168
+
169
+
170
+ class WeatherObservation(BaseModel):
171
+ """Noisy weather readings visible to the agent."""
172
+ wind_speed_kmh: float # +/- 5 km/h noise
173
+ wind_direction_deg: float # +/- 20 deg noise
174
+ humidity_pct: float # Exact
175
+ rain_active: bool # Observable
176
+
177
+
178
+ # ══════════════════════════════════════════════════════
179
+ # RESOURCE MODELS
180
+ # ══════════════════════════════════════════════════════
181
+
182
+ class CrewState(BaseModel):
183
+ """State of a single ground crew."""
184
+ crew_id: str
185
+ row: int
186
+ col: int
187
+ is_deployed: bool = False
188
+ is_active: bool = True # False if crew lost (injury)
189
+
190
+
191
+ class TankerState(BaseModel):
192
+ """State of a single air tanker."""
193
+ tanker_id: str
194
+ cooldown_remaining: int = 0 # 0 = ready to drop
195
+ is_active: bool = True
196
+
197
+
198
+ class ResourceState(BaseModel):
199
+ """Complete resource state visible to the agent."""
200
+ crews: list[CrewState]
201
+ tankers: list[TankerState]
202
+ firebreak_budget: int = Field(ge=0, description="Remaining firebreak cells")
203
+ recon_budget: int = Field(ge=0, default=0, description="Remaining recon flights")
204
+
205
+
206
+ # ══════════════════════════════════════════════════════
207
+ # ACTION MODEL
208
+ # ══════════════════════════════════════════════════════
209
+
210
+ class Action(BaseModel):
211
+ """
212
+ Agent action. One action per step.
213
+
214
+ Validation catches invalid actions at the type level.
215
+ Semantic validation (VRAM-like feasibility checks) happens in the environment.
216
+ """
217
+ action_type: ActionType
218
+
219
+ # DEPLOY_CREW / DROP_RETARDANT / RECON_FLIGHT params
220
+ target_row: Optional[int] = None
221
+ target_col: Optional[int] = None
222
+
223
+ # DEPLOY_CREW / MOVE_CREW / BUILD_FIREBREAK params
224
+ crew_id: Optional[str] = None
225
+
226
+ # MOVE_CREW / BUILD_FIREBREAK params
227
+ direction: Optional[Direction] = None
228
+
229
+ # DROP_RETARDANT params
230
+ tanker_id: Optional[str] = None
231
+
232
+ # ORDER_CREW_OBJECTIVE params
233
+ objective: Optional[CrewObjective] = None
234
+
235
+ # IDLE params
236
+ reason: Optional[str] = None
237
+
238
+ @model_validator(mode="after")
239
+ def validate_params(self) -> "Action":
240
+ """Ensure required parameters are present for each action type."""
241
+ t = self.action_type
242
+
243
+ if t == ActionType.DEPLOY_CREW:
244
+ if self.crew_id is None:
245
+ raise ValueError("DEPLOY_CREW requires crew_id")
246
+ if self.target_row is None or self.target_col is None:
247
+ raise ValueError("DEPLOY_CREW requires target_row and target_col")
248
+
249
+ elif t == ActionType.MOVE_CREW:
250
+ if self.crew_id is None:
251
+ raise ValueError("MOVE_CREW requires crew_id")
252
+ if self.direction is None:
253
+ raise ValueError("MOVE_CREW requires direction")
254
+
255
+ elif t == ActionType.ORDER_CREW_OBJECTIVE:
256
+ if self.crew_id is None:
257
+ raise ValueError("ORDER_CREW_OBJECTIVE requires crew_id")
258
+ if self.objective is None:
259
+ raise ValueError("ORDER_CREW_OBJECTIVE requires objective")
260
+
261
+ elif t == ActionType.DROP_RETARDANT:
262
+ if self.tanker_id is None:
263
+ raise ValueError("DROP_RETARDANT requires tanker_id")
264
+ if self.target_row is None or self.target_col is None:
265
+ raise ValueError("DROP_RETARDANT requires target_row and target_col")
266
+
267
+ elif t == ActionType.BUILD_FIREBREAK:
268
+ if self.crew_id is None:
269
+ raise ValueError("BUILD_FIREBREAK requires crew_id")
270
+ if self.direction is None:
271
+ raise ValueError("BUILD_FIREBREAK requires direction")
272
+
273
+ elif t == ActionType.RECON_FLIGHT:
274
+ if self.target_row is None or self.target_col is None:
275
+ raise ValueError("RECON_FLIGHT requires target_row and target_col")
276
+
277
+ return self
278
+
279
+
280
+ # ══════════════════════════════════════════════════════
281
+ # OBSERVATION MODEL
282
+ # ══════════════════════════════════════════════════════
283
+
284
+ class ClusterStats(BaseModel):
285
+ """Running statistics about the episode."""
286
+ cells_burned: int = 0
287
+ cells_burning: int = 0
288
+ cells_saved: int = 0
289
+ population_threatened: int = 0
290
+ population_lost: int = 0
291
+ containment_pct: float = Field(ge=0.0, le=100.0, default=0.0)
292
+ current_step: int = 0
293
+ max_steps: int = 100
294
+ firebreaks_built: int = 0
295
+ retardant_drops: int = 0
296
+
297
+
298
+ class Observation(BaseModel):
299
+ """Complete observation returned to the agent each step."""
300
+ grid: list[list[CellObservation]]
301
+ weather: WeatherObservation
302
+ resources: ResourceState
303
+ stats: ClusterStats
304
+ recent_events: list[str] = Field(default_factory=list, max_length=5)
305
+ briefing: Optional[Any] = None # OperationalBriefing on first obs, None thereafter
306
+
307
+
308
+ # ══════════════════════════════════════════════════════
309
+ # STEP RESULT
310
+ # ══════════════════════════════════════════════════════
311
+
312
+ class StepResult(BaseModel):
313
+ """Returned by env.step(). Contains everything the agent needs."""
314
+ observation: Observation
315
+ reward: float
316
+ done: bool = False
317
+ info: dict = Field(default_factory=dict)
318
+
319
+
320
+ # ══════════════════════════════════════════════════════
321
+ # TIER CONFIGURATION
322
+ # ══════════════════════════════════════════════════════
323
+
324
+ class TierConfig(BaseModel):
325
+ """Configuration for a difficulty tier."""
326
+ tier_name: str
327
+ grid_rows: int
328
+ grid_cols: int
329
+ num_crews: int
330
+ num_tankers: int
331
+ firebreak_budget: int
332
+ recon_budget: int = 0
333
+ episode_length: int
334
+ num_ignition_points: int = 1
335
+ staggered_ignition_step: Optional[int] = None # Step at which extra ignition(s) start
336
+ enable_smoke_occlusion: bool = False
337
+ enable_sensor_noise: bool = False
338
+ enable_fog_of_war: bool = False
339
+ fog_visibility_radius: int = 7
340
+ enable_wind_shifts: bool = False
341
+ enable_crew_loss: bool = False
342
+ crew_loss_step: Optional[int] = None
343
+ crew_loss_id: Optional[str] = None
344
+ tanker_cooldown: int = 5
345
+ wind_speed_init: float = 10.0
346
+ wind_dir_init: float = 0.0
347
+ humidity_init: float = 40.0
348
+
349
+ # Reward weights
350
+ w_containment: float = 0.30
351
+ w_population: float = 0.35
352
+ w_efficiency: float = 0.10
353
+ w_speed: float = 0.15
354
+ w_area: float = 0.10
355
+
356
+
357
+ # ══════════════════════════════════════════════════════
358
+ # PRESET TIER CONFIGS
359
+ # ══════════════════════════════════════════════════════
360
+
361
+ TIER_EASY = TierConfig(
362
+ tier_name="easy",
363
+ grid_rows=15,
364
+ grid_cols=15,
365
+ num_crews=4,
366
+ num_tankers=1,
367
+ firebreak_budget=15,
368
+ recon_budget=0,
369
+ episode_length=80,
370
+ num_ignition_points=1,
371
+ enable_smoke_occlusion=False,
372
+ enable_sensor_noise=False,
373
+ enable_fog_of_war=False,
374
+ enable_wind_shifts=False,
375
+ wind_speed_init=10.0,
376
+ wind_dir_init=0.0,
377
+ humidity_init=40.0,
378
+ w_containment=0.30,
379
+ w_population=0.35,
380
+ w_efficiency=0.10,
381
+ w_speed=0.15,
382
+ w_area=0.10,
383
+ )
384
+
385
+ TIER_MEDIUM = TierConfig(
386
+ tier_name="medium",
387
+ grid_rows=25,
388
+ grid_cols=25,
389
+ num_crews=5,
390
+ num_tankers=2,
391
+ firebreak_budget=20,
392
+ recon_budget=1,
393
+ episode_length=150,
394
+ num_ignition_points=2,
395
+ enable_smoke_occlusion=True,
396
+ enable_sensor_noise=True,
397
+ enable_fog_of_war=False,
398
+ enable_wind_shifts=True,
399
+ wind_speed_init=15.0,
400
+ wind_dir_init=45.0,
401
+ humidity_init=35.0,
402
+ w_containment=0.25,
403
+ w_population=0.35,
404
+ w_efficiency=0.15,
405
+ w_speed=0.10,
406
+ w_area=0.15,
407
+ )
408
+
409
+ TIER_HARD = TierConfig(
410
+ tier_name="hard",
411
+ grid_rows=40,
412
+ grid_cols=40,
413
+ num_crews=6,
414
+ num_tankers=3,
415
+ firebreak_budget=30,
416
+ recon_budget=3,
417
+ episode_length=300,
418
+ num_ignition_points=3,
419
+ staggered_ignition_step=30,
420
+ enable_smoke_occlusion=True,
421
+ enable_sensor_noise=True,
422
+ enable_fog_of_war=True,
423
+ fog_visibility_radius=7,
424
+ enable_wind_shifts=True,
425
+ enable_crew_loss=True,
426
+ crew_loss_step=40,
427
+ crew_loss_id="crew_5",
428
+ wind_speed_init=20.0,
429
+ wind_dir_init=90.0,
430
+ humidity_init=30.0,
431
+ w_containment=0.20,
432
+ w_population=0.40,
433
+ w_efficiency=0.15,
434
+ w_speed=0.10,
435
+ w_area=0.15,
436
+ )
env/rendering.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Frame rendering helpers for episode replay GIFs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import List
8
+
9
+ import numpy as np
10
+
11
+
12
+ def render_frame(state: dict, step: int, stats: dict | None = None) -> np.ndarray:
13
+ """
14
+ Render a ground-truth state dict into an RGB uint8 array (H_px, W_px, 3).
15
+
16
+ The figure is 8x8 inches at 100 dpi = 800x800 px.
17
+ Main panel (top 85%): grid. Bottom strip: stats bar.
18
+ """
19
+ import matplotlib
20
+ matplotlib.use("Agg")
21
+ import matplotlib.pyplot as plt
22
+ import matplotlib.patches as mpatches
23
+ from matplotlib.patches import FancyArrow
24
+ import io
25
+
26
+ grid = state["grid"]
27
+ rows = len(grid)
28
+ cols = len(grid[0]) if rows > 0 else 1
29
+
30
+ fig = plt.figure(figsize=(8, 8), dpi=100)
31
+ # Main panel
32
+ ax = fig.add_axes([0.02, 0.15, 0.96, 0.83])
33
+ # Stats strip
34
+ ax_bar = fig.add_axes([0.02, 0.01, 0.96, 0.12])
35
+ ax_bar.axis("off")
36
+
37
+ # ── Build colour grid ──
38
+ rgb = np.ones((rows, cols, 3))
39
+ for r in range(rows):
40
+ for c in range(cols):
41
+ cell = grid[r][c]
42
+ fs = cell["fire_state"]
43
+ intensity = cell.get("fire_intensity", 0.0)
44
+ if fs == "burning":
45
+ sat = 0.4 + 0.6 * intensity
46
+ rgb[r, c] = [1.0, 1.0 - sat * 0.8, 0.0]
47
+ elif fs == "ember":
48
+ rgb[r, c] = [0.9, 0.4, 0.0]
49
+ elif fs == "burned_out":
50
+ rgb[r, c] = [0.25, 0.22, 0.20]
51
+ elif fs == "firebreak":
52
+ rgb[r, c] = [0.55, 0.35, 0.15]
53
+ elif fs == "suppressed":
54
+ rgb[r, c] = [0.6, 0.8, 0.6]
55
+ else:
56
+ # Unburned: shade by fuel
57
+ fuel = cell.get("fuel_type", "grass")
58
+ if fuel == "water":
59
+ rgb[r, c] = [0.3, 0.5, 0.9]
60
+ elif fuel == "road":
61
+ rgb[r, c] = [0.7, 0.7, 0.7]
62
+ elif fuel == "timber":
63
+ rgb[r, c] = [0.1, 0.45, 0.1]
64
+ elif fuel == "shrub":
65
+ rgb[r, c] = [0.5, 0.7, 0.2]
66
+ elif fuel == "urban":
67
+ rgb[r, c] = [0.8, 0.75, 0.7]
68
+ else:
69
+ rgb[r, c] = [0.7, 0.85, 0.4]
70
+
71
+ ax.imshow(rgb, origin="upper", aspect="auto", interpolation="nearest")
72
+
73
+ # ── Populated cell outlines ──
74
+ for r in range(rows):
75
+ for c in range(cols):
76
+ if grid[r][c].get("is_populated"):
77
+ rect = mpatches.Rectangle(
78
+ (c - 0.5, r - 0.5), 1, 1,
79
+ linewidth=1.5, edgecolor="blue", facecolor="none"
80
+ )
81
+ ax.add_patch(rect)
82
+
83
+ # ── Crew markers ──
84
+ resources = state.get("resources", {})
85
+ for crew in resources.get("crews", []):
86
+ if not crew.get("is_deployed") or not crew.get("is_active", True):
87
+ continue
88
+ cr, cc = crew["row"], crew["col"]
89
+ ax.plot(cc, cr, "o", color="lime", markersize=7, markeredgecolor="black", markeredgewidth=0.8)
90
+ ax.text(cc, cr - 0.6, crew["crew_id"].replace("crew_", "c"),
91
+ ha="center", va="bottom", fontsize=5, color="white",
92
+ fontweight="bold")
93
+
94
+ ax.set_xlim(-0.5, cols - 0.5)
95
+ ax.set_ylim(rows - 0.5, -0.5)
96
+ ax.set_xticks([])
97
+ ax.set_yticks([])
98
+ ax.set_title(f"Step {step}", fontsize=9, pad=2)
99
+
100
+ # ── Stats strip ──
101
+ weather = state.get("weather", {})
102
+ wind_spd = weather.get("wind_speed_kmh", 0)
103
+ wind_dir = weather.get("wind_direction_deg", 0)
104
+ cells_burning = state.get("cells_burning", 0) if stats is None else stats.get("cells_burning", 0)
105
+ containment = state.get("containment_pct", 0) if stats is None else stats.get("containment_pct", 0)
106
+ pop_lost = state.get("population_lost", 0) if stats is None else stats.get("population_lost", 0)
107
+
108
+ # Fallback: compute from grid if not in state root
109
+ if cells_burning == 0:
110
+ cells_burning = sum(1 for r in grid for c in r if c["fire_state"] == "burning")
111
+
112
+ strip_text = (
113
+ f"Step {step} | Burning: {cells_burning} | Containment: {containment:.1f}% | "
114
+ f"Pop lost: {pop_lost} | Wind: {wind_spd:.0f} km/h"
115
+ )
116
+ ax_bar.text(0.5, 0.5, strip_text, ha="center", va="center",
117
+ fontsize=8, transform=ax_bar.transAxes,
118
+ bbox=dict(boxstyle="round,pad=0.3", facecolor="#f0f0f0", edgecolor="gray"))
119
+
120
+ # Wind arrow
121
+ import math
122
+ rad = math.radians(wind_dir)
123
+ dx, dy = math.sin(rad) * 0.08, -math.cos(rad) * 0.08
124
+ ax_bar.annotate("", xy=(0.92 + dx, 0.5 + dy), xytext=(0.92 - dx, 0.5 - dy),
125
+ xycoords="axes fraction",
126
+ arrowprops=dict(arrowstyle="->", color="darkred", lw=1.5))
127
+
128
+ # Convert figure to RGB array
129
+ buf = io.BytesIO()
130
+ fig.savefig(buf, format="png", dpi=100)
131
+ plt.close(fig)
132
+ buf.seek(0)
133
+ import imageio.v3 as iio
134
+ img = iio.imread(buf, extension=".png")
135
+ return img[:, :, :3].astype(np.uint8)
136
+
137
+
138
+ def render_episode_gif(frames: List[np.ndarray], output_path: str, fps: int = 5) -> None:
139
+ """Stitch RGB frames into an animated GIF at the given fps."""
140
+ import imageio.v3 as iio
141
+ iio.imwrite(output_path, frames, extension=".gif", loop=0,
142
+ duration=int(1000 / fps))
env/resources.py ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Resource management for the Wildfire Containment Simulator.
3
+
4
+ Manages ground crews, air tankers, and firebreak construction.
5
+ Each resource type has distinct mechanics and constraints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .models import (
11
+ CrewState, TankerState, ResourceState, Direction,
12
+ DIRECTION_DELTAS, FireState, FuelType, TierConfig,
13
+ )
14
+ from .grid import Grid
15
+
16
+
17
+ class ResourceManager:
18
+ """
19
+ Manages all firefighting resources: crews, tankers, firebreak budget.
20
+
21
+ Handles deployment, movement, suppression, retardant drops,
22
+ and firebreak construction with full constraint checking.
23
+ """
24
+
25
+ def __init__(self, config: TierConfig, grid: Grid):
26
+ self.config = config
27
+ self.grid = grid
28
+
29
+ # Initialize crews
30
+ self.crews: list[CrewState] = []
31
+ for i in range(config.num_crews):
32
+ self.crews.append(CrewState(
33
+ crew_id=f"crew_{i}",
34
+ row=0, col=0,
35
+ is_deployed=False,
36
+ is_active=True,
37
+ ))
38
+
39
+ # Initialize tankers
40
+ self.tankers: list[TankerState] = []
41
+ for i in range(config.num_tankers):
42
+ self.tankers.append(TankerState(
43
+ tanker_id=f"tanker_{i}",
44
+ cooldown_remaining=0,
45
+ is_active=True,
46
+ ))
47
+
48
+ self.firebreak_budget = config.firebreak_budget
49
+ self.recon_budget = config.recon_budget
50
+
51
+ # Track revealed cells from recon flights (step -> set of cells)
52
+ self.revealed_cells: set[tuple[int, int]] = set()
53
+ self.reveal_expiry: dict[tuple[int, int], int] = {} # cell -> step when it expires
54
+
55
+ # Stats
56
+ self.total_retardant_drops = 0
57
+ self.total_firebreaks_built = 0
58
+ self.wasted_actions = 0
59
+ self.idle_crew_steps = 0
60
+ self.crew_casualties = False
61
+
62
+ # Multi-agent crew state
63
+ self._crew_objectives: dict[str, str] = {} # crew_id -> objective string
64
+ self._ic_ordered_this_step: set[str] = set() # crews that got an IC order this step
65
+ self.autonomous_saves: int = 0 # times local policy retreat prevented a casualty
66
+
67
+ def reset(self) -> None:
68
+ """Reset all resources to initial state."""
69
+ for crew in self.crews:
70
+ crew.is_deployed = False
71
+ crew.is_active = True
72
+ crew.row = 0
73
+ crew.col = 0
74
+ for tanker in self.tankers:
75
+ tanker.cooldown_remaining = 0
76
+ tanker.is_active = True
77
+ self.firebreak_budget = self.config.firebreak_budget
78
+ self.recon_budget = self.config.recon_budget
79
+ self.revealed_cells.clear()
80
+ self.reveal_expiry.clear()
81
+ self.total_retardant_drops = 0
82
+ self.total_firebreaks_built = 0
83
+ self.wasted_actions = 0
84
+ self.idle_crew_steps = 0
85
+ self.crew_casualties = False
86
+ self._crew_objectives = {}
87
+ self._ic_ordered_this_step = set()
88
+ self.autonomous_saves = 0
89
+
90
+ # ─── Crew Operations ─────────────────────────────
91
+
92
+ def deploy_crew(self, crew_id: str, row: int, col: int) -> tuple[bool, str]:
93
+ """Deploy a crew to a target cell. Returns (success, message)."""
94
+ crew = self._get_crew(crew_id)
95
+ if crew is None:
96
+ return False, f"Crew {crew_id} not found"
97
+ if not crew.is_active:
98
+ return False, f"Crew {crew_id} is inactive (lost)"
99
+ if crew.is_deployed:
100
+ return False, f"Crew {crew_id} already deployed. Use MOVE_CREW instead."
101
+
102
+ if not self.grid._in_bounds(row, col):
103
+ return False, f"Target ({row},{col}) out of bounds"
104
+
105
+ static = self.grid.static_grid[row][col]
106
+ dynamic = self.grid.dynamic_grid[row][col]
107
+
108
+ if static.fuel_type == FuelType.WATER:
109
+ return False, f"Cannot deploy crew to water cell ({row},{col})"
110
+ if dynamic.fire_intensity > 0.7:
111
+ return False, f"Cell ({row},{col}) too dangerous (intensity {dynamic.fire_intensity:.2f})"
112
+
113
+ # Clear old position if any
114
+ if crew.is_deployed:
115
+ self.grid.dynamic_grid[crew.row][crew.col].crew_present = False
116
+
117
+ crew.row = row
118
+ crew.col = col
119
+ crew.is_deployed = True
120
+ self.grid.dynamic_grid[row][col].crew_present = True
121
+
122
+ return True, f"Crew {crew_id} deployed to ({row},{col})"
123
+
124
+ def move_crew(self, crew_id: str, direction: Direction) -> tuple[bool, str]:
125
+ """Move a deployed crew one cell in the given direction."""
126
+ crew = self._get_crew(crew_id)
127
+ if crew is None:
128
+ return False, f"Crew {crew_id} not found"
129
+ if not crew.is_active:
130
+ return False, f"Crew {crew_id} is inactive"
131
+ if not crew.is_deployed:
132
+ return False, f"Crew {crew_id} not deployed. Use DEPLOY_CREW first."
133
+
134
+ dr, dc = DIRECTION_DELTAS[direction]
135
+ nr, nc = crew.row + dr, crew.col + dc
136
+
137
+ if not self.grid._in_bounds(nr, nc):
138
+ return False, f"Cannot move {crew_id} {direction.value}: out of bounds"
139
+
140
+ static = self.grid.static_grid[nr][nc]
141
+ dynamic = self.grid.dynamic_grid[nr][nc]
142
+
143
+ if static.fuel_type == FuelType.WATER:
144
+ return False, f"Cannot move to water cell ({nr},{nc})"
145
+ if dynamic.fire_intensity > 0.7:
146
+ return False, f"Cell ({nr},{nc}) too dangerous (intensity {dynamic.fire_intensity:.2f})"
147
+
148
+ # Move
149
+ self.grid.dynamic_grid[crew.row][crew.col].crew_present = False
150
+ crew.row = nr
151
+ crew.col = nc
152
+ self.grid.dynamic_grid[nr][nc].crew_present = True
153
+
154
+ return True, f"Crew {crew_id} moved {direction.value} to ({nr},{nc})"
155
+
156
+ def apply_suppression(self) -> list[str]:
157
+ """
158
+ All deployed, active crews suppress fire at their current cell.
159
+ Called each tick. Returns event messages.
160
+ """
161
+ events = []
162
+ for crew in self.crews:
163
+ if not crew.is_active or not crew.is_deployed:
164
+ if crew.is_active and not crew.is_deployed:
165
+ self.idle_crew_steps += 1
166
+ continue
167
+
168
+ dyn = self.grid.dynamic_grid[crew.row][crew.col]
169
+
170
+ # Check for crew casualty (fire intensity spiked around them)
171
+ if dyn.fire_intensity > 0.85:
172
+ crew.is_active = False
173
+ self.crew_casualties = True
174
+ self.grid.dynamic_grid[crew.row][crew.col].crew_present = False
175
+ events.append(f"CREW CASUALTY: {crew.crew_id} trapped at ({crew.row},{crew.col})!")
176
+ continue
177
+
178
+ if dyn.fire_state in (FireState.BURNING, FireState.EMBER):
179
+ # Suppress: reduce intensity
180
+ dyn.suppression_level = min(1.0, dyn.suppression_level + 0.15)
181
+ dyn.fire_intensity = max(0.0, dyn.fire_intensity - 0.15)
182
+
183
+ if dyn.fire_intensity <= 0.0:
184
+ dyn.fire_state = FireState.SUPPRESSED
185
+ events.append(f"Crew {crew.crew_id} suppressed fire at ({crew.row},{crew.col})")
186
+
187
+ return events
188
+
189
+ # ─── Multi-Agent Crew Local Policy ───────────────
190
+
191
+ def set_crew_objective(self, crew_id: str, objective: str) -> tuple[bool, str]:
192
+ """IC sets a high-level objective for a crew. Persists until changed."""
193
+ crew = self._get_crew(crew_id)
194
+ if crew is None:
195
+ return False, f"Crew {crew_id} not found"
196
+ if not crew.is_active:
197
+ return False, f"Crew {crew_id} is inactive"
198
+ self._crew_objectives[crew_id] = objective
199
+ self._ic_ordered_this_step.add(crew_id)
200
+ return True, f"Crew {crew_id} assigned objective: {objective}"
201
+
202
+ def clear_ic_orders(self) -> None:
203
+ """Call at the start of each step to reset per-step IC tracking."""
204
+ self._ic_ordered_this_step = set()
205
+
206
+ def get_crew_local_obs(self, crew_id: str) -> dict:
207
+ """Return 3x3 neighbourhood view centred on the crew's position."""
208
+ crew = self._get_crew(crew_id)
209
+ if crew is None or not crew.is_deployed:
210
+ return {}
211
+ cells = []
212
+ for dr in range(-1, 2):
213
+ for dc in range(-1, 2):
214
+ r, c = crew.row + dr, crew.col + dc
215
+ if not self.grid._in_bounds(r, c):
216
+ cells.append({"row": r, "col": c, "fire_state": "out_of_bounds",
217
+ "intensity": 0.0, "smoke": 0.0})
218
+ else:
219
+ dyn = self.grid.dynamic_grid[r][c]
220
+ cells.append({
221
+ "row": r, "col": c,
222
+ "fire_state": dyn.fire_state.value,
223
+ "intensity": round(dyn.fire_intensity, 3),
224
+ "smoke": round(dyn.smoke_density, 3),
225
+ })
226
+ return {
227
+ "crew_id": crew_id,
228
+ "position": (crew.row, crew.col),
229
+ "health": "active" if crew.is_active else "casualty",
230
+ "neighborhood": cells,
231
+ "objective": self._crew_objectives.get(crew_id, "none"),
232
+ }
233
+
234
+ def apply_local_policies(self) -> list[str]:
235
+ """
236
+ Run each deployed crew's local policy for crews NOT ordered by IC this step.
237
+ Called after fire spread, before suppression.
238
+ """
239
+ events = []
240
+ for crew in self.crews:
241
+ if not crew.is_active or not crew.is_deployed:
242
+ continue
243
+ if crew.crew_id in self._ic_ordered_this_step:
244
+ continue
245
+
246
+ dyn = self.grid.dynamic_grid[crew.row][crew.col]
247
+ objective = self._crew_objectives.get(crew.crew_id, "advance")
248
+
249
+ if objective == "hold":
250
+ continue
251
+
252
+ if dyn.fire_intensity > 0.8:
253
+ # Retreat: move away from fire centre
254
+ direction = self._retreat_direction(crew)
255
+ if direction is not None:
256
+ old_intensity = dyn.fire_intensity
257
+ ok, msg = self.move_crew(crew.crew_id, direction)
258
+ if ok:
259
+ new_dyn = self.grid.dynamic_grid[crew.row][crew.col]
260
+ if new_dyn.fire_intensity < old_intensity:
261
+ self.autonomous_saves += 1
262
+ events.append(
263
+ f"AUTO-RETREAT: {crew.crew_id} retreated {direction.value} "
264
+ f"(intensity was {old_intensity:.2f})"
265
+ )
266
+ else:
267
+ events.append(f"AUTO-RETREAT: {crew.crew_id} moved {direction.value}")
268
+ else:
269
+ # Advance toward nearest fire in 3x3, biased by objective
270
+ direction = self._advance_direction(crew, objective)
271
+ if direction is not None:
272
+ ok, msg = self.move_crew(crew.crew_id, direction)
273
+ if ok:
274
+ events.append(f"AUTO-ADVANCE: {crew.crew_id} moved {direction.value}")
275
+
276
+ return events
277
+
278
+ def _retreat_direction(self, crew) -> "Direction | None":
279
+ """Find the safest direction to retreat from current position."""
280
+ best_dir = None
281
+ best_score = -1.0
282
+ for direction, (dr, dc) in DIRECTION_DELTAS.items():
283
+ nr, nc = crew.row + dr, crew.col + dc
284
+ if not self.grid._in_bounds(nr, nc):
285
+ continue
286
+ static = self.grid.static_grid[nr][nc]
287
+ if static.fuel_type == FuelType.WATER:
288
+ continue
289
+ dyn = self.grid.dynamic_grid[nr][nc]
290
+ # Prefer low intensity, not burning
291
+ score = 1.0 - dyn.fire_intensity
292
+ if dyn.fire_state in (FireState.BURNING, FireState.EMBER):
293
+ score -= 0.5
294
+ if score > best_score:
295
+ best_score = score
296
+ best_dir = direction
297
+ return best_dir
298
+
299
+ def _advance_direction(self, crew, objective: str) -> "Direction | None":
300
+ """Find direction toward nearest fire, biased by objective."""
301
+ # Look in 3x3 for fire targets
302
+ targets = []
303
+ for dr in range(-1, 2):
304
+ for dc in range(-1, 2):
305
+ if dr == 0 and dc == 0:
306
+ continue
307
+ r, c = crew.row + dr, crew.col + dc
308
+ if not self.grid._in_bounds(r, c):
309
+ continue
310
+ dyn = self.grid.dynamic_grid[r][c]
311
+ if dyn.fire_state in (FireState.BURNING, FireState.EMBER):
312
+ targets.append((dr, dc))
313
+
314
+ if not targets:
315
+ return None
316
+
317
+ # Pick target, with direction bias from objective
318
+ bias = {
319
+ "prioritize_north": (-1, 0),
320
+ "prioritize_south": (1, 0),
321
+ "prioritize_east": (0, 1),
322
+ "prioritize_west": (0, -1),
323
+ }.get(objective, (0, 0))
324
+
325
+ best = min(targets, key=lambda t: abs(t[0] - bias[0]) + abs(t[1] - bias[1]))
326
+ # Find Direction matching (dr, dc)
327
+ for direction, delta in DIRECTION_DELTAS.items():
328
+ if delta == best:
329
+ nr, nc = crew.row + best[0], crew.col + best[1]
330
+ if self.grid._in_bounds(nr, nc):
331
+ static = self.grid.static_grid[nr][nc]
332
+ if static.fuel_type != FuelType.WATER:
333
+ return direction
334
+ return None
335
+
336
+ # ─── Tanker Operations ────────────────────────────
337
+
338
+ def drop_retardant(self, tanker_id: str, center_row: int, center_col: int) -> tuple[bool, str]:
339
+ """Drop retardant on a 3x3 area centered on (center_row, center_col)."""
340
+ tanker = self._get_tanker(tanker_id)
341
+ if tanker is None:
342
+ return False, f"Tanker {tanker_id} not found"
343
+ if not tanker.is_active:
344
+ return False, f"Tanker {tanker_id} inactive"
345
+ if tanker.cooldown_remaining > 0:
346
+ return False, f"Tanker {tanker_id} on cooldown ({tanker.cooldown_remaining} steps)"
347
+
348
+ if not self.grid._in_bounds(center_row, center_col):
349
+ return False, f"Target ({center_row},{center_col}) out of bounds"
350
+
351
+ # Check smoke density at target
352
+ if self.grid.dynamic_grid[center_row][center_col].smoke_density > 0.8:
353
+ return False, f"Smoke too dense at ({center_row},{center_col}) for tanker drop"
354
+
355
+ # Apply retardant to 3x3 area
356
+ affected = 0
357
+ for dr in range(-1, 2):
358
+ for dc in range(-1, 2):
359
+ r, c = center_row + dr, center_col + dc
360
+ if not self.grid._in_bounds(r, c):
361
+ continue
362
+ dyn = self.grid.dynamic_grid[r][c]
363
+ dyn.fire_intensity = max(0.0, dyn.fire_intensity - 0.4)
364
+ dyn.moisture = min(1.0, dyn.moisture + 0.2)
365
+ dyn.suppression_level = min(1.0, dyn.suppression_level + 0.3)
366
+
367
+ if dyn.fire_state in (FireState.BURNING, FireState.EMBER) and dyn.fire_intensity <= 0.0:
368
+ dyn.fire_state = FireState.SUPPRESSED
369
+ affected += 1
370
+
371
+ tanker.cooldown_remaining = self.config.tanker_cooldown
372
+ self.total_retardant_drops += 1
373
+
374
+ return True, f"Tanker {tanker_id} dropped retardant at ({center_row},{center_col}), {affected} cells affected"
375
+
376
+ def tick_tanker_cooldowns(self) -> None:
377
+ """Reduce cooldown timers by 1 each step."""
378
+ for tanker in self.tankers:
379
+ if tanker.cooldown_remaining > 0:
380
+ tanker.cooldown_remaining -= 1
381
+
382
+ # ─── Firebreak Operations ─────────────────────────
383
+
384
+ def build_firebreak(self, crew_id: str, direction: Direction) -> tuple[bool, str]:
385
+ """Build a firebreak in the cell adjacent to the crew in the given direction."""
386
+ crew = self._get_crew(crew_id)
387
+ if crew is None:
388
+ return False, f"Crew {crew_id} not found"
389
+ if not crew.is_active or not crew.is_deployed:
390
+ return False, f"Crew {crew_id} not deployed/active"
391
+ if self.firebreak_budget <= 0:
392
+ return False, "No firebreak budget remaining"
393
+
394
+ dr, dc = DIRECTION_DELTAS[direction]
395
+ tr, tc = crew.row + dr, crew.col + dc
396
+
397
+ if not self.grid._in_bounds(tr, tc):
398
+ return False, f"Target ({tr},{tc}) out of bounds"
399
+
400
+ static = self.grid.static_grid[tr][tc]
401
+ dynamic = self.grid.dynamic_grid[tr][tc]
402
+
403
+ if static.fuel_type in (FuelType.WATER, FuelType.URBAN):
404
+ return False, f"Cannot build firebreak on {static.fuel_type.value} cell"
405
+ if dynamic.fire_state != FireState.UNBURNED:
406
+ return False, f"Cell ({tr},{tc}) is not UNBURNED (state: {dynamic.fire_state.value})"
407
+
408
+ dynamic.fire_state = FireState.FIREBREAK
409
+ dynamic.fire_intensity = 0.0
410
+ self.firebreak_budget -= 1
411
+ self.total_firebreaks_built += 1
412
+
413
+ return True, f"Firebreak built at ({tr},{tc}) by {crew_id}. Budget: {self.firebreak_budget}"
414
+
415
+ # ─── Recon Operations ─────────────────────────────
416
+
417
+ def recon_flight(self, center_row: int, center_col: int, current_step: int) -> tuple[bool, str]:
418
+ """Execute a reconnaissance flight revealing a 10x10 area for 5 steps."""
419
+ if self.recon_budget <= 0:
420
+ return False, "No recon budget remaining"
421
+
422
+ if not self.grid._in_bounds(center_row, center_col):
423
+ return False, f"Target ({center_row},{center_col}) out of bounds"
424
+
425
+ # Reveal 10x10 area
426
+ for r in range(max(0, center_row - 5), min(self.grid.rows, center_row + 5)):
427
+ for c in range(max(0, center_col - 5), min(self.grid.cols, center_col + 5)):
428
+ self.revealed_cells.add((r, c))
429
+ self.reveal_expiry[(r, c)] = current_step + 5
430
+
431
+ self.recon_budget -= 1
432
+ return True, f"Recon flight over ({center_row},{center_col}). {len(self.revealed_cells)} cells revealed."
433
+
434
+ def expire_reveals(self, current_step: int) -> None:
435
+ """Remove expired cell reveals."""
436
+ expired = [cell for cell, step in self.reveal_expiry.items() if current_step >= step]
437
+ for cell in expired:
438
+ self.revealed_cells.discard(cell)
439
+ del self.reveal_expiry[cell]
440
+
441
+ # ─── Crew Loss (Hard tier) ────────────────────────
442
+
443
+ def apply_crew_loss(self, crew_id: str) -> list[str]:
444
+ """Disable a specific crew (injury event)."""
445
+ crew = self._get_crew(crew_id)
446
+ if crew is None:
447
+ return []
448
+ if not crew.is_active:
449
+ return []
450
+
451
+ crew.is_active = False
452
+ if crew.is_deployed:
453
+ self.grid.dynamic_grid[crew.row][crew.col].crew_present = False
454
+ crew.is_deployed = False
455
+
456
+ return [f"CREW LOSS: {crew_id} injured and evacuated."]
457
+
458
+ # ─── State / Observation ──────────────────────────
459
+
460
+ def get_resource_state(self) -> ResourceState:
461
+ """Build resource state for observation."""
462
+ return ResourceState(
463
+ crews=[c.model_copy() for c in self.crews],
464
+ tankers=[t.model_copy() for t in self.tankers],
465
+ firebreak_budget=self.firebreak_budget,
466
+ recon_budget=self.recon_budget,
467
+ )
468
+
469
+ def get_crew_positions(self) -> list[tuple[int, int]]:
470
+ """Return positions of all deployed, active crews."""
471
+ return [
472
+ (c.row, c.col) for c in self.crews
473
+ if c.is_active and c.is_deployed
474
+ ]
475
+
476
+ def get_total_possible_actions(self, episode_length: int) -> int:
477
+ """Estimate total possible meaningful actions for efficiency scoring."""
478
+ return episode_length * self.config.num_crews
479
+
480
+ # ─── Helpers ──────────────────────────────────────
481
+
482
+ def _get_crew(self, crew_id: str) -> CrewState | None:
483
+ for c in self.crews:
484
+ if c.crew_id == crew_id:
485
+ return c
486
+ return None
487
+
488
+ def _get_tanker(self, tanker_id: str) -> TankerState | None:
489
+ for t in self.tankers:
490
+ if t.tanker_id == tanker_id:
491
+ return t
492
+ return None
env/reward.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reward computation for the Wildfire Containment Simulator.
3
+
4
+ Computes a weighted composite reward in [0.0, 1.0] from five components:
5
+ containment, population safety, resource efficiency, speed, and area saved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .models import TierConfig
11
+ from .grid import Grid
12
+ from .resources import ResourceManager
13
+
14
+
15
+ class RewardCalculator:
16
+ """
17
+ Computes per-step reward as a weighted composite of five normalized components.
18
+
19
+ All components are in [0, 1]. The final reward applies multiplicative penalties
20
+ for catastrophic failures (crew casualties, populated cells burned).
21
+ """
22
+
23
+ def __init__(self, config: TierConfig):
24
+ self.config = config
25
+ self.invalid_action_count = 0
26
+ self.steps_with_fire = 0
27
+ self.containment_achieved = False
28
+ self.containment_step: int | None = None
29
+
30
+ def reset(self) -> None:
31
+ self.invalid_action_count = 0
32
+ self.steps_with_fire = 0
33
+ self.containment_achieved = False
34
+ self.containment_step = None
35
+
36
+ def record_invalid_action(self) -> None:
37
+ self.invalid_action_count += 1
38
+
39
+ def compute_reward(
40
+ self,
41
+ grid: Grid,
42
+ resources: ResourceManager,
43
+ current_step: int,
44
+ ) -> float:
45
+ """
46
+ Compute the composite reward for the current state.
47
+
48
+ Returns a float in [0.0, 1.0].
49
+ """
50
+ cfg = self.config
51
+
52
+ # Track fire presence
53
+ from .models import FireState
54
+ burning_count = grid.count_by_state(FireState.BURNING) + grid.count_by_state(FireState.EMBER)
55
+ if burning_count > 0:
56
+ self.steps_with_fire += 1
57
+
58
+ # Check containment
59
+ if burning_count == 0 and current_step > 0 and not self.containment_achieved:
60
+ self.containment_achieved = True
61
+ self.containment_step = current_step
62
+
63
+ # ── Component 1: Containment Score ──
64
+ total_perim, contained_perim = grid.get_fire_perimeter()
65
+ if total_perim > 0:
66
+ containment_score = contained_perim / total_perim
67
+ else:
68
+ # No active fire perimeter = either no fire or fully contained
69
+ containment_score = 1.0 if self.containment_achieved else 0.5
70
+
71
+ # ── Component 2: Population Safety ──
72
+ total_pop = grid.get_total_population()
73
+ lost_pop = grid.get_population_lost()
74
+ if total_pop > 0:
75
+ population_score = 1.0 - (lost_pop / total_pop)
76
+ else:
77
+ population_score = 1.0
78
+
79
+ # ── Component 3: Resource Efficiency ──
80
+ total_possible = resources.get_total_possible_actions(current_step + 1)
81
+ wasted = resources.wasted_actions + resources.idle_crew_steps
82
+ if total_possible > 0:
83
+ efficiency_score = 1.0 - min(1.0, wasted / total_possible)
84
+ else:
85
+ efficiency_score = 1.0
86
+
87
+ # ── Component 4: Speed Score ──
88
+ if self.containment_achieved and self.containment_step is not None:
89
+ speed_score = 1.0 - (self.containment_step / cfg.episode_length)
90
+ elif burning_count == 0 and current_step == 0:
91
+ speed_score = 1.0
92
+ else:
93
+ # Fire still active — score based on progress
94
+ speed_score = max(0.0, 0.3 - (current_step / cfg.episode_length) * 0.3)
95
+
96
+ # ── Component 5: Area Saved ──
97
+ total_burnable = grid.get_total_burnable()
98
+ burned = grid.get_burned_count()
99
+ if total_burnable > 0:
100
+ area_score = 1.0 - (burned / total_burnable)
101
+ else:
102
+ area_score = 1.0
103
+
104
+ # ── Weighted composite ──
105
+ weights = [cfg.w_containment, cfg.w_population, cfg.w_efficiency, cfg.w_speed, cfg.w_area]
106
+ scores = [containment_score, population_score, efficiency_score, speed_score, area_score]
107
+ total_weight = sum(weights)
108
+
109
+ reward = sum(w * s for w, s in zip(weights, scores)) / total_weight if total_weight > 0 else 0.0
110
+
111
+ # ── Penalty: invalid actions ──
112
+ reward -= 0.02 * self.invalid_action_count
113
+
114
+ # ── Penalty: populated cell burned ──
115
+ if lost_pop > 0:
116
+ # Linear penalty per populated cell lost (not exponential)
117
+ pop_cells_lost = sum(
118
+ 1 for r in range(grid.rows) for c in range(grid.cols)
119
+ if grid.dynamic_grid[r][c].fire_state == FireState.BURNED_OUT
120
+ and grid.static_grid[r][c].is_populated
121
+ )
122
+ reward *= max(0.15, 1.0 - 0.08 * pop_cells_lost)
123
+
124
+ # ── Penalty: crew casualty ──
125
+ if resources.crew_casualties:
126
+ reward = 0.0
127
+
128
+ return float(max(0.0, min(1.0, reward)))
129
+
130
+ def compute_step_reward(
131
+ self,
132
+ prev_state: dict,
133
+ current_state: dict,
134
+ action_was_valid: bool,
135
+ action_was_redundant: bool,
136
+ ) -> float:
137
+ """Dense per-step reward based on state deltas."""
138
+ total_pop = current_state.get("total_pop", 0)
139
+
140
+ delta_containment = current_state["containment_pct"] - prev_state["containment_pct"]
141
+
142
+ if total_pop > 0:
143
+ prev_pop_safety = 1.0 - prev_state["pop_lost"] / total_pop
144
+ curr_pop_safety = 1.0 - current_state["pop_lost"] / total_pop
145
+ delta_pop_safety = curr_pop_safety - prev_pop_safety
146
+ else:
147
+ delta_pop_safety = 0.0
148
+
149
+ reward = (delta_containment * 0.4) + (delta_pop_safety * 0.4)
150
+ if action_was_redundant:
151
+ reward -= 0.1
152
+ return reward
153
+
154
+ def compute_terminal_reward(
155
+ self,
156
+ final_state: dict,
157
+ episode_steps: int,
158
+ max_steps: int,
159
+ ) -> float:
160
+ """Sparse terminal reward applied only on episode end."""
161
+ total_pop = final_state.get("total_pop", 0)
162
+ pop_lost = final_state.get("pop_lost", 0)
163
+
164
+ reward = 0.0
165
+ if pop_lost == 0:
166
+ reward += 5.0
167
+ efficiency_bonus = (max_steps - episode_steps) / max_steps * 2.0
168
+ reward += efficiency_bonus
169
+ else:
170
+ reward += -3.0 * (pop_lost / total_pop) if total_pop > 0 else -3.0
171
+
172
+ if final_state.get("crew_casualty_occurred", False):
173
+ reward -= 2.0
174
+
175
+ invalid_penalty = min(0.2, 0.01 * final_state.get("invalid_action_count", 0))
176
+ reward -= invalid_penalty
177
+
178
+ # Briefing adherence bonus: +1.0 if all priority zones survived
179
+ priority_zones = final_state.get("priority_zones", [])
180
+ if priority_zones:
181
+ grid_ref = final_state.get("_grid_ref")
182
+ if grid_ref is not None:
183
+ from .models import FireState
184
+ all_safe = all(
185
+ grid_ref.dynamic_grid[r][c].fire_state not in (
186
+ FireState.BURNED_OUT, FireState.BURNING, FireState.EMBER
187
+ )
188
+ for r, c in priority_zones
189
+ if 0 <= r < grid_ref.rows and 0 <= c < grid_ref.cols
190
+ )
191
+ if all_safe:
192
+ reward += 1.0
193
+
194
+ return reward
195
+
196
+ def get_component_breakdown(self, grid: Grid, resources: ResourceManager, current_step: int) -> dict:
197
+ """Return individual component scores for debugging/logging."""
198
+ from .models import FireState
199
+
200
+ burning_count = grid.count_by_state(FireState.BURNING) + grid.count_by_state(FireState.EMBER)
201
+
202
+ total_perim, contained_perim = grid.get_fire_perimeter()
203
+ containment_score = contained_perim / total_perim if total_perim > 0 else (1.0 if self.containment_achieved else 0.5)
204
+
205
+ total_pop = grid.get_total_population()
206
+ lost_pop = grid.get_population_lost()
207
+ population_score = 1.0 - (lost_pop / total_pop) if total_pop > 0 else 1.0
208
+
209
+ total_possible = resources.get_total_possible_actions(current_step + 1)
210
+ wasted = resources.wasted_actions + resources.idle_crew_steps
211
+ efficiency_score = 1.0 - min(1.0, wasted / total_possible) if total_possible > 0 else 1.0
212
+
213
+ if self.containment_achieved and self.containment_step is not None:
214
+ speed_score = 1.0 - (self.containment_step / self.config.episode_length)
215
+ else:
216
+ speed_score = max(0.0, 0.3 - (current_step / self.config.episode_length) * 0.3)
217
+
218
+ total_burnable = grid.get_total_burnable()
219
+ burned = grid.get_burned_count()
220
+ area_score = 1.0 - (burned / total_burnable) if total_burnable > 0 else 1.0
221
+
222
+ return {
223
+ "containment": round(containment_score, 4),
224
+ "population_safety": round(population_score, 4),
225
+ "efficiency": round(efficiency_score, 4),
226
+ "speed": round(speed_score, 4),
227
+ "area_saved": round(area_score, 4),
228
+ "burning_cells": burning_count,
229
+ "population_lost": lost_pop,
230
+ "invalid_actions": self.invalid_action_count,
231
+ "crew_casualty": resources.crew_casualties,
232
+ }
env/serialization.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Converts an Observation into a structured text prompt for LLM agents.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .models import Observation
11
+
12
+ from .models import FireState, IntensityBin
13
+
14
+
15
+ def serialize_observation(obs: "Observation", step_num: int, max_steps: int) -> str:
16
+ situation = _format_situation(obs)
17
+ grid_summary = _summarize_grid_regions(obs.grid)
18
+ resources = _format_resources(obs.resources)
19
+ events = _format_events(obs.recent_events)
20
+
21
+ parts = []
22
+
23
+ # Prepend briefing on first observation (step 0)
24
+ if obs.briefing is not None:
25
+ from .briefing import briefing_to_text
26
+ parts.append(briefing_to_text(obs.briefing))
27
+ parts.append("")
28
+ elif step_num > 0 and hasattr(obs, "_briefing_reminder") and obs._briefing_reminder:
29
+ parts.append(obs._briefing_reminder)
30
+ parts.append("")
31
+
32
+ parts += [
33
+ f"=== WILDFIRE INCIDENT COMMAND — STEP {step_num}/{max_steps} ===",
34
+ "",
35
+ "SITUATION:",
36
+ situation,
37
+ "",
38
+ "GRID SUMMARY (smoke-obscured cells marked [?]):",
39
+ grid_summary,
40
+ "",
41
+ "RESOURCES:",
42
+ resources,
43
+ "",
44
+ "RECENT EVENTS:",
45
+ events,
46
+ "",
47
+ "Available actions: deploy_crew, move_crew, drop_retardant, build_firebreak, recon_flight, idle",
48
+ 'Produce your action as JSON: {"action_type": "...", ...}',
49
+ ]
50
+ return "\n".join(parts)
51
+
52
+
53
+ # ── Situation block ──────────────────────────────────────────
54
+
55
+ def _format_situation(obs: "Observation") -> str:
56
+ stats = obs.stats
57
+ w = obs.weather
58
+
59
+ burning = stats.cells_burning
60
+ containment = round(stats.containment_pct, 1)
61
+ pop_at_risk = stats.population_threatened
62
+
63
+ wind_dir = _deg_to_compass(w.wind_direction_deg)
64
+ rain = "active" if w.rain_active else "inactive"
65
+
66
+ last_event = obs.recent_events[-1] if obs.recent_events else "None"
67
+
68
+ lines = [
69
+ f"- Fire active on {burning} cells. Containment: {containment}%. Population at risk: {pop_at_risk} zones.",
70
+ f"- Wind: {w.wind_speed_kmh:.0f} km/h {wind_dir} (±5 km/h noise). Humidity: {w.humidity_pct:.0f}%. Rain: {rain}.",
71
+ f"- Last event: {last_event}",
72
+ ]
73
+ return "\n".join(lines)
74
+
75
+
76
+ def _deg_to_compass(deg: float) -> str:
77
+ dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
78
+ idx = round(deg / 45.0) % 8
79
+ return dirs[idx]
80
+
81
+
82
+ # ── Grid summary ─────────────────────────────────────────────
83
+
84
+ def _summarize_grid_regions(grid: list) -> str:
85
+ rows = len(grid)
86
+ cols = len(grid[0]) if rows > 0 else 0
87
+
88
+ fire_cells: list[tuple[int, int]] = []
89
+ pop_cells: list[tuple[int, int]] = []
90
+ firebreak_cells: list[tuple[int, int]] = []
91
+ fog_count = 0
92
+
93
+ for r in range(rows):
94
+ for c in range(cols):
95
+ cell = grid[r][c]
96
+ if cell.fire_state == FireState.UNKNOWN:
97
+ fog_count += 1
98
+ elif cell.fire_state in (FireState.BURNING, FireState.EMBER):
99
+ fire_cells.append((r, c))
100
+ elif cell.fire_state == FireState.FIREBREAK:
101
+ firebreak_cells.append((r, c))
102
+ if cell.is_populated:
103
+ pop_cells.append((r, c))
104
+
105
+ lines: list[str] = []
106
+
107
+ fire_regions = _cluster_to_bboxes(fire_cells, max_regions=5)
108
+ for bbox in fire_regions:
109
+ lines.append(f" FIRE — {bbox}")
110
+
111
+ pop_regions = _cluster_to_bboxes(pop_cells, max_regions=5)
112
+ for bbox in pop_regions:
113
+ lines.append(f" POPULATED — {bbox}")
114
+
115
+ fb_regions = _cluster_to_bboxes(firebreak_cells, max_regions=5)
116
+ for bbox in fb_regions:
117
+ lines.append(f" FIREBREAK — {bbox}")
118
+
119
+ if fog_count > 0:
120
+ lines.append(f" [?] {fog_count} cells obscured by smoke or fog-of-war")
121
+
122
+ if not lines:
123
+ lines.append(" No active fire detected.")
124
+
125
+ return "\n".join(lines)
126
+
127
+
128
+ def _cluster_to_bboxes(cells: list[tuple[int, int]], max_regions: int) -> list[str]:
129
+ """Group cells into rectangular bounding boxes using a greedy sweep."""
130
+ if not cells:
131
+ return []
132
+
133
+ cell_set = set(cells)
134
+ visited: set[tuple[int, int]] = set()
135
+ regions: list[tuple[int, int, int, int, int]] = [] # (size, rmin, rmax, cmin, cmax)
136
+
137
+ for seed in cells:
138
+ if seed in visited:
139
+ continue
140
+ r0, c0 = seed
141
+ rmin = rmax = r0
142
+ cmin = cmax = c0
143
+ stack = [seed]
144
+ region_cells: list[tuple[int, int]] = []
145
+
146
+ while stack:
147
+ r, c = stack.pop()
148
+ if (r, c) in visited:
149
+ continue
150
+ visited.add((r, c))
151
+ region_cells.append((r, c))
152
+ rmin, rmax = min(rmin, r), max(rmax, r)
153
+ cmin, cmax = min(cmin, c), max(cmax, c)
154
+ for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)):
155
+ nb = (r + dr, c + dc)
156
+ if nb in cell_set and nb not in visited:
157
+ stack.append(nb)
158
+
159
+ regions.append((len(region_cells), rmin, rmax, cmin, cmax))
160
+
161
+ regions.sort(key=lambda x: -x[0])
162
+ result = []
163
+ for size, rmin, rmax, cmin, cmax in regions[:max_regions]:
164
+ if rmin == rmax and cmin == cmax:
165
+ result.append(f"Row {rmin}, Col {cmin} ({size} cell)")
166
+ else:
167
+ result.append(f"Row {rmin}-{rmax}, Col {cmin}-{cmax} ({size} cells)")
168
+ return result
169
+
170
+
171
+ # ── Resources block ──────────────────────────────────────────
172
+
173
+ def _format_resources(resources) -> str:
174
+ lines: list[str] = []
175
+
176
+ for crew in resources.crews:
177
+ if not crew.is_active:
178
+ status = "CASUALTY"
179
+ elif crew.is_deployed:
180
+ status = f"deployed at ({crew.row},{crew.col}), active"
181
+ else:
182
+ status = "undeployed, available"
183
+ lines.append(f" {crew.crew_id}: {status}")
184
+
185
+ for tanker in resources.tankers:
186
+ if not tanker.is_active:
187
+ t_status = "inactive"
188
+ elif tanker.cooldown_remaining > 0:
189
+ t_status = f"cooldown {tanker.cooldown_remaining} steps remaining"
190
+ else:
191
+ t_status = "ready"
192
+ lines.append(f" {tanker.tanker_id}: {t_status}")
193
+
194
+ lines.append(f" Firebreaks remaining: {resources.firebreak_budget}. Recon flights remaining: {resources.recon_budget}")
195
+ return "\n".join(lines)
196
+
197
+
198
+ # ── Events block ─────────────────────────────────────────────
199
+
200
+ def _format_events(events: list[str]) -> str:
201
+ if not events:
202
+ return " None"
203
+ recent = events[-3:]
204
+ return "\n".join(f" - {e}" for e in recent)
env/weather.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stochastic weather engine for the Wildfire Containment Simulator.
3
+
4
+ Models wind (random walk + shift events), humidity (sinusoidal daily cycle),
5
+ and rain (Poisson events with fixed duration).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from .models import WeatherState, WeatherObservation, TierConfig
13
+
14
+
15
+ class WeatherEngine:
16
+ """
17
+ Evolves weather state each simulation step.
18
+
19
+ Wind: random walk with configurable drift and occasional shift events.
20
+ Humidity: sinusoidal daily cycle with perturbation.
21
+ Rain: Poisson-triggered events that last 5-15 steps.
22
+ """
23
+
24
+ def __init__(self, config: TierConfig, rng: np.random.Generator):
25
+ self.config = config
26
+ self.rng = rng
27
+ self.steps_since_shift = 0
28
+
29
+ self.state = WeatherState(
30
+ wind_speed_kmh=config.wind_speed_init,
31
+ wind_direction_deg=config.wind_dir_init,
32
+ humidity_pct=config.humidity_init,
33
+ rain_active=False,
34
+ rain_steps_remaining=0,
35
+ )
36
+
37
+ def reset(self) -> None:
38
+ """Reset weather to initial conditions."""
39
+ self.state = WeatherState(
40
+ wind_speed_kmh=self.config.wind_speed_init,
41
+ wind_direction_deg=self.config.wind_dir_init,
42
+ humidity_pct=self.config.humidity_init,
43
+ rain_active=False,
44
+ rain_steps_remaining=0,
45
+ )
46
+ self.steps_since_shift = 0
47
+
48
+ def step(self, current_step: int) -> list[str]:
49
+ """
50
+ Advance weather by one step. Returns list of event strings.
51
+ """
52
+ events: list[str] = []
53
+ s = self.state
54
+
55
+ # ── Wind speed: random walk ──
56
+ if self.config.tier_name != "easy":
57
+ speed_delta = float(self.rng.normal(0, 2.0))
58
+ s.wind_speed_kmh = float(np.clip(s.wind_speed_kmh + speed_delta, 0, 60))
59
+
60
+ # Wind direction: slow drift
61
+ dir_delta = float(self.rng.normal(0, 8.0))
62
+ s.wind_direction_deg = (s.wind_direction_deg + dir_delta) % 360
63
+
64
+ # ── Wind shift events ──
65
+ if self.config.enable_wind_shifts:
66
+ self.steps_since_shift += 1
67
+ if self.steps_since_shift >= 50:
68
+ if self.rng.random() < 0.10:
69
+ shift = self.rng.choice([-90, 90])
70
+ s.wind_direction_deg = (s.wind_direction_deg + shift) % 360
71
+ s.wind_speed_kmh = min(60, s.wind_speed_kmh + 10)
72
+ self.steps_since_shift = 0
73
+ events.append(
74
+ f"WIND SHIFT: direction jumped to {s.wind_direction_deg:.0f} deg, "
75
+ f"speed now {s.wind_speed_kmh:.0f} km/h"
76
+ )
77
+
78
+ # ── Humidity: sinusoidal daily cycle ──
79
+ # Assume 1 step = ~15 min, so 96 steps = 1 day
80
+ day_phase = (current_step % 96) / 96.0 # 0-1 over the day
81
+ base_humidity = self.config.humidity_init
82
+ # Lower at midday (phase ~0.5), higher at dawn/dusk
83
+ import math
84
+ cycle = base_humidity + 15 * math.cos(2 * math.pi * (day_phase - 0.5))
85
+ perturbation = float(self.rng.normal(0, 2.0))
86
+ s.humidity_pct = float(np.clip(cycle + perturbation, 10, 95))
87
+
88
+ # ── Rain events ──
89
+ if s.rain_active:
90
+ s.rain_steps_remaining -= 1
91
+ if s.rain_steps_remaining <= 0:
92
+ s.rain_active = False
93
+ events.append("Rain stopped.")
94
+ else:
95
+ # Small chance of rain each step
96
+ rain_prob = 0.005 if self.config.tier_name == "easy" else 0.01
97
+ if self.rng.random() < rain_prob:
98
+ s.rain_active = True
99
+ s.rain_steps_remaining = int(self.rng.integers(5, 16))
100
+ events.append(f"Rain started! Expected duration: {s.rain_steps_remaining} steps.")
101
+
102
+ return events
103
+
104
+ def get_observation(self) -> WeatherObservation:
105
+ """Return noisy weather observation for the agent."""
106
+ s = self.state
107
+
108
+ if self.config.enable_sensor_noise:
109
+ noisy_speed = s.wind_speed_kmh + float(self.rng.normal(0, 5.0))
110
+ noisy_speed = float(np.clip(noisy_speed, 0, 80))
111
+
112
+ noisy_dir = s.wind_direction_deg + float(self.rng.normal(0, 20.0))
113
+ noisy_dir = noisy_dir % 360
114
+ else:
115
+ noisy_speed = s.wind_speed_kmh
116
+ noisy_dir = s.wind_direction_deg
117
+
118
+ return WeatherObservation(
119
+ wind_speed_kmh=round(noisy_speed, 1),
120
+ wind_direction_deg=round(noisy_dir, 1),
121
+ humidity_pct=round(s.humidity_pct, 1),
122
+ rain_active=s.rain_active,
123
+ )
124
+
125
+ def get_true_state(self) -> WeatherState:
126
+ """Return ground-truth weather (for graders/state())."""
127
+ return self.state.model_copy()
env/wildfire_env.py ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wildfire Containment Simulator — Main Environment.
3
+
4
+ Implements the OpenEnv API: step(), reset(), state().
5
+ Orchestrates grid, fire spread, weather, resources, and reward computation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+ from pydantic import ValidationError
15
+
16
+ from .models import (
17
+ Action, ActionType, Observation, StepResult, ClusterStats,
18
+ FireState, FuelType, TierConfig, TIER_EASY, TIER_MEDIUM, TIER_HARD,
19
+ )
20
+ from .grid import Grid
21
+ from .fire_spread import FireSpreadEngine
22
+ from .weather import WeatherEngine
23
+ from .resources import ResourceManager
24
+ from .reward import RewardCalculator
25
+ from .briefing import generate_briefing, OperationalBriefing
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class WildfireEnv:
31
+ """
32
+ Wildfire Containment Simulator environment.
33
+
34
+ Simulates a grid-based wildfire where an AI agent dispatches
35
+ firefighting resources to contain the fire before it reaches
36
+ populated zones.
37
+
38
+ API:
39
+ reset(task_id, seed) -> Observation
40
+ step(action) -> StepResult
41
+ state() -> dict
42
+ """
43
+
44
+ TIER_MAP = {
45
+ "easy": TIER_EASY,
46
+ "medium": TIER_MEDIUM,
47
+ "hard": TIER_HARD,
48
+ }
49
+
50
+ def __init__(self, config: Optional[TierConfig] = None):
51
+ self.config = config or TIER_EASY
52
+ self.rng = np.random.default_rng(42)
53
+ self.current_step = 0
54
+ self.done = False
55
+
56
+ # Components (initialized in reset)
57
+ self.grid: Optional[Grid] = None
58
+ self.fire_engine: Optional[FireSpreadEngine] = None
59
+ self.weather: Optional[WeatherEngine] = None
60
+ self.resources: Optional[ResourceManager] = None
61
+ self.reward_calc: Optional[RewardCalculator] = None
62
+
63
+ self.events_log: list[str] = []
64
+
65
+ # Episode-level tracking for new reward structure
66
+ self._prev_action: Optional[Action] = None
67
+ self._invalid_action_count: int = 0
68
+ self._crew_casualty_occurred: bool = False
69
+ self._prev_state: Optional[dict] = None
70
+ self.active_briefing: Optional[OperationalBriefing] = None
71
+
72
+ # Last observation returned to the agent (agent's view, not ground truth)
73
+ self._current_obs: Optional[Observation] = None
74
+
75
+ def reset(self, task_id: str = "easy", seed: int = 42) -> Observation:
76
+ """
77
+ Initialize the environment for a new episode.
78
+
79
+ Args:
80
+ task_id: One of "easy", "medium", "hard".
81
+ seed: Random seed for reproducibility.
82
+
83
+ Returns:
84
+ Initial observation.
85
+ """
86
+ self.config = self.TIER_MAP.get(task_id, TIER_EASY)
87
+ self.rng = np.random.default_rng(seed)
88
+ self.current_step = 0
89
+ self.done = False
90
+ self.events_log = []
91
+ self._prev_action = None
92
+ self._invalid_action_count = 0
93
+ self._crew_casualty_occurred = False
94
+ self._prev_state = None
95
+
96
+ # Initialize components
97
+ self.grid = Grid(self.config, self.rng)
98
+ self.fire_engine = FireSpreadEngine(self.grid, self.rng)
99
+ self.weather = WeatherEngine(self.config, self.rng)
100
+ self.resources = ResourceManager(self.config, self.grid)
101
+ self.reward_calc = RewardCalculator(self.config)
102
+ self.reward_calc.reset()
103
+ self.resources.reset()
104
+ self.weather.reset()
105
+
106
+ # Ignite initial fire points
107
+ self._ignite_initial_fires()
108
+
109
+ # Generate operational briefing for this episode
110
+ self.active_briefing = generate_briefing(self.config, self.rng, self.grid)
111
+
112
+ # Build and return initial observation (with briefing attached)
113
+ obs = self._build_observation()
114
+ obs.briefing = self.active_briefing
115
+ self.events_log.append("Episode started. Fire ignited.")
116
+ self._current_obs = obs
117
+ return obs
118
+
119
+ def step(self, action: Action) -> StepResult:
120
+ """
121
+ Execute one simulation step.
122
+
123
+ Follows the 11-step tick sequence:
124
+ 1. Validate action
125
+ 2. Execute action
126
+ 3. Spread fire
127
+ 4. Update intensities (handled inside spread)
128
+ 5. Apply suppression
129
+ 6. Evolve weather
130
+ 7. Update moisture
131
+ 8. Propagate smoke
132
+ 9. Compute reward
133
+ 10. Check termination
134
+ 11. Build observation
135
+
136
+ Args:
137
+ action: The agent's chosen action.
138
+
139
+ Returns:
140
+ StepResult with observation, reward, done flag, and info dict.
141
+ """
142
+ if self.done:
143
+ return StepResult(
144
+ observation=self._build_observation(),
145
+ reward=0.0,
146
+ done=True,
147
+ info={"error": "Episode already finished"},
148
+ )
149
+
150
+ step_events: list[str] = []
151
+
152
+ # Snapshot state before this step's changes
153
+ prev_state = self._snapshot_state()
154
+
155
+ # ── Step 1: Validate action ──
156
+ action_was_redundant = self._is_redundant(action)
157
+ valid, msg = self._validate_action(action)
158
+ if not valid:
159
+ self.reward_calc.record_invalid_action()
160
+ self._invalid_action_count += 1
161
+ self.resources.wasted_actions += 1
162
+ step_events.append(f"Invalid action: {msg}")
163
+ # Skip to reward/termination
164
+ else:
165
+ # ── Step 2: Execute action ──
166
+ exec_events = self._execute_action(action)
167
+ step_events.extend(exec_events)
168
+
169
+ self._prev_action = action
170
+
171
+ # ── Step 3-4: Spread fire + update intensities ──
172
+ ws = self.weather.state
173
+ spread_events = self.fire_engine.spread_step(ws.wind_speed_kmh, ws.wind_direction_deg)
174
+ step_events.extend(spread_events)
175
+
176
+ # ── Step 5: Apply suppression ──
177
+ supp_events = self.resources.apply_suppression()
178
+ step_events.extend(supp_events)
179
+
180
+ # ── Step 6: Evolve weather ──
181
+ weather_events = self.weather.step(self.current_step)
182
+ step_events.extend(weather_events)
183
+
184
+ # ── Step 7: Update moisture ──
185
+ self.grid.update_moisture(ws.rain_active, ws.humidity_pct)
186
+
187
+ # ── Step 8: Propagate smoke ──
188
+ self.grid.propagate_smoke(ws.wind_direction_deg, ws.wind_speed_kmh)
189
+
190
+ # ── Tick tanker cooldowns ──
191
+ self.resources.tick_tanker_cooldowns()
192
+
193
+ # ── Expire recon reveals ──
194
+ self.resources.expire_reveals(self.current_step)
195
+
196
+ # ── Handle staggered ignition (hard tier) ──
197
+ if (self.config.staggered_ignition_step is not None
198
+ and self.current_step == self.config.staggered_ignition_step):
199
+ self._ignite_staggered_fire()
200
+ step_events.append("NEW IGNITION: Additional fire started!")
201
+
202
+ # ── Handle crew loss (hard tier) ──
203
+ if (self.config.enable_crew_loss
204
+ and self.config.crew_loss_step == self.current_step
205
+ and self.config.crew_loss_id):
206
+ loss_events = self.resources.apply_crew_loss(self.config.crew_loss_id)
207
+ step_events.extend(loss_events)
208
+
209
+ # Track crew casualty
210
+ if self.resources.crew_casualties:
211
+ self._crew_casualty_occurred = True
212
+
213
+ self.current_step += 1
214
+
215
+ # ── Step 9: Compute reward ──
216
+ legacy_reward = self.reward_calc.compute_reward(self.grid, self.resources, self.current_step)
217
+
218
+ current_state = self._snapshot_state()
219
+ step_reward = self.reward_calc.compute_step_reward(
220
+ prev_state, current_state, valid, action_was_redundant
221
+ )
222
+
223
+ # ── Step 10: Check termination ──
224
+ self.done = self._check_termination()
225
+
226
+ terminal_reward = 0.0
227
+ if self.done:
228
+ terminal_state = dict(current_state)
229
+ terminal_state["crew_casualty_occurred"] = self._crew_casualty_occurred
230
+ terminal_state["invalid_action_count"] = self._invalid_action_count
231
+ if self.active_briefing:
232
+ terminal_state["priority_zones"] = self.active_briefing.priority_populated_zones
233
+ terminal_state["_grid_ref"] = self.grid
234
+ terminal_reward = self.reward_calc.compute_terminal_reward(
235
+ terminal_state, self.current_step, self.config.episode_length
236
+ )
237
+
238
+ reward = step_reward + terminal_reward
239
+
240
+ # ── Step 11: Build observation ──
241
+ obs = self._build_observation()
242
+
243
+ # Keep last 5 events
244
+ self.events_log = (self.events_log + step_events)[-20:]
245
+
246
+ info = {
247
+ "step": self.current_step,
248
+ "events": step_events,
249
+ "legacy_reward": round(legacy_reward, 4),
250
+ "reward_breakdown": self.reward_calc.get_component_breakdown(
251
+ self.grid, self.resources, self.current_step
252
+ ),
253
+ }
254
+
255
+ result = StepResult(
256
+ observation=obs,
257
+ reward=round(reward, 4),
258
+ done=self.done,
259
+ info=info,
260
+ )
261
+ self._current_obs = result.observation
262
+ return result
263
+
264
+ def state(self) -> dict:
265
+ """
266
+ Return full ground-truth state for grading/debugging.
267
+ NOT for agent use — contains information hidden from the agent.
268
+ """
269
+ if self.grid is None:
270
+ return {"error": "Environment not initialized. Call reset() first."}
271
+
272
+ # Full grid state without any occlusion
273
+ full_grid = []
274
+ for r in range(self.grid.rows):
275
+ row = []
276
+ for c in range(self.grid.cols):
277
+ static = self.grid.static_grid[r][c]
278
+ dynamic = self.grid.dynamic_grid[r][c]
279
+ row.append({
280
+ "row": r, "col": c,
281
+ "fuel_type": static.fuel_type.value,
282
+ "fuel_load": static.fuel_load,
283
+ "elevation_m": static.elevation_m,
284
+ "is_populated": static.is_populated,
285
+ "population": static.population,
286
+ "fire_state": dynamic.fire_state.value,
287
+ "fire_intensity": round(dynamic.fire_intensity, 4),
288
+ "moisture": round(dynamic.moisture, 4),
289
+ "time_burning": dynamic.time_burning,
290
+ "suppression_level": round(dynamic.suppression_level, 4),
291
+ "smoke_density": round(dynamic.smoke_density, 4),
292
+ "crew_present": dynamic.crew_present,
293
+ })
294
+ full_grid.append(row)
295
+
296
+ return {
297
+ "tier": self.config.tier_name,
298
+ "current_step": self.current_step,
299
+ "done": self.done,
300
+ "grid": full_grid,
301
+ "weather": self.weather.get_true_state().model_dump(),
302
+ "resources": self.resources.get_resource_state().model_dump(),
303
+ "reward_breakdown": self.reward_calc.get_component_breakdown(
304
+ self.grid, self.resources, self.current_step
305
+ ),
306
+ "total_population": self.grid.get_total_population(),
307
+ "population_lost": self.grid.get_population_lost(),
308
+ "cells_burned": self.grid.get_burned_count(),
309
+ "total_burnable": self.grid.get_total_burnable(),
310
+ }
311
+
312
+ # ══════════════════════════════════════════════════
313
+ # PRIVATE METHODS
314
+ # ══════════════════════════════════════════════════
315
+
316
+ def _snapshot_state(self) -> dict:
317
+ """Capture a lightweight state dict for reward delta computation."""
318
+ total, contained = self.grid.get_fire_perimeter()
319
+ containment_pct = contained / total if total > 0 else 1.0
320
+ return {
321
+ "containment_pct": containment_pct,
322
+ "pop_lost": self.grid.get_population_lost(),
323
+ "total_pop": self.grid.get_total_population(),
324
+ }
325
+
326
+ def _is_redundant(self, action: Action) -> bool:
327
+ """True if action repeats the same type + target coords as the previous action."""
328
+ if self._prev_action is None:
329
+ return False
330
+ prev = self._prev_action
331
+ if action.action_type != prev.action_type:
332
+ return False
333
+ return action.target_row == prev.target_row and action.target_col == prev.target_col
334
+
335
+ def _ignite_initial_fires(self) -> None:
336
+ """Place initial fire ignition points based on tier config.
337
+
338
+ Ignition candidates are shifted away from populated cells to ensure
339
+ a minimum survivable distance, reducing unwinnable-scenario variance.
340
+ """
341
+ rows, cols = self.config.grid_rows, self.config.grid_cols
342
+
343
+ # Minimum Manhattan distance from any populated cell per tier
344
+ min_pop_dist = {"easy": 4, "medium": 6, "hard": 7}.get(self.config.tier_name, 5)
345
+
346
+ if self.config.tier_name == "easy":
347
+ r, c = self._find_ignition_candidate(rows // 2, cols // 2, min_pop_dist)
348
+ self.grid.ignite_cell(r, c, intensity=0.3)
349
+ elif self.config.tier_name == "medium":
350
+ r1, c1 = self._find_ignition_candidate(rows // 3, cols // 3, min_pop_dist)
351
+ self.grid.ignite_cell(r1, c1, intensity=0.3)
352
+ r2, c2 = self._find_ignition_candidate(2 * rows // 3, 2 * cols // 3, min_pop_dist)
353
+ self.grid.ignite_cell(r2, c2, intensity=0.3)
354
+ else:
355
+ # Two initial points (third comes later via staggered ignition)
356
+ r1, c1 = self._find_ignition_candidate(rows // 4, cols // 4, min_pop_dist)
357
+ self.grid.ignite_cell(r1, c1, intensity=0.3)
358
+ r2, c2 = self._find_ignition_candidate(rows // 2, 3 * cols // 4, min_pop_dist)
359
+ self.grid.ignite_cell(r2, c2, intensity=0.3)
360
+
361
+ def _find_ignition_candidate(self, target_r: int, target_c: int, min_pop_dist: int) -> tuple[int, int]:
362
+ """Return the nearest valid ignition cell to (target_r, target_c) that is at
363
+ least min_pop_dist (Manhattan) from every populated cell.
364
+
365
+ Searches in expanding rings; falls back to the original target if no
366
+ compliant cell is found within the grid bounds.
367
+ """
368
+ rows, cols = self.config.grid_rows, self.config.grid_cols
369
+
370
+ pop_cells = [
371
+ (r, c)
372
+ for r in range(rows)
373
+ for c in range(cols)
374
+ if self.grid.static_grid[r][c].is_populated
375
+ ]
376
+
377
+ def _min_pop_dist(r: int, c: int) -> int:
378
+ if not pop_cells:
379
+ return 9999
380
+ return min(abs(r - pr) + abs(c - pc) for pr, pc in pop_cells)
381
+
382
+ for radius in range(max(rows, cols)):
383
+ for dr in range(-radius, radius + 1):
384
+ for dc in range(-radius, radius + 1):
385
+ if radius > 0 and abs(dr) + abs(dc) != radius:
386
+ continue
387
+ r, c = target_r + dr, target_c + dc
388
+ if not self.grid._in_bounds(r, c):
389
+ continue
390
+ static = self.grid.static_grid[r][c]
391
+ if static.fuel_type in (FuelType.WATER, FuelType.ROAD):
392
+ continue
393
+ if _min_pop_dist(r, c) >= min_pop_dist:
394
+ return r, c
395
+
396
+ return target_r, target_c
397
+
398
+ def _ignite_staggered_fire(self) -> None:
399
+ """Ignite additional fire point(s) for hard tier."""
400
+ rows, cols = self.config.grid_rows, self.config.grid_cols
401
+ # Place in an area likely to cause problems
402
+ target_r = 3 * rows // 4
403
+ target_c = cols // 3
404
+ # Find nearest unburned cell
405
+ for dr in range(5):
406
+ for dc in range(5):
407
+ r, c = target_r + dr, target_c + dc
408
+ if self.grid._in_bounds(r, c):
409
+ if self.grid.dynamic_grid[r][c].fire_state == FireState.UNBURNED:
410
+ self.grid.ignite_cell(r, c, intensity=0.7)
411
+ return
412
+
413
+ def _validate_action(self, action: Action) -> tuple[bool, str]:
414
+ """Validate action parameters. Returns (is_valid, error_message)."""
415
+ try:
416
+ # Pydantic validation already ran on construction,
417
+ # but we do semantic validation here
418
+ if action.action_type == ActionType.DEPLOY_CREW:
419
+ if not self.grid._in_bounds(action.target_row, action.target_col):
420
+ return False, f"Target ({action.target_row},{action.target_col}) out of bounds"
421
+
422
+ elif action.action_type == ActionType.DROP_RETARDANT:
423
+ if not self.grid._in_bounds(action.target_row, action.target_col):
424
+ return False, f"Target ({action.target_row},{action.target_col}) out of bounds"
425
+
426
+ elif action.action_type == ActionType.RECON_FLIGHT:
427
+ if not self.grid._in_bounds(action.target_row, action.target_col):
428
+ return False, f"Target ({action.target_row},{action.target_col}) out of bounds"
429
+
430
+ return True, ""
431
+
432
+ except Exception as e:
433
+ return False, str(e)
434
+
435
+ def _execute_action(self, action: Action) -> list[str]:
436
+ """Execute a validated action. Returns event messages."""
437
+ events = []
438
+ at = action.action_type
439
+
440
+ if at == ActionType.DEPLOY_CREW:
441
+ ok, msg = self.resources.deploy_crew(action.crew_id, action.target_row, action.target_col)
442
+ events.append(msg)
443
+ if not ok:
444
+ self.resources.wasted_actions += 1
445
+
446
+ elif at == ActionType.MOVE_CREW:
447
+ ok, msg = self.resources.move_crew(action.crew_id, action.direction)
448
+ events.append(msg)
449
+ if not ok:
450
+ self.resources.wasted_actions += 1
451
+
452
+ elif at == ActionType.DROP_RETARDANT:
453
+ ok, msg = self.resources.drop_retardant(action.tanker_id, action.target_row, action.target_col)
454
+ events.append(msg)
455
+ if not ok:
456
+ self.resources.wasted_actions += 1
457
+
458
+ elif at == ActionType.BUILD_FIREBREAK:
459
+ ok, msg = self.resources.build_firebreak(action.crew_id, action.direction)
460
+ events.append(msg)
461
+ if not ok:
462
+ self.resources.wasted_actions += 1
463
+
464
+ elif at == ActionType.RECON_FLIGHT:
465
+ ok, msg = self.resources.recon_flight(action.target_row, action.target_col, self.current_step)
466
+ events.append(msg)
467
+ if not ok:
468
+ self.resources.wasted_actions += 1
469
+
470
+ elif at == ActionType.IDLE:
471
+ reason = action.reason or "No action taken"
472
+ events.append(f"IDLE: {reason}")
473
+
474
+ return events
475
+
476
+ def _check_termination(self) -> bool:
477
+ """Check if the episode should end."""
478
+ # Time limit
479
+ if self.current_step >= self.config.episode_length:
480
+ return True
481
+
482
+ # Fire fully contained (no burning cells)
483
+ burning = self.grid.count_by_state(FireState.BURNING)
484
+ ember = self.grid.count_by_state(FireState.EMBER)
485
+ if burning == 0 and ember == 0 and self.current_step > 1:
486
+ # Don't end on step 0-1 (fire just started)
487
+ if not (self.config.staggered_ignition_step
488
+ and self.current_step < self.config.staggered_ignition_step):
489
+ return True
490
+
491
+ # All populated zones burned (catastrophic failure)
492
+ total_pop = self.grid.get_total_population()
493
+ lost_pop = self.grid.get_population_lost()
494
+ if total_pop > 0 and lost_pop >= total_pop:
495
+ return True
496
+
497
+ return False
498
+
499
+ def _build_observation(self) -> Observation:
500
+ """Build the agent's observation with appropriate noise/occlusion."""
501
+ # Grid observation with fog/smoke
502
+ crew_positions = self.resources.get_crew_positions()
503
+ grid_obs = self.grid.build_observation(
504
+ enable_fog=self.config.enable_fog_of_war,
505
+ fog_radius=self.config.fog_visibility_radius,
506
+ crew_positions=crew_positions,
507
+ revealed_cells=self.resources.revealed_cells,
508
+ )
509
+
510
+ # Weather observation (possibly noisy)
511
+ weather_obs = self.weather.get_observation()
512
+
513
+ # Resource state (fully observable)
514
+ resource_state = self.resources.get_resource_state()
515
+
516
+ # Stats
517
+ stats = ClusterStats(
518
+ cells_burned=self.grid.get_burned_count(),
519
+ cells_burning=self.grid.count_by_state(FireState.BURNING),
520
+ cells_saved=self.grid.get_total_burnable() - self.grid.get_burned_count() - self.grid.count_by_state(FireState.BURNING),
521
+ population_threatened=self._count_threatened_population(),
522
+ population_lost=self.grid.get_population_lost(),
523
+ containment_pct=self._compute_containment_pct(),
524
+ current_step=self.current_step,
525
+ max_steps=self.config.episode_length,
526
+ firebreaks_built=self.resources.total_firebreaks_built,
527
+ retardant_drops=self.resources.total_retardant_drops,
528
+ )
529
+
530
+ # Recent events (last 5)
531
+ recent = self.events_log[-5:] if self.events_log else []
532
+
533
+ return Observation(
534
+ grid=grid_obs,
535
+ weather=weather_obs,
536
+ resources=resource_state,
537
+ stats=stats,
538
+ recent_events=recent,
539
+ )
540
+
541
+ def _count_threatened_population(self) -> int:
542
+ """Count population within 3 cells of active fire."""
543
+ threatened = 0
544
+ burning_cells = self.grid.get_burning_cells()
545
+ counted = set()
546
+
547
+ for br, bc in burning_cells:
548
+ for r in range(max(0, br - 3), min(self.grid.rows, br + 4)):
549
+ for c in range(max(0, bc - 3), min(self.grid.cols, bc + 4)):
550
+ if (r, c) not in counted:
551
+ static = self.grid.static_grid[r][c]
552
+ if static.is_populated:
553
+ dynamic = self.grid.dynamic_grid[r][c]
554
+ if dynamic.fire_state not in (FireState.BURNED_OUT, FireState.BURNING):
555
+ threatened += static.population
556
+ counted.add((r, c))
557
+ return threatened
558
+
559
+ def _compute_containment_pct(self) -> float:
560
+ """Compute fire containment percentage."""
561
+ total, contained = self.grid.get_fire_perimeter()
562
+ if total == 0:
563
+ return 100.0
564
+ return round(100.0 * contained / total, 1)
frontend/app.js ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Wildfire ICS — Frontend Application Logic
3
+ * app.js | Vanilla JS, no external dependencies
4
+ *
5
+ * API contract (critical):
6
+ * POST /reset → returns Observation directly
7
+ * POST /step → returns StepResult { observation, reward, done, info }
8
+ * POST /auto_step → returns { steps: [StepSnapshot], done: bool }
9
+ * GET /state/render → trimmed ground-truth snapshot (fog bypassed)
10
+ */
11
+
12
+ "use strict";
13
+
14
+ // ── Simulation state ──────────────────────────────────────────────────────────
15
+ const sim = {
16
+ obs: null, // current Observation (agent's view)
17
+ cumulativeReward: 0,
18
+ lastStepReward: 0,
19
+ done: false,
20
+ groundTruthData: null, // from GET /state/render when toggle is on
21
+ agentMode: "heuristic",
22
+ tier: "easy",
23
+ seed: 42,
24
+ playing: false,
25
+ speed: 600, // ms between auto_step calls
26
+ playTimer: null,
27
+ cellSize: 0, // computed per reset
28
+ };
29
+
30
+ // ── Canvas setup ──────────────────────────────────────────────────────────────
31
+ const canvas = document.getElementById("grid-canvas");
32
+ const ctx = canvas.getContext("2d");
33
+ const canvasWrap = document.getElementById("canvas-wrap");
34
+
35
+ // ── Cell colour function — mirrors env/rendering.py exactly ─────────────────
36
+ function cellColor(cell) {
37
+ const fs = cell.fire_state;
38
+ const intensity = cell.fire_intensity ?? 0;
39
+
40
+ if (fs === "unknown") return "rgba(0,0,0,0.82)";
41
+
42
+ if (fs === "burning") {
43
+ const sat = 0.4 + 0.6 * intensity;
44
+ const g = Math.round((1.0 - sat * 0.8) * 255);
45
+ return `rgb(255,${g},0)`;
46
+ }
47
+ if (fs === "ember") return "#e55c00";
48
+ if (fs === "burned_out") return "#3f3530";
49
+ if (fs === "firebreak") return "#8c5a28";
50
+ if (fs === "suppressed") return "#88cc88";
51
+
52
+ // Unburned — shade by fuel type
53
+ const fuel = cell.fuel_type ?? "grass";
54
+ switch (fuel) {
55
+ case "water": return "#4d80e6";
56
+ case "road": return "#b0b0b0";
57
+ case "timber": return "#1a7a1a";
58
+ case "shrub": return "#7fba33";
59
+ case "urban": return "#ccbfb2";
60
+ default: return "#a8d95e"; // grass
61
+ }
62
+ }
63
+
64
+ // ── Canvas renderer ───────────────────────────────────────────────────────────
65
+ function renderCanvas(obs, groundTruth = null) {
66
+ if (!obs || !obs.grid || obs.grid.length === 0) return;
67
+
68
+ const rows = obs.grid.length;
69
+ const cols = obs.grid[0].length;
70
+
71
+ // Resize canvas if grid dimensions changed
72
+ const panelW = canvasWrap.parentElement.clientWidth - 24;
73
+ const panelH = canvasWrap.parentElement.clientHeight - 24;
74
+ const cs = Math.max(4, Math.floor(Math.min(panelW / cols, panelH / rows)));
75
+ sim.cellSize = cs;
76
+
77
+ if (canvas.width !== cs * cols || canvas.height !== cs * rows) {
78
+ canvas.width = cs * cols;
79
+ canvas.height = cs * rows;
80
+ }
81
+
82
+ // Build a lookup for ground-truth overlay (only unknown cells get overridden)
83
+ const gtGrid = groundTruth?.grid ?? null;
84
+
85
+ for (let r = 0; r < rows; r++) {
86
+ for (let c = 0; c < cols; c++) {
87
+ let cell = obs.grid[r][c];
88
+
89
+ // Ground-truth overlay: if toggle is on and cell is unknown, show real state
90
+ if (gtGrid && cell.fire_state === "unknown") {
91
+ cell = { ...cell, ...gtGrid[r][c], _gt_overlay: true };
92
+ }
93
+
94
+ const color = cellColor(cell);
95
+ ctx.fillStyle = color;
96
+ ctx.fillRect(c * cs, r * cs, cs, cs);
97
+
98
+ // Ground-truth overlay marker (slightly transparent to distinguish)
99
+ if (cell._gt_overlay) {
100
+ ctx.fillStyle = "rgba(255,200,0,0.08)";
101
+ ctx.fillRect(c * cs, r * cs, cs, cs);
102
+ }
103
+
104
+ // Populated cell: blue border
105
+ if (cell.is_populated) {
106
+ ctx.strokeStyle = "#58a6ff";
107
+ ctx.lineWidth = Math.max(1, cs * 0.1);
108
+ ctx.strokeRect(c * cs + 0.5, r * cs + 0.5, cs - 1, cs - 1);
109
+ }
110
+
111
+ // Crew present: green dot
112
+ if (cell.crew_present) {
113
+ ctx.fillStyle = "#00ff88";
114
+ const r2 = Math.max(2, cs * 0.22);
115
+ ctx.beginPath();
116
+ ctx.arc(c * cs + cs / 2, r * cs + cs / 2, r2, 0, Math.PI * 2);
117
+ ctx.fill();
118
+ }
119
+ }
120
+ }
121
+
122
+ // Draw crew markers from resources (labelled)
123
+ const crews = obs.resources?.crews ?? [];
124
+ for (const crew of crews) {
125
+ if (!crew.is_deployed || !crew.is_active) continue;
126
+ const cx = crew.col * cs + cs / 2;
127
+ const cy = crew.row * cs + cs / 2;
128
+ const r2 = Math.max(3, cs * 0.28);
129
+
130
+ ctx.beginPath();
131
+ ctx.arc(cx, cy, r2, 0, Math.PI * 2);
132
+ ctx.fillStyle = crew.is_active ? "lime" : "#f85149";
133
+ ctx.fill();
134
+ ctx.strokeStyle = "#000";
135
+ ctx.lineWidth = 1;
136
+ ctx.stroke();
137
+
138
+ if (cs >= 10) {
139
+ const label = crew.crew_id.replace("crew_", "c");
140
+ ctx.fillStyle = "#fff";
141
+ ctx.font = `bold ${Math.max(7, cs * 0.4)}px 'Courier New', monospace`;
142
+ ctx.textAlign = "center";
143
+ ctx.textBaseline = "middle";
144
+ ctx.fillText(label, cx, cy);
145
+ }
146
+ }
147
+
148
+ // Pulse canvas border if fire is active
149
+ const burning = obs.stats?.cells_burning ?? 0;
150
+ if (burning > 0) {
151
+ canvasWrap.classList.add("fire-active");
152
+ } else {
153
+ canvasWrap.classList.remove("fire-active");
154
+ }
155
+
156
+ // Update step progress bar
157
+ const cur = obs.stats?.current_step ?? 0;
158
+ const max = obs.stats?.max_steps ?? 1;
159
+ document.getElementById("step-progress-fill").style.width =
160
+ `${Math.min(100, (cur / max) * 100)}%`;
161
+ }
162
+
163
+ // ── Stats panel ───────────────────────────────────────────────────────────────
164
+ function updateStats(stats, cumulativeReward, lastStepReward) {
165
+ if (!stats) return;
166
+
167
+ const cur = stats.current_step ?? 0;
168
+ const max = stats.max_steps ?? 1;
169
+
170
+ setText("stat-step", `${cur} / ${max}`);
171
+ setText("stat-containment-val", `${(stats.containment_pct ?? 0).toFixed(1)}%`);
172
+ setText("stat-burning-val", stats.cells_burning ?? 0);
173
+ setText("stat-pop-threat-val", stats.population_threatened ?? 0);
174
+ setText("stat-pop-lost-val", stats.population_lost ?? 0);
175
+
176
+ // Cumulative reward
177
+ setText("reward-total", cumulativeReward.toFixed(3));
178
+
179
+ // Per-step delta with colour
180
+ const deltaEl = document.getElementById("reward-delta");
181
+ if (deltaEl) {
182
+ const sign = lastStepReward >= 0 ? "+" : "";
183
+ deltaEl.textContent = `${sign}${lastStepReward.toFixed(3)} this step`;
184
+ deltaEl.className = "reward-delta " + (lastStepReward >= 0 ? "positive" : "negative");
185
+ }
186
+ }
187
+
188
+ function setText(id, value) {
189
+ const el = document.getElementById(id);
190
+ if (el) el.textContent = value;
191
+ }
192
+
193
+ // ── Resources panel ───────────────────────────────────────────────────────────
194
+ function updateResources(resources) {
195
+ if (!resources) return;
196
+
197
+ const crewBody = document.getElementById("crew-tbody");
198
+ if (crewBody) {
199
+ crewBody.innerHTML = "";
200
+ for (const crew of (resources.crews ?? [])) {
201
+ const tr = document.createElement("tr");
202
+ let cls = "crew-idle";
203
+ let status = "STAGING";
204
+ if (!crew.is_active) { cls = "crew-lost"; status = "LOST"; }
205
+ else if (crew.is_deployed) { cls = "crew-deployed"; status = `${crew.row},${crew.col}`; }
206
+ tr.className = cls;
207
+ tr.innerHTML = `<td>${crew.crew_id.replace("crew_","C")}</td><td>${status}</td>`;
208
+ crewBody.appendChild(tr);
209
+ }
210
+ }
211
+
212
+ const tankerBody = document.getElementById("tanker-tbody");
213
+ if (tankerBody) {
214
+ tankerBody.innerHTML = "";
215
+ for (const tanker of (resources.tankers ?? [])) {
216
+ const tr = document.createElement("tr");
217
+ tr.className = "tanker-row";
218
+ const cd = tanker.cooldown_remaining ?? 0;
219
+ const maxCd = 5; // matches TierConfig.tanker_cooldown default
220
+ const pct = cd === 0 ? 0 : (cd / maxCd) * 100;
221
+ const readyClass = cd === 0 ? "tanker-ready" : "tanker-charging";
222
+ const readyLabel = cd === 0 ? "READY" : `CD:${cd}`;
223
+ tr.innerHTML = `
224
+ <td>${tanker.tanker_id.replace("tanker_","T")}</td>
225
+ <td class="${readyClass}">${readyLabel}</td>
226
+ <td>
227
+ <div class="cooldown-bar-wrap">
228
+ <div class="cooldown-bar-fill" style="width:${pct}%"></div>
229
+ </div>
230
+ </td>`;
231
+ tankerBody.appendChild(tr);
232
+ }
233
+ }
234
+
235
+ // Budgets
236
+ const fb = resources.firebreak_budget ?? 0;
237
+ const rb = resources.recon_budget ?? 0;
238
+ setText("firebreak-budget", `FB: ${fb}`);
239
+ setText("recon-budget", `RC: ${rb}`);
240
+ }
241
+
242
+ // ── Weather panel ─────────────────────────────────────────────────────────────
243
+ function updateWeather(weather) {
244
+ if (!weather) return;
245
+
246
+ const speed = weather.wind_speed_kmh ?? 0;
247
+ const dir = weather.wind_direction_deg ?? 0;
248
+ const hum = weather.humidity_pct ?? 0;
249
+ const rain = weather.rain_active ?? false;
250
+
251
+ setText("wind-speed-val", `${speed.toFixed(0)} km/h`);
252
+ setText("wind-dir-val", `${dir.toFixed(0)}°`);
253
+ setText("humidity-val", `${hum.toFixed(0)}%`);
254
+
255
+ // Rotate needle: 0° = North (top of dial)
256
+ const needle = document.getElementById("wind-needle");
257
+ if (needle) needle.style.transform = `translateX(-50%) translateY(-100%) rotate(${dir}deg)`;
258
+
259
+ const rainBadge = document.getElementById("rain-badge");
260
+ if (rainBadge) rainBadge.classList.toggle("active", rain);
261
+ }
262
+
263
+ // ── Events log ────────────────────────────────────────────────────────────────
264
+ let _lastEventSet = [];
265
+
266
+ function updateEvents(events) {
267
+ if (!events || events.length === 0) return;
268
+
269
+ const newEvents = events.filter(e => !_lastEventSet.includes(e));
270
+ if (newEvents.length === 0) return;
271
+ _lastEventSet = events;
272
+
273
+ const log = document.getElementById("events-log");
274
+ if (!log) return;
275
+
276
+ for (const evt of newEvents.slice().reverse()) {
277
+ const div = document.createElement("div");
278
+ div.className = "event-entry";
279
+ div.textContent = evt;
280
+ log.insertBefore(div, log.firstChild);
281
+ }
282
+
283
+ // Keep at most 30 entries
284
+ while (log.children.length > 30) log.removeChild(log.lastChild);
285
+ }
286
+
287
+ // ── Action log ────────────────────────────────────────────────────────────────
288
+ function updateActionLog(action) {
289
+ if (!action) return;
290
+ setText("last-action-type", action.action_type?.toUpperCase() ?? "—");
291
+ const params = { ...action };
292
+ delete params.action_type;
293
+ const paramStr = Object.entries(params)
294
+ .filter(([, v]) => v !== null && v !== undefined)
295
+ .map(([k, v]) => `${k}: ${v}`)
296
+ .join(" | ") || "—";
297
+ setText("last-action-params", paramStr);
298
+ }
299
+
300
+ // ── Terminal overlay ──────────────────────────────────────────────────────────
301
+ function showTerminal(obs) {
302
+ const overlay = document.getElementById("terminal-overlay");
303
+ if (!overlay) return;
304
+
305
+ const stats = obs?.stats ?? {};
306
+ const popLost = stats.population_lost ?? 0;
307
+ const containment = stats.containment_pct ?? 0;
308
+
309
+ const card = document.getElementById("terminal-card");
310
+ const title = card.querySelector("h2");
311
+
312
+ if (popLost === 0) {
313
+ title.textContent = "✅ FIRE CONTAINED";
314
+ title.className = "win";
315
+ } else {
316
+ title.textContent = "⚠ EPISODE ENDED";
317
+ title.className = "loss";
318
+ }
319
+
320
+ setText("terminal-containment", `${containment.toFixed(1)}%`);
321
+ setText("terminal-pop-lost", popLost);
322
+ setText("terminal-reward", sim.cumulativeReward.toFixed(3));
323
+ setText("terminal-step", stats.current_step ?? "—");
324
+
325
+ overlay.classList.add("show");
326
+ }
327
+
328
+ function hideTerminal() {
329
+ document.getElementById("terminal-overlay")?.classList.remove("show");
330
+ }
331
+
332
+ // ── API helpers ───────────────────────────────────────────────────────────────
333
+ async function apiPost(path, body = null, params = {}) {
334
+ const url = new URL(path, window.location.origin);
335
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
336
+ const opts = { method: "POST" };
337
+ if (body) { opts.body = JSON.stringify(body); opts.headers = { "Content-Type": "application/json" }; }
338
+ const res = await fetch(url, opts);
339
+ if (!res.ok) {
340
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
341
+ throw new Error(err.detail ?? res.statusText);
342
+ }
343
+ return res.json();
344
+ }
345
+
346
+ async function apiGet(path) {
347
+ const res = await fetch(path);
348
+ if (!res.ok) {
349
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
350
+ throw new Error(err.detail ?? res.statusText);
351
+ }
352
+ return res.json();
353
+ }
354
+
355
+ // ── Full UI update from obs ───────────────────────────────────────────────────
356
+ function applyObservation(obs) {
357
+ sim.obs = obs;
358
+ renderCanvas(obs, sim.groundTruthData);
359
+ updateStats(obs.stats, sim.cumulativeReward, sim.lastStepReward);
360
+ updateResources(obs.resources);
361
+ updateWeather(obs.weather);
362
+ updateEvents(obs.recent_events ?? []);
363
+ }
364
+
365
+ // ── Reset flow ────────────────────────────────────────────────────────────────
366
+ async function doReset() {
367
+ stopPlay();
368
+ hideTerminal();
369
+ setStatus("Resetting…");
370
+ setControlsEnabled(false);
371
+
372
+ sim.cumulativeReward = 0;
373
+ sim.lastStepReward = 0;
374
+ sim.done = false;
375
+ sim.groundTruthData = null;
376
+ _lastEventSet = [];
377
+ document.getElementById("events-log").innerHTML = "";
378
+ setText("last-action-type", "—");
379
+ setText("last-action-params", "—");
380
+
381
+ try {
382
+ // POST /reset → returns Observation directly (not wrapped in StepResult)
383
+ const obs = await apiPost("/reset", null, {
384
+ task_id: sim.tier,
385
+ seed: sim.seed,
386
+ });
387
+ applyObservation(obs);
388
+ setStatus("Ready");
389
+ } catch (e) {
390
+ setStatus(`Error: ${e.message}`);
391
+ console.error(e);
392
+ } finally {
393
+ setControlsEnabled(true);
394
+ }
395
+ }
396
+
397
+ // ── Auto-step (agent drives the sim) ────────────────────────────────────────
398
+ async function doAutoStep() {
399
+ if (sim.done) { stopPlay(); return; }
400
+ if (!sim.obs) { stopPlay(); return; }
401
+
402
+ try {
403
+ // POST /auto_step → { steps: [StepSnapshot], done: bool }
404
+ const data = await apiPost("/auto_step", null, {
405
+ n: 1,
406
+ agent: sim.agentMode,
407
+ });
408
+
409
+ for (const snap of data.steps) {
410
+ // StepSnapshot: { observation, reward, done, info, action_taken }
411
+ sim.lastStepReward = snap.reward;
412
+ sim.cumulativeReward += snap.reward;
413
+ sim.done = snap.done;
414
+
415
+ applyObservation(snap.observation);
416
+ updateActionLog(snap.action_taken);
417
+
418
+ if (snap.done) {
419
+ stopPlay();
420
+ showTerminal(snap.observation);
421
+ break;
422
+ }
423
+ }
424
+
425
+ // Refresh ground-truth overlay if active
426
+ if (document.getElementById("gt-toggle")?.checked) {
427
+ refreshGroundTruth();
428
+ }
429
+ } catch (e) {
430
+ setStatus(`Step error: ${e.message}`);
431
+ console.error(e);
432
+ stopPlay();
433
+ }
434
+ }
435
+
436
+ // ── Ground truth overlay ──────────────────────────────────────────────────────
437
+ async function refreshGroundTruth() {
438
+ try {
439
+ const gt = await apiGet("/state/render");
440
+ sim.groundTruthData = gt;
441
+ renderCanvas(sim.obs, gt);
442
+ } catch (e) {
443
+ console.warn("Ground truth fetch failed:", e.message);
444
+ }
445
+ }
446
+
447
+ // ── Play / pause ──────────────────────────────────────────────────────────────
448
+ function startPlay() {
449
+ if (sim.playing || sim.done || !sim.obs) return;
450
+ sim.playing = true;
451
+ updatePlayButton();
452
+ doAutoStep();
453
+ sim.playTimer = setInterval(doAutoStep, sim.speed);
454
+ }
455
+
456
+ function stopPlay() {
457
+ if (sim.playTimer) { clearInterval(sim.playTimer); sim.playTimer = null; }
458
+ sim.playing = false;
459
+ updatePlayButton();
460
+ }
461
+
462
+ function togglePlay() {
463
+ if (sim.playing) stopPlay(); else startPlay();
464
+ }
465
+
466
+ function updatePlayButton() {
467
+ const btn = document.getElementById("btn-play");
468
+ if (!btn) return;
469
+ btn.textContent = sim.playing ? "⏸ Pause" : "▶ Play";
470
+ btn.classList.toggle("playing", sim.playing);
471
+ }
472
+
473
+ // ── Status line ───────────────────────────────────────────────────────────────
474
+ function setStatus(msg) {
475
+ const el = document.getElementById("status-text");
476
+ if (el) el.textContent = msg;
477
+ }
478
+
479
+ function setControlsEnabled(enabled) {
480
+ ["btn-reset", "btn-play", "btn-step"].forEach(id => {
481
+ const el = document.getElementById(id);
482
+ if (el) el.disabled = !enabled;
483
+ });
484
+ }
485
+
486
+ // ── Canvas hover tooltip ──────────────────────────────────────────────────────
487
+ const tooltip = document.getElementById("cell-tooltip");
488
+
489
+ canvas.addEventListener("mousemove", (e) => {
490
+ if (!sim.obs || sim.cellSize === 0) return;
491
+ const rect = canvas.getBoundingClientRect();
492
+ const scaleX = canvas.width / rect.width;
493
+ const scaleY = canvas.height / rect.height;
494
+ const px = (e.clientX - rect.left) * scaleX;
495
+ const py = (e.clientY - rect.top) * scaleY;
496
+ const col = Math.floor(px / sim.cellSize);
497
+ const row = Math.floor(py / sim.cellSize);
498
+
499
+ const grid = sim.obs.grid;
500
+ if (row < 0 || row >= grid.length || col < 0 || col >= grid[0].length) {
501
+ tooltip.style.display = "none";
502
+ return;
503
+ }
504
+ const cell = grid[row][col];
505
+
506
+ tooltip.textContent =
507
+ `(${row},${col}) ${cell.fire_state}` +
508
+ (cell.fuel_type ? ` · ${cell.fuel_type}` : "") +
509
+ (cell.is_populated ? " · 🏘 pop" : "") +
510
+ (cell.fire_intensity ? ` · int:${cell.fire_intensity.toFixed(2)}` : "");
511
+
512
+ const wrapRect = canvasWrap.getBoundingClientRect();
513
+ tooltip.style.left = `${e.clientX - wrapRect.left + 10}px`;
514
+ tooltip.style.top = `${e.clientY - wrapRect.top + 10}px`;
515
+ tooltip.style.display = "block";
516
+ });
517
+
518
+ canvas.addEventListener("mouseleave", () => { tooltip.style.display = "none"; });
519
+
520
+ // ── Controls wiring ───────────────────────────────────────────────────────────
521
+ document.addEventListener("DOMContentLoaded", () => {
522
+
523
+ document.getElementById("btn-reset")?.addEventListener("click", doReset);
524
+
525
+ document.getElementById("btn-play")?.addEventListener("click", togglePlay);
526
+
527
+ document.getElementById("btn-step")?.addEventListener("click", async () => {
528
+ if (sim.done || !sim.obs) return;
529
+ stopPlay();
530
+ await doAutoStep();
531
+ });
532
+
533
+ // Tier selector
534
+ document.getElementById("tier-select")?.addEventListener("change", (e) => {
535
+ sim.tier = e.target.value;
536
+ });
537
+
538
+ // Seed input
539
+ document.getElementById("seed-input")?.addEventListener("change", (e) => {
540
+ sim.seed = parseInt(e.target.value, 10) || 42;
541
+ });
542
+
543
+ // Agent selector
544
+ document.getElementById("agent-select")?.addEventListener("change", (e) => {
545
+ sim.agentMode = e.target.value;
546
+ // Reset active agent on next /reset or stop and let server re-create
547
+ if (sim.playing) stopPlay();
548
+ });
549
+
550
+ // Speed slider
551
+ document.getElementById("speed-slider")?.addEventListener("input", (e) => {
552
+ sim.speed = parseInt(e.target.value, 10);
553
+ setText("speed-label", `${sim.speed}ms`);
554
+ if (sim.playing) {
555
+ clearInterval(sim.playTimer);
556
+ sim.playTimer = setInterval(doAutoStep, sim.speed);
557
+ }
558
+ });
559
+
560
+ // Ground truth toggle
561
+ document.getElementById("gt-toggle")?.addEventListener("change", async (e) => {
562
+ if (e.target.checked) {
563
+ await refreshGroundTruth();
564
+ } else {
565
+ sim.groundTruthData = null;
566
+ renderCanvas(sim.obs, null);
567
+ }
568
+ });
569
+
570
+ // Terminal "Play again" button
571
+ document.getElementById("btn-play-again")?.addEventListener("click", doReset);
572
+
573
+ // Auto-reset on load with easy tier
574
+ doReset();
575
+ });
576
+
577
+ // ── Resize handler — redraw canvas when window resizes ───────────────────────
578
+ window.addEventListener("resize", () => {
579
+ if (sim.obs) renderCanvas(sim.obs, sim.groundTruthData);
580
+ });
frontend/index.html ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Wildfire ICS — Containment Simulator</title>
7
+ <meta name="description"
8
+ content="Interactive Wildfire Incident Command System simulator. Watch an AI agent
9
+ dispatch firefighting resources across three difficulty tiers in real time.
10
+ OpenEnv x Scaler Hackathon — Meta &amp; HuggingFace.">
11
+ <link rel="stylesheet" href="style.css">
12
+ </head>
13
+ <body>
14
+
15
+ <!-- ── Header ───────────────────────────────────────────────────────────────── -->
16
+ <header id="app-header">
17
+ <div class="logo">🔥 WILDFIRE ICS <span>Containment Simulator</span></div>
18
+
19
+ <div class="header-controls">
20
+
21
+ <div class="control-group">
22
+ <label for="tier-select">TIER</label>
23
+ <select id="tier-select" title="Difficulty tier">
24
+ <option value="easy" selected>Easy · 15×15 · 80 steps</option>
25
+ <option value="medium">Medium · 25×25 · 150 steps</option>
26
+ <option value="hard"> Hard · 40×40 · 300 steps</option>
27
+ </select>
28
+ </div>
29
+
30
+ <div class="control-group">
31
+ <label for="seed-input">SEED</label>
32
+ <input id="seed-input" type="number" value="42" min="0" max="999" title="Episode seed">
33
+ </div>
34
+
35
+ <div class="control-group">
36
+ <label for="agent-select">AGENT</label>
37
+ <select id="agent-select" title="Built-in agent">
38
+ <option value="heuristic" selected>Heuristic</option>
39
+ <option value="random">Random</option>
40
+ </select>
41
+ </div>
42
+
43
+ <button id="btn-reset" class="btn btn-secondary" title="Reset the simulation">↺ Reset</button>
44
+ <button id="btn-play" class="btn btn-play" title="Play / pause auto-step">▶ Play</button>
45
+ <button id="btn-step" class="btn btn-secondary" title="Single step">Step →</button>
46
+
47
+ <div class="control-group">
48
+ <label for="speed-slider">SPEED</label>
49
+ <input id="speed-slider" type="range" min="100" max="2000" step="100" value="600"
50
+ title="Step delay in ms">
51
+ <span id="speed-label" style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);width:44px;">600ms</span>
52
+ </div>
53
+
54
+ <div class="toggle-wrap" title="Overlay ground-truth state (bypasses fog-of-war)">
55
+ <input type="checkbox" id="gt-toggle">
56
+ <label for="gt-toggle">Ground Truth</label>
57
+ </div>
58
+
59
+ </div>
60
+ </header>
61
+
62
+ <!-- ── Session warning ───────────────────────────────────────────────────────── -->
63
+ <div id="session-banner">
64
+ ⚠ Single-session mode — all browser tabs share the same simulation instance.
65
+ </div>
66
+
67
+ <!-- ── Main body ──────────────────────────────────────────────────────────────── -->
68
+ <div id="app-body">
69
+
70
+ <!-- Canvas panel -->
71
+ <main id="canvas-panel">
72
+ <div id="canvas-wrap">
73
+ <canvas id="grid-canvas" title="Hover over cells for details"></canvas>
74
+ <div id="cell-tooltip"></div>
75
+
76
+ <!-- Step progress bar -->
77
+ <div id="step-progress-wrap">
78
+ <div id="step-progress-fill"></div>
79
+ </div>
80
+
81
+ <!-- Terminal overlay (shown when episode ends) -->
82
+ <div id="terminal-overlay">
83
+ <div id="terminal-card">
84
+ <h2 class="win">✅ FIRE CONTAINED</h2>
85
+ <div class="stat-row">
86
+ <span>Containment</span>
87
+ <span id="terminal-containment">—</span>
88
+ </div>
89
+ <div class="stat-row">
90
+ <span>Population lost</span>
91
+ <span id="terminal-pop-lost">—</span>
92
+ </div>
93
+ <div class="stat-row">
94
+ <span>Total reward</span>
95
+ <span id="terminal-reward">—</span>
96
+ </div>
97
+ <div class="stat-row">
98
+ <span>Steps taken</span>
99
+ <span id="terminal-step">—</span>
100
+ </div>
101
+ <button id="btn-play-again" class="btn btn-primary" style="margin-top:18px;width:100%">
102
+ ↺ Play again
103
+ </button>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </main>
108
+
109
+ <!-- Sidebar -->
110
+ <aside id="sidebar">
111
+
112
+ <!-- Stats -->
113
+ <section class="panel" id="stats-panel">
114
+ <div class="panel-title">Episode Stats</div>
115
+ <div class="stat-grid">
116
+ <div class="stat-item step-item">
117
+ <span class="stat-label">STEP</span>
118
+ <span class="stat-value" id="stat-step">— / —</span>
119
+ </div>
120
+ <div class="stat-item" id="stat-containment">
121
+ <span class="stat-label">CONTAINMENT</span>
122
+ <span class="stat-value" id="stat-containment-val">—</span>
123
+ </div>
124
+ <div class="stat-item" id="stat-burning">
125
+ <span class="stat-label">BURNING</span>
126
+ <span class="stat-value" id="stat-burning-val">—</span>
127
+ </div>
128
+ <div class="stat-item" id="stat-pop-threat">
129
+ <span class="stat-label">POP THREATENED</span>
130
+ <span class="stat-value" id="stat-pop-threat-val">—</span>
131
+ </div>
132
+ <div class="stat-item" id="stat-pop-lost">
133
+ <span class="stat-label">POP LOST</span>
134
+ <span class="stat-value" id="stat-pop-lost-val">—</span>
135
+ </div>
136
+ </div>
137
+ <div id="reward-bar">
138
+ <span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted)">
139
+ Reward <strong id="reward-total" style="color:var(--text)">0.000</strong>
140
+ </span>
141
+ <span id="reward-delta" class="reward-delta">+0.000 this step</span>
142
+ </div>
143
+ </section>
144
+
145
+ <!-- Resources -->
146
+ <section class="panel" id="resources-panel">
147
+ <div class="panel-title">Resources</div>
148
+ <table class="resource-table">
149
+ <thead>
150
+ <tr><th>Crew</th><th>Position / Status</th></tr>
151
+ </thead>
152
+ <tbody id="crew-tbody">
153
+ <tr><td colspan="2" style="color:var(--text-dim)">Reset to load…</td></tr>
154
+ </tbody>
155
+ </table>
156
+ <div style="height:6px"></div>
157
+ <table class="resource-table">
158
+ <thead>
159
+ <tr><th>Tanker</th><th>Status</th><th>Cooldown</th></tr>
160
+ </thead>
161
+ <tbody id="tanker-tbody">
162
+ <tr><td colspan="3" style="color:var(--text-dim)">—</td></tr>
163
+ </tbody>
164
+ </table>
165
+ <div style="margin-top:6px;font-family:var(--font-mono);font-size:11px;color:var(--text-muted);display:flex;gap:12px">
166
+ <span id="firebreak-budget">FB: —</span>
167
+ <span id="recon-budget">RC: —</span>
168
+ </div>
169
+ </section>
170
+
171
+ <!-- Weather -->
172
+ <section class="panel" id="weather-panel">
173
+ <div class="panel-title">Weather</div>
174
+ <div class="weather-row">
175
+ <div id="wind-dial-wrap">
176
+ <div id="wind-dial-bg"></div>
177
+ <div id="wind-needle"></div>
178
+ </div>
179
+ <div class="weather-stats">
180
+ <div class="weather-stat-row">
181
+ <span class="wlabel">Wind</span>
182
+ <span class="wvalue" id="wind-speed-val">— km/h</span>
183
+ </div>
184
+ <div class="weather-stat-row">
185
+ <span class="wlabel">Dir</span>
186
+ <span class="wvalue" id="wind-dir-val">—°</span>
187
+ </div>
188
+ <div class="weather-stat-row">
189
+ <span class="wlabel">Humidity</span>
190
+ <span class="wvalue" id="humidity-val">—%</span>
191
+ </div>
192
+ <div style="margin-top:4px">
193
+ <span id="rain-badge">🌧 RAIN</span>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </section>
198
+
199
+ <!-- Events log -->
200
+ <section class="panel">
201
+ <div class="panel-title">Events Log</div>
202
+ <div id="events-log"></div>
203
+ </section>
204
+
205
+ <!-- Last action -->
206
+ <section class="panel">
207
+ <div class="panel-title">Last Action</div>
208
+ <div id="action-log">
209
+ <div id="last-action-type" style="color:var(--text-muted)">—</div>
210
+ <div id="last-action-params" style="color:var(--text-dim);margin-top:3px">—</div>
211
+ </div>
212
+ </section>
213
+
214
+ <!-- Legend -->
215
+ <section class="panel">
216
+ <div class="panel-title">Legend</div>
217
+ <div class="legend-grid">
218
+ <div class="legend-item">
219
+ <div class="legend-swatch" style="background:#ff9000"></div>Burning
220
+ </div>
221
+ <div class="legend-item">
222
+ <div class="legend-swatch" style="background:#e55c00"></div>Ember
223
+ </div>
224
+ <div class="legend-item">
225
+ <div class="legend-swatch" style="background:#3f3530"></div>Burned out
226
+ </div>
227
+ <div class="legend-item">
228
+ <div class="legend-swatch" style="background:#88cc88"></div>Suppressed
229
+ </div>
230
+ <div class="legend-item">
231
+ <div class="legend-swatch" style="background:#8c5a28"></div>Firebreak
232
+ </div>
233
+ <div class="legend-item">
234
+ <div class="legend-swatch" style="background:#a8d95e"></div>Grass
235
+ </div>
236
+ <div class="legend-item">
237
+ <div class="legend-swatch" style="background:#1a7a1a"></div>Timber
238
+ </div>
239
+ <div class="legend-item">
240
+ <div class="legend-swatch" style="background:#4d80e6"></div>Water
241
+ </div>
242
+ <div class="legend-item">
243
+ <div class="legend-swatch" style="background:#ccbfb2;border:1px solid #333"></div>Urban 🏘
244
+ </div>
245
+ <div class="legend-item">
246
+ <div class="legend-swatch" style="background:#b0b0b0"></div>Road
247
+ </div>
248
+ <div class="legend-item">
249
+ <div class="legend-swatch" style="border:1.5px solid #58a6ff;background:transparent"></div>Populated
250
+ </div>
251
+ <div class="legend-item">
252
+ <div class="legend-swatch" style="background:#000;border:1px solid #484f58"></div>Fog / Unknown
253
+ </div>
254
+ </div>
255
+ </section>
256
+
257
+ <!-- Status line -->
258
+ <section class="panel" style="padding:6px 12px">
259
+ <span style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted)">
260
+ Status: <span id="status-text" style="color:var(--text)">Initializing…</span>
261
+ </span>
262
+ </section>
263
+
264
+ </aside>
265
+ </div>
266
+
267
+ <!-- ── Footer ─────────────────────────────────────────────────────────────────── -->
268
+ <footer id="app-footer">
269
+ <span>OpenEnv × Scaler Hackathon — Meta &amp; HuggingFace</span>
270
+ <span>
271
+ <a href="/docs" target="_blank" rel="noopener">API Docs (Swagger)</a>
272
+ &nbsp;·&nbsp;
273
+ <a href="/health" target="_blank" rel="noopener">Health</a>
274
+ </span>
275
+ </footer>
276
+
277
+ <script src="app.js"></script>
278
+ </body>
279
+ </html>
frontend/style.css ADDED
@@ -0,0 +1,567 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Wildfire ICS — Emergency Operations Dashboard
3
+ style.css | No external font imports (system fonts only)
4
+ ============================================================ */
5
+
6
+ /* ── Reset & tokens ─────────────────────────────────────────── */
7
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
8
+
9
+ :root {
10
+ --bg: #0d1117;
11
+ --surface: #161b22;
12
+ --surface-2: #1c2128;
13
+ --border: #30363d;
14
+ --border-hi: #484f58;
15
+
16
+ --fire: #ff6b35;
17
+ --fire-dim: #c44a1a;
18
+ --ember: #e55c00;
19
+ --safe: #3fb950;
20
+ --warn: #d29922;
21
+ --crit: #f85149;
22
+ --fog: #3d444d;
23
+
24
+ --text: #e6edf3;
25
+ --text-muted: #7d8590;
26
+ --text-dim: #484f58;
27
+
28
+ --font-mono: 'Courier New', Consolas, 'Lucida Console', monospace;
29
+ --font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
30
+
31
+ --radius: 6px;
32
+ --radius-sm: 4px;
33
+ --glow-fire: 0 0 18px rgba(255, 107, 53, 0.45);
34
+ --glow-safe: 0 0 12px rgba(63, 185, 80, 0.35);
35
+ }
36
+
37
+ html, body {
38
+ height: 100%;
39
+ background: var(--bg);
40
+ color: var(--text);
41
+ font-family: var(--font-ui);
42
+ font-size: 14px;
43
+ line-height: 1.5;
44
+ overflow: hidden;
45
+ }
46
+
47
+ /* ── Scrollbar ───────────────────────────────────────────────── */
48
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
49
+ ::-webkit-scrollbar-track { background: var(--surface); }
50
+ ::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 3px; }
51
+
52
+ /* ── Header ──────────────────────────────────────────────────── */
53
+ #app-header {
54
+ height: 54px;
55
+ background: var(--surface);
56
+ border-bottom: 1px solid var(--border);
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 16px;
60
+ padding: 0 16px;
61
+ /* subtle scan-line texture */
62
+ background-image: repeating-linear-gradient(
63
+ 0deg,
64
+ transparent,
65
+ transparent 2px,
66
+ rgba(255,255,255,0.015) 2px,
67
+ rgba(255,255,255,0.015) 4px
68
+ );
69
+ flex-shrink: 0;
70
+ z-index: 10;
71
+ }
72
+
73
+ #app-header .logo {
74
+ font-family: var(--font-mono);
75
+ font-size: 15px;
76
+ font-weight: bold;
77
+ color: var(--fire);
78
+ letter-spacing: 2px;
79
+ white-space: nowrap;
80
+ text-transform: uppercase;
81
+ }
82
+
83
+ #app-header .logo span {
84
+ color: var(--text-muted);
85
+ font-size: 11px;
86
+ letter-spacing: 0;
87
+ font-weight: normal;
88
+ margin-left: 8px;
89
+ }
90
+
91
+ .header-controls {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 10px;
95
+ flex-wrap: wrap;
96
+ }
97
+
98
+ /* ── Form controls ───────────────────────────────────────────── */
99
+ select, input[type="number"] {
100
+ background: var(--surface-2);
101
+ border: 1px solid var(--border);
102
+ color: var(--text);
103
+ font-family: var(--font-mono);
104
+ font-size: 12px;
105
+ border-radius: var(--radius-sm);
106
+ padding: 4px 8px;
107
+ height: 30px;
108
+ outline: none;
109
+ transition: border-color 0.15s;
110
+ }
111
+ select:focus, input[type="number"]:focus {
112
+ border-color: var(--fire);
113
+ }
114
+ select { cursor: pointer; }
115
+
116
+ input[type="number"] { width: 72px; }
117
+
118
+ label {
119
+ font-size: 11px;
120
+ color: var(--text-muted);
121
+ font-family: var(--font-mono);
122
+ }
123
+
124
+ .control-group {
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 5px;
128
+ }
129
+
130
+ /* ── Buttons ─────────────────────────────────────────────────── */
131
+ .btn {
132
+ height: 30px;
133
+ padding: 0 12px;
134
+ border-radius: var(--radius-sm);
135
+ border: 1px solid var(--border);
136
+ font-family: var(--font-mono);
137
+ font-size: 12px;
138
+ cursor: pointer;
139
+ transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
140
+ white-space: nowrap;
141
+ }
142
+
143
+ .btn-primary {
144
+ background: var(--fire-dim);
145
+ border-color: var(--fire);
146
+ color: #fff;
147
+ }
148
+ .btn-primary:hover { background: var(--fire); box-shadow: var(--glow-fire); }
149
+ .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
150
+
151
+ .btn-secondary {
152
+ background: var(--surface-2);
153
+ color: var(--text);
154
+ }
155
+ .btn-secondary:hover { border-color: var(--border-hi); background: var(--surface); }
156
+ .btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
157
+
158
+ .btn-play {
159
+ background: #1a4a1a;
160
+ border-color: var(--safe);
161
+ color: var(--safe);
162
+ min-width: 80px;
163
+ }
164
+ .btn-play:hover { background: #225522; box-shadow: var(--glow-safe); }
165
+ .btn-play.playing {
166
+ background: #4a1a1a;
167
+ border-color: var(--crit);
168
+ color: var(--crit);
169
+ }
170
+
171
+ /* ── Ground truth toggle ─────────────────────────────────────── */
172
+ .toggle-wrap {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 6px;
176
+ font-size: 11px;
177
+ color: var(--text-muted);
178
+ font-family: var(--font-mono);
179
+ }
180
+ .toggle-wrap input[type="checkbox"] {
181
+ accent-color: var(--warn);
182
+ width: 14px; height: 14px;
183
+ cursor: pointer;
184
+ }
185
+
186
+ /* ── Speed slider ────────────────────────────────────────────── */
187
+ input[type="range"] {
188
+ -webkit-appearance: none;
189
+ width: 80px;
190
+ height: 4px;
191
+ background: var(--border);
192
+ border-radius: 2px;
193
+ outline: none;
194
+ }
195
+ input[type="range"]::-webkit-slider-thumb {
196
+ -webkit-appearance: none;
197
+ width: 12px; height: 12px;
198
+ border-radius: 50%;
199
+ background: var(--fire);
200
+ cursor: pointer;
201
+ }
202
+
203
+ /* ── Session warning banner ──────────────────────────────────── */
204
+ #session-banner {
205
+ background: #2a1f0a;
206
+ border-bottom: 1px solid var(--warn);
207
+ color: var(--warn);
208
+ font-size: 11px;
209
+ font-family: var(--font-mono);
210
+ text-align: center;
211
+ padding: 3px 16px;
212
+ flex-shrink: 0;
213
+ }
214
+
215
+ /* ── Main layout ─────────────────────────────────────────────── */
216
+ #app-body {
217
+ display: flex;
218
+ height: calc(100vh - 54px - 24px); /* header + banner */
219
+ overflow: hidden;
220
+ }
221
+
222
+ /* ── Canvas panel ────────────────────────────────────────────── */
223
+ #canvas-panel {
224
+ flex: 1 1 auto;
225
+ min-width: 0;
226
+ display: flex;
227
+ flex-direction: column;
228
+ align-items: center;
229
+ justify-content: center;
230
+ padding: 12px;
231
+ position: relative;
232
+ }
233
+
234
+ #canvas-wrap {
235
+ position: relative;
236
+ border: 2px solid var(--border);
237
+ border-radius: var(--radius);
238
+ overflow: hidden;
239
+ transition: box-shadow 0.4s;
240
+ }
241
+ #canvas-wrap.fire-active {
242
+ box-shadow: var(--glow-fire);
243
+ border-color: var(--fire-dim);
244
+ animation: pulse-border 2s ease-in-out infinite;
245
+ }
246
+ @keyframes pulse-border {
247
+ 0%, 100% { box-shadow: 0 0 8px rgba(255,107,53,0.3); }
248
+ 50% { box-shadow: 0 0 24px rgba(255,107,53,0.65); }
249
+ }
250
+
251
+ #grid-canvas { display: block; image-rendering: pixelated; }
252
+
253
+ /* Tooltip overlay (shows cell info on hover) */
254
+ #cell-tooltip {
255
+ position: absolute;
256
+ background: rgba(13,17,23,0.9);
257
+ border: 1px solid var(--border-hi);
258
+ border-radius: var(--radius-sm);
259
+ padding: 6px 10px;
260
+ font-family: var(--font-mono);
261
+ font-size: 11px;
262
+ color: var(--text);
263
+ pointer-events: none;
264
+ display: none;
265
+ white-space: nowrap;
266
+ z-index: 5;
267
+ }
268
+
269
+ /* Terminal overlay (end of episode) */
270
+ #terminal-overlay {
271
+ position: absolute;
272
+ inset: 0;
273
+ background: rgba(13,17,23,0.88);
274
+ display: none;
275
+ align-items: center;
276
+ justify-content: center;
277
+ z-index: 20;
278
+ }
279
+ #terminal-overlay.show { display: flex; }
280
+ #terminal-card {
281
+ background: var(--surface);
282
+ border: 1px solid var(--border-hi);
283
+ border-radius: var(--radius);
284
+ padding: 28px 36px;
285
+ text-align: center;
286
+ font-family: var(--font-mono);
287
+ max-width: 360px;
288
+ }
289
+ #terminal-card h2 { font-size: 20px; margin-bottom: 12px; }
290
+ #terminal-card h2.win { color: var(--safe); }
291
+ #terminal-card h2.loss { color: var(--crit); }
292
+ #terminal-card .stat-row {
293
+ display: flex;
294
+ justify-content: space-between;
295
+ gap: 24px;
296
+ font-size: 13px;
297
+ color: var(--text-muted);
298
+ margin-top: 6px;
299
+ }
300
+ #terminal-card .stat-row span:last-child { color: var(--text); }
301
+ #terminal-card .btn { margin-top: 18px; width: 100%; justify-content: center; display: flex; align-items: center; }
302
+
303
+ /* ── Sidebar ─────────────────────────────────────────────────── */
304
+ #sidebar {
305
+ width: 290px;
306
+ flex-shrink: 0;
307
+ display: flex;
308
+ flex-direction: column;
309
+ gap: 0;
310
+ border-left: 1px solid var(--border);
311
+ overflow-y: auto;
312
+ background: var(--surface);
313
+ }
314
+
315
+ .panel {
316
+ border-bottom: 1px solid var(--border);
317
+ padding: 10px 12px;
318
+ }
319
+
320
+ .panel-title {
321
+ font-family: var(--font-mono);
322
+ font-size: 10px;
323
+ color: var(--text-dim);
324
+ text-transform: uppercase;
325
+ letter-spacing: 1.5px;
326
+ margin-bottom: 8px;
327
+ }
328
+
329
+ /* ── Stats panel ─────────────────────────────────────────────── */
330
+ .stat-grid {
331
+ display: grid;
332
+ grid-template-columns: 1fr 1fr;
333
+ gap: 6px;
334
+ }
335
+
336
+ .stat-item {
337
+ background: var(--surface-2);
338
+ border: 1px solid var(--border);
339
+ border-radius: var(--radius-sm);
340
+ padding: 7px 9px;
341
+ }
342
+ .stat-item .stat-label {
343
+ font-size: 10px;
344
+ color: var(--text-muted);
345
+ font-family: var(--font-mono);
346
+ display: block;
347
+ }
348
+ .stat-item .stat-value {
349
+ font-family: var(--font-mono);
350
+ font-size: 18px;
351
+ font-weight: bold;
352
+ color: var(--text);
353
+ transition: color 0.3s;
354
+ }
355
+
356
+ .stat-item.step-item { grid-column: 1 / -1; }
357
+ .stat-item.step-item .stat-value { font-size: 14px; }
358
+
359
+ #stat-containment .stat-value { color: var(--safe); }
360
+ #stat-burning .stat-value { color: var(--fire); }
361
+ #stat-pop-threat .stat-value { color: var(--warn); }
362
+ #stat-pop-lost .stat-value { color: var(--crit); }
363
+
364
+ /* Reward display */
365
+ #reward-bar {
366
+ margin-top: 6px;
367
+ padding: 6px 9px;
368
+ background: var(--surface-2);
369
+ border: 1px solid var(--border);
370
+ border-radius: var(--radius-sm);
371
+ font-family: var(--font-mono);
372
+ font-size: 12px;
373
+ display: flex;
374
+ justify-content: space-between;
375
+ }
376
+ #reward-bar .reward-delta { color: var(--text-muted); }
377
+ #reward-bar .reward-delta.positive { color: var(--safe); }
378
+ #reward-bar .reward-delta.negative { color: var(--crit); }
379
+
380
+ /* ── Resources panel ─────────────────────────────────────────── */
381
+ .resource-table {
382
+ width: 100%;
383
+ border-collapse: collapse;
384
+ font-family: var(--font-mono);
385
+ font-size: 11px;
386
+ }
387
+ .resource-table th {
388
+ text-align: left;
389
+ color: var(--text-dim);
390
+ font-weight: normal;
391
+ padding-bottom: 4px;
392
+ font-size: 10px;
393
+ }
394
+ .resource-table td {
395
+ padding: 3px 2px;
396
+ color: var(--text);
397
+ border-top: 1px solid var(--border);
398
+ }
399
+ .resource-table tr.crew-deployed td { color: var(--safe); }
400
+ .resource-table tr.crew-lost td { color: var(--crit); text-decoration: line-through; }
401
+ .resource-table tr.crew-idle td { color: var(--text-muted); }
402
+
403
+ .tanker-row td { vertical-align: middle; }
404
+ .cooldown-bar-wrap {
405
+ width: 60px;
406
+ height: 5px;
407
+ background: var(--border);
408
+ border-radius: 3px;
409
+ overflow: hidden;
410
+ }
411
+ .cooldown-bar-fill {
412
+ height: 100%;
413
+ background: var(--warn);
414
+ border-radius: 3px;
415
+ transition: width 0.3s;
416
+ }
417
+ .tanker-ready { color: var(--safe) !important; }
418
+ .tanker-charging { color: var(--warn) !important; }
419
+
420
+ /* ── Weather panel ───────────────────────────────────────────── */
421
+ #weather-panel .weather-row {
422
+ display: flex;
423
+ align-items: center;
424
+ gap: 12px;
425
+ justify-content: space-between;
426
+ }
427
+ #wind-dial-wrap {
428
+ position: relative;
429
+ width: 54px; height: 54px;
430
+ flex-shrink: 0;
431
+ }
432
+ #wind-dial-bg {
433
+ width: 54px; height: 54px;
434
+ border-radius: 50%;
435
+ border: 2px solid var(--border);
436
+ background: var(--surface-2);
437
+ }
438
+ #wind-needle {
439
+ position: absolute;
440
+ top: 50%; left: 50%;
441
+ width: 2px; height: 22px;
442
+ background: var(--fire);
443
+ transform-origin: bottom center;
444
+ transform: translateX(-50%) translateY(-100%) rotate(0deg);
445
+ border-radius: 1px;
446
+ transition: transform 0.6s ease;
447
+ }
448
+ .weather-stats {
449
+ flex: 1;
450
+ font-family: var(--font-mono);
451
+ font-size: 12px;
452
+ }
453
+ .weather-stat-row {
454
+ display: flex;
455
+ justify-content: space-between;
456
+ padding: 2px 0;
457
+ }
458
+ .weather-stat-row .wlabel { color: var(--text-muted); }
459
+ .weather-stat-row .wvalue { color: var(--text); }
460
+
461
+ #rain-badge {
462
+ display: inline-block;
463
+ padding: 1px 6px;
464
+ border-radius: 10px;
465
+ font-size: 10px;
466
+ background: #1a3a5c;
467
+ color: #58a6ff;
468
+ border: 1px solid #1f6feb;
469
+ display: none;
470
+ }
471
+ #rain-badge.active { display: inline-block; }
472
+
473
+ /* ── Events log ──────────────────────────────────────────────── */
474
+ #events-log {
475
+ max-height: 160px;
476
+ overflow-y: auto;
477
+ font-family: var(--font-mono);
478
+ font-size: 11px;
479
+ }
480
+ .event-entry {
481
+ padding: 3px 0;
482
+ border-bottom: 1px solid var(--border);
483
+ color: var(--text-muted);
484
+ animation: slide-in 0.2s ease-out;
485
+ }
486
+ .event-entry:first-child { color: var(--text); }
487
+ @keyframes slide-in {
488
+ from { opacity: 0; transform: translateY(-6px); }
489
+ to { opacity: 1; transform: translateY(0); }
490
+ }
491
+
492
+ /* ── Action log ──────────────────────────────────────────────── */
493
+ #action-log {
494
+ font-family: var(--font-mono);
495
+ font-size: 11px;
496
+ }
497
+ #last-action-type {
498
+ color: var(--fire);
499
+ font-weight: bold;
500
+ font-size: 12px;
501
+ }
502
+ #last-action-params {
503
+ color: var(--text-muted);
504
+ margin-top: 3px;
505
+ word-break: break-all;
506
+ }
507
+
508
+ /* ── Legend panel ────────────────────────────────────────────── */
509
+ .legend-grid {
510
+ display: grid;
511
+ grid-template-columns: 1fr 1fr;
512
+ gap: 4px;
513
+ }
514
+ .legend-item {
515
+ display: flex;
516
+ align-items: center;
517
+ gap: 6px;
518
+ font-family: var(--font-mono);
519
+ font-size: 10px;
520
+ color: var(--text-muted);
521
+ }
522
+ .legend-swatch {
523
+ width: 12px; height: 12px;
524
+ border-radius: 2px;
525
+ flex-shrink: 0;
526
+ }
527
+
528
+ /* ── Footer ──────────────────────────────────────────────────── */
529
+ #app-footer {
530
+ height: 24px;
531
+ background: var(--surface);
532
+ border-top: 1px solid var(--border);
533
+ display: flex;
534
+ align-items: center;
535
+ justify-content: space-between;
536
+ padding: 0 16px;
537
+ font-size: 10px;
538
+ font-family: var(--font-mono);
539
+ color: var(--text-dim);
540
+ flex-shrink: 0;
541
+ }
542
+ #app-footer a { color: var(--text-dim); text-decoration: none; }
543
+ #app-footer a:hover { color: var(--fire); }
544
+
545
+ /* ── Step counter progress bar ───────────────────────────────── */
546
+ #step-progress-wrap {
547
+ height: 3px;
548
+ background: var(--border);
549
+ position: absolute;
550
+ bottom: 0; left: 0; right: 0;
551
+ }
552
+ #step-progress-fill {
553
+ height: 100%;
554
+ background: var(--fire);
555
+ transition: width 0.3s;
556
+ width: 0%;
557
+ }
558
+
559
+ /* ── Responsive tweak ────────────────────────────────────────── */
560
+ @media (max-width: 900px) {
561
+ #sidebar { width: 240px; }
562
+ }
563
+ @media (max-width: 680px) {
564
+ #app-body { flex-direction: column; }
565
+ #sidebar { width: 100%; height: 240px; flex-direction: row; overflow-x: auto; }
566
+ html, body { overflow: auto; }
567
+ }
graders/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Wildfire Containment Simulator Graders."""
2
+ from .grader_easy import grade as grade_easy
3
+ from .grader_medium import grade as grade_medium
4
+ from .grader_hard import grade as grade_hard
5
+
6
+ __all__ = ["grade_easy", "grade_medium", "grade_hard"]
graders/grader_easy.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grader for Task 1 (Easy): Static cluster, predictable load."""
2
+
3
+ from __future__ import annotations
4
+ from env import WildfireEnv
5
+
6
+
7
+ def grade(agent, seed: int = 42):
8
+ """
9
+ Run a full episode on Easy tier.
10
+
11
+ Returns:
12
+ Tuple of (total_reward: float, details: dict)
13
+ """
14
+ env = WildfireEnv()
15
+ obs = env.reset(task_id="easy", seed=seed)
16
+ total_reward = 0.0
17
+
18
+ while not env.done:
19
+ action = agent.act(obs)
20
+ result = env.step(action)
21
+ total_reward += result.reward
22
+ obs = result.observation
23
+
24
+ final = env.state()
25
+ total_pop = final.get("total_population", 1) or 1
26
+ pop_lost = final.get("population_lost", 0)
27
+
28
+ details = {
29
+ "total_reward": round(total_reward, 4),
30
+ "containment_pct": round(final.get("containment_pct", 0.0), 4),
31
+ "pop_saved_pct": round(1.0 - pop_lost / total_pop, 4),
32
+ "steps": env.current_step,
33
+ "crew_casualty": env._crew_casualty_occurred,
34
+ }
35
+ return total_reward, details
graders/grader_hard.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grader for Task 3 (Hard): Full production chaos."""
2
+
3
+ from __future__ import annotations
4
+ from env import WildfireEnv
5
+
6
+
7
+ def grade(agent, seed: int = 42):
8
+ """
9
+ Run a full episode on Hard tier.
10
+
11
+ Returns:
12
+ Tuple of (total_reward: float, details: dict)
13
+ """
14
+ env = WildfireEnv()
15
+ obs = env.reset(task_id="hard", seed=seed)
16
+ total_reward = 0.0
17
+
18
+ while not env.done:
19
+ action = agent.act(obs)
20
+ result = env.step(action)
21
+ total_reward += result.reward
22
+ obs = result.observation
23
+
24
+ final = env.state()
25
+ total_pop = final.get("total_population", 1) or 1
26
+ pop_lost = final.get("population_lost", 0)
27
+
28
+ details = {
29
+ "total_reward": round(total_reward, 4),
30
+ "containment_pct": round(final.get("containment_pct", 0.0), 4),
31
+ "pop_saved_pct": round(1.0 - pop_lost / total_pop, 4),
32
+ "steps": env.current_step,
33
+ "crew_casualty": env._crew_casualty_occurred,
34
+ }
35
+ return total_reward, details
graders/grader_medium.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grader for Task 2 (Medium): Heterogeneous terrain, wind shifts, smoke."""
2
+
3
+ from __future__ import annotations
4
+ from env import WildfireEnv
5
+
6
+
7
+ def grade(agent, seed: int = 42):
8
+ """
9
+ Run a full episode on Medium tier.
10
+
11
+ Returns:
12
+ Tuple of (total_reward: float, details: dict)
13
+ """
14
+ env = WildfireEnv()
15
+ obs = env.reset(task_id="medium", seed=seed)
16
+ total_reward = 0.0
17
+
18
+ while not env.done:
19
+ action = agent.act(obs)
20
+ result = env.step(action)
21
+ total_reward += result.reward
22
+ obs = result.observation
23
+
24
+ final = env.state()
25
+ total_pop = final.get("total_population", 1) or 1
26
+ pop_lost = final.get("population_lost", 0)
27
+
28
+ details = {
29
+ "total_reward": round(total_reward, 4),
30
+ "containment_pct": round(final.get("containment_pct", 0.0), 4),
31
+ "pop_saved_pct": round(1.0 - pop_lost / total_pop, 4),
32
+ "steps": env.current_step,
33
+ "crew_casualty": env._crew_casualty_occurred,
34
+ }
35
+ return total_reward, details
implementation_plan.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Addressing the Heuristic Performance for the Hackathon Pitch
2
+
3
+ The fact that the heuristic agent performs so well is a common challenge in RL hackathons. If the baseline is unbeatable, the RL training seems pointless.
4
+
5
+ To solve this, we will use **The "Commander's Intent" Narrative**. We won't just "break" the heuristic; we will expose its fundamental weakness: **it is a rigid expert system that cannot read natural language or follow instructions.**
6
+
7
+ This directly aligns with the hackathon's **Theme 2: Long-Horizon Planning & Instruction Following**.
8
+
9
+ ## The Narrative for the Judges
10
+ "Our heuristic baseline is an expert system that aggressively fights fires to save lives. But like all rigid heuristics, it suffers from 'tunnel vision'. It cannot read natural language briefings or follow Commander's Intent. When a small fire threatens a low-priority outpost, the heuristic will blindly divert all resources to save it—abandoning the Priority 1 city to a massive inferno. Our RL agent reads the briefing, understands the Commander's priorities, and makes the hard strategic tradeoffs required in disaster management."
11
+
12
+ ## Proposed Changes
13
+
14
+ We will make the following code adjustments to guarantee the heuristic fails in specific, explainable ways, while the RL agent is incentivized to succeed:
15
+
16
+ ### 1. Introduce Resource Scarcity (`env/models.py`)
17
+ Currently, the heuristic has enough crews and firebreak budget to surround *everything*. By slightly reducing these budgets on `medium` and `hard` tiers, the agent *must* prioritize.
18
+ - **Modify `TIER_MEDIUM`**: Reduce crews from 5 to 4, firebreaks from 20 to 15.
19
+ - **Modify `TIER_HARD`**: Reduce crews from 6 to 5, firebreaks from 30 to 20.
20
+
21
+ ### 2. Heavily Penalize Priority Zone Loss (`env/reward.py`)
22
+ The `OperationalBriefing` defines `priority_populated_zones`. Right now, the reward gives a small +1.0 terminal bonus if they survive. We will change this to be a massive penalty if they burn.
23
+ - **Terminal Reward**: If any `priority_populated_zone` burns, apply a `-5.0` penalty.
24
+ - **Step Reward**: If the population lost belongs to a priority zone, apply a much harsher delta penalty. This ensures the heuristic's score tanks when it ignores the briefing.
25
+
26
+ ### 3. Create the "Decoy" Ignition (`env/wildfire_env.py`)
27
+ In `_ignite_initial_fires`, when there are multiple ignitions (medium/hard), we will ensure one ignition is closer to a *non-priority* zone, and one is slightly further from a *priority* zone.
28
+ - Because the heuristic purely sorts by `Manhattan distance to fire` in `_protect_population`, it will take the bait and commit its limited crews to the non-priority zone.
29
+ - The RL agent, reading the prompt, will learn to route crews to the priority zone first.
30
+
31
+ ### 4. Remove the Heuristic's "Omniscience" (`agents/heuristic_agent.py`)
32
+ The heuristic currently has a few "cheat" behaviors where it perfectly calculates the safest deployment without needing recon. We will slightly dumb down `_initial_deployment` so it spreads crews out blindly, forcing it to actually rely on `RECON_FLIGHT` to find fires, wasting valuable early steps that the RL agent can optimize.
33
+
34
+ ## User Review Required
35
+ Do you approve of this "Commander's Intent / Decoy Fire" strategy? It preserves the heuristic's strength in easy scenarios but guarantees it fails in complex scenarios, making your RL training the obvious hero of the presentation.
inference.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wildfire Containment Simulator — Inference Script
3
+ ===================================================
4
+ Runs an LLM agent (via OpenAI-compatible client) against all three task tiers
5
+ and emits structured [START] / [STEP] / [END] logs for automated evaluation.
6
+
7
+ Required environment variables:
8
+ API_BASE_URL LLM endpoint (default: https://router.huggingface.co/v1)
9
+ MODEL_NAME Model identifier (default: Qwen/Qwen2.5-72B-Instruct)
10
+ HF_TOKEN HuggingFace / API key
11
+
12
+ Optional:
13
+ TASK_NAME Run a single task: easy | medium | hard (default: all three)
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import textwrap
19
+ from typing import List, Optional
20
+
21
+ from openai import OpenAI
22
+
23
+ from env import WildfireEnv, Action, ActionType
24
+ from env.models import Observation
25
+
26
+ # ── Environment variables ──────────────────────────────────────────────────────
27
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
28
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
29
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
30
+
31
+ TASKS = ["easy", "medium", "hard"]
32
+ SEED = 42
33
+ SUCCESS_THRESHOLD = 0.5
34
+ TEMPERATURE = 0.2
35
+ MAX_TOKENS = 120
36
+
37
+ # ── Structured log helpers ─────────────────────────────────────────────────────
38
+
39
+ def log_start(task: str, model: str) -> None:
40
+ print(f"[START] task={task} env=wildfire-containment-simulator model={model}", flush=True)
41
+
42
+
43
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
44
+ err = error if error else "null"
45
+ print(
46
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
47
+ f"done={str(done).lower()} error={err}",
48
+ flush=True,
49
+ )
50
+
51
+
52
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
53
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
54
+ print(
55
+ f"[END] success={str(success).lower()} steps={steps} "
56
+ f"score={score:.2f} rewards={rewards_str}",
57
+ flush=True,
58
+ )
59
+
60
+
61
+ # ── Observation → LLM prompt ───────────────────────────────────────────────────
62
+
63
+ SYSTEM_PROMPT = textwrap.dedent("""
64
+ You are an AI wildfire incident commander. Each step issue exactly ONE action as JSON.
65
+
66
+ Action types and required fields:
67
+ deploy_crew : {"action_type":"deploy_crew","crew_id":"crew_N","target_row":R,"target_col":C}
68
+ move_crew : {"action_type":"move_crew","crew_id":"crew_N","direction":"N|S|E|W|NE|NW|SE|SW"}
69
+ drop_retardant : {"action_type":"drop_retardant","tanker_id":"tanker_N","target_row":R,"target_col":C}
70
+ build_firebreak: {"action_type":"build_firebreak","crew_id":"crew_N","direction":"N|S|E|W|NE|NW|SE|SW"}
71
+ recon_flight : {"action_type":"recon_flight","target_row":R,"target_col":C}
72
+ idle : {"action_type":"idle","reason":"..."}
73
+
74
+ Strategy:
75
+ - DEPLOY undeployed crews first (deploy_crew) before any other crew action.
76
+ - MOVE crews toward fire to suppress it.
77
+ - BUILD firebreaks between fire and populated zones.
78
+ - DROP retardant on high-intensity clusters near populated cells.
79
+ - Output ONLY raw JSON. No explanation, no markdown, no code fences.
80
+ """).strip()
81
+
82
+
83
+ def build_user_prompt(obs: Observation, step: int, history: List[str]) -> str:
84
+ stats = obs.stats
85
+ weather = obs.weather
86
+ res = obs.resources
87
+
88
+ burning = [
89
+ f"({cell.row},{cell.col},{cell.intensity_bin.value})"
90
+ for row in obs.grid for cell in row
91
+ if cell.fire_state.value in ("burning", "ember")
92
+ ][:12]
93
+
94
+ populated_safe = [
95
+ f"({cell.row},{cell.col})"
96
+ for row in obs.grid for cell in row
97
+ if cell.is_populated and cell.fire_state.value not in ("burned_out", "burning")
98
+ ][:8]
99
+
100
+ crews = [f"{c.crew_id}@({c.row},{c.col}) deployed={c.is_deployed} active={c.is_active}"
101
+ for c in res.crews]
102
+ tankers = [f"{t.tanker_id} cooldown={t.cooldown_remaining} active={t.is_active}"
103
+ for t in res.tankers]
104
+
105
+ history_block = "\n".join(history[-4:]) if history else "none"
106
+
107
+ return textwrap.dedent(f"""
108
+ Step {step} / {stats.max_steps}
109
+ Fire: {stats.cells_burning} burning, {stats.cells_burned} burned out
110
+ Population lost: {stats.population_lost} | Containment: {stats.containment_pct:.1f}%
111
+ Weather: {weather.wind_speed_kmh:.0f} km/h @ {weather.wind_direction_deg:.0f}° | humidity {weather.humidity_pct:.0f}% | rain={weather.rain_active}
112
+
113
+ Burning cells (row,col,intensity): {burning}
114
+ Safe populated cells: {populated_safe}
115
+
116
+ Crews: {crews}
117
+ Tankers: {tankers}
118
+ Firebreak budget: {res.firebreak_budget} | Recon budget: {res.recon_budget}
119
+
120
+ Recent events: {obs.recent_events}
121
+ Last actions:
122
+ {history_block}
123
+
124
+ Output your next action as JSON:
125
+ """).strip()
126
+
127
+
128
+ # ── LLM → Action ──────────────────────────────────────────────────────────────
129
+
130
+ def _compact_action(action: Action) -> str:
131
+ """Short human-readable string for [STEP] log."""
132
+ at = action.action_type.value
133
+ if at == "deploy_crew":
134
+ return f"deploy_crew({action.crew_id},{action.target_row},{action.target_col})"
135
+ if at == "move_crew":
136
+ return f"move_crew({action.crew_id},{action.direction.value})"
137
+ if at == "drop_retardant":
138
+ return f"drop_retardant({action.tanker_id},{action.target_row},{action.target_col})"
139
+ if at == "build_firebreak":
140
+ return f"build_firebreak({action.crew_id},{action.direction.value})"
141
+ if at == "recon_flight":
142
+ return f"recon_flight({action.target_row},{action.target_col})"
143
+ return f"idle({action.reason or ''})"
144
+
145
+
146
+ def get_llm_action(
147
+ client: OpenAI,
148
+ obs: Observation,
149
+ step: int,
150
+ history: List[str],
151
+ ) -> tuple[Action, str, Optional[str]]:
152
+ """Call LLM, parse JSON action. Falls back to IDLE on any failure."""
153
+ user_prompt = build_user_prompt(obs, step, history)
154
+ error: Optional[str] = None
155
+
156
+ try:
157
+ completion = client.chat.completions.create(
158
+ model=MODEL_NAME,
159
+ messages=[
160
+ {"role": "system", "content": SYSTEM_PROMPT},
161
+ {"role": "user", "content": user_prompt},
162
+ ],
163
+ temperature=TEMPERATURE,
164
+ max_tokens=MAX_TOKENS,
165
+ stream=False,
166
+ )
167
+ raw = (completion.choices[0].message.content or "").strip()
168
+
169
+ # Strip markdown code fences if present
170
+ if "```" in raw:
171
+ parts = raw.split("```")
172
+ raw = parts[1] if len(parts) > 1 else raw
173
+ if raw.lower().startswith("json"):
174
+ raw = raw[4:].strip()
175
+
176
+ data = json.loads(raw)
177
+ action = Action(**data)
178
+ return action, _compact_action(action), None
179
+
180
+ except Exception as exc:
181
+ error = str(exc)[:80]
182
+ idle = Action(action_type=ActionType.IDLE, reason="llm_parse_error")
183
+ return idle, "idle(llm_parse_error)", error
184
+
185
+
186
+ # ── Single-task episode ────────────────────────────────────────────────────────
187
+
188
+ def run_task(client: OpenAI, task_id: str, seed: int) -> float:
189
+ """Run one full episode and return the final score in [0, 1]."""
190
+ env = WildfireEnv()
191
+ obs = env.reset(task_id=task_id, seed=seed)
192
+
193
+ rewards: List[float] = []
194
+ history: List[str] = []
195
+ steps_taken: int = 0
196
+ score: float = 0.0
197
+ success: bool = False
198
+
199
+ log_start(task=task_id, model=MODEL_NAME)
200
+
201
+ try:
202
+ step = 0
203
+ while not env.done:
204
+ step += 1
205
+ action, action_str, error = get_llm_action(client, obs, step, history)
206
+
207
+ result = env.step(action)
208
+ obs = result.observation
209
+ reward = result.reward
210
+ done = result.done
211
+ steps_taken = step
212
+
213
+ rewards.append(reward)
214
+ log_step(step=step, action=action_str, reward=reward, done=done, error=error)
215
+ history.append(f"Step {step}: {action_str} -> reward {reward:.2f}")
216
+
217
+ # Score = final composite reward (consistent with graders)
218
+ score = rewards[-1] if rewards else 0.0
219
+ score = min(max(score, 0.0), 1.0)
220
+ success = score >= SUCCESS_THRESHOLD
221
+
222
+ except Exception as exc:
223
+ error_msg = str(exc)[:120]
224
+ print(f"[DEBUG] Episode error: {error_msg}", flush=True)
225
+
226
+ finally:
227
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
228
+
229
+ return score
230
+
231
+
232
+ # ── Entry point ────────────────────────────────────────────────────────────────
233
+
234
+ def main() -> None:
235
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
236
+
237
+ task_override = os.getenv("TASK_NAME")
238
+ tasks = [task_override] if task_override else TASKS
239
+
240
+ results = {}
241
+ for task_id in tasks:
242
+ results[task_id] = run_task(client, task_id, seed=SEED)
243
+
244
+ # Final summary line (not part of scored format, helpful for debugging)
245
+ summary = " | ".join(f"{t}={s:.3f}" for t, s in results.items())
246
+ print(f"\n[SUMMARY] {summary}", flush=True)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
openenv.yaml ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: wildfire-containment-simulator
2
+ version: "1.0.0"
3
+ description: >
4
+ A grid-based wildfire propagation simulator where an AI agent dispatches
5
+ limited firefighting resources (ground crews, air tankers, firebreaks) to
6
+ contain an evolving fire before it reaches populated zones. Features
7
+ Rothermel-inspired fire spread, wind-driven dynamics, smoke-based partial
8
+ observability, and multi-objective reward balancing containment, population
9
+ safety, resource efficiency, speed, and area preservation.
10
+
11
+ author: Team Wildfire
12
+ license: MIT
13
+
14
+ environment:
15
+ class: env.wildfire_env.WildfireEnv
16
+ api:
17
+ reset:
18
+ description: "Initialize environment for a new episode"
19
+ parameters:
20
+ task_id:
21
+ type: string
22
+ enum: [easy, medium, hard]
23
+ default: easy
24
+ seed:
25
+ type: integer
26
+ default: 42
27
+ returns: Observation
28
+ step:
29
+ description: "Execute one simulation step with the given action"
30
+ parameters:
31
+ action: Action
32
+ returns: StepResult
33
+ state:
34
+ description: "Return full ground-truth state for grading (not for agent use)"
35
+ returns: dict
36
+
37
+ action_space:
38
+ type: object
39
+ description: "One action per step. Six action types with typed parameters."
40
+ properties:
41
+ action_type:
42
+ type: string
43
+ enum: [deploy_crew, move_crew, drop_retardant, build_firebreak, recon_flight, idle]
44
+ crew_id:
45
+ type: string
46
+ description: "Required for deploy_crew, move_crew, build_firebreak"
47
+ tanker_id:
48
+ type: string
49
+ description: "Required for drop_retardant"
50
+ target_row:
51
+ type: integer
52
+ description: "Required for deploy_crew, drop_retardant, recon_flight"
53
+ target_col:
54
+ type: integer
55
+ description: "Required for deploy_crew, drop_retardant, recon_flight"
56
+ direction:
57
+ type: string
58
+ enum: [N, S, E, W, NE, NW, SE, SW]
59
+ description: "Required for move_crew, build_firebreak"
60
+ reason:
61
+ type: string
62
+ description: "Optional reason string for idle action"
63
+
64
+ observation_space:
65
+ type: object
66
+ properties:
67
+ grid:
68
+ type: array
69
+ description: "2D array of CellObservation with fire_state, intensity_bin, smoke, population, crew presence"
70
+ weather:
71
+ type: object
72
+ properties:
73
+ wind_speed_kmh: { type: number }
74
+ wind_direction_deg: { type: number }
75
+ humidity_pct: { type: number }
76
+ rain_active: { type: boolean }
77
+ resources:
78
+ type: object
79
+ properties:
80
+ crews: { type: array, description: "List of CrewState (id, position, deployed, active)" }
81
+ tankers: { type: array, description: "List of TankerState (id, cooldown, active)" }
82
+ firebreak_budget: { type: integer }
83
+ recon_budget: { type: integer }
84
+ stats:
85
+ type: object
86
+ properties:
87
+ cells_burned: { type: integer }
88
+ cells_burning: { type: integer }
89
+ population_lost: { type: integer }
90
+ containment_pct: { type: number }
91
+ current_step: { type: integer }
92
+ max_steps: { type: integer }
93
+ recent_events:
94
+ type: array
95
+ items: { type: string }
96
+ maxItems: 5
97
+
98
+ reward:
99
+ type: number
100
+ minimum: -8.0
101
+ maximum: 8.0
102
+ description: >
103
+ Decomposed reward: dense per-step signal (delta_containment * 0.4 +
104
+ delta_pop_safety * 0.4) plus sparse terminal reward on episode end
105
+ (+5 all-pop-safe, +0-2 efficiency bonus, +1 briefing adherence,
106
+ -3*loss_pct if pop lost, -2 crew casualty). Designed for GRPO training.
107
+
108
+ tasks:
109
+ - id: easy
110
+ name: "Flatland Grass Fire"
111
+ description: "15x15 flat grid, single ignition, constant wind, no noise. Learn basic containment."
112
+ difficulty: easy
113
+ episode_length: 80
114
+
115
+ - id: medium
116
+ name: "Canyon Terrain with Wind Shifts"
117
+ description: "25x25 mixed terrain, two ignition points, variable wind, smoke occlusion, sensor noise."
118
+ difficulty: medium
119
+ episode_length: 150
120
+
121
+ - id: hard
122
+ name: "Wildland-Urban Interface Crisis"
123
+ description: "40x40 complex terrain, three staggered ignitions, fog-of-war, crew loss, node failures."
124
+ difficulty: hard
125
+ episode_length: 300
126
+
127
+ baseline:
128
+ script: scripts/evaluate.py
129
+ agents:
130
+ - name: random
131
+ class: agents.random_agent.RandomAgent
132
+ - name: heuristic
133
+ class: agents.heuristic_agent.HeuristicAgent
prompts.md ADDED
@@ -0,0 +1,644 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Wildfire Containment Simulator — Agent Prompt Sequence
2
+
3
+ **Usage:** Feed these prompts **one at a time** to your coding agent (Claude Code or Antigravity). After each prompt finishes, run its acceptance test yourself before moving to the next. Each prompt assumes the prior ones completed successfully.
4
+
5
+ **Global context to paste once at the start of every new agent session** (if the agent loses context between prompts):
6
+
7
+ > You are working on the Wildfire Containment Simulator — an OpenEnv-compatible RL environment for the Meta × PyTorch × HuggingFace OpenEnv Hackathon finale (April 25–26, 2026). The repo is at `https://github.com/Abrodolph/Wildfire-Containment-Simulator`. Core packages: `env/` (simulation), `agents/` (baselines), `graders/` (one per tier), `scripts/` (evaluation). The env exposes `reset()`, `step()`, `state()` with Pydantic-validated `Action`, `Observation`, `StepResult` models defined in `env/models.py`. Three tiers exist: easy (15×15), medium (25×25), hard (40×40). You can run `pytest`, `python scripts/evaluate.py`, and any other command. Iterate on failures until tests pass. Never skip the acceptance test at the end of each prompt.
8
+
9
+ ---
10
+
11
+ ## Prompt 1 — Repo Cleanup & Test Scaffolding ✅ DONE
12
+
13
+ ```
14
+ Clean up repo cruft and set up a test scaffold before we make any functional changes.
15
+
16
+ Tasks:
17
+ 1. Delete the nested `Wildfire-Containment-Simulator/` directory at repo root (leftover HF Space metadata).
18
+ 2. Delete the literal `{env,graders,agents,scripts}` directory at repo root (shell-brace artifact).
19
+ 3. Delete all committed `__pycache__/` directories and `*.egg-info/` folders.
20
+ 4. Delete the `venv/` directory if it's committed.
21
+ 5. Update `.gitignore` to include: `__pycache__/`, `*.egg-info/`, `venv/`, `.venv/`, `*.pyc`, `.pytest_cache/`, `.ruff_cache/`, `checkpoints/`, `results/`.
22
+ 6. Consolidate server entry points: keep `server/app.py` as the single source of truth. Update the root `app.py` to be a one-line shim that imports and runs `server.app:main`. Update `Dockerfile` CMD to match.
23
+ 7. Create `tests/` directory with `tests/__init__.py` and `tests/conftest.py`. In conftest, add a fixture `fresh_env` that yields a `WildfireEnv()` instance.
24
+ 8. Create `tests/test_smoke.py` with three tests:
25
+ - `test_env_resets_on_all_tiers` — calls `env.reset(task_id=t, seed=42)` for t in ["easy", "medium", "hard"] and asserts obs is not None.
26
+ - `test_idle_action_never_crashes` — resets env, calls `env.step(Action(action_type=ActionType.IDLE))` 10 times, asserts no exception.
27
+ - `test_determinism` — runs a fixed 20-step idle rollout twice with seed=42 on easy tier, asserts the final `stats.cells_burned` matches.
28
+ 9. Add `pytest` and `pytest-cov` to `requirements.txt` if missing.
29
+
30
+ Acceptance test:
31
+ - `pytest tests/ -v` passes with 3 tests green.
32
+ - `python app.py` still starts the server on port 7860.
33
+ - `git status` shows no `__pycache__` or `{env,...}` cruft.
34
+ - Output the diff summary of deleted files and new files.
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Prompt 2 — Reward Restructuring (Decomposed Terminal + Dense Step) ✅ DONE
40
+
41
+ ```
42
+ Replace the current normalized [0,1] composite reward with a decomposed terminal + dense step structure. This is critical for GRPO training — the current reward is too flat to produce meaningful advantages.
43
+
44
+ Read `env/reward.py` first. Understand the current RewardCalculator class. Also read `env/wildfire_env.py` to see how reward is called per step.
45
+
46
+ Tasks:
47
+ 1. In `env/reward.py`, add a new method `compute_step_reward(prev_state, current_state, action_was_valid, action_was_redundant) -> float` that returns:
48
+ - (delta_containment_pct * 0.4) + (delta_population_safety * 0.4) + (-0.1 if action_was_redundant else 0.0)
49
+ - where delta_containment_pct is (current_containment - prev_containment) in [0, 1] units
50
+ - delta_population_safety is (1 - current_pop_lost/total_pop) - (1 - prev_pop_lost/total_pop)
51
+ - redundant = same action_type + same target coords as the immediately prior action
52
+
53
+ 2. Add a method `compute_terminal_reward(final_state, episode_steps, max_steps) -> float`:
54
+ - start at 0
55
+ - if all_populations_safe (pop_lost == 0): add +5.0
56
+ - else: add -3.0 * (pop_lost / total_pop)
57
+ - if any crew_casualty occurred in the episode: add -2.0 (stacks with above)
58
+ - efficiency_bonus = (max_steps - episode_steps) / max_steps * 2.0 — ONLY applied if pop_lost == 0
59
+ - invalid_action_penalty_total = min(0.2, 0.01 * invalid_action_count) — subtract this
60
+
61
+ 3. In `env/wildfire_env.py`:
62
+ - Track `self._prev_action` and `self._invalid_action_count` and `self._crew_casualty_occurred` across the episode (reset them in `reset()`).
63
+ - Replace the current reward computation in `step()` with: step_reward from above, plus terminal_reward ONLY when `done == True`.
64
+ - The StepResult.reward should be `step_reward + (terminal if done else 0.0)`.
65
+
66
+ 4. Keep the OLD composite reward accessible as `info["legacy_reward"]` in StepResult for backward compatibility with existing graders. (Graders get updated in Prompt 10.)
67
+
68
+ 5. Add `tests/test_reward.py` with:
69
+ - `test_successful_episode_scores_high` — run heuristic agent on easy tier seed=42, assert total reward > +3.0
70
+ - `test_all_pop_lost_scores_negative` — construct a scenario (or mock state) where all population is lost, assert terminal < -2.0
71
+ - `test_crew_casualty_stacks` — scenario with pop loss AND crew casualty, assert terminal includes both penalties
72
+ - `test_redundant_action_penalty` — call the same DEPLOY_CREW twice, assert second call's step_reward includes -0.1
73
+
74
+ Acceptance test:
75
+ - `pytest tests/test_reward.py -v` passes all 4 tests.
76
+ - Run `python scripts/evaluate.py 20` on easy tier with the heuristic agent. Report mean + std of total rewards. Successful episodes should cluster in the +5 to +8 range, failed episodes in the -2 to -5 range. If the ranges overlap by more than 20% of episodes, the reward isn't separated enough — report that and DO NOT proceed.
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Prompt 3 — Observation-to-Text Serializer ✅ DONE
82
+
83
+ ```
84
+ Write a serializer that converts a Pydantic Observation into a structured text prompt that an LLM can reason over. This is required because OpenEnv is an LLM-training framework — the agent is a language model, not a numeric policy.
85
+
86
+ Read `env/models.py` to understand the Observation schema. Read the README section "Observation Space" for the intended structure.
87
+
88
+ Tasks:
89
+ 1. Create `env/serialization.py` with a function `serialize_observation(obs: Observation, step_num: int, max_steps: int) -> str`.
90
+
91
+ 2. Output format (match this structure exactly — the LLM will be trained on it):
92
+
93
+ ```
94
+ === WILDFIRE INCIDENT COMMAND — STEP {step}/{max_steps} ===
95
+
96
+ SITUATION:
97
+ - Fire active on {N} cells. Containment: {pct}%. Population at risk: {N} zones.
98
+ - Wind: {speed} km/h {dir} (±{noise} km/h noise). Humidity: {h}%. Rain: {active|inactive}.
99
+ - Last event: {most_recent_event or "None"}
100
+
101
+ GRID SUMMARY (smoke-obscured cells marked [?]):
102
+ {bounding_box_descriptions_of_fire_regions}
103
+ {populated_zone_descriptions}
104
+ {firebreak_descriptions_if_any}
105
+
106
+ RESOURCES:
107
+ - crew_0: {deployed at (r,c) | undeployed available}. Status: {active|casualty}.
108
+ - crew_1: ...
109
+ - tanker_0: {ready | cooldown N steps remaining}
110
+ - Firebreaks remaining: {N}. Recon flights remaining: {N}.
111
+
112
+ RECENT EVENTS:
113
+ - Step {N}: {event description}
114
+ - ... (last 3 events max)
115
+
116
+ Available actions: deploy_crew, move_crew, drop_retardant, build_firebreak, recon_flight, idle
117
+ Produce your action as JSON: {"action_type": "...", ...}
118
+ ```
119
+
120
+ 3. Helper functions inside the module (keep private with leading underscore):
121
+ - `_summarize_grid_regions(obs.grid) -> List[str]` — detect rectangular bounding boxes of (a) active fire cells clustered together, (b) populated cells, (c) built firebreaks. Output as "Row X-Y, Col A-B: description". Cap at 5 regions per category, prioritize by size.
122
+ - `_format_resources(obs.resources) -> str`
123
+ - `_format_events(obs.recent_events) -> str`
124
+
125
+ 4. Add `tests/test_serialization.py`:
126
+ - `test_serialize_produces_all_sections` — reset env, serialize, assert the output contains "SITUATION:", "GRID SUMMARY:", "RESOURCES:", "RECENT EVENTS:", "Available actions:".
127
+ - `test_serialize_handles_fog_of_war` — hard tier reset, assert "[?]" appears somewhere in output (smoke or fog-obscured cells).
128
+ - `test_serialize_length_under_2048_tokens` — run on all 3 tiers, assert `len(tokenizer.encode(output))` < 1800 using tiktoken's cl100k_base (if tiktoken not installed, use `len(text.split()) < 1500` as a proxy).
129
+
130
+ Acceptance test:
131
+ - `pytest tests/test_serialization.py -v` passes all 3 tests.
132
+ - Run a manual sanity check: `python -c "from env import WildfireEnv; from env.serialization import serialize_observation; env = WildfireEnv(); obs = env.reset(task_id='medium', seed=42); print(serialize_observation(obs, 0, 150))"` — paste the output and confirm it reads like a realistic incident briefing.
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Prompt 4 — LLM Action Parser with 3-Layer Fallback ✅ DONE
138
+
139
+ ```
140
+ Build a robust parser that converts LLM text output into a validated Action object. LLMs produce malformed JSON, hallucinated fields, and out-of-range coords — we need to never crash.
141
+
142
+ Tasks:
143
+ 1. Create `env/action_parser.py` with a function `parse_action(llm_output: str, obs: Observation) -> Tuple[Action, str]` returning the action AND a status string ("json_success", "regex_fallback", "safe_idle").
144
+
145
+ 2. Three layers, in order:
146
+
147
+ LAYER 1 — Direct JSON parse:
148
+ - Extract JSON from output using a helper `_extract_json_block(text)` that finds content between first `{` and matching `}` (handles ```json fences, handles leading/trailing text).
149
+ - Try `json.loads` then `Action(**data)` — Pydantic validates fields.
150
+ - On success return (action, "json_success").
151
+
152
+ LAYER 2 — Regex extraction:
153
+ - Search for action_type via regex: `action_type["\s:]+["']?(deploy_crew|move_crew|drop_retardant|build_firebreak|recon_flight|idle)`
154
+ - Based on detected action_type, extract required fields with regex patterns (e.g., `crew_id["\s:]+["']?(crew_\d+)`, `target_row["\s:]+(\d+)`, `direction["\s:]+["']?(N|S|E|W|NE|NW|SE|SW)`).
155
+ - Construct Action; if Pydantic validates, return (action, "regex_fallback").
156
+
157
+ LAYER 3 — Safe fallback:
158
+ - Return `(Action(action_type=ActionType.IDLE, reason="parse_failure"), "safe_idle")`.
159
+
160
+ 3. Add coordinate sanity check: after any layer succeeds, if target_row or target_col is outside the current grid dimensions (infer from obs.grid shape), downgrade to safe_idle. Never trust LLM-provided coords blindly.
161
+
162
+ 4. Add `tests/test_action_parser.py` with 8 test cases covering:
163
+ - Clean JSON output
164
+ - JSON wrapped in ```json fences
165
+ - JSON with extra surrounding commentary
166
+ - Malformed JSON (missing quotes) that regex can save
167
+ - Completely garbage output → safe_idle
168
+ - Out-of-bounds coords → safe_idle
169
+ - Hallucinated action_type (e.g., "nuke_fire") → safe_idle
170
+ - Empty string → safe_idle
171
+
172
+ Acceptance test:
173
+ - `pytest tests/test_action_parser.py -v` passes all 8 tests.
174
+ - Zero crashes across the test suite.
175
+ - Status string is correctly reported for each case.
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Prompt 5 — Replay / GIF Renderer ✅ DONE
181
+
182
+ ```
183
+ Build a replay script that renders any episode as an animated GIF. This is critical for the storytelling score — every demo asset depends on it.
184
+
185
+ Tasks:
186
+ 1. Add `imageio` and `matplotlib` to `requirements.txt` if not present.
187
+
188
+ 2. Create `scripts/replay.py` with CLI: `python scripts/replay.py --tier {easy|medium|hard} --seed {int} --agent {random|heuristic} --output {path.gif}`.
189
+
190
+ 3. The script should:
191
+ - Instantiate the env, run the agent, capture the full ground-truth `env.state()` at every step.
192
+ - For each step, render a matplotlib figure (8x8 inches, 100 dpi) with:
193
+ * Main panel (80% area): grid colored by cell state. Burning = red (intensity → color saturation), burned = dark gray, populated = blue square outline, firebreak = brown, crew = green circle with crew_id label, tanker drop zone = translucent cyan overlay.
194
+ * Bottom strip: step number, cells burning, containment %, pop lost, wind arrow + speed.
195
+ - Save all frames, stitch to GIF at 5 fps, write to output path.
196
+ - Also save final-frame PNG to same path with `.png` extension.
197
+
198
+ 4. Keep the rendering code in `env/rendering.py` (importable helpers), not inline in the script. Functions:
199
+ - `render_frame(state: EnvState, step: int, stats: dict) -> np.ndarray` — returns RGB array.
200
+ - `render_episode_gif(frames: List[np.ndarray], output_path: str, fps: int = 5)`.
201
+
202
+ 5. Add `tests/test_rendering.py`:
203
+ - `test_render_frame_produces_rgb` — reset env on easy, render frame, assert shape is (H, W, 3) and dtype is uint8.
204
+ - `test_gif_creation` — run 20 steps of random agent, call `render_episode_gif`, assert output file exists and is > 10KB.
205
+
206
+ Acceptance test:
207
+ - `pytest tests/test_rendering.py -v` passes both tests.
208
+ - Run: `python scripts/replay.py --tier medium --seed 42 --agent heuristic --output demos/heuristic_medium_42.gif`
209
+ - Open the GIF. Confirm it shows fire spreading, crews moving, and the stats strip updating. Paste the final-frame stats as confirmation.
210
+ ```
211
+
212
+ ---
213
+
214
+ ## Prompt 6 — Curriculum Controller ✅ DONE
215
+
216
+ ```
217
+ Add a curriculum controller that auto-promotes through tiers based on rolling performance. This produces the characteristic "dip-and-recover" pattern on training curves that makes for compelling demo visuals.
218
+
219
+ Tasks:
220
+ 1. Create `env/curriculum.py` with class `CurriculumController`:
221
+ - `__init__(self, start_tier: str = "easy", thresholds: Optional[dict] = None)` — default thresholds: easy→medium at 4.0 avg over 10 eps, medium→hard at 3.5 avg over 10 eps (these are total episode rewards under the new reward scheme, NOT [0,1]).
222
+ - `after_episode(self, total_reward: float) -> Optional[str]` — returns the new tier name if a promotion just fired, else None.
223
+ - `get_tier(self) -> str` — current tier.
224
+ - `get_history(self) -> List[Tuple[int, str, float]]` — list of (episode_idx, tier, reward) for plotting.
225
+ - `promotion_log: List[Tuple[int, str]]` — list of (episode_idx, new_tier) for marking vertical lines on plots.
226
+
227
+ 2. Demote behavior: if recent 10-ep avg drops below (threshold * 0.5) after a promotion, demote back. Log this too.
228
+
229
+ 3. Add `tests/test_curriculum.py`:
230
+ - `test_promotion_fires_at_threshold` — feed 10 rewards of 5.0, assert promotion to medium.
231
+ - `test_no_premature_promotion` — feed 5 rewards of 5.0, assert still on easy.
232
+ - `test_demotion_on_collapse` — promote to medium, then feed 10 rewards of 0.5, assert demoted to easy.
233
+ - `test_history_tracking` — run 20 episodes, assert history length is 20 and promotion_log is correctly populated.
234
+
235
+ Acceptance test:
236
+ - `pytest tests/test_curriculum.py -v` passes all 4 tests.
237
+ - The controller is not yet wired into the env itself (that happens in the training notebook, Prompt 8). This prompt just builds the component.
238
+ ```
239
+
240
+ ---
241
+
242
+ ## Prompt 7 — Eval Comparison Script ✅ DONE
243
+
244
+ ```
245
+ Build the eval comparison script that generates the headline comparison table for the pitch. This runs multiple agents on fixed seeds and outputs a clean comparison.
246
+
247
+ Tasks:
248
+ 1. Create `scripts/eval_compare.py` with CLI: `python scripts/eval_compare.py --seeds 42 43 44 45 46 --tiers medium hard --agents random heuristic base_llm trained_llm --output eval_results.json`.
249
+
250
+ 2. Agent registry — a dict mapping agent name to a factory function:
251
+ - `random` → existing RandomAgent
252
+ - `heuristic` → existing HeuristicAgent
253
+ - `base_llm` → LLM agent using `env/serialization.py` + `env/action_parser.py`, calling a base model (stub this for now — read model path from env var `BASE_MODEL_PATH`, default to None which skips this agent with a warning).
254
+ - `trained_llm` → same pattern, env var `TRAINED_MODEL_PATH`.
255
+
256
+ 3. For each (agent, tier, seed) combination:
257
+ - Run the episode.
258
+ - Record: final containment_pct, pop_saved_pct (= 1 - pop_lost/total_pop), total_reward, episode_steps.
259
+
260
+ 4. Output:
261
+ - A JSON file at the specified path with full results.
262
+ - A printed table to stdout formatted like:
263
+ ```
264
+ === EVAL RESULTS — Medium Tier (5 seeds) ===
265
+ Containment Pop Saved Reward Steps
266
+ Random Agent 41% 60% -1.2 150
267
+ Heuristic Agent 49% 71% +1.8 143
268
+ Base LLM (Qwen) 38% 55% -0.9 150 [skipped — no model]
269
+ Trained LLM (ours) 67% 89% +4.1 121 [skipped — no model]
270
+ ```
271
+ - Use mean across seeds for each column. Mark skipped agents clearly.
272
+
273
+ 5. Add `--quick` flag that runs only easy tier with 2 seeds for smoke testing.
274
+
275
+ 6. Add `tests/test_eval_compare.py`:
276
+ - `test_quick_mode_runs` — invoke with --quick, assert eval_results.json exists, assert at least random and heuristic have non-null entries.
277
+
278
+ Acceptance test:
279
+ - `python scripts/eval_compare.py --quick` completes in under 2 minutes.
280
+ - `pytest tests/test_eval_compare.py -v` passes.
281
+ - Full run `python scripts/eval_compare.py --seeds 42 43 44 45 46 --tiers medium hard --agents random heuristic` produces the table. The LLM columns will show "[skipped — no model]" which is expected at this stage.
282
+ ```
283
+
284
+ ---
285
+
286
+ ## Prompt 8 — GRPO Training Notebook (Colab) ✅ DONE
287
+
288
+ ```
289
+ Build the GRPO training notebook. This is a hackathon minimum requirement — without it we're technically DQ'd.
290
+
291
+ Tasks:
292
+ 1. Create `training/grpo_colab.ipynb` (a Jupyter notebook — JSON format). Use `nbformat` to construct it programmatically to avoid JSON escaping errors.
293
+
294
+ 2. Notebook sections (each a separate cell with a markdown header cell above it):
295
+
296
+ **Section 1: Setup**
297
+ - pip install: `unsloth trl openenv-core pydantic numpy imageio matplotlib`
298
+ - Clone the repo or install from path.
299
+ - Import FastLanguageModel from unsloth, load `unsloth/Qwen2.5-1.5B-Instruct` in 4-bit with max_seq_length=2048.
300
+ - Apply LoRA: r=16, alpha=32, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"].
301
+
302
+ **Section 2: Environment & Rollout**
303
+ - Import WildfireEnv, serialize_observation, parse_action.
304
+ - Define `collect_rollout(env, model, tokenizer, tier, seed) -> List[Dict]` that:
305
+ * resets env
306
+ * for each step: serializes obs → generates completion → parses action → steps env → records (prompt, completion, reward, step_status).
307
+ * returns trajectory list.
308
+ - Define `system_prompt` — a short, firm instruction to always output action as JSON only.
309
+
310
+ **Section 3: GRPO Training Loop**
311
+ - Use TRL's GRPOTrainer. Config: num_generations=8 per prompt, learning_rate=5e-6, max_steps=50, save_steps=10, per_device_train_batch_size=1, gradient_accumulation_steps=4.
312
+ - Reward function: for each generation, run a mini-rollout (fresh env, same seed, sample action from the completion) and return the 1-step reward + discounted terminal if done. Cache seeds so generations for the same prompt see the same env state.
313
+ - Wire in the CurriculumController from Prompt 6: after each full episode, call `controller.after_episode(total_reward)` and switch tier for the next episode.
314
+
315
+ **Section 4: Checkpointing & Recovery**
316
+ - Save LoRA adapter to `./checkpoints/step_{N}` every 10 steps.
317
+ - Save a JSON of training stats (step, mean_reward, tier, parse_failure_rate) to `./training_stats.json` every step.
318
+ - Add a "resume from checkpoint" cell at the top of Section 3 that loads the latest checkpoint if present.
319
+
320
+ **Section 5: Plot Reward Curve**
321
+ - Load training_stats.json, plot mean_reward vs step with matplotlib.
322
+ - Save as `reward_curve.png`.
323
+ - Mark tier promotions as vertical lines using controller.promotion_log.
324
+
325
+ 3. Add `training/README.md` with:
326
+ - How to open in Colab (a badge link).
327
+ - Which cells to run in order.
328
+ - Expected runtime on T4: ~45 min for 50 steps.
329
+ - How to download the trained adapter.
330
+
331
+ 4. Add `training/test_notebook_imports.py` — a plain Python file (not pytest) that imports every module the notebook uses and instantiates the env + tokenizer (skipping the model load). This catches broken imports before you open Colab.
332
+
333
+ Acceptance test:
334
+ - `python training/test_notebook_imports.py` runs without error.
335
+ - Open the notebook locally with `jupyter nbconvert --to notebook --execute training/grpo_colab.ipynb --ExecutePreprocessor.timeout=600` — skip this if no GPU available locally (which is expected). Instead, validate notebook JSON with `jupyter nbconvert --to script training/grpo_colab.ipynb` and confirm the generated .py file has no syntax errors.
336
+ - Confirm the notebook has exactly the 5 sections described, each with a markdown header.
337
+ ```
338
+
339
+ ---
340
+
341
+ ## Prompt 9 — Training Curves Dashboard ✅ DONE
342
+
343
+ ```
344
+ Build a 4-panel training dashboard. Panel D (curriculum transitions) is the storytelling hook.
345
+
346
+ Tasks:
347
+ 1. Create `scripts/plot_dashboard.py` with CLI: `python scripts/plot_dashboard.py --stats training/training_stats.json --output training/training_dashboard.png`.
348
+
349
+ 2. Layout: 2x2 matplotlib grid, figsize=(12, 8), dpi=100.
350
+
351
+ - Panel A (top-left): Mean episode reward vs training step. Line plot with moving average (window=5) as a thicker overlay.
352
+ - Panel B (top-right): Population survival rate (% of eps with zero pop loss) vs training step. Computed as rolling 10-ep fraction.
353
+ - Panel C (bottom-left): Mean containment % at episode end, vs training step.
354
+ - Panel D (bottom-right): Curriculum tier timeline. X-axis = episode index, Y-axis = tier (easy=0, medium=1, hard=2) drawn as a step function. Vertical dashed lines at promotion events with tier labels.
355
+
356
+ 3. Handle missing data gracefully — if `training_stats.json` is absent, generate a synthetic stats file at `training/synthetic_stats_demo.json` with 50 fake training steps showing a plausible upward curve + one tier promotion, then plot from that (clearly label it "SYNTHETIC DEMO" in the figure title). This lets us test the plot script without a real training run.
357
+
358
+ 4. Add `tests/test_dashboard.py`:
359
+ - `test_synthetic_dashboard` — run `plot_dashboard.py` with no stats file, assert the synthetic PNG is created and > 50KB.
360
+
361
+ Acceptance test:
362
+ - `pytest tests/test_dashboard.py -v` passes.
363
+ - Open the generated PNG. Confirm all 4 panels render, Panel D has visible vertical promotion lines, and the synthetic warning label is visible.
364
+ ```
365
+
366
+ ---
367
+
368
+ ## Prompt 10 — Grader Alignment & Legacy Reward Cleanup ✅ DONE
369
+
370
+ ```
371
+ The existing graders in graders/ were written against the old [0,1] composite reward. Align them with the new decomposed reward so eval numbers are consistent between training and grading.
372
+
373
+ Tasks:
374
+ 1. Read `graders/grader_easy.py`, `graders/grader_medium.py`, `graders/grader_hard.py`. Identify where each one reads `result.reward` or computes a final score.
375
+
376
+ 2. Update each grader:
377
+ - Sum step rewards + terminal reward across the episode using the new decomposed structure.
378
+ - Return the total episode reward as the grader's score.
379
+ - Add a `details` dict to the grader return value: `{"total_reward": float, "containment_pct": float, "pop_saved_pct": float, "steps": int, "crew_casualty": bool}`.
380
+
381
+ 3. Remove any references to `legacy_reward` that are no longer needed. Keep `legacy_reward` in StepResult.info for one more cycle (delete later), but graders should NOT use it.
382
+
383
+ 4. Update `scripts/evaluate.py` to print the new detailed metrics alongside the reward.
384
+
385
+ 5. Update `README.md` "Baseline Scores" table with the new reward scale. Re-run `python scripts/evaluate.py 5` and paste the new numbers. Expected pattern: heuristic should now clearly beat random on ALL tiers under the new reward. If it doesn't, flag it — this is a diagnostic signal that the reward or the heuristic needs work.
386
+
387
+ 6. Add `tests/test_graders.py`:
388
+ - `test_each_grader_returns_float_and_details` — run each of the 3 graders with the heuristic agent, assert return structure.
389
+ - `test_grader_scores_are_in_expected_range` — assert easy total_reward > 3.0 for heuristic, medium > 1.0, hard > 0.0 (generous lower bounds).
390
+
391
+ Acceptance test:
392
+ - `pytest tests/test_graders.py -v` passes.
393
+ - `python scripts/evaluate.py 5` produces a table where heuristic beats random on every tier. Paste the output. If heuristic loses on any tier, investigate before proceeding — this likely indicates a variance or reward issue.
394
+ - README "Baseline Scores" section is updated with new numbers.
395
+ ```
396
+
397
+ ---
398
+
399
+ ## Prompt 11 — Demo Seed Finder + Demo Runner ✅ DONE
400
+
401
+ ```
402
+ Find a fixed seed that produces a clean, visually obvious contrast between heuristic and (eventually) trained-LLM behavior on medium tier. This becomes the 3-minute pitch demo.
403
+
404
+ Tasks:
405
+ 1. Create `scripts/find_demo_seed.py`:
406
+ - Iterate seeds 0..500 on medium tier.
407
+ - For each seed, run the HEURISTIC agent, record: total_reward, pop_saved_pct, wind_shift_step (if any), and whether at least one populated cell was lost.
408
+ - Filter for seeds where: (a) a wind shift fires between step 60–90, (b) heuristic loses at least one populated cell, (c) heuristic total_reward is between 0.0 and +2.0 (i.e., a flawed but not catastrophic baseline �� gives room for improvement).
409
+ - Output top 5 candidate seeds to `demos/candidate_seeds.json` with a short description of each.
410
+
411
+ 2. Create `scripts/run_demo.py` with CLI: `python scripts/run_demo.py --seed {int}`:
412
+ - Runs heuristic on medium tier with that seed, generates GIF to `demos/heuristic_demo.gif` using the Prompt 5 renderer.
413
+ - Prints a play-by-play narrative: "Step 45: fire approaches populated cell (12, 8). Step 60: wind shifts. Step 75: crew committed to wrong flank. Step 89: populated cell burns."
414
+ - If `--agent trained_llm` is passed and a TRAINED_MODEL_PATH env var exists, also runs the trained model and saves `demos/trained_demo.gif` + a second narrative.
415
+ - Print a clean side-by-side comparison at the end: both agents' final stats.
416
+
417
+ 3. Pick ONE seed from the top 5 as `DEMO_SEED`. Hardcode it as a constant in `scripts/run_demo.py`: `DEMO_SEED = <chosen_seed>`. The `--seed` flag defaults to this. Document the narrative for this specific seed in a comment block at the top of the file.
418
+
419
+ 4. Add `demos/README.md` explaining how to regenerate demo assets.
420
+
421
+ Acceptance test:
422
+ - `python scripts/find_demo_seed.py` completes in under 10 minutes, outputs candidate_seeds.json.
423
+ - `python scripts/run_demo.py` (with default seed) produces heuristic_demo.gif and prints a coherent narrative. Confirm the narrative matches what the GIF actually shows.
424
+ - Paste the chosen DEMO_SEED value.
425
+ ```
426
+
427
+ ---
428
+
429
+ ## Prompt 12 — Theme 2 Framing: Operational Briefing System
430
+
431
+ ```
432
+ Add a structured operational briefing that the env produces on reset(). The agent receives this as part of its first observation. This is what pivots the environment into Theme 2 (Long-Horizon Planning & Instruction Following) framing — judges need to see instruction-following as a first-class feature.
433
+
434
+ Tasks:
435
+ 1. Create `env/briefing.py` with:
436
+ - Pydantic model `OperationalBriefing` with fields: `incident_id: str`, `ignition_cause: str`, `priority_populated_zones: List[Tuple[int, int]]` (cells the agent must prioritize protecting), `priority_infrastructure: List[Tuple[int, int]]` (e.g., road cells, optional), `forecast_events: List[str]` (e.g., "Wind shift southwest expected by step 60"), `declared_time: str` (narrative time like "04:00").
437
+ - Function `generate_briefing(tier_config, rng) -> OperationalBriefing` that synthesizes a plausible briefing from the tier config. For populated priorities, pick the top 2 largest pop clusters. For forecast events, derive from the weather schedule if the engine exposes scheduled wind shifts; otherwise generate 1-2 plausible generic forecasts.
438
+ - Function `briefing_to_text(briefing: OperationalBriefing) -> str` — formats as a natural-language briefing block:
439
+ ```
440
+ === OPERATIONAL BRIEFING ===
441
+ Incident {incident_id} declared at {declared_time}.
442
+ Cause: {ignition_cause}.
443
+
444
+ PRIORITY 1: Protect populated zones at {coords list with cell names}.
445
+ PRIORITY 2: Maintain {infrastructure} open where possible.
446
+
447
+ FORECAST:
448
+ - {forecast_1}
449
+ - {forecast_2}
450
+
451
+ Commander's intent: Contain fire with zero civilian casualties. Preserve crew safety.
452
+ ```
453
+
454
+ 2. Update `env/models.py`:
455
+ - Add `briefing: Optional[OperationalBriefing]` field to `Observation`. Populated only on the first observation after reset; subsequent observations can reuse or omit.
456
+
457
+ 3. Update `env/wildfire_env.py`:
458
+ - On reset, generate a briefing and attach to the first observation.
459
+ - Store `self.active_briefing` for the episode so reward logic can reference it.
460
+
461
+ 4. Update `env/reward.py` compute_terminal_reward:
462
+ - Add a `briefing_adherence_bonus`: +1.0 if all priority_populated_zones survived, 0 otherwise.
463
+ - Stack this on top of the existing terminal reward.
464
+
465
+ 5. Update `env/serialization.py` serialize_observation:
466
+ - If `obs.briefing` is present, prepend `briefing_to_text(obs.briefing)` above the SITUATION block.
467
+ - Subsequent steps: include a shortened reminder like "Priority zones: (r1,c1), (r2,c2) — still standing" or "— 1 LOST".
468
+
469
+ 6. Add `tests/test_briefing.py`:
470
+ - `test_briefing_generated_on_reset` — reset on medium, assert obs.briefing is not None and has ≥1 priority zone.
471
+ - `test_briefing_adherence_bonus` — run heuristic successfully saving priority zones, assert terminal includes the +1.0.
472
+ - `test_briefing_in_serialized_prompt` — serialize first obs, assert "OPERATIONAL BRIEFING" substring is present.
473
+
474
+ Acceptance test:
475
+ - `pytest tests/test_briefing.py -v` passes all 3 tests.
476
+ - Run the serializer manually on a fresh medium reset and confirm the briefing reads coherently. Paste the output.
477
+ - Re-run `python scripts/evaluate.py 5`. Reward numbers will shift slightly due to the new bonus — that's expected. Paste the new numbers.
478
+ ```
479
+
480
+ ---
481
+
482
+ ## Prompt 13 — README Rewrite for Finale Framing
483
+
484
+ ```
485
+ Rewrite the README to frame this as a finale submission aligned with Theme 2 (Long-Horizon Planning & Instruction Following). Keep all the technical depth but re-lead with the finale narrative.
486
+
487
+ Tasks:
488
+ 1. Replace the current README.md top section (above "Real-World Motivation") with:
489
+
490
+ ```markdown
491
+ # Wildfire Containment Simulator
492
+
493
+ **OpenEnv Finale Submission — Theme 2: Long-Horizon Planning & Instruction Following**
494
+
495
+ ![Training Demo](demos/heuristic_demo.gif)
496
+
497
+ A partially-observable disaster simulation where an LLM acts as Incident Commander, interpreting operational briefings, tracking state across 300-step episodes, and recovering from cascading failures. Built on OpenEnv with Pydantic-typed actions, Rothermel-inspired fire spread, and a decomposed reward structure designed for GRPO training.
498
+
499
+ **Headline result:** Our trained Qwen-2.5-1.5B IC achieves {X}% population survival on Hard tier vs. {Y}% for the rule-based heuristic baseline. See [HF blog post]({link}) for details.
500
+
501
+ ## Quick Links
502
+ - 🔥 **HF Space (live env):** {link}
503
+ - 📒 **Training notebook (Colab):** [training/grpo_colab.ipynb]({link})
504
+ - 📊 **Eval results:** [eval_results.json]({link})
505
+ - 🎬 **Demo:** `python scripts/run_demo.py`
506
+ - 📝 **Blog post:** {link}
507
+ ```
508
+
509
+ 2. Add a new section right after the quick links called **"Why Theme 2"**:
510
+ - 3 bullets explaining long-horizon planning (300 steps, sparse terminal reward), instruction following (operational briefings), and recovery from early mistakes (staggered ignitions, crew loss events).
511
+
512
+ 3. Keep all existing sections (Environment API, Action Space, Observation Space, Reward Function, Tiers, Fire Spread Model, Project Structure, Key Design Decisions).
513
+
514
+ 4. Update the **Reward Function** section to describe the new decomposed structure (step rewards + terminal spikes), not the old [0,1] composite.
515
+
516
+ 5. Add a new **"Baseline Scores"** table with post-training numbers. If training hasn't completed yet, use placeholder `{TBD}` and add a prominent note: "Numbers will be updated post-training on April 24."
517
+
518
+ 6. Add a **"Reproducing Our Results"** section:
519
+ - How to run baseline evals.
520
+ - How to open the Colab notebook.
521
+ - How to run the demo seed.
522
+ - How to render replays.
523
+
524
+ Acceptance test:
525
+ - README renders cleanly on GitHub (preview via VSCode or `grip`).
526
+ - All links are either live or clearly marked as placeholders.
527
+ - The first screenful (hero + quick links + theme justification) is self-contained — a judge can get the pitch in 30 seconds without scrolling.
528
+ ```
529
+
530
+ ---
531
+
532
+ ## Prompt 14 — CI & Final Repo Polish
533
+
534
+ ```
535
+ Add CI and final-mile polish. This is the "looks professional on GitHub" pass.
536
+
537
+ Tasks:
538
+ 1. Create `.github/workflows/ci.yml`:
539
+ - Triggers: push to main, PRs.
540
+ - Runs: setup Python 3.10, install requirements, run `pytest tests/ -v --cov=env --cov-report=term`.
541
+ - Cache pip dependencies.
542
+ - Required checks: all tests pass.
543
+
544
+ 2. Add a coverage badge and CI badge to the top of README (below the title):
545
+ ```
546
+ ![CI](https://github.com/Abrodolph/Wildfire-Containment-Simulator/actions/workflows/ci.yml/badge.svg)
547
+ ![OpenEnv](https://img.shields.io/badge/OpenEnv-compliant-blue)
548
+ ![Theme](https://img.shields.io/badge/Theme-2%20Long%20Horizon-orange)
549
+ ```
550
+
551
+ 3. Create `LICENSE` file with MIT license (to match the README frontmatter).
552
+
553
+ 4. Audit `openenv.yaml` against the latest OpenEnv spec — fetch the latest spec from the openenv repo (github.com/meta-pytorch/openenv if that's the canonical URL at the time of writing) and verify field names, required properties, and schema version. Report any discrepancies and fix them.
554
+
555
+ 5. Clean up `pyproject.toml`:
556
+ - Pin Python to `>=3.10`.
557
+ - Ensure all console_scripts point to existing entry points (no dead references).
558
+ - Move `pytest`, `pytest-cov` to `[project.optional-dependencies]` under a `dev` extra.
559
+
560
+ 6. Add `CONTRIBUTING.md` (brief — 15 lines is fine) explaining how to add a new tier, how to add a new action type, and where tests live.
561
+
562
+ 7. Run `python -c "import env; import server; from env.wildfire_env import WildfireEnv; WildfireEnv().reset(task_id='easy', seed=0)"` as a final smoke test.
563
+
564
+ Acceptance test:
565
+ - CI badge appears (may show pending until the first push).
566
+ - `pytest tests/ -v --cov=env` runs clean locally and reports >60% coverage on env/.
567
+ - OpenEnv spec audit is completed — paste any discrepancies found and confirm they're fixed.
568
+ - Repo root looks clean: no `__pycache__`, no `{env,...}` artifacts, no nested duplicate folder.
569
+ ```
570
+
571
+ ---
572
+
573
+ ## Prompt 15 (OPTIONAL — Only if P1 complete by April 24 evening) — Multi-Agent Crew Architecture
574
+
575
+ ```
576
+ OPTIONAL: Only execute this if Prompts 1-14 are complete AND the training run has produced a working reward curve. Otherwise skip — this is a high-risk refactor close to deadline.
577
+
578
+ Convert crews from passive tools into semi-autonomous sub-agents. This legitimizes the Halluminate sub-theme claim (Theme 1: Multi-Actor Environments) as a secondary pitch angle.
579
+
580
+ Tasks:
581
+ 1. Add `local_observation` method to Crew in `env/resources.py`:
582
+ - Returns a 3×3 neighborhood view centered on the crew's position (fire_state, intensity, smoke), plus crew's own health state.
583
+
584
+ 2. Add a `local_policy` function per crew:
585
+ - Rule-based: if intensity at current cell > 0.8, retreat one cell away from fire center. Otherwise move toward nearest visible fire in the 3×3 window. If no fire visible, hold position.
586
+ - Crews execute this policy automatically each step UNLESS the IC's most recent order overrides.
587
+
588
+ 3. Change IC action space:
589
+ - Keep existing `MOVE_CREW(crew_id, direction)` but re-label semantically as `ORDER_CREW_MOVE`.
590
+ - Add `ORDER_CREW_OBJECTIVE(crew_id, objective: Literal["hold", "advance", "retreat", "prioritize_north", "prioritize_south", "prioritize_east", "prioritize_west"])` — the crew's local policy then biases toward that objective.
591
+ - If the IC issues no order in a given step, crews follow their local_policy autonomously.
592
+
593
+ 4. Reward impact:
594
+ - Add tracking for "autonomous saves" — when a crew retreats on its own local_policy and avoids a casualty that would have otherwise occurred. Log these; they become a talking point ("our crews saved themselves 3 times in this episode without IC instruction").
595
+
596
+ 5. Add `tests/test_multi_agent.py`:
597
+ - `test_crew_retreats_from_high_intensity` — construct scenario with intensity spike at crew's cell, assert crew moves away next step even with no IC order.
598
+ - `test_ic_order_overrides_local_policy` — assert `ORDER_CREW_MOVE` still works when issued.
599
+ - `test_autonomous_save_tracking` — count autonomous_saves after a scripted scenario.
600
+
601
+ 6. Update `env/serialization.py` to include crew local observations in the prompt under a new `CREW REPORTS` section (each crew reports what they see and what they're doing).
602
+
603
+ 7. Update README to add a "Multi-Agent Architecture" section describing the IC/crew decomposition.
604
+
605
+ Acceptance test:
606
+ - `pytest tests/test_multi_agent.py -v` passes all 3 tests.
607
+ - Run `python scripts/run_demo.py` — confirm the narrative now includes autonomous crew moments.
608
+ - If ANYTHING breaks the existing test suite, revert the changes immediately. This prompt must not destabilize P1 deliverables.
609
+ ```
610
+
611
+ ---
612
+
613
+ ## Final Checklist (Run Before Submission)
614
+
615
+ Run these commands sequentially. All must pass.
616
+
617
+ ```bash
618
+ # 1. All tests green
619
+ pytest tests/ -v
620
+
621
+ # 2. Baseline eval produces expected pattern
622
+ python scripts/evaluate.py 5
623
+
624
+ # 3. Eval comparison runs
625
+ python scripts/eval_compare.py --seeds 42 43 44 45 46 --tiers medium hard --agents random heuristic
626
+
627
+ # 4. Demo runs cleanly
628
+ python scripts/run_demo.py
629
+
630
+ # 5. Dashboard generates
631
+ python scripts/plot_dashboard.py --stats training/training_stats.json --output training/training_dashboard.png
632
+
633
+ # 6. Replay generates
634
+ python scripts/replay.py --tier medium --seed 42 --agent heuristic --output demos/heuristic_medium_42.gif
635
+
636
+ # 7. Notebook imports work
637
+ python training/test_notebook_imports.py
638
+
639
+ # 8. Env still serves
640
+ python app.py &
641
+ sleep 3
642
+ curl http://localhost:7860/health
643
+ kill %1
644
+ ```
pyproject.toml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wildfire-containment-simulator"
7
+ version = "1.0.0"
8
+ description = "Grid-based wildfire containment RL environment (OpenEnv Finale — Theme 2)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "pydantic>=2.0",
14
+ "numpy>=1.24",
15
+ "openai>=1.0",
16
+ "fastapi>=0.100.0",
17
+ "uvicorn>=0.23.0",
18
+ "matplotlib>=3.7",
19
+ "imageio>=2.28",
20
+ "openenv-core>=0.2.0",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=7.0",
26
+ "pytest-cov>=4.0",
27
+ ]
28
+
29
+ [project.scripts]
30
+ server = "server.app:main"
31
+ serve = "server.app:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["."]
35
+ include = ["env*", "agents*", "graders*", "scripts*", "server*"]
36
+
37
+ [tool.setuptools.package-data]
38
+ "*" = ["openenv.yaml"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ pydantic>=2.0
2
+ numpy>=1.24
3
+ openai>=1.0
4
+ fastapi>=0.100.0
5
+ uvicorn>=0.23.0
6
+ matplotlib>=3.7
7
+ imageio>=2.28
8
+ pytest>=7.0
9
+ pytest-cov>=4.0
scripts/eval_compare.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Eval comparison script — runs multiple agents on fixed seeds and prints a summary table.
3
+
4
+ Usage:
5
+ python scripts/eval_compare.py --seeds 42 43 44 45 46 --tiers medium hard --agents random heuristic
6
+ python scripts/eval_compare.py --quick
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+ import warnings
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ from env import WildfireEnv
18
+ from agents.random_agent import RandomAgent
19
+ from agents.heuristic_agent import HeuristicAgent
20
+
21
+
22
+ def _make_llm_agent(model_path_env: str):
23
+ """Return an LLM agent factory or None if the model path is unset."""
24
+ path = os.environ.get(model_path_env)
25
+ if not path:
26
+ return None
27
+ try:
28
+ from agents.llm_agent import LLMAgent # type: ignore
29
+ return LLMAgent(model_path=path)
30
+ except ImportError:
31
+ warnings.warn(f"agents.llm_agent not found — skipping {model_path_env}")
32
+ return None
33
+
34
+
35
+ AGENT_REGISTRY = {
36
+ "random": lambda: RandomAgent(),
37
+ "heuristic": lambda: HeuristicAgent(),
38
+ "base_llm": lambda: _make_llm_agent("BASE_MODEL_PATH"),
39
+ "trained_llm": lambda: _make_llm_agent("TRAINED_MODEL_PATH"),
40
+ }
41
+
42
+ AGENT_LABELS = {
43
+ "random": "Random Agent",
44
+ "heuristic": "Heuristic Agent",
45
+ "base_llm": "Base LLM",
46
+ "trained_llm": "Trained LLM (ours)",
47
+ }
48
+
49
+
50
+ def run_episode(agent, tier: str, seed: int) -> dict:
51
+ env = WildfireEnv()
52
+ obs = env.reset(task_id=tier, seed=seed)
53
+ total_reward = 0.0
54
+ steps = 0
55
+ done = False
56
+ while not done:
57
+ action = agent.act(obs)
58
+ result = env.step(action)
59
+ total_reward += result.reward
60
+ obs = result.observation
61
+ done = result.done
62
+ steps += 1
63
+
64
+ final = env.state()
65
+ total_pop = final.get("total_population", 1) or 1
66
+ pop_lost = final.get("population_lost", 0)
67
+ containment = final.get("containment_pct", 0.0)
68
+
69
+ return {
70
+ "containment_pct": containment,
71
+ "pop_saved_pct": 1.0 - pop_lost / total_pop,
72
+ "total_reward": total_reward,
73
+ "episode_steps": steps,
74
+ }
75
+
76
+
77
+ def run_comparison(agent_names, tiers, seeds):
78
+ results = {}
79
+ for agent_name in agent_names:
80
+ factory = AGENT_REGISTRY.get(agent_name)
81
+ agent = factory() if factory else None
82
+ results[agent_name] = {}
83
+ for tier in tiers:
84
+ if agent is None:
85
+ results[agent_name][tier] = None
86
+ continue
87
+ tier_results = []
88
+ for seed in seeds:
89
+ ep = run_episode(agent, tier, seed)
90
+ tier_results.append(ep)
91
+ results[agent_name][tier] = {
92
+ "containment_pct": sum(r["containment_pct"] for r in tier_results) / len(tier_results),
93
+ "pop_saved_pct": sum(r["pop_saved_pct"] for r in tier_results) / len(tier_results),
94
+ "total_reward": sum(r["total_reward"] for r in tier_results) / len(tier_results),
95
+ "episode_steps": sum(r["episode_steps"] for r in tier_results) / len(tier_results),
96
+ "runs": tier_results,
97
+ }
98
+ return results
99
+
100
+
101
+ def print_table(results, tiers, agent_names, seeds):
102
+ for tier in tiers:
103
+ n = len(seeds)
104
+ print(f"\n=== EVAL RESULTS — {tier.capitalize()} Tier ({n} seed{'s' if n != 1 else ''}) ===")
105
+ header = f"{'Agent':<28} {'Containment':>12} {'Pop Saved':>10} {'Reward':>8} {'Steps':>7}"
106
+ print(header)
107
+ print("-" * len(header))
108
+ for agent_name in agent_names:
109
+ label = AGENT_LABELS.get(agent_name, agent_name)
110
+ data = results[agent_name].get(tier)
111
+ if data is None:
112
+ print(f"{label:<28} {'[skipped — no model]':>39}")
113
+ else:
114
+ containment = f"{data['containment_pct']*100:.0f}%"
115
+ pop_saved = f"{data['pop_saved_pct']*100:.0f}%"
116
+ reward = f"{data['total_reward']:+.1f}"
117
+ steps = f"{data['episode_steps']:.0f}"
118
+ print(f"{label:<28} {containment:>12} {pop_saved:>10} {reward:>8} {steps:>7}")
119
+
120
+
121
+ def main():
122
+ parser = argparse.ArgumentParser()
123
+ parser.add_argument("--seeds", type=int, nargs="+", default=[42, 43, 44, 45, 46])
124
+ parser.add_argument("--tiers", nargs="+", choices=["easy", "medium", "hard"], default=["medium", "hard"])
125
+ parser.add_argument("--agents", nargs="+", choices=list(AGENT_REGISTRY), default=["random", "heuristic"])
126
+ parser.add_argument("--output", default="eval_results.json")
127
+ parser.add_argument("--quick", action="store_true", help="Easy tier, 2 seeds only")
128
+ args = parser.parse_args()
129
+
130
+ if args.quick:
131
+ args.tiers = ["easy"]
132
+ args.seeds = [42, 43]
133
+ args.agents = [a for a in args.agents if a in ("random", "heuristic")]
134
+
135
+ print(f"Running: agents={args.agents}, tiers={args.tiers}, seeds={args.seeds}")
136
+ results = run_comparison(args.agents, args.tiers, args.seeds)
137
+ print_table(results, args.tiers, args.agents, args.seeds)
138
+
139
+ out_dir = os.path.dirname(args.output)
140
+ if out_dir:
141
+ os.makedirs(out_dir, exist_ok=True)
142
+
143
+ serializable = {}
144
+ for agent_name, tier_data in results.items():
145
+ serializable[agent_name] = {}
146
+ for tier, data in tier_data.items():
147
+ if data is None:
148
+ serializable[agent_name][tier] = None
149
+ else:
150
+ serializable[agent_name][tier] = {
151
+ k: v for k, v in data.items() if k != "runs"
152
+ }
153
+ serializable[agent_name][tier]["runs"] = data["runs"]
154
+
155
+ with open(args.output, "w") as f:
156
+ json.dump(serializable, f, indent=2)
157
+ print(f"\nResults saved -> {args.output}")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
scripts/evaluate.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wildfire Containment Simulator — Evaluation Script.
3
+
4
+ Runs both agents (random + heuristic) on all 3 difficulty tiers,
5
+ reports scores, and saves results to JSON.
6
+ """
7
+
8
+ import json
9
+ import sys
10
+ import os
11
+ import time
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
14
+
15
+ from agents.random_agent import RandomAgent
16
+ from agents.heuristic_agent import HeuristicAgent
17
+ from graders.grader_easy import grade as grade_easy
18
+ from graders.grader_medium import grade as grade_medium
19
+ from graders.grader_hard import grade as grade_hard
20
+
21
+
22
+ def run_evaluation(num_runs: int = 5) -> dict:
23
+ graders = {
24
+ "easy": grade_easy,
25
+ "medium": grade_medium,
26
+ "hard": grade_hard,
27
+ }
28
+
29
+ agents = {
30
+ "random": lambda seed: RandomAgent(seed=seed),
31
+ "heuristic": lambda seed: HeuristicAgent(),
32
+ }
33
+
34
+ results = {}
35
+
36
+ print("=" * 80)
37
+ print("WILDFIRE CONTAINMENT SIMULATOR — Evaluation")
38
+ print("=" * 80)
39
+ print()
40
+
41
+ for agent_name, agent_factory in agents.items():
42
+ results[agent_name] = {}
43
+ for tier_name, grader_fn in graders.items():
44
+ scores = []
45
+ detail_rows = []
46
+ times = []
47
+
48
+ for run in range(num_runs):
49
+ seed = 42 + run
50
+ agent = agent_factory(seed)
51
+
52
+ start = time.time()
53
+ score, details = grader_fn(agent, seed=seed)
54
+ elapsed = time.time() - start
55
+
56
+ scores.append(score)
57
+ detail_rows.append(details)
58
+ times.append(elapsed)
59
+
60
+ mean_score = sum(scores) / len(scores)
61
+ std_score = (sum((s - mean_score) ** 2 for s in scores) / len(scores)) ** 0.5
62
+ mean_containment = sum(d["containment_pct"] for d in detail_rows) / len(detail_rows)
63
+ mean_pop_saved = sum(d["pop_saved_pct"] for d in detail_rows) / len(detail_rows)
64
+ mean_steps = sum(d["steps"] for d in detail_rows) / len(detail_rows)
65
+ casualty_rate = sum(1 for d in detail_rows if d["crew_casualty"]) / len(detail_rows)
66
+
67
+ results[agent_name][tier_name] = {
68
+ "scores": [round(s, 4) for s in scores],
69
+ "mean": round(mean_score, 4),
70
+ "std": round(std_score, 4),
71
+ "mean_containment_pct": round(mean_containment, 4),
72
+ "mean_pop_saved_pct": round(mean_pop_saved, 4),
73
+ "mean_steps": round(mean_steps, 1),
74
+ "crew_casualty_rate": round(casualty_rate, 2),
75
+ "mean_time_s": round(sum(times) / len(times), 3),
76
+ }
77
+
78
+ print(f" {agent_name:12s} | {tier_name:8s} | "
79
+ f"reward={mean_score:+.2f}+-{std_score:.2f} | "
80
+ f"contain={mean_containment*100:.0f}% | "
81
+ f"pop_saved={mean_pop_saved*100:.0f}% | "
82
+ f"steps={mean_steps:.0f}")
83
+
84
+ print()
85
+
86
+ print("=" * 80)
87
+ print(f"{'Agent':>12s} | {'Easy':>10s} | {'Medium':>10s} | {'Hard':>10s}")
88
+ print("-" * 80)
89
+ for agent_name in agents:
90
+ easy = results[agent_name]["easy"]["mean"]
91
+ medium = results[agent_name]["medium"]["mean"]
92
+ hard = results[agent_name]["hard"]["mean"]
93
+ print(f"{agent_name:>12s} | {easy:>+10.2f} | {medium:>+10.2f} | {hard:>+10.2f}")
94
+ print("=" * 80)
95
+
96
+ output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.json")
97
+ with open(output_path, "w") as f:
98
+ json.dump(results, f, indent=2)
99
+ print(f"\nResults saved to {output_path}")
100
+
101
+ return results
102
+
103
+
104
+ if __name__ == "__main__":
105
+ num_runs = int(sys.argv[1]) if len(sys.argv) > 1 else 3
106
+ run_evaluation(num_runs=num_runs)
scripts/find_demo_seed.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Find demo seeds on medium tier where the heuristic struggles interestingly.
3
+
4
+ Filters for seeds where:
5
+ (a) a wind shift fires between step 60-90
6
+ (b) heuristic loses at least one populated cell
7
+ (c) heuristic total_reward is between -4.0 and +2.0
8
+
9
+ Usage:
10
+ python scripts/find_demo_seed.py
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+
19
+ from env import WildfireEnv
20
+ from agents.heuristic_agent import HeuristicAgent
21
+
22
+ TIER = "medium"
23
+ MAX_SEED = 500
24
+
25
+
26
+ def scan_seed(seed):
27
+ env = WildfireEnv()
28
+ agent = HeuristicAgent()
29
+ obs = env.reset(task_id=TIER, seed=seed)
30
+
31
+ total_reward = 0.0
32
+ wind_shift_step = None
33
+ done = False
34
+
35
+ while not done:
36
+ action = agent.act(obs)
37
+ result = env.step(action)
38
+ total_reward += result.reward
39
+
40
+ for event in result.info.get("events", []):
41
+ if "WIND SHIFT" in event and wind_shift_step is None:
42
+ wind_shift_step = env.current_step
43
+
44
+ obs = result.observation
45
+ done = result.done
46
+
47
+ final = env.state()
48
+ pop_lost = final.get("population_lost", 0)
49
+ total_pop = final.get("total_population", 1) or 1
50
+
51
+ return {
52
+ "seed": seed,
53
+ "total_reward": round(total_reward, 3),
54
+ "pop_lost": pop_lost,
55
+ "pop_saved_pct": round(1.0 - pop_lost / total_pop, 3),
56
+ "wind_shift_step": wind_shift_step,
57
+ "steps": env.current_step,
58
+ "containment_pct": round(final.get("containment_pct", 0.0), 3),
59
+ }
60
+
61
+
62
+ def main():
63
+ candidates = []
64
+ print(f"Scanning seeds 0-{MAX_SEED - 1} on {TIER} tier...")
65
+
66
+ for seed in range(MAX_SEED):
67
+ if seed % 50 == 0:
68
+ print(f" seed {seed}...")
69
+ info = scan_seed(seed)
70
+
71
+ wind_ok = (info["wind_shift_step"] is not None
72
+ and 60 <= info["wind_shift_step"] <= 90)
73
+ pop_ok = info["pop_lost"] >= 1
74
+ reward_ok = -4.0 <= info["total_reward"] <= 2.0
75
+
76
+ if wind_ok and pop_ok and reward_ok:
77
+ candidates.append(info)
78
+
79
+ candidates.sort(key=lambda x: x["total_reward"], reverse=True)
80
+ top5 = candidates[:5]
81
+
82
+ for c in top5:
83
+ ws = c["wind_shift_step"]
84
+ print(f" seed={c['seed']:3d} reward={c['total_reward']:+.2f} "
85
+ f"pop_lost={c['pop_lost']} wind_shift=step {ws} "
86
+ f"steps={c['steps']}")
87
+
88
+ os.makedirs("demos", exist_ok=True)
89
+ with open("demos/candidate_seeds.json", "w") as f:
90
+ json.dump(top5, f, indent=2)
91
+ print(f"\nTop {len(top5)} candidates saved -> demos/candidate_seeds.json")
92
+
93
+ if not top5:
94
+ print("No candidates matched all 3 filters — relaxing pop_lost filter...")
95
+ fallback = [scan_seed(s) for s in [42, 7, 13, 99, 123]]
96
+ fallback.sort(key=lambda x: x["total_reward"], reverse=True)
97
+ with open("demos/candidate_seeds.json", "w") as f:
98
+ json.dump(fallback, f, indent=2)
99
+ print("Saved fallback candidates.")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
scripts/plot_dashboard.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training curves dashboard — 4-panel matplotlib figure.
3
+
4
+ Usage:
5
+ python scripts/plot_dashboard.py --stats training/training_stats.json --output training/training_dashboard.png
6
+ python scripts/plot_dashboard.py # generates synthetic demo if no stats file
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import math
12
+ import os
13
+ import sys
14
+
15
+ import matplotlib
16
+ matplotlib.use("Agg")
17
+ import matplotlib.pyplot as plt
18
+ import numpy as np
19
+
20
+
21
+ SYNTHETIC_PATH = "training/synthetic_stats_demo.json"
22
+ TIER_ORDER = {"easy": 0, "medium": 1, "hard": 2}
23
+ TIER_COLORS = {"easy": "tab:green", "medium": "tab:orange", "hard": "tab:red"}
24
+
25
+
26
+ def _moving_average(values, window):
27
+ out = []
28
+ for i in range(len(values)):
29
+ w = values[max(0, i - window + 1): i + 1]
30
+ out.append(sum(w) / len(w))
31
+ return out
32
+
33
+
34
+ def _rolling_fraction(flags, window=10):
35
+ out = []
36
+ for i in range(len(flags)):
37
+ w = flags[max(0, i - window + 1): i + 1]
38
+ out.append(sum(w) / len(w))
39
+ return out
40
+
41
+
42
+ def _generate_synthetic():
43
+ """Create 50 fake training steps with a plausible upward curve + one promotion."""
44
+ stats = []
45
+ rng = np.random.default_rng(0)
46
+ tier = "easy"
47
+ for i in range(50):
48
+ if i == 20:
49
+ tier = "medium"
50
+ base = 2.0 + i * 0.08 if tier == "easy" else 1.0 + (i - 20) * 0.06
51
+ reward = float(base + rng.normal(0, 0.5))
52
+ stats.append({
53
+ "step": i,
54
+ "mean_reward": reward,
55
+ "tier": tier,
56
+ "parse_failure_rate": max(0.0, 0.3 - i * 0.005 + float(rng.normal(0, 0.02))),
57
+ "promoted_to": "medium" if i == 20 else None,
58
+ })
59
+ os.makedirs(os.path.dirname(SYNTHETIC_PATH), exist_ok=True)
60
+ with open(SYNTHETIC_PATH, "w") as f:
61
+ json.dump(stats, f, indent=2)
62
+ return stats, True
63
+
64
+
65
+ def load_stats(path):
66
+ if path and os.path.exists(path):
67
+ with open(path) as f:
68
+ return json.load(f), False
69
+ return _generate_synthetic()
70
+
71
+
72
+ def plot_dashboard(stats, output_path, synthetic=False):
73
+ steps = [s["step"] for s in stats]
74
+ rewards = [s["mean_reward"] for s in stats]
75
+ tiers = [s["tier"] for s in stats]
76
+ tier_nums = [TIER_ORDER.get(t, 0) for t in tiers]
77
+
78
+ # Population survival: 1 if reward >= 5.0 (terminal bonus threshold), else 0
79
+ pop_survived = [1 if r >= 5.0 else 0 for r in rewards]
80
+ # Containment proxy: clamp reward to [0,1] range as a rough proxy
81
+ containment = [min(1.0, max(0.0, r / 8.0)) for r in rewards]
82
+
83
+ promotion_events = [
84
+ (s["step"], s["promoted_to"])
85
+ for s in stats
86
+ if s.get("promoted_to")
87
+ ]
88
+
89
+ fig, axes = plt.subplots(2, 2, figsize=(12, 8), dpi=100)
90
+ title_suffix = " [SYNTHETIC DEMO]" if synthetic else ""
91
+ fig.suptitle(f"Wildfire Containment Simulator — Training Dashboard{title_suffix}",
92
+ fontsize=13, fontweight="bold", color="darkred" if synthetic else "black")
93
+
94
+ # Panel A — Mean episode reward
95
+ ax = axes[0, 0]
96
+ ax.plot(steps, rewards, alpha=0.35, color="steelblue", linewidth=1)
97
+ ax.plot(steps, _moving_average(rewards, 5), color="steelblue", linewidth=2, label="MA-5")
98
+ for ep, new_tier in promotion_events:
99
+ ax.axvline(x=ep, color=TIER_COLORS.get(new_tier, "gray"), linestyle="--", alpha=0.7)
100
+ ax.text(ep + 0.3, ax.get_ylim()[1] * 0.95 if ax.get_ylim()[1] != 0 else 0.5,
101
+ new_tier, fontsize=7, color=TIER_COLORS.get(new_tier, "gray"))
102
+ ax.set_title("A — Episode Reward")
103
+ ax.set_xlabel("Step")
104
+ ax.set_ylabel("Reward")
105
+ ax.legend(fontsize=8)
106
+ ax.grid(True, alpha=0.3)
107
+
108
+ # Panel B — Population survival rate (rolling 10-ep fraction)
109
+ ax = axes[0, 1]
110
+ survival_rate = _rolling_fraction(pop_survived, window=10)
111
+ ax.plot(steps, [v * 100 for v in survival_rate], color="forestgreen", linewidth=2)
112
+ ax.fill_between(steps, [v * 100 for v in survival_rate], alpha=0.15, color="forestgreen")
113
+ for ep, new_tier in promotion_events:
114
+ ax.axvline(x=ep, color=TIER_COLORS.get(new_tier, "gray"), linestyle="--", alpha=0.7)
115
+ ax.set_title("B — Population Survival Rate (rolling 10-ep)")
116
+ ax.set_xlabel("Step")
117
+ ax.set_ylabel("% Episodes with Zero Pop Loss")
118
+ ax.set_ylim(0, 105)
119
+ ax.grid(True, alpha=0.3)
120
+
121
+ # Panel C — Mean containment % at episode end
122
+ ax = axes[1, 0]
123
+ containment_ma = _moving_average(containment, 5)
124
+ ax.plot(steps, [v * 100 for v in containment], alpha=0.3, color="darkorange", linewidth=1)
125
+ ax.plot(steps, [v * 100 for v in containment_ma], color="darkorange", linewidth=2, label="MA-5")
126
+ for ep, new_tier in promotion_events:
127
+ ax.axvline(x=ep, color=TIER_COLORS.get(new_tier, "gray"), linestyle="--", alpha=0.7)
128
+ ax.set_title("C — Containment % at Episode End")
129
+ ax.set_xlabel("Step")
130
+ ax.set_ylabel("Containment %")
131
+ ax.set_ylim(0, 105)
132
+ ax.legend(fontsize=8)
133
+ ax.grid(True, alpha=0.3)
134
+
135
+ # Panel D — Curriculum tier timeline (step function)
136
+ ax = axes[1, 1]
137
+ ax.step(steps, tier_nums, where="post", color="mediumpurple", linewidth=2)
138
+ ax.fill_between(steps, tier_nums, step="post", alpha=0.15, color="mediumpurple")
139
+ for ep, new_tier in promotion_events:
140
+ tier_num = TIER_ORDER.get(new_tier, 0)
141
+ color = TIER_COLORS.get(new_tier, "gray")
142
+ ax.axvline(x=ep, color=color, linestyle="--", alpha=0.8, linewidth=1.5)
143
+ ax.text(ep + 0.3, tier_num - 0.1, f"-> {new_tier}", fontsize=8,
144
+ color=color, fontweight="bold")
145
+ ax.set_yticks([0, 1, 2])
146
+ ax.set_yticklabels(["easy", "medium", "hard"])
147
+ ax.set_title("D — Curriculum Tier Timeline")
148
+ ax.set_xlabel("Episode")
149
+ ax.set_ylabel("Tier")
150
+ ax.set_ylim(-0.3, 2.5)
151
+ ax.grid(True, alpha=0.3)
152
+
153
+ plt.tight_layout()
154
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
155
+ fig.savefig(output_path, dpi=100)
156
+ plt.close(fig)
157
+ print(f"Dashboard saved -> {output_path}")
158
+
159
+
160
+ def main():
161
+ parser = argparse.ArgumentParser()
162
+ parser.add_argument("--stats", default=None)
163
+ parser.add_argument("--output", default="training/training_dashboard.png")
164
+ args = parser.parse_args()
165
+
166
+ stats, synthetic = load_stats(args.stats)
167
+ if synthetic:
168
+ print(f"No stats file found — generated synthetic demo at {SYNTHETIC_PATH}")
169
+ plot_dashboard(stats, args.output, synthetic=synthetic)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ main()
scripts/replay.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Replay script — renders a full episode as an animated GIF.
3
+
4
+ Usage:
5
+ python scripts/replay.py --tier medium --seed 42 --agent heuristic --output demos/out.gif
6
+ """
7
+
8
+ import argparse
9
+ import os
10
+ import sys
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from env import WildfireEnv
15
+ from env.rendering import render_frame, render_episode_gif
16
+ from agents.random_agent import RandomAgent
17
+ from agents.heuristic_agent import HeuristicAgent
18
+
19
+
20
+ AGENT_REGISTRY = {
21
+ "random": RandomAgent,
22
+ "heuristic": HeuristicAgent,
23
+ }
24
+
25
+
26
+ def main():
27
+ parser = argparse.ArgumentParser()
28
+ parser.add_argument("--tier", choices=["easy", "medium", "hard"], default="medium")
29
+ parser.add_argument("--seed", type=int, default=42)
30
+ parser.add_argument("--agent", choices=list(AGENT_REGISTRY), default="heuristic")
31
+ parser.add_argument("--output", default="demos/replay.gif")
32
+ args = parser.parse_args()
33
+
34
+ os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
35
+
36
+ env = WildfireEnv()
37
+ agent = AGENT_REGISTRY[args.agent]()
38
+ obs = env.reset(task_id=args.tier, seed=args.seed)
39
+
40
+ frames = []
41
+ step = 0
42
+
43
+ # Capture initial frame
44
+ s = env.state()
45
+ frames.append(render_frame(s, step))
46
+
47
+ done = False
48
+ while not done:
49
+ action = agent.act(obs)
50
+ result = env.step(action)
51
+ obs = result.observation
52
+ done = result.done
53
+ step += 1
54
+ s = env.state()
55
+ frames.append(render_frame(s, step))
56
+
57
+ print(f"Episode finished at step {step}. Rendering {len(frames)} frames...")
58
+
59
+ render_episode_gif(frames, args.output)
60
+ print(f"GIF saved → {args.output}")
61
+
62
+ # Save final-frame PNG
63
+ png_path = os.path.splitext(args.output)[0] + ".png"
64
+ import imageio.v3 as iio
65
+ iio.imwrite(png_path, frames[-1], extension=".png")
66
+ print(f"Final frame PNG → {png_path}")
67
+
68
+ # Print final stats
69
+ final_state = env.state()
70
+ pop_lost = final_state.get("population_lost", 0)
71
+ total_pop = final_state.get("total_population", 0)
72
+ cells_burned = final_state.get("cells_burned", 0)
73
+ print(f"\nFinal stats: step={step}, pop_lost={pop_lost}/{total_pop}, cells_burned={cells_burned}")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()