Commit Β·
1c03487
0
Parent(s):
Initial submission: Cascade Containment
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .gitignore +0 -0
- Dockerfile +27 -0
- README.md +53 -0
- __init__.py +0 -0
- __pycache__/client.cpython-313.pyc +0 -0
- __pycache__/models.cpython-313.pyc +0 -0
- baseline/__init__.py +0 -0
- baseline/__pycache__/__init__.cpython-313.pyc +0 -0
- baseline/__pycache__/evaluator.cpython-313.pyc +0 -0
- baseline/__pycache__/policy.cpython-313.pyc +0 -0
- baseline/evaluator.py +197 -0
- baseline/policy.py +154 -0
- baseline/run.py +32 -0
- client.py +66 -0
- config/tasks.yaml +0 -0
- core/__init__.py +0 -0
- core/__pycache__/__init__.cpython-313.pyc +0 -0
- core/__pycache__/policy_update.cpython-313.pyc +0 -0
- core/__pycache__/reward.cpython-313.pyc +0 -0
- core/__pycache__/trajectory.cpython-313.pyc +0 -0
- core/policy_update.py +58 -0
- core/reward.py +20 -0
- core/trajectory.py +81 -0
- inference.py +44 -0
- models.py +70 -0
- openenv.yaml +120 -0
- pyproject.toml +3 -0
- requirements.txt +7 -0
- scripts/test_local.py +131 -0
- scripts/validate.py +0 -0
- server/__init__.py +0 -0
- server/__pycache__/__init__.cpython-313.pyc +0 -0
- server/__pycache__/app.cpython-313.pyc +0 -0
- server/__pycache__/constants.cpython-313.pyc +0 -0
- server/__pycache__/environment.cpython-313.pyc +0 -0
- server/__pycache__/grader.cpython-313.pyc +0 -0
- server/__pycache__/utils.cpython-313.pyc +0 -0
- server/app.py +21 -0
- server/constants.py +70 -0
- server/environment.py +316 -0
- server/grader.py +197 -0
- server/tasks/__init__.py +0 -0
- server/tasks/__pycache__/__init__.cpython-313.pyc +0 -0
- server/tasks/__pycache__/base.cpython-313.pyc +0 -0
- server/tasks/__pycache__/registry.cpython-313.pyc +0 -0
- server/tasks/__pycache__/task_easy.cpython-313.pyc +0 -0
- server/tasks/__pycache__/task_hard.cpython-313.pyc +0 -0
- server/tasks/__pycache__/task_medium.cpython-313.pyc +0 -0
- server/tasks/base.py +33 -0
- server/tasks/registry.py +38 -0
.gitignore
ADDED
|
Binary file (118 Bytes). View file
|
|
|
Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/Dockerfile
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Builds the Cascade Containment environment server.
|
| 4 |
+
# Exposes port 7860 β required for Hugging Face Spaces deployment.
|
| 5 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
|
| 7 |
+
FROM python:3.11-slim
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
RUN apt-get update && apt-get install -y \
|
| 12 |
+
build-essential \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
COPY models.py .
|
| 19 |
+
COPY constants.py .
|
| 20 |
+
COPY server/ ./server/
|
| 21 |
+
COPY core/ ./core/
|
| 22 |
+
|
| 23 |
+
ENV PYTHONPATH="/app:/app/server"
|
| 24 |
+
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Cascade Containment
|
| 3 |
+
emoji: π¦
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Cascade Containment
|
| 12 |
+
|
| 13 |
+
An RL benchmark for epidemic containment policy under uncertainty.
|
| 14 |
+
A city health authority must allocate limited resources across districts
|
| 15 |
+
to contain a spreading outbreak β with delayed data, resource scarcity,
|
| 16 |
+
and cascading hospital stress.
|
| 17 |
+
|
| 18 |
+
Generalises to wildfire deployment, cyberattack isolation, and misinformation containment.
|
| 19 |
+
|
| 20 |
+
## Environment
|
| 21 |
+
|
| 22 |
+
- **3 tasks:** Easy (2 districts), Medium (4 districts), Hard (6 districts with 3-day data lag)
|
| 23 |
+
- **Action space:** `action_type` (test/restrict/allocate) + `district_id`
|
| 24 |
+
- **Learning:** GRPO-style episodic memory with advantage gating
|
| 25 |
+
|
| 26 |
+
## Usage
|
| 27 |
+
|
| 28 |
+
\```python
|
| 29 |
+
from client import CascadeContainmentEnv
|
| 30 |
+
from models import ContainmentAction
|
| 31 |
+
|
| 32 |
+
with CascadeContainmentEnv(base_url="https://YOUR-SPACE-URL.hf.space").sync() as env:
|
| 33 |
+
obs = env.reset(task_name="easy")
|
| 34 |
+
result = env.step(ContainmentAction(action_type="allocate", district_id=0))
|
| 35 |
+
\```
|
| 36 |
+
|
| 37 |
+
## Tasks
|
| 38 |
+
|
| 39 |
+
| Task | Districts | Steps | Resources | Data Lag |
|
| 40 |
+
|------|-----------|-------|-----------|----------|
|
| 41 |
+
| easy | 2 | 10 | 10 | None |
|
| 42 |
+
| medium | 4 | 15 | 8 | None |
|
| 43 |
+
| hard | 6 | 20 | 7 | 3 days |
|
| 44 |
+
|
| 45 |
+
## Reward Function
|
| 46 |
+
|
| 47 |
+
| Term | Value | Condition |
|
| 48 |
+
|------|-------|-----------|
|
| 49 |
+
| Infection penalty | -0.50 | Per district above 0.4 threshold |
|
| 50 |
+
| Hospital breach | -1.00 | Per breached hospital |
|
| 51 |
+
| Early containment | +0.50 | Scaled by time remaining |
|
| 52 |
+
| Unnecessary restriction | -0.20 | Restricting below 0.2 threshold |
|
| 53 |
+
| Correct prioritisation | +0.30 | Allocating to highest-infected district |
|
__init__.py
ADDED
|
File without changes
|
__pycache__/client.cpython-313.pyc
ADDED
|
Binary file (3.46 kB). View file
|
|
|
__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (3.47 kB). View file
|
|
|
baseline/__init__.py
ADDED
|
File without changes
|
baseline/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (206 Bytes). View file
|
|
|
baseline/__pycache__/evaluator.cpython-313.pyc
ADDED
|
Binary file (9.12 kB). View file
|
|
|
baseline/__pycache__/policy.cpython-313.pyc
ADDED
|
Binary file (6.34 kB). View file
|
|
|
baseline/evaluator.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# baseline/evaluator.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# GRPO-style evaluation loop for Cascade Containment.
|
| 4 |
+
# Imports core components β stays focused on orchestration only.
|
| 5 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
from typing import List, Tuple
|
| 13 |
+
from openai import OpenAI
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from client import CascadeContainmentEnv
|
| 17 |
+
from models import ContainmentAction, CityObservation
|
| 18 |
+
from baseline.policy import get_client, build_prompt, call_llm, parse_action
|
| 19 |
+
from core.trajectory import EpisodicMemory
|
| 20 |
+
from core.reward import normalise_score
|
| 21 |
+
from core.policy_update import compute_advantage, update_memory
|
| 22 |
+
|
| 23 |
+
N_ROLLOUTS = 3
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ββ Prompt Builder With Memory ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
|
| 28 |
+
def build_prompt_with_memory(obs: CityObservation, memory: EpisodicMemory) -> str:
|
| 29 |
+
"""Extend base prompt with retrieved memories from similar past situations."""
|
| 30 |
+
from baseline.policy import build_prompt
|
| 31 |
+
base = build_prompt(obs)
|
| 32 |
+
memory_block = memory.retrieve(obs)
|
| 33 |
+
|
| 34 |
+
if not memory_block:
|
| 35 |
+
return base
|
| 36 |
+
|
| 37 |
+
injection = (
|
| 38 |
+
"\n"
|
| 39 |
+
+ memory_block
|
| 40 |
+
+ "\nUse these past experiences to make a better decision.\n"
|
| 41 |
+
)
|
| 42 |
+
return base.replace("Your decision:", injection + "Your decision:")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ββ Single Rollout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
|
| 47 |
+
def run_rollout(
|
| 48 |
+
env: Any,
|
| 49 |
+
task_name: str,
|
| 50 |
+
client: OpenAI,
|
| 51 |
+
memory: EpisodicMemory,
|
| 52 |
+
verbose: bool = True,
|
| 53 |
+
) -> Tuple[float, int, List[dict]]:
|
| 54 |
+
"""Run one complete episode using memory-augmented prompts."""
|
| 55 |
+
result = env.reset(task_name=task_name)
|
| 56 |
+
obs = result.observation
|
| 57 |
+
done = result.done
|
| 58 |
+
total_reward = 0.0
|
| 59 |
+
step = 0
|
| 60 |
+
trajectory = []
|
| 61 |
+
|
| 62 |
+
while not done:
|
| 63 |
+
prompt = build_prompt_with_memory(obs, memory)
|
| 64 |
+
response = call_llm(prompt, client)
|
| 65 |
+
action = parse_action(response, len(obs.districts))
|
| 66 |
+
|
| 67 |
+
result = env.step(action)
|
| 68 |
+
next_obs = result.observation
|
| 69 |
+
reward = result.reward or 0.0
|
| 70 |
+
done = result.done
|
| 71 |
+
total_reward += reward
|
| 72 |
+
step += 1
|
| 73 |
+
|
| 74 |
+
trajectory.append({
|
| 75 |
+
"obs": obs,
|
| 76 |
+
"action": action,
|
| 77 |
+
"reward": reward,
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
if verbose:
|
| 81 |
+
print(
|
| 82 |
+
f" step {step:2d}: {action.action_type:8} "
|
| 83 |
+
f"β district {action.district_id} "
|
| 84 |
+
f"| reward: {reward:+.4f}"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
obs = next_obs
|
| 88 |
+
if done:
|
| 89 |
+
break
|
| 90 |
+
|
| 91 |
+
return total_reward, step, trajectory
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ββ GRPO Task Runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
def run_task_grpo(
|
| 97 |
+
env: Any,
|
| 98 |
+
task_name: str,
|
| 99 |
+
client: OpenAI,
|
| 100 |
+
verbose: bool = True,
|
| 101 |
+
) -> float:
|
| 102 |
+
"""GRPO-style simulated learning loop for one task."""
|
| 103 |
+
if verbose:
|
| 104 |
+
print(f"\n Task: {task_name.upper()} | {N_ROLLOUTS} rollouts")
|
| 105 |
+
print(f" {'β'*44}")
|
| 106 |
+
|
| 107 |
+
memory = EpisodicMemory(max_size=20)
|
| 108 |
+
rollouts = []
|
| 109 |
+
|
| 110 |
+
for i in range(N_ROLLOUTS):
|
| 111 |
+
if verbose:
|
| 112 |
+
label = "base prompt" if len(memory) == 0 else f"memory: {len(memory)} entries"
|
| 113 |
+
print(f"\n Rollout {i+1}/{N_ROLLOUTS} [{label}]")
|
| 114 |
+
|
| 115 |
+
total_reward, steps, trajectory = run_rollout(env, task_name, client, memory, verbose)
|
| 116 |
+
score = normalise_score(total_reward, steps)
|
| 117 |
+
rollouts.append((total_reward, steps, score))
|
| 118 |
+
|
| 119 |
+
if verbose:
|
| 120 |
+
print(f" β Reward: {total_reward:+.4f} | Score: {score:.4f}")
|
| 121 |
+
|
| 122 |
+
# GRPO advantage computation
|
| 123 |
+
completed_rewards = [r[0] for r in rollouts]
|
| 124 |
+
advantage = compute_advantage(total_reward, completed_rewards[:-1])
|
| 125 |
+
stored = update_memory(memory, trajectory, advantage)
|
| 126 |
+
|
| 127 |
+
if verbose:
|
| 128 |
+
mean = sum(completed_rewards[:-1]) / max(len(completed_rewards) - 1, 1) \
|
| 129 |
+
if len(completed_rewards) > 1 else total_reward
|
| 130 |
+
print(f" β Advantage: {advantage:+.4f} | "
|
| 131 |
+
+ (f"β Stored {stored} steps" if stored > 0 else "β Suppressed"))
|
| 132 |
+
|
| 133 |
+
all_rewards = [r[0] for r in rollouts]
|
| 134 |
+
mean_reward = sum(all_rewards) / len(all_rewards)
|
| 135 |
+
best_score = max(rollouts, key=lambda x: x[0])[2]
|
| 136 |
+
|
| 137 |
+
if verbose:
|
| 138 |
+
print(f"\n Rewards: {[round(r, 4) for r in all_rewards]}")
|
| 139 |
+
print(f" Mean: {mean_reward:+.4f}")
|
| 140 |
+
print(f" Advantages: {[round(r - mean_reward, 4) for r in all_rewards]}")
|
| 141 |
+
print(f" Best score: {best_score:.4f}")
|
| 142 |
+
|
| 143 |
+
return best_score
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ββ Full Evaluator ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
|
| 148 |
+
def run_evaluation(
|
| 149 |
+
base_url: str = "http://localhost:7860",
|
| 150 |
+
verbose: bool = True,
|
| 151 |
+
) -> dict:
|
| 152 |
+
"""Run all three tasks with GRPO episodic memory learning."""
|
| 153 |
+
if verbose:
|
| 154 |
+
print("\n" + "="*52)
|
| 155 |
+
print(" CASCADE CONTAINMENT β GRPO EVALUATION")
|
| 156 |
+
print("="*52)
|
| 157 |
+
print(f" Rollouts per task: {N_ROLLOUTS}")
|
| 158 |
+
print(f" Learning: Episodic memory + advantage gating")
|
| 159 |
+
|
| 160 |
+
client = get_client()
|
| 161 |
+
scores = {}
|
| 162 |
+
start = time.time()
|
| 163 |
+
|
| 164 |
+
with CascadeContainmentEnv(base_url=base_url).sync() as env:
|
| 165 |
+
for task_name in ["easy", "medium", "hard"]:
|
| 166 |
+
try:
|
| 167 |
+
score = run_task_grpo(env, task_name, client, verbose)
|
| 168 |
+
scores[task_name] = score
|
| 169 |
+
if verbose:
|
| 170 |
+
print(f"\n β {task_name.upper()} final score: {score:.4f}")
|
| 171 |
+
except Exception as e:
|
| 172 |
+
scores[task_name] = 0.0
|
| 173 |
+
if verbose:
|
| 174 |
+
print(f" β {task_name.upper()} failed: {e}")
|
| 175 |
+
import traceback
|
| 176 |
+
traceback.print_exc()
|
| 177 |
+
|
| 178 |
+
scores["average"] = round(
|
| 179 |
+
sum(v for k, v in scores.items() if k != "average") / 3,
|
| 180 |
+
4
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
elapsed = round(time.time() - start, 1)
|
| 184 |
+
|
| 185 |
+
if verbose:
|
| 186 |
+
print("\n" + "="*52)
|
| 187 |
+
print(" FINAL SCORES")
|
| 188 |
+
print("="*52)
|
| 189 |
+
print(f" Easy: {scores.get('easy', 0.0):.4f}")
|
| 190 |
+
print(f" Medium: {scores.get('medium', 0.0):.4f}")
|
| 191 |
+
print(f" Hard: {scores.get('hard', 0.0):.4f}")
|
| 192 |
+
print(f" {'β'*32}")
|
| 193 |
+
print(f" Average: {scores.get('average', 0.0):.4f}")
|
| 194 |
+
print(f" Time: {elapsed}s")
|
| 195 |
+
print("="*52 + "\n")
|
| 196 |
+
|
| 197 |
+
return scores
|
baseline/policy.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# baseline/policy.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# LLM-based policy for Cascade Containment.
|
| 4 |
+
# Reads CityObservation, calls LLM via OpenAI client, returns ContainmentAction.
|
| 5 |
+
# Uses environment variables for API configuration as required by hackathon rules.
|
| 6 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from openai import OpenAI
|
| 15 |
+
from models import CityObservation, ContainmentAction
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ββ Client Setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
|
| 20 |
+
def get_client() -> OpenAI:
|
| 21 |
+
"""
|
| 22 |
+
Initialise OpenAI client from environment variables.
|
| 23 |
+
Required by hackathon rules β never hardcode API keys.
|
| 24 |
+
"""
|
| 25 |
+
return OpenAI(
|
| 26 |
+
api_key = os.environ.get("HF_TOKEN", ""),
|
| 27 |
+
base_url = os.environ.get("API_BASE_URL", "https://router.huggingface.co/v1"),
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ββ Prompt Builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
|
| 33 |
+
def build_prompt(obs: CityObservation) -> str:
|
| 34 |
+
"""
|
| 35 |
+
Convert a CityObservation into a clear, structured prompt.
|
| 36 |
+
The prompt gives the LLM everything it needs to make an informed decision.
|
| 37 |
+
"""
|
| 38 |
+
lines = [
|
| 39 |
+
"You are a public health authority managing an epidemic outbreak.",
|
| 40 |
+
"Your goal is to contain infection across all districts before hospitals collapse.",
|
| 41 |
+
"",
|
| 42 |
+
f"Current situation (Step {obs.current_step}/{obs.max_steps}):",
|
| 43 |
+
f"Available resources: {obs.available_resources}",
|
| 44 |
+
"",
|
| 45 |
+
"District status:",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
for d in obs.districts:
|
| 49 |
+
status = "DANGER" if d.reported_infection_rate > 0.4 else \
|
| 50 |
+
"WARNING" if d.reported_infection_rate > 0.2 else "SAFE"
|
| 51 |
+
lines.append(
|
| 52 |
+
f" District {d.district_id}: "
|
| 53 |
+
f"infection={d.reported_infection_rate:.2f} [{status}], "
|
| 54 |
+
f"growth_hint={d.growth_rate_hint:.2f}, "
|
| 55 |
+
f"hospital={d.hospital_capacity_remaining:.2f}, "
|
| 56 |
+
f"restricted={'yes' if d.restriction_active else 'no'}, "
|
| 57 |
+
f"tested_recently={'yes' if d.tested_recently else 'no'}"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
lines += [
|
| 61 |
+
"",
|
| 62 |
+
"Available actions:",
|
| 63 |
+
" - 'test' : Get accurate infection data for a district (costs 1 resource)",
|
| 64 |
+
" - 'restrict' : Impose movement restriction in a district (free, but penalised if infection is low)",
|
| 65 |
+
" - 'allocate' : Deploy medical resources to a district (costs 1 resource)",
|
| 66 |
+
"",
|
| 67 |
+
"Strategy hints:",
|
| 68 |
+
" - Prioritise districts in DANGER or with high growth_hint",
|
| 69 |
+
" - Use 'test' on high growth_hint districts to reveal true infection",
|
| 70 |
+
" - Use 'allocate' on the most infected district",
|
| 71 |
+
" - Only 'restrict' districts above 0.2 infection rate",
|
| 72 |
+
" - If resources = 0, you can only use 'restrict'",
|
| 73 |
+
"",
|
| 74 |
+
"Respond with ONLY a JSON object in this exact format:",
|
| 75 |
+
'{"action_type": "allocate", "district_id": 2}',
|
| 76 |
+
"",
|
| 77 |
+
"Your decision:",
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
return "\n".join(lines)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ββ LLM Call ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
|
| 85 |
+
def call_llm(prompt: str, client: OpenAI) -> str:
|
| 86 |
+
"""Call the LLM and return the raw response string."""
|
| 87 |
+
response = client.chat.completions.create(
|
| 88 |
+
model = os.environ.get("MODEL_NAME", "meta-llama/Llama-3.1-8B-Instruct"),
|
| 89 |
+
messages = [
|
| 90 |
+
{
|
| 91 |
+
"role": "system",
|
| 92 |
+
"content": "You are an epidemic response AI. Always respond with valid JSON only. No explanation."
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"role": "user",
|
| 96 |
+
"content": prompt
|
| 97 |
+
}
|
| 98 |
+
],
|
| 99 |
+
max_tokens = 50,
|
| 100 |
+
temperature = 0.2, # Low temperature for consistent, reliable decisions
|
| 101 |
+
)
|
| 102 |
+
return (response.choices[0].message.content or "").strip()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ββ Response Parser βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
+
|
| 107 |
+
def parse_action(response: str, num_districts: int) -> ContainmentAction:
|
| 108 |
+
"""
|
| 109 |
+
Parse LLM response into a ContainmentAction.
|
| 110 |
+
Handles common LLM formatting issues defensively.
|
| 111 |
+
Falls back to a safe default if parsing fails entirely.
|
| 112 |
+
"""
|
| 113 |
+
valid_types = {"test", "restrict", "allocate"}
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
# Strip markdown code fences if present
|
| 117 |
+
cleaned = re.sub(r"```(?:json)?|```", "", response).strip()
|
| 118 |
+
|
| 119 |
+
# Extract JSON object if surrounded by other text
|
| 120 |
+
match = re.search(r"\{.*?\}", cleaned, re.DOTALL)
|
| 121 |
+
if match:
|
| 122 |
+
cleaned = match.group()
|
| 123 |
+
|
| 124 |
+
data = json.loads(cleaned)
|
| 125 |
+
action_type = str(data.get("action_type", "allocate")).lower().strip()
|
| 126 |
+
district_id = int(data.get("district_id", 0))
|
| 127 |
+
|
| 128 |
+
# Validate and clamp
|
| 129 |
+
if action_type not in valid_types:
|
| 130 |
+
action_type = "allocate"
|
| 131 |
+
district_id = max(0, min(district_id, num_districts - 1))
|
| 132 |
+
|
| 133 |
+
return ContainmentAction(
|
| 134 |
+
action_type = action_type,
|
| 135 |
+
district_id = district_id,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
except Exception:
|
| 139 |
+
# Safe fallback β allocate to district 0
|
| 140 |
+
return ContainmentAction(action_type="allocate", district_id=0)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ββ Main Policy Function ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 144 |
+
|
| 145 |
+
def get_action(obs: CityObservation, client: OpenAI) -> ContainmentAction:
|
| 146 |
+
"""
|
| 147 |
+
Main entry point for the policy.
|
| 148 |
+
Takes an observation, returns a ContainmentAction.
|
| 149 |
+
Called by evaluator.py on every step.
|
| 150 |
+
"""
|
| 151 |
+
prompt = build_prompt(obs)
|
| 152 |
+
response = call_llm(prompt, client)
|
| 153 |
+
action = parse_action(response, len(obs.districts))
|
| 154 |
+
return action
|
baseline/run.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# baseline/run.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# CLI entry point for the baseline evaluation.
|
| 4 |
+
# Called by inference.py β can also be run directly for testing.
|
| 5 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 10 |
+
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
load_dotenv()
|
| 13 |
+
print(f"DEBUG TOKEN: '{os.environ.get('HF_TOKEN', 'NOT SET')[:10]}...'")
|
| 14 |
+
|
| 15 |
+
# If HF_TOKEN not set in .env, fall back to the HF CLI cache file
|
| 16 |
+
if not os.environ.get("HF_TOKEN"):
|
| 17 |
+
cache_path = os.path.expanduser("~/.cache/huggingface/token")
|
| 18 |
+
if os.path.exists(cache_path):
|
| 19 |
+
with open(cache_path, "r") as f:
|
| 20 |
+
os.environ["HF_TOKEN"] = f.read().strip()
|
| 21 |
+
print(f"β Loaded HF_TOKEN from cache: {os.environ['HF_TOKEN'][:8]}...")
|
| 22 |
+
|
| 23 |
+
from baseline.evaluator import run_evaluation
|
| 24 |
+
|
| 25 |
+
def main():
|
| 26 |
+
base_url = os.environ.get("ENV_BASE_URL", "http://localhost:7860")
|
| 27 |
+
scores = run_evaluation(base_url=base_url, verbose=True)
|
| 28 |
+
return scores
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
if __name__ == "__main__":
|
| 32 |
+
main()
|
client.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# client.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Client-side interface for the Cascade Containment environment.
|
| 4 |
+
# Implements the two required abstract methods from EnvClient:
|
| 5 |
+
# _step_payload β serialises ContainmentAction to dict for WebSocket
|
| 6 |
+
# _parse_result β deserialises server response to CityObservation
|
| 7 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
|
| 9 |
+
from openenv.core.env_client import EnvClient
|
| 10 |
+
from openenv.core.client_types import StepResult
|
| 11 |
+
from openenv.core.env_server.types import State
|
| 12 |
+
from models import ContainmentAction, CityObservation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CascadeContainmentEnv(EnvClient[ContainmentAction, CityObservation, State]):
|
| 16 |
+
"""
|
| 17 |
+
Client for the Cascade Containment OpenEnv environment.
|
| 18 |
+
|
| 19 |
+
Async usage:
|
| 20 |
+
async with CascadeContainmentEnv(base_url="http://localhost:7860") as env:
|
| 21 |
+
obs = await env.reset("easy")
|
| 22 |
+
result = await env.step(ContainmentAction(action_type="allocate", district_id=0))
|
| 23 |
+
|
| 24 |
+
Sync usage:
|
| 25 |
+
with CascadeContainmentEnv(base_url="http://localhost:7860").sync() as env:
|
| 26 |
+
obs = env.reset("easy")
|
| 27 |
+
result = env.step(ContainmentAction(action_type="allocate", district_id=0))
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def _step_payload(self, action: ContainmentAction) -> dict:
|
| 31 |
+
"""Serialise ContainmentAction to dict for WebSocket transmission."""
|
| 32 |
+
return {
|
| 33 |
+
"action_type": action.action_type,
|
| 34 |
+
"district_id": action.district_id,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
def _parse_result(self, result: dict) -> StepResult:
|
| 38 |
+
"""Deserialise server response into a typed StepResult."""
|
| 39 |
+
observation = CityObservation(**result["observation"])
|
| 40 |
+
return StepResult(
|
| 41 |
+
observation = observation,
|
| 42 |
+
reward = result.get("reward", 0.0),
|
| 43 |
+
done = result.get("done", False),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
def _parse_state(self, result: dict) -> State:
|
| 47 |
+
"""Deserialise server response into a typed State."""
|
| 48 |
+
return State(
|
| 49 |
+
episode_id = result.get("episode_id", ""),
|
| 50 |
+
step_count = result.get("step_count", 0),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ Connection test (run directly to verify client works) βββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
with CascadeContainmentEnv(base_url="http://localhost:7860").sync() as env:
|
| 58 |
+
obs = env.reset()
|
| 59 |
+
print(f"β Connected successfully")
|
| 60 |
+
print(f" Districts: {len(obs.observation.districts)}")
|
| 61 |
+
print(f" Resources: {obs.observation.available_resources}")
|
| 62 |
+
print(f" Max steps: {obs.observation.max_steps}")
|
| 63 |
+
|
| 64 |
+
result = env.step(ContainmentAction(action_type="allocate", district_id=0))
|
| 65 |
+
print(f" Step reward: {result.reward}")
|
| 66 |
+
print(f"β Client working end-to-end")
|
config/tasks.yaml
ADDED
|
File without changes
|
core/__init__.py
ADDED
|
File without changes
|
core/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (198 Bytes). View file
|
|
|
core/__pycache__/policy_update.cpython-313.pyc
ADDED
|
Binary file (2.11 kB). View file
|
|
|
core/__pycache__/reward.cpython-313.pyc
ADDED
|
Binary file (953 Bytes). View file
|
|
|
core/__pycache__/trajectory.cpython-313.pyc
ADDED
|
Binary file (4.8 kB). View file
|
|
|
core/policy_update.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/policy_update.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# GRPO-style advantage computation and memory update logic.
|
| 4 |
+
# Determines which rollouts are above average and should be reinforced.
|
| 5 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 10 |
+
|
| 11 |
+
from typing import List, Tuple
|
| 12 |
+
from core.trajectory import EpisodicMemory
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def compute_advantage(
|
| 16 |
+
current_reward: float,
|
| 17 |
+
completed_rewards: List[float],
|
| 18 |
+
) -> float:
|
| 19 |
+
"""
|
| 20 |
+
GRPO advantage = R_i - mean(R).
|
| 21 |
+
Positive advantage β this rollout was better than average β reinforce.
|
| 22 |
+
Negative advantage β below average β suppress.
|
| 23 |
+
"""
|
| 24 |
+
if not completed_rewards:
|
| 25 |
+
return 0.0
|
| 26 |
+
mean = sum(completed_rewards) / len(completed_rewards)
|
| 27 |
+
return round(current_reward - mean, 4)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def should_reinforce(advantage: float) -> bool:
|
| 31 |
+
"""
|
| 32 |
+
Reinforce if advantage >= 0 (at or above mean).
|
| 33 |
+
Suppress if below mean.
|
| 34 |
+
"""
|
| 35 |
+
return advantage >= 0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def update_memory(
|
| 39 |
+
memory: EpisodicMemory,
|
| 40 |
+
trajectory: List[dict],
|
| 41 |
+
advantage: float,
|
| 42 |
+
) -> int:
|
| 43 |
+
"""
|
| 44 |
+
If advantage >= 0, store all positive-reward steps from this trajectory
|
| 45 |
+
into episodic memory. Returns number of steps stored.
|
| 46 |
+
|
| 47 |
+
If advantage < 0, memory is unchanged β bad rollout suppressed.
|
| 48 |
+
"""
|
| 49 |
+
if not should_reinforce(advantage):
|
| 50 |
+
return 0
|
| 51 |
+
|
| 52 |
+
stored = 0
|
| 53 |
+
for step_data in trajectory:
|
| 54 |
+
if step_data["reward"] > 0:
|
| 55 |
+
memory.store(step_data["obs"], step_data["action"], step_data["reward"])
|
| 56 |
+
stored += 1
|
| 57 |
+
|
| 58 |
+
return stored
|
core/reward.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/reward.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Reward computation utilities for the GRPO evaluation loop.
|
| 4 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
|
| 6 |
+
import math
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def normalise_score(total_reward: float, steps: int) -> float:
|
| 10 |
+
"""
|
| 11 |
+
Map cumulative reward to [0.0, 1.0] via sigmoid on average reward per step.
|
| 12 |
+
Guaranteed to always return a value strictly within the valid range.
|
| 13 |
+
|
| 14 |
+
Average reward of 0 β 0.5
|
| 15 |
+
Positive average β above 0.5
|
| 16 |
+
Negative average β below 0.5
|
| 17 |
+
"""
|
| 18 |
+
raw = total_reward / max(steps, 1)
|
| 19 |
+
score = 1.0 / (1.0 + math.exp(-raw))
|
| 20 |
+
return round(min(1.0, max(0.0, score)), 4)
|
core/trajectory.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/trajectory.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Episodic memory for GRPO-style simulated learning.
|
| 4 |
+
# Stores high-reward (observation, action, reward) tuples from past rollouts.
|
| 5 |
+
# Retrieved at each step to provide instance-level guidance to the policy.
|
| 6 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 11 |
+
|
| 12 |
+
from typing import List
|
| 13 |
+
from models import ContainmentAction, CityObservation
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class EpisodicMemory:
|
| 17 |
+
"""
|
| 18 |
+
Stores high-reward steps from past rollouts.
|
| 19 |
+
Retrieved by similarity to current observation to guide next rollout.
|
| 20 |
+
|
| 21 |
+
This is the contextual bandit component β the agent gets specific examples:
|
| 22 |
+
"last time infection was [0.45, 0.12] with 7 resources,
|
| 23 |
+
allocating to district 0 earned +0.3"
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, max_size: int = 20):
|
| 27 |
+
self.memories: List[dict] = []
|
| 28 |
+
self.max_size = max_size
|
| 29 |
+
|
| 30 |
+
def store(self, obs: CityObservation, action: ContainmentAction, reward: float):
|
| 31 |
+
"""Store a step only if it earned positive reward."""
|
| 32 |
+
if reward <= 0:
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
self.memories.append({
|
| 36 |
+
"infection_profile": [round(d.reported_infection_rate, 2) for d in obs.districts],
|
| 37 |
+
"resources": obs.available_resources,
|
| 38 |
+
"action_type": action.action_type,
|
| 39 |
+
"district_id": action.district_id,
|
| 40 |
+
"reward": round(reward, 4),
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
# Keep only the highest-reward memories
|
| 44 |
+
self.memories.sort(key=lambda m: m["reward"], reverse=True)
|
| 45 |
+
self.memories = self.memories[:self.max_size]
|
| 46 |
+
|
| 47 |
+
def retrieve(self, obs: CityObservation, top_k: int = 3) -> str:
|
| 48 |
+
"""
|
| 49 |
+
Find stored memories most similar to the current observation.
|
| 50 |
+
Similarity = L1 distance between infection profiles.
|
| 51 |
+
Returns a formatted string for prompt injection.
|
| 52 |
+
"""
|
| 53 |
+
if not self.memories:
|
| 54 |
+
return ""
|
| 55 |
+
|
| 56 |
+
current = [round(d.reported_infection_rate, 2) for d in obs.districts]
|
| 57 |
+
|
| 58 |
+
def l1_distance(memory: dict) -> float:
|
| 59 |
+
profile = memory["infection_profile"]
|
| 60 |
+
if len(profile) != len(current):
|
| 61 |
+
return float("inf")
|
| 62 |
+
return sum(abs(a - b) for a, b in zip(profile, current))
|
| 63 |
+
|
| 64 |
+
ranked = sorted(self.memories, key=l1_distance)
|
| 65 |
+
top = ranked[:top_k]
|
| 66 |
+
|
| 67 |
+
lines = ["Relevant past decisions (from successful rollouts):"]
|
| 68 |
+
for m in top:
|
| 69 |
+
lines.append(
|
| 70 |
+
f" - Profile {m['infection_profile']} | resources={m['resources']}: "
|
| 71 |
+
f"'{m['action_type']}' on district {m['district_id']} "
|
| 72 |
+
f"β reward {m['reward']:+.4f}"
|
| 73 |
+
)
|
| 74 |
+
return "\n".join(lines)
|
| 75 |
+
|
| 76 |
+
def clear(self):
|
| 77 |
+
"""Clear memory between tasks β memories are task-specific."""
|
| 78 |
+
self.memories = []
|
| 79 |
+
|
| 80 |
+
def __len__(self) -> int:
|
| 81 |
+
return len(self.memories)
|
inference.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# inference.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Root-level entry point for hackathon evaluation.
|
| 4 |
+
# Judges run this file to verify reproducible scores across all three tasks.
|
| 5 |
+
#
|
| 6 |
+
# Required environment variables:
|
| 7 |
+
# API_BASE_URL β LLM API endpoint
|
| 8 |
+
# MODEL_NAME β Model identifier for inference
|
| 9 |
+
# HF_TOKEN β Hugging Face / API key
|
| 10 |
+
# ENV_BASE_URL β Running environment server URL (default: localhost:7860)
|
| 11 |
+
#
|
| 12 |
+
# Usage:
|
| 13 |
+
# python inference.py
|
| 14 |
+
#
|
| 15 |
+
# Runtime must be under 20 minutes on 2vCPU / 8GB RAM.
|
| 16 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 21 |
+
|
| 22 |
+
from baseline.run import main
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == "__main__":
|
| 26 |
+
scores = main()
|
| 27 |
+
|
| 28 |
+
# Machine-readable summary for auto-validator
|
| 29 |
+
print("\nSCORES:")
|
| 30 |
+
print(f" easy: {scores.get('easy', 0.0):.4f}")
|
| 31 |
+
print(f" medium: {scores.get('medium', 0.0):.4f}")
|
| 32 |
+
print(f" hard: {scores.get('hard', 0.0):.4f}")
|
| 33 |
+
print(f" average: {scores.get('average', 0.0):.4f}")
|
| 34 |
+
|
| 35 |
+
# Warn and exit non-zero if evaluation failed entirely
|
| 36 |
+
if scores.get("average", 0.0) == 0.0:
|
| 37 |
+
print("\nWARNING: All scores are zero. Check:")
|
| 38 |
+
print(" 1. Is the environment server running?")
|
| 39 |
+
print(f" ENV_BASE_URL = {os.environ.get('ENV_BASE_URL', 'http://localhost:7860')}")
|
| 40 |
+
print(" 2. Are API credentials set?")
|
| 41 |
+
print(f" API_BASE_URL = {os.environ.get('API_BASE_URL', 'NOT SET')}")
|
| 42 |
+
print(f" MODEL_NAME = {os.environ.get('MODEL_NAME', 'NOT SET')}")
|
| 43 |
+
print(f" HF_TOKEN = {'SET' if os.environ.get('HF_TOKEN') else 'NOT SET'}")
|
| 44 |
+
sys.exit(1)
|
models.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from pydantic import Field
|
| 4 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# ββ District-level view (visible to agent) ββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class DistrictObservation:
|
| 11 |
+
district_id: int
|
| 12 |
+
reported_infection_rate: float # Lagged in hard task; real-time otherwise
|
| 13 |
+
growth_rate_hint: float # Noisy signal of true spread rate
|
| 14 |
+
hospital_capacity_remaining: float # 0.0 = overwhelmed, 1.0 = fully available
|
| 15 |
+
population_density: float # Fraction of city population in this district
|
| 16 |
+
tested_recently: bool # True if tested within last 2 days
|
| 17 |
+
restriction_active: bool # True if movement restriction is in place
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ββ District-level ground truth (hidden from agent) βββββββββββββββββββββββββββ
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class DistrictTruth:
|
| 24 |
+
district_id: int
|
| 25 |
+
true_infection_rate: float # Actual infection rate used by grader
|
| 26 |
+
true_spread_rate: float # Fixed per episode; agent never sees this
|
| 27 |
+
hospital_capacity_remaining: float
|
| 28 |
+
population_density: float
|
| 29 |
+
days_since_tested: int
|
| 30 |
+
restriction_active: bool
|
| 31 |
+
deployed_resources: int # Resource units currently active here
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ββ City state (internal world truth; never sent to agent) ββββββββββββββββββββ
|
| 35 |
+
# Not a subclass of State β stored internally in environment.py alongside
|
| 36 |
+
# a plain State(episode_id=..., step_count=...) for OpenEnv tracking.
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class CityState:
|
| 40 |
+
day: int = 0
|
| 41 |
+
available_resources: int = 0
|
| 42 |
+
task_name: str = "easy"
|
| 43 |
+
data_lag_days: int = 0
|
| 44 |
+
max_steps: int = 10
|
| 45 |
+
districts: List[DistrictTruth] = field(default_factory=list)
|
| 46 |
+
infection_history: List[List[float]] = field(default_factory=list)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ββ Action (sent by agent each step) βββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
+
|
| 51 |
+
class ContainmentAction(Action):
|
| 52 |
+
"""
|
| 53 |
+
One action per step. action_type must be one of:
|
| 54 |
+
'test' β Spend 1 resource for accurate district infection data
|
| 55 |
+
'restrict' β Impose movement restriction (penalised if infection is low)
|
| 56 |
+
'allocate' β Deploy 1 resource unit to reduce spread rate this step
|
| 57 |
+
"""
|
| 58 |
+
action_type: str = Field(..., description="One of: 'test', 'restrict', 'allocate'")
|
| 59 |
+
district_id: int = Field(..., description="Target district (0-indexed)")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ββ Observation (received by agent each step) βββββββββββββββββββββββββββββββββ
|
| 63 |
+
# done and reward are inherited from Observation β do not redeclare them.
|
| 64 |
+
|
| 65 |
+
class CityObservation(Observation):
|
| 66 |
+
districts: List[DistrictObservation] = Field(..., description="Per-district state visible to agent")
|
| 67 |
+
available_resources: int = Field(..., description="Resource units remaining this turn")
|
| 68 |
+
current_step: int = Field(..., description="Current step in the episode")
|
| 69 |
+
max_steps: int = Field(..., description="Total steps allowed this episode")
|
| 70 |
+
message: Optional[str] = Field(None, description="Human-readable feedback for debugging")
|
openenv.yaml
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# openenv.yaml
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Environment manifest for Cascade Containment.
|
| 4 |
+
# Read by the OpenEnv auto-validator before any code is executed.
|
| 5 |
+
# Field names and structure must match the OpenEnv spec exactly.
|
| 6 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
name: cascade-containment
|
| 9 |
+
version: "1.0.0"
|
| 10 |
+
description: >
|
| 11 |
+
An RL benchmark for epidemic containment policy under uncertainty.
|
| 12 |
+
A city health authority must allocate limited resources across districts
|
| 13 |
+
to contain a spreading outbreak β with delayed data, resource scarcity,
|
| 14 |
+
and cascading hospital stress. Generalises to wildfire deployment,
|
| 15 |
+
cyberattack isolation, and misinformation containment.
|
| 16 |
+
|
| 17 |
+
author: SST-Team
|
| 18 |
+
license: MIT
|
| 19 |
+
|
| 20 |
+
# ββ Environment Entry Point βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
|
| 22 |
+
server:
|
| 23 |
+
module: server.app
|
| 24 |
+
app: app
|
| 25 |
+
port: 7860
|
| 26 |
+
dockerfile: Dockerfile
|
| 27 |
+
|
| 28 |
+
# ββ Action Space ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
action:
|
| 31 |
+
type: object
|
| 32 |
+
class: ContainmentAction
|
| 33 |
+
fields:
|
| 34 |
+
action_type:
|
| 35 |
+
type: string
|
| 36 |
+
description: "One of: 'test', 'restrict', 'allocate'"
|
| 37 |
+
enum: [test, restrict, allocate]
|
| 38 |
+
district_id:
|
| 39 |
+
type: integer
|
| 40 |
+
description: "Target district index (0-indexed)"
|
| 41 |
+
minimum: 0
|
| 42 |
+
|
| 43 |
+
# ββ Observation Space βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
+
|
| 45 |
+
observation:
|
| 46 |
+
type: object
|
| 47 |
+
class: CityObservation
|
| 48 |
+
fields:
|
| 49 |
+
districts:
|
| 50 |
+
type: array
|
| 51 |
+
description: "Per-district state visible to agent"
|
| 52 |
+
items:
|
| 53 |
+
type: object
|
| 54 |
+
fields:
|
| 55 |
+
district_id:
|
| 56 |
+
type: integer
|
| 57 |
+
reported_infection_rate:
|
| 58 |
+
type: number
|
| 59 |
+
minimum: 0.0
|
| 60 |
+
maximum: 1.0
|
| 61 |
+
growth_rate_hint:
|
| 62 |
+
type: number
|
| 63 |
+
minimum: 0.0
|
| 64 |
+
maximum: 1.0
|
| 65 |
+
hospital_capacity_remaining:
|
| 66 |
+
type: number
|
| 67 |
+
minimum: 0.0
|
| 68 |
+
maximum: 1.0
|
| 69 |
+
population_density:
|
| 70 |
+
type: number
|
| 71 |
+
minimum: 0.0
|
| 72 |
+
maximum: 1.0
|
| 73 |
+
tested_recently:
|
| 74 |
+
type: boolean
|
| 75 |
+
restriction_active:
|
| 76 |
+
type: boolean
|
| 77 |
+
available_resources:
|
| 78 |
+
type: integer
|
| 79 |
+
description: "Resource units remaining this turn"
|
| 80 |
+
current_step:
|
| 81 |
+
type: integer
|
| 82 |
+
max_steps:
|
| 83 |
+
type: integer
|
| 84 |
+
done:
|
| 85 |
+
type: boolean
|
| 86 |
+
reward:
|
| 87 |
+
type: number
|
| 88 |
+
nullable: true
|
| 89 |
+
message:
|
| 90 |
+
type: string
|
| 91 |
+
nullable: true
|
| 92 |
+
|
| 93 |
+
# ββ Tasks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
|
| 95 |
+
tasks:
|
| 96 |
+
- name: easy
|
| 97 |
+
description: "2 districts, 1 outbreak, real-time data, generous resources"
|
| 98 |
+
max_steps: 10
|
| 99 |
+
num_districts: 2
|
| 100 |
+
|
| 101 |
+
- name: medium
|
| 102 |
+
description: "4 districts, 2 simultaneous outbreaks, limited resources"
|
| 103 |
+
max_steps: 15
|
| 104 |
+
num_districts: 4
|
| 105 |
+
|
| 106 |
+
- name: hard
|
| 107 |
+
description: "6 districts, 3-day data lag, scarce resources"
|
| 108 |
+
max_steps: 20
|
| 109 |
+
num_districts: 6
|
| 110 |
+
|
| 111 |
+
# ββ Generalisation Note βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
|
| 113 |
+
tags:
|
| 114 |
+
- reinforcement-learning
|
| 115 |
+
- resource-allocation
|
| 116 |
+
- sequential-decision-making
|
| 117 |
+
- epidemic-containment
|
| 118 |
+
- cascade-dynamics
|
| 119 |
+
- partial-observability
|
| 120 |
+
- openenv
|
pyproject.toml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "cascade-containment"
|
| 3 |
+
version = "1.0.0"
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/requirements.txt
|
| 2 |
+
fastapi>=0.104.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
pydantic>=2.0.0
|
| 5 |
+
openenv-core>=0.2.1
|
| 6 |
+
openai>=1.0.0
|
| 7 |
+
python-dotenv>=0.19.0
|
scripts/test_local.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# scripts/test_local.py
|
| 2 |
+
# Quick sanity check for everything built so far.
|
| 3 |
+
# Run this from the project root: python scripts/test_local.py
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 8 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../server'))
|
| 9 |
+
|
| 10 |
+
from server.environment import EpidemicContainmentEnv
|
| 11 |
+
from models import ContainmentAction
|
| 12 |
+
from server.grader import grade_trajectory, grade_task
|
| 13 |
+
|
| 14 |
+
def test_grader(task_name: str):
|
| 15 |
+
print(f"\n--- Grader test: {task_name} ---")
|
| 16 |
+
env = EpidemicContainmentEnv()
|
| 17 |
+
obs = env.reset(task_name)
|
| 18 |
+
|
| 19 |
+
while not obs.done:
|
| 20 |
+
action = ContainmentAction(action_type="allocate", district_id=0)
|
| 21 |
+
obs = env.step(action)
|
| 22 |
+
|
| 23 |
+
trajectory = env.get_trajectory()
|
| 24 |
+
result = grade_trajectory(trajectory, task_name)
|
| 25 |
+
|
| 26 |
+
print(f" Final score: {result.final_score:.4f}")
|
| 27 |
+
print(f" Containment: {result.containment_score:.4f}")
|
| 28 |
+
print(f" Hospital: {result.hospital_score:.4f}")
|
| 29 |
+
print(f" Efficiency: {result.efficiency_score:.4f}")
|
| 30 |
+
print(f" Speed: {result.speed_score:.4f}")
|
| 31 |
+
print(f" Hospital breached: {result.hospital_breached}")
|
| 32 |
+
print(f" Districts safe: {result.districts_contained}")
|
| 33 |
+
print(f" Steps taken: {result.total_steps}")
|
| 34 |
+
assert 0.0 <= result.final_score <= 1.0, "Score out of range!"
|
| 35 |
+
print(f"β Score in valid range [0.0, 1.0]")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_task(task_name: str):
|
| 39 |
+
print(f"\n{'='*50}")
|
| 40 |
+
print(f"Testing task: {task_name.upper()}")
|
| 41 |
+
print(f"{'='*50}")
|
| 42 |
+
|
| 43 |
+
env = EpidemicContainmentEnv()
|
| 44 |
+
|
| 45 |
+
# Test reset()
|
| 46 |
+
obs = env.reset(task_name)
|
| 47 |
+
print(f"β reset() OK")
|
| 48 |
+
print(f" Districts: {len(obs.districts)}")
|
| 49 |
+
print(f" Resources: {obs.available_resources}")
|
| 50 |
+
print(f" Max steps: {obs.max_steps}")
|
| 51 |
+
print(f" Message: {obs.message}")
|
| 52 |
+
|
| 53 |
+
# Test state()
|
| 54 |
+
state = env.state
|
| 55 |
+
print(f"β state() OK")
|
| 56 |
+
print(f" Episode ID: {state.episode_id}")
|
| 57 |
+
print(f" Step count: {state.step_count}")
|
| 58 |
+
|
| 59 |
+
# Run a few steps with different action types
|
| 60 |
+
actions = [
|
| 61 |
+
ContainmentAction(action_type="test", district_id=0),
|
| 62 |
+
ContainmentAction(action_type="allocate", district_id=0),
|
| 63 |
+
ContainmentAction(action_type="restrict", district_id=1),
|
| 64 |
+
ContainmentAction(action_type="allocate", district_id=0),
|
| 65 |
+
ContainmentAction(action_type="test", district_id=1),
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
total_reward = 0.0
|
| 69 |
+
for i, action in enumerate(actions):
|
| 70 |
+
obs = env.step(action)
|
| 71 |
+
total_reward += obs.reward or 0.0
|
| 72 |
+
print(f" Step {i+1}: {action.action_type:8} β district {action.district_id} "
|
| 73 |
+
f"| reward: {obs.reward:+.4f} | done: {obs.done}")
|
| 74 |
+
if obs.done:
|
| 75 |
+
print(f" Episode ended early: {obs.message}")
|
| 76 |
+
break
|
| 77 |
+
|
| 78 |
+
print(f"β step() OK β total reward so far: {total_reward:+.4f}")
|
| 79 |
+
|
| 80 |
+
# Test invalid action handling
|
| 81 |
+
obs = env.reset(task_name)
|
| 82 |
+
bad_action = ContainmentAction(action_type="invalid_type", district_id=99)
|
| 83 |
+
obs = env.step(bad_action)
|
| 84 |
+
print(f"β Invalid action handled gracefully: {obs.message}")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def run_full_episode(task_name: str):
|
| 88 |
+
"""Run a complete episode to verify terminal conditions work."""
|
| 89 |
+
print(f"\n--- Full episode: {task_name} ---")
|
| 90 |
+
env = EpidemicContainmentEnv()
|
| 91 |
+
obs = env.reset(task_name)
|
| 92 |
+
|
| 93 |
+
total_reward = 0.0
|
| 94 |
+
step = 0
|
| 95 |
+
|
| 96 |
+
while not obs.done:
|
| 97 |
+
# Simple greedy policy: always allocate to district 0
|
| 98 |
+
action = ContainmentAction(action_type="allocate", district_id=0)
|
| 99 |
+
obs = env.step(action)
|
| 100 |
+
total_reward += obs.reward or 0.0
|
| 101 |
+
step += 1
|
| 102 |
+
|
| 103 |
+
print(f" Ended at step {step}: {obs.message}")
|
| 104 |
+
print(f" Total reward: {total_reward:+.4f}")
|
| 105 |
+
print(f"β Full episode completed cleanly")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
print("Running Cascade Containment environment tests...\n")
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
test_task("easy")
|
| 113 |
+
test_task("medium")
|
| 114 |
+
test_task("hard")
|
| 115 |
+
|
| 116 |
+
test_grader("easy")
|
| 117 |
+
test_grader("medium")
|
| 118 |
+
test_grader("hard")
|
| 119 |
+
|
| 120 |
+
run_full_episode("easy")
|
| 121 |
+
run_full_episode("medium")
|
| 122 |
+
run_full_episode("hard")
|
| 123 |
+
|
| 124 |
+
print(f"\n{'='*50}")
|
| 125 |
+
print("β ALL TESTS PASSED")
|
| 126 |
+
print(f"{'='*50}\n")
|
| 127 |
+
|
| 128 |
+
except Exception as e:
|
| 129 |
+
print(f"\nβ TEST FAILED: {e}")
|
| 130 |
+
import traceback
|
| 131 |
+
traceback.print_exc()
|
scripts/validate.py
ADDED
|
File without changes
|
server/__init__.py
ADDED
|
File without changes
|
server/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (211 Bytes). View file
|
|
|
server/__pycache__/app.cpython-313.pyc
ADDED
|
Binary file (715 Bytes). View file
|
|
|
server/__pycache__/constants.cpython-313.pyc
ADDED
|
Binary file (1.34 kB). View file
|
|
|
server/__pycache__/environment.cpython-313.pyc
ADDED
|
Binary file (12.9 kB). View file
|
|
|
server/__pycache__/grader.cpython-313.pyc
ADDED
|
Binary file (5.54 kB). View file
|
|
|
server/__pycache__/utils.cpython-313.pyc
ADDED
|
Binary file (9.26 kB). View file
|
|
|
server/app.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/app.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# FastAPI application entry point for Cascade Containment.
|
| 4 |
+
# Uses a factory function so each WebSocket session gets its own isolated
|
| 5 |
+
# environment instance β required for concurrent session safety.
|
| 6 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 11 |
+
|
| 12 |
+
from openenv.core.env_server import create_app
|
| 13 |
+
from server.environment import EpidemicContainmentEnv
|
| 14 |
+
from models import ContainmentAction, CityObservation
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
app = create_app(
|
| 18 |
+
EpidemicContainmentEnv,
|
| 19 |
+
ContainmentAction,
|
| 20 |
+
CityObservation,
|
| 21 |
+
)
|
server/constants.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# constants.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Single source of truth for all numeric configuration in the environment.
|
| 4 |
+
# Nothing in this file is computed β these are fixed values only.
|
| 5 |
+
# Adjust reward weights here during tuning without touching environment.py.
|
| 6 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 11 |
+
|
| 12 |
+
# ββ Task Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
|
| 14 |
+
TASK_CONFIG = {
|
| 15 |
+
"easy": {
|
| 16 |
+
"num_districts": 2,
|
| 17 |
+
"max_steps": 10,
|
| 18 |
+
"resource_pool": 10, # Resources available per episode
|
| 19 |
+
"data_lag_days": 0, # Agent sees real-time infection data
|
| 20 |
+
},
|
| 21 |
+
"medium": {
|
| 22 |
+
"num_districts": 4,
|
| 23 |
+
"max_steps": 15,
|
| 24 |
+
"resource_pool": 8, # Tighter budget forces real tradeoffs
|
| 25 |
+
"data_lag_days": 0,
|
| 26 |
+
},
|
| 27 |
+
"hard": {
|
| 28 |
+
"num_districts": 6,
|
| 29 |
+
"max_steps": 20,
|
| 30 |
+
"resource_pool": 7, # Scarce resources + delayed data
|
| 31 |
+
"data_lag_days": 3, # Agent sees infection rates from 3 days ago
|
| 32 |
+
},
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ββ Infection Thresholds ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
|
| 38 |
+
INFECTION_THRESHOLD = 0.40 # Above this β district is in danger (penalty fires)
|
| 39 |
+
SAFE_THRESHOLD = 0.20 # Below this β district is contained (bonus fires)
|
| 40 |
+
LOW_THRESHOLD = 0.20 # Below this β restriction is deemed unnecessary
|
| 41 |
+
HOSPITAL_BREACH_POINT = 0.00 # At or below this β hospital has collapsed
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ Spread Mechanics ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
|
| 46 |
+
SPREAD_RATE_MIN = 0.05 # Slowest possible true spread rate per day
|
| 47 |
+
SPREAD_RATE_MAX = 0.20 # Fastest possible true spread rate per day
|
| 48 |
+
GROWTH_HINT_NOISE = 0.03 # Random noise added to growth_rate_hint (Β± value)
|
| 49 |
+
|
| 50 |
+
ALLOCATE_REDUCTION = 0.10 # How much one 'allocate' reduces spread this step
|
| 51 |
+
RESTRICT_REDUCTION = 0.05 # How much one 'restrict' reduces spread per step
|
| 52 |
+
SPILLOVER_RATE = 0.02 # Fraction of infection that spreads to adjacent districts per day
|
| 53 |
+
|
| 54 |
+
RESOURCE_REPLENISH = 3 # Resource units restored at the start of each new day
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ββ Reward Weights ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
|
| 59 |
+
REWARD_INFECTION_PENALTY = -0.50 # Per district above INFECTION_THRESHOLD each step
|
| 60 |
+
REWARD_HOSPITAL_BREACH = -1.00 # Per district with breached hospital capacity
|
| 61 |
+
REWARD_EARLY_CONTAINMENT = +0.50 # Base value; scaled by (1 - step/max_steps)
|
| 62 |
+
REWARD_UNNECESSARY_RESTRICTION = -0.20 # Restricting a district below LOW_THRESHOLD
|
| 63 |
+
REWARD_CORRECT_PRIORITISATION = +0.30 # Allocating to the highest-infected district
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ββ Episode Terminal Conditions βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
|
| 68 |
+
# Episode ends early (success) if ALL districts drop below SAFE_THRESHOLD.
|
| 69 |
+
# Episode ends early (failure) if ANY district's hospital capacity hits HOSPITAL_BREACH_POINT.
|
| 70 |
+
# Otherwise episode runs until max_steps is reached.
|
server/environment.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/environment.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Core RL environment for Cascade Containment.
|
| 4 |
+
# Implements the three-method OpenEnv interface: reset(), step(), state().
|
| 5 |
+
# Maintains two objects: OpenEnv State (episode tracking) and CityState
|
| 6 |
+
# (city simulation). The agent only ever sees CityObservation.
|
| 7 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
import os
|
| 12 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 13 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
|
| 14 |
+
|
| 15 |
+
from uuid import uuid4
|
| 16 |
+
from typing import Optional, Tuple
|
| 17 |
+
|
| 18 |
+
import copy
|
| 19 |
+
from server.grader import TrajectoryStep
|
| 20 |
+
|
| 21 |
+
from openenv.core.env_server.types import State
|
| 22 |
+
from openenv.core.env_server.interfaces import Environment
|
| 23 |
+
|
| 24 |
+
from models import (
|
| 25 |
+
CityState,
|
| 26 |
+
CityObservation,
|
| 27 |
+
ContainmentAction,
|
| 28 |
+
)
|
| 29 |
+
from server.constants import (
|
| 30 |
+
TASK_CONFIG,
|
| 31 |
+
INFECTION_THRESHOLD,
|
| 32 |
+
SAFE_THRESHOLD,
|
| 33 |
+
LOW_THRESHOLD,
|
| 34 |
+
ALLOCATE_REDUCTION,
|
| 35 |
+
RESTRICT_REDUCTION,
|
| 36 |
+
RESOURCE_REPLENISH,
|
| 37 |
+
REWARD_INFECTION_PENALTY,
|
| 38 |
+
REWARD_HOSPITAL_BREACH,
|
| 39 |
+
REWARD_EARLY_CONTAINMENT,
|
| 40 |
+
REWARD_UNNECESSARY_RESTRICTION,
|
| 41 |
+
REWARD_CORRECT_PRIORITISATION,
|
| 42 |
+
)
|
| 43 |
+
from server.utils import (
|
| 44 |
+
build_observation,
|
| 45 |
+
compute_spread,
|
| 46 |
+
get_highest_infected_district,
|
| 47 |
+
all_districts_contained,
|
| 48 |
+
any_hospital_breached,
|
| 49 |
+
districts_above_threshold,
|
| 50 |
+
snapshot_infection_rates,
|
| 51 |
+
generate_episode_id,
|
| 52 |
+
)
|
| 53 |
+
from server.tasks.registry import get_task
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class EpidemicContainmentEnv(Environment):
|
| 57 |
+
"""
|
| 58 |
+
Cascade Containment β an RL environment for epidemic response policy.
|
| 59 |
+
|
| 60 |
+
The agent plays a city health authority making sequential resource
|
| 61 |
+
allocation decisions under uncertainty and delayed feedback.
|
| 62 |
+
|
| 63 |
+
Interface:
|
| 64 |
+
reset(task_name) β CityObservation
|
| 65 |
+
step(action) β CityObservation
|
| 66 |
+
state() β State
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
def __init__(self):
|
| 70 |
+
self._city: CityState = CityState()
|
| 71 |
+
self._state: State = State(episode_id=str(uuid4()), step_count=0)
|
| 72 |
+
self._task_name: str = "easy"
|
| 73 |
+
self._trajectory: list = []
|
| 74 |
+
|
| 75 |
+
# ββ Public Interface ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
|
| 77 |
+
def reset(self, task_name: str = "easy") -> CityObservation:
|
| 78 |
+
"""
|
| 79 |
+
Start a new episode. Initialises city state from the chosen task
|
| 80 |
+
and returns the first observation. Agent sees no reward on reset.
|
| 81 |
+
"""
|
| 82 |
+
self._task_name = task_name
|
| 83 |
+
task = get_task(task_name)
|
| 84 |
+
|
| 85 |
+
# Build fresh city state from task definition
|
| 86 |
+
self._city = task.build_initial_state()
|
| 87 |
+
|
| 88 |
+
# Initialise OpenEnv State for episode tracking
|
| 89 |
+
self._state = State(
|
| 90 |
+
episode_id = generate_episode_id(),
|
| 91 |
+
step_count = 0,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
self._trajectory = []
|
| 95 |
+
|
| 96 |
+
return build_observation(
|
| 97 |
+
state = self._city,
|
| 98 |
+
step_count = self._state.step_count,
|
| 99 |
+
reward = None,
|
| 100 |
+
message = (f"Episode started. Task: {task_name}. "
|
| 101 |
+
f"Districts: {len(self._city.districts)}, "
|
| 102 |
+
f"Steps: {self._city.max_steps} available."),
|
| 103 |
+
done = False,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
def step(self, action: ContainmentAction) -> CityObservation:
|
| 107 |
+
"""
|
| 108 |
+
Apply the agent's action, advance the simulation by one day,
|
| 109 |
+
and return the resulting observation with reward signal.
|
| 110 |
+
"""
|
| 111 |
+
assert self._city is not None, "Call reset() before step()."
|
| 112 |
+
assert self._state is not None, "Call reset() before step()."
|
| 113 |
+
|
| 114 |
+
# ββ 1. Validate action ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
action, message = self._validate_action(action)
|
| 116 |
+
|
| 117 |
+
# ββ 2. Snapshot infection rates into history (before updating) ββββββββ
|
| 118 |
+
self._city.infection_history.append(
|
| 119 |
+
snapshot_infection_rates(self._city.districts)
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# ββ 3. Apply action effect to city state ββββββββββββββββββββββββββββββ
|
| 123 |
+
self._apply_action(action)
|
| 124 |
+
|
| 125 |
+
# ββ 4. Advance spread dynamics by one day βββββββββββββββββββββββββββββ
|
| 126 |
+
new_rates = compute_spread(self._city.districts)
|
| 127 |
+
for i, district in enumerate(self._city.districts):
|
| 128 |
+
district.true_infection_rate = new_rates[i]
|
| 129 |
+
|
| 130 |
+
# ββ 5. Update hospital capacity based on infection levels βββββββββββββ
|
| 131 |
+
self._update_hospital_capacity()
|
| 132 |
+
|
| 133 |
+
# ββ 6. Replenish resources at start of each new day βββββββββββββββββββ
|
| 134 |
+
self._city.available_resources = min(
|
| 135 |
+
self._city.available_resources + RESOURCE_REPLENISH,
|
| 136 |
+
TASK_CONFIG[self._task_name]["resource_pool"], # Cap at task pool size
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# ββ 7. Reset deployed resources (allocate effect lasts one step) ββββββ
|
| 140 |
+
for district in self._city.districts:
|
| 141 |
+
district.deployed_resources = 0
|
| 142 |
+
|
| 143 |
+
# ββ 8. Increment counters βββββββββββββββββββββββββββββββββββββββββββββ
|
| 144 |
+
self._city.day += 1
|
| 145 |
+
self._state.step_count += 1
|
| 146 |
+
|
| 147 |
+
# ββ 9. Compute reward βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
+
reward = self._compute_reward(action)
|
| 149 |
+
|
| 150 |
+
# Record step for grader
|
| 151 |
+
self._trajectory.append(TrajectoryStep(
|
| 152 |
+
step = self._state.step_count,
|
| 153 |
+
city_state = copy.deepcopy(self._city),
|
| 154 |
+
action = action,
|
| 155 |
+
reward = reward,
|
| 156 |
+
done = False,
|
| 157 |
+
))
|
| 158 |
+
|
| 159 |
+
# ββ 10. Check terminal conditions βββββββββββββββββββββββββββββββββββββ
|
| 160 |
+
done, terminal_message = self._check_terminal()
|
| 161 |
+
|
| 162 |
+
# ββ 11. Build and return observation ββββββββββββββββββββββββββββββββββ
|
| 163 |
+
final_message = terminal_message if terminal_message else message
|
| 164 |
+
|
| 165 |
+
return build_observation(
|
| 166 |
+
state = self._city,
|
| 167 |
+
step_count = self._state.step_count,
|
| 168 |
+
reward = reward,
|
| 169 |
+
message = final_message,
|
| 170 |
+
done = done,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
@property
|
| 174 |
+
def state(self) -> State:
|
| 175 |
+
return self._state
|
| 176 |
+
|
| 177 |
+
# ββ Private: Action Handling ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 178 |
+
|
| 179 |
+
def _validate_action(
|
| 180 |
+
self, action: ContainmentAction
|
| 181 |
+
) -> Tuple[ContainmentAction, str]:
|
| 182 |
+
"""
|
| 183 |
+
Validate the action and handle edge cases gracefully.
|
| 184 |
+
Invalid actions are replaced with a safe default rather than crashing β
|
| 185 |
+
this ensures the episode continues even if the LLM produces bad output.
|
| 186 |
+
"""
|
| 187 |
+
valid_types = {"test", "restrict", "allocate"}
|
| 188 |
+
num_districts = len(self._city.districts)
|
| 189 |
+
|
| 190 |
+
# Fix invalid action_type
|
| 191 |
+
if action.action_type not in valid_types:
|
| 192 |
+
return ContainmentAction(action_type="allocate", district_id=0), \
|
| 193 |
+
f"Invalid action_type '{action.action_type}'. Defaulted to allocate on district 0."
|
| 194 |
+
|
| 195 |
+
# Fix out-of-range district_id
|
| 196 |
+
if not (0 <= action.district_id < num_districts):
|
| 197 |
+
safe_id = max(0, min(action.district_id, num_districts - 1))
|
| 198 |
+
return ContainmentAction(action_type=action.action_type, district_id=safe_id), \
|
| 199 |
+
f"district_id {action.district_id} out of range. Clamped to {safe_id}."
|
| 200 |
+
|
| 201 |
+
# Handle resource exhaustion β fall back to restrict (free action)
|
| 202 |
+
if action.action_type in {"test", "allocate"} and self._city.available_resources <= 0:
|
| 203 |
+
return ContainmentAction(action_type="restrict", district_id=action.district_id), \
|
| 204 |
+
f"No resources left. Action changed to restrict on district {action.district_id}."
|
| 205 |
+
|
| 206 |
+
return action, f"{action.action_type.capitalize()} on district {action.district_id}."
|
| 207 |
+
|
| 208 |
+
def _apply_action(self, action: ContainmentAction) -> None:
|
| 209 |
+
"""Apply the validated action's effect to the city state."""
|
| 210 |
+
district = self._city.districts[action.district_id]
|
| 211 |
+
|
| 212 |
+
if action.action_type == "test":
|
| 213 |
+
# Reveal accurate data (handled in build_observation via days_since_tested)
|
| 214 |
+
district.days_since_tested = 0
|
| 215 |
+
self._city.available_resources -= 1
|
| 216 |
+
|
| 217 |
+
elif action.action_type == "restrict":
|
| 218 |
+
# Toggle restriction state
|
| 219 |
+
district.restriction_active = True
|
| 220 |
+
district.days_since_tested += 1
|
| 221 |
+
|
| 222 |
+
elif action.action_type == "allocate":
|
| 223 |
+
# Deploy one resource unit β reduces spread this step via compute_spread
|
| 224 |
+
district.deployed_resources += 1
|
| 225 |
+
self._city.available_resources -= 1
|
| 226 |
+
district.days_since_tested += 1
|
| 227 |
+
|
| 228 |
+
# Increment days_since_tested for all non-targeted districts
|
| 229 |
+
for d in self._city.districts:
|
| 230 |
+
if d.district_id != action.district_id:
|
| 231 |
+
d.days_since_tested += 1
|
| 232 |
+
|
| 233 |
+
# ββ Private: Simulation Mechanics ββββββββββββββββββββββββββββββββββββββββ
|
| 234 |
+
|
| 235 |
+
def _update_hospital_capacity(self) -> None:
|
| 236 |
+
"""
|
| 237 |
+
Reduce hospital capacity in districts above the infection threshold.
|
| 238 |
+
High infection consumes capacity faster. Recovery is slow.
|
| 239 |
+
"""
|
| 240 |
+
for district in self._city.districts:
|
| 241 |
+
if district.true_infection_rate > INFECTION_THRESHOLD:
|
| 242 |
+
# Capacity drains proportional to how far above threshold
|
| 243 |
+
excess = district.true_infection_rate - INFECTION_THRESHOLD
|
| 244 |
+
drain = round(excess * 0.15, 4)
|
| 245 |
+
district.hospital_capacity_remaining = max(
|
| 246 |
+
0.0,
|
| 247 |
+
district.hospital_capacity_remaining - drain
|
| 248 |
+
)
|
| 249 |
+
else:
|
| 250 |
+
# Slow recovery when infection is below threshold
|
| 251 |
+
district.hospital_capacity_remaining = min(
|
| 252 |
+
1.0,
|
| 253 |
+
district.hospital_capacity_remaining + 0.02
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
# ββ Private: Reward Computation βββββββββββββββββββββββββββββββββββββββββββ
|
| 257 |
+
|
| 258 |
+
def _compute_reward(self, action: ContainmentAction) -> float:
|
| 259 |
+
"""
|
| 260 |
+
Compute the shaped reward signal for the current step.
|
| 261 |
+
All five reward terms fire independently each step.
|
| 262 |
+
"""
|
| 263 |
+
reward = 0.0
|
| 264 |
+
|
| 265 |
+
# Term 1: Penalty for each district above danger threshold
|
| 266 |
+
for district in districts_above_threshold(self._city.districts):
|
| 267 |
+
reward += REWARD_INFECTION_PENALTY
|
| 268 |
+
|
| 269 |
+
# Term 2: Heavy penalty for hospital capacity breach
|
| 270 |
+
for district in self._city.districts:
|
| 271 |
+
if district.hospital_capacity_remaining <= 0.0:
|
| 272 |
+
reward += REWARD_HOSPITAL_BREACH
|
| 273 |
+
|
| 274 |
+
# Term 3: Early containment bonus (decays over time)
|
| 275 |
+
for district in self._city.districts:
|
| 276 |
+
if district.true_infection_rate < SAFE_THRESHOLD:
|
| 277 |
+
time_factor = 1 - (self._state.step_count / self._city.max_steps)
|
| 278 |
+
reward += REWARD_EARLY_CONTAINMENT * time_factor
|
| 279 |
+
|
| 280 |
+
# Term 4: Penalty for unnecessary restriction
|
| 281 |
+
if action.action_type == "restrict":
|
| 282 |
+
target = self._city.districts[action.district_id]
|
| 283 |
+
if target.true_infection_rate < LOW_THRESHOLD:
|
| 284 |
+
reward += REWARD_UNNECESSARY_RESTRICTION
|
| 285 |
+
|
| 286 |
+
# Term 5: Bonus for correctly prioritising the most infected district
|
| 287 |
+
if action.action_type == "allocate":
|
| 288 |
+
if action.district_id == get_highest_infected_district(self._city.districts):
|
| 289 |
+
reward += REWARD_CORRECT_PRIORITISATION
|
| 290 |
+
|
| 291 |
+
return round(reward, 4)
|
| 292 |
+
|
| 293 |
+
# ββ Private: Terminal Conditions ββββββββββββββββββββββββββββββββββββββββββ
|
| 294 |
+
|
| 295 |
+
def _check_terminal(self) -> Tuple[bool, Optional[str]]:
|
| 296 |
+
"""
|
| 297 |
+
Check if the episode should end.
|
| 298 |
+
Returns (done, message) β message is None if episode continues.
|
| 299 |
+
"""
|
| 300 |
+
# Success: all districts contained
|
| 301 |
+
if all_districts_contained(self._city.districts):
|
| 302 |
+
return True, "β Outbreak contained. All districts below safe threshold."
|
| 303 |
+
|
| 304 |
+
# Failure: hospital collapse
|
| 305 |
+
if any_hospital_breached(self._city.districts):
|
| 306 |
+
return True, "β Hospital capacity breached. Episode failed."
|
| 307 |
+
|
| 308 |
+
# Natural end: max steps reached
|
| 309 |
+
if self._state.step_count >= self._city.max_steps:
|
| 310 |
+
return True, f"Episode complete. {self._city.max_steps} steps reached."
|
| 311 |
+
|
| 312 |
+
return False, None
|
| 313 |
+
|
| 314 |
+
def get_trajectory(self) -> list:
|
| 315 |
+
"""Return the recorded trajectory for the current episode."""
|
| 316 |
+
return self._trajectory
|
server/grader.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/grader.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Deterministic scorer for completed Cascade Containment episodes.
|
| 4 |
+
# Called by baseline/evaluator.py after each full episode.
|
| 5 |
+
# Always returns a float in [0.0, 1.0].
|
| 6 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 11 |
+
|
| 12 |
+
from typing import List, Tuple
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
|
| 15 |
+
from models import CityState, ContainmentAction
|
| 16 |
+
from server.constants import (
|
| 17 |
+
INFECTION_THRESHOLD,
|
| 18 |
+
SAFE_THRESHOLD,
|
| 19 |
+
HOSPITAL_BREACH_POINT,
|
| 20 |
+
TASK_CONFIG,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ββ Trajectory Record βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class TrajectoryStep:
|
| 28 |
+
"""
|
| 29 |
+
A single recorded step in an episode.
|
| 30 |
+
Stored by environment.py and passed to the grader after episode ends.
|
| 31 |
+
"""
|
| 32 |
+
step: int
|
| 33 |
+
city_state: CityState # Hidden ground truth at this step
|
| 34 |
+
action: ContainmentAction # What the agent did
|
| 35 |
+
reward: float # Reward received
|
| 36 |
+
done: bool # Was this the final step
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ββ Grader Score Breakdown ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class GradeResult:
|
| 43 |
+
"""
|
| 44 |
+
Full scoring breakdown for one episode.
|
| 45 |
+
The final_score is what the evaluator reports.
|
| 46 |
+
"""
|
| 47 |
+
final_score: float # Weighted composite: 0.0 to 1.0
|
| 48 |
+
containment_score: float # How well infection was kept below threshold
|
| 49 |
+
hospital_score: float # How well hospital capacity was preserved
|
| 50 |
+
efficiency_score: float # How well resources were directed
|
| 51 |
+
speed_score: float # How quickly the episode was resolved
|
| 52 |
+
hospital_breached: bool # Whether any hospital collapse occurred
|
| 53 |
+
districts_contained: int # How many districts ended below safe threshold
|
| 54 |
+
total_steps: int # Steps taken before episode ended
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ββ Main Grader βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
|
| 59 |
+
def grade_trajectory(
|
| 60 |
+
trajectory: List[TrajectoryStep],
|
| 61 |
+
task_name: str,
|
| 62 |
+
) -> GradeResult:
|
| 63 |
+
"""
|
| 64 |
+
Score a completed episode trajectory.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
trajectory: Ordered list of TrajectoryStep from one full episode.
|
| 68 |
+
task_name: "easy", "medium", or "hard" β affects scoring strictness.
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
GradeResult with final_score in [0.0, 1.0] and full breakdown.
|
| 72 |
+
"""
|
| 73 |
+
if not trajectory:
|
| 74 |
+
return GradeResult(
|
| 75 |
+
final_score = 0.0,
|
| 76 |
+
containment_score = 0.0,
|
| 77 |
+
hospital_score = 0.0,
|
| 78 |
+
efficiency_score = 0.0,
|
| 79 |
+
speed_score = 0.0,
|
| 80 |
+
hospital_breached = False,
|
| 81 |
+
districts_contained = 0,
|
| 82 |
+
total_steps = 0,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
config = TASK_CONFIG[task_name]
|
| 86 |
+
num_districts = config["num_districts"]
|
| 87 |
+
max_steps = config["max_steps"]
|
| 88 |
+
total_steps = len(trajectory)
|
| 89 |
+
|
| 90 |
+
# ββ Component 1: Containment Score βββββββββββββββββββββββββββββββββββββββ
|
| 91 |
+
# Fraction of district-days that stayed below infection threshold.
|
| 92 |
+
# Perfect agent = 1.0 (no district ever exceeded threshold).
|
| 93 |
+
|
| 94 |
+
total_district_days = total_steps * num_districts
|
| 95 |
+
safe_district_days = 0
|
| 96 |
+
|
| 97 |
+
for step in trajectory:
|
| 98 |
+
for district in step.city_state.districts:
|
| 99 |
+
if district.true_infection_rate <= INFECTION_THRESHOLD:
|
| 100 |
+
safe_district_days += 1
|
| 101 |
+
|
| 102 |
+
containment_score = safe_district_days / total_district_days
|
| 103 |
+
|
| 104 |
+
# ββ Component 2: Hospital Score βββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
# Measures how well hospital capacity was preserved across the episode.
|
| 106 |
+
# Any breach = heavy penalty. Near-breach is also penalised proportionally.
|
| 107 |
+
|
| 108 |
+
hospital_breached = False
|
| 109 |
+
total_capacity_preserved = 0.0
|
| 110 |
+
|
| 111 |
+
for step in trajectory:
|
| 112 |
+
for district in step.city_state.districts:
|
| 113 |
+
if district.hospital_capacity_remaining <= HOSPITAL_BREACH_POINT:
|
| 114 |
+
hospital_breached = True
|
| 115 |
+
total_capacity_preserved += district.hospital_capacity_remaining
|
| 116 |
+
|
| 117 |
+
avg_capacity = total_capacity_preserved / total_district_days
|
| 118 |
+
hospital_score = avg_capacity * (0.3 if hospital_breached else 1.0)
|
| 119 |
+
hospital_score = round(min(1.0, max(0.0, hospital_score)), 4)
|
| 120 |
+
|
| 121 |
+
# ββ Component 3: Efficiency Score ββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
# Fraction of allocate/test actions that targeted districts above threshold.
|
| 123 |
+
# Rewards directing resources where they're actually needed.
|
| 124 |
+
|
| 125 |
+
resource_actions = [
|
| 126 |
+
s for s in trajectory
|
| 127 |
+
if s.action.action_type in {"allocate", "test"}
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
if resource_actions:
|
| 131 |
+
correct_actions = 0
|
| 132 |
+
for step in resource_actions:
|
| 133 |
+
target = step.city_state.districts[step.action.district_id]
|
| 134 |
+
if target.true_infection_rate > INFECTION_THRESHOLD:
|
| 135 |
+
correct_actions += 1
|
| 136 |
+
efficiency_score = correct_actions / len(resource_actions)
|
| 137 |
+
else:
|
| 138 |
+
efficiency_score = 0.5 # Neutral if no resource actions taken
|
| 139 |
+
|
| 140 |
+
# ββ Component 4: Speed Score ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 141 |
+
# Rewards finishing faster than max_steps.
|
| 142 |
+
# If episode ran to max_steps, speed_score = 0.0.
|
| 143 |
+
# If contained in half the steps, speed_score = 0.5. Etc.
|
| 144 |
+
|
| 145 |
+
last_step = trajectory[-1]
|
| 146 |
+
if last_step.done and not hospital_breached:
|
| 147 |
+
speed_score = round(1.0 - (total_steps / max_steps), 4)
|
| 148 |
+
speed_score = max(0.0, speed_score)
|
| 149 |
+
else:
|
| 150 |
+
speed_score = 0.0 # No speed bonus for failed or incomplete episodes
|
| 151 |
+
|
| 152 |
+
# ββ Final Weighted Score ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 153 |
+
# Weights reflect judging priorities:
|
| 154 |
+
# containment = primary signal
|
| 155 |
+
# hospital = safety constraint
|
| 156 |
+
# efficiency = quality differentiator
|
| 157 |
+
# speed = tiebreaker
|
| 158 |
+
|
| 159 |
+
final_score = (
|
| 160 |
+
containment_score * 0.45 +
|
| 161 |
+
hospital_score * 0.30 +
|
| 162 |
+
efficiency_score * 0.15 +
|
| 163 |
+
speed_score * 0.10
|
| 164 |
+
)
|
| 165 |
+
final_score = round(min(1.0, max(0.0, final_score)), 4)
|
| 166 |
+
|
| 167 |
+
# ββ Final district count ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
final_step = trajectory[-1]
|
| 169 |
+
districts_contained = sum(
|
| 170 |
+
1 for d in final_step.city_state.districts
|
| 171 |
+
if d.true_infection_rate < SAFE_THRESHOLD
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
return GradeResult(
|
| 175 |
+
final_score = final_score,
|
| 176 |
+
containment_score = round(containment_score, 4),
|
| 177 |
+
hospital_score = hospital_score,
|
| 178 |
+
efficiency_score = round(efficiency_score, 4),
|
| 179 |
+
speed_score = speed_score,
|
| 180 |
+
hospital_breached = hospital_breached,
|
| 181 |
+
districts_contained = districts_contained,
|
| 182 |
+
total_steps = total_steps,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ββ Convenience: Grade a Single Score to 0.0β1.0 βββββββββββββββββββββββββββββ
|
| 187 |
+
|
| 188 |
+
def grade_task(
|
| 189 |
+
trajectory: List[TrajectoryStep],
|
| 190 |
+
task_name: str,
|
| 191 |
+
) -> float:
|
| 192 |
+
"""
|
| 193 |
+
Thin wrapper that returns just the final_score float.
|
| 194 |
+
Used by baseline/evaluator.py for clean score reporting.
|
| 195 |
+
"""
|
| 196 |
+
result = grade_trajectory(trajectory, task_name)
|
| 197 |
+
return result.final_score
|
server/tasks/__init__.py
ADDED
|
File without changes
|
server/tasks/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (217 Bytes). View file
|
|
|
server/tasks/__pycache__/base.cpython-313.pyc
ADDED
|
Binary file (1.63 kB). View file
|
|
|
server/tasks/__pycache__/registry.cpython-313.pyc
ADDED
|
Binary file (1.7 kB). View file
|
|
|
server/tasks/__pycache__/task_easy.cpython-313.pyc
ADDED
|
Binary file (1.95 kB). View file
|
|
|
server/tasks/__pycache__/task_hard.cpython-313.pyc
ADDED
|
Binary file (2.09 kB). View file
|
|
|
server/tasks/__pycache__/task_medium.cpython-313.pyc
ADDED
|
Binary file (1.97 kB). View file
|
|
|
server/tasks/base.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/tasks/base.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Abstract base class that every task must implement.
|
| 4 |
+
# Defines the interface environment.py uses to initialise any episode.
|
| 5 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
|
| 10 |
+
|
| 11 |
+
from abc import ABC, abstractmethod
|
| 12 |
+
from models import CityState
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class BaseTask(ABC):
|
| 16 |
+
|
| 17 |
+
# These must be defined by every subclass
|
| 18 |
+
name: str
|
| 19 |
+
num_districts: int
|
| 20 |
+
max_steps: int
|
| 21 |
+
resource_pool: int
|
| 22 |
+
data_lag_days: int
|
| 23 |
+
|
| 24 |
+
@abstractmethod
|
| 25 |
+
def build_initial_state(self) -> CityState:
|
| 26 |
+
"""
|
| 27 |
+
Return a freshly initialised CityState for a new episode.
|
| 28 |
+
Called by environment.py at the start of every reset().
|
| 29 |
+
"""
|
| 30 |
+
...
|
| 31 |
+
|
| 32 |
+
def __repr__(self) -> str:
|
| 33 |
+
return f"Task(name={self.name}, districts={self.num_districts}, steps={self.max_steps})"
|
server/tasks/registry.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# server/tasks/registry.py
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Maps task name strings to their classes.
|
| 4 |
+
# This is what environment.py and the evaluator use to select a task.
|
| 5 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) # reaches server/
|
| 10 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..')) # reaches project root
|
| 11 |
+
|
| 12 |
+
from server.tasks.task_easy import EasyTask
|
| 13 |
+
from server.tasks.task_medium import MediumTask
|
| 14 |
+
from server.tasks.task_hard import HardTask
|
| 15 |
+
from server.tasks.base import BaseTask
|
| 16 |
+
from typing import Dict, Type
|
| 17 |
+
|
| 18 |
+
TASK_REGISTRY: Dict[str, Type[BaseTask]] = {
|
| 19 |
+
"easy": EasyTask,
|
| 20 |
+
"medium": MediumTask,
|
| 21 |
+
"hard": HardTask,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_task(name: str) -> BaseTask:
|
| 26 |
+
"""
|
| 27 |
+
Return an instantiated task object by name.
|
| 28 |
+
Raises ValueError for unrecognised task names.
|
| 29 |
+
|
| 30 |
+
Usage:
|
| 31 |
+
task = get_task("medium")
|
| 32 |
+
initial_state = task.build_initial_state()
|
| 33 |
+
"""
|
| 34 |
+
if name not in TASK_REGISTRY:
|
| 35 |
+
raise ValueError(
|
| 36 |
+
f"Unknown task '{name}'. Valid options: {list(TASK_REGISTRY.keys())}"
|
| 37 |
+
)
|
| 38 |
+
return TASK_REGISTRY[name]()
|