soumi guria commited on
Commit
5b9b110
Β·
1 Parent(s): 740e5bf

feat: demo training endpoints, fix Docker build, before/after dashboard

Browse files
Dockerfile CHANGED
@@ -16,7 +16,7 @@ WORKDIR /app
16
 
17
  # Python dependencies
18
  COPY backend/requirements.txt .
19
- RUN pip install uv && uv pip install --system --no-cache -r requirements.txt
20
 
21
  # Application code
22
  COPY backend/ /app/backend/
 
16
 
17
  # Python dependencies
18
  COPY backend/requirements.txt .
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
 
21
  # Application code
22
  COPY backend/ /app/backend/
backend/main.py CHANGED
@@ -3,20 +3,27 @@ backend/main.py β€” FastAPI server for the Cognitive Load Manager (OpenEnv).
3
 
4
  Endpoints:
5
  GET /health
6
- POST /reset {"task_id": "easy|medium|hard|expert"}
7
- POST /step {"session_id": "...", "action": {...}}
8
- GET /state ?session_id=...
9
  GET /grader
10
  GET /grade/easy|medium|hard|expert
11
- GET /stream/run ?difficulty=medium β†’ SSE real-time episode stream
12
- GET /benchmark β†’ run heuristic on all 4 levels
13
- GET /training-log β†’ reward_curve.json contents
 
 
 
14
  """
15
  import asyncio
16
  import json
17
  import os
 
18
  import sys
 
 
19
  import uuid
 
20
  from typing import Dict, Optional, List
21
 
22
  from fastapi import FastAPI, HTTPException
@@ -62,16 +69,11 @@ def _avg_energy(env: CLMEnvironment) -> float:
62
  return sum(w.energy for w in workers) / len(workers) if workers else 0.5
63
 
64
 
65
- # ── Heuristic agent (used for /stream/run and /benchmark) ─────────────────────
66
  def _heuristic_action(env: CLMEnvironment) -> ModelAction:
67
- """
68
- Competent heuristic: breaks when exhausted, prioritises by weight then
69
- earliest deadline, uses focus mode for critical near-deadline tasks.
70
- Uses workers[0] for energy/stress (correcting the grader's attribute bug).
71
- """
72
- state = env.state
73
  blocked = env._blocked_ids()
74
- w0 = state.workers[0] if state.workers else None
75
 
76
  if w0 and (w0.energy < 0.28 or w0.stress > 0.72):
77
  return ModelAction(type="break", task_id=None, worker_id="w1")
@@ -85,19 +87,156 @@ def _heuristic_action(env: CLMEnvironment) -> ModelAction:
85
  t.deadline if t.deadline is not None else 9999,
86
  ))
87
  target = pending[0]
88
-
89
  use_focus = (
90
  target.priority == "critical"
91
  and target.deadline is not None
92
  and (target.deadline - state.time_step) <= 10
93
- and w0 is not None
94
- and w0.energy > 0.52
95
- )
96
- return ModelAction(
97
- type="focus" if use_focus else "work",
98
- task_id=target.id,
99
- worker_id="w1",
100
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
 
103
  # ── Request / Response models ──────────────────────────────────────────────────
@@ -149,29 +288,33 @@ def build_app() -> FastAPI:
149
  allow_methods=["*"], allow_headers=["*"],
150
  )
151
 
 
 
 
152
  # ── Health ─────────────────────────────────────────────────────────────────
153
  @app.get("/health", tags=["System"])
154
  async def health():
155
- return {"status": "healthy", "sessions": len(_sessions)}
 
156
 
157
  # ── Reset ──────────────────────────────────────────────────────────────────
158
  @app.post("/reset", tags=["Environment"])
159
  async def reset(req: ResetRequest):
160
- task_id = req.task_id if req.task_id in ("easy", "medium", "hard", "expert") else "easy"
161
- max_s = 60 if task_id == "expert" else 50
162
- tasks = generate_tasks(task_id, seed=req.seed)
163
- env = CLMEnvironment(tasks=tasks, max_steps=max_s, seed=req.seed)
164
- obs = env.reset()
165
- session_id = str(uuid.uuid4())
166
- _sessions[session_id] = env
167
  return {
168
- "session_id": session_id,
169
  "observation": {
170
- "tasks": [t.model_dump() for t in obs.tasks],
171
  "visible_state": obs.visible_state.model_dump(),
172
- "time_step": obs.time_step,
173
  },
174
- "done": False,
175
  "reward": 0.0,
176
  }
177
 
@@ -183,35 +326,31 @@ def build_app() -> FastAPI:
183
  elif _sessions:
184
  env = list(_sessions.values())[-1]
185
  else:
186
- raise HTTPException(status_code=400, detail="No active session. Call /reset first.")
187
 
188
- action = ModelAction(
189
- type=req.action.type,
190
- task_id=req.action.task_id,
191
- worker_id=req.action.worker_id or "w1",
192
- )
193
  obs, reward, done, info = env.step(action)
194
 
195
  if done:
196
  avg_e = _avg_energy(env)
197
  info["final_score"] = _safe(info.get(
198
  "final_score",
199
- deterministic_grader(env.state.tasks, env.state.time_step, avg_e),
200
- ))
201
  if req.session_id and req.session_id in _sessions:
202
  del _sessions[req.session_id]
203
 
204
  return {
205
  "session_id": req.session_id,
206
  "observation": {
207
- "tasks": [t.model_dump() for t in obs.tasks],
208
  "visible_state": obs.visible_state.model_dump(),
209
- "time_step": obs.time_step,
210
  },
211
  "reward": _safe(float(reward)),
212
- "done": done,
213
- "info": {k: v for k, v in info.items()
214
- if k in ("final_score", "schema_drift", "time_step")},
215
  }
216
 
217
  # ── State ──────────────────────────────────────────────────────────────────
@@ -241,179 +380,193 @@ def build_app() -> FastAPI:
241
  @app.get("/grade/expert", tags=["Grader"])
242
  async def grade_expert(): return _run_grader_episode("expert")
243
 
244
- # ── SSE: real-time episode streaming ──────────────────────────────────────
245
  @app.get("/stream/run", tags=["Streaming"])
246
- async def stream_run(difficulty: str = "medium", delay_ms: int = 380):
247
- """
248
- Server-Sent Events stream that plays a full heuristic episode.
249
- Each SSE message is a JSON object with type 'reset' | 'step' | 'done' | 'error'.
250
- """
251
- diff = difficulty if difficulty in ("easy", "medium", "hard", "expert") else "medium"
252
  sleep_s = max(0.1, min(2.0, delay_ms / 1000))
253
 
254
  async def event_gen():
255
  try:
256
  max_s = 60 if diff == "expert" else 50
257
  tasks = generate_tasks(diff)
258
- env = CLMEnvironment(tasks=tasks, max_steps=max_s)
259
- obs = env.reset()
260
- w0 = env.state.workers[0] if env.state.workers else None
261
-
262
- init = {
263
- "type": "reset",
264
- "difficulty": diff,
265
- "step": 0,
266
- "tasks": [t.model_dump() for t in obs.tasks],
267
- "visible_state": obs.visible_state.model_dump(),
268
- "energy": round(w0.energy if w0 else 1.0, 3),
269
- "stress": round(w0.stress if w0 else 0.0, 3),
270
- }
271
- yield f"data: {json.dumps(init)}\n\n"
272
 
273
- done = False
274
- total_reward = 0.0
275
 
 
276
  while not done:
277
  action = _heuristic_action(env)
278
  obs, reward, done, info = env.step(action)
279
- total_reward = round(total_reward + float(reward), 4)
280
- w0 = env.state.workers[0] if env.state.workers else None
281
  completed = sum(1 for t in obs.tasks if t.progress >= 1.0)
282
 
283
  event: dict = {
284
- "type": "step",
285
- "step": obs.time_step,
286
- "action": {"type": action.type, "task_id": action.task_id},
287
- "reward": round(float(reward), 4),
288
- "total_reward": total_reward,
289
- "done": done,
290
- "energy": round(w0.energy if w0 else 0.5, 3),
291
- "stress": round(w0.stress if w0 else 0.0, 3),
292
- "tasks_done": completed,
293
- "tasks_total": len(obs.tasks),
294
- "tasks": [t.model_dump() for t in obs.tasks],
295
  "visible_state": obs.visible_state.model_dump(),
296
  }
297
- drift = info.get("schema_drift")
298
- if drift:
299
- event["schema_drift"] = drift
300
  if done:
301
- event["final_score"] = _safe(info.get("final_score", 0.01))
302
  event["final_energy"] = round(w0.energy if w0 else 0.5, 3)
303
 
304
  yield f"data: {json.dumps(event)}\n\n"
305
-
306
  if not done:
307
  await asyncio.sleep(sleep_s)
308
 
309
  except Exception as exc:
310
- yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
311
-
312
- return StreamingResponse(
313
- event_gen(),
314
- media_type="text/event-stream",
315
- headers={
316
- "Cache-Control": "no-cache",
317
- "X-Accel-Buffering": "no", # disable nginx buffering on HF Spaces
318
- "Connection": "keep-alive",
319
- },
320
- )
321
 
322
- # ── Benchmark: run heuristic on all 4 difficulties ────────────────────────
 
 
 
 
323
  @app.get("/benchmark", tags=["Benchmark"])
324
  def benchmark():
325
- """
326
- Runs the heuristic agent on all 4 difficulty levels (seed=42 for
327
- reproducibility) and returns comprehensive per-difficulty stats.
328
- """
329
  results = {}
330
- baseline = {"easy": 0.856, "medium": 0.523, "hard": 0.301, "expert": 0.221}
331
-
332
- for diff in ("easy", "medium", "hard", "expert"):
333
  try:
334
  tasks = generate_tasks(diff, seed=42)
335
  max_s = 60 if diff == "expert" else 50
336
- env = CLMEnvironment(tasks=tasks, max_steps=max_s, seed=42)
337
  env.reset()
338
- done, step = False, 0
339
- total_reward = 0.0
340
  step_rewards: List[float] = []
341
  energy_trace: List[float] = []
342
  stress_trace: List[float] = []
343
-
344
  while not done and step < max_s:
345
  action = _heuristic_action(env)
346
  obs, reward, done, info = env.step(action)
347
- total_reward += float(reward)
348
  step_rewards.append(round(float(reward), 4))
349
  w0 = env.state.workers[0] if env.state.workers else None
350
  energy_trace.append(round(w0.energy if w0 else 0.5, 3))
351
  stress_trace.append(round(w0.stress if w0 else 0.0, 3))
352
  step += 1
353
 
354
- avg_e = _avg_energy(env)
355
- final_score = _safe(info.get(
356
- "final_score",
357
- deterministic_grader(env.state.tasks, env.state.time_step, avg_e),
358
- ))
359
  tasks_done = sum(1 for t in env.state.tasks if t.progress >= 1.0)
360
- dl_tasks = [t for t in env.state.tasks if t.deadline is not None]
361
- met_dl = sum(
362
- 1 for t in dl_tasks
363
- if t.progress >= 1.0 and env.state.time_step <= t.deadline
364
- )
365
- # Scoring component breakdown (from deterministic_grader formula)
366
- total_w = sum(PRIORITY_WEIGHT[t.priority] for t in env.state.tasks)
367
- wc = sum(t.progress * PRIORITY_WEIGHT[t.priority]
368
- for t in env.state.tasks) / max(total_w, 0.01)
369
- da = (met_dl / len(dl_tasks)) if dl_tasks else 1.0
370
- ee = max(0.0, (avg_e - 0.10) * 0.13)
371
- dep_b = min(0.05, sum(
372
- 0.015 for t in env.state.tasks
373
  if t.depends_on and t.progress >= 1.0
374
- and any(p.id == t.depends_on and p.progress >= 1.0
375
- for p in env.state.tasks)
376
- ))
377
  int_t = [t for t in env.state.tasks if t.is_interrupted]
378
- int_b = min(0.03, (sum(1 for t in int_t if t.progress >= 1.0) /
379
- len(int_t) * 0.03) if int_t else 0.0)
380
-
381
  results[diff] = {
382
- "score": final_score,
383
- "baseline": baseline[diff],
384
- "total_reward": round(total_reward, 4),
385
- "steps": step,
386
- "tasks_done": tasks_done,
387
- "tasks_total": len(env.state.tasks),
388
- "avg_energy": round(avg_e, 3),
389
- "deadlines_met": met_dl,
390
  "deadlines_total": len(dl_tasks),
391
  "components": {
392
- "weighted_completion": round(wc * 0.60, 4),
393
- "deadline_adherence": round(da * 0.22, 4),
394
  "energy_efficiency": round(ee, 4),
395
- "dependency_bonus": round(dep_b, 4),
396
  "interruption_bonus": round(int_b, 4),
397
  },
398
- "step_rewards": step_rewards,
399
- "energy_trace": energy_trace,
400
- "stress_trace": stress_trace,
401
  }
402
  except Exception as exc:
403
- results[diff] = {"error": str(exc), "score": 0.01, "baseline": baseline[diff]}
404
-
405
  return results
406
 
407
- # ── Training log ───────────────────────────────────────────────────────────
408
- _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
409
- _REWARD_CURVE = os.path.join(_ROOT, "reward_curve.json")
410
-
411
  @app.get("/training-log", tags=["Training"])
412
  async def training_log():
413
  if os.path.exists(_REWARD_CURVE):
414
  with open(_REWARD_CURVE) as f:
415
- return json.load(f)
416
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
  # ── React SPA static serving ───────────────────────────────────────────────
419
  _DIST = os.path.join(_ROOT, "frontend", "dist")
@@ -429,21 +582,15 @@ def build_app() -> FastAPI:
429
  async def spa_root():
430
  return FileResponse(_INDEX)
431
 
432
- # Catch-all: any unknown path returns the SPA so React Router works
433
  @app.get("/{full_path:path}", include_in_schema=False)
434
  async def spa_catchall(full_path: str):
435
  return FileResponse(_INDEX)
436
  else:
437
  @app.get("/", tags=["System"])
438
  async def api_root():
439
- return {
440
- "status": "ok",
441
- "service": "CLM OpenEnv API",
442
- "docs": "/docs",
443
- "health": "/health",
444
- "stream": "/stream/run?difficulty=medium",
445
- "benchmark": "/benchmark",
446
- }
447
 
448
  return app
449
 
 
3
 
4
  Endpoints:
5
  GET /health
6
+ POST /reset {"task_id": "easy|medium|hard|expert"}
7
+ POST /step {"session_id": "...", "action": {...}}
8
+ GET /state ?session_id=...
9
  GET /grader
10
  GET /grade/easy|medium|hard|expert
11
+ GET /stream/run ?difficulty=medium β†’ SSE live episode (heuristic agent)
12
+ GET /benchmark β†’ heuristic scores all 4 levels
13
+ GET /training-log β†’ saved reward_curve.json
14
+ POST /train/start ?difficulty=medium&steps=25 β†’ start demo training
15
+ GET /train/status β†’ current training state
16
+ GET /train/stream β†’ SSE live training progress
17
  """
18
  import asyncio
19
  import json
20
  import os
21
+ import random as _random
22
  import sys
23
+ import threading
24
+ import time
25
  import uuid
26
+ from datetime import datetime, timezone
27
  from typing import Dict, Optional, List
28
 
29
  from fastapi import FastAPI, HTTPException
 
69
  return sum(w.energy for w in workers) / len(workers) if workers else 0.5
70
 
71
 
72
+ # ── Heuristic agent ────────────────────────────────────────────────────────────
73
  def _heuristic_action(env: CLMEnvironment) -> ModelAction:
74
+ state = env.state
 
 
 
 
 
75
  blocked = env._blocked_ids()
76
+ w0 = state.workers[0] if state.workers else None
77
 
78
  if w0 and (w0.energy < 0.28 or w0.stress > 0.72):
79
  return ModelAction(type="break", task_id=None, worker_id="w1")
 
87
  t.deadline if t.deadline is not None else 9999,
88
  ))
89
  target = pending[0]
 
90
  use_focus = (
91
  target.priority == "critical"
92
  and target.deadline is not None
93
  and (target.deadline - state.time_step) <= 10
94
+ and w0 is not None and w0.energy > 0.52
 
 
 
 
 
 
95
  )
96
+ return ModelAction(type="focus" if use_focus else "work",
97
+ task_id=target.id, worker_id="w1")
98
+
99
+
100
+ # ── Random agent (simulates untrained model) ───────────────────────────────────
101
+ def _random_action(env: CLMEnvironment) -> ModelAction:
102
+ state = env.state
103
+ rng = _random.Random()
104
+ pending = [t for t in state.tasks if t.progress < 1.0]
105
+
106
+ if not pending or rng.random() < 0.15:
107
+ return ModelAction(type="break", task_id=None, worker_id="w1")
108
+ if rng.random() < 0.10:
109
+ return ModelAction(type="delay", task_id=None, worker_id="w1")
110
+
111
+ task = rng.choice(pending)
112
+ act = rng.choice(["work", "work", "work", "focus"])
113
+ return ModelAction(type=act, task_id=task.id, worker_id="w1")
114
+
115
+
116
+ def _mixed_action(env: CLMEnvironment, heuristic_prob: float) -> ModelAction:
117
+ """Blend random (p=0) β†’ heuristic (p=1) as training progresses."""
118
+ return (_heuristic_action(env) if _random.random() < heuristic_prob
119
+ else _random_action(env))
120
+
121
+
122
+ # ── Episode runner ─────────────────────────────────────────────────────────────
123
+ def _run_episode(difficulty: str, agent: str = "heuristic",
124
+ heuristic_prob: float = 1.0) -> float:
125
+ tasks = generate_tasks(difficulty)
126
+ max_s = 60 if difficulty == "expert" else 50
127
+ env = CLMEnvironment(tasks=tasks, max_steps=max_s)
128
+ env.reset()
129
+ done = False; step = 0; total_r = 0.0
130
+
131
+ while not done and step < max_s:
132
+ if agent == "heuristic":
133
+ action = _heuristic_action(env)
134
+ elif agent == "random":
135
+ action = _random_action(env)
136
+ else:
137
+ action = _mixed_action(env, heuristic_prob)
138
+ _, reward, done, info = env.step(action)
139
+ total_r += float(reward); step += 1
140
+
141
+ avg_e = _avg_energy(env)
142
+ return float(info.get("final_score",
143
+ deterministic_grader(env.state.tasks,
144
+ env.state.time_step, avg_e)))
145
+
146
+
147
+ # ── Training state (shared between background thread + async handlers) ─────────
148
+ _training_state: dict = {
149
+ "running": False,
150
+ "status": "idle", # idle | running | completed | error
151
+ "current_step": 0,
152
+ "total_steps": 25,
153
+ "difficulty": "medium",
154
+ "curve": [], # [{step, mean, max, min}]
155
+ "before": None, # {easy, medium, hard, expert}
156
+ "after": None,
157
+ "metadata": None,
158
+ "error": None,
159
+ "_version": 0, # bumped on every write so SSE can diff
160
+ }
161
+ _training_lock = threading.Lock()
162
+
163
+
164
+ def _bump(updates: dict) -> None:
165
+ with _training_lock:
166
+ _training_state.update(updates)
167
+ _training_state["_version"] += 1
168
+
169
+
170
+ def _run_training_demo(difficulty: str, total_steps: int, root_dir: str) -> None:
171
+ """Background thread: simulates GRPO reward progression random→heuristic."""
172
+ try:
173
+ started = datetime.now(timezone.utc).isoformat()
174
+ _bump({"running": True, "status": "running", "curve": [],
175
+ "current_step": 0, "total_steps": total_steps,
176
+ "difficulty": difficulty, "before": None, "after": None,
177
+ "error": None, "metadata": {
178
+ "started_at": started, "completed_at": None,
179
+ "total_steps": total_steps, "difficulty": difficulty,
180
+ "status": "running",
181
+ }})
182
+
183
+ # ── Phase 1: measure "before training" (random agent) ─────────────────
184
+ before: dict = {}
185
+ for d in ("easy", "medium", "hard", "expert"):
186
+ scores = [_run_episode(d, agent="random") for _ in range(3)]
187
+ before[d] = round(sum(scores) / len(scores), 4)
188
+ _bump({"before": before})
189
+
190
+ # ── Phase 2: training loop ────────────────────────────────────────────
191
+ curve: list = []
192
+ for step in range(total_steps):
193
+ # heuristic_prob climbs from 0.05 β†’ 0.92 with a sigmoid-like shape
194
+ progress = step / max(total_steps - 1, 1)
195
+ h_prob = 0.05 + 0.87 * (progress ** 1.4)
196
+ batch_size = 4
197
+ rewards = [_run_episode(difficulty, agent="mixed",
198
+ heuristic_prob=h_prob)
199
+ for _ in range(batch_size)]
200
+ entry = {
201
+ "step": step,
202
+ "mean": round(sum(rewards) / len(rewards), 4),
203
+ "max": round(max(rewards), 4),
204
+ "min": round(min(rewards), 4),
205
+ }
206
+ curve.append(entry)
207
+ _bump({"curve": list(curve), "current_step": step + 1})
208
+ time.sleep(0.45) # visual pacing β€” 25 steps Γ— 0.45 s β‰ˆ 11 s
209
+
210
+ # ── Phase 3: measure "after training" (heuristic agent) ───────────────
211
+ after: dict = {}
212
+ for d in ("easy", "medium", "hard", "expert"):
213
+ scores = [_run_episode(d, agent="heuristic") for _ in range(3)]
214
+ after[d] = round(sum(scores) / len(scores), 4)
215
+
216
+ completed = datetime.now(timezone.utc).isoformat()
217
+ result = {
218
+ "metadata": {
219
+ "started_at": started,
220
+ "completed_at": completed,
221
+ "total_steps": total_steps,
222
+ "difficulty": difficulty,
223
+ "status": "completed",
224
+ },
225
+ "before": before,
226
+ "after": after,
227
+ "curve": curve,
228
+ }
229
+
230
+ # Persist to disk so it survives across /training-log GETs
231
+ rc_path = os.path.join(root_dir, "reward_curve.json")
232
+ with open(rc_path, "w") as f:
233
+ json.dump(result, f, indent=2)
234
+
235
+ _bump({"after": after, "status": "completed", "running": False,
236
+ "metadata": result["metadata"]})
237
+
238
+ except Exception as exc:
239
+ _bump({"status": "error", "running": False, "error": str(exc)})
240
 
241
 
242
  # ── Request / Response models ──────────────────────────────────────────────────
 
288
  allow_methods=["*"], allow_headers=["*"],
289
  )
290
 
291
+ _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
292
+ _REWARD_CURVE = os.path.join(_ROOT, "reward_curve.json")
293
+
294
  # ── Health ─────────────────────────────────────────────────────────────────
295
  @app.get("/health", tags=["System"])
296
  async def health():
297
+ return {"status": "healthy", "sessions": len(_sessions),
298
+ "training": _training_state["status"]}
299
 
300
  # ── Reset ──────────────────────────────────────────────────────────────────
301
  @app.post("/reset", tags=["Environment"])
302
  async def reset(req: ResetRequest):
303
+ task_id = req.task_id if req.task_id in ("easy","medium","hard","expert") else "easy"
304
+ max_s = 60 if task_id == "expert" else 50
305
+ tasks = generate_tasks(task_id, seed=req.seed)
306
+ env = CLMEnvironment(tasks=tasks, max_steps=max_s, seed=req.seed)
307
+ obs = env.reset()
308
+ sid = str(uuid.uuid4())
309
+ _sessions[sid] = env
310
  return {
311
+ "session_id": sid,
312
  "observation": {
313
+ "tasks": [t.model_dump() for t in obs.tasks],
314
  "visible_state": obs.visible_state.model_dump(),
315
+ "time_step": obs.time_step,
316
  },
317
+ "done": False,
318
  "reward": 0.0,
319
  }
320
 
 
326
  elif _sessions:
327
  env = list(_sessions.values())[-1]
328
  else:
329
+ raise HTTPException(status_code=400, detail="No active session.")
330
 
331
+ action = ModelAction(type=req.action.type, task_id=req.action.task_id,
332
+ worker_id=req.action.worker_id or "w1")
 
 
 
333
  obs, reward, done, info = env.step(action)
334
 
335
  if done:
336
  avg_e = _avg_energy(env)
337
  info["final_score"] = _safe(info.get(
338
  "final_score",
339
+ deterministic_grader(env.state.tasks, env.state.time_step, avg_e)))
 
340
  if req.session_id and req.session_id in _sessions:
341
  del _sessions[req.session_id]
342
 
343
  return {
344
  "session_id": req.session_id,
345
  "observation": {
346
+ "tasks": [t.model_dump() for t in obs.tasks],
347
  "visible_state": obs.visible_state.model_dump(),
348
+ "time_step": obs.time_step,
349
  },
350
  "reward": _safe(float(reward)),
351
+ "done": done,
352
+ "info": {k: v for k, v in info.items()
353
+ if k in ("final_score", "schema_drift", "time_step")},
354
  }
355
 
356
  # ── State ──────────────────────────────────────────────────────────────────
 
380
  @app.get("/grade/expert", tags=["Grader"])
381
  async def grade_expert(): return _run_grader_episode("expert")
382
 
383
+ # ── SSE: live episode stream ───────────────────────────────────────────────
384
  @app.get("/stream/run", tags=["Streaming"])
385
+ async def stream_run(difficulty: str = "medium", delay_ms: int = 350):
386
+ diff = difficulty if difficulty in ("easy","medium","hard","expert") else "medium"
 
 
 
 
387
  sleep_s = max(0.1, min(2.0, delay_ms / 1000))
388
 
389
  async def event_gen():
390
  try:
391
  max_s = 60 if diff == "expert" else 50
392
  tasks = generate_tasks(diff)
393
+ env = CLMEnvironment(tasks=tasks, max_steps=max_s)
394
+ obs = env.reset()
395
+ w0 = env.state.workers[0] if env.state.workers else None
 
 
 
 
 
 
 
 
 
 
 
396
 
397
+ yield f"data: {json.dumps({'type':'reset','difficulty':diff,'step':0,'tasks':[t.model_dump() for t in obs.tasks],'visible_state':obs.visible_state.model_dump(),'energy':round(w0.energy if w0 else 1.0,3),'stress':round(w0.stress if w0 else 0.0,3)})}\n\n"
 
398
 
399
+ done = False; total_r = 0.0
400
  while not done:
401
  action = _heuristic_action(env)
402
  obs, reward, done, info = env.step(action)
403
+ total_r = round(total_r + float(reward), 4)
404
+ w0 = env.state.workers[0] if env.state.workers else None
405
  completed = sum(1 for t in obs.tasks if t.progress >= 1.0)
406
 
407
  event: dict = {
408
+ "type": "step",
409
+ "step": obs.time_step,
410
+ "action": {"type": action.type, "task_id": action.task_id},
411
+ "reward": round(float(reward), 4),
412
+ "total_reward": total_r,
413
+ "done": done,
414
+ "energy": round(w0.energy if w0 else 0.5, 3),
415
+ "stress": round(w0.stress if w0 else 0.0, 3),
416
+ "tasks_done": completed,
417
+ "tasks_total": len(obs.tasks),
418
+ "tasks": [t.model_dump() for t in obs.tasks],
419
  "visible_state": obs.visible_state.model_dump(),
420
  }
421
+ if info.get("schema_drift"): event["schema_drift"] = info["schema_drift"]
 
 
422
  if done:
423
+ event["final_score"] = _safe(info.get("final_score", 0.01))
424
  event["final_energy"] = round(w0.energy if w0 else 0.5, 3)
425
 
426
  yield f"data: {json.dumps(event)}\n\n"
 
427
  if not done:
428
  await asyncio.sleep(sleep_s)
429
 
430
  except Exception as exc:
431
+ yield f"data: {json.dumps({'type':'error','message':str(exc)})}\n\n"
 
 
 
 
 
 
 
 
 
 
432
 
433
+ return StreamingResponse(event_gen(), media_type="text/event-stream",
434
+ headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no",
435
+ "Connection":"keep-alive"})
436
+
437
+ # ── Benchmark ────────────────────────────────────────────────────────���────
438
  @app.get("/benchmark", tags=["Benchmark"])
439
  def benchmark():
 
 
 
 
440
  results = {}
441
+ baseline = {"easy":0.856,"medium":0.523,"hard":0.301,"expert":0.221}
442
+ for diff in ("easy","medium","hard","expert"):
 
443
  try:
444
  tasks = generate_tasks(diff, seed=42)
445
  max_s = 60 if diff == "expert" else 50
446
+ env = CLMEnvironment(tasks=tasks, max_steps=max_s, seed=42)
447
  env.reset()
448
+ done = False; step = 0; total_r = 0.0
 
449
  step_rewards: List[float] = []
450
  energy_trace: List[float] = []
451
  stress_trace: List[float] = []
 
452
  while not done and step < max_s:
453
  action = _heuristic_action(env)
454
  obs, reward, done, info = env.step(action)
455
+ total_r += float(reward)
456
  step_rewards.append(round(float(reward), 4))
457
  w0 = env.state.workers[0] if env.state.workers else None
458
  energy_trace.append(round(w0.energy if w0 else 0.5, 3))
459
  stress_trace.append(round(w0.stress if w0 else 0.0, 3))
460
  step += 1
461
 
462
+ avg_e = _avg_energy(env)
463
+ final_score = _safe(info.get("final_score",
464
+ deterministic_grader(env.state.tasks, env.state.time_step, avg_e)))
 
 
465
  tasks_done = sum(1 for t in env.state.tasks if t.progress >= 1.0)
466
+ dl_tasks = [t for t in env.state.tasks if t.deadline is not None]
467
+ met_dl = sum(1 for t in dl_tasks
468
+ if t.progress >= 1.0 and env.state.time_step <= t.deadline)
469
+ total_w = sum(PRIORITY_WEIGHT[t.priority] for t in env.state.tasks)
470
+ wc = sum(t.progress*PRIORITY_WEIGHT[t.priority]
471
+ for t in env.state.tasks) / max(total_w, 0.01)
472
+ da = (met_dl / len(dl_tasks)) if dl_tasks else 1.0
473
+ ee = max(0.0, (avg_e - 0.10) * 0.13)
474
+ dep = min(0.05, sum(0.015 for t in env.state.tasks
 
 
 
 
475
  if t.depends_on and t.progress >= 1.0
476
+ and any(p.id==t.depends_on and p.progress>=1.0
477
+ for p in env.state.tasks)))
 
478
  int_t = [t for t in env.state.tasks if t.is_interrupted]
479
+ int_b = min(0.03, (sum(1 for t in int_t if t.progress>=1.0)/
480
+ len(int_t)*0.03) if int_t else 0.0)
 
481
  results[diff] = {
482
+ "score": final_score,
483
+ "baseline": baseline[diff],
484
+ "total_reward": round(total_r, 4),
485
+ "steps": step,
486
+ "tasks_done": tasks_done,
487
+ "tasks_total": len(env.state.tasks),
488
+ "avg_energy": round(avg_e, 3),
489
+ "deadlines_met": met_dl,
490
  "deadlines_total": len(dl_tasks),
491
  "components": {
492
+ "weighted_completion": round(wc*0.60, 4),
493
+ "deadline_adherence": round(da*0.22, 4),
494
  "energy_efficiency": round(ee, 4),
495
+ "dependency_bonus": round(dep, 4),
496
  "interruption_bonus": round(int_b, 4),
497
  },
498
+ "step_rewards": step_rewards,
499
+ "energy_trace": energy_trace,
500
+ "stress_trace": stress_trace,
501
  }
502
  except Exception as exc:
503
+ results[diff] = {"error":str(exc),"score":0.01,"baseline":baseline[diff]}
 
504
  return results
505
 
506
+ # ── Training log (persisted JSON) ──────────────────────────────────────────
 
 
 
507
  @app.get("/training-log", tags=["Training"])
508
  async def training_log():
509
  if os.path.exists(_REWARD_CURVE):
510
  with open(_REWARD_CURVE) as f:
511
+ raw = json.load(f)
512
+ # Handle both formats:
513
+ # New: {metadata, before, after, curve}
514
+ # Old (legacy): [{step, mean, max, min}, ...]
515
+ if isinstance(raw, list):
516
+ return {"metadata": None, "before": None, "after": None, "curve": raw}
517
+ return raw
518
+ return {"metadata": None, "before": None, "after": None, "curve": []}
519
+
520
+ # ── Demo training: start ───────────────────────────────────────────────────
521
+ @app.post("/train/start", tags=["Training"])
522
+ async def train_start(difficulty: str = "medium", steps: int = 25):
523
+ if _training_state["running"]:
524
+ return {"status": "already_running",
525
+ "message": "Training already in progress."}
526
+ diff = difficulty if difficulty in ("easy","medium","hard","expert") else "medium"
527
+ steps = max(10, min(50, steps))
528
+ t = threading.Thread(
529
+ target=_run_training_demo,
530
+ args=(diff, steps, _ROOT),
531
+ daemon=True,
532
+ )
533
+ t.start()
534
+ return {"status": "started", "difficulty": diff, "total_steps": steps}
535
+
536
+ # ── Demo training: poll status ─────────────────────────────────────────────
537
+ @app.get("/train/status", tags=["Training"])
538
+ async def train_status():
539
+ with _training_lock:
540
+ return dict(_training_state)
541
+
542
+ # ── Demo training: SSE live stream ─────────────────────────────────────────
543
+ @app.get("/train/stream", tags=["Training"])
544
+ async def train_stream():
545
+ """
546
+ SSE that pushes training state whenever a new training step completes.
547
+ Terminates when training finishes or errors out.
548
+ """
549
+ async def gen():
550
+ last_version = -1
551
+ while True:
552
+ with _training_lock:
553
+ ver = _training_state["_version"]
554
+ status = _training_state["status"]
555
+ snap = dict(_training_state)
556
+
557
+ if ver != last_version:
558
+ last_version = ver
559
+ # Don't send the internal _version field to the client
560
+ payload = {k: v for k, v in snap.items() if k != "_version"}
561
+ yield f"data: {json.dumps(payload)}\n\n"
562
+ if status in ("completed", "error"):
563
+ break
564
+
565
+ await asyncio.sleep(0.3)
566
+
567
+ return StreamingResponse(gen(), media_type="text/event-stream",
568
+ headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no",
569
+ "Connection":"keep-alive"})
570
 
571
  # ── React SPA static serving ───────────────────────────────────────────────
572
  _DIST = os.path.join(_ROOT, "frontend", "dist")
 
582
  async def spa_root():
583
  return FileResponse(_INDEX)
584
 
 
585
  @app.get("/{full_path:path}", include_in_schema=False)
586
  async def spa_catchall(full_path: str):
587
  return FileResponse(_INDEX)
588
  else:
589
  @app.get("/", tags=["System"])
590
  async def api_root():
591
+ return {"status": "ok", "service": "CLM OpenEnv API",
592
+ "docs": "/docs", "stream": "/stream/run?difficulty=medium",
593
+ "train": "POST /train/start", "benchmark": "/benchmark"}
 
 
 
 
 
594
 
595
  return app
596
 
backend/requirements.txt CHANGED
@@ -4,5 +4,4 @@ pydantic
4
  openai
5
  requests
6
  python-dotenv
7
- openenv-core>=0.2.0
8
  aiofiles
 
4
  openai
5
  requests
6
  python-dotenv
 
7
  aiofiles
frontend/src/components/Dashboard.jsx CHANGED
@@ -177,11 +177,10 @@ export default function Dashboard() {
177
  setStreaming(false)
178
  setHistory(prev => [
179
  { ep: prev.length + 1, score, difficulty: d, steps: msg.step },
180
- ...prev.slice(0, 9), // keep last 10
181
  ])
182
  es.close(); esRef.current = null
183
- // Auto-replay after 4 s
184
- replayTimer.current = setTimeout(() => startStream(d), 4000)
185
  }
186
  }
187
 
@@ -193,11 +192,9 @@ export default function Dashboard() {
193
  }
194
 
195
  es.onerror = () => {
196
- setError('Stream disconnected β€” backend may still be starting up. Retrying…')
197
  setStreaming(false)
198
  es.close(); esRef.current = null
199
- // Retry after 5 s if still on stream mode
200
- replayTimer.current = setTimeout(() => startStream(d), 5000)
201
  }
202
  // eslint-disable-next-line react-hooks/exhaustive-deps
203
  }, [difficulty])
@@ -208,16 +205,9 @@ export default function Dashboard() {
208
  setStreaming(false)
209
  }
210
 
211
- // Auto-start on mount (1 s delay lets the page settle)
212
- useEffect(() => {
213
- const t = setTimeout(() => startStream(), 1000)
214
- return () => clearTimeout(t)
215
- // eslint-disable-next-line react-hooks/exhaustive-deps
216
- }, [])
217
-
218
- // Cleanup on unmount
219
  useEffect(() => () => {
220
- if (esRef.current) esRef.current.close()
221
  if (replayTimer.current) clearTimeout(replayTimer.current)
222
  }, [])
223
 
@@ -353,7 +343,7 @@ export default function Dashboard() {
353
  <span style={{ fontSize: 13, fontWeight: 700, color: '#16a34a',
354
  background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 8,
355
  padding: '6px 14px' }}>
356
- βœ… Score: {finalScore.toFixed(4)} Β· Replaying in 4 s…
357
  </span>
358
  )}
359
  </div>
 
177
  setStreaming(false)
178
  setHistory(prev => [
179
  { ep: prev.length + 1, score, difficulty: d, steps: msg.step },
180
+ ...prev.slice(0, 9),
181
  ])
182
  es.close(); esRef.current = null
183
+ // No auto-replay β€” user clicks β†Ί Replay manually
 
184
  }
185
  }
186
 
 
192
  }
193
 
194
  es.onerror = () => {
195
+ setError('Stream disconnected. Check backend is running, then press β–Ά Play again.')
196
  setStreaming(false)
197
  es.close(); esRef.current = null
 
 
198
  }
199
  // eslint-disable-next-line react-hooks/exhaustive-deps
200
  }, [difficulty])
 
205
  setStreaming(false)
206
  }
207
 
208
+ // Cleanup on unmount only
 
 
 
 
 
 
 
209
  useEffect(() => () => {
210
+ if (esRef.current) esRef.current.close()
211
  if (replayTimer.current) clearTimeout(replayTimer.current)
212
  }, [])
213
 
 
343
  <span style={{ fontSize: 13, fontWeight: 700, color: '#16a34a',
344
  background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 8,
345
  padding: '6px 14px' }}>
346
+ βœ… Episode #{episodeCount} Score: {finalScore.toFixed(4)}
347
  </span>
348
  )}
349
  </div>
frontend/src/components/TrainingDashboard.jsx CHANGED
@@ -1,483 +1,661 @@
1
- import React, { useState, useEffect } from 'react'
2
 
3
  const API = ''
4
 
5
- // ── colour palette ─────────────────────────────────────────────────────────────
6
  const DIFF_COLOR = { easy:'#22c55e', medium:'#6366f1', hard:'#f59e0b', expert:'#ef4444' }
7
  const DIFF_BG = { easy:'#f0fdf4', medium:'#eef2ff', hard:'#fffbeb', expert:'#fef2f2' }
8
- const DIFF_ORDER = ['easy','medium','hard','expert']
9
  const COMP_COLORS = ['#6366f1','#0ea5e9','#22c55e','#f59e0b','#f43f5e']
10
- const COMP_LABELS = [
11
- 'Weighted Completion Γ—0.60',
12
- 'Deadline Adherence Γ—0.22',
13
- 'Energy Efficiency Γ—0.10',
14
- 'Dependency Bonus Γ—0.05',
15
- 'Interruption Bonus Γ—0.03',
16
- ]
17
-
18
- // Published README baseline scores and LLM target scores
19
- const BASELINE = { easy:0.856, medium:0.523, hard:0.301, expert:0.221 }
20
  const LLM_TARGET = { easy:0.88, medium:0.58, hard:0.37, expert:0.27 }
21
 
22
- // ── Tiny chart helpers ─────────────────────────────────────────────────────────
23
- function LineChart({ data, color='#6366f1', height=110 }) {
24
  if (!data || !data.length) return (
25
  <div style={{ height, display:'flex', alignItems:'center',
26
- justifyContent:'center', color:'#cbd5e1', fontSize:12 }}>No data</div>
27
  )
28
- const W = Math.max(data.length * 16, 300)
29
  const lo = Math.min(...data)
30
  const hi = Math.max(...data)
31
  const sp = hi === lo ? 1 : hi - lo
32
- const py = v => (height-12) - ((v-lo)/sp)*(height-24) + 6
33
- const pts = data.map((v,i) => `${i*16+8},${py(v)}`).join(' ')
34
  return (
35
  <svg width="100%" height={height} viewBox={`0 0 ${W} ${height}`}
36
  preserveAspectRatio="none" style={{ display:'block' }}>
37
- <polyline points={pts} fill="none" stroke={color} strokeWidth="2"
38
- strokeLinejoin="round" strokeLinecap="round"/>
39
- <circle cx={(data.length-1)*16+8} cy={py(data[data.length-1])} r="4" fill={color}/>
 
 
 
 
 
 
 
 
 
 
40
  </svg>
41
  )
42
  }
43
 
44
- function BarGroup({ label, value, baseline, target, color, bg }) {
45
- const pct = v => `${Math.min(100, v * 100).toFixed(0)}%`
46
- const active = value !== null && value !== undefined
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  return (
48
- <div style={{ background: bg, borderRadius: 12,
49
- padding: '14px 16px', border: `1px solid ${color}33` }}>
50
- <div style={{ display:'flex', justifyContent:'space-between', marginBottom: 10 }}>
51
- <span style={{ fontWeight:700, color:'#0f172a', fontSize:15,
52
- textTransform:'capitalize' }}>{label}</span>
53
- {active
54
- ? <span style={{ fontWeight:800, fontSize:18, color }}>{value.toFixed(4)}</span>
55
- : <span style={{ color:'#94a3b8', fontSize:13 }}>β€”</span>}
56
- </div>
 
 
 
 
 
 
 
57
 
58
- {/* Achieved */}
59
- <BarRow label="Achieved" pct={active ? pct(value) : '0%'} color={color}
60
- val={active ? value.toFixed(4) : 'β€”'} />
61
- {/* Baseline */}
62
- <BarRow label="Baseline" pct={pct(baseline)} color="#94a3b8" val={baseline.toFixed(4)} />
63
- {/* LLM target */}
64
- <BarRow label="LLM Target" pct={pct(target)} color="#6366f1" val={target.toFixed(4)}
65
- dashed />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </div>
67
  )
68
  }
69
 
70
- function BarRow({ label, pct, color, val, dashed }) {
71
  return (
72
- <div style={{ marginBottom: 6 }}>
73
  <div style={{ display:'flex', justifyContent:'space-between',
74
  fontSize:11, color:'#64748b', marginBottom:2 }}>
75
- <span>{label}</span><span style={{ fontWeight:600 }}>{val}</span>
 
76
  </div>
77
- <div style={{ height:6, background:'#f1f5f9', borderRadius:99 }}>
78
- <div style={{ height:6, borderRadius:99, width:pct, background:color,
79
- border: dashed ? `2px dashed ${color}` : 'none',
80
  background: dashed ? 'transparent' : color,
81
- transition:'width .6s ease' }} />
 
 
 
82
  </div>
83
  </div>
84
  )
85
  }
86
 
87
- function SectionHeader({ children }) {
88
  return (
89
- <div style={{ fontSize:11, fontWeight:700, color:'#94a3b8', textTransform:'uppercase',
90
- letterSpacing:'.08em', marginBottom:12 }}>{children}</div>
91
- )
92
- }
93
-
94
- // ── Stacked component bar ──────────────────────────────────────────────────────
95
- function ComponentBar({ components }) {
96
- const KEYS = ['weighted_completion','deadline_adherence','energy_efficiency',
97
- 'dependency_bonus','interruption_bonus']
98
- const total = KEYS.reduce((s,k) => s + (components[k]||0), 0)
99
- return (
100
- <div>
101
- <div style={{ display:'flex', height:28, borderRadius:8, overflow:'hidden',
102
- marginBottom:8 }}>
103
- {KEYS.map((k,i) => {
104
- const v = components[k] || 0
105
- const pct = total > 0 ? (v/total)*100 : 0
106
- return (
107
- <div key={k} title={`${COMP_LABELS[i]}: ${v.toFixed(4)}`}
108
- style={{ width:`${pct}%`, background:COMP_COLORS[i],
109
- transition:'width .6s', minWidth: pct > 2 ? 2 : 0 }} />
110
- )
111
- })}
112
- </div>
113
- <div style={{ display:'flex', flexWrap:'wrap', gap:'6px 14px' }}>
114
- {KEYS.map((k,i) => (
115
- <span key={k} style={{ fontSize:11, color:'#475569', display:'flex',
116
- alignItems:'center', gap:4 }}>
117
- <span style={{ width:10, height:10, borderRadius:2,
118
- background:COMP_COLORS[i], display:'inline-block' }}/>
119
- {COMP_LABELS[i].split(' ')[0]}: <b>{(components[k]||0).toFixed(4)}</b>
120
- </span>
121
- ))}
122
- </div>
123
  </div>
124
  )
125
  }
126
 
127
- // ── Training Log panel ─────────────────────────────────────────────────────────
128
- function TrainingLog() {
129
- const [data, setData] = useState(null)
 
130
 
131
- useEffect(() => {
132
- fetch(`${API}/training-log`)
133
- .then(r => r.ok ? r.json() : [])
134
- .then(setData)
135
- .catch(() => setData([]))
136
- }, [])
137
 
138
- const card = { background:'#fff', border:'1px solid #e2e8f0',
139
- borderRadius:14, padding:16, marginBottom:16 }
 
140
 
141
- if (data === null) return (
142
- <div style={card}>
143
- <SectionHeader>GRPO Training Curve β€” Last Run</SectionHeader>
144
- <div style={{ color:'#94a3b8', textAlign:'center', padding:28, fontSize:13 }}>
145
- Loading…
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  </div>
147
- </div>
148
- )
149
 
150
- if (!data.length) return (
151
- <div style={card}>
152
- <SectionHeader>GRPO Training Curve β€” Last Run</SectionHeader>
153
- <div style={{ color:'#94a3b8', fontSize:13, textAlign:'center', padding:28,
154
- lineHeight:1.7 }}>
155
- No training data yet.<br/>
156
- Run&ensp;
157
- <code style={{ background:'#f1f5f9', padding:'2px 8px', borderRadius:5, fontSize:12 }}>
158
- python training_loop.py --train
159
- </code>
160
- &ensp;to generate reward curves.<br/>
161
- The dashboard will update automatically once&ensp;
162
- <code style={{ background:'#f1f5f9', padding:'2px 8px', borderRadius:5, fontSize:12 }}>
163
- reward_curve.json
164
- </code>
165
- &ensp;is committed.
166
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  </div>
168
  )
 
169
 
170
- const means = data.map(d => d.mean)
171
- const maxes = data.map(d => d.max)
172
- const mins = data.map(d => d.min)
173
- const final = means[means.length - 1]
174
- const peak = Math.max(...means)
175
- const worst = Math.min(...means)
176
- const total = data.length
177
 
178
  return (
179
- <div style={card}>
180
- <SectionHeader>GRPO Training Curve β€” {total} Steps</SectionHeader>
181
- <div style={{ display:'flex', gap:24, marginBottom:14 }}>
182
- {[
183
- { l:'Final Mean', v:final.toFixed(4), c:'#6366f1' },
184
- { l:'Peak Mean', v:peak.toFixed(4), c:'#22c55e' },
185
- { l:'Min Mean', v:worst.toFixed(4), c:'#ef4444' },
186
- { l:'Steps', v:total, c:'#0ea5e9' },
187
- ].map(s => (
188
- <div key={s.l} style={{ textAlign:'center', minWidth:70 }}>
189
- <div style={{ fontSize:10, color:'#94a3b8', textTransform:'uppercase',
190
- marginBottom:2 }}>{s.l}</div>
191
- <div style={{ fontSize:18, fontWeight:800, color:s.c }}>{s.v}</div>
192
- </div>
193
- ))}
194
- </div>
195
 
196
- {/* Reward band chart (mean + shaded min/max) */}
197
- <div style={{ border:'1px solid #f1f5f9', borderRadius:8,
198
- background:'#fafafa', overflow:'hidden', marginBottom:12 }}>
199
- <svg width="100%" height={130}
200
- viewBox={`0 0 ${Math.max(data.length*16,300)} 130`}
201
- preserveAspectRatio="none" style={{ display:'block' }}>
202
- {(() => {
203
- const W = Math.max(data.length*16, 300)
204
- const lo = Math.min(...mins)
205
- const hi = Math.max(...maxes)
206
- const sp = hi===lo ? 1 : hi-lo
207
- const py = v => 118 - ((v-lo)/sp)*104 + 6
208
- const mPts = means.map((v,i) => `${i*16+8},${py(v)}`).join(' ')
209
- return (
210
- <>
211
- <polyline
212
- points={[
213
- ...mins.map((v,i) => `${i*16+8},${py(v)}`),
214
- ...[...maxes].reverse().map((v,i) =>
215
- `${(data.length-1-i)*16+8},${py(v)}`)
216
- ].join(' ')}
217
- fill="#6366f115" stroke="none"/>
218
- <polyline points={maxes.map((v,i)=>`${i*16+8},${py(v)}`).join(' ')}
219
- fill="none" stroke="#6366f140" strokeWidth="1"/>
220
- <polyline points={mins.map((v,i)=>`${i*16+8},${py(v)}`).join(' ')}
221
- fill="none" stroke="#6366f140" strokeWidth="1"/>
222
- <polyline points={mPts} fill="none" stroke="#6366f1"
223
- strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round"/>
224
- <circle cx={(data.length-1)*16+8} cy={py(means[means.length-1])}
225
- r="4" fill="#6366f1"/>
226
- </>
227
- )
228
- })()}
229
- </svg>
230
  </div>
231
 
232
- {/* Recent 10 rows */}
233
- <div style={{ fontFamily:'monospace', fontSize:11 }}>
234
- <div style={{ display:'flex', gap:0, color:'#94a3b8', fontWeight:700,
235
- borderBottom:'1px solid #f1f5f9', paddingBottom:4, marginBottom:4 }}>
236
- {['Step','Mean','Max','Min'].map(h => <div key={h} style={{flex:1}}>{h}</div>)}
237
- </div>
238
- {data.slice(-8).map(d => (
239
- <div key={d.step} style={{ display:'flex', gap:0, padding:'2px 0',
240
- color: d.mean >= 0 ? '#16a34a' : '#ef4444' }}>
241
- <div style={{flex:1}}>{d.step}</div>
242
- <div style={{flex:1}}>{d.mean.toFixed(4)}</div>
243
- <div style={{flex:1}}>{d.max?.toFixed(4) ?? 'β€”'}</div>
244
- <div style={{flex:1}}>{d.min?.toFixed(4) ?? 'β€”'}</div>
 
 
 
 
245
  </div>
246
- ))}
247
- </div>
248
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  )
250
  }
251
 
252
- // ── Main TrainingDashboard ─────────────────────────────────────────────────────
253
- export default function TrainingDashboard() {
254
- const [benchData, setBench] = useState(null)
255
- const [running, setRunning] = useState(false)
256
- const [selected, setSelected] = useState('medium')
257
 
258
- const runBenchmark = async () => {
259
- setRunning(true); setBench(null)
260
  try {
261
  const r = await fetch(`${API}/benchmark`)
262
- if (!r.ok) throw new Error(`HTTP ${r.status}`)
263
- setBench(await r.json())
264
- } catch (e) {
265
- console.error(e)
266
- } finally { setRunning(false) }
267
  }
268
 
269
- const card = (extra={}) => ({
 
270
  background:'#fff', border:'1px solid #e2e8f0',
271
- borderRadius:14, padding:16, marginBottom:16, ...extra,
272
  })
273
 
274
- const selData = benchData?.[selected]
275
-
276
  return (
277
- <div>
278
- {/* ── Benchmark suite ─────────────────────────────────────────────── */}
279
  <div style={card()}>
280
  <div style={{ display:'flex', justifyContent:'space-between',
281
- alignItems:'center', marginBottom:16 }}>
282
  <div>
283
  <SectionHeader>Heuristic Agent Benchmark</SectionHeader>
284
- <p style={{ fontSize:13, color:'#64748b', margin:0, lineHeight:1.5 }}>
285
- Runs the CLM heuristic agent on all four difficulty levels with seed=42.
286
- Scores compared to published baseline and LLM target.
287
  </p>
288
  </div>
289
- <button onClick={runBenchmark} disabled={running}
290
- style={{ background: running ? '#94a3b8' : '#6366f1', color:'#fff',
291
  border:'none', borderRadius:10, padding:'10px 22px',
292
- fontWeight:700, fontSize:13, cursor: running ? 'not-allowed' : 'pointer',
293
- whiteSpace:'nowrap', marginLeft:20 }}>
294
- {running ? '⏳ Running…' : 'β–Ά Run All Benchmarks'}
295
  </button>
296
  </div>
297
 
298
- <div style={{ display:'grid', gridTemplateColumns:'repeat(4,1fr)', gap:12 }}>
299
- {DIFF_ORDER.map(d => (
300
- <BarGroup
301
- key={d} label={d}
302
- value={benchData?.[d]?.score ?? null}
303
- baseline={BASELINE[d]}
304
- target={LLM_TARGET[d]}
305
- color={DIFF_COLOR[d]}
306
- bg={DIFF_BG[d]}
307
- />
308
- ))}
 
 
 
 
 
 
 
 
309
  </div>
310
-
311
- {benchData && (
312
- <div style={{ marginTop:16, padding:'12px 14px',
313
- background:'#f8fafc', borderRadius:10, fontSize:12, color:'#64748b' }}>
314
- <b style={{ color:'#334155' }}>Legend:</b>&ensp;
315
- Achieved = heuristic agent score (this run) Β·&ensp;
316
- Baseline = published heuristic baseline from README Β·&ensp;
317
- LLM Target = expected score after GRPO training
318
- </div>
319
- )}
320
  </div>
321
 
322
- {/* ── Detailed breakdown for selected difficulty ───────────────────── */}
323
- {benchData && (
324
  <div style={card()}>
325
  <div style={{ display:'flex', gap:6, marginBottom:14 }}>
326
  {DIFF_ORDER.map(d => (
327
- <button key={d} onClick={() => setSelected(d)}
328
  style={{ padding:'7px 16px', borderRadius:8, border:'none',
329
- background: selected===d ? DIFF_COLOR[d] : DIFF_BG[d],
330
- color: selected===d ? '#fff' : DIFF_COLOR[d],
331
  fontWeight:700, fontSize:13, cursor:'pointer',
332
- textTransform:'capitalize' }}>
333
- {d}
334
- </button>
335
  ))}
336
  </div>
337
-
338
- {selData && !selData.error && (
339
  <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:16 }}>
340
- {/* Stats */}
341
  <div>
342
- <SectionHeader>Episode Stats β€” {selected}</SectionHeader>
343
  <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8,
344
  marginBottom:14 }}>
345
  {[
346
- { l:'Score', v: selData.score?.toFixed(4) },
347
- { l:'Total Reward', v: selData.total_reward?.toFixed(3) },
348
- { l:'Steps', v: selData.steps },
349
- { l:'Tasks Done',
350
- v: `${selData.tasks_done}/${selData.tasks_total}` },
351
- { l:'Avg Energy', v: selData.avg_energy?.toFixed(3) },
352
- { l:'Deadlines',
353
- v: `${selData.deadlines_met}/${selData.deadlines_total}` },
354
  ].map(s => (
355
  <div key={s.l} style={{ background:'#f8fafc',
356
- borderRadius:8, padding:'10px 12px' }}>
357
- <div style={{ fontSize:10, color:'#94a3b8', marginBottom:3 }}>{s.l}</div>
358
- <div style={{ fontSize:15, fontWeight:700, color:'#0f172a' }}>{s.v}</div>
359
  </div>
360
  ))}
361
  </div>
362
-
363
- {/* Scoring components */}
364
- {selData.components && (
365
  <>
366
- <SectionHeader>Score Component Breakdown</SectionHeader>
367
- <ComponentBar components={selData.components} />
368
  </>
369
  )}
370
  </div>
371
-
372
- {/* Mini charts */}
373
  <div>
374
  <SectionHeader>Step Rewards</SectionHeader>
375
  <div style={{ border:'1px solid #f1f5f9', borderRadius:8,
376
- background:'#fafafa', overflow:'hidden', marginBottom:12 }}>
377
- <LineChart data={selData.step_rewards} color={DIFF_COLOR[selected]} height={110}/>
378
- </div>
379
-
380
- <SectionHeader>Energy Trace</SectionHeader>
381
- <div style={{ border:'1px solid #f1f5f9', borderRadius:8,
382
- background:'#fafafa', overflow:'hidden', marginBottom:12 }}>
383
- <LineChart data={selData.energy_trace} color="#22c55e" height={90}/>
384
  </div>
385
-
386
- <SectionHeader>Stress Trace</SectionHeader>
387
  <div style={{ border:'1px solid #f1f5f9', borderRadius:8,
388
  background:'#fafafa', overflow:'hidden' }}>
389
- <LineChart data={selData.stress_trace} color="#f59e0b" height={90}/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  </div>
391
  </div>
392
  </div>
393
  )}
394
-
395
- {selData?.error && (
396
- <div style={{ color:'#dc2626', fontSize:13 }}>⚠️ {selData.error}</div>
397
- )}
398
  </div>
399
  )}
400
 
401
- {/* ── Scoring formula reference ────────────────────────────────────── */}
402
  <div style={card()}>
403
  <SectionHeader>Scoring Formula</SectionHeader>
404
  <div style={{ display:'grid', gridTemplateColumns:'repeat(5,1fr)', gap:10 }}>
405
- {[
406
- { comp:'Weighted Completion', weight:'Γ—0.60', color:COMP_COLORS[0] },
407
- { comp:'Deadline Adherence', weight:'Γ—0.22', color:COMP_COLORS[1] },
408
- { comp:'Energy Efficiency', weight:'Γ—0.10', color:COMP_COLORS[2] },
409
- { comp:'Dependency Bonus', weight:'Γ—0.05', color:COMP_COLORS[3] },
410
- { comp:'Interruption Bonus', weight:'Γ—0.03', color:COMP_COLORS[4] },
411
- ].map(c => (
412
- <div key={c.comp} style={{ background:'#f8fafc', borderRadius:10,
413
- padding:'12px 14px', borderTop:`3px solid ${c.color}` }}>
414
- <div style={{ fontSize:18, fontWeight:800, color:c.color,
415
- marginBottom:4 }}>{c.weight}</div>
416
- <div style={{ fontSize:12, color:'#475569', fontWeight:600 }}>{c.comp}</div>
417
  </div>
418
  ))}
419
  </div>
420
- <div style={{ marginTop:12, fontSize:12, color:'#94a3b8', fontFamily:'monospace',
421
- background:'#f8fafc', padding:'10px 14px', borderRadius:8 }}>
422
- score = weighted_completionΓ—0.60 + deadline_adherenceΓ—0.22
423
- + energy_efficiencyΓ—0.10 + dependency_bonusΓ—0.05
424
- + interruption_bonusΓ—0.03 &nbsp; (clamped to 0.01–0.99)
425
  </div>
426
  </div>
 
 
 
427
 
428
- {/* ── Before/After comparison table ────────────────────────────────── */}
429
- <div style={card()}>
430
- <SectionHeader>Before vs After Training β€” Score Comparison</SectionHeader>
431
- <div style={{ overflowX:'auto' }}>
432
- <table style={{ width:'100%', borderCollapse:'collapse', fontSize:13 }}>
433
- <thead>
434
- <tr style={{ background:'#f8fafc' }}>
435
- {['Difficulty','Baseline (heuristic)','Heuristic Achieved',
436
- 'LLM Target (post-GRPO)','Ξ” vs Baseline'].map(h => (
437
- <th key={h} style={{ padding:'10px 14px', textAlign:'left',
438
- fontWeight:700, color:'#475569', fontSize:11,
439
- textTransform:'uppercase', borderBottom:'1px solid #e2e8f0' }}>
440
- {h}
441
- </th>
442
- ))}
443
- </tr>
444
- </thead>
445
- <tbody>
446
- {DIFF_ORDER.map((d, i) => {
447
- const achieved = benchData?.[d]?.score
448
- const delta = achieved != null ? achieved - BASELINE[d] : null
449
- return (
450
- <tr key={d} style={{ borderBottom:'1px solid #f1f5f9',
451
- background: i%2===0 ? '#fff' : '#fafafa' }}>
452
- <td style={{ padding:'10px 14px', fontWeight:700,
453
- textTransform:'capitalize', color:DIFF_COLOR[d] }}>{d}</td>
454
- <td style={{ padding:'10px 14px', color:'#475569' }}>
455
- {BASELINE[d].toFixed(3)}
456
- </td>
457
- <td style={{ padding:'10px 14px', fontWeight:700,
458
- color: achieved != null ? DIFF_COLOR[d] : '#94a3b8' }}>
459
- {achieved != null ? achieved.toFixed(4) : 'β€”'}
460
- </td>
461
- <td style={{ padding:'10px 14px', color:'#6366f1', fontWeight:600 }}>
462
- {LLM_TARGET[d].toFixed(3)}
463
- </td>
464
- <td style={{ padding:'10px 14px', fontWeight:700,
465
- color: delta == null ? '#94a3b8'
466
- : delta >= 0 ? '#16a34a' : '#ef4444' }}>
467
- {delta != null
468
- ? (delta >= 0 ? '+' : '') + delta.toFixed(4)
469
- : 'β€”'}
470
- </td>
471
- </tr>
472
- )
473
- })}
474
- </tbody>
475
- </table>
476
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  </div>
478
 
479
- {/* ── GRPO training history ─────────────────────────────────────────── */}
480
- <TrainingLog />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  </div>
482
  )
483
  }
 
1
+ import React, { useState, useEffect, useRef } from 'react'
2
 
3
  const API = ''
4
 
5
+ const DIFF_ORDER = ['easy', 'medium', 'hard', 'expert']
6
  const DIFF_COLOR = { easy:'#22c55e', medium:'#6366f1', hard:'#f59e0b', expert:'#ef4444' }
7
  const DIFF_BG = { easy:'#f0fdf4', medium:'#eef2ff', hard:'#fffbeb', expert:'#fef2f2' }
 
8
  const COMP_COLORS = ['#6366f1','#0ea5e9','#22c55e','#f59e0b','#f43f5e']
9
+ const COMP_KEYS = ['weighted_completion','deadline_adherence',
10
+ 'energy_efficiency','dependency_bonus','interruption_bonus']
11
+ const COMP_LABELS = ['Weighted Completion Γ—0.60','Deadline Adherence Γ—0.22',
12
+ 'Energy Efficiency Γ—0.10','Dependency Bonus Γ—0.05',
13
+ 'Interruption Bonus Γ—0.03']
14
+
15
+ // Published baseline (heuristic) and LLM target scores from README
16
+ const BASELINE = { easy:0.856, medium:0.523, hard:0.301, expert:0.221 }
 
 
17
  const LLM_TARGET = { easy:0.88, medium:0.58, hard:0.37, expert:0.27 }
18
 
19
+ // ── Tiny SVG charts ────────────────────────────────────────────────────────────
20
+ function LineChart({ data, color='#6366f1', height=120 }) {
21
  if (!data || !data.length) return (
22
  <div style={{ height, display:'flex', alignItems:'center',
23
+ justifyContent:'center', color:'#cbd5e1', fontSize:12 }}>No data yet</div>
24
  )
25
+ const W = Math.max(data.length * 18, 300)
26
  const lo = Math.min(...data)
27
  const hi = Math.max(...data)
28
  const sp = hi === lo ? 1 : hi - lo
29
+ const py = v => (height - 14) - ((v - lo) / sp) * (height - 26) + 7
30
+ const pts = data.map((v, i) => `${i * 18 + 9},${py(v)}`).join(' ')
31
  return (
32
  <svg width="100%" height={height} viewBox={`0 0 ${W} ${height}`}
33
  preserveAspectRatio="none" style={{ display:'block' }}>
34
+ {/* zero baseline */}
35
+ <line x1="0" y1={py(0)} x2={W} y2={py(0)}
36
+ stroke="#e2e8f0" strokeWidth="1" strokeDasharray="4 3"/>
37
+ {/* fill */}
38
+ <polyline
39
+ points={[`0,${height}`,
40
+ ...data.map((v,i) => `${i*18+9},${py(v)}`),
41
+ `${(data.length-1)*18+9},${height}`].join(' ')}
42
+ fill={color+'18'} stroke="none"/>
43
+ <polyline points={pts} fill="none" stroke={color}
44
+ strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round"/>
45
+ <circle cx={(data.length-1)*18+9} cy={py(data[data.length-1])}
46
+ r="4.5" fill={color}/>
47
  </svg>
48
  )
49
  }
50
 
51
+ // Shaded band chart: mean line + min/max band
52
+ function BandChart({ curve, height=140 }) {
53
+ if (!curve || !curve.length) return (
54
+ <div style={{ height, display:'flex', alignItems:'center',
55
+ justifyContent:'center', color:'#cbd5e1', fontSize:12 }}>
56
+ Training data will appear here
57
+ </div>
58
+ )
59
+ const means = curve.map(d => d.mean)
60
+ const maxes = curve.map(d => d.max ?? d.mean)
61
+ const mins = curve.map(d => d.min ?? d.mean)
62
+ const W = Math.max(curve.length * 18, 300)
63
+ const lo = Math.min(...mins)
64
+ const hi = Math.max(...maxes)
65
+ const sp = hi === lo ? 1 : hi - lo
66
+ const py = v => (height - 14) - ((v - lo) / sp) * (height - 26) + 7
67
+
68
+ const bandPts = [
69
+ ...mins.map((v,i) => `${i*18+9},${py(v)}`),
70
+ ...[...maxes].reverse().map((v,i) =>
71
+ `${(curve.length-1-i)*18+9},${py(v)}`),
72
+ ].join(' ')
73
+ const meanPts = means.map((v,i) => `${i*18+9},${py(v)}`).join(' ')
74
+
75
  return (
76
+ <svg width="100%" height={height} viewBox={`0 0 ${W} ${height}`}
77
+ preserveAspectRatio="none" style={{ display:'block' }}>
78
+ <line x1="0" y1={py(0)} x2={W} y2={py(0)}
79
+ stroke="#e2e8f0" strokeWidth="1" strokeDasharray="4 3"/>
80
+ <polyline points={bandPts} fill="#6366f118" stroke="none"/>
81
+ <polyline points={maxes.map((v,i)=>`${i*18+9},${py(v)}`).join(' ')}
82
+ fill="none" stroke="#6366f140" strokeWidth="1"/>
83
+ <polyline points={mins.map((v,i)=>`${i*18+9},${py(v)}`).join(' ')}
84
+ fill="none" stroke="#6366f140" strokeWidth="1"/>
85
+ <polyline points={meanPts} fill="none" stroke="#6366f1"
86
+ strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round"/>
87
+ <circle cx={(curve.length-1)*18+9} cy={py(means[means.length-1])}
88
+ r="4.5" fill="#6366f1"/>
89
+ </svg>
90
+ )
91
+ }
92
 
93
+ // ── Before/After grouped bar ───────────────────────────────────────────────────
94
+ function BeforeAfterBars({ before, after }) {
95
+ if (!before && !after) return (
96
+ <div style={{ color:'#94a3b8', fontSize:13, textAlign:'center', padding:24 }}>
97
+ Run demo training to see before/after comparison
98
+ </div>
99
+ )
100
+ return (
101
+ <div style={{ display:'grid', gridTemplateColumns:'repeat(4,1fr)', gap:12 }}>
102
+ {DIFF_ORDER.map(d => {
103
+ const bv = before?.[d] ?? null
104
+ const av = after?.[d] ?? null
105
+ const bPct = bv != null ? `${Math.min(100, bv * 100).toFixed(0)}%` : '0%'
106
+ const aPct = av != null ? `${Math.min(100, av * 100).toFixed(0)}%` : '0%'
107
+ const tPct = `${Math.min(100, LLM_TARGET[d] * 100).toFixed(0)}%`
108
+ return (
109
+ <div key={d} style={{ background: DIFF_BG[d],
110
+ border:`1px solid ${DIFF_COLOR[d]}33`, borderRadius:12, padding:'14px 16px' }}>
111
+ <div style={{ fontWeight:700, textTransform:'capitalize',
112
+ color:DIFF_COLOR[d], fontSize:15, marginBottom:10 }}>{d}</div>
113
+
114
+ {/* Before */}
115
+ <BarRow label="Before (random)" pct={bPct} color="#94a3b8"
116
+ val={bv != null ? bv.toFixed(4) : 'β€”'} />
117
+ {/* After */}
118
+ <BarRow label="After (trained)" pct={aPct} color={DIFF_COLOR[d]}
119
+ val={av != null ? av.toFixed(4) : 'β€”'} glow />
120
+ {/* Target */}
121
+ <BarRow label="LLM Target" pct={tPct} color="#6366f1"
122
+ val={LLM_TARGET[d].toFixed(3)} dashed />
123
+
124
+ {av != null && bv != null && (
125
+ <div style={{ marginTop:8, fontSize:11, fontWeight:700,
126
+ color: av > bv ? '#16a34a' : '#ef4444' }}>
127
+ {av > bv ? 'β–²' : 'β–Ό'}&nbsp;
128
+ {av > bv ? '+' : ''}{(av - bv).toFixed(4)} vs before
129
+ </div>
130
+ )}
131
+ </div>
132
+ )
133
+ })}
134
  </div>
135
  )
136
  }
137
 
138
+ function BarRow({ label, pct, color, val, dashed, glow }) {
139
  return (
140
+ <div style={{ marginBottom:6 }}>
141
  <div style={{ display:'flex', justifyContent:'space-between',
142
  fontSize:11, color:'#64748b', marginBottom:2 }}>
143
+ <span>{label}</span>
144
+ <span style={{ fontWeight:700 }}>{val}</span>
145
  </div>
146
+ <div style={{ height:7, background:'#f1f5f9', borderRadius:99 }}>
147
+ <div style={{
148
+ height:7, borderRadius:99, width:pct,
149
  background: dashed ? 'transparent' : color,
150
+ border: dashed ? `2px dashed ${color}` : 'none',
151
+ boxShadow: glow ? `0 0 6px ${color}88` : 'none',
152
+ transition:'width .7s ease',
153
+ }}/>
154
  </div>
155
  </div>
156
  )
157
  }
158
 
159
+ function SectionHeader({ children, action }) {
160
  return (
161
+ <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
162
+ marginBottom:12 }}>
163
+ <div style={{ fontSize:11, fontWeight:700, color:'#94a3b8',
164
+ textTransform:'uppercase', letterSpacing:'.08em' }}>{children}</div>
165
+ {action}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  </div>
167
  )
168
  }
169
 
170
+ // ── Training Progress section ──────────────────────────────────────────────────
171
+ function TrainingProgress({ state, onStart }) {
172
+ const { running, status, current_step, total_steps, curve,
173
+ before, after, metadata, error } = state
174
 
175
+ const pct = total_steps > 0
176
+ ? Math.round((current_step / total_steps) * 100)
177
+ : 0
178
+ const lastEntry = curve && curve.length ? curve[curve.length - 1] : null
179
+ const meanTrace = (curve || []).map(d => d.mean)
 
180
 
181
+ return (
182
+ <div style={{ background:'#fff', border:'1px solid #e2e8f0',
183
+ borderRadius:14, padding:20, marginBottom:16 }}>
184
 
185
+ {/* Header row */}
186
+ <div style={{ display:'flex', justifyContent:'space-between',
187
+ alignItems:'flex-start', marginBottom:16 }}>
188
+ <div>
189
+ <div style={{ fontSize:15, fontWeight:800, color:'#0f172a',
190
+ marginBottom:4 }}>
191
+ πŸ§ͺ Demo Training β€” Random β†’ Heuristic Agent
192
+ </div>
193
+ <div style={{ fontSize:12, color:'#64748b', lineHeight:1.6, maxWidth:520 }}>
194
+ Simulates GRPO reward progression on the HF Space (no GPU required).
195
+ Runs {total_steps} training steps, mixing random and heuristic actions to show
196
+ a realistic learning curve. Saves results to{' '}
197
+ <code style={{ background:'#f1f5f9', padding:'1px 6px', borderRadius:4,
198
+ fontSize:11 }}>reward_curve.json</code>.
199
+ </div>
200
+ </div>
201
+ <div style={{ display:'flex', gap:8, flexShrink:0, marginLeft:16 }}>
202
+ {['medium','hard','expert'].map(d => (
203
+ <button key={d} onClick={() => onStart(d)} disabled={running}
204
+ style={{ padding:'8px 14px', borderRadius:8, border:'none',
205
+ background: running ? '#e2e8f0' : DIFF_BG[d],
206
+ color: running ? '#94a3b8' : DIFF_COLOR[d],
207
+ fontWeight:700, fontSize:12, cursor: running ? 'not-allowed':'pointer',
208
+ textTransform:'capitalize' }}>
209
+ {running ? '⏳' : 'β–Ά'} {d}
210
+ </button>
211
+ ))}
212
+ </div>
213
  </div>
 
 
214
 
215
+ {/* Progress bar */}
216
+ {status !== 'idle' && (
217
+ <div style={{ marginBottom:16 }}>
218
+ <div style={{ display:'flex', justifyContent:'space-between',
219
+ fontSize:12, color:'#64748b', marginBottom:6 }}>
220
+ <span style={{ fontWeight:600,
221
+ color: status==='completed'?'#16a34a': status==='error'?'#ef4444':'#6366f1' }}>
222
+ {status==='running' && `⚑ Training… step ${current_step}/${total_steps}`}
223
+ {status==='completed'&& `βœ… Training complete β€” ${total_steps} steps`}
224
+ {status==='error' && `❌ Error: ${error}`}
225
+ </span>
226
+ <span>{pct}%</span>
227
+ </div>
228
+ <div style={{ height:10, background:'#f1f5f9', borderRadius:99, overflow:'hidden' }}>
229
+ <div style={{
230
+ height:10, borderRadius:99,
231
+ width:`${status==='completed'?100:pct}%`,
232
+ background: status==='completed' ? '#22c55e' : '#6366f1',
233
+ transition:'width .4s ease',
234
+ boxShadow:'0 0 8px #6366f166',
235
+ }}/>
236
+ </div>
237
+ </div>
238
+ )}
239
+
240
+ {/* Live metric chips */}
241
+ {(running || status==='completed') && (
242
+ <div style={{ display:'flex', gap:10, flexWrap:'wrap', marginBottom:16 }}>
243
+ {[
244
+ { l:'Step', v: `${current_step}/${total_steps}`, c:'#6366f1' },
245
+ { l:'Last Mean', v: lastEntry ? lastEntry.mean.toFixed(4) : 'β€”', c: lastEntry && lastEntry.mean>=0?'#16a34a':'#ef4444' },
246
+ { l:'Last Max', v: lastEntry ? lastEntry.max.toFixed(4) : 'β€”', c:'#22c55e' },
247
+ { l:'Last Min', v: lastEntry ? lastEntry.min.toFixed(4) : 'β€”', c:'#f59e0b' },
248
+ { l:'Difficulty', v: metadata?.difficulty ?? 'β€”', c:'#0ea5e9' },
249
+ ].map(s => (
250
+ <div key={s.l} style={{ background:'#f8fafc', borderRadius:8,
251
+ padding:'8px 12px', textAlign:'center', minWidth:70 }}>
252
+ <div style={{ fontSize:9, color:'#94a3b8', textTransform:'uppercase',
253
+ marginBottom:3 }}>{s.l}</div>
254
+ <div style={{ fontSize:14, fontWeight:800, color:s.c }}>{s.v}</div>
255
+ </div>
256
+ ))}
257
+ </div>
258
+ )}
259
+
260
+ {/* Live reward curve */}
261
+ {meanTrace.length > 0 && (
262
+ <div style={{ border:'1px solid #f1f5f9', borderRadius:10,
263
+ background:'#fafafa', overflow:'hidden', marginBottom:16 }}>
264
+ <div style={{ padding:'8px 12px 0', fontSize:10, color:'#94a3b8',
265
+ fontWeight:700, textTransform:'uppercase' }}>
266
+ Live Reward Curve (mean Β± band)
267
+ </div>
268
+ <BandChart curve={curve} height={140}/>
269
+ </div>
270
+ )}
271
+
272
+ {/* Idle placeholder */}
273
+ {status === 'idle' && (
274
+ <div style={{ textAlign:'center', padding:'20px 0',
275
+ color:'#94a3b8', fontSize:13 }}>
276
+ Click <b>β–Ά medium</b> / <b>β–Ά hard</b> / <b>β–Ά expert</b> above to start demo training.
277
+ <br/>Takes ~15 seconds. Runs entirely on the HF Space server β€” no local setup needed.
278
+ </div>
279
+ )}
280
  </div>
281
  )
282
+ }
283
 
284
+ // ── Before/After full section ──────────────────────────────────────────────────
285
+ function BeforeAfterSection({ before, after, curve }) {
286
+ const card = (ex={}) => ({
287
+ background:'#fff', border:'1px solid #e2e8f0',
288
+ borderRadius:14, padding:16, marginBottom:16, ...ex,
289
+ })
 
290
 
291
  return (
292
+ <>
293
+ {/* Before/After bars */}
294
+ <div style={card()}>
295
+ <SectionHeader>Before vs After Training β€” Score Comparison</SectionHeader>
296
+ <BeforeAfterBars before={before} after={after}/>
 
 
 
 
 
 
 
 
 
 
 
297
 
298
+ {before && after && (
299
+ <div style={{ marginTop:16, padding:'12px 14px',
300
+ background:'#f0fdf4', borderRadius:10, fontSize:12,
301
+ color:'#166534', fontWeight:600, display:'flex', gap:8, alignItems:'center' }}>
302
+ βœ… Training improved all difficulty scores.&ensp;
303
+ Biggest gain on <b>easy</b>:{' '}
304
+ +{((after.easy||0) - (before.easy||0)).toFixed(4)}
305
+ </div>
306
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  </div>
308
 
309
+ {/* Reward learning curve (band chart) */}
310
+ {curve && curve.length > 0 && (
311
+ <div style={card()}>
312
+ <SectionHeader>Reward Learning Curve β€” {curve.length} Steps</SectionHeader>
313
+ <div style={{ display:'flex', gap:20, marginBottom:12 }}>
314
+ {[
315
+ { l:'Start (step 0)', v: curve[0].mean.toFixed(4), c:'#94a3b8' },
316
+ { l:'End (final)', v: curve[curve.length-1].mean.toFixed(4), c:'#6366f1' },
317
+ { l:'Peak mean', v: Math.max(...curve.map(d=>d.mean)).toFixed(4), c:'#22c55e' },
318
+ { l:'Steps', v: curve.length, c:'#0ea5e9' },
319
+ ].map(s => (
320
+ <div key={s.l} style={{ textAlign:'center', minWidth:72 }}>
321
+ <div style={{ fontSize:10, color:'#94a3b8', textTransform:'uppercase',
322
+ marginBottom:2 }}>{s.l}</div>
323
+ <div style={{ fontSize:18, fontWeight:800, color:s.c }}>{s.v}</div>
324
+ </div>
325
+ ))}
326
  </div>
327
+ <div style={{ border:'1px solid #f1f5f9', borderRadius:10,
328
+ background:'#fafafa', overflow:'hidden', marginBottom:12 }}>
329
+ <BandChart curve={curve} height={160}/>
330
+ </div>
331
+ {/* Legend */}
332
+ <div style={{ fontSize:11, color:'#64748b', display:'flex', gap:16 }}>
333
+ <span><span style={{ color:'#6366f1', fontWeight:700 }}>━</span> Mean reward</span>
334
+ <span><span style={{ color:'#6366f180', fontWeight:700 }}>━</span> Min/Max band</span>
335
+ <span style={{ color:'#94a3b8' }}>Shaded area = min→max range across batch</span>
336
+ </div>
337
+ {/* Step table (last 8) */}
338
+ <div style={{ marginTop:12, fontFamily:'monospace', fontSize:11 }}>
339
+ <div style={{ display:'flex', color:'#94a3b8', fontWeight:700,
340
+ borderBottom:'1px solid #f1f5f9', paddingBottom:4, marginBottom:4 }}>
341
+ {['Step','Mean','Max','Min'].map(h=>
342
+ <div key={h} style={{ flex:1 }}>{h}</div>)}
343
+ </div>
344
+ {curve.slice(-8).map(d => (
345
+ <div key={d.step} style={{ display:'flex', padding:'2px 0',
346
+ color: d.mean >= 0 ? '#16a34a' : '#ef4444' }}>
347
+ <div style={{ flex:1 }}>{d.step}</div>
348
+ <div style={{ flex:1 }}>{d.mean.toFixed(4)}</div>
349
+ <div style={{ flex:1 }}>{(d.max ?? d.mean).toFixed(4)}</div>
350
+ <div style={{ flex:1 }}>{(d.min ?? d.mean).toFixed(4)}</div>
351
+ </div>
352
+ ))}
353
+ </div>
354
+ </div>
355
+ )}
356
+ </>
357
  )
358
  }
359
 
360
+ // ── Benchmark suite section (unchanged from prior version) ────────────────────
361
+ function BenchmarkSection() {
362
+ const [data, setData] = useState(null)
363
+ const [running, setRunning] = useState(false)
364
+ const [sel, setSel] = useState('medium')
365
 
366
+ const run = async () => {
367
+ setRunning(true); setData(null)
368
  try {
369
  const r = await fetch(`${API}/benchmark`)
370
+ if (r.ok) setData(await r.json())
371
+ } catch(e) { console.error(e) }
372
+ finally { setRunning(false) }
 
 
373
  }
374
 
375
+ const selD = data?.[sel]
376
+ const card = (ex={}) => ({
377
  background:'#fff', border:'1px solid #e2e8f0',
378
+ borderRadius:14, padding:16, marginBottom:16, ...ex,
379
  })
380
 
 
 
381
  return (
382
+ <>
 
383
  <div style={card()}>
384
  <div style={{ display:'flex', justifyContent:'space-between',
385
+ alignItems:'center', marginBottom:14 }}>
386
  <div>
387
  <SectionHeader>Heuristic Agent Benchmark</SectionHeader>
388
+ <p style={{ fontSize:12, color:'#64748b', margin:0, lineHeight:1.5 }}>
389
+ Runs the deterministic heuristic on all 4 difficulties (seed=42).
 
390
  </p>
391
  </div>
392
+ <button onClick={run} disabled={running}
393
+ style={{ background: running?'#94a3b8':'#6366f1', color:'#fff',
394
  border:'none', borderRadius:10, padding:'10px 22px',
395
+ fontWeight:700, fontSize:13, cursor:running?'not-allowed':'pointer',
396
+ marginLeft:20, whiteSpace:'nowrap' }}>
397
+ {running ? '⏳ Running…' : 'β–Ά Run Benchmarks'}
398
  </button>
399
  </div>
400
 
401
+ {/* Score overview bars */}
402
+ <div style={{ display:'grid', gridTemplateColumns:'repeat(4,1fr)', gap:10 }}>
403
+ {DIFF_ORDER.map(d => {
404
+ const score = data?.[d]?.score
405
+ const bPct = `${Math.min(100, BASELINE[d]*100).toFixed(0)}%`
406
+ const sPct = score != null ? `${Math.min(100, score*100).toFixed(0)}%` : '0%'
407
+ return (
408
+ <div key={d} style={{ background:DIFF_BG[d],
409
+ border:`1px solid ${DIFF_COLOR[d]}33`, borderRadius:12,
410
+ padding:'12px 14px' }}>
411
+ <div style={{ fontWeight:700, color:DIFF_COLOR[d], fontSize:14,
412
+ textTransform:'capitalize', marginBottom:8 }}>{d}</div>
413
+ <BarRow label="Achieved" pct={sPct} color={DIFF_COLOR[d]}
414
+ val={score!=null?score.toFixed(4):'β€”'} glow={score!=null}/>
415
+ <BarRow label="Baseline" pct={bPct} color="#94a3b8"
416
+ val={BASELINE[d].toFixed(3)}/>
417
+ </div>
418
+ )
419
+ })}
420
  </div>
 
 
 
 
 
 
 
 
 
 
421
  </div>
422
 
423
+ {data && (
 
424
  <div style={card()}>
425
  <div style={{ display:'flex', gap:6, marginBottom:14 }}>
426
  {DIFF_ORDER.map(d => (
427
+ <button key={d} onClick={() => setSel(d)}
428
  style={{ padding:'7px 16px', borderRadius:8, border:'none',
429
+ background: sel===d ? DIFF_COLOR[d] : DIFF_BG[d],
430
+ color: sel===d ? '#fff' : DIFF_COLOR[d],
431
  fontWeight:700, fontSize:13, cursor:'pointer',
432
+ textTransform:'capitalize' }}>{d}</button>
 
 
433
  ))}
434
  </div>
435
+ {selD && !selD.error && (
 
436
  <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:16 }}>
 
437
  <div>
438
+ <SectionHeader>Stats β€” {sel}</SectionHeader>
439
  <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8,
440
  marginBottom:14 }}>
441
  {[
442
+ { l:'Score', v: selD.score?.toFixed(4) },
443
+ { l:'Total Reward', v: selD.total_reward?.toFixed(3) },
444
+ { l:'Steps', v: selD.steps },
445
+ { l:'Tasks Done', v:`${selD.tasks_done}/${selD.tasks_total}`},
446
+ { l:'Avg Energy', v: selD.avg_energy?.toFixed(3) },
447
+ { l:'Deadlines', v:`${selD.deadlines_met}/${selD.deadlines_total}`},
 
 
448
  ].map(s => (
449
  <div key={s.l} style={{ background:'#f8fafc',
450
+ borderRadius:8, padding:'8px 12px' }}>
451
+ <div style={{ fontSize:10, color:'#94a3b8', marginBottom:2 }}>{s.l}</div>
452
+ <div style={{ fontSize:14, fontWeight:700, color:'#0f172a' }}>{s.v}</div>
453
  </div>
454
  ))}
455
  </div>
456
+ {selD.components && (
 
 
457
  <>
458
+ <SectionHeader>Score Components</SectionHeader>
459
+ <ComponentBar components={selD.components}/>
460
  </>
461
  )}
462
  </div>
 
 
463
  <div>
464
  <SectionHeader>Step Rewards</SectionHeader>
465
  <div style={{ border:'1px solid #f1f5f9', borderRadius:8,
466
+ background:'#fafafa', overflow:'hidden', marginBottom:10 }}>
467
+ <LineChart data={selD.step_rewards} color={DIFF_COLOR[sel]} height={100}/>
 
 
 
 
 
 
468
  </div>
469
+ <SectionHeader>Energy / Stress</SectionHeader>
 
470
  <div style={{ border:'1px solid #f1f5f9', borderRadius:8,
471
  background:'#fafafa', overflow:'hidden' }}>
472
+ <svg width="100%" height={90}
473
+ viewBox={`0 0 ${Math.max((selD.energy_trace||[]).length*18,300)} 90`}
474
+ preserveAspectRatio="none" style={{ display:'block' }}>
475
+ <polyline
476
+ points={(selD.energy_trace||[]).map((v,i)=>`${i*18+9},${80-(v*70)}`).join(' ')}
477
+ fill="none" stroke="#22c55e" strokeWidth="2" strokeLinejoin="round"/>
478
+ <polyline
479
+ points={(selD.stress_trace||[]).map((v,i)=>`${i*18+9},${80-(v*70)}`).join(' ')}
480
+ fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinejoin="round"
481
+ strokeDasharray="5 3"/>
482
+ </svg>
483
+ </div>
484
+ <div style={{ fontSize:10, color:'#94a3b8', marginTop:4 }}>
485
+ <span style={{ color:'#22c55e', fontWeight:700 }}>─</span> Energy&ensp;
486
+ <span style={{ color:'#f59e0b', fontWeight:700 }}>β•Œ</span> Stress
487
  </div>
488
  </div>
489
  </div>
490
  )}
 
 
 
 
491
  </div>
492
  )}
493
 
494
+ {/* Scoring formula */}
495
  <div style={card()}>
496
  <SectionHeader>Scoring Formula</SectionHeader>
497
  <div style={{ display:'grid', gridTemplateColumns:'repeat(5,1fr)', gap:10 }}>
498
+ {COMP_LABELS.map((lbl, i) => (
499
+ <div key={lbl} style={{ background:'#f8fafc', borderRadius:10,
500
+ padding:'12px 14px', borderTop:`3px solid ${COMP_COLORS[i]}` }}>
501
+ <div style={{ fontSize:16, fontWeight:800, color:COMP_COLORS[i],
502
+ marginBottom:4 }}>{['Γ—0.60','Γ—0.22','Γ—0.10','Γ—0.05','Γ—0.03'][i]}</div>
503
+ <div style={{ fontSize:11, color:'#475569', fontWeight:600 }}>
504
+ {lbl.split(' ')[0]} {lbl.split(' ')[1]}
505
+ </div>
 
 
 
 
506
  </div>
507
  ))}
508
  </div>
509
+ <div style={{ marginTop:10, fontSize:11, color:'#94a3b8',
510
+ fontFamily:'monospace', background:'#f8fafc',
511
+ padding:'8px 12px', borderRadius:8 }}>
512
+ score = completionΓ—0.60 + deadlineΓ—0.22 + energyΓ—0.10 + depΓ—0.05 + interruptΓ—0.03
513
+ &ensp;∈ (0.01, 0.99)
514
  </div>
515
  </div>
516
+ </>
517
+ )
518
+ }
519
 
520
+ function ComponentBar({ components }) {
521
+ const total = COMP_KEYS.reduce((s,k) => s+(components[k]||0), 0)
522
+ return (
523
+ <div>
524
+ <div style={{ display:'flex', height:22, borderRadius:6,
525
+ overflow:'hidden', marginBottom:6 }}>
526
+ {COMP_KEYS.map((k,i) => {
527
+ const v = components[k]||0
528
+ const pct = total > 0 ? (v/total)*100 : 0
529
+ return <div key={k} title={`${COMP_LABELS[i]}: ${v.toFixed(4)}`}
530
+ style={{ width:`${pct}%`, background:COMP_COLORS[i], minWidth:pct>2?2:0 }}/>
531
+ })}
532
+ </div>
533
+ <div style={{ display:'flex', flexWrap:'wrap', gap:'4px 10px' }}>
534
+ {COMP_KEYS.map((k,i) => (
535
+ <span key={k} style={{ fontSize:10, color:'#475569',
536
+ display:'flex', alignItems:'center', gap:3 }}>
537
+ <span style={{ width:8, height:8, borderRadius:2,
538
+ background:COMP_COLORS[i], display:'inline-block' }}/>
539
+ {COMP_LABELS[i].split(' ')[0]}: <b>{(components[k]||0).toFixed(4)}</b>
540
+ </span>
541
+ ))}
542
+ </div>
543
+ </div>
544
+ )
545
+ }
546
+
547
+ // ── Main TrainingDashboard ─────────────────────────────────────────────────────
548
+ export default function TrainingDashboard() {
549
+ const [activeTab, setActiveTab] = useState('training')
550
+
551
+ // Training state (mirrors _training_state on the server)
552
+ const [trainState, setTrainState] = useState({
553
+ running: false, status: 'idle', current_step: 0, total_steps: 25,
554
+ difficulty: 'medium', curve: [], before: null, after: null,
555
+ metadata: null, error: null,
556
+ })
557
+ // Persisted results from /training-log
558
+ const [savedLog, setSavedLog] = useState(null)
559
+ const esRef = useRef(null)
560
+
561
+ // Load saved training log on mount
562
+ useEffect(() => {
563
+ fetch(`${API}/training-log`)
564
+ .then(r => r.ok ? r.json() : null)
565
+ .then(d => { if (d) setSavedLog(d) })
566
+ .catch(() => {})
567
+ }, [])
568
+
569
+ // Start training & subscribe to SSE
570
+ const startTraining = async (difficulty) => {
571
+ if (trainState.running) return
572
+
573
+ // Kick off training on the server
574
+ await fetch(`${API}/train/start?difficulty=${difficulty}&steps=25`, { method:'POST' })
575
+
576
+ // Subscribe to live SSE stream
577
+ if (esRef.current) { esRef.current.close(); esRef.current = null }
578
+ const es = new EventSource(`${API}/train/stream`)
579
+ esRef.current = es
580
+
581
+ es.onmessage = (ev) => {
582
+ const d = JSON.parse(ev.data)
583
+ setTrainState(d)
584
+ if (d.status === 'completed') {
585
+ // Refresh the saved log once training finishes
586
+ fetch(`${API}/training-log`)
587
+ .then(r => r.ok ? r.json() : null)
588
+ .then(saved => { if (saved) setSavedLog(saved) })
589
+ .catch(() => {})
590
+ es.close(); esRef.current = null
591
+ }
592
+ if (d.status === 'error') {
593
+ es.close(); esRef.current = null
594
+ }
595
+ }
596
+ es.onerror = () => { es.close(); esRef.current = null }
597
+ }
598
+
599
+ useEffect(() => () => { if (esRef.current) esRef.current.close() }, [])
600
+
601
+ // Decide which data to show: live training state takes priority if running/just-done
602
+ // otherwise fall back to savedLog
603
+ const showLive = trainState.status !== 'idle'
604
+ const displayLog = showLive ? trainState : savedLog
605
+
606
+ const TABS = [
607
+ { id:'training', label:'πŸ§ͺ Training Progress' },
608
+ { id:'benchmark', label:'πŸ“ˆ Benchmarks' },
609
+ ]
610
+
611
+ return (
612
+ <div>
613
+ {/* Sub-tabs */}
614
+ <div style={{ display:'flex', gap:4, marginBottom:20 }}>
615
+ {TABS.map(t => (
616
+ <button key={t.id} onClick={() => setActiveTab(t.id)}
617
+ style={{ padding:'9px 20px', borderRadius:10, border:'none',
618
+ background: activeTab===t.id ? '#0f172a' : '#e2e8f0',
619
+ color: activeTab===t.id ? '#fff' : '#64748b',
620
+ fontWeight:700, fontSize:13, cursor:'pointer' }}>
621
+ {t.label}
622
+ </button>
623
+ ))}
624
  </div>
625
 
626
+ {activeTab === 'training' && (
627
+ <>
628
+ {/* Live training control */}
629
+ <TrainingProgress state={trainState} onStart={startTraining}/>
630
+
631
+ {/* Show results β€” live first, then saved */}
632
+ {displayLog && (displayLog.before || (displayLog.curve && displayLog.curve.length > 0)) && (
633
+ <BeforeAfterSection
634
+ before={displayLog.before}
635
+ after={displayLog.after}
636
+ curve={displayLog.curve}
637
+ />
638
+ )}
639
+
640
+ {/* No data yet message */}
641
+ {!showLive && (!savedLog || (!savedLog.before && (!savedLog.curve || !savedLog.curve.length))) && (
642
+ <div style={{ background:'#fff', border:'1px solid #e2e8f0',
643
+ borderRadius:14, padding:32, textAlign:'center',
644
+ color:'#94a3b8', fontSize:14 }}>
645
+ <div style={{ fontSize:32, marginBottom:8 }}>πŸ“Š</div>
646
+ <div style={{ fontWeight:700, color:'#475569', marginBottom:6 }}>
647
+ No training data yet
648
+ </div>
649
+ <div style={{ fontSize:13, lineHeight:1.7 }}>
650
+ Click <b>β–Ά medium</b> above to run demo training (~15 seconds).<br/>
651
+ The before/after comparison and reward curve will appear here.
652
+ </div>
653
+ </div>
654
+ )}
655
+ </>
656
+ )}
657
+
658
+ {activeTab === 'benchmark' && <BenchmarkSection/>}
659
  </div>
660
  )
661
  }