Commit Β·
f27fd6a
1
Parent(s): d795c8e
Improve agent: fix strategy rule, chain-of-thought, 8 rollouts, better memory
Browse files- baseline/evaluator.py +22 -67
- baseline/policy.py +54 -43
- core/trajectory.py +27 -25
baseline/evaluator.py
CHANGED
|
@@ -1,17 +1,8 @@
|
|
| 1 |
# baseline/evaluator.py
|
| 2 |
-
|
| 3 |
-
# GRPO-style evaluation loop for Cascade Containment.
|
| 4 |
-
# Imports core components β stays focused on orchestration only.
|
| 5 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
-
|
| 7 |
-
import os
|
| 8 |
-
import sys
|
| 9 |
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 10 |
-
|
| 11 |
-
import time
|
| 12 |
-
from typing import List, Tuple
|
| 13 |
from openai import OpenAI
|
| 14 |
-
from typing import Any
|
| 15 |
|
| 16 |
from client import CascadeContainmentEnv
|
| 17 |
from models import ContainmentAction, CityObservation
|
|
@@ -19,16 +10,12 @@ from baseline.policy import get_client, build_prompt, call_llm, parse_action
|
|
| 19 |
from core.trajectory import EpisodicMemory
|
| 20 |
from core.reward import normalise_score
|
| 21 |
from core.policy_update import compute_advantage, update_memory
|
| 22 |
-
|
| 23 |
import requests as http_requests
|
| 24 |
|
| 25 |
-
N_ROLLOUTS = 5
|
| 26 |
|
| 27 |
|
| 28 |
-
# ββ Prompt Builder With Memory ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
-
|
| 30 |
def build_prompt_with_memory(obs: CityObservation, memory: EpisodicMemory) -> str:
|
| 31 |
-
"""Extend base prompt with retrieved memories from similar past situations."""
|
| 32 |
from baseline.policy import build_prompt
|
| 33 |
base = build_prompt(obs)
|
| 34 |
memory_block = memory.retrieve(obs)
|
|
@@ -36,25 +23,15 @@ def build_prompt_with_memory(obs: CityObservation, memory: EpisodicMemory) -> st
|
|
| 36 |
if not memory_block:
|
| 37 |
return base
|
| 38 |
|
| 39 |
-
injection =
|
| 40 |
-
|
| 41 |
-
+ memory_block
|
| 42 |
-
+ "\nUse these past experiences to make a better decision.\n"
|
| 43 |
-
)
|
| 44 |
-
return base.replace("Your decision:", injection + "Your decision:")
|
| 45 |
-
|
| 46 |
|
| 47 |
-
# ββ Single Rollout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
|
| 49 |
def run_rollout(
|
| 50 |
-
env:
|
| 51 |
-
|
| 52 |
-
client: OpenAI,
|
| 53 |
-
memory: EpisodicMemory,
|
| 54 |
-
verbose: bool = True,
|
| 55 |
) -> Tuple[float, int, List[dict]]:
|
| 56 |
-
|
| 57 |
-
result = env.reset(task_name=task_name)
|
| 58 |
obs = result.observation
|
| 59 |
done = result.done
|
| 60 |
total_reward = 0.0
|
|
@@ -67,11 +44,11 @@ def run_rollout(
|
|
| 67 |
action = parse_action(response, len(obs.districts))
|
| 68 |
|
| 69 |
try:
|
| 70 |
-
result
|
| 71 |
except Exception as e:
|
| 72 |
if "close frame" in str(e).lower() or "websocket" in str(e).lower():
|
| 73 |
if verbose:
|
| 74 |
-
print(f" β WebSocket dropped at step {step+1}, ending
|
| 75 |
break
|
| 76 |
raise
|
| 77 |
|
|
@@ -81,17 +58,12 @@ def run_rollout(
|
|
| 81 |
total_reward += reward
|
| 82 |
step += 1
|
| 83 |
|
| 84 |
-
trajectory.append({
|
| 85 |
-
"obs": obs,
|
| 86 |
-
"action": action,
|
| 87 |
-
"reward": reward,
|
| 88 |
-
})
|
| 89 |
|
| 90 |
if verbose:
|
| 91 |
print(
|
| 92 |
f" step {step:2d}: {action.action_type:8} "
|
| 93 |
-
f"β district {action.district_id} "
|
| 94 |
-
f"| reward: {reward:+.4f}"
|
| 95 |
)
|
| 96 |
|
| 97 |
obs = next_obs
|
|
@@ -101,16 +73,10 @@ def run_rollout(
|
|
| 101 |
return total_reward, step, trajectory
|
| 102 |
|
| 103 |
|
| 104 |
-
# ββ GRPO Task Runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
-
|
| 106 |
def run_task_grpo(
|
| 107 |
-
env:
|
| 108 |
-
|
| 109 |
-
client: OpenAI,
|
| 110 |
-
base_url: str,
|
| 111 |
-
verbose: bool = True,
|
| 112 |
) -> float:
|
| 113 |
-
"""GRPO-style simulated learning loop for one task."""
|
| 114 |
if verbose:
|
| 115 |
print(f"\n Task: {task_name.upper()} | {N_ROLLOUTS} rollouts")
|
| 116 |
print(f" {'β'*44}")
|
|
@@ -123,14 +89,13 @@ def run_task_grpo(
|
|
| 123 |
label = "base prompt" if len(memory) == 0 else f"memory: {len(memory)} entries"
|
| 124 |
print(f"\n Rollout {i+1}/{N_ROLLOUTS} [{label}]")
|
| 125 |
|
| 126 |
-
total_reward, steps, trajectory = run_rollout(
|
|
|
|
|
|
|
| 127 |
|
| 128 |
-
# Get proper grader score from server
|
| 129 |
num_districts = {"easy": 2, "medium": 4, "hard": 6}.get(task_name, 2)
|
| 130 |
try:
|
| 131 |
-
grade_resp = http_requests.get(
|
| 132 |
-
base_url.rstrip('/') + '/grade', timeout=10
|
| 133 |
-
)
|
| 134 |
if grade_resp.status_code == 200:
|
| 135 |
data = grade_resp.json()
|
| 136 |
score = data["final_score"]
|
|
@@ -146,26 +111,24 @@ def run_task_grpo(
|
|
| 146 |
except Exception:
|
| 147 |
score = normalise_score(total_reward, steps, num_districts)
|
| 148 |
|
| 149 |
-
# Append BEFORE advantage computation
|
| 150 |
rollouts.append((total_reward, steps, score))
|
| 151 |
|
| 152 |
if verbose:
|
| 153 |
print(f" β Reward: {total_reward:+.4f} | Score: {score:.4f}")
|
| 154 |
|
| 155 |
-
# ββ GRPO advantage computation and memory update ββββββββββββββββββββββ
|
| 156 |
completed_rewards = [r[0] for r in rollouts]
|
| 157 |
advantage = compute_advantage(total_reward, completed_rewards[:-1])
|
| 158 |
stored = update_memory(memory, trajectory, advantage)
|
| 159 |
|
| 160 |
if verbose:
|
| 161 |
mean = sum(completed_rewards[:-1]) / max(len(completed_rewards) - 1, 1) \
|
| 162 |
-
|
| 163 |
print(f" β Advantage: {advantage:+.4f} | "
|
| 164 |
+ (f"β Stored {stored} steps" if stored > 0 else "β Suppressed"))
|
| 165 |
|
| 166 |
all_rewards = [r[0] for r in rollouts]
|
| 167 |
mean_reward = sum(all_rewards) / len(all_rewards)
|
| 168 |
-
best_score
|
| 169 |
|
| 170 |
if verbose:
|
| 171 |
print(f"\n Rewards: {[round(r, 4) for r in all_rewards]}")
|
|
@@ -176,13 +139,7 @@ def run_task_grpo(
|
|
| 176 |
return best_score
|
| 177 |
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
def run_evaluation(
|
| 182 |
-
base_url: str = "http://localhost:7860",
|
| 183 |
-
verbose: bool = True,
|
| 184 |
-
) -> dict:
|
| 185 |
-
"""Run all three tasks with GRPO episodic memory learning."""
|
| 186 |
if verbose:
|
| 187 |
print("\n" + "="*52)
|
| 188 |
print(" CASCADE CONTAINMENT β GRPO EVALUATION")
|
|
@@ -209,10 +166,8 @@ def run_evaluation(
|
|
| 209 |
traceback.print_exc()
|
| 210 |
|
| 211 |
scores["average"] = round(
|
| 212 |
-
sum(v for k, v in scores.items() if k != "average") / 3,
|
| 213 |
-
4
|
| 214 |
)
|
| 215 |
-
|
| 216 |
elapsed = round(time.time() - start, 1)
|
| 217 |
|
| 218 |
if verbose:
|
|
|
|
| 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
|
|
|
|
|
|
|
| 5 |
from openai import OpenAI
|
|
|
|
| 6 |
|
| 7 |
from client import CascadeContainmentEnv
|
| 8 |
from models import ContainmentAction, CityObservation
|
|
|
|
| 10 |
from core.trajectory import EpisodicMemory
|
| 11 |
from core.reward import normalise_score
|
| 12 |
from core.policy_update import compute_advantage, update_memory
|
|
|
|
| 13 |
import requests as http_requests
|
| 14 |
|
| 15 |
+
N_ROLLOUTS = 8 # increased from 5 β GRPO needs more rollouts for stable learning signal
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
| 18 |
def build_prompt_with_memory(obs: CityObservation, memory: EpisodicMemory) -> str:
|
|
|
|
| 19 |
from baseline.policy import build_prompt
|
| 20 |
base = build_prompt(obs)
|
| 21 |
memory_block = memory.retrieve(obs)
|
|
|
|
| 23 |
if not memory_block:
|
| 24 |
return base
|
| 25 |
|
| 26 |
+
injection = "\n" + memory_block + "\nApply these lessons to your current decision.\n"
|
| 27 |
+
return base.replace("Your response:", injection + "Your response:")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
|
|
|
| 29 |
|
| 30 |
def run_rollout(
|
| 31 |
+
env: Any, task_name: str, client: OpenAI,
|
| 32 |
+
memory: EpisodicMemory, verbose: bool = True,
|
|
|
|
|
|
|
|
|
|
| 33 |
) -> Tuple[float, int, List[dict]]:
|
| 34 |
+
result = env.reset(task_name=task_name)
|
|
|
|
| 35 |
obs = result.observation
|
| 36 |
done = result.done
|
| 37 |
total_reward = 0.0
|
|
|
|
| 44 |
action = parse_action(response, len(obs.districts))
|
| 45 |
|
| 46 |
try:
|
| 47 |
+
result = env.step(action)
|
| 48 |
except Exception as e:
|
| 49 |
if "close frame" in str(e).lower() or "websocket" in str(e).lower():
|
| 50 |
if verbose:
|
| 51 |
+
print(f" β WebSocket dropped at step {step+1}, ending early")
|
| 52 |
break
|
| 53 |
raise
|
| 54 |
|
|
|
|
| 58 |
total_reward += reward
|
| 59 |
step += 1
|
| 60 |
|
| 61 |
+
trajectory.append({"obs": obs, "action": action, "reward": reward})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
if verbose:
|
| 64 |
print(
|
| 65 |
f" step {step:2d}: {action.action_type:8} "
|
| 66 |
+
f"β district {action.district_id} | reward: {reward:+.4f}"
|
|
|
|
| 67 |
)
|
| 68 |
|
| 69 |
obs = next_obs
|
|
|
|
| 73 |
return total_reward, step, trajectory
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
| 76 |
def run_task_grpo(
|
| 77 |
+
env: Any, task_name: str, client: OpenAI,
|
| 78 |
+
base_url: str, verbose: bool = True,
|
|
|
|
|
|
|
|
|
|
| 79 |
) -> float:
|
|
|
|
| 80 |
if verbose:
|
| 81 |
print(f"\n Task: {task_name.upper()} | {N_ROLLOUTS} rollouts")
|
| 82 |
print(f" {'β'*44}")
|
|
|
|
| 89 |
label = "base prompt" if len(memory) == 0 else f"memory: {len(memory)} entries"
|
| 90 |
print(f"\n Rollout {i+1}/{N_ROLLOUTS} [{label}]")
|
| 91 |
|
| 92 |
+
total_reward, steps, trajectory = run_rollout(
|
| 93 |
+
env, task_name, client, memory, verbose
|
| 94 |
+
)
|
| 95 |
|
|
|
|
| 96 |
num_districts = {"easy": 2, "medium": 4, "hard": 6}.get(task_name, 2)
|
| 97 |
try:
|
| 98 |
+
grade_resp = http_requests.get(base_url.rstrip('/') + '/grade', timeout=10)
|
|
|
|
|
|
|
| 99 |
if grade_resp.status_code == 200:
|
| 100 |
data = grade_resp.json()
|
| 101 |
score = data["final_score"]
|
|
|
|
| 111 |
except Exception:
|
| 112 |
score = normalise_score(total_reward, steps, num_districts)
|
| 113 |
|
|
|
|
| 114 |
rollouts.append((total_reward, steps, score))
|
| 115 |
|
| 116 |
if verbose:
|
| 117 |
print(f" β Reward: {total_reward:+.4f} | Score: {score:.4f}")
|
| 118 |
|
|
|
|
| 119 |
completed_rewards = [r[0] for r in rollouts]
|
| 120 |
advantage = compute_advantage(total_reward, completed_rewards[:-1])
|
| 121 |
stored = update_memory(memory, trajectory, advantage)
|
| 122 |
|
| 123 |
if verbose:
|
| 124 |
mean = sum(completed_rewards[:-1]) / max(len(completed_rewards) - 1, 1) \
|
| 125 |
+
if len(completed_rewards) > 1 else total_reward
|
| 126 |
print(f" β Advantage: {advantage:+.4f} | "
|
| 127 |
+ (f"β Stored {stored} steps" if stored > 0 else "β Suppressed"))
|
| 128 |
|
| 129 |
all_rewards = [r[0] for r in rollouts]
|
| 130 |
mean_reward = sum(all_rewards) / len(all_rewards)
|
| 131 |
+
best_score = max(rollouts, key=lambda x: x[2])[2]
|
| 132 |
|
| 133 |
if verbose:
|
| 134 |
print(f"\n Rewards: {[round(r, 4) for r in all_rewards]}")
|
|
|
|
| 139 |
return best_score
|
| 140 |
|
| 141 |
|
| 142 |
+
def run_evaluation(base_url: str = "http://localhost:7860", verbose: bool = True) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if verbose:
|
| 144 |
print("\n" + "="*52)
|
| 145 |
print(" CASCADE CONTAINMENT β GRPO EVALUATION")
|
|
|
|
| 166 |
traceback.print_exc()
|
| 167 |
|
| 168 |
scores["average"] = round(
|
| 169 |
+
sum(v for k, v in scores.items() if k != "average") / 3, 4
|
|
|
|
| 170 |
)
|
|
|
|
| 171 |
elapsed = round(time.time() - start, 1)
|
| 172 |
|
| 173 |
if verbose:
|
baseline/policy.py
CHANGED
|
@@ -1,10 +1,6 @@
|
|
| 1 |
# baseline/policy.py
|
| 2 |
-
import os
|
| 3 |
-
import sys
|
| 4 |
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 5 |
-
|
| 6 |
-
import json
|
| 7 |
-
import re
|
| 8 |
from openai import OpenAI
|
| 9 |
from models import CityObservation, ContainmentAction
|
| 10 |
|
|
@@ -27,21 +23,24 @@ def build_prompt(obs: CityObservation) -> str:
|
|
| 27 |
)
|
| 28 |
|
| 29 |
lines = [
|
| 30 |
-
"You are an epidemic response coordinator.",
|
| 31 |
-
"Your goal:
|
| 32 |
"",
|
| 33 |
f"Step {obs.current_step}/{obs.max_steps} | Resources remaining: {obs.available_resources}",
|
| 34 |
-
"βΉοΈ Resources replenish by 1 each step
|
| 35 |
-
" Spend wisely β you can never have more resources than the starting amount.",
|
| 36 |
"",
|
| 37 |
-
"Districts (sorted by
|
| 38 |
]
|
| 39 |
|
| 40 |
for d in sorted_districts:
|
| 41 |
if d.reported_infection_rate > 0.4:
|
| 42 |
status = "π΄ CRITICAL"
|
| 43 |
elif d.reported_infection_rate > 0.2:
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
else:
|
| 46 |
status = "π’ SAFE"
|
| 47 |
|
|
@@ -52,7 +51,7 @@ def build_prompt(obs: CityObservation) -> str:
|
|
| 52 |
else:
|
| 53 |
hosp_status = "hospital OK"
|
| 54 |
|
| 55 |
-
lag_note = " [
|
| 56 |
lines.append(
|
| 57 |
f" D{d.district_id}: {status} infection={d.reported_infection_rate:.2f}{lag_note} "
|
| 58 |
f"growth={d.growth_rate_hint:.2f} {hosp_status}({d.hospital_capacity_remaining:.2f})"
|
|
@@ -63,45 +62,56 @@ def build_prompt(obs: CityObservation) -> str:
|
|
| 63 |
if not has_data_lag:
|
| 64 |
lines += [
|
| 65 |
"HOW ACTIONS WORK:",
|
| 66 |
-
" - 'allocate': costs 1 resource. REDUCES existing infection AND slows spread.",
|
| 67 |
-
" Infections naturally recover
|
| 68 |
-
"
|
| 69 |
-
" - 'restrict': FREE. Slows future spread. Does NOT reduce existing infection.",
|
| 70 |
-
" Use when
|
| 71 |
-
" - 'test':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
"",
|
| 73 |
-
"
|
| 74 |
-
"
|
| 75 |
-
"2. Find the district with HIGHEST infection rate.",
|
| 76 |
-
"3. If it is above 0.2 and you have resources: 'allocate' on it.",
|
| 77 |
-
"4. Keep allocating to the SAME district next step too.",
|
| 78 |
-
" Only switch when that district drops below 0.2 (safe).",
|
| 79 |
-
"5. If resources = 0: 'restrict' on the highest infected district.",
|
| 80 |
-
"6. NEVER use 'test' β data is real-time and accurate.",
|
| 81 |
-
"7. NEVER restrict a district below 0.2 β you will be penalised.",
|
| 82 |
]
|
| 83 |
else:
|
| 84 |
lines += [
|
| 85 |
"HOW ACTIONS WORK:",
|
| 86 |
" - 'allocate': costs 1 resource. Reduces infection AND slows spread.",
|
| 87 |
-
" Data is 3 days old β act on growth_hint to anticipate true state.",
|
| 88 |
" - 'restrict': FREE. Slows future spread only.",
|
| 89 |
-
" -
|
|
|
|
| 90 |
"",
|
| 91 |
-
"
|
| 92 |
-
"1. If ANY hospital
|
| 93 |
-
"
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
"
|
|
|
|
|
|
|
|
|
|
| 97 |
]
|
| 98 |
|
| 99 |
lines += [
|
| 100 |
"",
|
| 101 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
'{"action_type": "allocate", "district_id": 0}',
|
| 103 |
"",
|
| 104 |
-
"Your
|
| 105 |
]
|
| 106 |
|
| 107 |
return "\n".join(lines)
|
|
@@ -113,14 +123,15 @@ def call_llm(prompt: str, client: OpenAI) -> str:
|
|
| 113 |
messages = [
|
| 114 |
{
|
| 115 |
"role": "system",
|
| 116 |
-
"content":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
},
|
| 118 |
-
{
|
| 119 |
-
"role": "user",
|
| 120 |
-
"content": prompt
|
| 121 |
-
}
|
| 122 |
],
|
| 123 |
-
max_tokens =
|
| 124 |
temperature = 0.1,
|
| 125 |
)
|
| 126 |
return (response.choices[0].message.content or "").strip()
|
|
|
|
| 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
|
| 5 |
from models import CityObservation, ContainmentAction
|
| 6 |
|
|
|
|
| 23 |
)
|
| 24 |
|
| 25 |
lines = [
|
| 26 |
+
"You are an epidemic response coordinator making life-or-death resource decisions.",
|
| 27 |
+
"Your goal: keep infection BELOW 0.40 in all districts and prevent hospital collapse.",
|
| 28 |
"",
|
| 29 |
f"Step {obs.current_step}/{obs.max_steps} | Resources remaining: {obs.available_resources}",
|
| 30 |
+
"βΉοΈ Resources replenish by 1 each step, capped at starting pool. Spend carefully.",
|
|
|
|
| 31 |
"",
|
| 32 |
+
"Districts (sorted by CURRENT infection, highest threat first):",
|
| 33 |
]
|
| 34 |
|
| 35 |
for d in sorted_districts:
|
| 36 |
if d.reported_infection_rate > 0.4:
|
| 37 |
status = "π΄ CRITICAL"
|
| 38 |
elif d.reported_infection_rate > 0.2:
|
| 39 |
+
# Add escalation warning based on growth hint
|
| 40 |
+
if d.growth_rate_hint > 0.06:
|
| 41 |
+
status = "π‘ WARNINGβCRITICAL SOON"
|
| 42 |
+
else:
|
| 43 |
+
status = "π‘ WARNING"
|
| 44 |
else:
|
| 45 |
status = "π’ SAFE"
|
| 46 |
|
|
|
|
| 51 |
else:
|
| 52 |
hosp_status = "hospital OK"
|
| 53 |
|
| 54 |
+
lag_note = " [3-DAY OLD DATA]" if has_data_lag else ""
|
| 55 |
lines.append(
|
| 56 |
f" D{d.district_id}: {status} infection={d.reported_infection_rate:.2f}{lag_note} "
|
| 57 |
f"growth={d.growth_rate_hint:.2f} {hosp_status}({d.hospital_capacity_remaining:.2f})"
|
|
|
|
| 62 |
if not has_data_lag:
|
| 63 |
lines += [
|
| 64 |
"HOW ACTIONS WORK:",
|
| 65 |
+
" - 'allocate': costs 1 resource. REDUCES existing infection by 5% AND slows spread.",
|
| 66 |
+
" Infections naturally recover 1%/day but spread (3-8%/day) dominates without action.",
|
| 67 |
+
" You need SUSTAINED allocation (multiple steps) to drive a district below safe level.",
|
| 68 |
+
" - 'restrict': FREE. Slows future spread only. Does NOT reduce existing infection.",
|
| 69 |
+
" Use only when you have no resources OR for districts already below 0.20.",
|
| 70 |
+
" - 'test': wastes 1 resource. Data is already real-time. NEVER use this.",
|
| 71 |
+
"",
|
| 72 |
+
"DECISION RULES β follow this priority order every step:",
|
| 73 |
+
"1. HOSPITAL EMERGENCY: If ANY hospital < 0.30 capacity β allocate on that district NOW.",
|
| 74 |
+
"2. TRIAGE: Look at ALL districts. Find the one with the HIGHEST infection rate right now.",
|
| 75 |
+
" That is your target this step. Do not stick to the same district if another is worse.",
|
| 76 |
+
"3. CRITICAL DISTRICT (above 0.40 and have resources): allocate on the highest.",
|
| 77 |
+
"4. WARNING DISTRICT (0.20-0.40) with growth > 0.06 AND resources available:",
|
| 78 |
+
" allocate on it NOW to prevent it from becoming CRITICAL next step.",
|
| 79 |
+
"5. If resources = 0: restrict on the highest infected district.",
|
| 80 |
+
"6. NEVER restrict a district below 0.20 β you will be penalised.",
|
| 81 |
+
"7. NEVER use 'test' β it wastes a resource you cannot afford.",
|
| 82 |
"",
|
| 83 |
+
"KEY INSIGHT: Infection spreads 3-8% per day. A WARNING district at 0.38 with",
|
| 84 |
+
"growth=0.07 will be CRITICAL next step. Act before it escalates, not after.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
]
|
| 86 |
else:
|
| 87 |
lines += [
|
| 88 |
"HOW ACTIONS WORK:",
|
| 89 |
" - 'allocate': costs 1 resource. Reduces infection AND slows spread.",
|
|
|
|
| 90 |
" - 'restrict': FREE. Slows future spread only.",
|
| 91 |
+
" - Data is 3 DAYS OLD β you cannot see current true infection rates.",
|
| 92 |
+
" Use growth_hint to estimate which districts are getting worse fastest.",
|
| 93 |
"",
|
| 94 |
+
"DECISION RULES for delayed-information scenario:",
|
| 95 |
+
"1. HOSPITAL EMERGENCY: If ANY hospital < 0.30 β allocate on that district NOW.",
|
| 96 |
+
" Hospital capacity IS real-time even when infection data is lagged.",
|
| 97 |
+
"2. TRIAGE under uncertainty: Combine lagged infection + growth_hint to estimate severity.",
|
| 98 |
+
" A district with infection=0.20 (3 days ago) and growth=0.08 is NOW likely at ~0.44.",
|
| 99 |
+
" Formula: estimated_current = reported_infection + 3 Γ growth_hint",
|
| 100 |
+
"3. Allocate on the district with HIGHEST estimated current infection.",
|
| 101 |
+
"4. If resources = 0: restrict on the district with highest growth_hint.",
|
| 102 |
+
"5. NEVER use 'test' β the 3-day lag is structural, testing does not help.",
|
| 103 |
]
|
| 104 |
|
| 105 |
lines += [
|
| 106 |
"",
|
| 107 |
+
"Think briefly (1 sentence): Which district is most dangerous RIGHT NOW and why?",
|
| 108 |
+
"Then give your JSON decision.",
|
| 109 |
+
"",
|
| 110 |
+
"Example response:",
|
| 111 |
+
'District 0 is critical at 0.65 and growing fastest.',
|
| 112 |
'{"action_type": "allocate", "district_id": 0}',
|
| 113 |
"",
|
| 114 |
+
"Your response:",
|
| 115 |
]
|
| 116 |
|
| 117 |
return "\n".join(lines)
|
|
|
|
| 123 |
messages = [
|
| 124 |
{
|
| 125 |
"role": "system",
|
| 126 |
+
"content": (
|
| 127 |
+
"You are an epidemic response AI. "
|
| 128 |
+
"First write one sentence of reasoning, then a JSON action on the next line. "
|
| 129 |
+
"JSON must be valid and contain action_type and district_id."
|
| 130 |
+
)
|
| 131 |
},
|
| 132 |
+
{"role": "user", "content": prompt}
|
|
|
|
|
|
|
|
|
|
| 133 |
],
|
| 134 |
+
max_tokens = 120, # increased to allow brief reasoning + JSON
|
| 135 |
temperature = 0.1,
|
| 136 |
)
|
| 137 |
return (response.choices[0].message.content or "").strip()
|
core/trajectory.py
CHANGED
|
@@ -1,14 +1,6 @@
|
|
| 1 |
# core/trajectory.py
|
| 2 |
-
|
| 3 |
-
# Episodic memory for GRPO-style simulated learning.
|
| 4 |
-
# Stores high-reward (observation, action, reward) tuples from past rollouts.
|
| 5 |
-
# Retrieved at each step to provide instance-level guidance to the policy.
|
| 6 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
-
|
| 8 |
-
import os
|
| 9 |
-
import sys
|
| 10 |
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 11 |
-
|
| 12 |
from typing import List
|
| 13 |
from models import ContainmentAction, CityObservation
|
| 14 |
|
|
@@ -16,11 +8,10 @@ from models import ContainmentAction, CityObservation
|
|
| 16 |
class EpisodicMemory:
|
| 17 |
"""
|
| 18 |
Stores high-reward steps from past rollouts.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
allocating to district 0 earned +0.3"
|
| 24 |
"""
|
| 25 |
|
| 26 |
def __init__(self, max_size: int = 20):
|
|
@@ -28,23 +19,31 @@ class EpisodicMemory:
|
|
| 28 |
self.max_size = max_size
|
| 29 |
|
| 30 |
def store(self, obs: CityObservation, action: ContainmentAction, reward: float):
|
| 31 |
-
"""Store a step only if it earned positive reward."""
|
| 32 |
-
if reward <
|
| 33 |
return
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
self.memories.append({
|
| 36 |
"infection_profile": [round(d.reported_infection_rate, 2) for d in obs.districts],
|
| 37 |
"resources": obs.available_resources,
|
|
|
|
| 38 |
"action_type": action.action_type,
|
| 39 |
"district_id": action.district_id,
|
| 40 |
"reward": round(reward, 4),
|
|
|
|
|
|
|
|
|
|
| 41 |
})
|
| 42 |
|
| 43 |
# Keep only the highest-reward memories
|
| 44 |
self.memories.sort(key=lambda m: m["reward"], reverse=True)
|
| 45 |
self.memories = self.memories[:self.max_size]
|
| 46 |
|
| 47 |
-
def retrieve(self, obs: CityObservation, top_k: int =
|
| 48 |
"""
|
| 49 |
Find stored memories most similar to the current observation.
|
| 50 |
Similarity = L1 distance between infection profiles.
|
|
@@ -54,27 +53,30 @@ class EpisodicMemory:
|
|
| 54 |
return ""
|
| 55 |
|
| 56 |
current = [round(d.reported_infection_rate, 2) for d in obs.districts]
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
def
|
| 59 |
profile = memory["infection_profile"]
|
| 60 |
if len(profile) != len(current):
|
| 61 |
return float("inf")
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
ranked = sorted(self.memories, key=
|
| 65 |
top = ranked[:top_k]
|
| 66 |
|
| 67 |
-
lines
|
| 68 |
for m in top:
|
| 69 |
lines.append(
|
| 70 |
-
f"
|
| 71 |
-
f"'{m['action_type']}'
|
| 72 |
-
f"β reward {m['reward']:+.4f}"
|
| 73 |
)
|
| 74 |
return "\n".join(lines)
|
| 75 |
|
| 76 |
def clear(self):
|
| 77 |
-
"""Clear memory between tasks β memories are task-specific."""
|
| 78 |
self.memories = []
|
| 79 |
|
| 80 |
def __len__(self) -> int:
|
|
|
|
| 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
|
| 5 |
from models import ContainmentAction, CityObservation
|
| 6 |
|
|
|
|
| 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 |
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.0: # 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 |
+
|
| 30 |
self.memories.append({
|
| 31 |
"infection_profile": [round(d.reported_infection_rate, 2) for d in obs.districts],
|
| 32 |
"resources": obs.available_resources,
|
| 33 |
+
"phase": phase,
|
| 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.
|
|
|
|
| 53 |
return ""
|
| 54 |
|
| 55 |
current = [round(d.reported_infection_rate, 2) for d in obs.districts]
|
| 56 |
+
phase = "early" if obs.current_step <= obs.max_steps // 3 else \
|
| 57 |
+
"mid" if obs.current_step <= 2 * obs.max_steps // 3 else "late"
|
| 58 |
|
| 59 |
+
def score(memory: dict) -> float:
|
| 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 |
|
| 68 |
+
ranked = sorted(self.memories, key=score)
|
| 69 |
top = ranked[:top_k]
|
| 70 |
|
| 71 |
+
lines = ["Past decisions that earned positive reward (use as guidance):"]
|
| 72 |
for m in top:
|
| 73 |
lines.append(
|
| 74 |
+
f" Phase={m.get('phase','?')} Profile={m['infection_profile']} resources={m['resources']}: "
|
| 75 |
+
f"'{m['action_type']}' D{m['district_id']} β reward {m['reward']:+.2f}"
|
|
|
|
| 76 |
)
|
| 77 |
return "\n".join(lines)
|
| 78 |
|
| 79 |
def clear(self):
|
|
|
|
| 80 |
self.memories = []
|
| 81 |
|
| 82 |
def __len__(self) -> int:
|