amanmurari commited on
Commit
d51a28d
·
verified ·
1 Parent(s): 0a56fb7

Upload folder using huggingface_hub

Browse files
Files changed (1) hide show
  1. inference.py +157 -321
inference.py CHANGED
@@ -5,8 +5,8 @@ Advanced Hybrid Agent: combines optimized rule engine + LLM for ambiguous cases.
5
 
6
  Mandatory env variables (injected by validator):
7
  API_BASE_URL LLM proxy endpoint (MUST use validator's LiteLLM proxy)
 
8
  MODEL_NAME Model identifier
9
- HF_TOKEN Hugging Face API token / LiteLLM proxy key
10
 
11
  Optional:
12
  SERVER_URL Running env server (default: http://localhost:8000)
@@ -45,19 +45,14 @@ except ImportError:
45
  from models import TrafficAction, TrafficObservation # type: ignore
46
 
47
  # ---------------------------------------------------------------------------
48
- # Configuration
49
  # ---------------------------------------------------------------------------
50
 
51
- API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
52
- MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4.1-mini")
53
- HF_TOKEN = os.getenv("HF_TOKEN")
54
- SERVER_URL = os.getenv("SERVER_URL", "http://localhost:8000")
55
 
56
- if HF_TOKEN is None:
57
- raise ValueError("HF_TOKEN environment variable is required")
58
-
59
- SEED = 42
60
- MAX_TOKENS = 64
61
  TEMPERATURE = 0.0
62
 
63
  # ---------------------------------------------------------------------------
@@ -65,370 +60,212 @@ TEMPERATURE = 0.0
65
  # ---------------------------------------------------------------------------
66
 
67
  SYSTEM_PROMPT = textwrap.dedent("""
68
- You are an elite Autonomous Traffic Control AI managing a 4-way intersection.
69
 
70
- OBJECTIVE: Maximise your SCORE by balancing throughput, emergency response,
71
- efficiency, and stability (avoid unnecessary phase switching).
72
 
73
  PHASES:
74
- 0 = North-South Green (N/S vehicles may pass, up to 3 per direction per step)
75
- 1 = East-West Green (E/W vehicles may pass, up to 3 per direction per step)
76
- 2 = All Red (no vehicles pass — use ONLY for emergency clearance)
77
-
78
- SCORING COMPONENTS (what you're graded on):
79
- - Throughput: vehicles cleared per step (target 1.8/step)
80
- - Emergency response: clear emergency vehicles FAST (avg delay < 3 steps)
81
- - Efficiency: minimize total waiting time
82
- - Adaptability: DON'T switch phases too often (penalty for over-switching!)
83
- - Stability: staying in a productive phase is rewarded
84
-
85
- CRITICAL RULES (apply in strict order):
86
- 1. EMERGENCY VEHICLES: If ANY emergency vehicle is waiting (emergency_queue > 0),
87
- switch to their direction IMMEDIATELY. Emergency delay is heavily penalized.
88
- Higher urgency = more critical. Urgency 8-10 is catastrophic.
89
-
90
- 2. STAY IN PRODUCTIVE PHASE: If current phase is clearing vehicles AND
91
- queue has traffic, STAY. Each switch costs 2 yellow steps of zero throughput.
92
-
93
- 3. MINIMUM PHASE TIME: Stay at least 3-5 steps in a phase (more for deeper queues).
94
- If time_in_phase < 3 and current direction has traffic, STAY.
95
-
96
- 4. SWITCH ON IMBALANCE: Only switch when the OTHER direction has 3+ more
97
- vehicles than current direction. Small differences don't justify the switch cost.
98
-
99
- 5. EMPTY QUEUE: If current direction queue = 0 and other direction > 0, switch.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- 6. NEVER use phase 2 (All Red) unless ALL queues are empty.
102
 
103
- OUTPUT: Exactly one JSON object, no markdown, no explanation:
104
- {"light_phase": <0, 1, or 2>}
105
- """).strip()
106
 
107
 
108
- def _build_prompt(obs: TrafficObservation, step: int, total_rewards: float) -> str:
109
- """Build a rich prompt with scoring context for the LLM."""
110
- q = obs.queue_lengths
111
  em_q = obs.emergency_queue
112
  em_u = obs.emergency_urgency
 
 
 
113
 
114
- # Queue trend info
115
- trend = getattr(obs, 'queue_trend', [0, 0, 0, 0])
116
- avg_wait = getattr(obs, 'avg_wait_time', 0.0)
117
 
 
 
 
 
118
  ns_total = q[0] + q[1]
119
  ew_total = q[2] + q[3]
120
- ns_em_total = em_q[0] + em_q[1]
121
- ew_em_total = em_q[2] + em_q[3]
122
-
123
- return textwrap.dedent(f"""
124
- STEP {step} | Cumulative reward: {total_rewards:.1f}
125
-
126
- CURRENT STATE:
127
- Active phase : {obs.current_phase} (0=NS Green, 1=EW Green, 2=All Red)
128
- Steps in phase : {obs.time_in_phase}
129
-
130
- QUEUES:
131
- Regular vehicles : N={q[0]}, S={q[1]}, E={q[2]}, W={q[3]}
132
- → NS total: {ns_total} | EW total: {ew_total} | Difference: {abs(ns_total - ew_total)}
133
- Queue trend (Δ) : N={trend[0]:+d}, S={trend[1]:+d}, E={trend[2]:+d}, W={trend[3]:+d}
134
- Avg wait time : {avg_wait:.1f} steps
135
-
136
- EMERGENCIES:
137
- Emergency queue : N={em_q[0]}, S={em_q[1]}, E={em_q[2]}, W={em_q[3]}
138
- Emergency urgency : N={em_u[0]}, S={em_u[1]}, E={em_u[2]}, W={em_u[3]}
139
- → NS emergencies: {ns_em_total} | EW emergencies: {ew_em_total}
140
-
141
- DECISION: {{"light_phase": <0, 1, or 2>}}
142
- """).strip()
143
-
144
- # ---------------------------------------------------------------------------
145
- # Advanced rule-based engine — score-maximizing
146
- # ---------------------------------------------------------------------------
147
-
148
- class SmartRuleEngine:
149
- """Stateful rule-based agent that tracks history for better decisions."""
150
-
151
- def __init__(self):
152
- self.phase_change_count = 0
153
- self.total_steps = 0
154
- self.last_3_queues: List[List[int]] = []
155
-
156
- def decide(self, obs: TrafficObservation) -> TrafficAction:
157
- self.total_steps += 1
158
-
159
- em_q = obs.emergency_queue
160
- em_u = obs.emergency_urgency
161
- q = obs.queue_lengths
162
- current = obs.current_phase
163
- time_in = obs.time_in_phase
164
-
165
- # Track queue history for trend analysis
166
- total_q = [q[i] + em_q[i] for i in range(4)]
167
- self.last_3_queues.append(total_q)
168
- if len(self.last_3_queues) > 3:
169
- self.last_3_queues.pop(0)
170
-
171
- # ── Rule 1: EMERGENCY PRIORITY (highest priority, override everything) ──
172
- ns_em_score = em_u[0] + em_u[1] + em_q[0] * 3 + em_q[1] * 3
173
- ew_em_score = em_u[2] + em_u[3] + em_q[2] * 3 + em_q[3] * 3
174
-
175
- if ns_em_score > 0 or ew_em_score > 0:
176
- target = 0 if ns_em_score >= ew_em_score else 1
177
- if target != current:
178
- self.phase_change_count += 1
179
- return TrafficAction(light_phase=target)
180
-
181
- # ── Rule 2: EMPTY CURRENT DIRECTION → instant switch ──
182
- ns_total = q[0] + q[1]
183
- ew_total = q[2] + q[3]
184
-
185
- if current == 0 and ns_total == 0 and ew_total > 0:
186
- self.phase_change_count += 1
187
- return TrafficAction(light_phase=1)
188
- if current == 1 and ew_total == 0 and ns_total > 0:
189
- self.phase_change_count += 1
190
- return TrafficAction(light_phase=0)
191
-
192
- # ── Rule 3: DYNAMIC MINIMUM PHASE TIME ──
193
- # Deeper queues → stay longer to maximize throughput before switching
194
- current_dir_queue = ns_total if current == 0 else ew_total
195
- other_dir_queue = ew_total if current == 0 else ns_total
196
-
197
- # Adaptive min time: 3 base + 1 per 4 vehicles, capped at 10
198
- min_phase_time = min(3 + current_dir_queue // 4, 10)
199
-
200
- if time_in < min_phase_time and current_dir_queue > 0:
201
- return TrafficAction(light_phase=current if current in (0, 1) else 0)
202
-
203
- # ── Rule 4: ADAPTABILITY-AWARE SWITCHING THRESHOLD ──
204
- # The more we've already switched, the higher the threshold to switch again
205
- switch_rate = self.phase_change_count / max(self.total_steps, 1)
206
- # Base threshold is 3 vehicles; increases if we're switching too much
207
- switch_threshold = 3 + int(switch_rate * 10)
208
-
209
- if current == 0 and ew_total >= ns_total + switch_threshold:
210
- self.phase_change_count += 1
211
- return TrafficAction(light_phase=1)
212
- elif current == 1 and ns_total >= ew_total + switch_threshold:
213
- self.phase_change_count += 1
214
- return TrafficAction(light_phase=0)
215
-
216
- # ── Rule 5: QUEUE TREND ANALYSIS ──
217
- # If other direction's queue is growing fast (trend > 0 for last 3 steps)
218
- if len(self.last_3_queues) >= 3:
219
- if current == 0:
220
- ew_growing = all(
221
- self.last_3_queues[i][2] + self.last_3_queues[i][3] <=
222
- self.last_3_queues[i+1][2] + self.last_3_queues[i+1][3]
223
- for i in range(len(self.last_3_queues) - 1)
224
- )
225
- if ew_growing and ew_total > ns_total and time_in >= 3:
226
- self.phase_change_count += 1
227
- return TrafficAction(light_phase=1)
228
- elif current == 1:
229
- ns_growing = all(
230
- self.last_3_queues[i][0] + self.last_3_queues[i][1] <=
231
- self.last_3_queues[i+1][0] + self.last_3_queues[i+1][1]
232
- for i in range(len(self.last_3_queues) - 1)
233
- )
234
- if ns_growing and ns_total > ew_total and time_in >= 3:
235
- self.phase_change_count += 1
236
- return TrafficAction(light_phase=0)
237
-
238
- # ── Default: STAY in current phase for stability bonus ──
239
  return TrafficAction(light_phase=current if current in (0, 1) else 0)
240
 
241
 
242
- # ---------------------------------------------------------------------------
243
- # Sanitize error strings
244
- # ---------------------------------------------------------------------------
245
-
246
- def _sanitize(s: str) -> str:
247
- """Strip newlines, carriage returns, and problematic characters for output."""
248
- return s.replace('\n', ' ').replace('\r', ' ').replace('"', "'").replace('\\', '')
 
 
 
249
 
250
- # ---------------------------------------------------------------------------
251
- # LLM action with smart fallback
252
- # ---------------------------------------------------------------------------
253
 
254
- _rule_engine = SmartRuleEngine()
255
-
256
-
257
- def get_llm_action(
258
- client: OpenAI,
259
- obs: TrafficObservation,
260
- step: int,
261
- total_rewards: float,
262
- ) -> TrafficAction:
263
- """
264
- Hybrid approach:
265
- - Use rules for clear-cut decisions (saves API calls + faster)
266
- - Use LLM for ambiguous situations (close queues, complex emergencies)
267
- """
268
- q = obs.queue_lengths
269
  em_q = obs.emergency_queue
 
270
  current = obs.current_phase
 
271
 
 
 
 
272
  ns_total = q[0] + q[1]
273
  ew_total = q[2] + q[3]
274
- ns_em = sum(em_q[0:2])
275
- ew_em = sum(em_q[2:4])
276
- diff = abs(ns_total - ew_total)
277
-
278
- # ── FAST PATH: clear-cut decisions → use rules (no LLM call needed) ──
279
-
280
- # Emergency vehicles → always rules (speed critical, don't wait for LLM)
281
- if ns_em > 0 or ew_em > 0:
282
- return _rule_engine.decide(obs)
283
 
284
- # Empty current direction obvious switch
285
- if current == 0 and ns_total == 0 and ew_total > 0:
286
- return _rule_engine.decide(obs)
287
- if current == 1 and ew_total == 0 and ns_total > 0:
288
- return _rule_engine.decide(obs)
289
 
290
- # Large imbalance obvious switch
291
- if diff >= 5:
292
- return _rule_engine.decide(obs)
 
293
 
294
- # Very early in phase → obviously stay
295
- if obs.time_in_phase < 3:
296
- return _rule_engine.decide(obs)
297
-
298
- # ── SLOW PATH: ambiguous situation → ask LLM ──
299
  try:
300
  resp = client.chat.completions.create(
301
  model=MODEL_NAME,
302
  messages=[
303
  {"role": "system", "content": SYSTEM_PROMPT},
304
- {"role": "user", "content": _build_prompt(obs, step, total_rewards)},
305
  ],
306
  temperature=TEMPERATURE,
307
  max_tokens=MAX_TOKENS,
308
  stream=False,
309
- timeout=30,
310
  )
311
- data_str = (resp.choices[0].message.content or "").strip()
312
- match = re.search(r'\{[^}]*\}', data_str.replace('\n', ' '))
313
- data = json.loads(match.group(0) if match else data_str)
314
- phase = max(0, min(2, int(data.get("light_phase", obs.current_phase))))
315
-
316
- # Update rule engine state even when using LLM
317
- _rule_engine.total_steps += 1
318
- if phase != current:
319
- _rule_engine.phase_change_count += 1
320
-
321
  return TrafficAction(light_phase=phase)
322
- except Exception:
323
- # LLM failed — use optimized rule-based agent as fallback
324
- return _rule_engine.decide(obs)
325
-
326
- # ---------------------------------------------------------------------------
327
- # Grade fetcher
328
- # ---------------------------------------------------------------------------
329
-
330
- def _fetch_score(task_id: str, state_payload: dict) -> float:
331
- try:
332
- r = _http.post(
333
- f"{SERVER_URL}/grade",
334
- json={
335
- "task_id": task_id,
336
- "total_vehicles_passed": state_payload.get("total_vehicles_passed", 0),
337
- "total_emergency_passed": state_payload.get("total_emergency_passed", 0),
338
- "total_waiting_time": state_payload.get("total_waiting_time", 0.0),
339
- "total_collisions": state_payload.get("total_collisions", 0),
340
- "total_emergency_delay": state_payload.get("total_emergency_delay", 0.0),
341
- "total_phase_changes": state_payload.get("total_phase_changes", 0),
342
- "step_count": max(state_payload.get("step_count", 1), 1),
343
- },
344
- timeout=10,
345
- )
346
- if r.status_code == 200:
347
- return max(0.001, min(0.999, float(r.json().get("score", 0.5))))
348
- except Exception:
349
- pass
350
- return 0.5
351
-
352
- # ---------------------------------------------------------------------------
353
- # Task runner
354
- # ---------------------------------------------------------------------------
355
-
356
- def run_task(task_id: str, client: OpenAI) -> None:
357
- global _rule_engine
358
- _rule_engine = SmartRuleEngine() # Fresh engine per task
359
 
360
- print(f"[START] task={task_id} env=traffic_control model={MODEL_NAME}", flush=True)
361
 
362
- rewards: List[float] = []
363
- success = False
364
- step = 0
365
- total_rewards = 0.0
 
 
 
 
366
 
367
- try:
368
- with TrafficControlEnv(base_url=SERVER_URL).sync() as env:
369
- step_result = env.reset(task_id=task_id, seed=SEED)
370
- step = 0
371
- broke_on_error = False
372
 
373
- while not step_result.done:
374
- obs = step_result.observation
375
  step += 1
376
-
377
- action = get_llm_action(client, obs, step, total_rewards)
378
- action_str = f"light_phase({action.light_phase})"
379
 
380
  try:
381
- step_result = env.step(action)
382
- reward_val = step_result.reward if step_result.reward is not None else 0.0
383
- rewards.append(reward_val)
384
- total_rewards += reward_val
385
- done_val = str(step_result.done).lower()
386
-
387
- error_val = "null"
388
- if hasattr(step_result, 'info') and step_result.info:
389
- err = step_result.info.get('error')
390
- if err:
391
- error_val = _sanitize(str(err))
392
-
393
- print(
394
- f"[STEP] step={step} action={action_str} "
395
- f"reward={reward_val:.2f} done={done_val} error={error_val}",
396
- flush=True,
397
- )
398
  except Exception as exc:
399
- env_err = _sanitize(str(exc))
400
- rewards.append(0.0)
401
  print(
402
- f"[STEP] step={step} action={action_str} "
403
- f"reward=0.00 done=true error={env_err}",
404
  flush=True,
405
  )
406
- broke_on_error = True
407
  break
408
 
409
- success = not broke_on_error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
 
411
- except Exception as exc:
412
- err_msg = _sanitize(str(exc))
413
- if step == 0:
414
- step = 1
415
- rewards.append(0.0)
416
- print(f"[STEP] step=1 action=null reward=0.00 done=true error={err_msg}", flush=True)
417
- success = False
418
-
419
- rewards_str = ",".join(f"{r:.2f}" for r in rewards) if rewards else "0.00"
420
-
421
- print(
422
- f"[END] success={str(success).lower()} steps={step} rewards={rewards_str}",
423
- flush=True,
424
- )
425
 
426
  # ---------------------------------------------------------------------------
427
  # Entry point
428
  # ---------------------------------------------------------------------------
429
 
430
  def main() -> None:
431
- client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
432
 
433
  for task in ["basic_flow", "emergency_priority", "dynamic_scenarios"]:
434
  run_task(task, client)
@@ -439,6 +276,5 @@ if __name__ == "__main__":
439
  main()
440
  except Exception as exc:
441
  err = _sanitize(str(exc))
442
- print(f"[START] task=unknown env=traffic_control model={MODEL_NAME}", flush=True)
443
- print(f"[STEP] step=1 action=null reward=0.00 done=true error={err}", flush=True)
444
- print(f"[END] success=false steps=1 rewards=0.00", flush=True)
 
5
 
6
  Mandatory env variables (injected by validator):
7
  API_BASE_URL LLM proxy endpoint (MUST use validator's LiteLLM proxy)
8
+ API_KEY LiteLLM proxy key
9
  MODEL_NAME Model identifier
 
10
 
11
  Optional:
12
  SERVER_URL Running env server (default: http://localhost:8000)
 
45
  from models import TrafficAction, TrafficObservation # type: ignore
46
 
47
  # ---------------------------------------------------------------------------
48
+ # Configuration - CRITICAL: Use os.environ[] with NO fallbacks per validator
49
  # ---------------------------------------------------------------------------
50
 
51
+ MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
52
+ SERVER_URL = os.environ.get("SERVER_URL", "http://localhost:8000")
 
 
53
 
54
+ SEED = 42
55
+ MAX_TOKENS = 64
 
 
 
56
  TEMPERATURE = 0.0
57
 
58
  # ---------------------------------------------------------------------------
 
60
  # ---------------------------------------------------------------------------
61
 
62
  SYSTEM_PROMPT = textwrap.dedent("""
63
+ You are an expert Autonomous Traffic Signal Controller.
64
 
65
+ OBJECTIVE: Maximise the final score (0-1) by keeping vehicles moving,
66
+ eliminating emergency delays, and minimising queue lengths.
67
 
68
  PHASES:
69
+ 0 = North-South Green (N/S can cross)
70
+ 1 = East-West Green (E/W can cross)
71
+ 2 = All Red (use only for emergency clearance)
72
+
73
+ SCORING RULES (memorise these they decide your reward):
74
+ +0.2 per regular vehicle that clears
75
+ +10.0 per emergency vehicle that clears (HUGE weight)
76
+ −0.4 × urgency per step an emergency waits (massive penalty)
77
+ −0.5 for switching to a phase with 0 vehicles waiting
78
+ Aim for 0 collisions, <50 total waiting time, >50 vehicles cleared.
79
+
80
+ DECISION PRIORITY (apply in order):
81
+ 1. EMERGENCY if any emergency_queue > 0, switch immediately to the
82
+ direction (0=N/S, 1=E/W) with the highest *urgency sum*.
83
+ (Urgency 9-10 is critical do NOT make them wait.)
84
+ 2. MIN GREEN — stay in current phase at least 3 steps if traffic present.
85
+ 3. QUEUE BALANCE after min green, switch if opposite direction has
86
+ 3+ more vehicles to reduce total waiting time.
87
+ 4. EMPTY QUEUE — if current direction has 0 waiting and other > 0,
88
+ switch immediately to serve the other side.
89
+
90
+ OUTPUT FORMAT (STRICT):
91
+ {"light_phase": 0_or_1_or_2}
92
+ No markdown, no extra text, just the JSON object.
93
+ """)
94
+
95
+
96
+ def _build_prompt(obs: TrafficObservation, step: int) -> str:
97
+ em_q = obs.emergency_queue
98
+ em_u = obs.emergency_urgency
99
+ q = obs.queue_lengths
100
+ current = obs.current_phase
101
+ time_in = obs.time_in_phase
102
+
103
+ return (
104
+ f"Step {step}\n"
105
+ f"Current phase: {current} (0=NS, 1=EW, 2=AllRed)\n"
106
+ f"Time in phase: {time_in} steps\n\n"
107
+ f"Queue lengths [N, S, E, W]: {list(q)}\n"
108
+ f"Emergency queues [N, S, E, W]: {list(em_q)}\n"
109
+ f"Emergency urgency [N, S, E, W]: {list(em_u)}\n\n"
110
+ f"Based on the scoring rules and decision priority, what phase should be set?\n"
111
+ f"Respond ONLY with JSON: {{\"light_phase\": 0, 1, or 2}}"
112
+ )
113
 
 
114
 
115
+ def _sanitize(s: str) -> str:
116
+ """Remove characters that break JSON parsing in logs."""
117
+ return s.replace('"', "'").replace("\\", "/").replace("\n", " ")
118
 
119
 
120
+ def _rule_based_action(obs: TrafficObservation) -> TrafficAction:
121
+ """Optimized rule engine for high scores."""
 
122
  em_q = obs.emergency_queue
123
  em_u = obs.emergency_urgency
124
+ q = obs.queue_lengths
125
+ current = obs.current_phase
126
+ time_in = obs.time_in_phase
127
 
128
+ # Emergency prioritization (urgency-weighted)
129
+ ns_em_urgency = em_u[0] + em_u[1] + em_q[0] * 2 + em_q[1] * 2
130
+ ew_em_urgency = em_u[2] + em_u[3] + em_q[2] * 2 + em_q[3] * 2
131
 
132
+ if ns_em_urgency > 0 or ew_em_urgency > 0:
133
+ return TrafficAction(light_phase=0 if ns_em_urgency >= ew_em_urgency else 1)
134
+
135
+ # Queue-based switching with hysteresis
136
  ns_total = q[0] + q[1]
137
  ew_total = q[2] + q[3]
138
+ min_phase_time = min(3 + max(ns_total, ew_total) // 5, 8)
139
+
140
+ if current == 0 and time_in < min_phase_time and ns_total > 0:
141
+ return TrafficAction(light_phase=0)
142
+ if current == 1 and time_in < min_phase_time and ew_total > 0:
143
+ return TrafficAction(light_phase=1)
144
+
145
+ if ns_total >= ew_total + 2:
146
+ return TrafficAction(light_phase=0)
147
+ elif ew_total >= ns_total + 2:
148
+ return TrafficAction(light_phase=1)
149
+ else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  return TrafficAction(light_phase=current if current in (0, 1) else 0)
151
 
152
 
153
+ def _parse_phase(raw: str) -> int:
154
+ """Extract phase from LLM response."""
155
+ try:
156
+ data = json.loads(raw)
157
+ phase = int(data.get("light_phase", data.get("phase", 0)))
158
+ return max(0, min(2, phase))
159
+ except Exception:
160
+ # Fallback: look for digit in response
161
+ m = re.search(r'\b([012])\b', raw)
162
+ return int(m.group(1)) if m else 0
163
 
 
 
 
164
 
165
+ def get_llm_action(client: OpenAI, obs: TrafficObservation, step: int) -> TrafficAction:
166
+ """Hybrid: rule-based for obvious cases, LLM for ambiguous."""
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  em_q = obs.emergency_queue
168
+ q = obs.queue_lengths
169
  current = obs.current_phase
170
+ time_in = obs.time_in_phase
171
 
172
+ # Quick wins — pure rule-based (no API call)
173
+ ns_em = em_q[0] + em_q[1]
174
+ ew_em = em_q[2] + em_q[3]
175
  ns_total = q[0] + q[1]
176
  ew_total = q[2] + q[3]
 
 
 
 
 
 
 
 
 
177
 
178
+ # Emergency handling always rule-based for speed
179
+ if ns_em > 0 and ew_em == 0:
180
+ return TrafficAction(light_phase=0)
181
+ if ew_em > 0 and ns_em == 0:
182
+ return TrafficAction(light_phase=1)
183
 
184
+ # Stay in phase if beneficial and within min time
185
+ if current in (0, 1) and time_in < 3:
186
+ if (current == 0 and ns_total > 0) or (current == 1 and ew_total > 0):
187
+ return TrafficAction(light_phase=current)
188
 
189
+ # Ambiguous case call LLM
 
 
 
 
190
  try:
191
  resp = client.chat.completions.create(
192
  model=MODEL_NAME,
193
  messages=[
194
  {"role": "system", "content": SYSTEM_PROMPT},
195
+ {"role": "user", "content": _build_prompt(obs, step)},
196
  ],
197
  temperature=TEMPERATURE,
198
  max_tokens=MAX_TOKENS,
199
  stream=False,
 
200
  )
201
+ raw = resp.choices[0].message.content.strip()
202
+ phase = _parse_phase(raw)
 
 
 
 
 
 
 
 
203
  return TrafficAction(light_phase=phase)
204
+ except Exception as exc:
205
+ import sys
206
+ print(f"LLM API Error: {exc}", file=sys.stderr)
207
+ # Fallback to rule-based on API error
208
+ return _rule_based_action(obs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
 
210
 
211
+ def run_task(task: str, client: OpenAI) -> None:
212
+ """Run a single task episode."""
213
+ with TrafficControlEnv(base_url=SERVER_URL).sync() as env:
214
+ # Note: openenv-core's reset takes task_id, so passing task_id=task
215
+ obs = env.reset(task_id=task, seed=SEED)
216
+ rewards: List[float] = []
217
+ step = 0
218
+ error_msg: Optional[str] = None
219
 
220
+ print(f'[START] task="{task}"', flush=True)
 
 
 
 
221
 
222
+ try:
223
+ while not obs.done:
224
  step += 1
225
+ action = get_llm_action(client, obs, step)
 
 
226
 
227
  try:
228
+ obs = env.step(action)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  except Exception as exc:
230
+ error_msg = _sanitize(str(exc))
 
231
  print(
232
+ f'[STEP] step={step} action={action} reward=0.0 done=true error="{error_msg}"',
 
233
  flush=True,
234
  )
 
235
  break
236
 
237
+ reward_val = obs.reward if obs.reward is not None else 0.0
238
+ rewards.append(reward_val)
239
+
240
+ print(
241
+ f'[STEP] step={step} action={action} '
242
+ f'reward={reward_val:.2f} done={str(obs.done).lower()} '
243
+ f'phase={obs.current_phase} '
244
+ f'queues={list(obs.queue_lengths)} '
245
+ f'emergency={list(obs.emergency_queue)}',
246
+ flush=True,
247
+ )
248
+
249
+ except Exception as exc:
250
+ error_msg = _sanitize(str(exc))
251
+
252
+ success = not error_msg and obs.done
253
+ total_reward = sum(rewards)
254
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards[-10:]) # last 10 for brevity
255
+
256
+ print(
257
+ f'[END] success={str(success).lower()} steps={step} '
258
+ f'total_reward={total_reward:.2f} rewards=[{rewards_str}]',
259
+ flush=True,
260
+ )
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
  # ---------------------------------------------------------------------------
264
  # Entry point
265
  # ---------------------------------------------------------------------------
266
 
267
  def main() -> None:
268
+ client = OpenAI(base_url=os.environ["API_BASE_URL"], api_key=os.environ["API_KEY"])
269
 
270
  for task in ["basic_flow", "emergency_priority", "dynamic_scenarios"]:
271
  run_task(task, client)
 
276
  main()
277
  except Exception as exc:
278
  err = _sanitize(str(exc))
279
+ print(f'[FATAL] error="{err}"', flush=True)
280
+ raise SystemExit(1)