Skip to main content

eval_core/
report_html.rs

1//! Self-contained HTML comparison report over the persisted eval runs.
2//!
3//! [`generate_report`] loads every `*.json` [`RunRecord`](crate::report::RunRecord) in a results
4//! directory, embeds them verbatim as a JSON blob inside a SINGLE `report.html` (no CDN, no external
5//! scripts/fonts — it opens offline by double-click), and renders, with vanilla JS + inline CSS, a
6//! layout that scales to comparing 10–50 models at a glance:
7//!
8//! - a sticky **controls bar** (name filter, latest/best/all-runs view toggle, sort selector),
9//! - a **leaderboard** (one row per model in the active view; ranked by accuracy with color-scaled
10//!   accuracy bars + latency/token heat, sortable columns, row-click run-history detail),
11//! - a **model × case heatmap** (rows = models, columns = the union of case names, green/red cells,
12//!   sticky first column + header, per-case pass-rate footer + per-model accuracy column),
13//! - a collapsible **raw runs** list (every saved run for reference).
14//!
15//! Correctness over polish: the JS recomputes EVERY aggregate client-side from the embedded records
16//! (accuracy = passed/total; latency p50/p95/mean from each outcome's `latency.secs + nanos/1e9`; mean
17//! tokens over outcomes with a non-null `tokens`), so the generator only embeds the data + page shell.
18//! None of EvalReport's Rust-side aggregates are serialized — only `outcomes` — so JS must derive them.
19
20use std::path::{Path, PathBuf};
21
22use crate::report::RunRecord;
23
24/// Load every `*.json` run record under `results_dir`, embed them in a self-contained `report.html`
25/// written to `results_dir/report.html`, and return that path. Files that fail to parse as a
26/// [`RunRecord`] are skipped with a warning (a stray/old-format file shouldn't sink the whole report).
27pub 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
41/// Read + parse every `*.json` (except the report itself, which is `.html`) in `dir` into
42/// [`RunRecord`]s. Unparseable files are skipped (warned), not fatal.
43fn 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        // An empty/missing dir yields an empty report rather than an error (the bin creates it).
48        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    // Newest first by the sortable file-timestamp (client JS re-sorts anyway, but a stable default
68    // order makes the raw embedded blob readable).
69    records.sort_by(|a, b| b.timestamp_file.cmp(&a.timestamp_file));
70    Ok(records)
71}
72
73/// Escape a JSON document so it can be embedded as a double-quoted JS string literal inside a
74/// `<script>` element and recovered with `JSON.parse("…")`.
75///
76/// Two concerns, in this order:
77/// 1. **JS string-literal** safety: escape backslashes, double-quotes, and newlines/CRs so the
78///    surrounding `"…"` literal isn't terminated early or broken across lines.
79/// 2. **HTML-parser** safety: neutralize `<`, `>`, and `&` (so a `</script>` or `<!--` inside the
80///    data can't end the script element). These become `\uXXXX` — still valid once `JSON.parse` runs.
81///
82/// Backslash must be escaped FIRST so the `\u` sequences we add in step 2 aren't themselves doubled.
83fn 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
93/// The full static page: inline CSS, the embedded data blob, and the vanilla-JS renderer.
94///
95/// `CSS`/`JS` are passed as named `format!` args (NOT inlined into the template literal), so their
96/// own `{`/`}` characters never need brace-escaping. The only braces in the format string below are
97/// the four `{...}` placeholders.
98fn 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>AetherCore AI eval report</title>
107<style>{css}</style>
108</head>
109<body>
110<header>
111  <h1>AetherCore 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 &amp; 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
162/// Inline stylesheet. Color encoding everywhere for glance-ability; sticky headers + sticky first
163/// column for the big tables. No `format!` here — this is a plain `const`, braces are literal CSS.
164const 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
264/// The vanilla-JS renderer. Recomputes everything from `RECORDS` (the embedded `RunRecord[]`).
265/// A plain `const` — its braces are literal JS, never seen by `format!`.
266const 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    /// Build a tiny synthetic RunRecord with one passing + one failing case.
762    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                // A small role-tagged transcript so the embedded-data test can assert it round-trips.
775                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        // Data is embedded (model names + the case names + an accuracy-bearing field present).
836        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        // The new per-case transcript + run-level system prompt round-trip into the embedded JSON.
845        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        // The redesigned section containers are emitted (tests pin these ids).
863        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        // No external resources — must open offline. (The SVG xmlns namespace URI is not a fetched
874        // resource, so we check for the tags that would PULL something over the network instead.)
875        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        // Still a valid shell with the section containers, just no data rows.
888        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        // 3 runs across 2 distinct model names; "model-a" runs twice on different timestamp_files.
895        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        // Both model names appear in the embedded data.
911        assert!(html.contains("model-a"), "model-a embedded");
912        assert!(html.contains("model-b"), "model-b embedded");
913
914        // All 3 runs are embedded (so "All runs" view has every run available)...
915        let run_count = html.matches("\\\"timestamp_file\\\"").count();
916        assert_eq!(run_count, 3, "all three RunRecords embedded");
917
918        // ...but the default "latest per model" grouping (computed in JS from the embedded data)
919        // collapses to TWO distinct models. We assert the distinct-model count is derivable from the
920        // embedded data: exactly two unique `model` values across the three records.
921        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}