DecisionLab / app /static /app.js
Michael Stattelman
Version updates
d8c255d
Raw History Blame Contribute Delete
32.8 kB
"use strict";
// The server owns the model list and its order (/api/status "order"). This default only covers the first paint.
let MODELS = []; // filled from /api/status; nothing is assumed before the server answers
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const pct = (p) => `${(100 * p).toFixed(1)}%`;
const nModels = () => MODELS.length;
// ------------------------------------------------------------------ API access from the page
// The API is open (no token). If the server cannot be reached, say so plainly.
async function api(url, opts = {}) {
try {
return await fetch(url, { ...opts, credentials: "same-origin" });
} catch {
throw new Error("The page lost its connection to the lab server. Reload the page (Ctrl+F5).");
}
}
// CSP (DL-SA-006) forbids inline style attributes; bars carry data-scale and get their transform here.
function applyScales(root) {
root.querySelectorAll("[data-scale]").forEach((el) => { el.style.transform = `scaleX(${+el.dataset.scale || 0})`; });
}
let DEMOS = [];
let current = null; // selected demo
let last = null; // {questions, reference, response, roundTrip}
let status = null;
// ------------------------------------------------------------------ status
async function pollStatus() {
try {
status = await (await api("/api/status")).json();
syncModels();
renderStatus();
const busy = Object.values(status.models).some((m) => m.status === "loading" || m.status === "idle");
setTimeout(pollStatus, busy ? 2000 : 15000);
} catch {
$("readiness").textContent = "Lab server not reachable";
setTimeout(pollStatus, 4000);
}
}
function syncModels() {
const order = status.order || Object.keys(status.models);
const next = order.filter((k) => status.models[k]).map((k) => {
const d = status.models[k];
return { key: k, name: d.name || k, side: d.side || k };
});
const changed = JSON.stringify(next) !== JSON.stringify(MODELS);
MODELS = next;
if (changed) { applyModelCount(); renderHero(); renderScoreHead(); }
}
function applyModelCount() {
document.documentElement.style.setProperty("--n-models", String(nModels()));
$("run").textContent = runLabel();
const n = { 1: "One", 2: "Both", 3: "All three" }[nModels()] || `All ${nModels()}`;
document.querySelectorAll("[data-n-models]").forEach((el) => { el.textContent = el.dataset.nModels.replace("{N}", n).replace("{n}", n.toLowerCase()); });
}
const runLabel = () => (nModels() <= 1 ? "Run the model" : nModels() === 2 ? "Run both models" : `Run all ${nModels()} models`);
function renderHero() {
// "vs" between boxes reads well on one row (up to 3 models); with more, the boxes wrap into rows without it.
const vs = MODELS.length <= 3;
$("versus").classList.toggle("many", !vs);
$("versus").innerHTML = MODELS.map((m, i) => `${i && vs ? `<div class="versus-mid">vs</div>` : ""}
<div class="versus-side side-${esc(m.side)}">
<span class="versus-name">${esc(m.name)}</span>
<span class="versus-figure" id="hero-params-${esc(m.key)}">–</span>
<span class="versus-unit">parameters</span>
</div>`).join("");
}
function sourceLine(m, d) {
if (d.path) return `<span title="Loaded from a local folder">Local folder <code>${esc(d.path)}</code></span>`;
const repo = d.repo || "";
return repo ? `<a href="https://huggingface.co/${esc(repo)}" target="_blank" rel="noopener noreferrer">${esc(repo)}</a>` : "";
}
function renderStatus() {
const ms = status.models;
const dir = status.env?.models_dir || "/models";
const found = MODELS.filter((m) => m.key !== "laya").length;
$("models-found").textContent = found
? `Found ${found} model${found === 1 ? "" : "s"} in ${dir} (your DecisionLab\\models folder). Add or remove a folder there and restart the container to change the line-up.`
: "";
const none = found ? "" : `<p class="no-models">No models found in <code>${esc(dir)}</code>. Unzip a model into your <code>DecisionLab\\models</code> folder (each in its own folder: a LightDec folder has <code>falcondec_config.json</code>, an Arthur folder has <code>config.json</code> and <code>model.safetensors</code>) and restart the container. Laya still runs.</p>`;
$("models").innerHTML = none + MODELS.map((m) => {
const d = ms[m.key] || {};
const label = { ready: "Ready", loading: "Loading", error: "Failed to load", idle: "Waiting" }[d.status] || d.status;
const facts = [
["Parameters", d.params_m ? `${d.params_m}M` : "–"],
["Weights", d.weights_mb ? `${d.weights_mb} MB` : m.key === "laya" ? "≈808 MB" : "–"],
["Device", d.device ? d.device.toUpperCase() : "–"],
["Load time", d.load_seconds ? `${d.load_seconds} s` : "–"],
["Version", d.version || "–"],
["Native confidence", d.confidence_native || "–"],
];
return `<article class="model-card side-${esc(m.side)}" data-model="${esc(m.key)}">
<h3>${esc(m.name)}<span class="state-tag ${esc(d.status)}">${esc(label)}</span></h3>
<p class="repo">${sourceLine(m, d)}${d.note ? ` · ${esc(d.note)}` : ""}</p>
<dl class="facts">${facts.map(([k, v]) => `<div><dt>${k}</dt><dd>${esc(v)}</dd></div>`).join("")}</dl>
${d.status === "error" ? `<p class="model-error">${esc(d.error)}</p><p><button class="btn btn-quiet btn-small" data-reload="${esc(m.key)}">Try loading again</button></p>` : ""}
</article>`;
}).join("");
for (const m of MODELS) {
const d = ms[m.key] || {}, el = $(`hero-params-${m.key}`);
if (el) el.textContent = d.params_m ? `${Math.round(d.params_m)}M` : "–";
}
document.querySelectorAll("[data-reload]").forEach((b) => b.addEventListener("click", async () => {
b.disabled = true; b.textContent = "Loading…";
await api(`/api/reload/${encodeURIComponent(b.dataset.reload)}`, { method: "POST" });
setTimeout(pollStatus, 500);
}));
const ready = MODELS.filter((m) => ms[m.key]?.status === "ready").map((m) => m.name);
const loading = MODELS.filter((m) => ms[m.key]?.status === "loading").map((m) => m.name);
const r = $("readiness");
r.classList.toggle("ready", ready.length === nModels());
r.textContent = ready.length === nModels() ? (nModels() === 2 ? "Both models ready" : `All ${nModels()} models ready`)
: loading.length ? `Loading ${loading.join(", ")}…` : ready.length ? `${ready.join(", ")} ready` : "Models not loaded";
const e = status.env;
$("env").textContent = `Server: ${e.gpu ? `GPU ${e.gpu}` : `CPU, ${e.threads} threads`}, PyTorch ${e.torch}, Python ${e.python}.`;
}
// ------------------------------------------------------------------ demos: tabs, tiles, filter, prev/next
let GROUPS = [];
let activeGroup = null;
const groupOf = (id) => GROUPS.find((g) => g.id === id);
async function loadDemos() {
[DEMOS, GROUPS] = await Promise.all([
api("/api/demos").then((r) => r.json()),
api("/api/groups").then((r) => r.json()).catch(() => []),
]);
if (!GROUPS.length) { // older server: derive groups from the demos
GROUPS = [...new Set(DEMOS.map((d) => d.group))].map((id) => ({ id, label: id, family: "", blurb: "" }));
}
DEMOS.forEach((d, i) => { d.num = i + 1; });
for (const g of GROUPS) g.count = DEMOS.filter((d) => d.group === g.id).length;
activeGroup = GROUPS[0]?.id;
renderTabs();
renderScope();
$("demo-filter").placeholder = `Filter all ${DEMOS.length} demos`;
$("demo-filter").addEventListener("input", renderTiles);
$("prev").addEventListener("click", () => step(-1));
$("next").addEventListener("click", () => step(1));
selectDemo(DEMOS[0].id);
}
function renderTabs() {
const families = [...new Set(GROUPS.map((g) => g.family))];
$("tabs").innerHTML = families.map((f) => `
<div class="tab-family">
${f ? `<span class="family-label">${esc(f)}</span>` : ""}
<div class="tabs">${GROUPS.filter((g) => g.family === f).map((g) => `
<button type="button" role="tab" class="tab" data-group="${esc(g.id)}">${esc(g.label)} <span class="tab-count">${g.count}</span></button>`).join("")}
</div>
</div>`).join("");
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => {
$("demo-filter").value = "";
activeGroup = t.dataset.group;
const first = DEMOS.find((d) => d.group === activeGroup);
if (first && current?.group !== activeGroup) selectDemo(first.id); else renderTiles();
}));
}
function renderTiles() {
const q = $("demo-filter").value.trim().toLowerCase();
const list = q
? DEMOS.filter((d) => `${d.num} ${d.title} ${d.blurb} ${groupOf(d.group)?.label ?? d.group}`.toLowerCase().includes(q))
: DEMOS.filter((d) => d.group === activeGroup);
document.querySelectorAll(".tab").forEach((t) => t.setAttribute("aria-selected", String(!q && t.dataset.group === activeGroup)));
$("group-blurb").textContent = q ? `${list.length} of ${DEMOS.length} demos match.` : (groupOf(activeGroup)?.blurb || "");
$("tiles").innerHTML = list.length ? list.map((d) => `
<button type="button" role="option" class="tile" data-demo="${esc(d.id)}" aria-selected="${current?.id === d.id}" title="${esc(d.blurb)}">
<span class="n">${d.num}</span><span class="t">${esc(d.title)}</span>
<span class="g">${q ? `${esc(groupOf(d.group)?.label ?? d.group)} · ` : ""}${Object.keys(d.questions).length} question${Object.keys(d.questions).length === 1 ? "" : "s"}</span>
</button>`).join("") : `<p class="none">No demo matches “${esc(q)}”.</p>`;
$("tiles").querySelectorAll(".tile").forEach((b) => b.addEventListener("click", () => selectDemo(b.dataset.demo)));
}
function renderScope() {
const nq = (ds) => ds.reduce((t, d) => t + Object.keys(d.questions).length, 0);
const all = [...new Set(GROUPS.map((g) => g.family))].filter(Boolean);
const families = all.length > 1 ? all : []; // a single family would only repeat "All demos"
$("scope").innerHTML = `<option value="">All demos (${DEMOS.length} demos, ${nq(DEMOS)} questions)</option>`
+ families.map((f) => {
const ds = DEMOS.filter((d) => groupOf(d.group)?.family === f);
return `<option value="family:${esc(f)}">${esc(f)} (${ds.length} demos)</option>`;
}).join("")
+ GROUPS.map((g) => `<option value="group:${esc(g.id)}">— ${esc(g.label)} (${g.count})</option>`).join("");
$("scope").addEventListener("change", updateRunLabel);
updateRunLabel();
}
function scopedDemos() {
const v = $("scope").value;
if (v.startsWith("family:")) return DEMOS.filter((d) => groupOf(d.group)?.family === v.slice(7));
if (v.startsWith("group:")) return DEMOS.filter((d) => d.group === v.slice(6));
return DEMOS;
}
function updateRunLabel() {
const n = scopedDemos().length;
$("run-all").textContent = n === DEMOS.length ? `Run all ${n} demos` : `Run ${n} demos`;
}
function selectDemo(id) {
current = DEMOS.find((d) => d.id === id);
if (!$("demo-filter").value.trim()) activeGroup = current.group;
renderTiles();
$("demo-title").innerHTML = `<span class="num">${current.num}</span>${esc(current.title)}`;
$("demo-blurb").textContent = current.blurb;
$("state").value = typeof current.state === "string" ? current.state : JSON.stringify(current.state, null, 2);
$("questions").value = JSON.stringify(current.questions, null, 2);
$("form-error").textContent = "";
$("prev").disabled = current.num === 1;
$("next").disabled = current.num === DEMOS.length;
}
function step(delta) {
const i = DEMOS.indexOf(current) + delta;
if (i >= 0 && i < DEMOS.length) {
$("demo-filter").value = "";
selectDemo(DEMOS[i].id);
}
}
function parseState(text) {
const t = text.trim();
if (t.startsWith("{") || t.startsWith("[")) {
try { return JSON.parse(t); } catch { /* plain text that happens to start with a brace */ }
}
return t;
}
// ------------------------------------------------------------------ running
async function decide(state, questions) {
const t0 = performance.now();
const res = await api("/api/decide", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(MODELS.length ? { state, questions, models: MODELS.map((m) => m.key) } : { state, questions }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.detail || `Request failed (${res.status})`);
return { response: body, roundTrip: performance.now() - t0 };
}
async function runCurrent() {
const err = $("form-error");
err.textContent = "";
let questions;
try { questions = JSON.parse($("questions").value); } catch (e) {
err.textContent = `Questions aren't valid JSON: ${e.message}`; return;
}
const state = parseState($("state").value);
if (!state || (typeof state === "string" && !state.length)) { err.textContent = "Add a state for the models to read."; return; }
const btn = $("run");
btn.disabled = true; btn.textContent = "Running…";
try {
const { response, roundTrip } = await decide(state, questions);
const sameDemo = current && JSON.stringify(current.questions) === JSON.stringify(questions);
last = { questions, reference: sameDemo ? current.reference : {}, response, roundTrip };
renderVerdicts();
$("verdicts").scrollIntoView();
} catch (e) {
err.textContent = e.message;
} finally {
btn.disabled = false; btn.textContent = runLabel();
}
}
// ------------------------------------------------------------------ rendering helpers
const measure = () => $("measure").value;
const threshold = () => parseFloat($("threshold").value);
const conf = (rec) => rec[measure()];
function optionLabels(q) {
const t = q.type || "choice";
const crit = q.criteria ?? q.options;
if (t === "noul") return [["true", "Yes"], ["false", "No"]];
if (t === "score") return crit.map((c, i) => [String(i), `${i} · ${c}`]);
if (Array.isArray(crit)) return crit.map((c) => [String(c), String(c)]);
return Object.entries(crit).map(([k, v]) => [k, v ? `${k}: ${v}` : k]);
}
function answerText(q, rec) {
if (!rec) return "–";
const t = q.type || "choice";
if (t === "noul") return `${rec.choice === "true" ? "Yes" : "No"} (P(yes) ${pct(rec.p_true)})`;
if (t === "score") {
const crit = q.criteria ?? q.options;
return `${crit[+rec.choice]} (level ${rec.expected_level.toFixed(2)})`;
}
return rec.choice;
}
function answerBlock(m, q, rec, err, ref) {
const head = `<div class="who">${esc(m.name)}</div>`;
if (err) return `<div class="answer side-${esc(m.side)}">${head}<div class="err">${esc(err)}</div></div>`;
if (!rec) return `<div class="answer side-${esc(m.side)}">${head}<div class="err">No answer returned.</div></div>`;
const c = conf(rec);
const act = c >= threshold();
const refTxt = ref === undefined ? "" : rec.choice === ref ? `<span class="ref hit">matches reference</span>` : `<span class="ref miss">differs from reference</span>`;
return `<div class="answer side-${esc(m.side)}">
${head}
<div class="what">${esc(answerText(q, rec))}</div>
<div class="how">confidence ${c.toFixed(3)}<span class="badge ${act ? "act" : "defer"}">${act ? "act" : "defer"}</span>${refTxt}</div>
</div>`;
}
// how many of the models that answered give the same top answer: {agree, of}
function agreement(recs) {
const got = recs.filter(Boolean);
if (got.length < 2) return null;
const counts = {};
for (const r of got) counts[r.choice] = (counts[r.choice] || 0) + 1;
return { agree: Math.max(...Object.values(counts)), of: got.length };
}
function agreeTag(ag) {
if (!ag) return "";
const all = ag.agree === ag.of;
const text = all ? (ag.of === 2 ? "Models agree" : `All ${ag.of} agree`) : ag.agree === 1 ? "All differ" : `${ag.agree} of ${ag.of} agree`;
return `<span class="agree ${all ? "yes" : ag.agree === 1 ? "no" : "part"}">${text}</span>`;
}
function renderVerdicts() {
if (!last) return;
const { questions, reference, response, roundTrip } = last;
const R = response.results;
const res = MODELS.map((m) => ({ m, r: R[m.key] || {} }));
const tm = $("timing");
tm.hidden = false;
const timed = res.filter((x) => x.r.ms != null);
const fast = timed.length ? timed.reduce((a, b) => (b.r.ms < a.r.ms ? b : a)) : null;
const slow = timed.length ? timed.reduce((a, b) => (b.r.ms > a.r.ms ? b : a)) : null;
tm.innerHTML = res.map((x) => `<div class="t-model side-${esc(x.m.side)}"><b>${x.r.ms != null ? `${x.r.ms} ms` : "–"}</b><span>${esc(x.m.name)} model time</span></div>`).join("")
+ `<div><b>${fast && slow && fast !== slow ? `${(slow.r.ms / fast.r.ms).toFixed(1)}×` : "–"}</b><span>${fast && slow && fast !== slow ? `${esc(fast.m.name)} fastest, vs ${esc(slow.m.name)}` : "speed ratio"}</span></div>`
+ `<div><b>${Math.round(roundTrip)} ms</b><span>round trip from your browser</span></div>`;
$("verdict-list").innerHTML = Object.entries(questions).map(([name, q]) => {
const recs = res.map((x) => x.r.answers?.[name]);
const ref = reference?.[name];
const rows = optionLabels(q).map(([k, label]) => `
<div class="bar-label ${ref === k ? "is-ref" : ""}">${esc(label)}</div>
<div class="bar-stack">${res.map((x, i) => {
const rec = recs[i], p = rec?.probs?.[k] ?? 0;
return `<div class="bar-cell side-${esc(x.m.side)} ${rec?.choice === k ? "top" : ""}" title="${esc(x.m.name)}"><i data-scale="${p.toFixed(4)}"></i><span>${rec ? pct(p) : ""}</span></div>`;
}).join("")}</div>`).join("");
return `<article class="verdict">
<div class="verdict-head">
<div><h3 class="verdict-q">${esc(q.instructions || q.question || name)}</h3>
<p class="verdict-meta">${esc(name)} · ${esc(q.type || "choice")}</p></div>
${agreeTag(agreement(recs))}
</div>
<div class="answers">${res.map((x, i) => answerBlock(x.m, q, recs[i], x.r.error, ref)).join("")}</div>
<div class="bars">${rows}</div>
</article>`;
}).join("");
applyScales($("verdict-list"));
$("raw").hidden = false;
$("raw-json").textContent = JSON.stringify(response, null, 2);
}
// ------------------------------------------------------------------ scoreboard
let board = null; // {rows: [{demo, name, q, ref, high, ans: {modelKey: rec}}], times: {modelKey: [ms]}}
async function runAll() {
const btn = $("run-all");
btn.disabled = true;
const rows = [], times = Object.fromEntries(MODELS.map((m) => [m.key, []]));
try {
const list = scopedDemos();
for (let i = 0; i < list.length; i++) {
const d = list[i];
$("progress").textContent = `Running demo ${i + 1} of ${list.length}: ${d.title}`;
const { response } = await decide(d.state, d.questions);
for (const k of Object.keys(times)) if (response.results[k]?.ms != null) times[k].push(response.results[k].ms);
for (const [name, q] of Object.entries(d.questions)) {
rows.push({ demo: d, name, q, ref: d.reference?.[name], high: d.stakes?.[name] === "high",
ans: Object.fromEntries(MODELS.map((m) => [m.key, response.results[m.key]?.answers?.[name]])) });
}
}
board = { rows, times };
$("progress").textContent = `Done: ${rows.length} questions across ${list.length} demos.`;
renderScoreHead();
renderBoard();
} catch (e) {
$("progress").textContent = `Stopped: ${e.message}`;
} finally {
btn.disabled = false;
}
}
function median(xs) {
if (!xs.length) return null;
const s = [...xs].sort((x, y) => x - y), m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
// Agentic Use Score (AUS): a 0-100 score skewed toward what matters when an agent acts on the answer.
// Every question counts once; questions marked high-stakes (irreversible, security, money) count twice.
const AUS_WEIGHTS = { precision: 0.35, caught: 0.25, accuracy: 0.15, autonomy: 0.15, speed: 0.10 };
const AUS_LABELS = {
precision: "Right when it acts", caught: "Flags its own mistakes", accuracy: "Overall accuracy",
autonomy: "Handles on its own", speed: "Speed",
};
function speedScore(ms) { // 1.0 at 50 ms or faster, 0 at 1 s or slower, log scale in between
if (ms == null) return 0;
return Math.max(0, Math.min(1, 1 - Math.log10(Math.max(ms, 1) / 50) / Math.log10(20)));
}
function modelStats(rows, key, times, thr) {
let n = 0, hits = 0, acted = 0, actedHits = 0, wrong = 0, wrongDeferred = 0; // raw counts
let W = 0, wHits = 0, wActed = 0, wActedHits = 0, wWrong = 0, wWrongDeferred = 0; // stakes-weighted
for (const r of rows) {
const rec = r.ans[key];
if (!rec || r.ref === undefined) continue;
const w = r.high ? 2 : 1, ok = rec.choice === r.ref, act = conf(rec) >= thr;
n++; W += w;
if (ok) { hits++; wHits += w; }
if (act) { acted++; wActed += w; if (ok) { actedHits++; wActedHits += w; } }
if (!ok) { wrong++; wWrong += w; if (!act) { wrongDeferred++; wWrongDeferred += w; } }
}
const med = median(times || []);
const parts = {
precision: (wActedHits + 1) / (wActed + 2), // small prior: a model that acts twice isn't "perfect"
caught: wWrong ? wWrongDeferred / wWrong : 1,
accuracy: W ? wHits / W : 0,
autonomy: W ? wActed / W : 0,
speed: speedScore(med),
};
const aus = 100 * Object.entries(AUS_WEIGHTS).reduce((t, [k, w]) => t + w * parts[k], 0);
return { n, hits, acted, actedHits, wrong, wrongDeferred, confidentWrong: acted - actedHits, med, parts, aus };
}
// Which models are best on a metric. values: {modelKey: number|null}. Returns the Set of keys within eps of the
// best value, or an empty Set when every model is tied (nothing to highlight) or fewer than two have a value.
function best(values, higherIsBetter = true, eps = 1e-9) {
const got = Object.entries(values).filter(([, v]) => v != null);
if (got.length < 2) return new Set();
const top = higherIsBetter ? Math.max(...got.map(([, v]) => v)) : Math.min(...got.map(([, v]) => v));
const keys = got.filter(([, v]) => Math.abs(v - top) < eps).map(([k]) => k);
return keys.length === got.length ? new Set() : new Set(keys);
}
const modelHeads = () => MODELS.map((m) => `<th scope="col" class="side-${esc(m.side)}">${esc(m.name)}</th>`).join("");
// per-group matches and confident mistakes, so a model's weak spots show up by use case
function groupBreakdown(rows, thr) {
const ids = [...new Set(rows.map((r) => r.demo.group))];
if (ids.length < 2) return "";
const line = (rs, key) => {
const scored = rs.filter((r) => r.ans[key] && r.ref !== undefined);
const hits = scored.filter((r) => r.ans[key].choice === r.ref).length;
const cw = scored.filter((r) => r.ans[key].choice !== r.ref && conf(r.ans[key]) >= thr).length;
return { hits, n: scored.length, cw, rate: scored.length ? hits / scored.length : null };
};
const body = ids.map((id) => {
const rs = rows.filter((r) => r.demo.group === id);
const L = Object.fromEntries(MODELS.map((m) => [m.key, line(rs, m.key)]));
const w = best(Object.fromEntries(MODELS.map((m) => [m.key, L[m.key].rate])));
return `<tr><td>${esc(groupOf(id)?.label ?? id)}<small>${esc(groupOf(id)?.family ?? "")}</small></td>${MODELS.map((m) => {
const x = L[m.key];
if (!x.n) return `<td><b>–</b><small>no reference answers</small></td>`; // e.g. Agent security: states and questions only
return `<td class="${w.has(m.key) ? "win" : ""}"><b>${x.hits} / ${x.n}</b><small>${x.cw} confident mistake${x.cw === 1 ? "" : "s"}</small></td>`;
}).join("")}</tr>`;
}).join("");
return `<div class="group-board">
<h3>By group</h3>
<div class="table-scroll"><table class="aus-table">
<thead><tr><th scope="col">Group</th>${modelHeads()}</tr></thead>
<tbody>${body}</tbody>
</table></div>
</div>`;
}
function renderScoreHead() {
$("score-head").innerHTML = `<th scope="col">Demo</th><th scope="col">Question</th><th scope="col">Reference</th>${modelHeads()}<th scope="col">Agree</th>`;
}
// A run with no reference answers (e.g. only the Agent security demos) cannot be scored: the Agentic Use Score
// would fall back to the same defaults for every model. Show what CAN be compared instead.
function renderUnscoredSummary() {
const { rows, times } = board;
const thr = threshold();
const card = (m) => {
const recs = rows.map((r) => r.ans[m.key]).filter(Boolean);
const acts = recs.filter((rec) => conf(rec) >= thr).length;
const med = median(times[m.key] || []);
const facts = [
["Questions answered", `${recs.length} / ${rows.length}`],
["Median model time", med != null ? `${Math.round(med)} ms` : "–"],
[`Acts on (conf ≥ ${thr.toFixed(2)})`, `${acts} / ${recs.length}`],
["Defers", `${recs.length - acts} / ${recs.length}`],
];
return `<div class="sum-card side-${esc(m.side)}">
<h3>${esc(m.name)}</h3>
<dl>${facts.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join("")}</dl>
</div>`;
};
const answered = rows.filter((r) => MODELS.every((m) => r.ans[m.key]));
const allAgree = answered.filter((r) => new Set(MODELS.map((m) => r.ans[m.key].choice)).size === 1).length;
const sum = $("summary");
sum.hidden = false;
sum.innerHTML = `<div class="unscored-note">
<h3>Not scored</h3>
<p>The Agentic Use Score compares each answer with a reference answer, and the questions in this run have none. So there is no score or ranking here; compare the models below by what they would act on at your threshold, their speed, and how often they agree.</p>
</div>`
+ MODELS.map(card).join("")
+ `<p class="sum-both">All ${MODELS.length} models give the same top answer on ${allAgree} of ${answered.length} questions. Confidence uses the measure selected above; changing it or the threshold updates every number here.</p>`;
}
function renderSummary() {
const { rows, times } = board;
if (!rows.some((r) => r.ref !== undefined)) return renderUnscoredSummary();
const thr = threshold();
const S = Object.fromEntries(MODELS.map((m) => [m.key, modelStats(rows, m.key, times[m.key], thr)]));
const ratio = (x, y) => (y ? x / y : null);
const per = (f) => Object.fromEntries(MODELS.map((m) => [m.key, f(S[m.key])]));
const metrics = [
{ label: "Matches reference", show: (x) => `${x.hits} / ${x.n}`, w: best(per((x) => x.hits)) },
{ label: "Median model time", show: (x) => (x.med != null ? `${Math.round(x.med)} ms` : "–"), w: best(per((x) => x.med), false, 0.5) },
{ label: `Acts on (conf ≥ ${thr.toFixed(2)})`, show: (x) => `${x.acted} / ${x.n}`, w: best(per((x) => x.acted)) },
{ label: "Correct when acting", show: (x) => (x.acted ? `${x.actedHits} / ${x.acted}` : "–"), w: best(per((x) => ratio(x.actedHits, x.acted))) },
{ label: "Confident mistakes", show: (x) => `${x.confidentWrong}`, w: best(per((x) => x.confidentWrong), false) },
{ label: "Mistakes it deferred", show: (x) => (x.wrong ? `${x.wrongDeferred} / ${x.wrong}` : "–"), w: best(per((x) => ratio(x.wrongDeferred, x.wrong) ?? 1)) },
];
const overall = best(per((x) => x.aus), true, 0.05);
const answered = rows.filter((r) => MODELS.every((m) => r.ans[m.key]));
const allAgree = answered.filter((r) => new Set(MODELS.map((m) => r.ans[m.key].choice)).size === 1).length;
const card = (m) => {
const st = S[m.key], win = overall.has(m.key);
return `<div class="sum-card side-${esc(m.side)} ${win ? "winner" : ""}">
<h3>${esc(m.name)}${win ? `<span class="win-tag">Best for agents</span>` : ""}</h3>
<dl>${metrics.map((x) => `<div class="${x.w.has(m.key) ? "win" : ""}"><dt>${x.label}</dt><dd>${x.show(st)}</dd></div>`).join("")}</dl>
</div>`;
};
const ranked = [...MODELS].sort((x, y) => S[y.key].aus - S[x.key].aus);
let pos = 0, prev = null;
const rankItems = ranked.map((m, i) => {
const v = S[m.key].aus;
if (prev === null || Math.abs(prev - v) >= 0.05) pos = i + 1;
const tied = ranked.some((o) => o !== m && Math.abs(S[o.key].aus - v) < 0.05);
prev = v;
return `<li class="side-${esc(m.side)} ${overall.has(m.key) ? "first" : ""}">
<span class="aus-pos">${tied ? `=${pos}` : pos}</span>
<span class="aus-name">${esc(m.name)}</span>
<span class="aus-bar"><i data-scale="${(v / 100).toFixed(4)}"></i></span>
<span class="aus-val">${v.toFixed(1)}</span>
</li>`;
}).join("");
const aus = `<div class="aus">
<div class="aus-head">
<h3>Agentic Use Score</h3>
<p>How safe and useful each model is when an agent acts on its answers. Weighted toward being right when it acts and flagging its own mistakes; high-stakes questions count double.</p>
</div>
<ol class="aus-rank">${rankItems}</ol>
<div class="table-scroll"><table class="aus-table">
<thead><tr><th scope="col">Component</th><th scope="col">Max points</th>${modelHeads()}</tr></thead>
<tbody>${Object.keys(AUS_WEIGHTS).map((k) => {
const max = AUS_WEIGHTS[k] * 100;
const pts = Object.fromEntries(MODELS.map((m) => [m.key, max * S[m.key].parts[k]]));
const w = best(pts, true, 0.05);
return `<tr><td>${AUS_LABELS[k]}</td><td>${max.toFixed(0)}</td>${MODELS.map((m) =>
`<td class="${w.has(m.key) ? "win" : ""}"><b>${pts[m.key].toFixed(1)}</b><small>${Math.round(100 * S[m.key].parts[k])}% of max</small></td>`).join("")}</tr>`;
}).join("")}</tbody>
<tfoot><tr><th scope="row">Agentic Use Score</th><td>100</td>${MODELS.map((m) =>
`<td class="${overall.has(m.key) ? "win" : ""}"><b>${S[m.key].aus.toFixed(1)}</b></td>`).join("")}</tr></tfoot>
</table></div>
</div>`;
const sum = $("summary");
sum.hidden = false;
sum.innerHTML = aus + MODELS.map(card).join("")
+ groupBreakdown(rows, thr)
+ `<p class="sum-both">Highlighted values are the best model on that metric (none are highlighted when all tie). All ${MODELS.length} models give the same top answer on ${allAgree} of ${answered.length} questions. Confidence uses the measure selected above; changing it or the threshold updates every number here.</p>`;
applyScales(sum);
}
function renderScoreRows() {
const { rows } = board;
const thr = threshold();
const cell = (q, rec, ref) => {
if (!rec) return `<td>–</td>`;
const cls = ref === undefined ? "" : rec.choice === ref ? "hit" : "miss";
return `<td class="${cls}">${esc(answerText(q, rec))}<small>conf ${conf(rec).toFixed(3)} · ${conf(rec) >= thr ? "act" : "defer"}</small></td>`;
};
const refText = (q, ref) => {
if (ref === undefined) return "–";
if ((q.type || "choice") === "noul") return ref === "true" ? "Yes" : "No";
if (q.type === "score") return (q.criteria ?? q.options)[+ref];
return ref;
};
const table = $("score-table");
table.hidden = false;
table.querySelector("tbody").innerHTML = rows.map((r) => {
const ag = agreement(MODELS.map((m) => r.ans[m.key]));
return `<tr>
<td>${esc(r.demo.title)}<small>${esc(groupOf(r.demo.group)?.label ?? r.demo.group)}</small></td>
<td>${esc(r.q.instructions)}<small>${esc(r.name)} · ${esc(r.q.type || "choice")}${r.high ? ` · <span class="stakes">high stakes</span>` : ""}</small></td>
<td>${esc(refText(r.q, r.ref))}</td>
${MODELS.map((m) => cell(r.q, r.ans[m.key], r.ref)).join("")}
<td>${ag ? `${ag.agree} / ${ag.of}` : "–"}</td>
</tr>`;
}).join("");
}
// DL1: the summary paints in the current frame; the long row table follows in its own task, so no single
// task has to rebuild everything. A newer request cancels a pending row render.
let rowsTimer = 0;
function renderBoard() {
if (!board) return;
renderSummary();
clearTimeout(rowsTimer);
rowsTimer = setTimeout(renderScoreRows, 0);
}
// ------------------------------------------------------------------ wiring
// DL1: the slider can fire faster than a frame; update the number at once and redraw at most once per frame.
let rerenderFrame = 0;
function onThresholdChange() {
$("thr-out").textContent = threshold().toFixed(2);
if (rerenderFrame) return;
rerenderFrame = requestAnimationFrame(() => {
rerenderFrame = 0;
renderVerdicts();
renderBoard();
});
}
$("run").addEventListener("click", runCurrent);
$("run-all").addEventListener("click", runAll);
$("threshold").addEventListener("input", onThresholdChange);
$("measure").addEventListener("change", onThresholdChange);
document.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") runCurrent(); });
applyModelCount();
renderHero();
renderScoreHead();
pollStatus();
loadDemos();