RohitChandramouli6618 commited on
Commit
1f741e8
·
1 Parent(s): c5694dd

Add interactive dashboard with live demo for judges

Browse files
Files changed (1) hide show
  1. server/app.py +350 -32
server/app.py CHANGED
@@ -1,17 +1,12 @@
1
  # server/app.py
2
- # ─────────────────────────────────────────────────────────────────────────────
3
- # FastAPI application entry point for Cascade Containment.
4
- # Uses a factory function so each WebSocket session gets its own isolated
5
- # environment instance — required for concurrent session safety.
6
- # ─────────────────────────────────────────────────────────────────────────────
7
-
8
  import sys
9
  import os
10
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
11
 
12
  from openenv.core.env_server import create_app
13
- from fastapi.responses import JSONResponse
14
  from server.environment import EpidemicContainmentEnv
 
15
  from models import ContainmentAction, CityObservation
16
  import server.environment as env_module
17
 
@@ -21,39 +16,362 @@ app = create_app(
21
  CityObservation,
22
  )
23
 
 
 
 
24
  @app.get("/grade")
25
  async def grade_last_episode():
26
- """
27
- Returns the deterministic grader score for the most recently completed episode.
28
- Computed automatically when an episode ends via the WebSocket session.
29
- """
30
  if not env_module._last_grade:
31
  return JSONResponse(
32
- {"error": "No completed episode yet — run a full episode first"},
33
  status_code=400
34
  )
35
  return JSONResponse(env_module._last_grade)
36
 
37
- from fastapi.responses import HTMLResponse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  @app.get("/", response_class=HTMLResponse)
40
- async def root():
41
  return """
42
- <html>
43
- <body style="font-family: sans-serif; padding: 2rem; background: #0f172a; color: #e2e8f0;">
44
- <h1>🦠 Cascade Containment</h1>
45
- <p>An RL benchmark for epidemic containment policy under uncertainty.</p>
46
- <p>Status: <strong style="color: #4ade80;">Running ✓</strong></p>
47
- <h3>Available Endpoints</h3>
48
- <ul>
49
- <li><a href="/health" style="color: #60a5fa;">/health</a> — Health check</li>
50
- <li>/reset — Start new episode</li>
51
- <li>/step — Take action</li>
52
- <li>/state — Get episode state</li>
53
- </ul>
54
- <p style="color: #94a3b8; margin-top: 2rem;">
55
- Tasks: easy (2 districts) · medium (4 districts) · hard (6 districts, 3-day data lag)
56
- </p>
57
- </body>
58
- </html>
59
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # server/app.py
 
 
 
 
 
 
2
  import sys
3
  import os
4
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
5
 
6
  from openenv.core.env_server import create_app
7
+ from fastapi.responses import JSONResponse, HTMLResponse
8
  from server.environment import EpidemicContainmentEnv
9
+ from server.grader import grade_trajectory
10
  from models import ContainmentAction, CityObservation
11
  import server.environment as env_module
12
 
 
16
  CityObservation,
17
  )
18
 
19
+
20
+ # ── Grade endpoint ────────────────────────────────────────────────────────────
21
+
22
  @app.get("/grade")
23
  async def grade_last_episode():
 
 
 
 
24
  if not env_module._last_grade:
25
  return JSONResponse(
26
+ {"error": "No completed episode yet"},
27
  status_code=400
28
  )
29
  return JSONResponse(env_module._last_grade)
30
 
31
+
32
+ # ── Info endpoint ─────────────────────────────────────────────────────────────
33
+
34
+ @app.get("/info")
35
+ async def environment_info():
36
+ return JSONResponse({
37
+ "name": "Cascade Containment",
38
+ "version": "1.0.0",
39
+ "description": "RL benchmark for epidemic containment policy under uncertainty",
40
+ "tasks": {
41
+ "easy": {"districts": 2, "max_steps": 10, "resources": 10, "data_lag": 0},
42
+ "medium": {"districts": 4, "max_steps": 15, "resources": 8, "data_lag": 0},
43
+ "hard": {"districts": 6, "max_steps": 15, "resources": 7, "data_lag": 3},
44
+ },
45
+ "action_space": {
46
+ "type": "discrete",
47
+ "fields": {
48
+ "action_type": ["test", "restrict", "allocate"],
49
+ "district_id": "int (0-indexed)"
50
+ }
51
+ },
52
+ "reward_terms": [
53
+ {"name": "Infection penalty", "value": -0.50, "condition": "per district above 0.4 threshold"},
54
+ {"name": "Hospital breach", "value": -1.00, "condition": "per collapsed hospital"},
55
+ {"name": "Early containment bonus", "value": "+0.50 × (1 - step/max_steps)", "condition": "per contained district"},
56
+ {"name": "Unnecessary restriction", "value": -0.20, "condition": "restricting district below 0.2"},
57
+ {"name": "Correct prioritisation", "value": +0.30, "condition": "allocating to highest-infected district"},
58
+ ],
59
+ "generalisation": [
60
+ "Wildfire resource deployment",
61
+ "Cyberattack isolation",
62
+ "Misinformation containment",
63
+ "Poverty intervention"
64
+ ]
65
+ })
66
+
67
+
68
+ # ── Demo endpoint ─────────────────────────────────────────────────────────────
69
+
70
+ @app.get("/demo/{task_name}")
71
+ async def run_demo(task_name: str):
72
+ """
73
+ Run a quick demo episode using a rule-based agent (no LLM required).
74
+ Returns grader score and step-by-step log for display.
75
+ """
76
+ if task_name not in ["easy", "medium", "hard"]:
77
+ return JSONResponse({"error": "task_name must be easy, medium, or hard"}, status_code=400)
78
+
79
+ try:
80
+ env = EpidemicContainmentEnv()
81
+ obs = env.reset(task_name)
82
+ log = []
83
+ done = obs.done
84
+
85
+ while not done:
86
+ # Rule-based agent: allocate to most infected, restrict if no resources
87
+ districts = obs.districts
88
+ most_infected = max(districts, key=lambda d: d.reported_infection_rate)
89
+
90
+ if obs.available_resources > 0:
91
+ action = ContainmentAction(
92
+ action_type="allocate",
93
+ district_id=most_infected.district_id
94
+ )
95
+ else:
96
+ action = ContainmentAction(
97
+ action_type="restrict",
98
+ district_id=most_infected.district_id
99
+ )
100
+
101
+ obs = env.step(action)
102
+ log.append({
103
+ "step": obs.current_step,
104
+ "action_type": action.action_type,
105
+ "district_id": action.district_id,
106
+ "reward": round(obs.reward or 0.0, 4),
107
+ "done": obs.done,
108
+ "message": obs.message,
109
+ })
110
+ done = obs.done
111
+
112
+ # Grade the episode
113
+ trajectory = env.get_trajectory()
114
+ result = grade_trajectory(trajectory, task_name)
115
+
116
+ return JSONResponse({
117
+ "task_name": task_name,
118
+ "total_steps": result.total_steps,
119
+ "final_score": result.final_score,
120
+ "containment_score": result.containment_score,
121
+ "hospital_score": result.hospital_score,
122
+ "efficiency_score": result.efficiency_score,
123
+ "speed_score": result.speed_score,
124
+ "hospital_breached": result.hospital_breached,
125
+ "districts_contained": result.districts_contained,
126
+ "log": log,
127
+ })
128
+
129
+ except Exception as e:
130
+ return JSONResponse({"error": str(e)}, status_code=500)
131
+
132
+
133
+ # ── Dashboard ─────────────────────────────────────────────────────────────────
134
 
135
  @app.get("/", response_class=HTMLResponse)
136
+ async def dashboard():
137
  return """
138
+ <!DOCTYPE html>
139
+ <html lang="en">
140
+ <head>
141
+ <meta charset="UTF-8">
142
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
143
+ <title>Cascade Containment — RL Environment</title>
144
+ <style>
145
+ * { box-sizing: border-box; margin: 0; padding: 0; }
146
+ body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; padding: 2rem; }
147
+ h1 { font-size: 1.8rem; color: #f1f5f9; margin-bottom: 0.25rem; }
148
+ .subtitle { color: #94a3b8; font-size: 0.95rem; margin-bottom: 2rem; }
149
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem; }
150
+ .card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.5rem; }
151
+ .card h2 { font-size: 1rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; }
152
+ .status { display: flex; align-items: center; gap: 0.5rem; }
153
+ .dot { width: 10px; height: 10px; border-radius: 50%; background: #22c55e; animation: pulse 2s infinite; }
154
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
155
+ .tag { display: inline-block; background: #0f172a; border: 1px solid #334155; border-radius: 6px; padding: 0.2rem 0.6rem; font-size: 0.8rem; color: #94a3b8; margin: 0.2rem; }
156
+ .btn { border: none; border-radius: 8px; padding: 0.6rem 1.2rem; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; font-weight: 600; }
157
+ .btn-easy { background: #166534; color: #86efac; }
158
+ .btn-medium { background: #854d0e; color: #fde68a; }
159
+ .btn-hard { background: #7f1d1d; color: #fca5a5; }
160
+ .btn:hover { opacity: 0.85; transform: translateY(-1px); }
161
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
162
+ .score-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 1rem; }
163
+ .score-item { background: #0f172a; border-radius: 8px; padding: 0.75rem; }
164
+ .score-label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; }
165
+ .score-value { font-size: 1.4rem; font-weight: 700; margin-top: 0.2rem; }
166
+ .score-bar { height: 4px; background: #1e293b; border-radius: 2px; margin-top: 0.5rem; }
167
+ .score-fill { height: 100%; border-radius: 2px; transition: width 0.8s ease; }
168
+ .log-container { background: #0f172a; border-radius: 8px; padding: 1rem; max-height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.8rem; margin-top: 1rem; }
169
+ .log-step { padding: 0.2rem 0; border-bottom: 1px solid #1e293b; color: #94a3b8; }
170
+ .log-step span { color: #38bdf8; }
171
+ .positive { color: #4ade80; }
172
+ .negative { color: #f87171; }
173
+ .neutral { color: #94a3b8; }
174
+ .reward-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
175
+ .reward-table th { text-align: left; color: #64748b; padding: 0.5rem; border-bottom: 1px solid #334155; }
176
+ .reward-table td { padding: 0.5rem; border-bottom: 1px solid #1e293b; }
177
+ .big-score { font-size: 3rem; font-weight: 800; text-align: center; padding: 1rem 0; }
178
+ .task-btns { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
179
+ .loading { color: #94a3b8; font-style: italic; }
180
+ .breach-badge { display: inline-block; background: #7f1d1d; color: #fca5a5; border-radius: 4px; padding: 0.1rem 0.4rem; font-size: 0.75rem; margin-left: 0.5rem; }
181
+ .safe-badge { display: inline-block; background: #166534; color: #86efac; border-radius: 4px; padding: 0.1rem 0.4rem; font-size: 0.75rem; margin-left: 0.5rem; }
182
+ </style>
183
+ </head>
184
+ <body>
185
+
186
+ <h1>🦠 Cascade Containment</h1>
187
+ <p class="subtitle">RL benchmark for epidemic containment policy — Meta PyTorch OpenEnv Hackathon x SST 2026</p>
188
+
189
+ <div class="grid">
190
+
191
+ <!-- Status Card -->
192
+ <div class="card">
193
+ <h2>Environment Status</h2>
194
+ <div class="status" style="margin-bottom:1rem;">
195
+ <div class="dot"></div>
196
+ <span style="color:#22c55e;font-weight:600;">Running</span>
197
+ </div>
198
+ <div style="margin-bottom:0.75rem;">
199
+ <div style="color:#64748b;font-size:0.8rem;margin-bottom:0.4rem;">ENDPOINTS</div>
200
+ <div><span class="tag">GET /health</span><span class="tag">POST /reset</span><span class="tag">POST /step</span><span class="tag">GET /grade</span><span class="tag">GET /demo/{task}</span></div>
201
+ </div>
202
+ <div>
203
+ <div style="color:#64748b;font-size:0.8rem;margin-bottom:0.4rem;">TASKS</div>
204
+ <div><span class="tag">easy — 2 districts</span><span class="tag">medium — 4 districts</span><span class="tag">hard — 6 districts + 3d lag</span></div>
205
+ </div>
206
+ </div>
207
+
208
+ <!-- Reward Function Card -->
209
+ <div class="card">
210
+ <h2>Reward Function</h2>
211
+ <table class="reward-table">
212
+ <tr><th>Term</th><th>Value</th></tr>
213
+ <tr><td>Infection penalty</td><td class="negative">-0.50 / district</td></tr>
214
+ <tr><td>Hospital breach</td><td class="negative">-1.00 / district</td></tr>
215
+ <tr><td>Early containment</td><td class="positive">+0.50 × time_factor</td></tr>
216
+ <tr><td>Unnecessary restrict</td><td class="negative">-0.20</td></tr>
217
+ <tr><td>Correct prioritisation</td><td class="positive">+0.30</td></tr>
218
+ </table>
219
+ </div>
220
+
221
+ </div>
222
+
223
+ <!-- Live Demo Card -->
224
+ <div class="card" style="margin-bottom:1.5rem;">
225
+ <h2>Live Demo — Rule-Based Agent</h2>
226
+ <p style="color:#94a3b8;font-size:0.85rem;margin-bottom:1rem;">
227
+ Runs a complete episode using a greedy rule-based agent (always allocates to highest-infected district).
228
+ Scored by the deterministic grader — containment, hospital, efficiency, speed.
229
+ </p>
230
+ <div class="task-btns">
231
+ <button class="btn btn-easy" onclick="runDemo('easy')" id="btn-easy">â–¶ Run Easy</button>
232
+ <button class="btn btn-medium" onclick="runDemo('medium')" id="btn-medium">â–¶ Run Medium</button>
233
+ <button class="btn btn-hard" onclick="runDemo('hard')" id="btn-hard">â–¶ Run Hard</button>
234
+ </div>
235
+
236
+ <div id="demo-results" style="display:none;">
237
+ <div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;">
238
+ <div>
239
+ <div style="color:#64748b;font-size:0.8rem;">FINAL SCORE</div>
240
+ <div class="big-score" id="final-score" style="color:#38bdf8;">—</div>
241
+ </div>
242
+ <div style="flex:1;">
243
+ <div class="score-grid">
244
+ <div class="score-item">
245
+ <div class="score-label">Containment</div>
246
+ <div class="score-value positive" id="s-containment">—</div>
247
+ <div class="score-bar"><div class="score-fill" id="b-containment" style="background:#4ade80;width:0%"></div></div>
248
+ </div>
249
+ <div class="score-item">
250
+ <div class="score-label">Hospital</div>
251
+ <div class="score-value" id="s-hospital">—</div>
252
+ <div class="score-bar"><div class="score-fill" id="b-hospital" style="background:#38bdf8;width:0%"></div></div>
253
+ </div>
254
+ <div class="score-item">
255
+ <div class="score-label">Efficiency</div>
256
+ <div class="score-value" id="s-efficiency">—</div>
257
+ <div class="score-bar"><div class="score-fill" id="b-efficiency" style="background:#a78bfa;width:0%"></div></div>
258
+ </div>
259
+ <div class="score-item">
260
+ <div class="score-label">Speed</div>
261
+ <div class="score-value" id="s-speed">—</div>
262
+ <div class="score-bar"><div class="score-fill" id="b-speed" style="background:#fb923c;width:0%"></div></div>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+
268
+ <div id="meta-info" style="color:#94a3b8;font-size:0.85rem;margin-bottom:0.75rem;"></div>
269
+
270
+ <div style="color:#64748b;font-size:0.8rem;margin-bottom:0.4rem;">STEP LOG</div>
271
+ <div class="log-container" id="step-log"></div>
272
+ </div>
273
+
274
+ <div id="demo-loading" style="display:none;" class="loading">Running episode...</div>
275
+ </div>
276
+
277
+ <!-- Grader Weights Card -->
278
+ <div class="card">
279
+ <h2>Grader Weights</h2>
280
+ <div class="score-grid">
281
+ <div class="score-item">
282
+ <div class="score-label">Containment</div>
283
+ <div class="score-value" style="color:#4ade80;">45%</div>
284
+ <div style="color:#64748b;font-size:0.75rem;margin-top:0.3rem;">District-days below 0.4 threshold</div>
285
+ </div>
286
+ <div class="score-item">
287
+ <div class="score-label">Hospital</div>
288
+ <div class="score-value" style="color:#38bdf8;">30%</div>
289
+ <div style="color:#64748b;font-size:0.75rem;margin-top:0.3rem;">Capacity preserved across episode</div>
290
+ </div>
291
+ <div class="score-item">
292
+ <div class="score-label">Efficiency</div>
293
+ <div class="score-value" style="color:#a78bfa;">15%</div>
294
+ <div style="color:#64748b;font-size:0.75rem;margin-top:0.3rem;">Resources directed to high-need districts</div>
295
+ </div>
296
+ <div class="score-item">
297
+ <div class="score-label">Speed</div>
298
+ <div class="score-value" style="color:#fb923c;">10%</div>
299
+ <div style="color:#64748b;font-size:0.75rem;margin-top:0.3rem;">Containment faster than max steps</div>
300
+ </div>
301
+ </div>
302
+ </div>
303
+
304
+ <script>
305
+ async function runDemo(taskName) {
306
+ ['easy','medium','hard'].forEach(t => {
307
+ document.getElementById('btn-'+t).disabled = true;
308
+ });
309
+ document.getElementById('demo-results').style.display = 'none';
310
+ document.getElementById('demo-loading').style.display = 'block';
311
+
312
+ try {
313
+ const resp = await fetch('/demo/' + taskName);
314
+ const data = await resp.json();
315
+
316
+ if (data.error) {
317
+ document.getElementById('demo-loading').textContent = 'Error: ' + data.error;
318
+ return;
319
+ }
320
+
321
+ // Update scores
322
+ const fmt = v => (v * 100).toFixed(1) + '%';
323
+ document.getElementById('final-score').textContent = fmt(data.final_score);
324
+ document.getElementById('s-containment').textContent = fmt(data.containment_score);
325
+ document.getElementById('s-hospital').textContent = fmt(data.hospital_score);
326
+ document.getElementById('s-efficiency').textContent = fmt(data.efficiency_score);
327
+ document.getElementById('s-speed').textContent = fmt(data.speed_score);
328
+
329
+ // Update bars
330
+ document.getElementById('b-containment').style.width = (data.containment_score * 100) + '%';
331
+ document.getElementById('b-hospital').style.width = (data.hospital_score * 100) + '%';
332
+ document.getElementById('b-efficiency').style.width = (data.efficiency_score * 100) + '%';
333
+ document.getElementById('b-speed').style.width = (data.speed_score * 100) + '%';
334
+
335
+ // Color final score by value
336
+ const scoreEl = document.getElementById('final-score');
337
+ scoreEl.style.color = data.final_score > 0.6 ? '#4ade80' :
338
+ data.final_score > 0.4 ? '#fbbf24' : '#f87171';
339
+
340
+ // Meta info
341
+ const breachBadge = data.hospital_breached
342
+ ? '<span class="breach-badge">Hospital Breached</span>'
343
+ : '<span class="safe-badge">No Breach</span>';
344
+ document.getElementById('meta-info').innerHTML =
345
+ `Task: <strong style="color:#f1f5f9">${data.task_name.toUpperCase()}</strong> &nbsp;|&nbsp; ` +
346
+ `Steps: <strong style="color:#f1f5f9">${data.total_steps}</strong> &nbsp;|&nbsp; ` +
347
+ `Districts contained: <strong style="color:#f1f5f9">${data.districts_contained}</strong>` +
348
+ breachBadge;
349
+
350
+ // Step log
351
+ const logEl = document.getElementById('step-log');
352
+ logEl.innerHTML = data.log.map(s => {
353
+ const cls = s.reward > 0 ? 'positive' : s.reward < -0.5 ? 'negative' : 'neutral';
354
+ return `<div class="log-step">
355
+ Step ${String(s.step).padStart(2,'0')} &nbsp;
356
+ <span>${s.action_type}</span> → district ${s.district_id} &nbsp;
357
+ <span class="${cls}">${s.reward >= 0 ? '+' : ''}${s.reward.toFixed(4)}</span>
358
+ ${s.message ? '&nbsp;<span style="color:#475569">' + s.message + '</span>' : ''}
359
+ </div>`;
360
+ }).join('');
361
+
362
+ document.getElementById('demo-loading').style.display = 'none';
363
+ document.getElementById('demo-results').style.display = 'block';
364
+
365
+ } catch(e) {
366
+ document.getElementById('demo-loading').textContent = 'Connection error: ' + e.message;
367
+ } finally {
368
+ ['easy','medium','hard'].forEach(t => {
369
+ document.getElementById('btn-'+t).disabled = false;
370
+ });
371
+ }
372
+ }
373
+ </script>
374
+
375
+ </body>
376
+ </html>
377
+ """