amanmurari commited on
Commit
10fb04e
·
verified ·
1 Parent(s): b65e0d9

Upload folder using huggingface_hub

Browse files
Files changed (1) hide show
  1. inference.py +308 -55
inference.py CHANGED
@@ -4,8 +4,10 @@ import os
4
  import sys
5
  import json
6
  import time
 
7
  import urllib.request
8
- from typing import List, Optional
 
9
 
10
  # Allow imports from repo root so both container-root and package contexts work
11
  _HERE = os.path.dirname(os.path.abspath(__file__))
@@ -18,88 +20,325 @@ from openai import OpenAI
18
 
19
  try:
20
  from traffic_control.client import TrafficControlEnv
21
- from traffic_control.models import TrafficAction, TrafficObservation
22
  except ImportError:
23
  from client import TrafficControlEnv # type: ignore
24
- from models import TrafficAction, TrafficObservation # type: ignore
25
 
26
  # ---------------------------------------------------------------------------
27
- # Environment variables — MUST be injected by the hackathon validator
28
  # ---------------------------------------------------------------------------
29
  API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
30
  API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
31
  MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
32
  SERVER_URL = os.getenv("SERVER_URL", "http://localhost:7860")
33
  SEED = 42
34
- MAX_TOKENS = 64
35
  TEMPERATURE = 0.0
36
 
 
 
 
 
 
 
 
37
  # ---------------------------------------------------------------------------
38
- # LLM Prompt
39
  # ---------------------------------------------------------------------------
40
 
41
- SYSTEM_PROMPT = """You are an expert Autonomous Traffic Signal Controller.
 
 
42
 
43
- PHASES:
44
- 0 = North-South Green
45
- 1 = East-West Green
46
- 2 = All Red
47
 
48
- SCORING:
49
- +0.2 per regular vehicle cleared
50
- +10.0 per emergency vehicle cleared
51
- -0.4 * urgency per step emergency waits
52
- -0.5 for switching to empty queue
53
 
54
- OUTPUT: {"light_phase": 0, 1, or 2} only JSON, no other text."""
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
- def _build_prompt(obs: TrafficObservation, step: int) -> str:
58
  return (
59
- f"Step {step}, phase={obs.current_phase}, time={obs.time_in_phase}\n"
60
- f"Queues N,S,E,W: {list(obs.queue_lengths)}\n"
61
- f"Emergency N,S,E,W: {list(obs.emergency_queue)} urgency={list(obs.emergency_urgency)}\n"
62
- f"What phase? Return only JSON: {{\"light_phase\": 0, 1, or 2}}"
 
 
 
 
 
 
 
 
 
 
63
  )
64
 
65
 
 
 
 
 
66
  def _sanitize(s: str) -> str:
67
- """Remove characters that break log parsing."""
68
  return s.replace('"', "'").replace("\n", " ")
69
 
70
 
71
- def _parse_phase(raw: str) -> int:
72
- """Extract phase from LLM response."""
73
- try:
74
- data = json.loads(raw)
75
- phase = int(data.get("light_phase", data.get("phase", 0)))
76
- return max(0, min(2, phase))
77
- except Exception:
78
- import re
79
- m = re.search(r'\b([012])\b', raw)
80
- return int(m.group(1)) if m else 0
81
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
- def get_llm_action(client: OpenAI, obs: TrafficObservation, step: int) -> TrafficAction:
84
- """Call LLM proxy for a traffic phase decision."""
85
  resp = client.chat.completions.create(
86
  model=MODEL_NAME,
87
  messages=[
88
  {"role": "system", "content": SYSTEM_PROMPT},
89
- {"role": "user", "content": _build_prompt(obs, step)},
90
  ],
91
  temperature=TEMPERATURE,
92
  max_tokens=MAX_TOKENS,
93
  )
94
- raw = resp.choices[0].message.content.strip()
95
  phase = _parse_phase(raw)
 
 
 
 
 
96
  return TrafficAction(light_phase=phase)
97
 
98
 
 
 
 
 
99
  def _wait_for_server(url: str, timeout: int = 60) -> None:
100
- """Block until the env server is healthy or timeout expires."""
101
  health_url = url.rstrip("/") + "/health"
102
- deadline = time.time() + timeout
103
  while time.time() < deadline:
104
  try:
105
  with urllib.request.urlopen(health_url, timeout=3) as r:
@@ -108,22 +347,25 @@ def _wait_for_server(url: str, timeout: int = 60) -> None:
108
  except Exception:
109
  pass
110
  time.sleep(2)
111
- # If the server never came up, log and continue anyway
112
  print(f"[WARN] Server not healthy after {timeout}s — proceeding anyway", flush=True)
113
 
114
 
 
 
 
 
115
  def run_task(task: str, client: OpenAI) -> dict:
116
- """Run a single task episode."""
117
  print(f'[START] task={task} env=traffic_control model={MODEL_NAME}', flush=True)
118
 
119
- rewards: List[float] = []
120
- step = 0
121
- last_error: Optional[str] = None
122
- done = False
 
 
123
 
124
  try:
125
  with TrafficControlEnv(base_url=SERVER_URL).sync() as env:
126
- # env.reset() returns a StepResult; unwrap the observation
127
  reset_result = env.reset(task_id=task, seed=SEED)
128
  obs: TrafficObservation = reset_result.observation
129
  done = reset_result.done
@@ -131,18 +373,32 @@ def run_task(task: str, client: OpenAI) -> dict:
131
  while not done:
132
  step += 1
133
 
134
- # Call the LLM proxy this is the call the validator monitors
135
- action = get_llm_action(client, obs, step)
 
 
 
 
 
 
136
  action_str = f"light_phase={action.light_phase}"
137
 
138
  try:
139
- # env.step() returns a StepResult; unwrap the observation
140
- result = env.step(action)
141
- obs = result.observation # TrafficObservation
142
  reward_val = result.reward if result.reward is not None else 0.0
143
  rewards.append(reward_val)
144
  done = result.done
145
  last_error = None
 
 
 
 
 
 
 
 
 
146
  except Exception as exc:
147
  reward_val = 0.0
148
  done = True
@@ -182,11 +438,8 @@ def run_task(task: str, client: OpenAI) -> dict:
182
  # ---------------------------------------------------------------------------
183
 
184
  def main():
185
- """Main entry point."""
186
- # Wait for the env server to be ready before starting inference
187
  _wait_for_server(SERVER_URL)
188
 
189
- # Initialize OpenAI client with hackathon-injected proxy credentials
190
  client = OpenAI(
191
  base_url=API_BASE_URL,
192
  api_key=API_KEY,
 
4
  import sys
5
  import json
6
  import time
7
+ import math
8
  import urllib.request
9
+ from collections import deque
10
+ from typing import Deque, List, Optional, Tuple
11
 
12
  # Allow imports from repo root so both container-root and package contexts work
13
  _HERE = os.path.dirname(os.path.abspath(__file__))
 
20
 
21
  try:
22
  from traffic_control.client import TrafficControlEnv
23
+ from traffic_control.models import TrafficAction, TrafficObservation, TrafficState
24
  except ImportError:
25
  from client import TrafficControlEnv # type: ignore
26
+ from models import TrafficAction, TrafficObservation, TrafficState # type: ignore
27
 
28
  # ---------------------------------------------------------------------------
29
+ # Environment variables — injected by hackathon validator
30
  # ---------------------------------------------------------------------------
31
  API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
32
  API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
33
  MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
34
  SERVER_URL = os.getenv("SERVER_URL", "http://localhost:7860")
35
  SEED = 42
36
+ MAX_TOKENS = 256 # enough for chain-of-thought + JSON
37
  TEMPERATURE = 0.0
38
 
39
+ # Minimum green hold steps (ensures stability_bonus in grading)
40
+ MIN_GREEN_HOLD = 4
41
+
42
+ # Task max_steps for score projection
43
+ TASK_MAX_STEPS = {"basic_flow": 200, "emergency_priority": 300, "dynamic_scenarios": 400}
44
+
45
+
46
  # ---------------------------------------------------------------------------
47
+ # Heuristic core — uses actual environment reward formula
48
  # ---------------------------------------------------------------------------
49
 
50
+ def _em_pressure(count: int, urgency: int) -> float:
51
+ """Emergency pressure = count × urgency^1.5 × 0.5 (matches env._compute_reward)."""
52
+ return count * (max(urgency, 1) ** 1.5) * 0.5 if count > 0 else 0.0
53
 
 
 
 
 
54
 
55
+ def _dir_pressure(queue: int, em_count: int, urgency: int) -> float:
56
+ """Combined directional pressure: throughput value + weighted emergency penalty."""
57
+ return queue * 0.30 + _em_pressure(em_count, urgency) * 4.0
 
 
58
 
 
59
 
60
+ def _compute_pressures(obs: TrafficObservation) -> Tuple[float, float]:
61
+ """Return (ns_pressure, ew_pressure)."""
62
+ ns_p = _dir_pressure(
63
+ obs.queue_lengths[0] + obs.queue_lengths[1],
64
+ obs.emergency_queue[0] + obs.emergency_queue[1],
65
+ max(obs.emergency_urgency[0], obs.emergency_urgency[1]),
66
+ )
67
+ ew_p = _dir_pressure(
68
+ obs.queue_lengths[2] + obs.queue_lengths[3],
69
+ obs.emergency_queue[2] + obs.emergency_queue[3],
70
+ max(obs.emergency_urgency[2], obs.emergency_urgency[3]),
71
+ )
72
+ return ns_p, ew_p
73
+
74
+
75
+ def _dynamic_hold_time(obs: TrafficObservation) -> int:
76
+ """
77
+ Adaptive minimum hold: longer when current direction has more traffic
78
+ so we don't leave vehicles stranded mid-queue.
79
+ """
80
+ current = obs.current_phase
81
+ if current in (0, 3): # NS_GREEN / NS_YELLOW
82
+ q = obs.queue_lengths[0] + obs.queue_lengths[1]
83
+ elif current in (1, 4): # EW_GREEN / EW_YELLOW
84
+ q = obs.queue_lengths[2] + obs.queue_lengths[3]
85
+ else:
86
+ return MIN_GREEN_HOLD
87
+ # Hold longer if queue is deep (drain rate ~3 veh/step)
88
+ return max(MIN_GREEN_HOLD, min(q // 3, 10))
89
+
90
+
91
+ def _heuristic_phase(obs: TrafficObservation) -> int:
92
+ """
93
+ Mathematically optimal phase recommendation.
94
+ Priority order:
95
+ 1. Critical emergency (urgency ≥ 8) — clear NOW
96
+ 2. Moderate emergency (urgency 5-7) — prioritise unless other side is worse
97
+ 3. Hysteresis — don't switch if hold time not reached
98
+ 4. Queue pressure — switch to higher pressure direction
99
+ 5. Default — hold current
100
+ """
101
+ ns_em = obs.emergency_queue[0] + obs.emergency_queue[1]
102
+ ew_em = obs.emergency_queue[2] + obs.emergency_queue[3]
103
+ ns_urg = max(obs.emergency_urgency[0], obs.emergency_urgency[1])
104
+ ew_urg = max(obs.emergency_urgency[2], obs.emergency_urgency[3])
105
+ cur = obs.current_phase
106
+
107
+ # 1. Critical emergency
108
+ if ns_em > 0 and ns_urg >= 8 and ew_em > 0 and ew_urg >= 8:
109
+ return 2 # ALL_RED: both critical, momentary pause to avoid collision
110
+ if ns_em > 0 and ns_urg >= 8:
111
+ return 0
112
+ if ew_em > 0 and ew_urg >= 8:
113
+ return 1
114
+
115
+ # 2. Moderate emergency — switch if other side isn't also urgent
116
+ if ns_em > 0 and ns_urg >= 5:
117
+ if ew_em == 0 or ns_urg >= ew_urg:
118
+ return 0
119
+ if ew_em > 0 and ew_urg >= 5:
120
+ return 1
121
+
122
+ # 3. Hysteresis
123
+ hold = _dynamic_hold_time(obs)
124
+ if obs.time_in_phase < hold:
125
+ if cur in (0, 3): return 0
126
+ if cur in (1, 4): return 1
127
+
128
+ # 4. Queue pressure
129
+ ns_p, ew_p = _compute_pressures(obs)
130
+ if ns_p > ew_p * 1.3:
131
+ return 0
132
+ if ew_p > ns_p * 1.3:
133
+ return 1
134
+
135
+ # 5. Hold current
136
+ if cur in (0, 3): return 0
137
+ if cur in (1, 4): return 1
138
+ return 0
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Live grade projection (shows LLM how its decisions impact the final score)
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def _project_score(task: str, state: Optional[TrafficState], step: int) -> str:
146
+ """Compute projected grading scores from current episode state."""
147
+ if state is None or step == 0:
148
+ return "(no data yet)"
149
+
150
+ s = state
151
+ steps = max(s.step_count, 1)
152
+ max_s = TASK_MAX_STEPS.get(task, 300)
153
+
154
+ throughput_per_step = s.total_vehicles_passed / steps
155
+ em_rate = s.total_emergency_passed / steps
156
+ avg_wait = s.total_waiting_time / steps
157
+
158
+ if task == "basic_flow":
159
+ tput = min(throughput_per_step / 1.8, 1.0)
160
+ eff = 1.0 / (1.0 + avg_wait * 0.1)
161
+ sw = s.total_phase_changes / steps
162
+ stab = max(0.0, 0.05 * (1.0 - min(sw * 4, 1.0)))
163
+ proj = tput * 0.6 + eff * 0.4 + stab
164
+ return (
165
+ f"throughput={tput:.2f}(×0.6) eff={eff:.2f}(×0.4) stability={stab:.3f} "
166
+ f"→ projected={proj:.3f} "
167
+ f"[veh/step={throughput_per_step:.2f} target=1.8, switch_rate={sw:.2f} target<0.25]"
168
+ )
169
+
170
+ if task == "emergency_priority":
171
+ tput = min(throughput_per_step / 1.5, 1.0)
172
+ em_score = min(em_rate / (1.0 / 20.0), 1.0)
173
+ if s.total_emergency_passed > 0:
174
+ delay = max(0.0, 1.0 - (s.total_emergency_delay / s.total_emergency_passed) / 12.0)
175
+ else:
176
+ delay = 0.5
177
+ eff = 1.0 / (1.0 + avg_wait * 0.05)
178
+ proj = tput * 0.30 + em_score * 0.35 + delay * 0.20 + eff * 0.15
179
+ return (
180
+ f"tput={tput:.2f}(×0.30) em_rate={em_score:.2f}(×0.35) "
181
+ f"delay={delay:.2f}(×0.20) eff={eff:.2f}(×0.15) → projected={proj:.3f} "
182
+ f"[em_cleared={s.total_emergency_passed} need≥{steps//20}]"
183
+ )
184
+
185
+ if task == "dynamic_scenarios":
186
+ tput = min(throughput_per_step / 2.0, 1.0)
187
+ em_score = min(em_rate / (1.0 / 15.0), 1.0)
188
+ if s.total_emergency_passed > 0:
189
+ delay = max(0.0, 1.0 - (s.total_emergency_delay / s.total_emergency_passed) / 5.0)
190
+ else:
191
+ delay = 0.0
192
+ eff = 1.0 / (1.0 + avg_wait * 0.08)
193
+ adapt = 1.0 / (1.0 + (s.total_phase_changes / steps) * 0.5)
194
+ proj = tput * 0.25 + em_score * 0.30 + delay * 0.20 + eff * 0.15 + adapt * 0.10
195
+ return (
196
+ f"tput={tput:.2f}(×0.25) em={em_score:.2f}(×0.30) delay={delay:.2f}(×0.20) "
197
+ f"eff={eff:.2f}(×0.15) adapt={adapt:.2f}(×0.10) → projected={proj:.3f}"
198
+ )
199
+
200
+ return "(unknown task)"
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # LLM prompt
205
+ # ---------------------------------------------------------------------------
206
+
207
+ SYSTEM_PROMPT = """You are an expert Autonomous Traffic Signal Controller optimising a 4-way intersection.
208
+
209
+ PHASES: 0=NS_GREEN 1=EW_GREEN 2=ALL_RED
210
+
211
+ REWARD FUNCTION (per step):
212
+ +0.30 × regular vehicles cleared
213
+ +12.0 × emergency vehicles cleared
214
+ -(urgency^1.5)×0.5 per waiting emergency vehicle (EVERY step it waits!)
215
+ -0.08 × total vehicles waiting
216
+ -0.5 to -2.0 for unnecessary phase switch (proportional to how empty the new direction is)
217
+ +0.05 stability bonus when traffic flows without switching
218
+
219
+ GRADING WEIGHTS:
220
+ basic_flow: throughput×0.60 efficiency×0.40 stability_bonus
221
+ emergency_priority: throughput×0.30 em_rate×0.35 delay×0.20 efficiency×0.15
222
+ dynamic_scenarios: throughput×0.25 em_rate×0.30 delay×0.20 efficiency×0.15 adaptability×0.10
223
+
224
+ DECISION RULES (follow strictly):
225
+ 1. Urgency ≥ 8 emergency → switch to that direction IMMEDIATELY
226
+ 2. Urgency 5-7 emergency → switch unless other side is equally urgent
227
+ 3. Hold current phase ≥ 4 steps before switching (preserves stability bonus)
228
+ 4. Switch only when pressure ratio > 1.3× (avoid unnecessary switches)
229
+ 5. ALL_RED only when both directions have simultaneous critical emergencies
230
+
231
+ Think step by step, then output ONLY valid JSON on the last line: {"light_phase": 0}"""
232
+
233
+
234
+ def _build_prompt(
235
+ obs: TrafficObservation,
236
+ step: int,
237
+ task: str,
238
+ history: Deque[str],
239
+ heuristic: int,
240
+ score_projection: str,
241
+ ) -> str:
242
+ ns_p, ew_p = _compute_pressures(obs)
243
+ ns_q = obs.queue_lengths[0] + obs.queue_lengths[1]
244
+ ew_q = obs.queue_lengths[2] + obs.queue_lengths[3]
245
+ ns_em = obs.emergency_queue[0] + obs.emergency_queue[1]
246
+ ew_em = obs.emergency_queue[2] + obs.emergency_queue[3]
247
+ ns_urg = max(obs.emergency_urgency[0], obs.emergency_urgency[1])
248
+ ew_urg = max(obs.emergency_urgency[2], obs.emergency_urgency[3])
249
+
250
+ phase_name = {0:"NS_GREEN",1:"EW_GREEN",2:"ALL_RED",3:"NS_YELLOW",4:"EW_YELLOW"}
251
+ hint_name = {0:"NS_GREEN (0)",1:"EW_GREEN (1)",2:"ALL_RED (2)"}
252
+ trend_str = f"[{obs.queue_trend[0]:+d},{obs.queue_trend[1]:+d},{obs.queue_trend[2]:+d},{obs.queue_trend[3]:+d}]"
253
+
254
+ history_str = "\n".join(history) if history else " (episode start)"
255
 
 
256
  return (
257
+ f"TASK: {task} | Step {step} | Current phase: {phase_name.get(obs.current_phase,'?')} (held {obs.time_in_phase} steps)\n"
258
+ f"\n"
259
+ f"STATE:\n"
260
+ f" NS: {ns_q} regular + {ns_em} emergency(urgency={ns_urg}) pressure={ns_p:.1f}\n"
261
+ f" EW: {ew_q} regular + {ew_em} emergency(urgency={ew_urg}) pressure={ew_p:.1f}\n"
262
+ f" Queues [N,S,E,W]: {list(obs.queue_lengths)} trend={trend_str}\n"
263
+ f" Avg wait: {obs.avg_wait_time:.1f} steps | Collision: {obs.collision}\n"
264
+ f"\n"
265
+ f"LIVE SCORE PROJECTION:\n {score_projection}\n"
266
+ f"\n"
267
+ f"RECENT HISTORY (last {len(history)} steps):\n{history_str}\n"
268
+ f"\n"
269
+ f"Heuristic recommendation: {hint_name[heuristic]}\n"
270
+ f"Reason through the decision, then output JSON on the last line."
271
  )
272
 
273
 
274
+ # ---------------------------------------------------------------------------
275
+ # Parse + LLM call
276
+ # ---------------------------------------------------------------------------
277
+
278
  def _sanitize(s: str) -> str:
 
279
  return s.replace('"', "'").replace("\n", " ")
280
 
281
 
282
+ def _parse_phase(raw: str) -> Optional[int]:
283
+ """Extract phase from LLM chain-of-thought output (JSON on last line)."""
284
+ import re
285
+ # Try last non-empty line first (chain-of-thought ends with JSON)
286
+ lines = [l.strip() for l in raw.split("\n") if l.strip()]
287
+ for line in reversed(lines):
288
+ try:
289
+ data = json.loads(line)
290
+ p = int(data.get("light_phase", data.get("phase", -1)))
291
+ if 0 <= p <= 2:
292
+ return p
293
+ except Exception:
294
+ pass
295
+ # Fallback: regex anywhere
296
+ m = re.search(r'"light_phase"\s*:\s*([012])', raw)
297
+ if m:
298
+ return int(m.group(1))
299
+ m = re.search(r'\b([012])\b', raw)
300
+ if m:
301
+ return int(m.group(1))
302
+ return None
303
+
304
+
305
+ def get_llm_action(
306
+ client: OpenAI,
307
+ obs: TrafficObservation,
308
+ step: int,
309
+ task: str,
310
+ history: Deque[str],
311
+ state: Optional[TrafficState],
312
+ ) -> TrafficAction:
313
+ heuristic = _heuristic_phase(obs)
314
+ score_proj = _project_score(task, state, step)
315
 
 
 
316
  resp = client.chat.completions.create(
317
  model=MODEL_NAME,
318
  messages=[
319
  {"role": "system", "content": SYSTEM_PROMPT},
320
+ {"role": "user", "content": _build_prompt(obs, step, task, history, heuristic, score_proj)},
321
  ],
322
  temperature=TEMPERATURE,
323
  max_tokens=MAX_TOKENS,
324
  )
325
+ raw = resp.choices[0].message.content.strip()
326
  phase = _parse_phase(raw)
327
+
328
+ # Fallback to heuristic if LLM output is unparseable or clearly wrong
329
+ if phase is None:
330
+ phase = heuristic
331
+
332
  return TrafficAction(light_phase=phase)
333
 
334
 
335
+ # ---------------------------------------------------------------------------
336
+ # Server health check
337
+ # ---------------------------------------------------------------------------
338
+
339
  def _wait_for_server(url: str, timeout: int = 60) -> None:
 
340
  health_url = url.rstrip("/") + "/health"
341
+ deadline = time.time() + timeout
342
  while time.time() < deadline:
343
  try:
344
  with urllib.request.urlopen(health_url, timeout=3) as r:
 
347
  except Exception:
348
  pass
349
  time.sleep(2)
 
350
  print(f"[WARN] Server not healthy after {timeout}s — proceeding anyway", flush=True)
351
 
352
 
353
+ # ---------------------------------------------------------------------------
354
+ # Episode runner
355
+ # ---------------------------------------------------------------------------
356
+
357
  def run_task(task: str, client: OpenAI) -> dict:
 
358
  print(f'[START] task={task} env=traffic_control model={MODEL_NAME}', flush=True)
359
 
360
+ rewards: List[float] = []
361
+ history: Deque[str] = deque(maxlen=6)
362
+ step = 0
363
+ last_error: Optional[str] = None
364
+ done = False
365
+ state: Optional[TrafficState] = None
366
 
367
  try:
368
  with TrafficControlEnv(base_url=SERVER_URL).sync() as env:
 
369
  reset_result = env.reset(task_id=task, seed=SEED)
370
  obs: TrafficObservation = reset_result.observation
371
  done = reset_result.done
 
373
  while not done:
374
  step += 1
375
 
376
+ # Refresh cumulative state every 10 steps for score projection
377
+ if step % 10 == 1:
378
+ try:
379
+ state = env.state()
380
+ except Exception:
381
+ pass
382
+
383
+ action = get_llm_action(client, obs, step, task, history, state)
384
  action_str = f"light_phase={action.light_phase}"
385
 
386
  try:
387
+ result = env.step(action)
388
+ obs = result.observation
 
389
  reward_val = result.reward if result.reward is not None else 0.0
390
  rewards.append(reward_val)
391
  done = result.done
392
  last_error = None
393
+
394
+ phase_name = {0:"NS",1:"EW",2:"AR",3:"NSy",4:"EWy"}
395
+ history.append(
396
+ f" s{step}: →{action.light_phase}"
397
+ f" clr={obs.vehicles_passed}r+{obs.emergency_passed}em"
398
+ f" r={reward_val:+.1f}"
399
+ f" now={phase_name.get(obs.current_phase,'?')}"
400
+ f" queues={list(obs.queue_lengths)}"
401
+ )
402
  except Exception as exc:
403
  reward_val = 0.0
404
  done = True
 
438
  # ---------------------------------------------------------------------------
439
 
440
  def main():
 
 
441
  _wait_for_server(SERVER_URL)
442
 
 
443
  client = OpenAI(
444
  base_url=API_BASE_URL,
445
  api_key=API_KEY,