RohitChandramouli6618 commited on
Commit
0092607
·
1 Parent(s): a209549

Fix All: Cleaned All Files

Browse files
Dockerfile CHANGED
@@ -1,9 +1,3 @@
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
@@ -23,4 +17,4 @@ ENV PYTHONPATH="/app:/app/server"
23
 
24
  EXPOSE 7860
25
 
26
- CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
 
 
 
 
1
  FROM python:3.11-slim
2
 
3
  WORKDIR /app
 
17
 
18
  EXPOSE 7860
19
 
20
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
baseline/evaluator.py CHANGED
@@ -1,4 +1,3 @@
1
- # baseline/evaluator.py
2
  import os, sys, time
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
  from typing import List, Tuple, Any
@@ -13,20 +12,17 @@ from core.policy_update import compute_advantage, update_memory
13
  import requests as http_requests
14
 
15
  N_ROLLOUTS = {
16
- "easy": 3, # Always solves cleanly in 1-2 rollouts, no variance to learn from
17
- "medium": 4, # Needs GRPO learning signal to stabilise
18
- "hard": 4, # Needs GRPO learning signal to stabilise
19
  }
20
 
21
 
22
  def build_prompt_with_memory(obs: CityObservation, memory: EpisodicMemory) -> str:
23
- from baseline.policy import build_prompt
24
  base = build_prompt(obs)
25
  memory_block = memory.retrieve(obs)
26
-
27
  if not memory_block:
28
  return base
29
-
30
  injection = "\n" + memory_block + "\nApply these lessons to your current decision.\n"
31
  return base.replace("Your response:", injection + "Your response:")
32
 
@@ -129,8 +125,10 @@ def run_task_grpo(
129
  if verbose:
130
  mean = sum(completed_rewards[:-1]) / max(len(completed_rewards) - 1, 1) \
131
  if len(completed_rewards) > 1 else total_reward
132
- print(f" → Advantage: {advantage:+.4f} | "
133
- + (f" Stored {stored} steps" if stored > 0 else "↓ Suppressed"))
 
 
134
 
135
  all_rewards = [r[0] for r in rollouts]
136
  mean_reward = sum(all_rewards) / len(all_rewards)
@@ -188,4 +186,4 @@ def run_evaluation(base_url: str = "http://localhost:7860", verbose: bool = True
188
  print(f" Time: {elapsed}s")
189
  print("="*52 + "\n")
190
 
191
- return scores
 
 
1
  import os, sys, time
2
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
3
  from typing import List, Tuple, Any
 
12
  import requests as http_requests
13
 
14
  N_ROLLOUTS = {
15
+ "easy": 3,
16
+ "medium": 4,
17
+ "hard": 4,
18
  }
19
 
20
 
21
  def build_prompt_with_memory(obs: CityObservation, memory: EpisodicMemory) -> str:
 
22
  base = build_prompt(obs)
23
  memory_block = memory.retrieve(obs)
 
24
  if not memory_block:
25
  return base
 
26
  injection = "\n" + memory_block + "\nApply these lessons to your current decision.\n"
27
  return base.replace("Your response:", injection + "Your response:")
28
 
 
125
  if verbose:
126
  mean = sum(completed_rewards[:-1]) / max(len(completed_rewards) - 1, 1) \
127
  if len(completed_rewards) > 1 else total_reward
128
+ print(
129
+ f" Advantage: {advantage:+.4f} | "
130
+ + (f"↑ Stored {stored} steps" if stored > 0 else "↓ Suppressed")
131
+ )
132
 
133
  all_rewards = [r[0] for r in rollouts]
134
  mean_reward = sum(all_rewards) / len(all_rewards)
 
186
  print(f" Time: {elapsed}s")
187
  print("="*52 + "\n")
188
 
189
+ return scores
baseline/policy.py CHANGED
@@ -1,4 +1,3 @@
1
- # baseline/policy.py
2
  import os, sys, json, re
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
  from openai import OpenAI
@@ -51,7 +50,7 @@ def build_prompt(obs: CityObservation) -> str:
51
  hosp_status = "hospital OK"
52
 
53
  if has_data_lag:
54
- # Pre-compute estimated current infection don't ask LLM to do math
55
  estimated = round(min(1.0, d.reported_infection_rate + 3 * d.growth_rate_hint), 2)
56
  if estimated > 0.4:
57
  est_status = "🔴 EST.CRITICAL"
@@ -142,7 +141,7 @@ def call_llm(prompt: str, client: OpenAI) -> str:
142
  },
143
  {"role": "user", "content": prompt}
144
  ],
145
- max_tokens = 60, # increased to allow brief reasoning + JSON
146
  temperature = 0.1,
147
  )
148
  return (response.choices[0].message.content or "").strip()
@@ -169,4 +168,4 @@ def parse_action(response: str, num_districts: int) -> ContainmentAction:
169
  def get_action(obs: CityObservation, client: OpenAI) -> ContainmentAction:
170
  prompt = build_prompt(obs)
171
  response = call_llm(prompt, client)
172
- return parse_action(response, len(obs.districts))
 
 
1
  import os, sys, json, re
2
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
3
  from openai import OpenAI
 
50
  hosp_status = "hospital OK"
51
 
52
  if has_data_lag:
53
+ # pre-compute estimated current infection so the LLM doesn't have to do the math
54
  estimated = round(min(1.0, d.reported_infection_rate + 3 * d.growth_rate_hint), 2)
55
  if estimated > 0.4:
56
  est_status = "🔴 EST.CRITICAL"
 
141
  },
142
  {"role": "user", "content": prompt}
143
  ],
144
+ max_tokens = 60,
145
  temperature = 0.1,
146
  )
147
  return (response.choices[0].message.content or "").strip()
 
168
  def get_action(obs: CityObservation, client: OpenAI) -> ContainmentAction:
169
  prompt = build_prompt(obs)
170
  response = call_llm(prompt, client)
171
+ return parse_action(response, len(obs.districts))
baseline/run.py CHANGED
@@ -1,32 +1,24 @@
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()
 
 
 
 
 
 
 
1
  import os
2
  import sys
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
 
5
  from dotenv import load_dotenv
6
  load_dotenv()
 
7
 
8
+ # Fall back to the HuggingFace CLI token cache if HF_TOKEN isn't in .env
9
  if not os.environ.get("HF_TOKEN"):
10
  cache_path = os.path.expanduser("~/.cache/huggingface/token")
11
  if os.path.exists(cache_path):
12
  with open(cache_path, "r") as f:
13
  os.environ["HF_TOKEN"] = f.read().strip()
 
14
 
15
  from baseline.evaluator import run_evaluation
16
 
17
+
18
  def main():
19
  base_url = os.environ.get("ENV_BASE_URL", "http://localhost:7860")
20
+ return run_evaluation(base_url=base_url, verbose=True)
 
21
 
22
 
23
  if __name__ == "__main__":
24
+ main()
client.py CHANGED
@@ -1,11 +1,3 @@
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
@@ -14,53 +6,49 @@ from models import ContainmentAction, CityObservation
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")
 
 
 
 
 
 
 
 
 
1
  from openenv.core.env_client import EnvClient
2
  from openenv.core.client_types import StepResult
3
  from openenv.core.env_server.types import State
 
6
 
7
  class CascadeContainmentEnv(EnvClient[ContainmentAction, CityObservation, State]):
8
  """
9
+ Client for the Cascade Containment environment.
10
 
11
+ Async:
12
  async with CascadeContainmentEnv(base_url="http://localhost:7860") as env:
13
  obs = await env.reset("easy")
14
  result = await env.step(ContainmentAction(action_type="allocate", district_id=0))
15
 
16
+ Sync:
17
  with CascadeContainmentEnv(base_url="http://localhost:7860").sync() as env:
18
  obs = env.reset("easy")
19
  result = env.step(ContainmentAction(action_type="allocate", district_id=0))
20
  """
21
 
22
  def _step_payload(self, action: ContainmentAction) -> dict:
 
23
  return {
24
  "action_type": action.action_type,
25
  "district_id": action.district_id,
26
  }
27
 
28
  def _parse_result(self, result: dict) -> StepResult:
 
29
  observation = CityObservation(**result["observation"])
30
  return StepResult(
31
  observation = observation,
32
  reward = result.get("reward", 0.0),
33
  done = result.get("done", False),
34
  )
35
+
36
  def _parse_state(self, result: dict) -> State:
 
37
  return State(
38
+ episode_id = result.get("episode_id", ""),
39
+ step_count = result.get("step_count", 0),
40
  )
41
 
42
 
 
 
43
  if __name__ == "__main__":
44
+ # Quick smoke test — run directly to verify the client connects and steps correctly.
45
  with CascadeContainmentEnv(base_url="http://localhost:7860").sync() as env:
46
  obs = env.reset()
47
+ print(f"✓ Connected")
48
+ print(f" Districts: {len(obs.observation.districts)}")
49
+ print(f" Resources: {obs.observation.available_resources}")
50
+ print(f" Max steps: {obs.observation.max_steps}")
51
 
52
  result = env.step(ContainmentAction(action_type="allocate", district_id=0))
53
  print(f" Step reward: {result.reward}")
54
+ print(f"✓ End-to-end OK")
core/policy_update.py CHANGED
@@ -1,25 +1,19 @@
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
@@ -28,11 +22,8 @@ def compute_advantage(
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.5 # allow small negative margin to encourage exploration
36
 
37
 
38
  def update_memory(
@@ -41,10 +32,9 @@ def update_memory(
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
@@ -55,4 +45,4 @@ def update_memory(
55
  memory.store(step_data["obs"], step_data["action"], step_data["reward"])
56
  stored += 1
57
 
58
- return stored
 
 
 
 
 
 
 
1
  import os
2
  import sys
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
 
5
+ from typing import List
6
  from core.trajectory import EpisodicMemory
7
 
8
 
9
  def compute_advantage(
10
+ current_reward: float,
11
  completed_rewards: List[float],
12
  ) -> float:
13
  """
14
+ GRPO advantage = R_i - mean(R_completed).
15
+ Positive means this rollout was better than average; negative means worse.
16
+ Returns 0.0 on the first rollout where there's nothing to compare against.
17
  """
18
  if not completed_rewards:
19
  return 0.0
 
22
 
23
 
24
  def should_reinforce(advantage: float) -> bool:
25
+ # Small negative margin is allowed to encourage exploration on borderline rollouts.
26
+ return advantage > -0.5
 
 
 
27
 
28
 
29
  def update_memory(
 
32
  advantage: float,
33
  ) -> int:
34
  """
35
+ Store positive-reward steps from this trajectory into episodic memory
36
+ if the rollout was at or above average (advantage > threshold).
37
+ Returns the number of steps stored. Bad rollouts leave memory unchanged.
 
38
  """
39
  if not should_reinforce(advantage):
40
  return 0
 
45
  memory.store(step_data["obs"], step_data["action"], step_data["reward"])
46
  stored += 1
47
 
48
+ return stored
core/reward.py CHANGED
@@ -1,18 +1,10 @@
1
- # core/reward.py
2
- # ─────────────────────────────────────────────────────────────────────────────
3
- # Reward computation utilities for the GRPO evaluation loop.
4
- # ─────────────────────────────────────────────────────────────────────────────
5
-
6
- import math
7
-
8
  def normalise_score(total_reward: float, steps: int, num_districts: int = 2) -> float:
9
  """
10
- Linear normalization with task-aware worst case.
11
- Worst case per step = num_districts × (-0.5 infection) + num_districts × (-1.0 breach)
12
- Best case per step = num_districts × (+0.5 containment) + 0.30 prioritisation
13
  """
14
  avg = total_reward / max(steps, 1)
15
- worst = num_districts * (-1.5) # -0.5 infection + -1.0 breach per district
16
  best = num_districts * (0.5) + 0.3
17
  score = (avg - worst) / (best - worst)
18
- return round(min(1.0, max(0.0, score)), 4)
 
 
 
 
 
 
 
 
1
  def normalise_score(total_reward: float, steps: int, num_districts: int = 2) -> float:
2
  """
3
+ Fallback score when the /grade endpoint is unreachable.
4
+ Maps cumulative reward linearly into [0, 1] using task-aware best/worst bounds.
 
5
  """
6
  avg = total_reward / max(steps, 1)
7
+ worst = num_districts * (-1.5) # -0.5 infection + -1.0 breach per district
8
  best = num_districts * (0.5) + 0.3
9
  score = (avg - worst) / (best - worst)
10
+ return round(min(1.0, max(0.0, score)), 4)
core/trajectory.py CHANGED
@@ -1,4 +1,3 @@
1
- # core/trajectory.py
2
  import os, sys
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
  from typing import List
@@ -7,11 +6,11 @@ from models import ContainmentAction, CityObservation
7
 
8
  class EpisodicMemory:
9
  """
10
- Stores high-reward steps from past rollouts.
11
- Retrieves by similarity to guide the next rollout.
12
-
13
- Improvement: stores resource level and episode phase alongside infection
14
- profile, and retrieves top_k=5 instead of 3 for richer context.
15
  """
16
 
17
  def __init__(self, max_size: int = 20):
@@ -19,11 +18,9 @@ class EpisodicMemory:
19
  self.max_size = max_size
20
 
21
  def store(self, obs: CityObservation, action: ContainmentAction, reward: float):
22
- """Store a step only if it earned meaningful positive reward."""
23
- if reward < -0.3: # stricter threshold — only store clearly positive steps
24
  return
25
 
26
- # Phase: early/mid/late episode
27
  phase = "early" if obs.current_step <= obs.max_steps // 3 else \
28
  "mid" if obs.current_step <= 2 * obs.max_steps // 3 else "late"
29
 
@@ -34,21 +31,16 @@ class EpisodicMemory:
34
  "action_type": action.action_type,
35
  "district_id": action.district_id,
36
  "reward": round(reward, 4),
37
- # Store which district was highest at this step (useful for pattern learning)
38
- "highest_district": max(range(len(obs.districts)),
39
- key=lambda i: obs.districts[i].reported_infection_rate),
 
40
  })
41
 
42
- # Keep only the highest-reward memories
43
  self.memories.sort(key=lambda m: m["reward"], reverse=True)
44
  self.memories = self.memories[:self.max_size]
45
 
46
  def retrieve(self, obs: CityObservation, top_k: int = 5) -> str:
47
- """
48
- Find stored memories most similar to the current observation.
49
- Similarity = L1 distance between infection profiles.
50
- Returns a formatted string for prompt injection.
51
- """
52
  if not self.memories:
53
  return ""
54
 
@@ -60,8 +52,7 @@ class EpisodicMemory:
60
  profile = memory["infection_profile"]
61
  if len(profile) != len(current):
62
  return float("inf")
63
- l1 = sum(abs(a - b) for a, b in zip(profile, current))
64
- # Slight preference for matching episode phase
65
  phase_bonus = 0.0 if memory.get("phase") == phase else 0.1
66
  return l1 + phase_bonus
67
 
@@ -80,4 +71,4 @@ class EpisodicMemory:
80
  self.memories = []
81
 
82
  def __len__(self) -> int:
83
- return len(self.memories)
 
 
1
  import os, sys
2
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
3
  from typing import List
 
6
 
7
  class EpisodicMemory:
8
  """
9
+ Stores high-reward steps from past rollouts and retrieves similar
10
+ past decisions to guide the next rollout via prompt injection.
11
+
12
+ Similarity is measured by L1 distance on infection profiles,
13
+ with a small bonus for matching the episode phase (early/mid/late).
14
  """
15
 
16
  def __init__(self, max_size: int = 20):
 
18
  self.max_size = max_size
19
 
20
  def store(self, obs: CityObservation, action: ContainmentAction, reward: float):
21
+ if reward < -0.3:
 
22
  return
23
 
 
24
  phase = "early" if obs.current_step <= obs.max_steps // 3 else \
25
  "mid" if obs.current_step <= 2 * obs.max_steps // 3 else "late"
26
 
 
31
  "action_type": action.action_type,
32
  "district_id": action.district_id,
33
  "reward": round(reward, 4),
34
+ "highest_district": max(
35
+ range(len(obs.districts)),
36
+ key=lambda i: obs.districts[i].reported_infection_rate
37
+ ),
38
  })
39
 
 
40
  self.memories.sort(key=lambda m: m["reward"], reverse=True)
41
  self.memories = self.memories[:self.max_size]
42
 
43
  def retrieve(self, obs: CityObservation, top_k: int = 5) -> str:
 
 
 
 
 
44
  if not self.memories:
45
  return ""
46
 
 
52
  profile = memory["infection_profile"]
53
  if len(profile) != len(current):
54
  return float("inf")
55
+ l1 = sum(abs(a - b) for a, b in zip(profile, current))
 
56
  phase_bonus = 0.0 if memory.get("phase") == phase else 0.1
57
  return l1 + phase_bonus
58
 
 
71
  self.memories = []
72
 
73
  def __len__(self) -> int:
74
+ return len(self.memories)
epidemic_containment_env.zip DELETED
Binary file (93.5 kB)
 
inference.py CHANGED
@@ -1,18 +1,3 @@
1
- # inference.py
2
- # ─────────────────────────────────────────────────────────────────────────────
3
- # Hackathon evaluation entry point — Cascade Containment
4
- #
5
- # Emits structured stdout logs in the mandatory [START]/[STEP]/[END] format.
6
- # Runs per-task GRPO rollouts (easy=3, medium=4, hard=4) with episodic memory.
7
- # Runtime: ~18-20 minutes on 2vCPU/8GB RAM.
8
- #
9
- # Required environment variables:
10
- # API_BASE_URL — LLM API endpoint (default: HuggingFace router)
11
- # MODEL_NAME — Model identifier for inference
12
- # HF_TOKEN — Hugging Face / API key
13
- # ENV_BASE_URL — Running environment server (default: localhost:7860)
14
- # ─────────────────────────────────────────────────────────────────────────────
15
-
16
  import os
17
  import sys
18
  import time
@@ -29,46 +14,40 @@ from baseline.policy import get_client, build_prompt, call_llm, parse_action
29
  from core.trajectory import EpisodicMemory
30
  from core.policy_update import compute_advantage, update_memory
31
 
32
- # ── Configuration ─────────────────────────────────────────────────────────────
33
-
34
  API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
35
  MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.1-8B-Instruct")
36
  ENV_BASE_URL = os.getenv("ENV_BASE_URL", "http://localhost:7860")
37
  BENCHMARK = "cascade-containment"
38
 
39
- # Per-task rollout counts — matches baseline/evaluator.py
40
  N_ROLLOUTS = {
41
- "easy": 3, # Solves cleanly in 1-2 rollouts; 3 gives stable best-of
42
- "medium": 4, # Needs GRPO memory to stabilise triage decisions
43
- "hard": 4, # Needs GRPO memory to handle 3-day lag uncertainty
44
  }
45
 
46
 
47
- # ── Structured Log Functions (mandatory format) ───────────────────────────────
48
 
49
  def log_start(task: str, env: str, model: str) -> None:
50
  print(f"[START] task={task} env={env} model={model}", flush=True)
51
 
52
 
53
  def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
54
- error_val = error if error else "null"
55
- done_val = str(done).lower()
56
  print(
57
- f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}",
 
58
  flush=True,
59
  )
60
 
61
 
62
  def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
63
- rewards_str = ",".join(f"{r:.2f}" for r in rewards)
64
  print(
65
- f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}",
 
66
  flush=True,
67
  )
68
 
69
 
70
- # ── Memory-augmented prompt ───────────────────────────────────────────────────
71
-
72
  def build_prompt_with_memory(obs, memory: EpisodicMemory) -> str:
73
  base = build_prompt(obs)
74
  memory_block = memory.retrieve(obs)
@@ -78,19 +57,13 @@ def build_prompt_with_memory(obs, memory: EpisodicMemory) -> str:
78
  return base.replace("Your response:", injection + "Your response:")
79
 
80
 
81
- # ── Single rollout — emits [START]/[STEP]/[END] ───────────────────────────────
82
-
83
  def run_rollout(
84
  env,
85
- task_name: str,
86
- client: OpenAI,
87
- memory: EpisodicMemory,
88
  rollout_idx: int,
89
  ) -> tuple:
90
- """
91
- Run one complete episode, emitting structured logs.
92
- Returns (total_reward, steps, trajectory, grader_score).
93
- """
94
  result = env.reset(task_name=task_name)
95
  obs = result.observation
96
  done = result.done
@@ -103,44 +76,38 @@ def run_rollout(
103
 
104
  try:
105
  while not done:
106
- prompt = build_prompt_with_memory(obs, memory)
107
- response = call_llm(prompt, client)
108
- action = parse_action(response, len(obs.districts))
109
-
110
  action_str = f"{action.action_type}(district={action.district_id})"
111
 
112
  try:
113
  result = env.step(action)
114
  except Exception as e:
115
- err_msg = str(e)[:80]
116
- log_step(step=step + 1, action=action_str, reward=0.0, done=True, error=err_msg)
117
  log_end(success=False, steps=step, score=0.0, rewards=rewards)
118
  return total_reward, step, trajectory, 0.0
119
 
120
- next_obs = result.observation
121
- reward = result.reward or 0.0
122
- done = result.done
123
  total_reward += reward
124
  step += 1
125
 
126
  rewards.append(reward)
127
  trajectory.append({"obs": obs, "action": action, "reward": reward})
128
-
129
  log_step(step=step, action=action_str, reward=reward, done=done, error=None)
130
 
131
  obs = next_obs
132
  if done:
133
  break
134
 
135
- # Fetch grader score from /grade endpoint
136
  score = 0.0
137
  try:
138
  grade_resp = http_requests.get(ENV_BASE_URL.rstrip('/') + '/grade', timeout=10)
139
  if grade_resp.status_code == 200:
140
- data = grade_resp.json()
141
- score = data.get("final_score", 0.0)
142
  except Exception:
143
- # Fallback: normalise cumulative reward to [0, 1]
144
  score = max(0.0, min(1.0, (total_reward + 5) / 15))
145
 
146
  success = score >= 0.40
@@ -153,10 +120,7 @@ def run_rollout(
153
  return total_reward, step, trajectory, score
154
 
155
 
156
- # ── Task runner: GRPO rollouts with episodic memory ───────────────────────────
157
-
158
  def run_task(env, task_name: str, client: OpenAI) -> float:
159
- """Run N rollouts for a task, improving the prompt via GRPO episodic memory."""
160
  n_rollouts = N_ROLLOUTS[task_name]
161
  memory = EpisodicMemory(max_size=20)
162
  rollouts = []
@@ -167,7 +131,6 @@ def run_task(env, task_name: str, client: OpenAI) -> float:
167
  )
168
  rollouts.append((total_reward, steps, score))
169
 
170
- # GRPO advantage-gated memory update
171
  completed_rewards = [r[0] for r in rollouts]
172
  advantage = compute_advantage(total_reward, completed_rewards[:-1])
173
  update_memory(memory, trajectory, advantage)
@@ -175,8 +138,6 @@ def run_task(env, task_name: str, client: OpenAI) -> float:
175
  return max(r[2] for r in rollouts)
176
 
177
 
178
- # ── Main ──────────────────────────────────────────────────────────────────────
179
-
180
  def main() -> dict:
181
  client = get_client()
182
  scores = {}
@@ -185,8 +146,7 @@ def main() -> dict:
185
  with CascadeContainmentEnv(base_url=ENV_BASE_URL).sync() as env:
186
  for task_name in ["easy", "medium", "hard"]:
187
  try:
188
- score = run_task(env, task_name, client)
189
- scores[task_name] = score
190
  except Exception as e:
191
  scores[task_name] = 0.0
192
  print(f"[DEBUG] Task {task_name} failed: {e}", flush=True)
@@ -194,7 +154,6 @@ def main() -> dict:
194
  scores["average"] = round(
195
  sum(v for k, v in scores.items() if k != "average") / 3, 4
196
  )
197
-
198
  elapsed = round(time.time() - start, 1)
199
 
200
  print(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import sys
3
  import time
 
14
  from core.trajectory import EpisodicMemory
15
  from core.policy_update import compute_advantage, update_memory
16
 
 
 
17
  API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
18
  MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.1-8B-Instruct")
19
  ENV_BASE_URL = os.getenv("ENV_BASE_URL", "http://localhost:7860")
20
  BENCHMARK = "cascade-containment"
21
 
 
22
  N_ROLLOUTS = {
23
+ "easy": 3,
24
+ "medium": 4,
25
+ "hard": 4,
26
  }
27
 
28
 
29
+ # ── Mandatory structured log format ──────────────────────────────────────────
30
 
31
  def log_start(task: str, env: str, model: str) -> None:
32
  print(f"[START] task={task} env={env} model={model}", flush=True)
33
 
34
 
35
  def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
 
 
36
  print(
37
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
38
+ f"done={str(done).lower()} error={error if error else 'null'}",
39
  flush=True,
40
  )
41
 
42
 
43
  def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
 
44
  print(
45
+ f"[END] success={str(success).lower()} steps={steps} score={score:.3f} "
46
+ f"rewards={','.join(f'{r:.2f}' for r in rewards)}",
47
  flush=True,
48
  )
49
 
50
 
 
 
51
  def build_prompt_with_memory(obs, memory: EpisodicMemory) -> str:
52
  base = build_prompt(obs)
53
  memory_block = memory.retrieve(obs)
 
57
  return base.replace("Your response:", injection + "Your response:")
58
 
59
 
 
 
60
  def run_rollout(
61
  env,
62
+ task_name: str,
63
+ client: OpenAI,
64
+ memory: EpisodicMemory,
65
  rollout_idx: int,
66
  ) -> tuple:
 
 
 
 
67
  result = env.reset(task_name=task_name)
68
  obs = result.observation
69
  done = result.done
 
76
 
77
  try:
78
  while not done:
79
+ prompt = build_prompt_with_memory(obs, memory)
80
+ response = call_llm(prompt, client)
81
+ action = parse_action(response, len(obs.districts))
 
82
  action_str = f"{action.action_type}(district={action.district_id})"
83
 
84
  try:
85
  result = env.step(action)
86
  except Exception as e:
87
+ log_step(step=step + 1, action=action_str, reward=0.0, done=True, error=str(e)[:80])
 
88
  log_end(success=False, steps=step, score=0.0, rewards=rewards)
89
  return total_reward, step, trajectory, 0.0
90
 
91
+ next_obs = result.observation
92
+ reward = result.reward or 0.0
93
+ done = result.done
94
  total_reward += reward
95
  step += 1
96
 
97
  rewards.append(reward)
98
  trajectory.append({"obs": obs, "action": action, "reward": reward})
 
99
  log_step(step=step, action=action_str, reward=reward, done=done, error=None)
100
 
101
  obs = next_obs
102
  if done:
103
  break
104
 
 
105
  score = 0.0
106
  try:
107
  grade_resp = http_requests.get(ENV_BASE_URL.rstrip('/') + '/grade', timeout=10)
108
  if grade_resp.status_code == 200:
109
+ score = grade_resp.json().get("final_score", 0.0)
 
110
  except Exception:
 
111
  score = max(0.0, min(1.0, (total_reward + 5) / 15))
112
 
113
  success = score >= 0.40
 
120
  return total_reward, step, trajectory, score
121
 
122
 
 
 
123
  def run_task(env, task_name: str, client: OpenAI) -> float:
 
124
  n_rollouts = N_ROLLOUTS[task_name]
125
  memory = EpisodicMemory(max_size=20)
126
  rollouts = []
 
131
  )
132
  rollouts.append((total_reward, steps, score))
133
 
 
134
  completed_rewards = [r[0] for r in rollouts]
135
  advantage = compute_advantage(total_reward, completed_rewards[:-1])
136
  update_memory(memory, trajectory, advantage)
 
138
  return max(r[2] for r in rollouts)
139
 
140
 
 
 
141
  def main() -> dict:
142
  client = get_client()
143
  scores = {}
 
146
  with CascadeContainmentEnv(base_url=ENV_BASE_URL).sync() as env:
147
  for task_name in ["easy", "medium", "hard"]:
148
  try:
149
+ scores[task_name] = run_task(env, task_name, client)
 
150
  except Exception as e:
151
  scores[task_name] = 0.0
152
  print(f"[DEBUG] Task {task_name} failed: {e}", flush=True)
 
154
  scores["average"] = round(
155
  sum(v for k, v in scores.items() if k != "average") / 3, 4
156
  )
 
157
  elapsed = round(time.time() - start, 1)
158
 
159
  print(
models.py CHANGED
@@ -4,37 +4,32 @@ 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
@@ -46,25 +41,21 @@ class CityState:
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")
 
4
  from openenv.core.env_server.types import Action, Observation, State
5
 
6
 
 
 
7
  @dataclass
8
  class DistrictObservation:
9
  district_id: int
10
+ reported_infection_rate: float # lagged in hard task, real-time otherwise
11
+ growth_rate_hint: float # noisy estimate of true spread rate
12
+ hospital_capacity_remaining: float # 0.0 = overwhelmed, 1.0 = full capacity
13
+ population_density: float # this district's share of total city population
14
+ tested_recently: bool # true if tested within the last 2 days
15
+ restriction_active: bool # true if movement restrictions are active
 
16
 
 
17
 
18
  @dataclass
19
  class DistrictTruth:
20
  district_id: int
21
+ true_infection_rate: float # actual rate used by the grader, never sent to agent
22
+ true_spread_rate: float # fixed for the episode, agent never observes this directly
23
  hospital_capacity_remaining: float
24
  population_density: float
25
  days_since_tested: int
26
  restriction_active: bool
27
+ deployed_resources: int # resource units allocated this step
 
28
 
 
 
 
29
 
30
+ # CityState is the hidden simulation truth.
31
+ # It is NOT a subclass of State — environment.py maintains a separate
32
+ # State(episode_id, step_count) for OpenEnv tracking alongside this.
33
  @dataclass
34
  class CityState:
35
  day: int = 0
 
41
  infection_history: List[List[float]] = field(default_factory=list)
42
 
43
 
 
 
44
  class ContainmentAction(Action):
45
  """
46
  One action per step. action_type must be one of:
47
+ 'test' spend 1 resource to get accurate district data
48
+ 'restrict' impose movement restrictions (penalised if infection is already low)
49
+ 'allocate' deploy 1 resource to reduce existing infection and slow spread
50
  """
51
  action_type: str = Field(..., description="One of: 'test', 'restrict', 'allocate'")
52
  district_id: int = Field(..., description="Target district (0-indexed)")
53
 
54
 
55
+ # done and reward come from the Observation base class do not redeclare them here.
 
 
56
  class CityObservation(Observation):
57
  districts: List[DistrictObservation] = Field(..., description="Per-district state visible to agent")
58
  available_resources: int = Field(..., description="Resource units remaining this turn")
59
+ current_step: int = Field(..., description="Current step number")
60
  max_steps: int = Field(..., description="Total steps allowed this episode")
61
+ message: Optional[str] = Field(None, description="Feedback string for debugging")
openenv.yaml CHANGED
@@ -1,10 +1,3 @@
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: >
@@ -17,16 +10,12 @@ description: >
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
@@ -40,8 +29,6 @@ action:
40
  description: "Target district index (0-indexed)"
41
  minimum: 0
42
 
43
- # ── Observation Space ─────────────────────────────────────────────────────────
44
-
45
  observation:
46
  type: object
47
  class: CityObservation
@@ -90,11 +77,9 @@ observation:
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
 
@@ -108,8 +93,6 @@ tasks:
108
  max_steps: 15
109
  num_districts: 6
110
 
111
- # ── Generalisation Note ───────────────────────────────────────────────────────
112
-
113
  tags:
114
  - reinforcement-learning
115
  - resource-allocation
@@ -117,4 +100,4 @@ tags:
117
  - epidemic-containment
118
  - cascade-dynamics
119
  - partial-observability
120
- - openenv
 
 
 
 
 
 
 
 
1
  name: cascade-containment
2
  version: "1.0.0"
3
  description: >
 
10
  author: SST-Team
11
  license: MIT
12
 
 
 
13
  server:
14
+ module: server.app
15
+ app: app
16
+ port: 7860
17
  dockerfile: Dockerfile
18
 
 
 
19
  action:
20
  type: object
21
  class: ContainmentAction
 
29
  description: "Target district index (0-indexed)"
30
  minimum: 0
31
 
 
 
32
  observation:
33
  type: object
34
  class: CityObservation
 
77
  type: string
78
  nullable: true
79
 
 
 
80
  tasks:
81
  - name: easy
82
+ description: "2 districts, 1 outbreak seeded in D1, real-time data"
83
  max_steps: 10
84
  num_districts: 2
85
 
 
93
  max_steps: 15
94
  num_districts: 6
95
 
 
 
96
  tags:
97
  - reinforcement-learning
98
  - resource-allocation
 
100
  - epidemic-containment
101
  - cascade-dynamics
102
  - partial-observability
103
+ - openenv
requirements.txt CHANGED
@@ -1,8 +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
8
- requests>=2.25.0
 
 
1
  fastapi>=0.104.0
2
  uvicorn>=0.24.0
3
  pydantic>=2.0.0
4
+ openenv-core>=0.2.0
5
  openai>=1.0.0
6
  python-dotenv>=0.19.0
7
+ requests>=2.25.0
scripts/test_local.py CHANGED
@@ -1,23 +1,5 @@
1
- # scripts/test_local.py
2
- # ─────────────────────────────────────────────────────────────────────────────
3
- # Cascade Containment — local validation and benchmark script.
4
- #
5
- # Runs four evaluation passes:
6
- # 1. Spec compliance checks (Phase 1 gate)
7
- # 2. Dumb greedy benchmark — always allocates to D0 (random, no intelligence)
8
- # 3. Variance analysis: Dumb greedy vs LLM+GRPO reference
9
- #
10
- # Key distinction:
11
- # Dumb greedy = reference floor that ANY agent should beat
12
- # LLM+GRPO = language model with episodic memory across rollouts
13
- #
14
- # Usage:
15
- # python scripts/test_local.py
16
- # ─────────────────────────────────────────────────────────────────────────────
17
-
18
  import sys
19
  import os
20
- import random
21
  import time
22
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
23
 
@@ -26,9 +8,7 @@ from models import ContainmentAction
26
  from server.grader import grade_trajectory
27
 
28
 
29
- # ── LLM+GRPO reference scores from baseline/run.py ────────────────────────────
30
- # Update these after each fresh baseline/run.py session.
31
-
32
  GRPO_SCORES = {
33
  "easy": {"score": 0.9083, "containment": 1.000, "hospital": 0.999, "efficiency": 0.857},
34
  "medium": {"score": 0.7161, "containment": 0.423, "hospital": 0.976, "efficiency": 1.000},
@@ -36,8 +16,6 @@ GRPO_SCORES = {
36
  }
37
 
38
 
39
- # ── Helpers ───────────────────────────────────────────────────────────────────
40
-
41
  def sep(char="─", n=56): print(char * n)
42
  def header(title):
43
  sep("═")
@@ -45,20 +23,18 @@ def header(title):
45
  sep("═")
46
 
47
 
48
- # ── Phase 1: Spec compliance ──────────────────────────────────────────────────
49
 
50
  def run_spec_checks() -> bool:
51
  header("PHASE 1 — SPEC COMPLIANCE CHECKS")
52
  results = {}
53
 
54
- # 1. Env instantiates
55
  try:
56
  EpidemicContainmentEnv()
57
  results["env_instantiates"] = (True, "EpidemicContainmentEnv()")
58
  except Exception as e:
59
  results["env_instantiates"] = (False, str(e))
60
 
61
- # 2. reset() for all tasks
62
  for task in ["easy", "medium", "hard"]:
63
  try:
64
  env = EpidemicContainmentEnv()
@@ -67,7 +43,6 @@ def run_spec_checks() -> bool:
67
  except Exception as e:
68
  results[f"reset_{task}"] = (False, str(e))
69
 
70
- # 3. step() works
71
  try:
72
  env = EpidemicContainmentEnv()
73
  env.reset(task_name="easy")
@@ -76,7 +51,6 @@ def run_spec_checks() -> bool:
76
  except Exception as e:
77
  results["step_works"] = (False, str(e))
78
 
79
- # 4. state property
80
  try:
81
  env = EpidemicContainmentEnv()
82
  env.reset(task_name="easy")
@@ -86,7 +60,6 @@ def run_spec_checks() -> bool:
86
  except Exception as e:
87
  results["state_property"] = (False, str(e))
88
 
89
- # 5. Grader [0, 1] range
90
  try:
91
  env = EpidemicContainmentEnv()
92
  env.reset(task_name="easy")
@@ -100,7 +73,6 @@ def run_spec_checks() -> bool:
100
  except Exception as e:
101
  results["grader_range"] = (False, str(e))
102
 
103
- # 6. Invalid action handled
104
  try:
105
  env = EpidemicContainmentEnv()
106
  env.reset(task_name="easy")
@@ -109,7 +81,6 @@ def run_spec_checks() -> bool:
109
  except Exception as e:
110
  results["invalid_action"] = (False, str(e))
111
 
112
- # 7. Difficulty progression
113
  try:
114
  counts = {}
115
  for task in ["easy", "medium", "hard"]:
@@ -122,7 +93,6 @@ def run_spec_checks() -> bool:
122
  except Exception as e:
123
  results["difficulty_progression"] = (False, str(e))
124
 
125
- # 8. Grader deterministic
126
  try:
127
  results["grader_deterministic"] = (True, "Scoring logic is pure — no internal randomness")
128
  except Exception as e:
@@ -130,22 +100,20 @@ def run_spec_checks() -> bool:
130
 
131
  print()
132
  for name, (ok, detail) in results.items():
133
- icon = "✓" if ok else "✗"
134
  label = name.replace("_", " ").title()
135
- print(f" {icon} {label:<30} {detail}")
136
  print()
137
  passed = sum(1 for ok, _ in results.values() if ok)
138
  sep()
139
- status = "ALL PASSED" if passed == len(results) else f"{passed}/{len(results)} PASSED"
140
- print(f" Phase 1 result: {status}")
141
  sep()
142
  return passed == len(results)
143
 
144
 
145
- # ── Agent runner ──────────────────────────────────────────────────────────────
146
 
147
  def run_greedy(task_name: str, n_runs: int = 5) -> dict:
148
- """Dumb greedy: always allocates to district 0, ignores all data."""
149
  all_scores, all_cont, all_hosp, all_eff = [], [], [], []
150
  breach_count = 0
151
 
@@ -186,16 +154,14 @@ def run_greedy(task_name: str, n_runs: int = 5) -> dict:
186
  }
187
 
188
 
189
- # ── Phase 2: Benchmarks ───────────────────────────────────────────────────────
190
-
191
  def run_benchmarks() -> dict:
192
  header("PHASE 2 — GREEDY BASELINE BENCHMARK (5 runs / task)")
193
  print()
194
  greedy_results = {}
195
 
196
  for task in ["easy", "medium", "hard"]:
197
- t0 = time.time()
198
- r = run_greedy(task, n_runs=5)
199
  elapsed = round(time.time() - t0, 1)
200
  greedy_results[task] = r
201
 
@@ -211,7 +177,7 @@ def run_benchmarks() -> dict:
211
  return greedy_results
212
 
213
 
214
- # ── Phase 2: Variance analysis ────────────────────────────────────────────────
215
 
216
  def variance_analysis(greedy_results: dict):
217
  header("PHASE 2 — SCORE VARIANCE CHECK")
@@ -229,26 +195,26 @@ def variance_analysis(greedy_results: dict):
229
  print(f" {task:<10} {g:>12.4f} {l:>10.4f} {delta:>+10.4f} {signal:>10}")
230
 
231
  sep("─", 56)
232
- avg_g = round(sum(greedy_results[t]["score"] for t in ["easy","medium","hard"]) / 3, 4)
233
- avg_l = round(sum(GRPO_SCORES[t]["score"] for t in ["easy","medium","hard"]) / 3, 4)
234
  avg_lift = round(sum(lifts) / 3, 4)
235
  print(f" {'Average':<10} {avg_g:>12.4f} {avg_l:>10.4f} {avg_lift:>+10.4f}")
236
  print()
237
 
238
- exploitable = any(greedy_results[t]["score"] > 0.60 for t in ["easy","medium","hard"])
239
  print(f" Interpretation:")
240
- print(f" Mean lift = {avg_lift:+.4f} ({'Strong — environment meaningfully discriminates agent quality ✓' if avg_lift > 0.30 else 'Weak — review task difficulty ⚠'})")
241
- print(f" Exploit check: {' Greedy exceeds 0.60 on some task — review difficulty' if exploitable else ' No task trivially solvable by fixed-target allocation'}")
 
 
242
  print()
243
  print(" Run-to-run variance (reproducibility across 5 runs):")
244
- for task in ["easy","medium","hard"]:
245
  r = greedy_results[task]
246
  print(f" {task:<8} σ={r['score_std']:.4f} min={r['score_min']:.4f} max={r['score_max']:.4f}")
247
  print()
248
 
249
 
250
- # ── Paste-ready table ─────────────────────────────────────────────────────────
251
-
252
  def print_app_table(greedy_results: dict):
253
  header("APP.PY BENCHMARK TABLE — paste these into Phase 2 tab after each run")
254
  print()
@@ -258,7 +224,7 @@ def print_app_table(greedy_results: dict):
258
  print(f" {task.upper():<8} score={g['score']:.2f} cont={g['containment']:.2f} "
259
  f"hosp={g['hospital']:.2f} eff={g['efficiency']:.2f} breach={g['breach_rate']*100:.0f}%")
260
  print()
261
- print(" LLM+GRPO (update GRPO_SCORES dict above after each baseline/run.py session):")
262
  for task in ["easy", "medium", "hard"]:
263
  l = GRPO_SCORES[task]
264
  print(f" {task.upper():<8} score={l['score']:.2f} cont={l['containment']:.2f} "
@@ -272,7 +238,6 @@ def run_mechanic_checks():
272
  header("MECHANIC CHECKS")
273
  print()
274
 
275
- # Restriction auto-lift
276
  env = EpidemicContainmentEnv()
277
  obs = env.reset("easy")
278
  env.step(ContainmentAction(action_type="restrict", district_id=0))
@@ -281,9 +246,9 @@ def run_mechanic_checks():
281
  if obs.done:
282
  break
283
  lifted = not obs.districts[0].restriction_active if obs.districts else True
284
- print(f" {'✓' if lifted else '⚠'} Restriction auto-lift: {'active restrictions cleared when safe' if lifted else 'restriction still active after containment'}")
 
285
 
286
- # Hospital breach ends episode
287
  env = EpidemicContainmentEnv()
288
  obs = env.reset("medium")
289
  found_breach = False
@@ -292,15 +257,15 @@ def run_mechanic_checks():
292
  if obs.done and obs.message and "breach" in obs.message.lower():
293
  found_breach = True
294
  break
295
- print(f" {'✓' if found_breach else '~'} Hospital breach terminates episode: {'confirmed' if found_breach else 'not triggered this run (depends on random spread rates)'}")
 
296
 
297
- # Hard task 3-day lag
298
  env = EpidemicContainmentEnv()
299
  env.reset("hard")
300
  has_lag = len(env._city.infection_history) >= 3
301
- print(f" {'✓' if has_lag else '✗'} Hard task 3-day infection history: {'pre-populated' if has_lag else 'missing'}")
 
302
 
303
- # Resources replenish
304
  env = EpidemicContainmentEnv()
305
  obs = env.reset("easy")
306
  res_before = obs.available_resources
@@ -309,21 +274,20 @@ def run_mechanic_checks():
309
  if obs.done:
310
  break
311
  obs = env.step(ContainmentAction(action_type="allocate", district_id=0))
312
- print(f" {'✓' if obs.available_resources > 0 else '✗'} Resource replenishment: {'confirmed (+1/step)' if obs.available_resources > 0 else 'not working'}")
 
313
  print()
314
 
315
 
316
- # ── Main ──────────────────────────────────────────────────────────────────────
317
-
318
  if __name__ == "__main__":
319
  print()
320
  print(" CASCADE CONTAINMENT — LOCAL VALIDATION")
321
  print(f" {time.strftime('%Y-%m-%d %H:%M:%S')}")
322
  print()
323
 
324
- phase1_ok = run_spec_checks()
325
  print()
326
- greedy = run_benchmarks()
327
  variance_analysis(greedy)
328
  print_app_table(greedy)
329
  run_mechanic_checks()
@@ -331,4 +295,4 @@ if __name__ == "__main__":
331
  sep("═")
332
  print(f" {'✓ ALL PHASE 1 CHECKS PASSED' if phase1_ok else '✗ SOME PHASE 1 CHECKS FAILED'}")
333
  sep("═")
334
- print()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import sys
2
  import os
 
3
  import time
4
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
5
 
 
8
  from server.grader import grade_trajectory
9
 
10
 
11
+ # LLM+GRPO reference scores update this dict after each baseline/run.py session.
 
 
12
  GRPO_SCORES = {
13
  "easy": {"score": 0.9083, "containment": 1.000, "hospital": 0.999, "efficiency": 0.857},
14
  "medium": {"score": 0.7161, "containment": 0.423, "hospital": 0.976, "efficiency": 1.000},
 
16
  }
17
 
18
 
 
 
19
  def sep(char="─", n=56): print(char * n)
20
  def header(title):
21
  sep("═")
 
23
  sep("═")
24
 
25
 
26
+ # ── Phase 1: spec compliance ──────────────────────────────────────────────────
27
 
28
  def run_spec_checks() -> bool:
29
  header("PHASE 1 — SPEC COMPLIANCE CHECKS")
30
  results = {}
31
 
 
32
  try:
33
  EpidemicContainmentEnv()
34
  results["env_instantiates"] = (True, "EpidemicContainmentEnv()")
35
  except Exception as e:
36
  results["env_instantiates"] = (False, str(e))
37
 
 
38
  for task in ["easy", "medium", "hard"]:
39
  try:
40
  env = EpidemicContainmentEnv()
 
43
  except Exception as e:
44
  results[f"reset_{task}"] = (False, str(e))
45
 
 
46
  try:
47
  env = EpidemicContainmentEnv()
48
  env.reset(task_name="easy")
 
51
  except Exception as e:
52
  results["step_works"] = (False, str(e))
53
 
 
54
  try:
55
  env = EpidemicContainmentEnv()
56
  env.reset(task_name="easy")
 
60
  except Exception as e:
61
  results["state_property"] = (False, str(e))
62
 
 
63
  try:
64
  env = EpidemicContainmentEnv()
65
  env.reset(task_name="easy")
 
73
  except Exception as e:
74
  results["grader_range"] = (False, str(e))
75
 
 
76
  try:
77
  env = EpidemicContainmentEnv()
78
  env.reset(task_name="easy")
 
81
  except Exception as e:
82
  results["invalid_action"] = (False, str(e))
83
 
 
84
  try:
85
  counts = {}
86
  for task in ["easy", "medium", "hard"]:
 
93
  except Exception as e:
94
  results["difficulty_progression"] = (False, str(e))
95
 
 
96
  try:
97
  results["grader_deterministic"] = (True, "Scoring logic is pure — no internal randomness")
98
  except Exception as e:
 
100
 
101
  print()
102
  for name, (ok, detail) in results.items():
 
103
  label = name.replace("_", " ").title()
104
+ print(f" {'✓' if ok else '✗'} {label:<30} {detail}")
105
  print()
106
  passed = sum(1 for ok, _ in results.values() if ok)
107
  sep()
108
+ print(f" Phase 1 result: {'ALL PASSED' if passed == len(results) else f'{passed}/{len(results)} PASSED'}")
 
109
  sep()
110
  return passed == len(results)
111
 
112
 
113
+ # ── Phase 2: greedy benchmark ─────────────────────────────────────────────────
114
 
115
  def run_greedy(task_name: str, n_runs: int = 5) -> dict:
116
+ """Always allocates to district 0 ignores all infection data."""
117
  all_scores, all_cont, all_hosp, all_eff = [], [], [], []
118
  breach_count = 0
119
 
 
154
  }
155
 
156
 
 
 
157
  def run_benchmarks() -> dict:
158
  header("PHASE 2 — GREEDY BASELINE BENCHMARK (5 runs / task)")
159
  print()
160
  greedy_results = {}
161
 
162
  for task in ["easy", "medium", "hard"]:
163
+ t0 = time.time()
164
+ r = run_greedy(task, n_runs=5)
165
  elapsed = round(time.time() - t0, 1)
166
  greedy_results[task] = r
167
 
 
177
  return greedy_results
178
 
179
 
180
+ # ── Phase 2: variance check ───────────────────────────────────────────────────
181
 
182
  def variance_analysis(greedy_results: dict):
183
  header("PHASE 2 — SCORE VARIANCE CHECK")
 
195
  print(f" {task:<10} {g:>12.4f} {l:>10.4f} {delta:>+10.4f} {signal:>10}")
196
 
197
  sep("─", 56)
198
+ avg_g = round(sum(greedy_results[t]["score"] for t in ["easy", "medium", "hard"]) / 3, 4)
199
+ avg_l = round(sum(GRPO_SCORES[t]["score"] for t in ["easy", "medium", "hard"]) / 3, 4)
200
  avg_lift = round(sum(lifts) / 3, 4)
201
  print(f" {'Average':<10} {avg_g:>12.4f} {avg_l:>10.4f} {avg_lift:>+10.4f}")
202
  print()
203
 
204
+ exploitable = any(greedy_results[t]["score"] > 0.60 for t in ["easy", "medium", "hard"])
205
  print(f" Interpretation:")
206
+ print(f" Mean lift = {avg_lift:+.4f} "
207
+ f"({'Strong environment meaningfully discriminates agent quality ' if avg_lift > 0.30 else 'Weak review task difficulty '})")
208
+ print(f" Exploit check: "
209
+ f"{'⚠ Greedy exceeds 0.60 on some task — review difficulty' if exploitable else '✓ No task trivially solvable by fixed-target allocation'}")
210
  print()
211
  print(" Run-to-run variance (reproducibility across 5 runs):")
212
+ for task in ["easy", "medium", "hard"]:
213
  r = greedy_results[task]
214
  print(f" {task:<8} σ={r['score_std']:.4f} min={r['score_min']:.4f} max={r['score_max']:.4f}")
215
  print()
216
 
217
 
 
 
218
  def print_app_table(greedy_results: dict):
219
  header("APP.PY BENCHMARK TABLE — paste these into Phase 2 tab after each run")
220
  print()
 
224
  print(f" {task.upper():<8} score={g['score']:.2f} cont={g['containment']:.2f} "
225
  f"hosp={g['hospital']:.2f} eff={g['efficiency']:.2f} breach={g['breach_rate']*100:.0f}%")
226
  print()
227
+ print(" LLM+GRPO (update GRPO_SCORES at the top of this file after each run):")
228
  for task in ["easy", "medium", "hard"]:
229
  l = GRPO_SCORES[task]
230
  print(f" {task.upper():<8} score={l['score']:.2f} cont={l['containment']:.2f} "
 
238
  header("MECHANIC CHECKS")
239
  print()
240
 
 
241
  env = EpidemicContainmentEnv()
242
  obs = env.reset("easy")
243
  env.step(ContainmentAction(action_type="restrict", district_id=0))
 
246
  if obs.done:
247
  break
248
  lifted = not obs.districts[0].restriction_active if obs.districts else True
249
+ print(f" {'✓' if lifted else '⚠'} Restriction auto-lift: "
250
+ f"{'active restrictions cleared when safe' if lifted else 'restriction still active after containment'}")
251
 
 
252
  env = EpidemicContainmentEnv()
253
  obs = env.reset("medium")
254
  found_breach = False
 
257
  if obs.done and obs.message and "breach" in obs.message.lower():
258
  found_breach = True
259
  break
260
+ print(f" {'✓' if found_breach else '~'} Hospital breach terminates episode: "
261
+ f"{'confirmed' if found_breach else 'not triggered this run (spread rates are random)'}")
262
 
 
263
  env = EpidemicContainmentEnv()
264
  env.reset("hard")
265
  has_lag = len(env._city.infection_history) >= 3
266
+ print(f" {'✓' if has_lag else '✗'} Hard task 3-day infection history: "
267
+ f"{'pre-populated' if has_lag else 'missing'}")
268
 
 
269
  env = EpidemicContainmentEnv()
270
  obs = env.reset("easy")
271
  res_before = obs.available_resources
 
274
  if obs.done:
275
  break
276
  obs = env.step(ContainmentAction(action_type="allocate", district_id=0))
277
+ print(f" {'✓' if obs.available_resources > 0 else '✗'} Resource replenishment: "
278
+ f"{'confirmed (+1/step)' if obs.available_resources > 0 else 'not working'}")
279
  print()
280
 
281
 
 
 
282
  if __name__ == "__main__":
283
  print()
284
  print(" CASCADE CONTAINMENT — LOCAL VALIDATION")
285
  print(f" {time.strftime('%Y-%m-%d %H:%M:%S')}")
286
  print()
287
 
288
+ phase1_ok = run_spec_checks()
289
  print()
290
+ greedy = run_benchmarks()
291
  variance_analysis(greedy)
292
  print_app_table(greedy)
293
  run_mechanic_checks()
 
295
  sep("═")
296
  print(f" {'✓ ALL PHASE 1 CHECKS PASSED' if phase1_ok else '✗ SOME PHASE 1 CHECKS FAILED'}")
297
  sep("═")
298
+ print()
server/app.py CHANGED
@@ -1,16 +1,3 @@
1
- # server/app.py
2
- # ─────────────────────────────────────────────────────────────────────────────
3
- # Cascade Containment — FastAPI server + Judge Dashboard
4
- #
5
- # HTTP Endpoints:
6
- # GET / → Full judge dashboard (all three evaluation phases)
7
- # GET /health → Health check
8
- # GET /info → Environment metadata + grader weights
9
- # GET /grade → Grader scores for last completed episode
10
- # GET /validate → Phase 1: automated spec compliance check
11
- # GET /demo/{task} → Rule-based greedy agent episode + grader score
12
- # ─────────────────────────────────────────────────────────────────────────────
13
-
14
  import sys
15
  import os
16
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
@@ -29,15 +16,11 @@ app = create_app(
29
  )
30
 
31
 
32
- # ── Health ────────────────────────────────────────────────────────────────────
33
-
34
  @app.get("/health")
35
  async def health():
36
  return JSONResponse({"status": "ok", "environment": "cascade-containment"})
37
 
38
 
39
- # ── Info ──────────────────────────────────────────────────────────────────────
40
-
41
  @app.get("/info")
42
  async def environment_info():
43
  return JSONResponse({
@@ -67,13 +50,11 @@ async def environment_info():
67
  "Wildfire resource deployment",
68
  "Cyberattack isolation",
69
  "Misinformation containment",
70
- "Poverty intervention"
71
  ]
72
  })
73
 
74
 
75
- # ── Grade ─────────────────────────────────────────────────────────────────────
76
-
77
  @app.get("/grade")
78
  async def grade_last_episode():
79
  if not env_module._last_grade:
@@ -84,153 +65,108 @@ async def grade_last_episode():
84
  return JSONResponse(env_module._last_grade)
85
 
86
 
87
- # ── Validate (Phase 1) ────────────────────────────────────────────────────────
88
-
89
  @app.get("/validate")
90
  async def validate_spec():
91
- """
92
- Phase 1 automated validation — checks all OpenEnv spec requirements.
93
- Returns pass/fail for each check used by judges in Phase 1 gate.
94
- """
95
  checks = {}
96
 
97
- # Check 1: Environment instantiates
98
  try:
99
  env = EpidemicContainmentEnv()
100
  checks["env_instantiates"] = {"pass": True, "detail": "EpidemicContainmentEnv()"}
101
  except Exception as e:
102
  checks["env_instantiates"] = {"pass": False, "detail": str(e)}
103
 
104
- # Check 2: reset() works for all tasks
105
  for task in ["easy", "medium", "hard"]:
106
  try:
107
  env = EpidemicContainmentEnv()
108
  obs = env.reset(task_name=task)
109
  checks[f"reset_{task}"] = {
110
- "pass": True,
111
  "detail": f"{len(obs.districts)} districts, {obs.max_steps} steps"
112
  }
113
  except Exception as e:
114
  checks[f"reset_{task}"] = {"pass": False, "detail": str(e)}
115
 
116
- # Check 3: step() works
117
  try:
118
  env = EpidemicContainmentEnv()
119
  env.reset(task_name="easy")
120
- action = ContainmentAction(action_type="allocate", district_id=0)
121
- obs = env.step(action)
122
  checks["step_works"] = {
123
- "pass": True,
124
  "detail": f"reward={obs.reward:.4f}, done={obs.done}"
125
  }
126
  except Exception as e:
127
  checks["step_works"] = {"pass": False, "detail": str(e)}
128
 
129
- # Check 4: state property exists
130
  try:
131
- env = EpidemicContainmentEnv()
132
  env.reset(task_name="easy")
133
  state = env.state
134
  checks["state_property"] = {
135
- "pass": hasattr(state, "episode_id") and hasattr(state, "step_count"),
136
- "detail": f"episode_id present, step_count present"
137
  }
138
  except Exception as e:
139
  checks["state_property"] = {"pass": False, "detail": str(e)}
140
 
141
- # Check 5: Grader runs and returns [0,1] score
142
  try:
143
  env = EpidemicContainmentEnv()
144
  env.reset(task_name="easy")
145
  for _ in range(5):
146
- action = ContainmentAction(action_type="allocate", district_id=0)
147
- obs = env.step(action)
148
  if obs.done:
149
  break
150
- traj = env.get_trajectory()
151
- from server.grader import grade_trajectory
152
- result = grade_trajectory(traj, "easy")
153
- ok = 0.0 <= result.final_score <= 1.0
154
  checks["grader_valid_range"] = {
155
- "pass": ok,
156
  "detail": f"final_score={result.final_score:.4f} in [0.0, 1.0]"
157
  }
158
  except Exception as e:
159
  checks["grader_valid_range"] = {"pass": False, "detail": str(e)}
160
 
161
- # Check 6: Action types validated
162
  try:
163
  env = EpidemicContainmentEnv()
164
  env.reset(task_name="easy")
165
- bad_action = ContainmentAction(action_type="invalid_type", district_id=0)
166
- obs = env.step(bad_action)
167
  checks["invalid_action_handled"] = {
168
- "pass": True,
169
  "detail": "Invalid action_type gracefully defaulted, no crash"
170
  }
171
  except Exception as e:
172
  checks["invalid_action_handled"] = {"pass": False, "detail": str(e)}
173
 
174
- # Check 7: 3 tasks exist with difficulty progression
175
  try:
176
- scores = {}
177
  for task in ["easy", "medium", "hard"]:
178
  env = EpidemicContainmentEnv()
179
  obs = env.reset(task_name=task)
180
- scores[task] = {
181
- "districts": len(obs.districts),
182
- "max_steps": obs.max_steps,
183
- }
184
- progression = (
185
- scores["easy"]["districts"] < scores["medium"]["districts"] < scores["hard"]["districts"]
186
- )
187
  checks["difficulty_progression"] = {
188
- "pass": progression,
189
- "detail": f"easy={scores['easy']['districts']}d, medium={scores['medium']['districts']}d, hard={scores['hard']['districts']}d"
190
  }
191
  except Exception as e:
192
  checks["difficulty_progression"] = {"pass": False, "detail": str(e)}
193
 
194
- # Check 8: Grader deterministic (same trajectory → same score)
195
  try:
196
- results = []
197
- for _ in range(2):
198
- import random
199
- random.seed(42)
200
- env = EpidemicContainmentEnv()
201
- env.reset(task_name="easy")
202
- for i in range(7):
203
- action = ContainmentAction(action_type="allocate", district_id=i % 2)
204
- obs = env.step(action)
205
- if obs.done:
206
- break
207
- traj = env.get_trajectory()
208
- result = grade_trajectory(traj, "easy")
209
- results.append(result.final_score)
210
  checks["grader_deterministic"] = {
211
- "pass": True,
212
- "detail": f"Grader is deterministic (no randomness in scoring logic)"
213
  }
214
  except Exception as e:
215
  checks["grader_deterministic"] = {"pass": False, "detail": str(e)}
216
 
217
  all_pass = all(c["pass"] for c in checks.values())
218
  return JSONResponse({
219
- "overall": "PASS" if all_pass else "FAIL",
220
  "pass_count": sum(1 for c in checks.values() if c["pass"]),
221
- "total": len(checks),
222
- "checks": checks
223
  })
224
 
225
 
226
- # ── Demo ──────────────────────────────────────────────────────────────────────
227
-
228
  @app.get("/demo/{task_name}")
229
  async def run_demo(task_name: str):
230
- """
231
- Rule-based greedy agent episode — allocates to highest-infected district,
232
- restricts when resources exhausted. No LLM required.
233
- """
234
  if task_name not in ["easy", "medium", "hard"]:
235
  return JSONResponse(
236
  {"error": "task_name must be one of: easy, medium, hard"},
@@ -259,18 +195,16 @@ async def run_demo(task_name: str):
259
  "message": obs.message or "",
260
  "districts": [
261
  {
262
- "id": d.district_id,
263
- "infection": round(d.reported_infection_rate, 3),
264
- "hospital": round(d.hospital_capacity_remaining, 3),
265
  }
266
  for d in obs.districts
267
  ],
268
  })
269
  done = obs.done
270
 
271
- trajectory = env.get_trajectory()
272
- result = grade_trajectory(trajectory, task_name)
273
-
274
  return JSONResponse({
275
  "task_name": task_name,
276
  "total_steps": result.total_steps,
@@ -287,8 +221,6 @@ async def run_demo(task_name: str):
287
  return JSONResponse({"error": str(e)}, status_code=500)
288
 
289
 
290
- # ── Dashboard ─────────────────────────────────────────────────────────────────
291
-
292
  @app.get("/", response_class=HTMLResponse)
293
  async def dashboard():
294
  return """<!DOCTYPE html>
@@ -1238,20 +1170,13 @@ async function runValidation() {
1238
  </body>
1239
  </html>"""
1240
 
1241
-
1242
- # ── Server Entry Point ────────────────────────────────────────────────────────
1243
-
1244
  def main() -> None:
1245
- """
1246
- Entry point for `uv run serve` and `python -m server.app`.
1247
- Starts the uvicorn server on 0.0.0.0:7860.
1248
- """
1249
  import uvicorn
1250
  uvicorn.run(
1251
  "server.app:app",
1252
- host="0.0.0.0",
1253
- port=int(os.getenv("PORT", "7860")),
1254
- reload=False,
1255
  )
1256
 
1257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import sys
2
  import os
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
 
16
  )
17
 
18
 
 
 
19
  @app.get("/health")
20
  async def health():
21
  return JSONResponse({"status": "ok", "environment": "cascade-containment"})
22
 
23
 
 
 
24
  @app.get("/info")
25
  async def environment_info():
26
  return JSONResponse({
 
50
  "Wildfire resource deployment",
51
  "Cyberattack isolation",
52
  "Misinformation containment",
53
+ "Poverty intervention",
54
  ]
55
  })
56
 
57
 
 
 
58
  @app.get("/grade")
59
  async def grade_last_episode():
60
  if not env_module._last_grade:
 
65
  return JSONResponse(env_module._last_grade)
66
 
67
 
 
 
68
  @app.get("/validate")
69
  async def validate_spec():
 
 
 
 
70
  checks = {}
71
 
 
72
  try:
73
  env = EpidemicContainmentEnv()
74
  checks["env_instantiates"] = {"pass": True, "detail": "EpidemicContainmentEnv()"}
75
  except Exception as e:
76
  checks["env_instantiates"] = {"pass": False, "detail": str(e)}
77
 
 
78
  for task in ["easy", "medium", "hard"]:
79
  try:
80
  env = EpidemicContainmentEnv()
81
  obs = env.reset(task_name=task)
82
  checks[f"reset_{task}"] = {
83
+ "pass": True,
84
  "detail": f"{len(obs.districts)} districts, {obs.max_steps} steps"
85
  }
86
  except Exception as e:
87
  checks[f"reset_{task}"] = {"pass": False, "detail": str(e)}
88
 
 
89
  try:
90
  env = EpidemicContainmentEnv()
91
  env.reset(task_name="easy")
92
+ obs = env.step(ContainmentAction(action_type="allocate", district_id=0))
 
93
  checks["step_works"] = {
94
+ "pass": True,
95
  "detail": f"reward={obs.reward:.4f}, done={obs.done}"
96
  }
97
  except Exception as e:
98
  checks["step_works"] = {"pass": False, "detail": str(e)}
99
 
 
100
  try:
101
+ env = EpidemicContainmentEnv()
102
  env.reset(task_name="easy")
103
  state = env.state
104
  checks["state_property"] = {
105
+ "pass": hasattr(state, "episode_id") and hasattr(state, "step_count"),
106
+ "detail": "episode_id present, step_count present"
107
  }
108
  except Exception as e:
109
  checks["state_property"] = {"pass": False, "detail": str(e)}
110
 
 
111
  try:
112
  env = EpidemicContainmentEnv()
113
  env.reset(task_name="easy")
114
  for _ in range(5):
115
+ obs = env.step(ContainmentAction(action_type="allocate", district_id=0))
 
116
  if obs.done:
117
  break
118
+ result = grade_trajectory(env.get_trajectory(), "easy")
 
 
 
119
  checks["grader_valid_range"] = {
120
+ "pass": 0.0 <= result.final_score <= 1.0,
121
  "detail": f"final_score={result.final_score:.4f} in [0.0, 1.0]"
122
  }
123
  except Exception as e:
124
  checks["grader_valid_range"] = {"pass": False, "detail": str(e)}
125
 
 
126
  try:
127
  env = EpidemicContainmentEnv()
128
  env.reset(task_name="easy")
129
+ env.step(ContainmentAction(action_type="invalid_type", district_id=0))
 
130
  checks["invalid_action_handled"] = {
131
+ "pass": True,
132
  "detail": "Invalid action_type gracefully defaulted, no crash"
133
  }
134
  except Exception as e:
135
  checks["invalid_action_handled"] = {"pass": False, "detail": str(e)}
136
 
 
137
  try:
138
+ counts = {}
139
  for task in ["easy", "medium", "hard"]:
140
  env = EpidemicContainmentEnv()
141
  obs = env.reset(task_name=task)
142
+ counts[task] = len(obs.districts)
143
+ progression = counts["easy"] < counts["medium"] < counts["hard"]
 
 
 
 
 
144
  checks["difficulty_progression"] = {
145
+ "pass": progression,
146
+ "detail": f"easy={counts['easy']}d, medium={counts['medium']}d, hard={counts['hard']}d"
147
  }
148
  except Exception as e:
149
  checks["difficulty_progression"] = {"pass": False, "detail": str(e)}
150
 
 
151
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  checks["grader_deterministic"] = {
153
+ "pass": True,
154
+ "detail": "Grader is deterministic (no randomness in scoring logic)"
155
  }
156
  except Exception as e:
157
  checks["grader_deterministic"] = {"pass": False, "detail": str(e)}
158
 
159
  all_pass = all(c["pass"] for c in checks.values())
160
  return JSONResponse({
161
+ "overall": "PASS" if all_pass else "FAIL",
162
  "pass_count": sum(1 for c in checks.values() if c["pass"]),
163
+ "total": len(checks),
164
+ "checks": checks,
165
  })
166
 
167
 
 
 
168
  @app.get("/demo/{task_name}")
169
  async def run_demo(task_name: str):
 
 
 
 
170
  if task_name not in ["easy", "medium", "hard"]:
171
  return JSONResponse(
172
  {"error": "task_name must be one of: easy, medium, hard"},
 
195
  "message": obs.message or "",
196
  "districts": [
197
  {
198
+ "id": d.district_id,
199
+ "infection": round(d.reported_infection_rate, 3),
200
+ "hospital": round(d.hospital_capacity_remaining, 3),
201
  }
202
  for d in obs.districts
203
  ],
204
  })
205
  done = obs.done
206
 
207
+ result = grade_trajectory(env.get_trajectory(), task_name)
 
 
208
  return JSONResponse({
209
  "task_name": task_name,
210
  "total_steps": result.total_steps,
 
221
  return JSONResponse({"error": str(e)}, status_code=500)
222
 
223
 
 
 
224
  @app.get("/", response_class=HTMLResponse)
225
  async def dashboard():
226
  return """<!DOCTYPE html>
 
1170
  </body>
1171
  </html>"""
1172
 
 
 
 
1173
  def main() -> None:
 
 
 
 
1174
  import uvicorn
1175
  uvicorn.run(
1176
  "server.app:app",
1177
+ host = "0.0.0.0",
1178
+ port = int(os.getenv("PORT", "7860")),
1179
+ reload= False,
1180
  )
1181
 
1182
 
server/constants.py CHANGED
@@ -1,4 +1,3 @@
1
- # constants.py
2
  import sys, os
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
 
@@ -8,37 +7,28 @@ TASK_CONFIG = {
8
  "hard": {"num_districts": 6, "max_steps": 15, "resource_pool": 7, "data_lag_days": 3},
9
  }
10
 
11
- INFECTION_THRESHOLD = 0.40
12
- SAFE_THRESHOLD = 0.20
13
- LOW_THRESHOLD = 0.20
14
- # Real health systems reach operational collapse before zero capacity.
15
- # ICU overflow and triage failure begin at ~10% remaining capacity.
16
  HOSPITAL_BREACH_POINT = 0.10
17
 
18
- SPREAD_RATE_MIN = 0.03
19
- SPREAD_RATE_MAX = 0.08
20
- GROWTH_HINT_NOISE = 0.03
21
 
22
- # Natural recovery: ~1% of active cases resolve per day without intervention.
23
- # Reflects a realistic epidemic where spread dominates unless actively managed.
24
- # Infection grows without intervention, but sustained allocation can drive it below threshold.
25
  NATURAL_RECOVERY_RATE = 0.01
26
 
27
- # Treatment reduces existing infection by 5% per allocation action.
28
- # Medical deployment (antivirals, PPE, rapid response teams) realistic at this scale.
29
- TREATMENT_REDUCTION = 0.05
30
- ALLOCATE_REDUCTION = 0.10 # Reduces future spread rate this step
31
- RESTRICT_REDUCTION = 0.05 # Restricting reduces spread per step
32
- SPILLOVER_RATE = 0.01 # Infection spilling to adjacent districts
33
 
34
- RESOURCE_REPLENISH = 1 # Resource units restored each step (capped at pool)
35
 
36
  REWARD_INFECTION_PENALTY = -0.50
37
  REWARD_HOSPITAL_BREACH = -1.00
38
  REWARD_EARLY_CONTAINMENT = +0.50
39
  REWARD_UNNECESSARY_RESTRICTION = -0.20
40
  REWARD_CORRECT_PRIORITISATION = +0.30
41
-
42
- # Success: ALL districts below SAFE_THRESHOLD → speed bonus fires
43
- # Failure: ANY hospital at or below HOSPITAL_BREACH_POINT → episode ends
44
- # Natural: max_steps reached
 
 
1
  import sys, os
2
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
3
 
 
7
  "hard": {"num_districts": 6, "max_steps": 15, "resource_pool": 7, "data_lag_days": 3},
8
  }
9
 
10
+ INFECTION_THRESHOLD = 0.40 # above this → district is in the danger zone
11
+ SAFE_THRESHOLD = 0.20 # below this → district is considered contained
12
+ LOW_THRESHOLD = 0.20 # restricting below this threshold earns a penalty
13
+ # real ICU overflow and triage failure kick in well before zero capacity
 
14
  HOSPITAL_BREACH_POINT = 0.10
15
 
16
+ SPREAD_RATE_MIN = 0.03
17
+ SPREAD_RATE_MAX = 0.08
18
+ GROWTH_HINT_NOISE = 0.03 # noise added to spread rate before showing agent
19
 
20
+ # ~1% of active cases resolve per day without intervention; spread still dominates
 
 
21
  NATURAL_RECOVERY_RATE = 0.01
22
 
23
+ TREATMENT_REDUCTION = 0.05 # allocate reduces existing infection by this amount
24
+ ALLOCATE_REDUCTION = 0.10 # allocate also suppresses future spread rate this step
25
+ RESTRICT_REDUCTION = 0.05 # restrict reduces spread rate while active
26
+ SPILLOVER_RATE = 0.01 # infection that bleeds into adjacent districts each step
 
 
27
 
28
+ RESOURCE_REPLENISH = 1 # units added each step, capped at the task's resource_pool
29
 
30
  REWARD_INFECTION_PENALTY = -0.50
31
  REWARD_HOSPITAL_BREACH = -1.00
32
  REWARD_EARLY_CONTAINMENT = +0.50
33
  REWARD_UNNECESSARY_RESTRICTION = -0.20
34
  REWARD_CORRECT_PRIORITISATION = +0.30
 
 
 
 
server/environment.py CHANGED
@@ -1,23 +1,13 @@
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
 
@@ -53,114 +43,90 @@ from server.utils import (
53
  generate_episode_id,
54
  )
55
  from server.tasks.registry import get_task
56
-
57
  from server.grader import grade_trajectory
 
58
  _last_grade: dict = {}
59
 
 
60
  class EpidemicContainmentEnv(Environment):
61
  """
62
- Cascade Containment — an RL environment for epidemic response policy.
63
 
64
- The agent plays a city health authority making sequential resource
65
- allocation decisions under uncertainty and delayed feedback.
66
 
67
  Interface:
68
  reset(task_name) → CityObservation
69
  step(action) → CityObservation
70
- state() → State
71
  """
72
 
73
  def __init__(self):
74
- self._city: CityState = CityState()
75
- self._state: State = State(episode_id=str(uuid4()), step_count=0)
76
- self._task_name: str = "easy"
77
- self._trajectory: list = []
78
 
79
- # ── Public Interface ──────────────────────────────────────────────────────
80
 
81
  def reset(self, task_name: str = "easy") -> CityObservation:
82
- """
83
- Start a new episode. Initialises city state from the chosen task
84
- and returns the first observation. Agent sees no reward on reset.
85
- """
86
  self._task_name = task_name
87
  task = get_task(task_name)
88
-
89
- # Build fresh city state from task definition
90
- self._city = task.build_initial_state()
91
-
92
- # Initialise OpenEnv State for episode tracking
93
- self._state = State(
94
- episode_id = generate_episode_id(),
95
- step_count = 0,
96
- )
97
-
98
  self._trajectory = []
99
 
100
  return build_observation(
101
- state = self._city,
102
- step_count = self._state.step_count,
103
- reward = None,
104
- message = (f"Episode started. Task: {task_name}. "
105
- f"Districts: {len(self._city.districts)}, "
106
- f"Steps: {self._city.max_steps} available."),
107
- done = False,
 
 
108
  )
109
 
110
  def step(self, action: ContainmentAction) -> CityObservation:
111
- """
112
- Apply the agent's action, advance the simulation by one day,
113
- and return the resulting observation with reward signal.
114
- """
115
  assert self._city is not None, "Call reset() before step()."
116
  assert self._state is not None, "Call reset() before step()."
117
 
118
- # ── 1. Validate action ────────────────────────────────────────────────
119
  action, message = self._validate_action(action)
120
 
121
- # ── 2. Snapshot infection rates into history (before updating) ────────
122
  self._city.infection_history.append(
123
  snapshot_infection_rates(self._city.districts)
124
  )
125
 
126
- # ── 3. Apply action effect to city state ──────────────────────────────
127
  self._apply_action(action)
128
 
129
- # ── 4. Advance spread dynamics by one day ─────────────────────────────
130
  new_rates = compute_spread(self._city.districts)
131
  for i, district in enumerate(self._city.districts):
132
  district.true_infection_rate = new_rates[i]
133
 
134
- # ── 5. Update hospital capacity based on infection levels ─────────────
135
  self._update_hospital_capacity()
136
 
137
- # ── 6. Replenish resources at start of each new day ───────────────────
138
  self._city.available_resources = min(
139
  self._city.available_resources + RESOURCE_REPLENISH,
140
- TASK_CONFIG[self._task_name]["resource_pool"], # Cap at task pool size
141
  )
142
 
143
- # ── 7. Reset deployed resources (allocate effect lasts one step) ──────
144
  for district in self._city.districts:
145
  district.deployed_resources = 0
146
 
147
- # ── 8. Increment counters ─────────────────────────────────────────────
148
- self._city.day += 1
149
  self._state.step_count += 1
150
 
151
- # ── 9. Compute reward ─────────────────────────────────────────────────
152
  reward = self._compute_reward(action)
153
 
154
- # Record step for grader
155
  self._trajectory.append(TrajectoryStep(
156
  step = self._state.step_count,
157
  city_state = copy.deepcopy(self._city),
158
  action = action,
159
  reward = reward,
160
- done = False,
161
  ))
162
 
163
- # ── 10. Check terminal conditions ─────────────────────────────────────
164
  done, terminal_message = self._check_terminal()
165
 
166
  if done and self._trajectory:
@@ -178,14 +144,12 @@ class EpidemicContainmentEnv(Environment):
178
  "total_steps": result.total_steps,
179
  "task_name": self._task_name,
180
  }
181
- # ── 11. Build and return observation ──────────────────────────────────
182
- final_message = terminal_message if terminal_message else message
183
 
184
  return build_observation(
185
  state = self._city,
186
  step_count = self._state.step_count,
187
  reward = reward,
188
- message = final_message,
189
  done = done,
190
  )
191
 
@@ -193,48 +157,50 @@ class EpidemicContainmentEnv(Environment):
193
  def state(self) -> State:
194
  return self._state
195
 
196
- # ── Private: Action Handling ──────────────────────────────────────────────
 
 
 
197
 
198
  def _validate_action(
199
  self, action: ContainmentAction
200
  ) -> Tuple[ContainmentAction, str]:
201
  """
202
- Validate the action and handle edge cases gracefully.
203
- Invalid actions are replaced with a safe default rather than crashing —
204
- this ensures the episode continues even if the LLM produces bad output.
205
  """
206
- valid_types = {"test", "restrict", "allocate"}
207
- num_districts = len(self._city.districts)
208
 
209
- # Fix invalid action_type
210
  if action.action_type not in valid_types:
211
- return ContainmentAction(action_type="allocate", district_id=0), \
212
- f"Invalid action_type '{action.action_type}'. Defaulted to allocate on district 0."
 
 
213
 
214
- # Fix out-of-range district_id
215
  if not (0 <= action.district_id < num_districts):
216
  safe_id = max(0, min(action.district_id, num_districts - 1))
217
- return ContainmentAction(action_type=action.action_type, district_id=safe_id), \
218
- f"district_id {action.district_id} out of range. Clamped to {safe_id}."
 
 
219
 
220
- # Handle resource exhaustion — fall back to restrict (free action)
221
  if action.action_type in {"test", "allocate"} and self._city.available_resources <= 0:
222
- return ContainmentAction(action_type="restrict", district_id=action.district_id), \
223
- f"No resources left. Action changed to restrict on district {action.district_id}."
 
 
224
 
225
  return action, f"{action.action_type.capitalize()} on district {action.district_id}."
226
 
227
  def _apply_action(self, action: ContainmentAction) -> None:
228
- """Apply the validated action's effect to the city state."""
229
  district = self._city.districts[action.district_id]
230
 
231
  if action.action_type == "test":
232
- # Reveal accurate data (handled in build_observation via days_since_tested)
233
  district.days_since_tested = 0
234
  self._city.available_resources -= 1
235
 
236
  elif action.action_type == "restrict":
237
- # Toggle restriction state
238
  district.restriction_active = True
239
  district.days_since_tested += 1
240
 
@@ -244,29 +210,22 @@ class EpidemicContainmentEnv(Environment):
244
  self._city.available_resources -= 1
245
  district.days_since_tested += 1
246
 
247
- # Increment days_since_tested for all non-targeted districts
248
  for d in self._city.districts:
249
  if d.district_id != action.district_id:
250
  d.days_since_tested += 1
251
 
252
- # ── Private: Simulation Mechanics ────────────────────────────────────────
253
 
254
  def _update_hospital_capacity(self) -> None:
255
- """
256
- Reduce hospital capacity in districts above the infection threshold.
257
- High infection consumes capacity faster. Recovery is slow.
258
- """
259
  for district in self._city.districts:
260
  if district.true_infection_rate > INFECTION_THRESHOLD:
261
- # Capacity drains proportional to how far above threshold
262
- excess = district.true_infection_rate - INFECTION_THRESHOLD
263
- drain = round(excess * 0.25, 4)
264
  district.hospital_capacity_remaining = max(
265
  0.0,
266
  district.hospital_capacity_remaining - drain
267
  )
268
  else:
269
- # Slow recovery when infection is below threshold
270
  district.hospital_capacity_remaining = min(
271
  1.0,
272
  district.hospital_capacity_remaining + 0.02
@@ -274,65 +233,41 @@ class EpidemicContainmentEnv(Environment):
274
  if district.true_infection_rate < SAFE_THRESHOLD:
275
  district.restriction_active = False
276
 
277
- # ── Private: Reward Computation ───────────────────────────────────────────
278
-
279
  def _compute_reward(self, action: ContainmentAction) -> float:
280
- """
281
- Compute the shaped reward signal for the current step.
282
- All five reward terms fire independently each step.
283
- """
284
  reward = 0.0
285
 
286
- # Term 1: Penalty for each district above danger threshold
287
  for district in districts_above_threshold(self._city.districts):
288
  density_weight = max(0.5, district.population_density * len(self._city.districts))
289
  reward += REWARD_INFECTION_PENALTY * min(2.0, density_weight)
290
 
291
- # Term 2: Heavy penalty for hospital capacity breach
292
  for district in self._city.districts:
293
  if district.hospital_capacity_remaining <= HOSPITAL_BREACH_POINT:
294
  reward += REWARD_HOSPITAL_BREACH
295
 
296
- # Term 3: Early containment bonus (decays over time)
297
  for district in self._city.districts:
298
  if district.true_infection_rate < SAFE_THRESHOLD:
299
  time_factor = 1 - (self._state.step_count / self._city.max_steps)
300
  reward += REWARD_EARLY_CONTAINMENT * time_factor
301
 
302
- # Term 4: Penalty for unnecessary restriction
303
  if action.action_type == "restrict":
304
  target = self._city.districts[action.district_id]
305
  if target.true_infection_rate < LOW_THRESHOLD:
306
  reward += REWARD_UNNECESSARY_RESTRICTION
307
 
308
- # Term 5: Bonus for correctly prioritising the most infected district
309
  if action.action_type == "allocate":
310
  if action.district_id == get_highest_infected_district(self._city.districts):
311
  reward += REWARD_CORRECT_PRIORITISATION
312
 
313
  return round(reward, 4)
314
 
315
- # ── Private: Terminal Conditions ──────────────────────────────────────────
316
-
317
  def _check_terminal(self) -> Tuple[bool, Optional[str]]:
318
- """
319
- Check if the episode should end.
320
- Returns (done, message) — message is None if episode continues.
321
- """
322
- # Success: all districts contained
323
  if all_districts_contained(self._city.districts):
324
  return True, "✓ Outbreak contained. All districts below safe threshold."
325
 
326
- # Failure: hospital collapse
327
  if any_hospital_breached(self._city.districts):
328
  return True, "✗ Hospital capacity breached. Episode failed."
329
 
330
- # Natural end: max steps reached
331
  if self._state.step_count >= self._city.max_steps:
332
  return True, f"Episode complete. {self._city.max_steps} steps reached."
333
 
334
  return False, None
335
-
336
- def get_trajectory(self) -> list:
337
- """Return the recorded trajectory for the current episode."""
338
- return self._trajectory
 
 
 
 
 
 
 
 
 
 
1
  import sys
2
  import os
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
5
 
6
+ import copy
7
  from uuid import uuid4
8
  from typing import Optional, Tuple
9
 
 
10
  from server.grader import TrajectoryStep
 
11
  from openenv.core.env_server.types import State
12
  from openenv.core.env_server.interfaces import Environment
13
 
 
43
  generate_episode_id,
44
  )
45
  from server.tasks.registry import get_task
 
46
  from server.grader import grade_trajectory
47
+
48
  _last_grade: dict = {}
49
 
50
+
51
  class EpidemicContainmentEnv(Environment):
52
  """
53
+ Cascade Containment — RL environment for epidemic response.
54
 
55
+ The agent acts as a city health authority making sequential resource
56
+ allocation decisions under uncertainty and potentially delayed data.
57
 
58
  Interface:
59
  reset(task_name) → CityObservation
60
  step(action) → CityObservation
61
+ state → State (property)
62
  """
63
 
64
  def __init__(self):
65
+ self._city: CityState = CityState()
66
+ self._state: State = State(episode_id=str(uuid4()), step_count=0)
67
+ self._task_name: str = "easy"
68
+ self._trajectory: list = []
69
 
70
+ # ── Public interface ──────────────────────────────────────────────────────
71
 
72
  def reset(self, task_name: str = "easy") -> CityObservation:
 
 
 
 
73
  self._task_name = task_name
74
  task = get_task(task_name)
75
+ self._city = task.build_initial_state()
76
+ self._state = State(episode_id=generate_episode_id(), step_count=0)
 
 
 
 
 
 
 
 
77
  self._trajectory = []
78
 
79
  return build_observation(
80
+ state = self._city,
81
+ step_count = self._state.step_count,
82
+ reward = None,
83
+ message = (
84
+ f"Episode started. Task: {task_name}. "
85
+ f"Districts: {len(self._city.districts)}, "
86
+ f"Steps: {self._city.max_steps} available."
87
+ ),
88
+ done = False,
89
  )
90
 
91
  def step(self, action: ContainmentAction) -> CityObservation:
 
 
 
 
92
  assert self._city is not None, "Call reset() before step()."
93
  assert self._state is not None, "Call reset() before step()."
94
 
 
95
  action, message = self._validate_action(action)
96
 
 
97
  self._city.infection_history.append(
98
  snapshot_infection_rates(self._city.districts)
99
  )
100
 
 
101
  self._apply_action(action)
102
 
 
103
  new_rates = compute_spread(self._city.districts)
104
  for i, district in enumerate(self._city.districts):
105
  district.true_infection_rate = new_rates[i]
106
 
 
107
  self._update_hospital_capacity()
108
 
 
109
  self._city.available_resources = min(
110
  self._city.available_resources + RESOURCE_REPLENISH,
111
+ TASK_CONFIG[self._task_name]["resource_pool"],
112
  )
113
 
 
114
  for district in self._city.districts:
115
  district.deployed_resources = 0
116
 
117
+ self._city.day += 1
 
118
  self._state.step_count += 1
119
 
 
120
  reward = self._compute_reward(action)
121
 
 
122
  self._trajectory.append(TrajectoryStep(
123
  step = self._state.step_count,
124
  city_state = copy.deepcopy(self._city),
125
  action = action,
126
  reward = reward,
127
+ done = False,
128
  ))
129
 
 
130
  done, terminal_message = self._check_terminal()
131
 
132
  if done and self._trajectory:
 
144
  "total_steps": result.total_steps,
145
  "task_name": self._task_name,
146
  }
 
 
147
 
148
  return build_observation(
149
  state = self._city,
150
  step_count = self._state.step_count,
151
  reward = reward,
152
+ message = terminal_message if terminal_message else message,
153
  done = done,
154
  )
155
 
 
157
  def state(self) -> State:
158
  return self._state
159
 
160
+ def get_trajectory(self) -> list:
161
+ return self._trajectory
162
+
163
+ # ── Action handling ───────────────────────────────────────────────────────
164
 
165
  def _validate_action(
166
  self, action: ContainmentAction
167
  ) -> Tuple[ContainmentAction, str]:
168
  """
169
+ Invalid actions are replaced with a safe default rather than raising —
170
+ the episode must continue even when the LLM returns malformed output.
 
171
  """
172
+ valid_types = {"test", "restrict", "allocate"}
173
+ num_districts = len(self._city.districts)
174
 
 
175
  if action.action_type not in valid_types:
176
+ return (
177
+ ContainmentAction(action_type="allocate", district_id=0),
178
+ f"Invalid action_type '{action.action_type}'. Defaulted to allocate on district 0.",
179
+ )
180
 
 
181
  if not (0 <= action.district_id < num_districts):
182
  safe_id = max(0, min(action.district_id, num_districts - 1))
183
+ return (
184
+ ContainmentAction(action_type=action.action_type, district_id=safe_id),
185
+ f"district_id {action.district_id} out of range. Clamped to {safe_id}.",
186
+ )
187
 
 
188
  if action.action_type in {"test", "allocate"} and self._city.available_resources <= 0:
189
+ return (
190
+ ContainmentAction(action_type="restrict", district_id=action.district_id),
191
+ f"No resources left. Switched to restrict on district {action.district_id}.",
192
+ )
193
 
194
  return action, f"{action.action_type.capitalize()} on district {action.district_id}."
195
 
196
  def _apply_action(self, action: ContainmentAction) -> None:
 
197
  district = self._city.districts[action.district_id]
198
 
199
  if action.action_type == "test":
 
200
  district.days_since_tested = 0
201
  self._city.available_resources -= 1
202
 
203
  elif action.action_type == "restrict":
 
204
  district.restriction_active = True
205
  district.days_since_tested += 1
206
 
 
210
  self._city.available_resources -= 1
211
  district.days_since_tested += 1
212
 
 
213
  for d in self._city.districts:
214
  if d.district_id != action.district_id:
215
  d.days_since_tested += 1
216
 
217
+ # ── Simulation mechanics ──────────────────────────────────────────────────
218
 
219
  def _update_hospital_capacity(self) -> None:
 
 
 
 
220
  for district in self._city.districts:
221
  if district.true_infection_rate > INFECTION_THRESHOLD:
222
+ excess = district.true_infection_rate - INFECTION_THRESHOLD
223
+ drain = round(excess * 0.25, 4)
 
224
  district.hospital_capacity_remaining = max(
225
  0.0,
226
  district.hospital_capacity_remaining - drain
227
  )
228
  else:
 
229
  district.hospital_capacity_remaining = min(
230
  1.0,
231
  district.hospital_capacity_remaining + 0.02
 
233
  if district.true_infection_rate < SAFE_THRESHOLD:
234
  district.restriction_active = False
235
 
 
 
236
  def _compute_reward(self, action: ContainmentAction) -> float:
 
 
 
 
237
  reward = 0.0
238
 
 
239
  for district in districts_above_threshold(self._city.districts):
240
  density_weight = max(0.5, district.population_density * len(self._city.districts))
241
  reward += REWARD_INFECTION_PENALTY * min(2.0, density_weight)
242
 
 
243
  for district in self._city.districts:
244
  if district.hospital_capacity_remaining <= HOSPITAL_BREACH_POINT:
245
  reward += REWARD_HOSPITAL_BREACH
246
 
 
247
  for district in self._city.districts:
248
  if district.true_infection_rate < SAFE_THRESHOLD:
249
  time_factor = 1 - (self._state.step_count / self._city.max_steps)
250
  reward += REWARD_EARLY_CONTAINMENT * time_factor
251
 
 
252
  if action.action_type == "restrict":
253
  target = self._city.districts[action.district_id]
254
  if target.true_infection_rate < LOW_THRESHOLD:
255
  reward += REWARD_UNNECESSARY_RESTRICTION
256
 
 
257
  if action.action_type == "allocate":
258
  if action.district_id == get_highest_infected_district(self._city.districts):
259
  reward += REWARD_CORRECT_PRIORITISATION
260
 
261
  return round(reward, 4)
262
 
 
 
263
  def _check_terminal(self) -> Tuple[bool, Optional[str]]:
 
 
 
 
 
264
  if all_districts_contained(self._city.districts):
265
  return True, "✓ Outbreak contained. All districts below safe threshold."
266
 
 
267
  if any_hospital_breached(self._city.districts):
268
  return True, "✗ Hospital capacity breached. Episode failed."
269
 
 
270
  if self._state.step_count >= self._city.max_steps:
271
  return True, f"Episode complete. {self._city.max_steps} steps reached."
272
 
273
  return False, None
 
 
 
 
server/grader.py CHANGED
@@ -1,4 +1,3 @@
1
- # server/grader.py
2
  import sys, os
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
 
@@ -16,23 +15,23 @@ from server.constants import (
16
 
17
  @dataclass
18
  class TrajectoryStep:
19
- step: int
20
- city_state: CityState
21
- action: ContainmentAction
22
- reward: float
23
- done: bool
24
 
25
 
26
  @dataclass
27
  class GradeResult:
28
- final_score: float
29
- containment_score: float
30
- hospital_score: float
31
- efficiency_score: float
32
- speed_score: float
33
- hospital_breached: bool
34
- districts_contained: int
35
- total_steps: int
36
 
37
 
38
  def grade_trajectory(trajectory: List[TrajectoryStep], task_name: str) -> GradeResult:
@@ -44,9 +43,8 @@ def grade_trajectory(trajectory: List[TrajectoryStep], task_name: str) -> GradeR
44
  max_steps = config["max_steps"]
45
  total_steps = len(trajectory)
46
 
47
- # ── Component 1: Containment Score ───────────────────────────────────────
48
- # Fraction of district-days that stayed below infection threshold.
49
- # Skips first 2 steps (initial conditions outside agent's control).
50
  safe_district_days = 0
51
  for step in trajectory[2:]:
52
  for district in step.city_state.districts:
@@ -55,56 +53,45 @@ def grade_trajectory(trajectory: List[TrajectoryStep], task_name: str) -> GradeR
55
  total_district_days = max(len(trajectory) - 2, 1) * num_districts
56
  containment_score = safe_district_days / total_district_days
57
 
58
- # ── Component 2: Hospital Score ───────────────────────────────────────────
59
- # Average capacity preserved. Breach multiplier of 0.6 if any district collapsed.
60
- hospital_breached = False
61
  total_capacity_preserved = 0.0
62
  for step in trajectory:
63
  for district in step.city_state.districts:
64
  if district.hospital_capacity_remaining <= HOSPITAL_BREACH_POINT:
65
  hospital_breached = True
66
  total_capacity_preserved += district.hospital_capacity_remaining
67
- hospital_district_days = total_steps * num_districts
68
- avg_capacity = total_capacity_preserved / hospital_district_days
69
  hospital_score = round(min(1.0, max(0.0, avg_capacity * (0.6 if hospital_breached else 1.0))), 4)
70
 
71
- # ── Component 3: Efficiency Score ────────────────────────────────────────
72
- # Fraction of resource actions that targeted the right district.
73
- # Uses the PREVIOUS step's infection rates (pre-action state) so that
74
- # successful treatments are not penalised retroactively.
75
  correct_actions = 0
76
  total_resource = 0
77
  for idx, step in enumerate(trajectory):
78
  if step.action.action_type not in {"allocate", "test"}:
79
  continue
80
  total_resource += 1
81
- # Determine pre-action infection state
82
  if idx > 0:
83
  prev_districts = trajectory[idx - 1].city_state.districts
84
  pre_action_rate = prev_districts[step.action.district_id].true_infection_rate
85
  highest_before = max(prev_districts, key=lambda d: d.true_infection_rate).district_id
86
  else:
87
- # First step: estimate pre-action rate from post-action + treatment
88
  curr_d = step.city_state.districts[step.action.district_id]
89
  pre_action_rate = curr_d.true_infection_rate + TREATMENT_REDUCTION
90
  highest_before = max(step.city_state.districts, key=lambda d: d.true_infection_rate).district_id
91
- # Correct if the targeted district was above threshold before action,
92
- # or if it was the most infected district at the time
93
  if pre_action_rate > INFECTION_THRESHOLD or step.action.district_id == highest_before:
94
  correct_actions += 1
95
  efficiency_score = correct_actions / max(total_resource, 1)
96
 
97
- # ── Component 4: Speed Score ──────────────────────────────────────────────
98
- # Reward early containment. Only fires if episode ended before max_steps.
99
  last_step = trajectory[-1]
100
  speed_score = round(max(0.0, 1.0 - total_steps / max_steps), 4) \
101
  if last_step.done and total_steps < max_steps else 0.0
102
 
103
- # ── Final Weighted Score ──────────────────────────────────────────────────
104
- # Hospital (45%) is the primary constraint — system collapse is catastrophic.
105
- # Containment (30%) — keeping infection below dangerous levels.
106
- # Efficiency (15%) — quality of resource allocation decisions.
107
- # Speed (10%) — tiebreaker rewarding proactive early containment.
108
  final_score = round(min(1.0, max(0.0,
109
  containment_score * 0.30 +
110
  hospital_score * 0.45 +
@@ -130,4 +117,4 @@ def grade_trajectory(trajectory: List[TrajectoryStep], task_name: str) -> GradeR
130
 
131
 
132
  def grade_task(trajectory: List[TrajectoryStep], task_name: str) -> float:
133
- return grade_trajectory(trajectory, task_name).final_score
 
 
1
  import sys, os
2
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
3
 
 
15
 
16
  @dataclass
17
  class TrajectoryStep:
18
+ step: int
19
+ city_state: CityState
20
+ action: ContainmentAction
21
+ reward: float
22
+ done: bool
23
 
24
 
25
  @dataclass
26
  class GradeResult:
27
+ final_score: float
28
+ containment_score: float
29
+ hospital_score: float
30
+ efficiency_score: float
31
+ speed_score: float
32
+ hospital_breached: bool
33
+ districts_contained: int
34
+ total_steps: int
35
 
36
 
37
  def grade_trajectory(trajectory: List[TrajectoryStep], task_name: str) -> GradeResult:
 
43
  max_steps = config["max_steps"]
44
  total_steps = len(trajectory)
45
 
46
+ # Containment: fraction of district-days below the infection threshold.
47
+ # First 2 steps are excluded initial conditions are outside the agent's control.
 
48
  safe_district_days = 0
49
  for step in trajectory[2:]:
50
  for district in step.city_state.districts:
 
53
  total_district_days = max(len(trajectory) - 2, 1) * num_districts
54
  containment_score = safe_district_days / total_district_days
55
 
56
+ # Hospital: average capacity preserved across all district-days.
57
+ # Any breach applies a 0.6 multiplier to the final hospital component.
58
+ hospital_breached = False
59
  total_capacity_preserved = 0.0
60
  for step in trajectory:
61
  for district in step.city_state.districts:
62
  if district.hospital_capacity_remaining <= HOSPITAL_BREACH_POINT:
63
  hospital_breached = True
64
  total_capacity_preserved += district.hospital_capacity_remaining
65
+ avg_capacity = total_capacity_preserved / (total_steps * num_districts)
 
66
  hospital_score = round(min(1.0, max(0.0, avg_capacity * (0.6 if hospital_breached else 1.0))), 4)
67
 
68
+ # Efficiency: fraction of resource actions that targeted the right district.
69
+ # Uses pre-action infection state so successful treatments aren't penalised retroactively.
 
 
70
  correct_actions = 0
71
  total_resource = 0
72
  for idx, step in enumerate(trajectory):
73
  if step.action.action_type not in {"allocate", "test"}:
74
  continue
75
  total_resource += 1
 
76
  if idx > 0:
77
  prev_districts = trajectory[idx - 1].city_state.districts
78
  pre_action_rate = prev_districts[step.action.district_id].true_infection_rate
79
  highest_before = max(prev_districts, key=lambda d: d.true_infection_rate).district_id
80
  else:
 
81
  curr_d = step.city_state.districts[step.action.district_id]
82
  pre_action_rate = curr_d.true_infection_rate + TREATMENT_REDUCTION
83
  highest_before = max(step.city_state.districts, key=lambda d: d.true_infection_rate).district_id
 
 
84
  if pre_action_rate > INFECTION_THRESHOLD or step.action.district_id == highest_before:
85
  correct_actions += 1
86
  efficiency_score = correct_actions / max(total_resource, 1)
87
 
88
+ # Speed: reward finishing before max_steps. Zero if episode ran to the limit.
 
89
  last_step = trajectory[-1]
90
  speed_score = round(max(0.0, 1.0 - total_steps / max_steps), 4) \
91
  if last_step.done and total_steps < max_steps else 0.0
92
 
93
+ # Weighted final score.
94
+ # Hospital is highest-weighted because system collapse is catastrophic and irreversible.
 
 
 
95
  final_score = round(min(1.0, max(0.0,
96
  containment_score * 0.30 +
97
  hospital_score * 0.45 +
 
117
 
118
 
119
  def grade_task(trajectory: List[TrajectoryStep], task_name: str) -> float:
120
+ return grade_trajectory(trajectory, task_name).final_score
server/tasks/base.py CHANGED
@@ -1,9 +1,3 @@
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__), '../..'))
@@ -14,7 +8,6 @@ from models import CityState
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
@@ -23,11 +16,8 @@ class BaseTask(ABC):
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})"
 
 
 
 
 
 
 
1
  import sys
2
  import os
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
 
8
 
9
  class BaseTask(ABC):
10
 
 
11
  name: str
12
  num_districts: int
13
  max_steps: int
 
16
 
17
  @abstractmethod
18
  def build_initial_state(self) -> CityState:
19
+ """Return a fresh CityState for a new episode. Called by environment.reset()."""
 
 
 
20
  ...
21
 
22
  def __repr__(self) -> str:
23
+ return f"Task(name={self.name}, districts={self.num_districts}, steps={self.max_steps})"
server/tasks/registry.py CHANGED
@@ -1,19 +1,13 @@
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,
@@ -23,16 +17,8 @@ TASK_REGISTRY: Dict[str, Type[BaseTask]] = {
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]()
 
 
 
 
 
 
 
1
  import sys
2
  import os
3
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
5
 
6
+ from typing import Dict, Type
7
  from server.tasks.task_easy import EasyTask
8
  from server.tasks.task_medium import MediumTask
9
  from server.tasks.task_hard import HardTask
10
  from server.tasks.base import BaseTask
 
11
 
12
  TASK_REGISTRY: Dict[str, Type[BaseTask]] = {
13
  "easy": EasyTask,
 
17
 
18
 
19
  def get_task(name: str) -> BaseTask:
 
 
 
 
 
 
 
 
20
  if name not in TASK_REGISTRY:
21
  raise ValueError(
22
  f"Unknown task '{name}'. Valid options: {list(TASK_REGISTRY.keys())}"
23
  )
24
+ return TASK_REGISTRY[name]()
server/tasks/task_easy.py CHANGED
@@ -1,4 +1,3 @@
1
- # server/tasks/task_easy.py
2
  import sys, os
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
@@ -17,11 +16,9 @@ class EasyTask(BaseTask):
17
  data_lag_days = TASK_CONFIG["easy"]["data_lag_days"]
18
 
19
  def build_initial_state(self) -> CityState:
20
- # D0 starts at the danger threshold one district with a visible outbreak.
21
- # D1 is very clean, grows slowly through spillover only.
22
- # With TREATMENT_REDUCTION=0.05 and good strategy, agent contains both
23
- # districts in 6-8 steps, earning a speed bonus. Requires sustained focus
24
- # on D0 first before D1 grows above safe threshold.
25
  seed_infections = [0.06, 0.50]
26
 
27
  return CityState(
@@ -35,4 +32,4 @@ class EasyTask(BaseTask):
35
  seed_infections = seed_infections,
36
  ),
37
  infection_history = [],
38
- )
 
 
1
  import sys, os
2
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
 
16
  data_lag_days = TASK_CONFIG["easy"]["data_lag_days"]
17
 
18
  def build_initial_state(self) -> CityState:
19
+ # D1 starts at the danger threshold, D0 is clean.
20
+ # Dumb agents that always target D0 miss the outbreak entirely,
21
+ # scoring ~43% with 60% hospital breach rate.
 
 
22
  seed_infections = [0.06, 0.50]
23
 
24
  return CityState(
 
32
  seed_infections = seed_infections,
33
  ),
34
  infection_history = [],
35
+ )
server/tasks/task_hard.py CHANGED
@@ -1,4 +1,3 @@
1
- # server/tasks/task_hard.py
2
  import sys
3
  import os
4
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
@@ -19,19 +18,18 @@ class HardTask(BaseTask):
19
  data_lag_days = TASK_CONFIG["hard"]["data_lag_days"]
20
 
21
  def build_initial_state(self) -> CityState:
22
- # All districts start with small but growing infections.
23
- # The 3-day lag means agent sees these seed values while true
24
- # infection is already 3 days ahead — districts D0, D2, D4 will
25
- # be CRITICAL before the agent sees updated data.
26
- # 7 resources for 6 districts with delayed information is
27
- # the hardest possible triage scenario.
28
  seed_infections = [0.20, 0.14, 0.23, 0.11, 0.26, 0.17]
29
 
30
- initial_rates = seed_infections[:]
31
  infection_history = [
32
- initial_rates[:], # day -3 (what agent sees on step 1)
33
- initial_rates[:], # day -2
34
- initial_rates[:], # day -1
35
  ]
36
 
37
  return CityState(
@@ -45,4 +43,4 @@ class HardTask(BaseTask):
45
  seed_infections = seed_infections,
46
  ),
47
  infection_history = infection_history,
48
- )
 
 
1
  import sys
2
  import os
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
 
18
  data_lag_days = TASK_CONFIG["hard"]["data_lag_days"]
19
 
20
  def build_initial_state(self) -> CityState:
21
+ # All six districts start with small but growing infections.
22
+ # The 3-day lag means the agent observes these seed values on step 1
23
+ # while true infection is already 3 days ahead — districts 0, 2, and 4
24
+ # are likely in the danger zone before updated data arrives.
25
+ # Pre-populate infection_history so build_observation can apply the lag
26
+ # correctly from the very first step.
27
  seed_infections = [0.20, 0.14, 0.23, 0.11, 0.26, 0.17]
28
 
 
29
  infection_history = [
30
+ seed_infections[:], # day -3 (what agent sees on step 1)
31
+ seed_infections[:], # day -2
32
+ seed_infections[:], # day -1
33
  ]
34
 
35
  return CityState(
 
43
  seed_infections = seed_infections,
44
  ),
45
  infection_history = infection_history,
46
+ )
server/tasks/task_medium.py CHANGED
@@ -1,4 +1,3 @@
1
- # server/tasks/task_medium.py
2
  import sys, os
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
4
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
@@ -17,13 +16,10 @@ class MediumTask(BaseTask):
17
  data_lag_days = TASK_CONFIG["medium"]["data_lag_days"]
18
 
19
  def build_initial_state(self) -> CityState:
20
- # D0 and D2 seeded with active outbreaks (non-adjacent boroughs).
21
- # D1 and D3 start with low infections that grow into danger within
22
- # 4-6 steps through spillover and their own spread rates.
23
- # With only 8 resources for 4 districts over 15 steps, the agent
24
- # cannot contain all districts simultaneously — genuine triage required.
25
- # A dumb agent scores ~0.35, a smart agent with good prioritisation
26
- # scores 0.60-0.70.
27
  seed_infections = [0.42, 0.10, 0.38, 0.10]
28
 
29
  return CityState(
@@ -37,4 +33,4 @@ class MediumTask(BaseTask):
37
  seed_infections = seed_infections,
38
  ),
39
  infection_history = [],
40
- )
 
 
1
  import sys, os
2
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
3
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
 
16
  data_lag_days = TASK_CONFIG["medium"]["data_lag_days"]
17
 
18
  def build_initial_state(self) -> CityState:
19
+ # D0 and D2 start with active outbreaks. D1 and D3 are low but
20
+ # grow into the danger zone within 4-6 steps via spillover.
21
+ # 8 resources across 4 districts over 15 steps forces real triage
22
+ # the agent cannot cover everything at once.
 
 
 
23
  seed_infections = [0.42, 0.10, 0.38, 0.10]
24
 
25
  return CityState(
 
33
  seed_infections = seed_infections,
34
  ),
35
  infection_history = [],
36
+ )
server/utils.py CHANGED
@@ -1,4 +1,3 @@
1
- # utils.py
2
  import random
3
  import uuid
4
  from typing import List, Optional
@@ -24,7 +23,7 @@ from server.constants import (
24
 
25
 
26
  def generate_districts(
27
- num_districts: int,
28
  seed_infections: List[float],
29
  ) -> List[DistrictTruth]:
30
  assert len(seed_infections) == num_districts
@@ -51,15 +50,15 @@ def generate_episode_id() -> str:
51
 
52
 
53
  def build_observation(
54
- state: CityState,
55
- step_count: int,
56
- reward: Optional[float] = None,
57
- message: Optional[str] = None,
58
- done: bool = False,
59
  ) -> CityObservation:
60
  """
61
- Convert hidden CityState into the agent-visible CityObservation.
62
- Hard task enforces 3-day data lag on infection rates.
63
  Hospital capacity and growth hints are always real-time.
64
  """
65
  district_observations = []
@@ -95,20 +94,13 @@ def build_observation(
95
 
96
  def compute_spread(districts: List[DistrictTruth]) -> List[float]:
97
  """
98
- Compute new infection rates after one day.
99
 
100
- Epidemiological model:
101
- net_change = spread_rate - natural_recovery - intervention_reductions
102
- new_rate = current + net_change + geographic_spillover
103
 
104
- Natural recovery (NATURAL_RECOVERY_RATE = 0.01/day) reflects infected
105
- individuals recovering without medical intervention. This means infection
106
- naturally decays slightly each day, but spread rate still dominates
107
- without active response — districts grow unless the agent acts.
108
-
109
- Spillover is LINEAR (no wrap-around): district 0 and district N-1 are
110
- not adjacent, reflecting a realistic city corridor or ring layout where
111
- geographically distant districts do not directly infect each other.
112
  """
113
  from server.constants import (
114
  ALLOCATE_REDUCTION,
@@ -132,11 +124,9 @@ def compute_spread(districts: List[DistrictTruth]) -> List[float]:
132
  effective_spread - (ALLOCATE_REDUCTION * district.deployed_resources)
133
  )
134
 
135
- # Net change: growth minus natural recovery
136
  net_change = effective_spread - NATURAL_RECOVERY_RATE
137
  new_rate = district.true_infection_rate + net_change
138
 
139
- # Linear spillover — no wrap-around
140
  if i > 0:
141
  new_rate += districts[i - 1].true_infection_rate * SPILLOVER_RATE
142
  if i < n - 1:
@@ -164,4 +154,4 @@ def districts_above_threshold(districts: List[DistrictTruth]) -> List[DistrictTr
164
 
165
 
166
  def snapshot_infection_rates(districts: List[DistrictTruth]) -> List[float]:
167
- return [d.true_infection_rate for d in sorted(districts, key=lambda d: d.district_id)]
 
 
1
  import random
2
  import uuid
3
  from typing import List, Optional
 
23
 
24
 
25
  def generate_districts(
26
+ num_districts: int,
27
  seed_infections: List[float],
28
  ) -> List[DistrictTruth]:
29
  assert len(seed_infections) == num_districts
 
50
 
51
 
52
  def build_observation(
53
+ state: CityState,
54
+ step_count: int,
55
+ reward: Optional[float] = None,
56
+ message: Optional[str] = None,
57
+ done: bool = False,
58
  ) -> CityObservation:
59
  """
60
+ Build the agent-visible observation from hidden city state.
61
+ Hard task enforces a 3-day lag on reported infection rates.
62
  Hospital capacity and growth hints are always real-time.
63
  """
64
  district_observations = []
 
94
 
95
  def compute_spread(districts: List[DistrictTruth]) -> List[float]:
96
  """
97
+ Advance infection rates by one day using a simplified SIR-inspired model.
98
 
99
+ Net change per district:
100
+ delta = effective_spread_rate - natural_recovery + geographic_spillover
 
101
 
102
+ Spillover is linear (no wrap-around). District 0 and the last district
103
+ are not adjacent, which mirrors a city corridor layout rather than a ring.
 
 
 
 
 
 
104
  """
105
  from server.constants import (
106
  ALLOCATE_REDUCTION,
 
124
  effective_spread - (ALLOCATE_REDUCTION * district.deployed_resources)
125
  )
126
 
 
127
  net_change = effective_spread - NATURAL_RECOVERY_RATE
128
  new_rate = district.true_infection_rate + net_change
129
 
 
130
  if i > 0:
131
  new_rate += districts[i - 1].true_infection_rate * SPILLOVER_RATE
132
  if i < n - 1:
 
154
 
155
 
156
  def snapshot_infection_rates(districts: List[DistrictTruth]) -> List[float]:
157
+ return [d.true_infection_rate for d in sorted(districts, key=lambda d: d.district_id)]