Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- 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 |
-
|
| 52 |
-
|
| 53 |
-
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 54 |
-
SERVER_URL = os.getenv("SERVER_URL", "http://localhost:8000")
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 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
|
| 69 |
|
| 70 |
-
OBJECTIVE: Maximise
|
| 71 |
-
|
| 72 |
|
| 73 |
PHASES:
|
| 74 |
-
0 = North-South Green (N/S
|
| 75 |
-
1 = East-West Green (E/W
|
| 76 |
-
2 = All Red (
|
| 77 |
-
|
| 78 |
-
SCORING
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
1. EMERGENCY
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
6. NEVER use phase 2 (All Red) unless ALL queues are empty.
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
""").
|
| 106 |
|
| 107 |
|
| 108 |
-
def
|
| 109 |
-
"""
|
| 110 |
-
q = obs.queue_lengths
|
| 111 |
em_q = obs.emergency_queue
|
| 112 |
em_u = obs.emergency_urgency
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
#
|
| 115 |
-
|
| 116 |
-
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
ns_total = q[0] + q[1]
|
| 119 |
ew_total = q[2] + q[3]
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 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 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
-
# ---------------------------------------------------------------------------
|
| 251 |
-
# LLM action with smart fallback
|
| 252 |
-
# ---------------------------------------------------------------------------
|
| 253 |
|
| 254 |
-
|
| 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 |
-
#
|
| 285 |
-
if
|
| 286 |
-
return
|
| 287 |
-
if
|
| 288 |
-
return
|
| 289 |
|
| 290 |
-
#
|
| 291 |
-
if
|
| 292 |
-
|
|
|
|
| 293 |
|
| 294 |
-
#
|
| 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",
|
| 305 |
],
|
| 306 |
temperature=TEMPERATURE,
|
| 307 |
max_tokens=MAX_TOKENS,
|
| 308 |
stream=False,
|
| 309 |
-
timeout=30,
|
| 310 |
)
|
| 311 |
-
|
| 312 |
-
|
| 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 |
-
|
| 324 |
-
|
| 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 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
|
| 367 |
-
|
| 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 |
-
|
| 374 |
-
|
| 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 |
-
|
| 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 |
-
|
| 400 |
-
rewards.append(0.0)
|
| 401 |
print(
|
| 402 |
-
f
|
| 403 |
-
f"reward=0.00 done=true error={env_err}",
|
| 404 |
flush=True,
|
| 405 |
)
|
| 406 |
-
broke_on_error = True
|
| 407 |
break
|
| 408 |
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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
|
| 443 |
-
|
| 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)
|
|
|