sreeramajay commited on
Commit
1370a16
·
verified ·
1 Parent(s): f63dfc4

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. server/app.py +418 -66
  2. viewer/excalidraw_viewer.html +52 -2
server/app.py CHANGED
@@ -8,8 +8,8 @@
8
 
9
  Serves both the OpenEnv API (for training/inference) and a Gradio UI that acts
10
  as an interactive README: browse scenarios, explore the scoring system, try
11
- sending actions, and read full documentation — all from a single Hugging Face
12
- Space.
13
  """
14
 
15
  from __future__ import annotations
@@ -80,7 +80,7 @@ def _scenario_display_name(s: Dict[str, Any]) -> str:
80
 
81
 
82
  def _tier_emoji(t: str) -> str:
83
- return {"easy": "🟢", "medium": "🟡", "hard": "🟠", "expert": "🔴"}.get(t, "⚪")
84
 
85
 
86
  def _tier_label(t: str) -> str:
@@ -152,13 +152,373 @@ def show_weights(difficulty: str) -> str:
152
  return header + "\n".join(rows)
153
 
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  # ---------------------------------------------------------------------------
156
  # Playground callbacks
157
  # ---------------------------------------------------------------------------
158
 
159
  def playground_reset(scenario_choice: str):
160
  if not scenario_choice:
161
- return "Select a scenario first.", "{}", "{}"
162
 
163
  sid = scenario_choice.split("|")[0].strip()
164
  env = _get_env()
@@ -173,25 +533,28 @@ def playground_reset(scenario_choice: str):
173
  f"**Checklist:** {obs_dict.get('concept_checklist', [])}"
174
  )
175
  breakdown = json.dumps(obs_dict.get("score_breakdown", {}), indent=2)
176
- state_snippet = json.dumps({
177
  "entities": obs_dict.get("entities", {}),
178
  "relations": obs_dict.get("relations", []),
179
  "layout": obs_dict.get("layout", {}),
180
- }, indent=2, default=str)
181
- return summary, breakdown, state_snippet
 
 
 
182
  except Exception as exc:
183
  tb = traceback.format_exc()
184
- return f"**Error:** {exc}\n```\n{tb}\n```", "{}", "{}"
185
 
186
 
187
  def playground_step(action_json: str):
188
  env = _get_env()
189
  if not env._scenario:
190
- return "Call **Reset** first.", "{}", "{}"
191
  try:
192
  action_dict = json.loads(action_json)
193
  except json.JSONDecodeError as exc:
194
- return f"**JSON parse error:** {exc}", "{}", "{}"
195
 
196
  try:
197
  action = VisualReasoningAction(**action_dict)
@@ -210,16 +573,18 @@ def playground_step(action_json: str):
210
  summary += f"**Covered concepts:** {obs_dict.get('concept_coverage', [])}"
211
 
212
  breakdown = json.dumps(obs_dict.get("score_breakdown", {}), indent=2)
213
- state_snippet = json.dumps({
214
  "entities": obs_dict.get("entities", {}),
215
  "relations": obs_dict.get("relations", []),
216
  "layout": obs_dict.get("layout", {}),
217
  "annotations": obs_dict.get("annotations", []),
218
- }, indent=2, default=str)
219
- return summary, breakdown, state_snippet
 
 
220
  except Exception as exc:
221
  tb = traceback.format_exc()
222
- return f"**Error:** {exc}\n```\n{tb}\n```", "{}", "{}"
223
 
224
 
225
  SAMPLE_ACTION = json.dumps({
@@ -291,7 +656,10 @@ def build_ui() -> gr.Blocks:
291
  title="Visual Reasoning Environment",
292
  ) as demo:
293
 
294
- # ── Header ──────────────────────────────────────────────────
 
 
 
295
  gr.Markdown("""
296
  # Visual Reasoning Environment
297
 
@@ -302,12 +670,9 @@ step-by-step, and narrates the reasoning. A 14-dimensional scoring system provid
302
  verifiable rewards for GRPO / RLVR training.
303
  """)
304
 
305
- # ── Tab layout ──────────────────────────────────────────────
306
  with gr.Tabs():
307
 
308
- # ══════════════════════════════════════════════════════════
309
- # TAB 1: Overview
310
- # ══════════════════════════════════════════════════════════
311
  with gr.Tab("Overview"):
312
  gr.Markdown("""
313
  ## How It Works
@@ -349,7 +714,7 @@ and must teach it visually in three phases:
349
  `default` · `current` · `visited` · `frontier` · `done` · `pivot` · `root` · `error` · `inactive` · `comparing`
350
 
351
  ### Named Positioning System
352
- Regions support a 3x3 grid position system — the LLM says WHERE on the whiteboard to draw:
353
 
354
  ```
355
  top-left │ top-center │ top-right
@@ -368,9 +733,7 @@ Per-step reward = **delta(overall_score)** + flat penalties + concept bonuses.
368
  Clipped to `[-0.2, 1.0]` for stable GRPO training.
369
  """)
370
 
371
- # ══════════════════════════════════════════════════════════
372
- # TAB 2: Scenarios
373
- # ══════════════════════════════════════════════════════════
374
  with gr.Tab("Scenarios"):
375
  gr.Markdown("## Algorithm Scenarios\nBrowse all 9 algorithm templates across 4 difficulty tiers. Select a scenario to see its full specification.")
376
 
@@ -408,14 +771,12 @@ Clipped to `[-0.2, 1.0]` for stable GRPO training.
408
  desc = ALGO_DESCRIPTIONS.get(algo, "")
409
  gr.Markdown(f"**`{algo}`** — {desc}")
410
 
411
- # ══════════════════════════════════════════════════════════
412
- # TAB 3: Scoring System
413
- # ══════════════════════════════════════════════════════════
414
  with gr.Tab("Scoring"):
415
  gr.Markdown("""
416
  ## Scoring System
417
 
418
- The overall score is a **weighted sum of 14 sub-scores** (each 0-1) **minus 5 penalties**.
419
  Weights are tuned per difficulty level — harder tiers emphasize algorithm correctness (`invariant`)
420
  while easier tiers give more weight to narration quality and concept coverage.
421
 
@@ -479,15 +840,13 @@ A claimed concept counts **only if both** conditions hold:
479
  - Up to **+0.10** efficiency bonus for finishing early with high coverage
480
  """)
481
 
482
- # ══════════════════════════════════════════════════════════
483
- # TAB 4: Playground
484
- # ══════════════════════════════════════════════════════════
485
  with gr.Tab("Playground"):
486
  gr.Markdown("""
487
  ## Interactive Playground
488
 
489
- Try sending actions to the environment. Select a scenario, reset the environment,
490
- then send JSON actions to see how the scoring system responds.
491
  """)
492
 
493
  with gr.Row():
@@ -500,35 +859,36 @@ then send JSON actions to see how the scoring system responds.
500
 
501
  pg_summary = gr.Markdown("_Press Reset to start an episode._")
502
 
503
- with gr.Row():
504
- with gr.Column(scale=2):
505
  pg_action_input = gr.Code(
506
  value=SAMPLE_ACTION,
507
  language="json",
508
  label="Action JSON",
509
- lines=18,
510
  )
511
  pg_step_btn = gr.Button("Send Step", variant="secondary")
512
- with gr.Column(scale=1):
513
- pg_scores = gr.Code(value="{}", language="json", label="Score Breakdown", lines=18)
514
-
515
- with gr.Accordion("Canvas State (entities, relations, layout)", open=False):
516
- pg_state = gr.Code(value="{}", language="json", label="Canvas State", lines=20)
 
 
 
517
 
518
  pg_reset_btn.click(
519
  fn=playground_reset,
520
  inputs=[pg_scenario],
521
- outputs=[pg_summary, pg_scores, pg_state],
522
  )
523
  pg_step_btn.click(
524
  fn=playground_step,
525
  inputs=[pg_action_input],
526
- outputs=[pg_summary, pg_scores, pg_state],
527
  )
528
 
529
- # ══════════════════════════════════════════════════════════
530
- # TAB 5: Architecture
531
- # ══════════════════════════════════════════════════════════
532
  with gr.Tab("Architecture"):
533
  gr.Markdown("""
534
  ## Architecture
@@ -545,10 +905,11 @@ server/
545
  pedagogical_scoring.py Attention coherence, pacing, scaffolding
546
  scenario_loader.py Static + procedurally-generated scenarios
547
  scenario_generator.py Procedural generation per difficulty tier
548
- regions.py Layout engine (queue/stack/tree/graph + 3x3 grid)
549
  constants.py Ops, roles, limits, canvas dimensions
550
  models.py Pydantic Action & Observation schemas
551
  viewer/tldraw_viewer.html tldraw browser whiteboard
 
552
  viewer/audio_viewer.html Audio narration + visualization viewer
553
  ```
554
 
@@ -575,7 +936,7 @@ All scoring is deterministic (no randomness, no floating-point order sensitivity
575
  The regression test `test_easy_1_reproducible` enforces bit-identical scores across runs,
576
  which is critical for GRPO/RLVR training reproducibility.
577
 
578
- ### Named Positioning (3x3 Grid)
579
  Instead of numeric x,y coordinates, the LLM uses 9 semantic tokens (`top-left`, `center`, etc.)
580
  plus 4 relative prefixes (`below:`, `above:`, `left-of:`, `right-of:`).
581
  This reduces the positioning search space from ~331K (numeric grid) to ~6.5K (for 4 regions),
@@ -634,24 +995,22 @@ PYTHONPATH=. python -m pytest tests/ -v
634
  BSD-style (see `LICENSE`).
635
  """)
636
 
637
- # ══════════════════════════════════════════════════════════
638
- # TAB 6: API Reference
639
- # ══════════════════════════════════════════════════════════
640
  with gr.Tab("API"):
641
  gr.Markdown(f"""
642
  ## API Reference
643
 
644
- This Space exposes the standard **OpenEnv** HTTP + WebSocket API at the root path.
645
- The Gradio UI is mounted at `/ui`.
646
 
647
  ### Endpoints
648
 
649
  | Method | Path | Description |
650
  |--------|------|-------------|
651
- | `POST` | `/reset` | Reset environment, get initial observation |
652
- | `POST` | `/step` | Send an action, get observation + reward |
653
- | `GET` | `/health` | Health check |
654
- | `WebSocket` | `/ws` | Streaming WebSocket interface |
655
 
656
  ### Action Schema (`VisualReasoningAction`)
657
 
@@ -720,18 +1079,11 @@ The Gradio UI is mounted at `/ui`.
720
  gradio_app = build_ui()
721
 
722
  _CSS = """
723
- .tier-badge { display: inline-block; padding: 2px 10px; border-radius: 12px;
724
- font-weight: 600; font-size: 0.85em; margin-right: 6px; }
725
- .tier-easy { background: #d4edda; color: #155724; }
726
- .tier-medium { background: #fff3cd; color: #856404; }
727
- .tier-hard { background: #ffe0cc; color: #8a4500; }
728
- .tier-expert { background: #f8d7da; color: #721c24; }
729
  .score-bar { font-family: monospace; }
730
- .header-subtitle { color: #6b7280; font-size: 1.05em; margin-top: -8px; }
731
  """
732
 
733
  app = gr.mount_gradio_app(
734
- openenv_app, gradio_app, path="/ui",
735
  theme=gr.themes.Soft(), css=_CSS,
736
  )
737
 
@@ -747,8 +1099,8 @@ def main():
747
  args = parser.parse_args()
748
 
749
  print(f"Starting Visual Reasoning Environment on {args.host}:{args.port}")
750
- print(f" OpenEnv API: http://{args.host}:{args.port}/")
751
- print(f" Gradio UI: http://{args.host}:{args.port}/ui")
752
 
753
  uvicorn.run(app, host=args.host, port=args.port)
754
 
 
8
 
9
  Serves both the OpenEnv API (for training/inference) and a Gradio UI that acts
10
  as an interactive README: browse scenarios, explore the scoring system, try
11
+ sending actions with a live canvas viewer, and read full documentation — all
12
+ from a single Hugging Face Space.
13
  """
14
 
15
  from __future__ import annotations
 
80
 
81
 
82
  def _tier_emoji(t: str) -> str:
83
+ return {"easy": "\U0001f7e2", "medium": "\U0001f7e1", "hard": "\U0001f7e0", "expert": "\U0001f534"}.get(t, "⚪")
84
 
85
 
86
  def _tier_label(t: str) -> str:
 
152
  return header + "\n".join(rows)
153
 
154
 
155
+ # ---------------------------------------------------------------------------
156
+ # Canvas renderer (Excalidraw-style, embedded in Gradio)
157
+ # ---------------------------------------------------------------------------
158
+
159
+ _CANVAS_JS = """
160
+ <script>
161
+ (function() {
162
+ const ROLE_COLORS = {
163
+ default: { fill: '#45475a', stroke: '#585b70', text: '#cdd6f4' },
164
+ current: { fill: '#89b4fa', stroke: '#74c7ec', text: '#1e1e2e' },
165
+ visited: { fill: '#a6e3a1', stroke: '#94e2d5', text: '#1e1e2e' },
166
+ frontier: { fill: '#fab387', stroke: '#f9e2af', text: '#1e1e2e' },
167
+ done: { fill: '#40a02b', stroke: '#a6e3a1', text: '#fff' },
168
+ pivot: { fill: '#f38ba8', stroke: '#eba0ac', text: '#1e1e2e' },
169
+ root: { fill: '#cba6f7', stroke: '#b4befe', text: '#1e1e2e' },
170
+ error: { fill: '#f38ba8', stroke: '#f38ba8', text: '#fff' },
171
+ inactive: { fill: '#313244', stroke: '#45475a', text: '#6c7086' },
172
+ comparing: { fill: '#f9e2af', stroke: '#fab387', text: '#1e1e2e' },
173
+ };
174
+
175
+ const GRID = 55;
176
+ const NODE_R = 22;
177
+ const ARROW_LEN = 9;
178
+ const FONT = '12px Inter, system-ui, sans-serif';
179
+ const FONT_BOLD = 'bold 12px Inter, system-ui, sans-serif';
180
+ const FONT_SMALL = '10px Inter, system-ui, sans-serif';
181
+ const FONT_ANN = '10px Inter, system-ui, sans-serif';
182
+
183
+ function drawRoundRect(ctx, x, y, w, h, r) {
184
+ ctx.beginPath();
185
+ ctx.moveTo(x + r, y);
186
+ ctx.lineTo(x + w - r, y);
187
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
188
+ ctx.lineTo(x + w, y + h - r);
189
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
190
+ ctx.lineTo(x + r, y + h);
191
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
192
+ ctx.lineTo(x, y + r);
193
+ ctx.quadraticCurveTo(x, y, x + r, y);
194
+ ctx.closePath();
195
+ }
196
+
197
+ function drawArrowhead(ctx, fx, fy, tx, ty, scale) {
198
+ const angle = Math.atan2(ty - fy, tx - fx);
199
+ const s = ARROW_LEN * scale;
200
+ ctx.beginPath();
201
+ ctx.moveTo(tx, ty);
202
+ ctx.lineTo(tx - s * Math.cos(angle - Math.PI / 7), ty - s * Math.sin(angle - Math.PI / 7));
203
+ ctx.lineTo(tx - s * Math.cos(angle + Math.PI / 7), ty - s * Math.sin(angle + Math.PI / 7));
204
+ ctx.closePath();
205
+ ctx.fill();
206
+ }
207
+
208
+ function autoFit(canvas, entities, layout) {
209
+ const pts = [];
210
+ for (const eid of Object.keys(entities)) {
211
+ const p = layout[eid];
212
+ if (p) pts.push({ x: Number(p.x), y: Number(p.y) });
213
+ }
214
+ if (pts.length < 2) return { ox: 40, oy: 30, scale: 1 };
215
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
216
+ for (const p of pts) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); }
217
+ const spanX = (maxX - minX) || 1;
218
+ const spanY = (maxY - minY) || 1;
219
+ const dpr = window.devicePixelRatio || 1;
220
+ const cw = canvas.width / dpr;
221
+ const ch = canvas.height / dpr;
222
+ const pad = 60;
223
+ const sx = (cw - pad * 2) / (spanX * GRID);
224
+ const sy = (ch - pad * 2) / (spanY * GRID);
225
+ const scale = Math.min(sx, sy, 1.3);
226
+ const ox = pad - minX * GRID * scale + ((cw - pad * 2) - spanX * GRID * scale) / 2;
227
+ const oy = pad - minY * GRID * scale + ((ch - pad * 2) - spanY * GRID * scale) / 2;
228
+ return { ox, oy, scale };
229
+ }
230
+
231
+ function renderCanvas(canvasEl, data) {
232
+ if (!canvasEl || !data) return;
233
+ const ctx = canvasEl.getContext('2d');
234
+ const dpr = window.devicePixelRatio || 1;
235
+ const rect = canvasEl.getBoundingClientRect();
236
+ canvasEl.width = rect.width * dpr;
237
+ canvasEl.height = rect.height * dpr;
238
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
239
+ ctx.clearRect(0, 0, rect.width, rect.height);
240
+
241
+ // Background
242
+ ctx.fillStyle = '#11111b';
243
+ ctx.fillRect(0, 0, rect.width, rect.height);
244
+
245
+ const entities = data.entities || {};
246
+ const relations = data.relations || [];
247
+ const layout = data.layout || {};
248
+ const annotations = data.annotations || [];
249
+ const notes = data.notes || [];
250
+
251
+ if (Object.keys(entities).length === 0) {
252
+ ctx.fillStyle = '#6c7086';
253
+ ctx.font = '14px Inter, system-ui, sans-serif';
254
+ ctx.textAlign = 'center';
255
+ ctx.fillText('Canvas is empty — Reset a scenario and send actions', rect.width / 2, rect.height / 2);
256
+ return;
257
+ }
258
+
259
+ const cam = autoFit(canvasEl, entities, layout);
260
+ const wx = (ex) => cam.ox + ex * GRID * cam.scale;
261
+ const wy = (ey) => cam.oy + ey * GRID * cam.scale;
262
+ const getPos = (eid) => {
263
+ const p = layout[eid];
264
+ if (!p) return null;
265
+ return { x: wx(Number(p.x)), y: wy(Number(p.y)) };
266
+ };
267
+
268
+ const highlighted = new Set();
269
+ for (const a of annotations) {
270
+ if (a && a.text === '[highlight]') highlighted.add(a.target_id);
271
+ }
272
+
273
+ // Regions
274
+ for (const [eid, ent] of Object.entries(entities)) {
275
+ if (!ent || ent.entity_type !== 'region') continue;
276
+ const b = ent.bounds;
277
+ if (!b) continue;
278
+ const x0 = wx(Number(b.x0)), y0 = wy(Number(b.y0));
279
+ const x1 = wx(Number(b.x1)), y1 = wy(Number(b.y1));
280
+ ctx.save();
281
+ ctx.strokeStyle = 'rgba(137,180,250,0.25)';
282
+ ctx.lineWidth = 1;
283
+ ctx.setLineDash([6, 4]);
284
+ drawRoundRect(ctx, x0, y0, x1 - x0, y1 - y0, 8);
285
+ ctx.stroke();
286
+ ctx.setLineDash([]);
287
+ ctx.font = FONT_SMALL;
288
+ ctx.fillStyle = 'rgba(137,180,250,0.5)';
289
+ ctx.textAlign = 'left';
290
+ ctx.fillText(ent.title || eid, x0 + 8, y0 + 14);
291
+ ctx.restore();
292
+ }
293
+
294
+ // Edges
295
+ for (const rel of relations) {
296
+ if (!rel || !rel.src || !rel.dst) continue;
297
+ const sp = getPos(rel.src), dp = getPos(rel.dst);
298
+ if (!sp || !dp) continue;
299
+ const r = NODE_R * cam.scale;
300
+ const dx = dp.x - sp.x, dy = dp.y - sp.y;
301
+ const dist = Math.hypot(dx, dy) || 1;
302
+ const ux = dx / dist, uy = dy / dist;
303
+ const sx = sp.x + ux * r, sy = sp.y + uy * r;
304
+ const ex = dp.x - ux * r, ey = dp.y - uy * r;
305
+ ctx.save();
306
+ ctx.strokeStyle = '#585b70';
307
+ ctx.lineWidth = 1.5 * cam.scale;
308
+ ctx.beginPath();
309
+ ctx.moveTo(sx, sy);
310
+ ctx.lineTo(ex, ey);
311
+ ctx.stroke();
312
+ ctx.fillStyle = '#585b70';
313
+ drawArrowhead(ctx, sx, sy, ex, ey, cam.scale);
314
+ if (rel.label) {
315
+ ctx.font = FONT_SMALL;
316
+ ctx.fillStyle = '#bac2de';
317
+ ctx.textAlign = 'center';
318
+ ctx.fillText(rel.label, (sx + ex) / 2, (sy + ey) / 2 - 6 * cam.scale);
319
+ }
320
+ ctx.restore();
321
+ }
322
+
323
+ // Entities
324
+ for (const [eid, ent] of Object.entries(entities)) {
325
+ if (!ent) continue;
326
+ const type = ent.entity_type || 'node';
327
+ if (type === 'region') continue;
328
+ const p = getPos(eid);
329
+ if (!p) continue;
330
+ const role = ent.role || 'default';
331
+ const colors = ROLE_COLORS[role] || ROLE_COLORS.default;
332
+ const r = NODE_R * cam.scale;
333
+ const isHl = highlighted.has(eid);
334
+
335
+ ctx.save();
336
+ if (isHl) {
337
+ // Golden highlight ring + glow
338
+ ctx.shadowColor = '#f9e2af';
339
+ ctx.shadowBlur = 18 * cam.scale;
340
+ ctx.beginPath();
341
+ if (type === 'pointer') {
342
+ ctx.arc(p.x, p.y, 18 * cam.scale, 0, Math.PI * 2);
343
+ } else if (type === 'container') {
344
+ const hw = 48 * cam.scale, hh = 28 * cam.scale;
345
+ drawRoundRect(ctx, p.x - hw, p.y - hh, hw * 2, hh * 2, 10 * cam.scale);
346
+ } else {
347
+ ctx.arc(p.x, p.y, r + 6 * cam.scale, 0, Math.PI * 2);
348
+ }
349
+ ctx.strokeStyle = '#f9e2af';
350
+ ctx.lineWidth = 2.5 * cam.scale;
351
+ ctx.stroke();
352
+ // Outer faint ring
353
+ ctx.beginPath();
354
+ if (type === 'container') {
355
+ const hw2 = 52 * cam.scale, hh2 = 32 * cam.scale;
356
+ drawRoundRect(ctx, p.x - hw2, p.y - hh2, hw2 * 2, hh2 * 2, 12 * cam.scale);
357
+ } else {
358
+ const outerR = type === 'pointer' ? 22 * cam.scale : r + 10 * cam.scale;
359
+ ctx.arc(p.x, p.y, outerR, 0, Math.PI * 2);
360
+ }
361
+ ctx.strokeStyle = 'rgba(249,226,175,0.3)';
362
+ ctx.lineWidth = 1.5 * cam.scale;
363
+ ctx.stroke();
364
+ ctx.shadowBlur = 0;
365
+ }
366
+
367
+ if (type === 'pointer') {
368
+ ctx.beginPath();
369
+ ctx.moveTo(p.x - 8 * cam.scale, p.y - 10 * cam.scale);
370
+ ctx.lineTo(p.x + 12 * cam.scale, p.y);
371
+ ctx.lineTo(p.x - 8 * cam.scale, p.y + 10 * cam.scale);
372
+ ctx.closePath();
373
+ ctx.fillStyle = colors.fill;
374
+ ctx.fill();
375
+ ctx.strokeStyle = colors.stroke;
376
+ ctx.lineWidth = 1.5 * cam.scale;
377
+ ctx.stroke();
378
+ ctx.shadowBlur = 0;
379
+ ctx.font = FONT_SMALL;
380
+ ctx.fillStyle = '#cdd6f4';
381
+ ctx.textAlign = 'left';
382
+ const val = ent.value != null ? ' \\u2192 ' + ent.value : '';
383
+ ctx.fillText(eid + val, p.x + 16 * cam.scale, p.y + 4 * cam.scale);
384
+ } else if (type === 'container') {
385
+ const w = 80 * cam.scale, h = 40 * cam.scale;
386
+ drawRoundRect(ctx, p.x - w / 2, p.y - h / 2, w, h, 6 * cam.scale);
387
+ ctx.fillStyle = 'rgba(69,90,100,0.25)';
388
+ ctx.fill();
389
+ ctx.strokeStyle = colors.stroke;
390
+ ctx.lineWidth = 1.5 * cam.scale;
391
+ ctx.setLineDash([4, 3]);
392
+ ctx.stroke();
393
+ ctx.setLineDash([]);
394
+ ctx.shadowBlur = 0;
395
+ ctx.font = FONT_SMALL;
396
+ ctx.fillStyle = '#89b4fa';
397
+ ctx.textAlign = 'center';
398
+ ctx.fillText(ent.title || eid, p.x, p.y - h / 2 - 5 * cam.scale);
399
+ const contents = (ent.contents || []).join(', ');
400
+ if (contents) {
401
+ ctx.fillStyle = '#cdd6f4';
402
+ ctx.fillText(contents, p.x, p.y + 3 * cam.scale);
403
+ }
404
+ } else {
405
+ ctx.beginPath();
406
+ ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
407
+ ctx.fillStyle = colors.fill;
408
+ ctx.fill();
409
+ ctx.strokeStyle = colors.stroke;
410
+ ctx.lineWidth = 2 * cam.scale;
411
+ ctx.stroke();
412
+ if (role === 'error') {
413
+ ctx.beginPath();
414
+ ctx.moveTo(p.x - r * .6, p.y - r * .6);
415
+ ctx.lineTo(p.x + r * .6, p.y + r * .6);
416
+ ctx.strokeStyle = '#fff';
417
+ ctx.lineWidth = 2 * cam.scale;
418
+ ctx.stroke();
419
+ }
420
+ ctx.shadowBlur = 0;
421
+ const valText = ent.value != null ? String(ent.value) : eid;
422
+ ctx.font = FONT_BOLD;
423
+ ctx.fillStyle = colors.text;
424
+ ctx.textAlign = 'center';
425
+ ctx.textBaseline = 'middle';
426
+ ctx.fillText(valText.length > 6 ? valText.slice(0, 6) : valText, p.x, p.y);
427
+ ctx.textBaseline = 'alphabetic';
428
+ if (role !== 'default') {
429
+ ctx.font = FONT_SMALL;
430
+ ctx.fillStyle = colors.stroke;
431
+ ctx.fillText(role, p.x, p.y + r + 12 * cam.scale);
432
+ }
433
+ }
434
+ ctx.restore();
435
+ }
436
+
437
+ // Annotations
438
+ const annMap = new Map();
439
+ for (const a of annotations) {
440
+ if (!a || a.text === '[highlight]' || a.text === '[popped]') continue;
441
+ annMap.set(a.target_id, a.text);
442
+ }
443
+ for (const [tid, text] of annMap) {
444
+ const p = getPos(tid);
445
+ if (!p) continue;
446
+ const r = NODE_R * cam.scale;
447
+ ctx.save();
448
+ ctx.font = FONT_ANN;
449
+ const tw = ctx.measureText(text).width;
450
+ const px = p.x - tw / 2 - 4, py = p.y - r - 14 * cam.scale;
451
+ drawRoundRect(ctx, px, py - 10, tw + 8, 16, 4);
452
+ ctx.fillStyle = 'rgba(250,179,135,0.15)';
453
+ ctx.fill();
454
+ ctx.strokeStyle = 'rgba(250,179,135,0.4)';
455
+ ctx.lineWidth = 0.5;
456
+ ctx.stroke();
457
+ ctx.fillStyle = '#fab387';
458
+ ctx.textAlign = 'center';
459
+ ctx.fillText(text, p.x, py);
460
+ ctx.restore();
461
+ }
462
+
463
+ // Notes
464
+ for (const note of notes) {
465
+ if (!note || !note.text) continue;
466
+ let nx = 20, ny = rect.height - 20;
467
+ const regionEnt = note.region && entities[note.region];
468
+ if (regionEnt && regionEnt.bounds) {
469
+ nx = wx(Number(regionEnt.bounds.x0)) + 8;
470
+ ny = wy(Number(regionEnt.bounds.y1)) - 8;
471
+ }
472
+ ctx.save();
473
+ ctx.font = FONT_SMALL;
474
+ ctx.fillStyle = 'rgba(108,112,134,0.7)';
475
+ ctx.textAlign = 'left';
476
+ ctx.fillText(note.text.length > 50 ? note.text.slice(0, 50) + '...' : note.text, nx, ny);
477
+ ctx.restore();
478
+ }
479
+ }
480
+
481
+ // Expose globally for Gradio callbacks
482
+ window.__vrRenderCanvas = renderCanvas;
483
+ })();
484
+ </script>
485
+ """
486
+
487
+ _CANVAS_HTML = """
488
+ <canvas id="vr-canvas" style="width:100%;height:100%;display:block;background:#11111b;border-radius:8px;"></canvas>
489
+ <div id="vr-empty-msg" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#6c7086;font:14px Inter,system-ui,sans-serif;pointer-events:none;">
490
+ Canvas empty — Reset a scenario and Send Step to see visualization
491
+ </div>
492
+ """
493
+
494
+
495
+ def _build_canvas_update_js(state_json: str) -> str:
496
+ return f"""
497
+ <canvas id="vr-canvas" style="width:100%;height:100%;display:block;background:#11111b;border-radius:8px;"></canvas>
498
+ <script>
499
+ (function() {{
500
+ const data = {state_json};
501
+ setTimeout(function() {{
502
+ const c = document.getElementById('vr-canvas');
503
+ if (c && window.__vrRenderCanvas) {{
504
+ const rect = c.parentElement.getBoundingClientRect();
505
+ c.style.width = rect.width + 'px';
506
+ c.style.height = Math.max(rect.height, 400) + 'px';
507
+ window.__vrRenderCanvas(c, data);
508
+ }}
509
+ }}, 100);
510
+ }})();
511
+ </script>
512
+ """
513
+
514
+
515
  # ---------------------------------------------------------------------------
516
  # Playground callbacks
517
  # ---------------------------------------------------------------------------
518
 
519
  def playground_reset(scenario_choice: str):
520
  if not scenario_choice:
521
+ return "Select a scenario first.", "{}", _CANVAS_HTML
522
 
523
  sid = scenario_choice.split("|")[0].strip()
524
  env = _get_env()
 
533
  f"**Checklist:** {obs_dict.get('concept_checklist', [])}"
534
  )
535
  breakdown = json.dumps(obs_dict.get("score_breakdown", {}), indent=2)
536
+ canvas_data = {
537
  "entities": obs_dict.get("entities", {}),
538
  "relations": obs_dict.get("relations", []),
539
  "layout": obs_dict.get("layout", {}),
540
+ "annotations": obs_dict.get("annotations", []),
541
+ "notes": obs_dict.get("notes", []),
542
+ }
543
+ canvas_html = _build_canvas_update_js(json.dumps(canvas_data, default=str))
544
+ return summary, breakdown, canvas_html
545
  except Exception as exc:
546
  tb = traceback.format_exc()
547
+ return f"**Error:** {exc}\n```\n{tb}\n```", "{}", _CANVAS_HTML
548
 
549
 
550
  def playground_step(action_json: str):
551
  env = _get_env()
552
  if not env._scenario:
553
+ return "Call **Reset** first.", "{}", _CANVAS_HTML
554
  try:
555
  action_dict = json.loads(action_json)
556
  except json.JSONDecodeError as exc:
557
+ return f"**JSON parse error:** {exc}", "{}", _CANVAS_HTML
558
 
559
  try:
560
  action = VisualReasoningAction(**action_dict)
 
573
  summary += f"**Covered concepts:** {obs_dict.get('concept_coverage', [])}"
574
 
575
  breakdown = json.dumps(obs_dict.get("score_breakdown", {}), indent=2)
576
+ canvas_data = {
577
  "entities": obs_dict.get("entities", {}),
578
  "relations": obs_dict.get("relations", []),
579
  "layout": obs_dict.get("layout", {}),
580
  "annotations": obs_dict.get("annotations", []),
581
+ "notes": obs_dict.get("notes", []),
582
+ }
583
+ canvas_html = _build_canvas_update_js(json.dumps(canvas_data, default=str))
584
+ return summary, breakdown, canvas_html
585
  except Exception as exc:
586
  tb = traceback.format_exc()
587
+ return f"**Error:** {exc}\n```\n{tb}\n```", "{}", _CANVAS_HTML
588
 
589
 
590
  SAMPLE_ACTION = json.dumps({
 
656
  title="Visual Reasoning Environment",
657
  ) as demo:
658
 
659
+ # Inject the canvas renderer JS globally
660
+ gr.HTML(_CANVAS_JS, visible=False)
661
+
662
+ # -- Header --
663
  gr.Markdown("""
664
  # Visual Reasoning Environment
665
 
 
670
  verifiable rewards for GRPO / RLVR training.
671
  """)
672
 
 
673
  with gr.Tabs():
674
 
675
+ # == TAB 1: Overview ==
 
 
676
  with gr.Tab("Overview"):
677
  gr.Markdown("""
678
  ## How It Works
 
714
  `default` · `current` · `visited` · `frontier` · `done` · `pivot` · `root` · `error` · `inactive` · `comparing`
715
 
716
  ### Named Positioning System
717
+ Regions support a 3×3 grid position system — the LLM says WHERE on the whiteboard to draw:
718
 
719
  ```
720
  top-left │ top-center │ top-right
 
733
  Clipped to `[-0.2, 1.0]` for stable GRPO training.
734
  """)
735
 
736
+ # == TAB 2: Scenarios ==
 
 
737
  with gr.Tab("Scenarios"):
738
  gr.Markdown("## Algorithm Scenarios\nBrowse all 9 algorithm templates across 4 difficulty tiers. Select a scenario to see its full specification.")
739
 
 
771
  desc = ALGO_DESCRIPTIONS.get(algo, "")
772
  gr.Markdown(f"**`{algo}`** — {desc}")
773
 
774
+ # == TAB 3: Scoring ==
 
 
775
  with gr.Tab("Scoring"):
776
  gr.Markdown("""
777
  ## Scoring System
778
 
779
+ The overall score is a **weighted sum of 14 sub-scores** (each 0–1) **minus 5 penalties**.
780
  Weights are tuned per difficulty level — harder tiers emphasize algorithm correctness (`invariant`)
781
  while easier tiers give more weight to narration quality and concept coverage.
782
 
 
840
  - Up to **+0.10** efficiency bonus for finishing early with high coverage
841
  """)
842
 
843
+ # == TAB 4: Playground ==
 
 
844
  with gr.Tab("Playground"):
845
  gr.Markdown("""
846
  ## Interactive Playground
847
 
848
+ Try sending actions to the environment and see the canvas update live.
849
+ Select a scenario, reset, then send JSON actions step by step.
850
  """)
851
 
852
  with gr.Row():
 
859
 
860
  pg_summary = gr.Markdown("_Press Reset to start an episode._")
861
 
862
+ with gr.Row(equal_height=True):
863
+ with gr.Column(scale=1):
864
  pg_action_input = gr.Code(
865
  value=SAMPLE_ACTION,
866
  language="json",
867
  label="Action JSON",
868
+ lines=16,
869
  )
870
  pg_step_btn = gr.Button("Send Step", variant="secondary")
871
+ with gr.Accordion("Score Breakdown", open=False):
872
+ pg_scores = gr.Code(value="{}", language="json", label="Scores", lines=14)
873
+ with gr.Column(scale=2):
874
+ pg_canvas = gr.HTML(
875
+ value=_CANVAS_HTML,
876
+ label="Canvas",
877
+ min_height=450,
878
+ )
879
 
880
  pg_reset_btn.click(
881
  fn=playground_reset,
882
  inputs=[pg_scenario],
883
+ outputs=[pg_summary, pg_scores, pg_canvas],
884
  )
885
  pg_step_btn.click(
886
  fn=playground_step,
887
  inputs=[pg_action_input],
888
+ outputs=[pg_summary, pg_scores, pg_canvas],
889
  )
890
 
891
+ # == TAB 5: Architecture ==
 
 
892
  with gr.Tab("Architecture"):
893
  gr.Markdown("""
894
  ## Architecture
 
905
  pedagogical_scoring.py Attention coherence, pacing, scaffolding
906
  scenario_loader.py Static + procedurally-generated scenarios
907
  scenario_generator.py Procedural generation per difficulty tier
908
+ regions.py Layout engine (queue/stack/tree/graph + 3×3 grid)
909
  constants.py Ops, roles, limits, canvas dimensions
910
  models.py Pydantic Action & Observation schemas
911
  viewer/tldraw_viewer.html tldraw browser whiteboard
912
+ viewer/excalidraw_viewer.html Excalidraw-style Canvas2D viewer
913
  viewer/audio_viewer.html Audio narration + visualization viewer
914
  ```
915
 
 
936
  The regression test `test_easy_1_reproducible` enforces bit-identical scores across runs,
937
  which is critical for GRPO/RLVR training reproducibility.
938
 
939
+ ### Named Positioning (3×3 Grid)
940
  Instead of numeric x,y coordinates, the LLM uses 9 semantic tokens (`top-left`, `center`, etc.)
941
  plus 4 relative prefixes (`below:`, `above:`, `left-of:`, `right-of:`).
942
  This reduces the positioning search space from ~331K (numeric grid) to ~6.5K (for 4 regions),
 
995
  BSD-style (see `LICENSE`).
996
  """)
997
 
998
+ # == TAB 6: API ==
 
 
999
  with gr.Tab("API"):
1000
  gr.Markdown(f"""
1001
  ## API Reference
1002
 
1003
+ This Space exposes the standard **OpenEnv** HTTP + WebSocket API under `/api`.
1004
+ The Gradio UI is mounted at `/` (root).
1005
 
1006
  ### Endpoints
1007
 
1008
  | Method | Path | Description |
1009
  |--------|------|-------------|
1010
+ | `POST` | `/api/reset` | Reset environment, get initial observation |
1011
+ | `POST` | `/api/step` | Send an action, get observation + reward |
1012
+ | `GET` | `/api/health` | Health check |
1013
+ | `WebSocket` | `/api/ws` | Streaming WebSocket interface |
1014
 
1015
  ### Action Schema (`VisualReasoningAction`)
1016
 
 
1079
  gradio_app = build_ui()
1080
 
1081
  _CSS = """
 
 
 
 
 
 
1082
  .score-bar { font-family: monospace; }
 
1083
  """
1084
 
1085
  app = gr.mount_gradio_app(
1086
+ openenv_app, gradio_app, path="/",
1087
  theme=gr.themes.Soft(), css=_CSS,
1088
  )
1089
 
 
1099
  args = parser.parse_args()
1100
 
1101
  print(f"Starting Visual Reasoning Environment on {args.host}:{args.port}")
1102
+ print(f" Gradio UI: http://{args.host}:{args.port}/")
1103
+ print(f" OpenEnv API: http://{args.host}:{args.port}/reset, /step, /health")
1104
 
1105
  uvicorn.run(app, host=args.host, port=args.port)
1106
 
viewer/excalidraw_viewer.html CHANGED
@@ -163,6 +163,7 @@ let S = {
163
  let cam = { ox: 40, oy: 30, scale: 1 };
164
  let canvas, ctx;
165
  let voiceTimer, toastTimer;
 
166
 
167
  const $ = id => document.getElementById(id);
168
 
@@ -306,8 +307,40 @@ function render() {
306
  ctx.save();
307
 
308
  if (isHl) {
309
- ctx.shadowColor = '#f9e2af';
310
- ctx.shadowBlur = 18 * cam.scale;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  }
312
 
313
  if (type === 'pointer') {
@@ -606,6 +639,22 @@ function connect() {
606
  };
607
  }
608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  /* ---- init ---- */
610
  function init() {
611
  canvas = $('canvas');
@@ -614,6 +663,7 @@ function init() {
614
  autoFit();
615
  render();
616
  connect();
 
617
  }
618
  init();
619
  </script>
 
163
  let cam = { ox: 40, oy: 30, scale: 1 };
164
  let canvas, ctx;
165
  let voiceTimer, toastTimer;
166
+ let _hlPhase = 0; // 0..1 oscillating for highlight pulse
167
 
168
  const $ = id => document.getElementById(id);
169
 
 
307
  ctx.save();
308
 
309
  if (isHl) {
310
+ // Animated golden ring + glow
311
+ const pulseAlpha = 0.4 + 0.6 * _hlPhase;
312
+ const pulseBlur = 12 + 10 * _hlPhase;
313
+ ctx.shadowColor = `rgba(249,226,175,${pulseAlpha})`;
314
+ ctx.shadowBlur = pulseBlur * cam.scale;
315
+
316
+ // Inner golden ring
317
+ ctx.beginPath();
318
+ if (type === 'pointer') {
319
+ ctx.arc(p.x, p.y, 18 * cam.scale, 0, Math.PI * 2);
320
+ } else if (type === 'container') {
321
+ const hw = 52 * cam.scale, hh = 28 * cam.scale;
322
+ drawRoundRect(p.x - hw, p.y - hh, hw * 2, hh * 2, 10 * cam.scale);
323
+ } else {
324
+ ctx.arc(p.x, p.y, r + 6 * cam.scale, 0, Math.PI * 2);
325
+ }
326
+ ctx.strokeStyle = `rgba(249,226,175,${pulseAlpha})`;
327
+ ctx.lineWidth = 2.5 * cam.scale;
328
+ ctx.stroke();
329
+
330
+ // Outer faint ring
331
+ ctx.shadowBlur = 0;
332
+ ctx.beginPath();
333
+ if (type === 'container') {
334
+ const hw2 = 56 * cam.scale, hh2 = 32 * cam.scale;
335
+ drawRoundRect(p.x - hw2, p.y - hh2, hw2 * 2, hh2 * 2, 12 * cam.scale);
336
+ } else {
337
+ const outerR = type === 'pointer' ? 22 * cam.scale : r + 10 * cam.scale;
338
+ ctx.arc(p.x, p.y, outerR, 0, Math.PI * 2);
339
+ }
340
+ ctx.strokeStyle = `rgba(249,226,175,${pulseAlpha * 0.4})`;
341
+ ctx.lineWidth = 1.5 * cam.scale;
342
+ ctx.stroke();
343
+ ctx.shadowBlur = 0;
344
  }
345
 
346
  if (type === 'pointer') {
 
639
  };
640
  }
641
 
642
+ /* ---- highlight pulse animation ---- */
643
+ let _hlAnimFrame = null;
644
+ function animateHighlights(ts) {
645
+ _hlPhase = 0.5 + 0.5 * Math.sin(ts / 600);
646
+ render();
647
+ _hlAnimFrame = requestAnimationFrame(animateHighlights);
648
+ }
649
+
650
+ function startHighlightAnim() {
651
+ if (!_hlAnimFrame) _hlAnimFrame = requestAnimationFrame(animateHighlights);
652
+ }
653
+
654
+ function stopHighlightAnim() {
655
+ if (_hlAnimFrame) { cancelAnimationFrame(_hlAnimFrame); _hlAnimFrame = null; }
656
+ }
657
+
658
  /* ---- init ---- */
659
  function init() {
660
  canvas = $('canvas');
 
663
  autoFit();
664
  render();
665
  connect();
666
+ startHighlightAnim();
667
  }
668
  init();
669
  </script>