RohitChandramouli6618 commited on
Commit
1c03487
Β·
0 Parent(s):

Initial submission: Cascade Containment

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitignore +0 -0
  2. Dockerfile +27 -0
  3. README.md +53 -0
  4. __init__.py +0 -0
  5. __pycache__/client.cpython-313.pyc +0 -0
  6. __pycache__/models.cpython-313.pyc +0 -0
  7. baseline/__init__.py +0 -0
  8. baseline/__pycache__/__init__.cpython-313.pyc +0 -0
  9. baseline/__pycache__/evaluator.cpython-313.pyc +0 -0
  10. baseline/__pycache__/policy.cpython-313.pyc +0 -0
  11. baseline/evaluator.py +197 -0
  12. baseline/policy.py +154 -0
  13. baseline/run.py +32 -0
  14. client.py +66 -0
  15. config/tasks.yaml +0 -0
  16. core/__init__.py +0 -0
  17. core/__pycache__/__init__.cpython-313.pyc +0 -0
  18. core/__pycache__/policy_update.cpython-313.pyc +0 -0
  19. core/__pycache__/reward.cpython-313.pyc +0 -0
  20. core/__pycache__/trajectory.cpython-313.pyc +0 -0
  21. core/policy_update.py +58 -0
  22. core/reward.py +20 -0
  23. core/trajectory.py +81 -0
  24. inference.py +44 -0
  25. models.py +70 -0
  26. openenv.yaml +120 -0
  27. pyproject.toml +3 -0
  28. requirements.txt +7 -0
  29. scripts/test_local.py +131 -0
  30. scripts/validate.py +0 -0
  31. server/__init__.py +0 -0
  32. server/__pycache__/__init__.cpython-313.pyc +0 -0
  33. server/__pycache__/app.cpython-313.pyc +0 -0
  34. server/__pycache__/constants.cpython-313.pyc +0 -0
  35. server/__pycache__/environment.cpython-313.pyc +0 -0
  36. server/__pycache__/grader.cpython-313.pyc +0 -0
  37. server/__pycache__/utils.cpython-313.pyc +0 -0
  38. server/app.py +21 -0
  39. server/constants.py +70 -0
  40. server/environment.py +316 -0
  41. server/grader.py +197 -0
  42. server/tasks/__init__.py +0 -0
  43. server/tasks/__pycache__/__init__.cpython-313.pyc +0 -0
  44. server/tasks/__pycache__/base.cpython-313.pyc +0 -0
  45. server/tasks/__pycache__/registry.cpython-313.pyc +0 -0
  46. server/tasks/__pycache__/task_easy.cpython-313.pyc +0 -0
  47. server/tasks/__pycache__/task_hard.cpython-313.pyc +0 -0
  48. server/tasks/__pycache__/task_medium.cpython-313.pyc +0 -0
  49. server/tasks/base.py +33 -0
  50. 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]()