1use std::path::{Path, PathBuf};
21
22use crate::report::RunRecord;
23
24pub fn generate_report(results_dir: &Path) -> anyhow::Result<PathBuf> {
28 std::fs::create_dir_all(results_dir)
29 .map_err(|e| anyhow::anyhow!("creating results dir {}: {e}", results_dir.display()))?;
30
31 let records = load_records(results_dir)?;
32 let json = serde_json::to_string(&records)
33 .map_err(|e| anyhow::anyhow!("serializing run records: {e}"))?;
34
35 let html = render_page(&json, records.len());
36 let out = results_dir.join("report.html");
37 std::fs::write(&out, html).map_err(|e| anyhow::anyhow!("writing {}: {e}", out.display()))?;
38 Ok(out)
39}
40
41fn load_records(dir: &Path) -> anyhow::Result<Vec<RunRecord>> {
44 let mut records = Vec::new();
45 let entries = match std::fs::read_dir(dir) {
46 Ok(e) => e,
47 Err(_) => return Ok(records),
49 };
50 for entry in entries.flatten() {
51 let path = entry.path();
52 let is_json = path
53 .extension()
54 .and_then(|e| e.to_str())
55 .is_some_and(|e| e.eq_ignore_ascii_case("json"));
56 if !is_json {
57 continue;
58 }
59 match std::fs::read_to_string(&path) {
60 Ok(text) => match serde_json::from_str::<RunRecord>(&text) {
61 Ok(rec) => records.push(rec),
62 Err(e) => tracing::warn!("skipping {} (not a RunRecord: {e})", path.display()),
63 },
64 Err(e) => tracing::warn!("skipping {} (read error: {e})", path.display()),
65 }
66 }
67 records.sort_by(|a, b| b.timestamp_file.cmp(&a.timestamp_file));
70 Ok(records)
71}
72
73fn escape_for_script(json: &str) -> String {
84 json.replace('\\', "\\\\")
85 .replace('"', "\\\"")
86 .replace('\n', "\\n")
87 .replace('\r', "\\r")
88 .replace('<', "\\u003c")
89 .replace('>', "\\u003e")
90 .replace('&', "\\u0026")
91}
92
93fn render_page(json_blob: &str, count: usize) -> String {
99 let data = escape_for_script(json_blob);
100 format!(
101 r#"<!doctype html>
102<html lang="en">
103<head>
104<meta charset="utf-8">
105<meta name="viewport" content="width=device-width, initial-scale=1">
106<title>EvalCore AI eval report</title>
107<style>{css}</style>
108</head>
109<body>
110<header>
111 <h1>EvalCore AI eval report</h1>
112 <p class="meta">{count} run(s) embedded. Generated offline; this file is fully self-contained.</p>
113</header>
114
115<div id="controls" class="controls">
116 <label class="ctl">Filter
117 <input id="filter" type="search" placeholder="model name substring…" autocomplete="off">
118 </label>
119 <span class="ctl-group" id="view-toggle" role="radiogroup" aria-label="View">
120 <button class="view-btn active" data-view="latest">Latest per model</button>
121 <button class="view-btn" data-view="best">Best per model</button>
122 <button class="view-btn" data-view="all">All runs</button>
123 </span>
124 <label class="ctl">Sort
125 <select id="sort-select"></select>
126 </label>
127 <span id="count-badge" class="badge"></span>
128</div>
129
130<main>
131 <section id="leaderboard-section">
132 <h2>Leaderboard <span class="sub">(ranking by the active view; click a row for run history)</span></h2>
133 <div class="scroll-x"><table id="leaderboard"></table></div>
134 </section>
135
136 <section id="heatmap-section">
137 <h2>Model × case heatmap <span class="sub">(green = pass, red = fail; hover a cell for latency/tokens/error)</span></h2>
138 <div class="scroll-both"><table id="case-heatmap" class="heatmap"></table></div>
139 </section>
140
141 <section id="raw-section">
142 <details id="raw-runs">
143 <summary>Raw runs & metadata (<span id="raw-count">0</span>)</summary>
144 <div class="scroll-x"><table id="raw-table"></table></div>
145 </details>
146 </section>
147</main>
148<script>
149const RECORDS = JSON.parse("{data}");
150{js}
151</script>
152</body>
153</html>
154"#,
155 css = CSS,
156 count = count,
157 data = data,
158 js = JS,
159 )
160}
161
162const CSS: &str = r#"
165:root {
166 --bg:#0f1115; --panel:#171a21; --fg:#e6e8ec; --muted:#9aa3b2; --line:#2a2f3a; --accent:#4f8cff;
167 --pass:#1b8a3a; --fail:#c0392b;
168}
169* { box-sizing: border-box; }
170body { margin:0; font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--fg); }
171header { padding:16px 24px 10px; }
172h1 { margin:0; font-size:20px; }
173h2 { font-size:16px; margin:0 0 8px; }
174.sub { color:var(--muted); font-weight:400; font-size:12px; }
175.meta, .hint { color:var(--muted); font-size:12px; margin:4px 0; }
176main { padding:8px 24px 48px; display:grid; gap:24px; max-width:1600px; }
177section { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; }
178
179/* sticky controls bar */
180.controls {
181 position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; align-items:center; gap:14px;
182 padding:10px 24px; background:rgba(15,17,21,0.96); border-bottom:1px solid var(--line);
183 backdrop-filter:blur(4px);
184}
185.ctl { display:flex; align-items:center; gap:6px; color:var(--muted); font-size:12px; }
186.ctl input, .ctl select {
187 background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:6px;
188 padding:5px 8px; font:inherit;
189}
190.ctl input { min-width:220px; }
191.ctl-group { display:inline-flex; border:1px solid var(--line); border-radius:6px; overflow:hidden; }
192.view-btn {
193 background:#0c0e12; color:var(--muted); border:0; border-right:1px solid var(--line);
194 padding:6px 12px; font:inherit; cursor:pointer;
195}
196.view-btn:last-child { border-right:0; }
197.view-btn:hover { color:var(--fg); }
198.view-btn.active { background:var(--accent); color:#fff; }
199.badge { margin-left:auto; color:var(--muted); font-size:12px; }
200
201/* generic tables */
202table { border-collapse:collapse; width:100%; }
203th, td { padding:6px 10px; border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
204th:first-child, td:first-child { text-align:left; }
205thead th { color:var(--muted); font-weight:600; position:sticky; top:0; background:var(--panel); z-index:2; }
206.scroll-x { overflow-x:auto; }
207.scroll-both { overflow:auto; max-height:75vh; }
208
209/* leaderboard */
210#leaderboard th { cursor:pointer; user-select:none; }
211#leaderboard th:hover { color:var(--fg); }
212#leaderboard tbody tr { cursor:pointer; }
213#leaderboard tbody tr:hover td { background:rgba(79,140,255,0.08); }
214.rank { color:var(--muted); }
215.model-cell { font-weight:600; }
216.acc-bar { position:relative; height:18px; width:160px; background:#0c0e12; border:1px solid var(--line); border-radius:4px; overflow:hidden; margin-left:auto; }
217.acc-fill { position:absolute; left:0; top:0; bottom:0; }
218.acc-text { position:relative; z-index:1; display:block; text-align:center; font-size:12px; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,0.65); line-height:18px; }
219.heat { color:#0b0d10; font-weight:600; }
220td.expander { padding:0; }
221.detail-host { background:#0c0e12; }
222.detail-host table { width:auto; min-width:60%; }
223.detail-host th, .detail-host td { border-bottom:1px solid var(--line); font-size:12px; }
224.detail-spark { display:flex; align-items:center; gap:12px; padding:8px 10px; flex-wrap:wrap; }
225.detail-spark .legend { color:var(--muted); font-size:11px; }
226
227/* heatmap */
228.heatmap th, .heatmap td { border:1px solid var(--line); padding:0; }
229.heatmap thead th { z-index:5; background:var(--panel); }
230.heatmap th.corner { position:sticky; left:0; top:0; z-index:6; }
231.heatmap th.case-head { height:120px; vertical-align:bottom; padding:4px; }
232.heatmap th.case-head > div { writing-mode:vertical-rl; transform:rotate(180deg); white-space:nowrap; font-weight:600; color:var(--muted); font-size:11px; margin:0 auto; }
233.heatmap th.right-head { writing-mode:initial; }
234.heatmap th.model-head, .heatmap td.model-head {
235 position:sticky; left:0; z-index:3; background:var(--panel); text-align:left; padding:4px 10px; font-weight:600; min-width:160px; max-width:240px; overflow:hidden; text-overflow:ellipsis;
236}
237.heatmap td.cell { width:22px; height:22px; text-align:center; font-size:11px; }
238.cell-pass { background:var(--pass); color:#fff; }
239.cell-fail { background:var(--fail); color:#fff; }
240.cell-na { background:#1d2129; color:var(--muted); }
241.heatmap td.acc-col, .heatmap th.acc-head { text-align:center; font-weight:600; color:#0b0d10; }
242.heatmap tfoot td { font-weight:600; color:#0b0d10; text-align:center; }
243.heatmap tfoot td.model-head { background:var(--panel); color:var(--fg); }
244
245.empty { color:var(--muted); padding:12px; }
246svg { display:block; }
247.legend { color:var(--muted); font-size:11px; }
248
249/* transcript expander (raw runs) */
250.transcript-sys, .transcript-case { margin:8px 10px; }
251.transcript-sys > summary, .transcript-case > summary { cursor:pointer; color:var(--fg); font-weight:600; padding:4px 0; }
252.transcript-case > summary.tc-pass { color:#5fd07a; }
253.transcript-case > summary.tc-fail { color:#e07a6e; }
254.msg { border-left:3px solid var(--line); margin:6px 0 6px 12px; padding:4px 0 4px 10px; }
255.msg-role { display:inline-block; font-size:10px; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); font-weight:700; }
256.msg-user { border-left-color:#4f8cff; }
257.msg-assistant { border-left-color:#9b6cff; }
258.msg-tool { border-left-color:#2a9d8f; }
259.msg-content { white-space:pre-wrap; word-break:break-word; margin:4px 0 0; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:var(--fg); }
260.msg-tool-call { white-space:pre-wrap; word-break:break-word; margin:4px 0 0; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#c9a35f; }
261.msg-meta { display:block; color:var(--muted); font-size:10px; margin-top:2px; }
262"#;
263
264const JS: &str = r##"
267// ---- DOM helper ----------------------------------------------------------
268function el(tag, attrs, children) {
269 const e = document.createElement(tag);
270 if (attrs) for (const k in attrs) {
271 if (k === 'class') e.className = attrs[k];
272 else if (k === 'title') e.title = attrs[k];
273 else if (k === 'html') e.innerHTML = attrs[k];
274 else if (k === 'style') e.style.cssText = attrs[k];
275 else e.setAttribute(k, attrs[k]);
276 }
277 if (children != null) for (const c of [].concat(children)) {
278 if (c == null) continue;
279 e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
280 }
281 return e;
282}
283
284// ---- formatting ----------------------------------------------------------
285function durMs(d) {
286 // Serialized std::time::Duration is { secs, nanos }; tolerate a bare number too.
287 if (d == null) return null;
288 if (typeof d === 'number') return d * 1000;
289 return (d.secs || 0) * 1000 + (d.nanos || 0) / 1e6;
290}
291function fmt(n, digits) { return (n == null || isNaN(n)) ? '—' : Number(n).toFixed(digits == null ? 1 : digits); }
292function pct(n) { return (n == null || isNaN(n)) ? '—' : (n * 100).toFixed(0) + '%'; }
293
294// ---- color scales (red -> amber -> green), text stays legible -------------
295// Accuracy: 0 = red, 0.5 = amber, 1 = green.
296function accColor(t) {
297 t = Math.max(0, Math.min(1, t));
298 const r = t < 0.5 ? 210 : Math.round(210 - (t - 0.5) * 2 * 170);
299 const g = t < 0.5 ? Math.round(80 + t * 2 * 110) : 190;
300 return 'rgb(' + r + ',' + g + ',60)';
301}
302// Metric heat where LOW is good (latency, tokens): norm in [0,1], 0 = green, 1 = red.
303function heatLowGood(norm) {
304 norm = Math.max(0, Math.min(1, isNaN(norm) ? 0 : norm));
305 const r = norm < 0.5 ? Math.round(60 + norm * 2 * 150) : 210;
306 const g = norm < 0.5 ? 190 : Math.round(190 - (norm - 0.5) * 2 * 130);
307 return 'rgb(' + r + ',' + g + ',60)';
308}
309
310// ---- per-run aggregate metrics (all derived client-side from outcomes) ----
311function metrics(rec) {
312 const r = rec.report || {};
313 const outs = r.outcomes || [];
314 const total = outs.length;
315 const passed = outs.filter(o => o.passed).length;
316 const acc = total ? passed / total : 0;
317 const lats = outs.map(o => durMs(o.latency)).filter(x => x != null).sort((a, b) => a - b);
318 const mean = lats.length ? lats.reduce((a, b) => a + b, 0) / lats.length : null;
319 // Nearest-rank quantile, matching the Rust side.
320 const pq = (p) => { if (!lats.length) return null; const rank = Math.max(1, Math.ceil(p * lats.length)); return lats[Math.min(rank, lats.length) - 1]; };
321 const toks = outs.map(o => o.tokens).filter(x => x != null);
322 const meanTok = toks.length ? toks.reduce((a, b) => a + b, 0) / toks.length : null;
323 return {
324 model: rec.model, when: rec.timestamp_display, whenKey: rec.timestamp_file,
325 backend: rec.backend, casesDir: rec.cases_dir,
326 // The run-level system prompt (the record carries it; fall back to the nested report copy).
327 systemPrompt: rec.system_prompt || r.system_prompt || '',
328 total, passed, acc, mean, p50: pq(0.5), p95: pq(0.95), meanTok, outs,
329 };
330}
331
332const RUNS = RECORDS.map(metrics);
333const MODELS = Array.from(new Set(RUNS.map(r => r.model)));
334
335// ---- view selection: latest | best | all ---------------------------------
336// Grouping key = model. latest = max timestamp_file; best = max accuracy.
337function rowsForView(view, filter) {
338 const f = (filter || '').trim().toLowerCase();
339 let runs = RUNS;
340 if (f) runs = runs.filter(r => r.model.toLowerCase().includes(f));
341 if (view === 'all') return runs.slice();
342 const by = {};
343 for (const r of runs) {
344 const cur = by[r.model];
345 if (!cur) { by[r.model] = r; continue; }
346 if (view === 'best') {
347 // highest accuracy, tie -> lowest p50, tie -> latest.
348 if (r.acc > cur.acc ||
349 (r.acc === cur.acc && (r.p50 || 1e9) < (cur.p50 || 1e9)) ||
350 (r.acc === cur.acc && r.p50 === cur.p50 && r.whenKey > cur.whenKey)) by[r.model] = r;
351 } else { // latest
352 if (r.whenKey > cur.whenKey) by[r.model] = r;
353 }
354 }
355 return Object.values(by);
356}
357// runsPerModel honors the current filter so the "#runs" column matches what's shown.
358function runsPerModel(filter) {
359 const f = (filter || '').trim().toLowerCase();
360 const counts = {};
361 for (const r of RUNS) {
362 if (f && !r.model.toLowerCase().includes(f)) continue;
363 counts[r.model] = (counts[r.model] || 0) + 1;
364 }
365 return counts;
366}
367function lastRunDate(model) {
368 let best = null;
369 for (const r of RUNS) if (r.model === model && (best == null || r.whenKey > best.whenKey)) best = r;
370 return best;
371}
372
373// ---- shared UI state -----------------------------------------------------
374const STATE = { view: 'latest', filter: '', sortKey: 'acc', sortDir: -1 };
375
376// Leaderboard columns. `num` => numeric sort; `get` => sort value.
377const LB_COLS = [
378 { key: 'rank', label: '#', num: true, get: r => r._rank, sortable: false },
379 { key: 'model', label: 'Model', num: false, get: r => r.model },
380 { key: 'backend', label: 'Backend', num: false, get: r => r.backend || '' },
381 { key: 'acc', label: 'Accuracy', num: true, get: r => r.acc },
382 { key: 'pt', label: 'Pass/Total', num: true, get: r => r.passed },
383 { key: 'p50', label: 'p50 ms', num: true, get: r => r.p50 == null ? Infinity : r.p50 },
384 { key: 'p95', label: 'p95 ms', num: true, get: r => r.p95 == null ? Infinity : r.p95 },
385 { key: 'tok', label: 'Mean tokens', num: true, get: r => r.meanTok == null ? Infinity : r.meanTok },
386 { key: 'runs', label: '#runs', num: true, get: r => r._runs },
387 { key: 'last', label: 'Last run', num: false, get: r => r._lastKey || '' },
388];
389
390function sortRows(rows) {
391 const col = LB_COLS.find(c => c.key === STATE.sortKey) || LB_COLS[3];
392 return rows.slice().sort((a, b) => {
393 const av = col.get(a), bv = col.get(b);
394 let c;
395 if (col.num) c = (av === Infinity ? 1e18 : (av || 0)) - (bv === Infinity ? 1e18 : (bv || 0));
396 else c = String(av).localeCompare(String(bv));
397 return c * STATE.sortDir;
398 });
399}
400
401// ---- Leaderboard ---------------------------------------------------------
402function renderLeaderboard() {
403 const t = document.getElementById('leaderboard');
404 t.innerHTML = '';
405 let rows = rowsForView(STATE.view, STATE.filter);
406 const perModel = runsPerModel(STATE.filter);
407 rows.forEach(r => {
408 r._runs = perModel[r.model] || 1;
409 const lr = lastRunDate(r.model);
410 r._lastKey = lr ? lr.whenKey : r.whenKey;
411 r._lastDisp = lr ? lr.when : r.when;
412 });
413
414 document.getElementById('count-badge').textContent =
415 rows.length + (STATE.view === 'all' ? ' run(s)' : ' model(s)') + ' / ' + MODELS.length + ' total';
416
417 if (!rows.length) {
418 t.appendChild(el('caption', { class: 'empty' }, RUNS.length ? 'No models match the filter.' : 'No runs yet.'));
419 return;
420 }
421
422 rows = sortRows(rows);
423 rows.forEach((r, i) => { r._rank = i + 1; });
424
425 // heat ranges across the VISIBLE set
426 const p50s = rows.map(r => r.p50).filter(x => x != null);
427 const p95s = rows.map(r => r.p95).filter(x => x != null);
428 const toks = rows.map(r => r.meanTok).filter(x => x != null);
429 const range = (arr) => { if (!arr.length) return null; const lo = Math.min(...arr), hi = Math.max(...arr); return { lo, hi, span: (hi - lo) || 1 }; };
430 const rP50 = range(p50s), rP95 = range(p95s), rTok = range(toks);
431 const norm = (v, rg) => rg == null || v == null ? NaN : (v - rg.lo) / rg.span;
432
433 const thead = el('thead');
434 const htr = el('tr');
435 for (const c of LB_COLS) {
436 const arrow = (c.sortable !== false && c.key === STATE.sortKey) ? (STATE.sortDir < 0 ? ' ▼' : ' ▲') : '';
437 const th = el('th', c.sortable === false ? null : { title: 'Sort by ' + c.label }, c.label + arrow);
438 if (c.sortable !== false) th.onclick = () => {
439 if (STATE.sortKey === c.key) STATE.sortDir = -STATE.sortDir;
440 else { STATE.sortKey = c.key; STATE.sortDir = c.num ? -1 : 1; }
441 syncSortSelect(); renderLeaderboard();
442 };
443 htr.appendChild(th);
444 }
445 thead.appendChild(htr); t.appendChild(thead);
446
447 const tb = el('tbody');
448 for (const r of rows) {
449 const tr = el('tr');
450 tr.appendChild(el('td', { class: 'rank' }, String(r._rank)));
451 tr.appendChild(el('td', { class: 'model-cell' }, r.model));
452 tr.appendChild(el('td', null, r.backend || '—'));
453
454 // accuracy bar
455 const fill = el('span', { class: 'acc-fill', style: 'width:' + (r.acc * 100) + '%;background:' + accColor(r.acc) });
456 const bar = el('div', { class: 'acc-bar' }, [fill, el('span', { class: 'acc-text' }, pct(r.acc))]);
457 tr.appendChild(el('td', null, bar));
458
459 tr.appendChild(el('td', null, r.passed + '/' + r.total));
460
461 const tdP50 = el('td', { class: 'heat' }, fmt(r.p50));
462 if (r.p50 != null) tdP50.style.background = heatLowGood(norm(r.p50, rP50));
463 tr.appendChild(tdP50);
464 const tdP95 = el('td', { class: 'heat' }, fmt(r.p95));
465 if (r.p95 != null) tdP95.style.background = heatLowGood(norm(r.p95, rP95));
466 tr.appendChild(tdP95);
467 const tdTok = el('td', { class: 'heat' }, fmt(r.meanTok, 0));
468 if (r.meanTok != null) tdTok.style.background = heatLowGood(norm(r.meanTok, rTok));
469 tr.appendChild(tdTok);
470
471 tr.appendChild(el('td', null, String(r._runs)));
472 tr.appendChild(el('td', null, (r._lastDisp || '—').split(' ')[0]));
473
474 // expandable run-history detail row
475 const detailTr = el('tr', { class: 'detail-row', style: 'display:none' });
476 const detailTd = el('td', { class: 'expander', colspan: String(LB_COLS.length) });
477 detailTr.appendChild(detailTd);
478 let built = false;
479 tr.onclick = () => {
480 if (!built) { detailTd.appendChild(modelDetail(r.model)); built = true; }
481 detailTr.style.display = detailTr.style.display === 'none' ? '' : 'none';
482 };
483
484 tb.appendChild(tr);
485 tb.appendChild(detailTr);
486 }
487 t.appendChild(tb);
488}
489
490// Per-model run history: a compact sub-table + accuracy/p50 sparklines over time.
491function modelDetail(model) {
492 const host = el('div', { class: 'detail-host' });
493 const runs = RUNS.filter(r => r.model === model).sort((a, b) => a.whenKey.localeCompare(b.whenKey));
494 if (runs.length > 1) {
495 const sp = el('div', { class: 'detail-spark' });
496 sp.appendChild(el('span', { class: 'legend' }, 'accuracy'));
497 sp.appendChild(spark(runs, r => r.acc * 100, '#39c46a', v => v.toFixed(0) + '%'));
498 sp.appendChild(el('span', { class: 'legend' }, 'p50 latency'));
499 sp.appendChild(spark(runs, r => r.p50 || 0, '#7aa9ff', v => v.toFixed(0) + 'ms'));
500 host.appendChild(sp);
501 }
502 const tbl = el('table');
503 const head = el('tr');
504 ['When', 'Accuracy', 'Pass/Total', 'p50 ms', 'Mean tokens', 'Backend'].forEach(h => head.appendChild(el('th', null, h)));
505 tbl.appendChild(el('thead', null, head));
506 const body = el('tbody');
507 for (const r of runs.slice().reverse()) {
508 const tr = el('tr');
509 tr.appendChild(el('td', null, r.when || r.whenKey));
510 tr.appendChild(el('td', null, pct(r.acc)));
511 tr.appendChild(el('td', null, r.passed + '/' + r.total));
512 tr.appendChild(el('td', null, fmt(r.p50)));
513 tr.appendChild(el('td', null, fmt(r.meanTok, 0)));
514 tr.appendChild(el('td', null, r.backend || '—'));
515 body.appendChild(tr);
516 }
517 tbl.appendChild(body);
518 host.appendChild(tbl);
519 return host;
520}
521
522// ---- Model × case heatmap ------------------------------------------------
523function renderHeatmap() {
524 const t = document.getElementById('case-heatmap');
525 t.innerHTML = '';
526 const rows = rowsForView(STATE.view, STATE.filter);
527 if (!rows.length) {
528 t.appendChild(el('caption', { class: 'empty' }, RUNS.length ? 'No models match the filter.' : 'No runs yet.'));
529 return;
530 }
531 // Stable model-row order: by accuracy desc, then name.
532 const modelRows = rows.slice().sort((a, b) => (b.acc - a.acc) || a.model.localeCompare(b.model));
533
534 // Column set = union of case names across the VISIBLE rows, sorted stably (alpha).
535 const nameSet = new Set();
536 for (const r of modelRows) for (const o of r.outs) nameSet.add(o.name);
537 const names = Array.from(nameSet).sort();
538
539 // index each visible row's outcomes by case name
540 for (const r of modelRows) { r._byName = {}; for (const o of r.outs) r._byName[o.name] = o; }
541
542 // header: corner + rotated case names + per-model accuracy header
543 const thead = el('thead');
544 const htr = el('tr');
545 htr.appendChild(el('th', { class: 'corner' }, 'Model'));
546 for (const name of names) htr.appendChild(el('th', { class: 'case-head', title: name }, el('div', null, name)));
547 htr.appendChild(el('th', { class: 'right-head acc-head' }, 'Accuracy'));
548 thead.appendChild(htr); t.appendChild(thead);
549
550 const tb = el('tbody');
551 for (const r of modelRows) {
552 const tr = el('tr');
553 tr.appendChild(el('td', { class: 'model-head', title: r.model + (STATE.view === 'all' ? ' @' + r.whenKey : '') }, r.model));
554 for (const name of names) {
555 const o = r._byName[name];
556 if (!o) { tr.appendChild(el('td', { class: 'cell cell-na', title: name + ': not run' }, '·')); continue; }
557 const ms = durMs(o.latency);
558 const tip = name + '\n' + (o.passed ? 'PASS' : 'FAIL') + ' · ' + fmt(ms) + 'ms' +
559 (o.tokens != null ? ' · ' + o.tokens + ' tok' : '') + (o.error ? '\n' + o.error : '');
560 tr.appendChild(el('td', { class: 'cell ' + (o.passed ? 'cell-pass' : 'cell-fail'), title: tip }, o.passed ? '✓' : '✗'));
561 }
562 const accTd = el('td', { class: 'acc-col', title: r.passed + '/' + r.total }, pct(r.acc));
563 accTd.style.background = accColor(r.acc);
564 tr.appendChild(accTd);
565 tb.appendChild(tr);
566 }
567 t.appendChild(tb);
568
569 // footer: per-case pass-rate across the shown models (color-scaled)
570 const tfoot = el('tfoot');
571 const ftr = el('tr');
572 ftr.appendChild(el('td', { class: 'model-head' }, 'Pass rate'));
573 for (const name of names) {
574 let ran = 0, pass = 0;
575 for (const r of modelRows) { const o = r._byName[name]; if (o) { ran++; if (o.passed) pass++; } }
576 const rate = ran ? pass / ran : null;
577 const td = el('td', { title: name + ': ' + pass + '/' + ran + ' passed' }, rate == null ? '—' : pct(rate));
578 if (rate != null) td.style.background = accColor(rate);
579 ftr.appendChild(td);
580 }
581 // overall accuracy across the visible matrix
582 let allRan = 0, allPass = 0;
583 for (const r of modelRows) for (const name of names) { const o = r._byName[name]; if (o) { allRan++; if (o.passed) allPass++; } }
584 const overall = allRan ? allPass / allRan : null;
585 const overTd = el('td', null, overall == null ? '—' : pct(overall));
586 if (overall != null) overTd.style.background = accColor(overall);
587 ftr.appendChild(overTd);
588 tfoot.appendChild(ftr);
589 t.appendChild(tfoot);
590}
591
592// ---- Raw runs / metadata (collapsed) -------------------------------------
593function renderRaw() {
594 document.getElementById('raw-count').textContent = String(RUNS.length);
595 const t = document.getElementById('raw-table');
596 t.innerHTML = '';
597 if (!RUNS.length) { t.appendChild(el('caption', { class: 'empty' }, 'No runs yet.')); return; }
598 const COLS = ['Model', 'Timestamp', 'Backend', 'Cases dir', 'Accuracy'];
599 const head = el('tr');
600 COLS.forEach(h => head.appendChild(el('th', null, h)));
601 t.appendChild(el('thead', null, head));
602 const body = el('tbody');
603 const sorted = RUNS.slice().sort((a, b) => b.whenKey.localeCompare(a.whenKey));
604 for (const r of sorted) {
605 const tr = el('tr');
606 tr.appendChild(el('td', null, r.model));
607 tr.appendChild(el('td', null, r.when || r.whenKey));
608 tr.appendChild(el('td', null, r.backend || '—'));
609 tr.appendChild(el('td', null, r.casesDir || '—'));
610 tr.appendChild(el('td', null, pct(r.acc)));
611
612 // Expand-on-click detail row: the system prompt + per-case transcripts (built lazily, once).
613 const detailTr = el('tr', { class: 'detail-row', style: 'display:none' });
614 const detailTd = el('td', { class: 'expander', colspan: String(COLS.length) });
615 detailTr.appendChild(detailTd);
616 let built = false;
617 tr.style.cursor = 'pointer';
618 tr.onclick = () => {
619 if (!built) { detailTd.appendChild(runDetail(r)); built = true; }
620 detailTr.style.display = detailTr.style.display === 'none' ? '' : 'none';
621 };
622 body.appendChild(tr);
623 body.appendChild(detailTr);
624 }
625 t.appendChild(body);
626}
627
628// Per-run detail: the shared system prompt at top, then one collapsible block per case rendering its
629// transcript as role-tagged message blocks (user / assistant [content + tool_calls] / tool [result]).
630function runDetail(run) {
631 const host = el('div', { class: 'detail-host' });
632
633 // System prompt (shown once for the whole run).
634 const sysWrap = el('details', { class: 'transcript-sys' });
635 sysWrap.appendChild(el('summary', null, 'System prompt'));
636 sysWrap.appendChild(el('pre', { class: 'msg-content' }, run.systemPrompt || '(none recorded)'));
637 host.appendChild(sysWrap);
638
639 const outs = run.outs || [];
640 if (!outs.length) { host.appendChild(el('div', { class: 'empty' }, 'No cases.')); return host; }
641
642 for (const o of outs) {
643 const block = el('details', { class: 'transcript-case' });
644 const status = o.passed ? 'PASS' : 'FAIL';
645 block.appendChild(el('summary', { class: o.passed ? 'tc-pass' : 'tc-fail' },
646 status + ' · ' + o.name + ' (' + ((o.transcript || []).length) + ' msgs)'));
647 const tx = o.transcript || [];
648 if (!tx.length) {
649 block.appendChild(el('div', { class: 'empty' }, 'No transcript recorded for this case.'));
650 } else {
651 for (const m of tx) block.appendChild(messageBlock(m));
652 }
653 host.appendChild(block);
654 }
655 return host;
656}
657
658// Render one transcript message as a role-tagged block. Assistant blocks show both content AND any
659// tool_calls; tool blocks show the result content. Text is escaped by `el()` (textNode children).
660function messageBlock(m) {
661 const role = (m && m.role) || 'unknown';
662 const wrap = el('div', { class: 'msg msg-' + role });
663 wrap.appendChild(el('span', { class: 'msg-role' }, role));
664 if (m && m.content != null && String(m.content).length) {
665 wrap.appendChild(el('pre', { class: 'msg-content' }, String(m.content)));
666 }
667 // Assistant tool calls: one line per call, name + JSON-stringified arguments.
668 const calls = m && m.tool_calls;
669 if (Array.isArray(calls) && calls.length) {
670 for (const c of calls) {
671 const fn = (c && c.function) || {};
672 const line = (fn.name || '(unnamed)') + '(' + (fn.arguments || '') + ')';
673 wrap.appendChild(el('pre', { class: 'msg-tool-call' }, line));
674 }
675 }
676 // Tool reply tool_call_id, when present, for correlation.
677 if (role === 'tool' && m.tool_call_id) {
678 wrap.appendChild(el('span', { class: 'msg-meta' }, '↳ id ' + m.tool_call_id));
679 }
680 return wrap;
681}
682
683// ---- inline SVG sparkline (accuracy / p50 over time) ---------------------
684function spark(points, valueFn, color, fmtFn) {
685 const W = 200, H = 36, pad = 4;
686 const xmlns = 'http://www.w3.org/2000/svg';
687 const svg = document.createElementNS(xmlns, 'svg');
688 svg.setAttribute('width', W); svg.setAttribute('height', H); svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
689 const vals = points.map(valueFn);
690 const min = Math.min(...vals), max = Math.max(...vals);
691 const span = (max - min) || 1;
692 const n = points.length;
693 const xs = i => n <= 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1);
694 const ys = v => H - pad - ((v - min) / span) * (H - 2 * pad);
695 let d = '';
696 points.forEach((p, i) => { d += (i ? ' L ' : 'M ') + xs(i).toFixed(1) + ' ' + ys(vals[i]).toFixed(1); });
697 const path = document.createElementNS(xmlns, 'path');
698 path.setAttribute('d', d); path.setAttribute('fill', 'none'); path.setAttribute('stroke', color); path.setAttribute('stroke-width', '2');
699 svg.appendChild(path);
700 points.forEach((p, i) => {
701 const c = document.createElementNS(xmlns, 'circle');
702 c.setAttribute('cx', xs(i)); c.setAttribute('cy', ys(vals[i])); c.setAttribute('r', '2.5'); c.setAttribute('fill', color);
703 const title = document.createElementNS(xmlns, 'title');
704 title.textContent = (points[i].when || points[i].whenKey) + ': ' + fmtFn(vals[i]);
705 c.appendChild(title); svg.appendChild(c);
706 });
707 return svg;
708}
709
710// ---- controls wiring -----------------------------------------------------
711function syncSortSelect() {
712 const sel = document.getElementById('sort-select');
713 if (!sel) return;
714 sel.value = STATE.sortKey + ':' + (STATE.sortDir < 0 ? 'desc' : 'asc');
715}
716function buildSortSelect() {
717 const sel = document.getElementById('sort-select');
718 sel.innerHTML = '';
719 for (const c of LB_COLS) {
720 if (c.sortable === false) continue;
721 for (const dir of ['desc', 'asc']) {
722 const opt = el('option', { value: c.key + ':' + dir }, c.label + ' ' + (dir === 'desc' ? '▼' : '▲'));
723 sel.appendChild(opt);
724 }
725 }
726 sel.onchange = () => {
727 const [k, d] = sel.value.split(':');
728 STATE.sortKey = k; STATE.sortDir = d === 'desc' ? -1 : 1;
729 renderLeaderboard();
730 };
731 syncSortSelect();
732}
733
734function renderAll() { renderLeaderboard(); renderHeatmap(); }
735
736function wireControls() {
737 const filter = document.getElementById('filter');
738 filter.oninput = () => { STATE.filter = filter.value; renderAll(); };
739 for (const btn of document.querySelectorAll('.view-btn')) {
740 btn.onclick = () => {
741 for (const b of document.querySelectorAll('.view-btn')) b.classList.remove('active');
742 btn.classList.add('active');
743 STATE.view = btn.getAttribute('data-view');
744 renderAll();
745 };
746 }
747 buildSortSelect();
748}
749
750wireControls();
751renderAll();
752renderRaw();
753"##;
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use crate::report::{CaseOutcome, EvalReport, RunRecord};
759 use std::time::Duration;
760
761 fn sample(model: &str, ts_file: &str, ts_display: &str) -> RunRecord {
763 use serde_json::json;
764 let outcomes = vec![
765 CaseOutcome {
766 name: "place_block".into(),
767 passed: true,
768 predicates: vec![("solid_placed>=1".into(), true)],
769 latency: Duration::from_millis(120),
770 tokens: Some(42),
771 tool_calls: vec!["place(...)".into()],
772 final_text: Some("done".into()),
773 error: None,
774 transcript: vec![
776 json!({"role": "user", "content": "place a block here"}),
777 json!({"role": "assistant", "content": "", "tool_calls": [
778 {"id": "c0", "type": "function",
779 "function": {"name": "set_voxel", "arguments": "{}"}}]}),
780 json!({"role": "tool", "tool_call_id": "c0", "content": "queued 1 block"}),
781 json!({"role": "assistant", "content": "done"}),
782 ],
783 },
784 CaseOutcome {
785 name: "dig_hole".into(),
786 passed: false,
787 predicates: vec![("removed".into(), false)],
788 latency: Duration::from_millis(300),
789 tokens: Some(50),
790 tool_calls: vec![],
791 final_text: None,
792 error: Some("backend error".into()),
793 transcript: vec![],
794 },
795 ];
796 RunRecord {
797 model: model.into(),
798 timestamp_display: ts_display.into(),
799 timestamp_file: ts_file.into(),
800 backend: "local".into(),
801 cases_dir: "eval/cases".into(),
802 system_prompt: "TEST SYSTEM PROMPT".into(),
803 report: EvalReport::new(
804 outcomes,
805 0.0,
806 format!("local: {model}"),
807 "TEST SYSTEM PROMPT".into(),
808 ),
809 }
810 }
811
812 #[test]
813 fn generates_self_contained_html_with_embedded_data() {
814 let dir = tempfile::tempdir().expect("tempdir");
815 let p = dir.path();
816
817 for (model, tf, td) in [
818 ("qwen2.5-7b", "20260618-140000", "2026-06-18 14:00:00"),
819 ("qwen2.5-3b", "20260618-150000", "2026-06-18 15:00:00"),
820 ("qwen2.5-7b", "20260618-160000", "2026-06-18 16:00:00"),
821 ] {
822 let rec = sample(model, tf, td);
823 let json = serde_json::to_string(&rec).expect("serialize record");
824 std::fs::write(p.join(format!("{model}_{tf}.json")), json).expect("write record");
825 }
826
827 let out = generate_report(p).expect("generate");
828 assert!(
829 out.is_file(),
830 "report.html should exist at {}",
831 out.display()
832 );
833 let html = std::fs::read_to_string(&out).expect("read report");
834
835 assert!(html.contains("qwen2.5-7b"), "model name embedded");
837 assert!(html.contains("qwen2.5-3b"), "second model embedded");
838 assert!(html.contains("place_block"), "case name embedded");
839 assert!(
840 html.contains("\\\"passed\\\""),
841 "outcome flags embedded (JSON escaped for script)"
842 );
843
844 assert!(
846 html.contains("\\\"transcript\\\""),
847 "per-case transcript field embedded (JSON escaped for script)"
848 );
849 assert!(
850 html.contains("place a block here"),
851 "transcript message content embedded"
852 );
853 assert!(
854 html.contains("\\\"system_prompt\\\""),
855 "run-level system_prompt field embedded (JSON escaped for script)"
856 );
857 assert!(
858 html.contains("TEST SYSTEM PROMPT"),
859 "system prompt text embedded"
860 );
861
862 assert!(
864 html.contains("id=\"leaderboard\""),
865 "leaderboard table present"
866 );
867 assert!(
868 html.contains("id=\"case-heatmap\""),
869 "case heatmap table present"
870 );
871 assert!(html.contains("id=\"controls\""), "controls bar present");
872
873 assert!(!html.contains("<script src"), "no external script tags");
876 assert!(!html.contains("<link "), "no external stylesheet links");
877 assert!(!html.contains("cdn"), "no CDN references");
878 assert!(html.contains("<script>"), "inline script present");
879 }
880
881 #[test]
882 fn empty_dir_yields_a_report_with_no_runs() {
883 let dir = tempfile::tempdir().expect("tempdir");
884 let out = generate_report(dir.path()).expect("generate empty");
885 let html = std::fs::read_to_string(&out).expect("read");
886 assert!(html.contains("0 run(s) embedded"));
887 assert!(html.contains("id=\"leaderboard\""));
889 assert!(html.contains("id=\"case-heatmap\""));
890 }
891
892 #[test]
893 fn collapses_duplicate_model_runs_to_distinct_models() {
894 let dir = tempfile::tempdir().expect("tempdir");
896 let p = dir.path();
897 for (model, tf, td) in [
898 ("model-a", "20260618-100000", "2026-06-18 10:00:00"),
899 ("model-a", "20260618-110000", "2026-06-18 11:00:00"),
900 ("model-b", "20260618-120000", "2026-06-18 12:00:00"),
901 ] {
902 let rec = sample(model, tf, td);
903 let json = serde_json::to_string(&rec).expect("serialize record");
904 std::fs::write(p.join(format!("{model}_{tf}.json")), json).expect("write record");
905 }
906
907 let out = generate_report(p).expect("generate");
908 let html = std::fs::read_to_string(&out).expect("read report");
909
910 assert!(html.contains("model-a"), "model-a embedded");
912 assert!(html.contains("model-b"), "model-b embedded");
913
914 let run_count = html.matches("\\\"timestamp_file\\\"").count();
916 assert_eq!(run_count, 3, "all three RunRecords embedded");
917
918 let mut models: Vec<&str> = html
922 .match_indices("\\\"model\\\":\\\"")
923 .filter_map(|(i, m)| {
924 let start = i + m.len();
925 html[start..]
926 .find("\\\"")
927 .map(|end| &html[start..start + end])
928 })
929 .collect();
930 models.sort();
931 models.dedup();
932 assert_eq!(
933 models,
934 vec!["model-a", "model-b"],
935 "two distinct models after dedup"
936 );
937 }
938
939 #[test]
940 fn escape_for_script_neutralizes_tag_breaks() {
941 let s = escape_for_script(r#"{"x":"</script><a>"}"#);
942 assert!(!s.contains("</script>"));
943 assert!(!s.contains('<'));
944 assert!(!s.contains('>'));
945 }
946}