RohitChandramouli6618 commited on
Commit
f27fd6a
Β·
1 Parent(s): d795c8e

Improve agent: fix strategy rule, chain-of-thought, 8 rollouts, better memory

Browse files
Files changed (3) hide show
  1. baseline/evaluator.py +22 -67
  2. baseline/policy.py +54 -43
  3. 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
- "\n"
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: Any,
51
- task_name: str,
52
- client: OpenAI,
53
- memory: EpisodicMemory,
54
- verbose: bool = True,
55
  ) -> Tuple[float, int, List[dict]]:
56
- """Run one complete episode using memory-augmented prompts."""
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 = env.step(action)
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 rollout early")
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: Any,
108
- task_name: str,
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(env, task_name, client, memory, verbose)
 
 
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
- if len(completed_rewards) > 1 else total_reward
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 = max(rollouts, key=lambda x: x[2])[2]
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
- # ── Full Evaluator ────────────────────────────────────────────────────────────
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: reduce infection in all districts and prevent hospital collapse.",
32
  "",
33
  f"Step {obs.current_step}/{obs.max_steps} | Resources remaining: {obs.available_resources}",
34
- "ℹ️ Resources replenish by 1 each step but cannot exceed your starting pool.",
35
- " Spend wisely β€” you can never have more resources than the starting amount.",
36
  "",
37
- "Districts (sorted by infection rate, highest first):",
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
- status = "🟑 WARNING"
 
 
 
 
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 = " [DATA IS 3 DAYS OLD]" if has_data_lag else ""
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 slightly each day, but spread dominates",
68
- " without intervention. Sustained allocation is needed to drive below safe.",
69
- " - 'restrict': FREE. Slows future spread. Does NOT reduce existing infection.",
70
- " Use when resources are low or for WARNING districts.",
71
- " - 'test': costs 1 resource. Reveals accurate data. (Not needed β€” data is real-time.)",
 
 
 
 
 
 
 
 
 
 
 
72
  "",
73
- "STRATEGY β€” follow in order:",
74
- "1. If ANY hospital is below 0.3 capacity: 'allocate' on that district IMMEDIATELY.",
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
- " - 'test': costs 1 resource. (NOT recommended β€” lag is unavoidable.)",
 
90
  "",
91
- "STRATEGY β€” follow in order:",
92
- "1. If ANY hospital is below 0.3 capacity: 'allocate' on that district IMMEDIATELY.",
93
- "2. If resources > 0: 'allocate' on the district with HIGHEST growth_hint.",
94
- " High growth_hint means true infection is accelerating β€” act early.",
95
- "3. If resources = 0: 'restrict' on the district with highest growth_hint.",
96
- "4. NEVER use 'test' β€” spending resources on information wastes your budget.",
 
 
 
97
  ]
98
 
99
  lines += [
100
  "",
101
- "Respond with ONLY valid JSON. No explanation. Example:",
 
 
 
 
102
  '{"action_type": "allocate", "district_id": 0}',
103
  "",
104
- "Your decision:",
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": "You are an epidemic response AI. Always respond with valid JSON only. No explanation, no markdown."
 
 
 
 
117
  },
118
- {
119
- "role": "user",
120
- "content": prompt
121
- }
122
  ],
123
- max_tokens = 50,
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
- Retrieved by similarity to current observation to guide next rollout.
20
-
21
- This is the contextual bandit component β€” the agent gets specific examples:
22
- "last time infection was [0.45, 0.12] with 7 resources,
23
- allocating to district 0 earned +0.3"
24
  """
25
 
26
  def __init__(self, max_size: int = 20):
@@ -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 < -0.3:
33
  return
34
 
 
 
 
 
35
  self.memories.append({
36
  "infection_profile": [round(d.reported_infection_rate, 2) for d in obs.districts],
37
  "resources": obs.available_resources,
 
38
  "action_type": action.action_type,
39
  "district_id": action.district_id,
40
  "reward": round(reward, 4),
 
 
 
41
  })
42
 
43
  # Keep only the highest-reward memories
44
  self.memories.sort(key=lambda m: m["reward"], reverse=True)
45
  self.memories = self.memories[:self.max_size]
46
 
47
- def retrieve(self, obs: CityObservation, top_k: int = 3) -> str:
48
  """
49
  Find stored memories most similar to the current observation.
50
  Similarity = L1 distance between infection profiles.
@@ -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 l1_distance(memory: dict) -> float:
59
  profile = memory["infection_profile"]
60
  if len(profile) != len(current):
61
  return float("inf")
62
- return sum(abs(a - b) for a, b in zip(profile, current))
 
 
 
63
 
64
- ranked = sorted(self.memories, key=l1_distance)
65
  top = ranked[:top_k]
66
 
67
- lines = ["Relevant past decisions (from successful rollouts):"]
68
  for m in top:
69
  lines.append(
70
- f" - Profile {m['infection_profile']} | resources={m['resources']}: "
71
- f"'{m['action_type']}' on district {m['district_id']} "
72
- f"β†’ reward {m['reward']:+.4f}"
73
  )
74
  return "\n".join(lines)
75
 
76
  def clear(self):
77
- """Clear memory between tasks β€” memories are task-specific."""
78
  self.memories = []
79
 
80
  def __len__(self) -> int:
 
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: