Laksh718 commited on
Commit
be1d96e
·
1 Parent(s): 043eb28

fix(space): make Gradio app.py compatible with Gradio 6.0 + frontend polish

Browse files

- Pin gradio to >=4.44,<6 so the training Space stops pulling Gradio 6
which broke `every=` on demo.load() and `css=` on Blocks(...). Forward-
compat: app.py now detects gr.Timer and uses Timer.tick() on Gradio 6+
while keeping every=4 working on Gradio 4/5. css moved to launch() on 6+.
- frontend: rewrite renderGraph as D3 enter/update/exit on a persistent
force simulation so Auto-Play animates state transitions smoothly
instead of rebuilding the SVG every step. Add status flash on the node
the agent just acted on. Add speed select (Slow/Normal/Fast/Turbo)
and a 'thinking…' indicator on the autoplay button.
- frontend: keyboard shortcuts (Space=autoplay, R=reset, C=compare,
Esc=close compare).
- /api/compare: restructure response to return {steps, metrics,
final_graph} per side so the Compare overlay's renderer (which expected
metrics.total_reward / metrics.final_sat / metrics.avg_trust /
steps[]) actually works. Each step now carries its own graph snapshot
so the mini-graphs animate as the user scrubs through the trajectory.
- compare mini-graph: persistent simulation per side, smooth status
transitions instead of full rebuild on every step.

Made-with: Cursor

Files changed (6) hide show
  1. Dockerfile +1 -1
  2. app.py +33 -12
  3. frontend/app.js +320 -137
  4. frontend/index.html +12 -1
  5. frontend/style.css +49 -2
  6. vergil/api/server.py +43 -12
Dockerfile CHANGED
@@ -18,6 +18,6 @@ RUN pip install --upgrade pip
18
  # Force strict synchronization of PyTorch and Torchvision directly from NVIDIA's servers
19
  RUN pip install "torch==2.3.1" "torchvision==0.18.1" --index-url https://download.pytorch.org/whl/cu121
20
  # Install all required modules in one robust resolution block
21
- RUN pip install "unsloth" "xformers==0.0.27" "trl" "peft" "accelerate" "bitsandbytes" "gymnasium" "networkx" "scipy" "datasets" "gradio" "huggingface_hub"
22
 
23
  CMD ["python", "app.py"]
 
18
  # Force strict synchronization of PyTorch and Torchvision directly from NVIDIA's servers
19
  RUN pip install "torch==2.3.1" "torchvision==0.18.1" --index-url https://download.pytorch.org/whl/cu121
20
  # Install all required modules in one robust resolution block
21
+ RUN pip install "unsloth" "xformers==0.0.27" "trl" "peft" "accelerate" "bitsandbytes" "gymnasium" "networkx" "scipy" "datasets" "gradio>=4.44,<6" "huggingface_hub"
22
 
23
  CMD ["python", "app.py"]
app.py CHANGED
@@ -117,7 +117,17 @@ body { background: #0a0e1a; }
117
  .gr-button { background: #3b82f6 !important; }
118
  """
119
 
120
- with gr.Blocks(title="VERGIL Training Monitor", css=css) as demo:
 
 
 
 
 
 
 
 
 
 
121
  gr.Markdown("""
122
  # ⟁ VERGIL — GRPO Training Monitor
123
 
@@ -162,14 +172,25 @@ When done, weights are auto-pushed to `Laksh718/vergil-commitment-engine`.
162
  **Target:** reward improving from ~0.1 (random) → ~0.6+ (strategic)
163
  """)
164
 
165
- # Auto-refresh every 4 seconds
166
- demo.load(fn=get_status, outputs=status_box, every=4)
167
- demo.load(fn=get_logs, outputs=log_box, every=4)
168
- demo.load(fn=get_metrics, outputs=metrics_box, every=10)
169
-
170
-
171
- demo.launch(
172
- server_name="0.0.0.0",
173
- server_port=7860,
174
- show_error=True,
175
- )
 
 
 
 
 
 
 
 
 
 
 
 
117
  .gr-button { background: #3b82f6 !important; }
118
  """
119
 
120
+ # Gradio 6.0 removed `every=` from `.load()` and `css=` from `Blocks(...)` —
121
+ # both now belong to a `Timer` and to `launch()` respectively. We detect the
122
+ # presence of `gr.Timer` and pick the right API at runtime so this Space
123
+ # works on Gradio 4.x, 5.x and 6.x without changes.
124
+ _has_timer = hasattr(gr, "Timer")
125
+ _blocks_kwargs = {"title": "VERGIL Training Monitor"}
126
+ if not _has_timer:
127
+ # Older Gradio: css still belongs on Blocks
128
+ _blocks_kwargs["css"] = css
129
+
130
+ with gr.Blocks(**_blocks_kwargs) as demo:
131
  gr.Markdown("""
132
  # ⟁ VERGIL — GRPO Training Monitor
133
 
 
172
  **Target:** reward improving from ~0.1 (random) → ~0.6+ (strategic)
173
  """)
174
 
175
+ if _has_timer:
176
+ # Gradio 6+: initial paint + periodic refresh via Timer.tick
177
+ demo.load(fn=get_status, outputs=status_box)
178
+ demo.load(fn=get_logs, outputs=log_box)
179
+ demo.load(fn=get_metrics, outputs=metrics_box)
180
+ fast_timer = gr.Timer(4)
181
+ slow_timer = gr.Timer(10)
182
+ fast_timer.tick(fn=get_status, outputs=status_box)
183
+ fast_timer.tick(fn=get_logs, outputs=log_box)
184
+ slow_timer.tick(fn=get_metrics, outputs=metrics_box)
185
+ else:
186
+ # Gradio 4/5: every= drives both the initial paint and refresh
187
+ demo.load(fn=get_status, outputs=status_box, every=4)
188
+ demo.load(fn=get_logs, outputs=log_box, every=4)
189
+ demo.load(fn=get_metrics, outputs=metrics_box, every=10)
190
+
191
+
192
+ _launch_kwargs = {"server_name": "0.0.0.0", "server_port": 7860, "show_error": True}
193
+ if _has_timer:
194
+ # Gradio 6+: css moved to launch()
195
+ _launch_kwargs["css"] = css
196
+ demo.launch(**_launch_kwargs)
frontend/app.js CHANGED
@@ -41,6 +41,26 @@ document.addEventListener('DOMContentLoaded', () => {
41
  document.querySelectorAll('.ma-btn').forEach(btn => {
42
  btn.addEventListener('click', () => takeAction(btn.dataset.action));
43
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  });
45
 
46
  async function loadScenarios() {
@@ -68,6 +88,11 @@ async function resetEpisode() {
68
  cascadeCount = 0;
69
  prevTrustAvg = null;
70
  prevHealth = null;
 
 
 
 
 
71
 
72
  const body = {};
73
  const sel = $('scenario-select').value;
@@ -159,6 +184,10 @@ function handleStepResponse(data, actionType, reasoning) {
159
  pushTimeline(actionType, node?.label || targetId || '—', reward);
160
  logAdd('agent', `${actionIcon(actionType)} ${node?.label || actionType} (${reward >= 0 ? '+' : ''}${reward.toFixed(3)})`);
161
 
 
 
 
 
162
  const cascades = data.info?.cascade_events || [];
163
  if (cascades.length) {
164
  const affected = cascades.filter(e => e.cascaded).length;
@@ -195,25 +224,62 @@ function episodeSummary() {
195
  }
196
 
197
  // ═══════════════════════════════════════════════════════════
198
- // AUTOPLAY
199
  // ═══════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  function toggleAutoplay() {
201
  if (autoTimer) {
202
  stopAutoplay();
203
  } else {
204
  $('btn-auto').textContent = '⏹ Stop Agent';
205
  $('btn-auto').classList.add('playing');
206
- autoTimer = setInterval(() => {
207
- if (!currentState) { stopAutoplay(); return; }
208
- agentStep();
209
- }, 1400);
210
- agentStep();
211
  }
212
  }
 
213
  function stopAutoplay() {
214
- if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
215
  $('btn-auto').textContent = '▶ Auto-Play Agent';
216
  $('btn-auto').classList.remove('playing');
 
 
 
 
 
 
 
 
 
 
 
217
  }
218
 
219
  // ═══════════════════════════════════════════════════════════
@@ -330,34 +396,31 @@ function renderGraphIndicators(state) {
330
  }
331
 
332
  // ══════════════════════════��════════════════════════════════
333
- // D3 GRAPH — v5 Node Anatomy
 
 
 
 
 
 
 
334
  // ═══════════════════════════════════════════════════════════
335
- function renderGraph(state) {
336
- const graphData = state.graph;
337
- if (!graphData || !graphData.nodes || graphData.nodes.length === 0) return;
338
-
339
- const container = document.getElementById('graph-area');
340
- const W = container.clientWidth || 600;
341
- const H = container.clientHeight || 400;
342
 
 
343
  const svg = d3.select('#graph-svg');
344
- svg.selectAll('*').remove();
345
-
346
- const prevPos = {};
347
- if (d3Sim) {
348
- d3Sim.stop();
349
- d3Sim.nodes().forEach(n => { prevPos[n.id] = { x: n.x, y: n.y }; });
350
- }
351
 
352
  const defs = svg.append('defs');
353
-
354
- // Arrow markers per edge type
355
- const markerDefs = [
356
  { id: 'arrow-dep', color: '#475569' },
357
  { id: 'arrow-conflict', color: '#fb7185' },
358
  { id: 'arrow-trust', color: '#8b5cf6' },
359
- ];
360
- markerDefs.forEach(({ id, color }) => {
361
  defs.append('marker')
362
  .attr('id', id)
363
  .attr('viewBox', '0 -4 8 8').attr('refX', 28).attr('refY', 0)
@@ -365,29 +428,72 @@ function renderGraph(state) {
365
  .append('path').attr('d', 'M0,-4L8,0L0,4').attr('fill', color);
366
  });
367
 
368
- // Glow filter for selected
369
- const filt = defs.append('filter').attr('id', 'glow').attr('x', '-30%').attr('y', '-30%').attr('width', '160%').attr('height', '160%');
370
- filt.append('feGaussianBlur').attr('in', 'SourceGraphic').attr('stdDeviation', '4').attr('result', 'blur');
371
- filt.append('feMerge').selectAll('feMergeNode').data(['blur','SourceGraphic']).join('feMergeNode').attr('in', d => d);
 
372
 
373
- const g = svg.append('g');
 
 
374
  svg.call(d3.zoom().scaleExtent([0.35, 3]).on('zoom', e => g.attr('transform', e.transform)));
 
 
 
 
 
375
 
376
- // Assign letter labels A, B, C…
377
- const letterMap = {};
378
- graphData.nodes.forEach((n, i) => { letterMap[n.id] = String.fromCharCode(65 + (i % 26)); });
379
-
380
- const nodes = graphData.nodes.map(n => ({
381
- ...n,
382
- letter: letterMap[n.id],
383
- x: prevPos[n.id]?.x ?? (W/2 + (Math.random()-0.5)*200),
384
- y: prevPos[n.id]?.y ?? (H/2 + (Math.random()-0.5)*160),
385
- }));
386
- const links = (graphData.edges || []).map(e => ({...e}));
387
-
388
- // Edges (curved paths for clarity)
389
- const edgeGroup = g.append('g').attr('class', 'edges');
390
- const link = edgeGroup.selectAll('path').data(links).join('path')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  .attr('class', d => {
392
  const t = d.edge_type || 'dependency';
393
  if (t === 'conflict') return 'edge conflict';
@@ -400,14 +506,26 @@ function renderGraph(state) {
400
  if (t === 'conflict') return 'url(#arrow-conflict)';
401
  if (t === 'trust_impact') return 'url(#arrow-trust)';
402
  return 'url(#arrow-dep)';
403
- });
 
 
 
 
 
404
 
405
- const R = d => 20 + (d.urgency || 0.4) * 7;
 
 
 
406
 
407
- // Node groups
408
- const nodeGroup = g.append('g').attr('class', 'nodes');
409
- const node = nodeGroup.selectAll('g').data(nodes).join('g')
410
- .attr('class', d => `node ${d.status || 'pending'}${d.id === selectedNode ? ' selected' : ''}`)
 
 
 
 
411
  .call(d3.drag()
412
  .on('start', (e, d) => { if (!e.active) d3Sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
413
  .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
@@ -415,57 +533,67 @@ function renderGraph(state) {
415
  )
416
  .on('click', (e, d) => { e.stopPropagation(); selectNode(d.id); });
417
 
418
- // Pulse ring (CSS animates only .pending)
419
- node.append('circle').attr('class', 'node-pulse').attr('r', d => R(d) + 10);
420
-
421
- // Background fill
422
- node.append('circle').attr('class', 'node-bg').attr('r', d => R(d));
423
-
424
- // Status ring stroke
425
- node.append('circle').attr('class', 'node-ring').attr('r', d => R(d));
426
-
427
- // Letter label (center)
428
- node.append('text')
429
- .attr('class', 'node-letter')
430
- .attr('dominant-baseline', 'central')
431
- .text(d => d.letter);
432
-
433
- // Commitment label below node
434
- node.append('text')
435
- .attr('class', 'node-label')
 
 
 
 
 
436
  .attr('dy', d => R(d) + 14)
437
  .text(d => {
438
  const lbl = d.label || d.id;
439
  return lbl.length > 16 ? lbl.slice(0, 14) + '…' : lbl;
440
  });
441
-
442
- // Hours hint (small, below label)
443
- node.append('text')
444
- .attr('class', 'node-deadline')
445
  .attr('dy', d => R(d) + 26)
446
  .text(d => d.estimated_duration_hours ? `${d.estimated_duration_hours}h` : '');
447
 
448
- // Force simulation
449
- d3Sim = d3.forceSimulation(nodes)
450
- .force('link', d3.forceLink(links).id(d => d.id).distance(130).strength(0.45))
451
- .force('charge', d3.forceManyBody().strength(-380))
452
- .force('center', d3.forceCenter(W/2, H/2))
453
- .force('collide', d3.forceCollide(d => R(d) + 32))
454
- .on('tick', () => {
455
- link.attr('d', d => {
456
- const src = d.source, tgt = d.target;
457
- const dx = tgt.x - src.x, dy = tgt.y - src.y;
458
- const dist = Math.sqrt(dx*dx + dy*dy) || 1;
459
- const sr = R(src) + 2, tr = R(tgt) + 2;
460
- const sx = src.x + (dx/dist)*sr, sy = src.y + (dy/dist)*sr;
461
- const tx = tgt.x - (dx/dist)*tr, ty = tgt.y - (dy/dist)*tr;
462
- // Gentle curve to distinguish overlapping edges
463
- const cx = (sx+tx)/2 - (dy/dist)*18;
464
- const cy = (sy+ty)/2 + (dx/dist)*18;
465
- return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}`;
 
 
 
 
466
  });
467
- node.attr('transform', d => `translate(${d.x},${d.y})`);
468
- });
 
 
 
 
469
  }
470
 
471
  // ═══════════════════════════════════════════════════════════
@@ -799,10 +927,13 @@ function closeCompare() {
799
  stopCompareAuto();
800
  $('compare-overlay').classList.add('hidden');
801
  compareData = null;
 
 
 
 
 
802
  }
803
 
804
- $('cmp-scenario-select')?.addEventListener('change', updateCmpScenarioMeta);
805
-
806
  function updateCmpScenarioMeta() {
807
  const id = $('cmp-scenario-select').value;
808
  const meta = SCENARIO_DESCS[id] || { icon:'⚡', name: id.replace('scenario_','').replace(/_/g,' '), desc:'' };
@@ -899,21 +1030,33 @@ function renderCmpStep(idx) {
899
  const icon = actionIcon(step.action);
900
  const r = step.reward || 0;
901
  const rS = r >= 0 ? '+' : '';
 
 
902
  if (isVergil && step.reasoning) {
903
- return `${icon} <strong>${step.action}</strong> → ${step.target || '—'}<br>
904
  <span style="color:#c084fc;margin-top:3px;display:block">🧠 ${step.reasoning}</span>
905
  <span style="color:var(--t3)">${rS}${r.toFixed(3)}</span>`;
906
  }
907
- return `${icon} <strong>${step.action}</strong> → ${step.target || '—'}<span style="color:var(--t3);margin-left:8px">${rS}${r.toFixed(3)}</span>`;
908
  }
909
 
910
  $('naive-step-display').innerHTML = stepHtml(nStep, false);
911
  $('vergil-step-display').innerHTML = stepHtml(vStep, true);
912
 
 
 
 
 
 
 
913
  if (nStep?.caused_failure) {
914
  $('cmp-svg-naive').classList.add('cascade-active');
915
  setTimeout(() => $('cmp-svg-naive').classList.remove('cascade-active'), 800);
916
  }
 
 
 
 
917
  }
918
 
919
  function compareStep(delta) { renderCmpStep(compareStepIdx + delta); }
@@ -942,54 +1085,94 @@ function stopCompareAuto() {
942
  if (btn) { btn.textContent = 'Auto ▶'; btn.classList.remove('playing'); }
943
  }
944
 
 
 
 
 
 
 
 
 
 
945
  function renderMiniGraph(svgSelector, graphData, side) {
946
  if (!graphData?.nodes?.length) return;
947
  const svgEl = document.querySelector(svgSelector);
948
  if (!svgEl) return;
 
949
  const W = svgEl.clientWidth || 500;
950
  const H = svgEl.clientHeight || 300;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
951
 
952
- const svg = d3.select(svgSelector);
953
- svg.selectAll('*').remove();
954
-
955
- const colorMap = {
956
- pending: '#818cf8', accepted: '#38bdf8',
957
- completed: '#34d399', failed: '#fb7185',
958
- };
959
-
960
- const g = svg.append('g');
961
- const nodes = graphData.nodes.map(n => ({ ...n, x: W/2 + (Math.random()-.5)*200, y: H/2 + (Math.random()-.5)*200 }));
962
- const links = (graphData.edges || []).map(e => ({...e}));
963
-
964
- const link = g.append('g').selectAll('line').data(links).join('line')
965
- .attr('stroke', '#2d3f58').attr('stroke-width', 1.5).attr('stroke-opacity', 0.6);
966
 
967
- const node = g.append('g').selectAll('g').data(nodes).join('g');
 
 
 
 
 
 
 
968
 
969
- node.append('circle')
970
- .attr('r', d => 10 + (d.urgency||0.5)*5)
971
- .attr('fill', d => `${colorMap[d.status] || '#475569'}18`)
972
- .attr('stroke', d => colorMap[d.status] || '#475569')
973
- .attr('stroke-width', d => d.status === 'failed' ? 2.5 : 1.5)
974
  .style('filter', d => d.status === 'failed' && side === 'naive'
975
  ? 'drop-shadow(0 0 8px rgba(251,113,133,0.8))' : 'none');
976
 
977
- node.append('text')
978
- .attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
979
- .attr('fill', '#94a3b8').attr('font-size', '9px').attr('font-weight', '600')
980
- .attr('pointer-events', 'none')
981
- .text(d => d.label?.slice(0,8) || d.id?.slice(0,6));
982
-
983
- const sim = d3.forceSimulation(nodes)
984
- .force('link', d3.forceLink(links).id(d => d.id).distance(80))
985
- .force('charge', d3.forceManyBody().strength(-180))
986
- .force('center', d3.forceCenter(W/2, H/2))
987
- .force('collide', d3.forceCollide(24))
988
- .on('tick', () => {
989
- link.attr('x1',d=>d.source.x).attr('y1',d=>d.source.y)
990
- .attr('x2',d=>d.target.x).attr('y2',d=>d.target.y);
991
- node.attr('transform', d=>`translate(${d.x},${d.y})`);
992
- });
993
-
994
- setTimeout(() => sim.stop(), 3000);
995
  }
 
41
  document.querySelectorAll('.ma-btn').forEach(btn => {
42
  btn.addEventListener('click', () => takeAction(btn.dataset.action));
43
  });
44
+
45
+ // Compare-overlay scenario picker — keep header in sync as user picks
46
+ $('cmp-scenario-select')?.addEventListener('change', updateCmpScenarioMeta);
47
+
48
+ // Live-update autoplay status badge if user changes speed mid-run
49
+ $('autoplay-speed')?.addEventListener('change', () => {
50
+ // No need to reset the timer — _autoplayTick reads speed each tick.
51
+ const sel = $('autoplay-speed');
52
+ if (sel) sel.blur();
53
+ });
54
+
55
+ // Keyboard shortcuts: space = autoplay toggle, R = reset, C = compare
56
+ document.addEventListener('keydown', (e) => {
57
+ const tag = (e.target?.tagName || '').toLowerCase();
58
+ if (tag === 'input' || tag === 'select' || tag === 'textarea') return;
59
+ if (e.code === 'Space') { e.preventDefault(); toggleAutoplay(); }
60
+ else if (e.key === 'r' || e.key === 'R') { resetEpisode(); }
61
+ else if (e.key === 'c' || e.key === 'C') { openCompare(); }
62
+ else if (e.key === 'Escape') { closeCompare(); }
63
+ });
64
  });
65
 
66
  async function loadScenarios() {
 
88
  cascadeCount = 0;
89
  prevTrustAvg = null;
90
  prevHealth = null;
91
+ _graphInitialized = false;
92
+ _persistentNodes = [];
93
+ _persistentLinks = [];
94
+ if (d3Sim) { d3Sim.stop(); d3Sim = null; }
95
+ d3.select('#graph-svg').selectAll('*').remove();
96
 
97
  const body = {};
98
  const sel = $('scenario-select').value;
 
184
  pushTimeline(actionType, node?.label || targetId || '—', reward);
185
  logAdd('agent', `${actionIcon(actionType)} ${node?.label || actionType} (${reward >= 0 ? '+' : ''}${reward.toFixed(3)})`);
186
 
187
+ // Flash-highlight the node the agent just acted on so the user
188
+ // can visually track the decision through the graph.
189
+ if (targetId) _flashNode(targetId, reward >= 0);
190
+
191
  const cascades = data.info?.cascade_events || [];
192
  if (cascades.length) {
193
  const affected = cascades.filter(e => e.cascaded).length;
 
224
  }
225
 
226
  // ═══════════════════════════════════════════════════════════
227
+ // AUTOPLAY — speed-aware loop with "thinking…" indicator
228
  // ═══════════════════════════════════════════════════════════
229
+ function _autoplaySpeedMs() {
230
+ const sel = $('autoplay-speed');
231
+ const v = parseInt(sel?.value || '1400', 10);
232
+ return Number.isFinite(v) ? v : 1400;
233
+ }
234
+
235
+ async function _autoplayTick() {
236
+ if (!autoTimer) return;
237
+ if (!currentState) { stopAutoplay(); return; }
238
+
239
+ const status = $('autoplay-status');
240
+ if (status) { status.textContent = 'thinking…'; status.classList.add('thinking'); }
241
+
242
+ try {
243
+ await agentStep();
244
+ } finally {
245
+ if (status) {
246
+ status.classList.remove('thinking');
247
+ const step = currentState?.step_number ?? 0;
248
+ status.textContent = `step ${step}`;
249
+ }
250
+ }
251
+
252
+ // Schedule next tick using *current* speed selection (so user can change mid-run)
253
+ if (autoTimer) {
254
+ autoTimer = setTimeout(_autoplayTick, _autoplaySpeedMs());
255
+ }
256
+ }
257
+
258
  function toggleAutoplay() {
259
  if (autoTimer) {
260
  stopAutoplay();
261
  } else {
262
  $('btn-auto').textContent = '⏹ Stop Agent';
263
  $('btn-auto').classList.add('playing');
264
+ autoTimer = setTimeout(_autoplayTick, 0);
 
 
 
 
265
  }
266
  }
267
+
268
  function stopAutoplay() {
269
+ if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; }
270
  $('btn-auto').textContent = '▶ Auto-Play Agent';
271
  $('btn-auto').classList.remove('playing');
272
+ const status = $('autoplay-status');
273
+ if (status) { status.textContent = ''; status.classList.remove('thinking'); }
274
+ }
275
+
276
+ function _flashNode(nodeId, ok) {
277
+ if (!nodeId) return;
278
+ const sel = d3.selectAll('.node').filter(d => d && d.id === nodeId);
279
+ if (sel.empty()) return;
280
+ const cls = ok ? 'flash-success' : 'flash-fail';
281
+ sel.classed(cls, true);
282
+ setTimeout(() => sel.classed(cls, false), 750);
283
  }
284
 
285
  // ═══════════════════════════════════════════════════════════
 
396
  }
397
 
398
  // ══════════════════════════��════════════════════════════════
399
+ // D3 GRAPH — incremental enter/update/exit (smooth animation)
400
+ //
401
+ // Key change vs. the old code: we no longer wipe and re-create
402
+ // the SVG every step. Instead we keep one persistent force
403
+ // simulation and use D3 data joins so node status changes
404
+ // animate (color/ring transitions) without re-laying-out the
405
+ // whole graph. Auto-play now looks like a state machine
406
+ // evolving, not a flicker reel.
407
  // ═══════════════════════════════════════════════════════════
408
+ const R = d => 20 + (d.urgency || 0.4) * 7;
409
+ const STATUS_TRANSITION_MS = 380;
410
+ let _graphInitialized = false;
411
+ let _persistentNodes = []; // mutated in place across steps
412
+ let _persistentLinks = [];
 
 
413
 
414
+ function _setupGraphChrome() {
415
  const svg = d3.select('#graph-svg');
416
+ if (!svg.select('defs').empty()) return; // already done
 
 
 
 
 
 
417
 
418
  const defs = svg.append('defs');
419
+ [
 
 
420
  { id: 'arrow-dep', color: '#475569' },
421
  { id: 'arrow-conflict', color: '#fb7185' },
422
  { id: 'arrow-trust', color: '#8b5cf6' },
423
+ ].forEach(({ id, color }) => {
 
424
  defs.append('marker')
425
  .attr('id', id)
426
  .attr('viewBox', '0 -4 8 8').attr('refX', 28).attr('refY', 0)
 
428
  .append('path').attr('d', 'M0,-4L8,0L0,4').attr('fill', color);
429
  });
430
 
431
+ const filt = defs.append('filter').attr('id', 'glow')
432
+ .attr('x','-30%').attr('y','-30%').attr('width','160%').attr('height','160%');
433
+ filt.append('feGaussianBlur').attr('in','SourceGraphic').attr('stdDeviation','4').attr('result','blur');
434
+ filt.append('feMerge').selectAll('feMergeNode').data(['blur','SourceGraphic'])
435
+ .join('feMergeNode').attr('in', d => d);
436
 
437
+ const g = svg.append('g').attr('class', 'graph-root');
438
+ g.append('g').attr('class', 'edges');
439
+ g.append('g').attr('class', 'nodes');
440
  svg.call(d3.zoom().scaleExtent([0.35, 3]).on('zoom', e => g.attr('transform', e.transform)));
441
+ }
442
+
443
+ function renderGraph(state) {
444
+ const graphData = state.graph;
445
+ if (!graphData || !graphData.nodes || graphData.nodes.length === 0) return;
446
 
447
+ const container = document.getElementById('graph-area');
448
+ const W = container.clientWidth || 600;
449
+ const H = container.clientHeight || 400;
450
+
451
+ _setupGraphChrome();
452
+ const svg = d3.select('#graph-svg');
453
+ const g = svg.select('g.graph-root');
454
+
455
+ // Detect topology change: different node-id set or first render
456
+ const incomingIds = graphData.nodes.map(n => n.id).sort().join('|');
457
+ const persistedIds = _persistentNodes.map(n => n.id).sort().join('|');
458
+ const topologyChanged = !_graphInitialized || incomingIds !== persistedIds;
459
+
460
+ if (topologyChanged) {
461
+ // Carry over positions of nodes that survive the topology change
462
+ const prevPos = Object.fromEntries(_persistentNodes.map(n => [n.id, { x: n.x, y: n.y }]));
463
+ const letterMap = {};
464
+ graphData.nodes.forEach((n, i) => { letterMap[n.id] = String.fromCharCode(65 + (i % 26)); });
465
+
466
+ _persistentNodes = graphData.nodes.map(n => ({
467
+ ...n,
468
+ letter: letterMap[n.id],
469
+ x: prevPos[n.id]?.x ?? (W/2 + (Math.random()-0.5)*200),
470
+ y: prevPos[n.id]?.y ?? (H/2 + (Math.random()-0.5)*160),
471
+ }));
472
+ _persistentLinks = (graphData.edges || []).map(e => ({ ...e }));
473
+ } else {
474
+ // Same topology — just merge fresh status / urgency / labels onto live objects
475
+ const byId = Object.fromEntries(_persistentNodes.map(n => [n.id, n]));
476
+ graphData.nodes.forEach(fresh => {
477
+ const live = byId[fresh.id];
478
+ if (!live) return;
479
+ live.status = fresh.status;
480
+ live.urgency = fresh.urgency;
481
+ live.label = fresh.label;
482
+ live.deadline = fresh.deadline;
483
+ live.estimated_duration_hours = fresh.estimated_duration_hours;
484
+ });
485
+ }
486
+
487
+ // ── EDGES (data join) ────────────────────────────────────
488
+ const edgeSel = g.select('g.edges')
489
+ .selectAll('path.edge')
490
+ .data(_persistentLinks, d => `${d.source.id || d.source}-${d.target.id || d.target}-${d.edge_type || 'dep'}`);
491
+
492
+ edgeSel.exit()
493
+ .transition().duration(STATUS_TRANSITION_MS).style('opacity', 0)
494
+ .remove();
495
+
496
+ const edgeEnter = edgeSel.enter().append('path')
497
  .attr('class', d => {
498
  const t = d.edge_type || 'dependency';
499
  if (t === 'conflict') return 'edge conflict';
 
506
  if (t === 'conflict') return 'url(#arrow-conflict)';
507
  if (t === 'trust_impact') return 'url(#arrow-trust)';
508
  return 'url(#arrow-dep)';
509
+ })
510
+ .style('opacity', 0);
511
+
512
+ edgeEnter.transition().duration(STATUS_TRANSITION_MS).style('opacity', 1);
513
+
514
+ const linkAll = edgeEnter.merge(edgeSel);
515
 
516
+ // ── NODES (data join) ────────────────────────────────────
517
+ const nodeSel = g.select('g.nodes')
518
+ .selectAll('g.node')
519
+ .data(_persistentNodes, d => d.id);
520
 
521
+ nodeSel.exit()
522
+ .transition().duration(STATUS_TRANSITION_MS)
523
+ .style('opacity', 0)
524
+ .remove();
525
+
526
+ const nodeEnter = nodeSel.enter().append('g')
527
+ .attr('class', d => `node ${d.status || 'pending'}`)
528
+ .style('opacity', 0)
529
  .call(d3.drag()
530
  .on('start', (e, d) => { if (!e.active) d3Sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
531
  .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
 
533
  )
534
  .on('click', (e, d) => { e.stopPropagation(); selectNode(d.id); });
535
 
536
+ nodeEnter.append('circle').attr('class', 'node-pulse').attr('r', d => R(d) + 10);
537
+ nodeEnter.append('circle').attr('class', 'node-bg').attr('r', d => R(d));
538
+ nodeEnter.append('circle').attr('class', 'node-ring').attr('r', d => R(d));
539
+ nodeEnter.append('text').attr('class', 'node-letter').attr('dominant-baseline', 'central');
540
+ nodeEnter.append('text').attr('class', 'node-label');
541
+ nodeEnter.append('text').attr('class', 'node-deadline');
542
+
543
+ nodeEnter.transition().duration(STATUS_TRANSITION_MS).style('opacity', 1);
544
+
545
+ const nodeAll = nodeEnter.merge(nodeSel);
546
+
547
+ // ── Update visuals on ALL nodes (smooth status transitions) ───────
548
+ nodeAll
549
+ .attr('class', d => `node ${d.status || 'pending'}${d.id === selectedNode ? ' selected' : ''}`);
550
+ nodeAll.select('circle.node-pulse').attr('r', d => R(d) + 10);
551
+ nodeAll.select('circle.node-bg')
552
+ .transition().duration(STATUS_TRANSITION_MS)
553
+ .attr('r', d => R(d));
554
+ nodeAll.select('circle.node-ring')
555
+ .transition().duration(STATUS_TRANSITION_MS)
556
+ .attr('r', d => R(d));
557
+ nodeAll.select('text.node-letter').text(d => d.letter);
558
+ nodeAll.select('text.node-label')
559
  .attr('dy', d => R(d) + 14)
560
  .text(d => {
561
  const lbl = d.label || d.id;
562
  return lbl.length > 16 ? lbl.slice(0, 14) + '…' : lbl;
563
  });
564
+ nodeAll.select('text.node-deadline')
 
 
 
565
  .attr('dy', d => R(d) + 26)
566
  .text(d => d.estimated_duration_hours ? `${d.estimated_duration_hours}h` : '');
567
 
568
+ // ── Force simulation: build once, gently rewarm on topology change ─
569
+ if (topologyChanged) {
570
+ if (d3Sim) d3Sim.stop();
571
+ d3Sim = d3.forceSimulation(_persistentNodes)
572
+ .force('link', d3.forceLink(_persistentLinks).id(d => d.id).distance(130).strength(0.45))
573
+ .force('charge', d3.forceManyBody().strength(-380))
574
+ .force('center', d3.forceCenter(W/2, H/2))
575
+ .force('collide', d3.forceCollide(d => R(d) + 32))
576
+ .alpha(0.6).alphaDecay(0.05)
577
+ .on('tick', () => {
578
+ linkAll.attr('d', d => {
579
+ const src = d.source, tgt = d.target;
580
+ const dx = tgt.x - src.x, dy = tgt.y - src.y;
581
+ const dist = Math.sqrt(dx*dx + dy*dy) || 1;
582
+ const sr = R(src) + 2, tr = R(tgt) + 2;
583
+ const sx = src.x + (dx/dist)*sr, sy = src.y + (dy/dist)*sr;
584
+ const tx = tgt.x - (dx/dist)*tr, ty = tgt.y - (dy/dist)*tr;
585
+ const cx = (sx+tx)/2 - (dy/dist)*18;
586
+ const cy = (sy+ty)/2 + (dx/dist)*18;
587
+ return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}`;
588
+ });
589
+ nodeAll.attr('transform', d => `translate(${d.x},${d.y})`);
590
  });
591
+ _graphInitialized = true;
592
+ } else {
593
+ // Gentle nudge so collide adapts to status-driven radius changes,
594
+ // but no re-layout flicker.
595
+ d3Sim.alpha(0.12).restart();
596
+ }
597
  }
598
 
599
  // ═══════════════════════════════════════════════════════════
 
927
  stopCompareAuto();
928
  $('compare-overlay').classList.add('hidden');
929
  compareData = null;
930
+ // Tear down mini-graph state so a fresh run starts clean
931
+ Object.values(_miniState).forEach(s => s.sim?.stop());
932
+ Object.keys(_miniState).forEach(k => delete _miniState[k]);
933
+ d3.select('#cmp-svg-naive').selectAll('*').remove();
934
+ d3.select('#cmp-svg-vergil').selectAll('*').remove();
935
  }
936
 
 
 
937
  function updateCmpScenarioMeta() {
938
  const id = $('cmp-scenario-select').value;
939
  const meta = SCENARIO_DESCS[id] || { icon:'⚡', name: id.replace('scenario_','').replace(/_/g,' '), desc:'' };
 
1030
  const icon = actionIcon(step.action);
1031
  const r = step.reward || 0;
1032
  const rS = r >= 0 ? '+' : '';
1033
+ const failTag = step.caused_failure
1034
+ ? '<span style="background:rgba(251,113,133,0.15);color:var(--s-failed);padding:1px 6px;border-radius:4px;font-size:9px;margin-left:6px">⚠ cascade</span>' : '';
1035
  if (isVergil && step.reasoning) {
1036
+ return `${icon} <strong>${step.action}</strong> → ${step.target || '—'}${failTag}<br>
1037
  <span style="color:#c084fc;margin-top:3px;display:block">🧠 ${step.reasoning}</span>
1038
  <span style="color:var(--t3)">${rS}${r.toFixed(3)}</span>`;
1039
  }
1040
+ return `${icon} <strong>${step.action}</strong> → ${step.target || '—'}${failTag}<span style="color:var(--t3);margin-left:8px">${rS}${r.toFixed(3)}</span>`;
1041
  }
1042
 
1043
  $('naive-step-display').innerHTML = stepHtml(nStep, false);
1044
  $('vergil-step-display').innerHTML = stepHtml(vStep, true);
1045
 
1046
+ // Animate the mini graphs to this step's CDG snapshot (or fall back to final graph)
1047
+ const naiveGraph = nStep?.graph || compareData.naive.final_graph;
1048
+ const vergilGraph = vStep?.graph || compareData.vergil.final_graph;
1049
+ if (naiveGraph) renderMiniGraph('#cmp-svg-naive', naiveGraph, 'naive');
1050
+ if (vergilGraph) renderMiniGraph('#cmp-svg-vergil', vergilGraph, 'vergil');
1051
+
1052
  if (nStep?.caused_failure) {
1053
  $('cmp-svg-naive').classList.add('cascade-active');
1054
  setTimeout(() => $('cmp-svg-naive').classList.remove('cascade-active'), 800);
1055
  }
1056
+ if (vStep?.caused_failure) {
1057
+ $('cmp-svg-vergil').classList.add('cascade-active');
1058
+ setTimeout(() => $('cmp-svg-vergil').classList.remove('cascade-active'), 800);
1059
+ }
1060
  }
1061
 
1062
  function compareStep(delta) { renderCmpStep(compareStepIdx + delta); }
 
1085
  if (btn) { btn.textContent = 'Auto ▶'; btn.classList.remove('playing'); }
1086
  }
1087
 
1088
+ /**
1089
+ * Mini graph renderer used by the Compare overlay. Maintains a persistent
1090
+ * simulation per side so stepping through the trajectory animates node
1091
+ * status changes (pending → accepted → completed/failed) smoothly instead
1092
+ * of redrawing from scratch.
1093
+ */
1094
+ const _miniState = {}; // svgSelector -> { sim, nodes, links }
1095
+ const COLOR_MAP = { pending: '#818cf8', accepted: '#38bdf8', completed: '#34d399', failed: '#fb7185' };
1096
+
1097
  function renderMiniGraph(svgSelector, graphData, side) {
1098
  if (!graphData?.nodes?.length) return;
1099
  const svgEl = document.querySelector(svgSelector);
1100
  if (!svgEl) return;
1101
+
1102
  const W = svgEl.clientWidth || 500;
1103
  const H = svgEl.clientHeight || 300;
1104
+ const svg = d3.select(svgSelector);
1105
+
1106
+ let st = _miniState[svgSelector];
1107
+ const incomingIds = graphData.nodes.map(n => n.id).sort().join('|');
1108
+ const sameTopology = st && st.idsKey === incomingIds;
1109
+
1110
+ if (!sameTopology) {
1111
+ // First draw or topology change: rebuild structure
1112
+ svg.selectAll('*').remove();
1113
+ const g = svg.append('g');
1114
+
1115
+ const prev = st?.byId || {};
1116
+ const nodes = graphData.nodes.map(n => ({
1117
+ ...n,
1118
+ x: prev[n.id]?.x ?? (W/2 + (Math.random()-.5)*200),
1119
+ y: prev[n.id]?.y ?? (H/2 + (Math.random()-.5)*200),
1120
+ }));
1121
+ const links = (graphData.edges || []).map(e => ({ ...e }));
1122
+
1123
+ const linkSel = g.append('g').selectAll('line').data(links).join('line')
1124
+ .attr('stroke', '#2d3f58').attr('stroke-width', 1.5).attr('stroke-opacity', 0.6);
1125
+
1126
+ const nodeSel = g.append('g').selectAll('g').data(nodes, d => d.id).join('g')
1127
+ .attr('class', 'mini-node');
1128
+
1129
+ nodeSel.append('circle').attr('class', 'mini-circle');
1130
+ nodeSel.append('text')
1131
+ .attr('class', 'mini-label')
1132
+ .attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
1133
+ .attr('fill', '#94a3b8').attr('font-size', '9px').attr('font-weight', '600')
1134
+ .attr('pointer-events', 'none')
1135
+ .text(d => d.label?.slice(0, 8) || d.id?.slice(0, 6));
1136
+
1137
+ if (st?.sim) st.sim.stop();
1138
+ const sim = d3.forceSimulation(nodes)
1139
+ .force('link', d3.forceLink(links).id(d => d.id).distance(80))
1140
+ .force('charge', d3.forceManyBody().strength(-180))
1141
+ .force('center', d3.forceCenter(W/2, H/2))
1142
+ .force('collide', d3.forceCollide(24))
1143
+ .alpha(0.7).alphaDecay(0.06)
1144
+ .on('tick', () => {
1145
+ linkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
1146
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
1147
+ nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
1148
+ });
1149
 
1150
+ st = _miniState[svgSelector] = { sim, nodes, links, idsKey: incomingIds, side, byId: {} };
1151
+ } else {
1152
+ // Same topology — just merge new statuses/urgency into the live nodes
1153
+ const byId = Object.fromEntries(st.nodes.map(n => [n.id, n]));
1154
+ graphData.nodes.forEach(fresh => {
1155
+ const live = byId[fresh.id];
1156
+ if (!live) return;
1157
+ live.status = fresh.status;
1158
+ live.urgency = fresh.urgency;
1159
+ });
1160
+ st.sim.alpha(0.08).restart();
1161
+ }
 
 
1162
 
1163
+ // Update circle visuals (always — covers both fresh & merged cases)
1164
+ svg.selectAll('g.mini-node').data(st.nodes, d => d.id)
1165
+ .select('circle.mini-circle')
1166
+ .transition().duration(360)
1167
+ .attr('r', d => 10 + (d.urgency || 0.5) * 5)
1168
+ .attr('fill', d => `${COLOR_MAP[d.status] || '#475569'}18`)
1169
+ .attr('stroke', d => COLOR_MAP[d.status] || '#475569')
1170
+ .attr('stroke-width', d => d.status === 'failed' ? 2.5 : 1.5);
1171
 
1172
+ svg.selectAll('g.mini-node').select('circle.mini-circle')
 
 
 
 
1173
  .style('filter', d => d.status === 'failed' && side === 'naive'
1174
  ? 'drop-shadow(0 0 8px rgba(251,113,133,0.8))' : 'none');
1175
 
1176
+ // Snapshot positions in case of next topology change
1177
+ st.byId = Object.fromEntries(st.nodes.map(n => [n.id, { x: n.x, y: n.y }]));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1178
  }
frontend/index.html CHANGED
@@ -115,7 +115,18 @@
115
  <button class="ma-btn counter" data-action="counter_propose" title="Propose new terms">🔄 Counter</button>
116
  <button class="ma-btn wait" data-action="do_nothing" title="Do nothing this step">⏳ Wait</button>
117
  </div>
118
- <button class="autoplay-btn" id="btn-auto">▶ Auto-Play Agent</button>
 
 
 
 
 
 
 
 
 
 
 
119
  </div>
120
  </section>
121
 
 
115
  <button class="ma-btn counter" data-action="counter_propose" title="Propose new terms">🔄 Counter</button>
116
  <button class="ma-btn wait" data-action="do_nothing" title="Do nothing this step">⏳ Wait</button>
117
  </div>
118
+ <div class="autoplay-cluster">
119
+ <button class="autoplay-btn" id="btn-auto">▶ Auto-Play Agent</button>
120
+ <div class="autoplay-meta">
121
+ <select id="autoplay-speed" class="speed-select" title="Auto-play speed">
122
+ <option value="2200">🐢 Slow</option>
123
+ <option value="1400" selected>⚡ Normal</option>
124
+ <option value="700">🚀 Fast</option>
125
+ <option value="250">🔥 Turbo</option>
126
+ </select>
127
+ <span class="autoplay-status" id="autoplay-status"></span>
128
+ </div>
129
+ </div>
130
  </div>
131
  </section>
132
 
frontend/style.css CHANGED
@@ -340,6 +340,48 @@ html, body {
340
  .ma-btn.wait:hover { border-color: var(--s-at-risk); color: var(--s-at-risk); }
341
  .ma-btn:disabled { opacity: 0.3; cursor: not-allowed; }
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  .autoplay-btn {
344
  width: 100%; font-size: 12px; font-weight: 600; padding: 8px;
345
  border: none; border-radius: var(--r-sm); cursor: pointer;
@@ -423,11 +465,16 @@ html, body {
423
  /* D3 Node styles */
424
  .node { cursor: pointer; }
425
  .node .node-bg {
426
- transition: r 200ms;
427
  }
428
  .node .node-ring {
429
  fill: none; stroke-width: 2;
430
- transition: stroke 300ms;
 
 
 
 
 
431
  }
432
  .node .node-letter {
433
  font-family: var(--font); font-size: 13px; font-weight: 800;
 
340
  .ma-btn.wait:hover { border-color: var(--s-at-risk); color: var(--s-at-risk); }
341
  .ma-btn:disabled { opacity: 0.3; cursor: not-allowed; }
342
 
343
+ .autoplay-cluster {
344
+ display: flex; flex-direction: column; gap: 6px;
345
+ }
346
+ .autoplay-meta {
347
+ display: flex; align-items: center; gap: 10px; justify-content: space-between;
348
+ font-size: 11px; color: var(--t3);
349
+ }
350
+ .speed-select {
351
+ font-family: var(--font); font-size: 11px; padding: 4px 8px;
352
+ background: var(--bg-card); color: var(--t2);
353
+ border: 1px solid var(--border); border-radius: var(--r-sm);
354
+ cursor: pointer; outline: none;
355
+ }
356
+ .speed-select:focus { border-color: var(--border-hi); }
357
+ .autoplay-status {
358
+ flex: 1; font-family: var(--mono); font-size: 10px;
359
+ text-align: right; color: var(--t3);
360
+ letter-spacing: 0.04em;
361
+ }
362
+ .autoplay-status.thinking::before {
363
+ content: '●'; color: var(--brand2); margin-right: 4px;
364
+ animation: thinkPulse 1s ease-in-out infinite;
365
+ }
366
+ @keyframes thinkPulse {
367
+ 0%,100% { opacity: 0.3; }
368
+ 50% { opacity: 1; }
369
+ }
370
+
371
+ /* Brief flash highlight on the node a decision targeted */
372
+ .node.flash-success .node-ring { animation: flashSuccess 0.7s ease; }
373
+ .node.flash-fail .node-ring { animation: flashFail 0.7s ease; }
374
+ @keyframes flashSuccess {
375
+ 0% { stroke-width: 2; filter: drop-shadow(0 0 0 rgba(52,211,153,0)); }
376
+ 40% { stroke-width: 5; filter: drop-shadow(0 0 12px rgba(52,211,153,0.9)); }
377
+ 100% { stroke-width: 2; filter: drop-shadow(0 0 0 rgba(52,211,153,0)); }
378
+ }
379
+ @keyframes flashFail {
380
+ 0% { stroke-width: 2; filter: drop-shadow(0 0 0 rgba(251,113,133,0)); }
381
+ 40% { stroke-width: 5; filter: drop-shadow(0 0 12px rgba(251,113,133,0.9)); }
382
+ 100% { stroke-width: 2; filter: drop-shadow(0 0 0 rgba(251,113,133,0)); }
383
+ }
384
+
385
  .autoplay-btn {
386
  width: 100%; font-size: 12px; font-weight: 600; padding: 8px;
387
  border: none; border-radius: var(--r-sm); cursor: pointer;
 
465
  /* D3 Node styles */
466
  .node { cursor: pointer; }
467
  .node .node-bg {
468
+ transition: r 380ms cubic-bezier(.4,0,.2,1), fill 380ms cubic-bezier(.4,0,.2,1);
469
  }
470
  .node .node-ring {
471
  fill: none; stroke-width: 2;
472
+ transition: stroke 380ms cubic-bezier(.4,0,.2,1), stroke-width 200ms;
473
+ }
474
+ .node .node-letter,
475
+ .node .node-label,
476
+ .node .node-deadline {
477
+ transition: fill 380ms cubic-bezier(.4,0,.2,1);
478
  }
479
  .node .node-letter {
480
  font-family: var(--font); font-size: 13px; font-weight: 800;
vergil/api/server.py CHANGED
@@ -416,13 +416,19 @@ async def compare_agents(request: CompareRequest):
416
  raise HTTPException(status_code=404, detail=f"Scenario not found: {path.name}")
417
 
418
  def _run_agent(agent_fn, label: str) -> dict:
419
- """Run one agent for n_steps and collect trajectory."""
 
 
 
 
 
420
  sim_env = VERGILEnv(seed=99)
421
  sim_pomdp = POMDPWrapper(sim_env)
422
  state, belief, _ = sim_pomdp.reset(scenario=_copy.deepcopy(scenario))
423
 
424
- trajectory = []
425
  total_reward = 0.0
 
426
 
427
  for step in range(request.n_steps):
428
  action, reasoning = agent_fn(state, sim_env)
@@ -431,13 +437,24 @@ async def compare_agents(request: CompareRequest):
431
  except Exception:
432
  break
433
 
434
- trajectory.append({
 
 
 
 
 
435
  'step': step + 1,
436
  'action': action.action_type.value,
437
  'target': action.target_node_id,
438
  'reward': round(reward, 4),
439
  'reasoning': reasoning,
440
- 'state': _state_to_api_minimal(new_state),
 
 
 
 
 
 
441
  })
442
  total_reward += reward
443
  state = new_state
@@ -445,17 +462,28 @@ async def compare_agents(request: CompareRequest):
445
  break
446
 
447
  final = state
 
 
 
 
 
 
 
448
  return {
449
  'label': label,
450
- 'trajectory': trajectory,
451
- 'total_reward': round(total_reward, 4),
452
- 'final_satisfiability': round(final.satisfiability_score, 3),
 
 
 
 
 
 
453
  'final_trust': {
454
  sid: round(te.trust_score, 3)
455
  for sid, te in final.trust_entries.items()
456
  },
457
- 'n_completed': sum(1 for n in final.cdg_nodes if n.status == CommitmentStatus.COMPLETED),
458
- 'n_failed': sum(1 for n in final.cdg_nodes if n.status == CommitmentStatus.FAILED),
459
  'final_graph': _state_to_api(final)['graph'],
460
  }
461
 
@@ -471,15 +499,18 @@ async def compare_agents(request: CompareRequest):
471
  vergil_agent = _heuristic_decide
472
  vergil_result = _run_agent(vergil_agent, vergil_label)
473
 
 
 
474
  return {
475
  "scenario_id": scenario.get('scenario_id', 'unknown'),
476
  "llm_loaded": _llm_model is not None,
477
  "naive": naive_result,
478
  "vergil": vergil_result,
479
  "comparison": {
480
- "reward_delta": round(vergil_result['total_reward'] - naive_result['total_reward'], 4),
481
- "sat_delta": round(vergil_result['final_satisfiability'] - naive_result['final_satisfiability'], 3),
482
- "failure_reduction": naive_result['n_failed'] - vergil_result['n_failed'],
 
483
  }
484
  }
485
 
 
416
  raise HTTPException(status_code=404, detail=f"Scenario not found: {path.name}")
417
 
418
  def _run_agent(agent_fn, label: str) -> dict:
419
+ """
420
+ Run one agent for n_steps and return BOTH a step list and a summary
421
+ metrics dict, plus per-step graph snapshots so the frontend can
422
+ scrub through the trajectory and watch the CDG evolve. The shape
423
+ below is what the Compare overlay's renderer expects.
424
+ """
425
  sim_env = VERGILEnv(seed=99)
426
  sim_pomdp = POMDPWrapper(sim_env)
427
  state, belief, _ = sim_pomdp.reset(scenario=_copy.deepcopy(scenario))
428
 
429
+ steps: List[Dict] = []
430
  total_reward = 0.0
431
+ prev_failed_count = 0
432
 
433
  for step in range(request.n_steps):
434
  action, reasoning = agent_fn(state, sim_env)
 
437
  except Exception:
438
  break
439
 
440
+ now_failed = sum(1 for n in new_state.cdg_nodes
441
+ if n.status == CommitmentStatus.FAILED)
442
+ caused_failure = now_failed > prev_failed_count
443
+ prev_failed_count = now_failed
444
+
445
+ steps.append({
446
  'step': step + 1,
447
  'action': action.action_type.value,
448
  'target': action.target_node_id,
449
  'reward': round(reward, 4),
450
  'reasoning': reasoning,
451
+ 'caused_failure': caused_failure,
452
+ 'graph': _state_to_api_minimal(new_state)['graph'],
453
+ 'satisfiability': round(new_state.satisfiability_score, 3),
454
+ 'trust_avg': round(
455
+ sum(te.trust_score for te in new_state.trust_entries.values())
456
+ / max(1, len(new_state.trust_entries)), 3
457
+ ),
458
  })
459
  total_reward += reward
460
  state = new_state
 
462
  break
463
 
464
  final = state
465
+ avg_trust = (
466
+ sum(te.trust_score for te in final.trust_entries.values())
467
+ / max(1, len(final.trust_entries))
468
+ ) if final.trust_entries else 0.0
469
+ n_completed = sum(1 for n in final.cdg_nodes if n.status == CommitmentStatus.COMPLETED)
470
+ n_failed = sum(1 for n in final.cdg_nodes if n.status == CommitmentStatus.FAILED)
471
+
472
  return {
473
  'label': label,
474
+ 'steps': steps,
475
+ 'metrics': {
476
+ 'total_reward': round(total_reward, 4),
477
+ 'final_sat': round(final.satisfiability_score, 3),
478
+ 'avg_trust': round(avg_trust, 3),
479
+ 'n_completed': n_completed,
480
+ 'n_failed': n_failed,
481
+ 'n_steps_taken': len(steps),
482
+ },
483
  'final_trust': {
484
  sid: round(te.trust_score, 3)
485
  for sid, te in final.trust_entries.items()
486
  },
 
 
487
  'final_graph': _state_to_api(final)['graph'],
488
  }
489
 
 
499
  vergil_agent = _heuristic_decide
500
  vergil_result = _run_agent(vergil_agent, vergil_label)
501
 
502
+ nm = naive_result['metrics']
503
+ vm = vergil_result['metrics']
504
  return {
505
  "scenario_id": scenario.get('scenario_id', 'unknown'),
506
  "llm_loaded": _llm_model is not None,
507
  "naive": naive_result,
508
  "vergil": vergil_result,
509
  "comparison": {
510
+ "reward_delta": round(vm['total_reward'] - nm['total_reward'], 4),
511
+ "sat_delta": round(vm['final_sat'] - nm['final_sat'], 3),
512
+ "failure_reduction": nm['n_failed'] - vm['n_failed'],
513
+ "trust_delta": round(vm['avg_trust'] - nm['avg_trust'], 3),
514
  }
515
  }
516