World-Action-Verifier / src /interactive_corr_v2.html
ffeng1017's picture
WAV interactive demo (ZeroGPU)
23a59ea verified
Raw
History Blame Contribute Delete
30.3 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>MMBench2 · hallucination signals vs true error</title>
<style>
/* Palette roles. Light and dark are both selected sets, not an auto-flip:
categorical slots 1-4 stepped for each surface and validated against it
(worst adjacent CVD dE 8.4 dark / 9.1 light; normal-vision 19.8 / 22.9).
On light, aqua and yellow sit below 3:1 contrast, so the relief rule
applies -- every mark is directly labeled and the table view is one click
away, never color alone. */
:root {
color-scheme: light;
--surface: #fcfcfb;
--plane: #f9f9f7;
--ink: #0b0b0b;
--ink-2: #52514e;
--muted: #898781;
--grid: #e1e0d9;
--axis: #c3c2b7;
--hairline: rgba(11, 11, 11, 0.10);
--wash: rgba(11, 11, 11, 0.04);
--s1: #2a78d6; /* u_r blue */
--s2: #eb6834; /* u_f orange */
--s3: #1baf7a; /* u_s aqua */
--s4: #eda100; /* WAV yellow */
--good: #0ca30c;
--shadow: 0 1px 2px rgba(11,11,11,.05), 0 8px 28px rgba(11,11,11,.06);
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme="light"])) {
color-scheme: dark;
--surface: #1a1a19; --plane: #0d0d0d;
--ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835;
--hairline: rgba(255,255,255,0.10);
--wash: rgba(255,255,255,0.05);
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500;
--good: #0ca30c;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 34px rgba(0,0,0,.35);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--surface: #1a1a19; --plane: #0d0d0d;
--ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835;
--hairline: rgba(255,255,255,0.10);
--wash: rgba(255,255,255,0.05);
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500;
--good: #0ca30c;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 34px rgba(0,0,0,.35);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--plane); color: var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1180px; margin: 0 auto; padding: 22px 18px 40px; }
header.top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.brand { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.brand span { color: var(--muted); font-weight: 500; }
.spacer { flex: 1 1 auto; }
.led { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.led.live { background: var(--good); }
.led.connecting { background: var(--s4); }
.conn { font-size: 12.5px; color: var(--muted); }
select.ctl, button.ctl {
font: inherit; font-size: 12.5px; font-weight: 600; color: var(--ink);
background: var(--surface); border: 1px solid var(--hairline);
border-radius: 8px; padding: 5px 9px; cursor: pointer;
}
button.ctl:hover { background: var(--wash); }
button.ctl[aria-pressed="true"] { background: var(--ink); color: var(--surface); border-color: var(--ink); }
.cols { display: grid; grid-template-columns: 1fr; gap: 14px; }
@media (min-width: 940px) { .cols { grid-template-columns: 340px 1fr; align-items: start; } }
.card {
background: var(--surface); border: 1px solid var(--hairline);
border-radius: 12px; box-shadow: var(--shadow); padding: 14px;
}
.card + .card { margin-top: 14px; }
.cardHead { display: flex; align-items: baseline; gap: 10px; margin-bottom: 2px; flex-wrap: wrap; }
h2.title { font-size: 13.5px; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
.sub { font-size: 12px; color: var(--muted); margin: 3px 0 12px; line-height: 1.5; }
.sub code { font-family: ui-monospace, Menlo, monospace; color: var(--ink-2); }
.stage { position: relative; aspect-ratio: 1/1; border-radius: 10px; overflow: hidden; background: var(--wash); }
.stage img { width: 100%; height: 100%; display: block; object-fit: cover; }
.stageTag {
position: absolute; top: 8px; left: 8px; padding: 3px 7px; border-radius: 6px;
background: rgba(0,0,0,.55); color: #fff; font-size: 10.5px; font-weight: 700; letter-spacing: .03em;
}
/* Live readout: stat tiles, not a chart -- each is a single number. */
.tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
.tile { padding: 7px 8px; border-radius: 8px; background: var(--wash); }
.tileK { font-size: 9.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
.tileV { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 1px; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); margin-bottom: 10px; }
.legend .item { display: inline-flex; align-items: center; gap: 6px; }
.swatch { width: 11px; height: 11px; border-radius: 3px; flex: none; }
.stroke { width: 14px; height: 2px; border-radius: 1px; flex: none; }
.legend .tag { font-size: 10px; color: var(--muted); }
/* One panel per signal — the value is a single number, the bar is its scale. */
.panels { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
@media (max-width: 620px) { .panels { grid-template-columns: 1fr 1fr; } }
.panel { display: flex; flex-direction: column; gap: 5px; min-width: 0;
padding: 9px 8px 7px; border-radius: 10px; background: var(--wash); }
.pName { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; }
.pName .swatch { width: 9px; height: 9px; border-radius: 2px; }
.pTag { font-size: 9.5px; font-weight: 700; color: var(--muted); letter-spacing: .02em; }
.pNums { display: flex; align-items: baseline; gap: 8px; }
.pCum { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
svg { display: block; width: 100%; overflow: visible; }
text { font-family: inherit; }
.axLabel { font-size: 10.5px; fill: var(--muted); font-variant-numeric: tabular-nums; }
.rowLabel { font-size: 12px; fill: var(--ink); font-weight: 600; }
.valLabel { font-size: 11px; fill: var(--ink-2); font-variant-numeric: tabular-nums; font-weight: 600; }
.hint { font-size: 11.5px; fill: var(--muted); }
.tt {
position: fixed; z-index: 40; pointer-events: none; opacity: 0;
transition: opacity .1s; background: var(--surface); color: var(--ink);
border: 1px solid var(--hairline); border-radius: 9px; box-shadow: var(--shadow);
padding: 8px 10px; font-size: 12px; min-width: 132px;
}
.tt .ttHead { font-size: 10.5px; color: var(--muted); font-weight: 700; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 5px; }
.tt .ttRow { display: flex; align-items: center; gap: 7px; margin-top: 3px; }
.tt .ttRow .stroke { width: 12px; }
.tt .ttVal { margin-left: auto; font-weight: 700; font-variant-numeric: tabular-nums; }
.tt .ttName { color: var(--ink-2); }
table.tv { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
table.tv th, table.tv td { text-align: right; padding: 5px 8px; border-bottom: 1px solid var(--grid); }
table.tv th:first-child, table.tv td:first-child { text-align: left; }
table.tv thead th { color: var(--muted); font-weight: 700; font-size: 10.5px; letter-spacing: .04em; text-transform: uppercase; }
table.tv td.k { display: flex; align-items: center; gap: 7px; font-weight: 600; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.foot { margin-top: 10px; font-size: 11.5px; color: var(--muted); line-height: 1.55; }
.btnRow { display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div class="brand">Hallucination signals <span>· correlation with true rollout error</span><span style="margin-left:8px;font-size:10px;color:var(--muted);font-weight:600">build __BUILD__</span></div>
<div class="spacer"></div>
<div class="led" id="led"></div>
<div class="conn" id="conn">offline</div>
<select id="taskSelect" class="ctl" aria-label="task"></select>
<button id="btnTheme" class="ctl" type="button">Theme</button>
</header>
<div class="cols">
<div>
<div class="card">
<div class="stage" id="stage">
<img id="frame" alt="world-model predicted frame"/>
<div class="stageTag">WM PREDICTION</div>
</div>
<div class="tiles">
<div class="tile"><div class="tileK">err₁</div><div class="tileV" id="tErr"></div></div>
<div class="tile"><div class="tileK">wav₁</div><div class="tileV" id="tWav"></div></div>
<div class="tile"><div class="tileK">step</div><div class="tileV" id="tStep"></div></div>
<div class="tile"><div class="tileK">u_r</div><div class="tileV" id="tUr"></div></div>
<div class="tile"><div class="tileK">u_f</div><div class="tileV" id="tUf"></div></div>
<div class="tile"><div class="tileK">u_s</div><div class="tileV" id="tUs"></div></div>
<div class="tile" style="grid-column: span 3;">
<div class="tileK">wav / err &nbsp;·&nbsp; H=<span id="tH1Lbl">2</span> and H=<span id="tH2Lbl">8</span></div>
<div class="tileV" style="font-size:13px;">
<span id="curH1"></span> &nbsp;·&nbsp; <span id="curH2"></span>
</div>
</div>
</div>
<div class="btnRow" style="margin-top:12px;">
<button id="btnReset" class="ctl" type="button">Reset</button>
<button id="btnClear" class="ctl" type="button">Clear</button>
<button id="btnReconnect" class="ctl" type="button">Reconnect</button>
</div>
<div class="foot" id="statusText"></div>
</div>
</div>
<div>
<!-- Primary: one grouped bar chart. Rows are signals, bars are horizons. -->
<div class="card">
<div class="cardHead">
<h2 class="title">Correlation with true error</h2>
<div class="spacer"></div>
<button id="btnTable" class="ctl" type="button" aria-pressed="false">Table</button>
</div>
<p class="sub">
Pearson <em>r</em> against the <b>unnormalized</b> ground-truth-action rollout error at
<b>H=<span id="h1Lbl">2</span></b> steps, over <b id="nsLbl">all</b> samples.
</p>
<div class="legend" id="legendBars"></div>
<div class="panels" id="panels"></div>
<div id="tableWrap" style="display:none; margin-top:12px;">
<table class="tv" id="tv">
<thead><tr><th>Signal</th><th>r</th><th>r</th></tr></thead>
<tbody id="tvBody"></tbody>
</table>
</div>
<div class="foot" id="barFoot">Waiting for the first rollout sample…</div>
<div class="foot">
Rollout horizon
<input id="hRange" type="range" min="1" max="8" step="1" value="2"
style="vertical-align:middle; width:110px; accent-color: var(--s4);"/>
<b id="hVal" style="color:var(--ink)">2</b> steps
<span style="opacity:.7">· recomputed from samples already collected</span>
</div>
<div class="foot">
Samples scored
<input id="nsRange" type="range" min="10" max="310" step="10" value="310"
style="vertical-align:middle; width:130px; accent-color: var(--s1);"/>
<b id="nsVal" style="color:var(--ink)">all</b>
<span style="opacity:.7">· the most recent N; the far right is every sample since reset</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tt" id="tt" role="tooltip" aria-hidden="true"></div>
<script>
(() => {
"use strict";
const TASK_SET = __TASK_SET__;
const INITIAL_TASK = "__INITIAL_TASK__";
// Categorical slots in FIXED order — colour follows the signal, never its rank,
// so filtering or reordering never repaints a series.
const SERIES = [
{ key: "u_r", name: "u_r", css: "--s1", note: "round-trip" },
{ key: "u_f", name: "u_f", css: "--s2", note: "flow instability" },
{ key: "u_s", name: "u_s", css: "--s3", note: "inter-seed" },
{ key: "wav", name: "WAV", css: "--s4", note: "Ours" },
];
const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const colorOf = (s) => cssVar(s.css);
/** The server streams per-step rollout distances, so any horizon is a prefix
mean over data already held — changing H never costs a round trip and never
discards accumulated samples. `h` here is a step count, not a row index. */
const prefixMean = (arr, h) => {
if (!Array.isArray(arr) || !arr.length) return NaN;
const n = Math.max(1, Math.min(h, arr.length));
let acc = 0;
for (let i = 0; i < n; i++) acc += arr[i];
return acc / n;
};
const valueAt = (s, key, h) =>
(key === "wav" ? prefixMean(s.wav_steps, h) : s[key]);
const targetAt = (s, h) => prefixMean(s.err_steps, h);
const $ = (id) => document.getElementById(id);
const ledEl = $("led"), connEl = $("conn"), statusTextEl = $("statusText");
const taskSelectEl = $("taskSelect"), ttEl = $("tt");
const hRangeEl = $("hRange"), hValEl = $("hVal");
const curH1El = $("curH1"), curH2El = $("curH2");
const barFootEl = $("barFoot");
let H1 = 2, HMAX = 8, showTable = false; // H1 is user-chosen
// How many of the most recent samples a correlation is computed over.
// 0 means "all since reset", which is what the slider's far right selects.
let NSAMP = 0;
let uiError = false; // report the first render failure, then stop shouting
// ---------- state ----------
const MAX_POINTS = 4000;
const samples = []; // {step, u_r, u_f, u_s, wav_steps[], err_steps[]}
let lastStep = -1;
function pearson(xs, ys) {
const n = xs.length;
if (n < 3) return null;
let sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0;
for (let i = 0; i < n; i++) {
const x = xs[i], y = ys[i];
sx += x; sy += y; sxx += x * x; syy += y * y; sxy += x * y;
}
const cov = sxy - sx * sy / n, vx = sxx - sx * sx / n, vy = syy - sy * sy / n;
if (vx <= 1e-18 || vy <= 1e-18) return null;
return cov / Math.sqrt(vx * vy);
}
function corr(key, h, lo, hi) {
const xs = [], ys = [];
for (let i = lo; i < hi; i++) {
const s = samples[i], x = valueAt(s, key, h), y = targetAt(s, h);
if (Number.isFinite(x) && Number.isFinite(y)) { xs.push(x); ys.push(y); }
}
return pearson(xs, ys);
}
const scoreLo = () => (NSAMP > 0 ? Math.max(0, samples.length - NSAMP) : 0);
const cumR = (key, h) => corr(key, h, scoreLo(), samples.length);
// ---------- svg helpers ----------
const NS = "http://www.w3.org/2000/svg";
function el(tag, attrs, parent) {
const n = document.createElementNS(NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function txt(parent, s, attrs) {
const n = el("text", attrs, parent);
n.appendChild(document.createTextNode(s)); // untrusted labels: never innerHTML
return n;
}
const fmt = (r) => (r == null ? "—" : (r >= 0 ? "+" : "−") + Math.abs(r).toFixed(3));
function showTip(html, x, y) {
ttEl.textContent = "";
ttEl.appendChild(html);
ttEl.style.opacity = "1";
ttEl.setAttribute("aria-hidden", "false");
const r = ttEl.getBoundingClientRect();
let left = x + 14, top = y - r.height / 2;
if (left + r.width > window.innerWidth - 8) left = x - r.width - 14;
top = Math.max(8, Math.min(top, window.innerHeight - r.height - 8));
ttEl.style.left = left + "px"; ttEl.style.top = top + "px";
}
function hideTip() { ttEl.style.opacity = "0"; ttEl.setAttribute("aria-hidden", "true"); }
function tipNode(head, rows) {
const frag = document.createDocumentFragment();
const h = document.createElement("div");
h.className = "ttHead"; h.textContent = head; frag.appendChild(h);
for (const r of rows) {
const d = document.createElement("div"); d.className = "ttRow";
const k = document.createElement("i"); k.className = "stroke";
k.style.background = r.color; d.appendChild(k);
const nm = document.createElement("span"); nm.className = "ttName";
nm.textContent = r.name; d.appendChild(nm);
const v = document.createElement("span"); v.className = "ttVal";
v.textContent = r.value; d.appendChild(v);
frag.appendChild(d);
}
return frag;
}
// ---------- legends ----------
function buildLegends() {
for (const [host, mark] of [[$("legendBars"), "swatch"]]) {
host.textContent = "";
for (const s of SERIES) {
const item = document.createElement("span"); item.className = "item";
const sw = document.createElement("i"); sw.className = mark;
sw.style.background = colorOf(s); item.appendChild(sw);
const nm = document.createElement("span"); nm.textContent = s.name; item.appendChild(nm);
const tg = document.createElement("span"); tg.className = "tag"; tg.textContent = s.note;
item.appendChild(tg);
host.appendChild(item);
}
}
}
// ---------- per-signal panels ----------
// One panel per signal: the headline is the number, the bar is only its scale.
// Two signed bars per panel — solid = cumulative (cum), outlined = instantaneous (ins) —
// sharing a zero baseline so sign reads instantly.
let panelsBuilt = false;
const panelSvgs = {}, panelCum = {};
function buildPanels() {
const host = $("panels"); host.textContent = "";
for (const s of SERIES) {
const p = document.createElement("div"); p.className = "panel";
const nm = document.createElement("div"); nm.className = "pName";
const sw = document.createElement("i"); sw.className = "swatch";
sw.style.background = colorOf(s); nm.appendChild(sw);
nm.appendChild(document.createTextNode(s.name));
const tg = document.createElement("span"); tg.className = "pTag"; tg.textContent = s.note;
nm.appendChild(tg);
p.appendChild(nm);
const nums = document.createElement("div"); nums.className = "pNums";
const c = document.createElement("span"); c.className = "pCum"; c.textContent = "—";
nums.appendChild(c); p.appendChild(nums);
panelCum[s.key] = c;
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", `correlation of ${s.name} with true error`);
p.appendChild(svg); panelSvgs[s.key] = svg;
host.appendChild(p);
}
panelsBuilt = true;
}
function drawPanel(s) {
const svg = panelSvgs[s.key];
const W = svg.clientWidth || 130, H = 84;
const padT = 6, padB = 12;
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.setAttribute("height", H);
svg.textContent = "";
const plotH = H - padT - padB;
const yZero = padT + plotH / 2;
const yAt = (r) => yZero - (plotH / 2) * Math.max(-1, Math.min(1, r));
for (const g of [1, 0.5, -0.5, -1]) {
el("line", { x1: 0, x2: W, y1: yAt(g), y2: yAt(g),
stroke: cssVar("--grid"), "stroke-width": 1 }, svg);
}
el("line", { x1: 0, x2: W, y1: yZero, y2: yZero,
stroke: cssVar("--axis"), "stroke-width": 1 }, svg);
const rc = cumR(s.key, H1);
panelCum[s.key].textContent = fmt(rc);
if (rc == null) {
txt(svg, "n<3", { x: W / 2, y: yZero - 4, "text-anchor": "middle", class: "hint" });
return;
}
const barW = Math.min(22, W * 0.26), gap = 2; // <=24px thick, 2px surface gap
const cx = W / 2;
const draw = (r, x) => {
if (r == null) return;
const y = yAt(r), top = Math.min(y, yZero), h = Math.max(Math.abs(y - yZero), 2);
const rad = Math.min(4, h / 2, barW / 2); // rounded data-end, square at baseline
const neg = r < 0;
const d = neg
? `M${x},${yZero} h${barW} v${h - rad} a${rad},${rad} 0 0 1 -${rad},${rad} h-${barW - 2 * rad} a${rad},${rad} 0 0 1 -${rad},-${rad} z`
: `M${x},${yZero} h${barW} v-${h - rad} a${rad},${rad} 0 0 0 -${rad},-${rad} h-${barW - 2 * rad} a${rad},${rad} 0 0 0 -${rad},${rad} z`;
el("path", { d, fill: colorOf(s), opacity: 0.95 }, svg);
};
draw(rc, cx - barW / 2);
const hit = el("rect", { x: 0, y: 0, width: W, height: H, fill: "transparent", tabindex: "0" }, svg);
const tip = () => tipNode(`${s.name} · H=${H1}`, [
{ color: colorOf(s), name: "cumulative", value: fmt(rc) },
]);
hit.addEventListener("pointermove", (e) => showTip(tip(), e.clientX, e.clientY));
hit.addEventListener("pointerleave", hideTip);
hit.addEventListener("focus", () => {
const b = hit.getBoundingClientRect();
showTip(tip(), b.right, b.top + b.height / 2);
});
hit.addEventListener("blur", hideTip);
}
function drawBars() {
if (!panelsBuilt) buildPanels();
for (const s of SERIES) {
drawPanel(s);
}
$("h1Lbl").textContent = String(H1);
$("tH1Lbl").textContent = String(H1);
$("tH2Lbl").textContent = String(HMAX);
}
// ---------- table view (the relief path; every value reachable without hover) ----------
function drawTable() {
const body = $("tvBody");
body.textContent = "";
for (const s of SERIES) {
const tr = document.createElement("tr");
const td0 = document.createElement("td"); td0.className = "k";
const sw = document.createElement("i"); sw.className = "swatch";
sw.style.background = colorOf(s); td0.appendChild(sw);
td0.appendChild(document.createTextNode(s.name));
tr.appendChild(td0);
for (const h of [H1, HMAX]) {
const td = document.createElement("td");
td.textContent = fmt(cumR(s.key, h));
tr.appendChild(td);
}
body.appendChild(tr);
}
document.querySelectorAll("#tv thead th")[1].textContent = `r @H=${H1}`;
document.querySelectorAll("#tv thead th")[2].textContent = `r @H=${HMAX}`;
}
function redraw() {
drawBars(); drawTable();
barFootEl.textContent = samples.length < 3
? `Waiting for rollout samples — ${samples.length} so far, 3 needed.`
: (NSAMP > 0 && NSAMP < samples.length)
? `${samples.length} rollout samples · scoring the most recent ${NSAMP}`
: `${samples.length} rollout samples · scoring all of them`;
}
// ---------- transport ----------
let ws = null, pendingTask = null;
const down = new Set();
const CAPTURE = ["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","w","W","a","A","s","S","d","D","Space","r","R","q","Q","Escape"];
const keyName = (e) => (e.key === " " ? "Space" : e.key);
function send(o) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)); }
function releaseAll() { for (const k of Array.from(down)) send({ type: "keyup", key: k }); down.clear(); }
function setLed(cls, label) { ledEl.className = "led " + cls; connEl.textContent = label; }
function clearHist() { samples.length = 0; lastStep = -1; redraw(); }
function num(v) { return (typeof v === "number" && Number.isFinite(v)) ? v : NaN; }
function onStatus(msg) {
if (typeof msg.step === "number") {
if (msg.step < lastStep) { samples.length = 0; }
lastStep = msg.step;
$("tStep").textContent = String(msg.step);
}
// The server streams `wav_max_horizon` steps; the slider may not exceed it.
if (typeof msg.wav_max_horizon === "number" && msg.wav_max_horizon !== HMAX) {
HMAX = msg.wav_max_horizon;
hRangeEl.max = String(HMAX);
if (H1 > HMAX) { H1 = HMAX; hRangeEl.value = String(H1); hValEl.textContent = String(H1); }
}
if (typeof msg.text === "string") statusTextEl.textContent = msg.text;
const set = (id, v) => { if (Number.isFinite(num(v))) $(id).textContent = num(v).toFixed(3); };
set("tErr", msg.true_error); set("tWav", msg.wav);
set("tUr", msg.u_r); set("tUf", msg.u_f); set("tUs", msg.u_s);
// A sample is the pair of per-step rollout distance arrays. Storing them
// whole (rather than a mean at one horizon) is what lets the horizon slider
// rescore history instead of restarting it.
const wav = msg.wav_steps, err = msg.err_steps;
if (!Array.isArray(wav) || !Array.isArray(err) || !wav.length || wav.length !== err.length) return;
samples.push({ step: (typeof msg.step === "number") ? msg.step : samples.length,
u_r: num(msg.u_r), u_f: num(msg.u_f), u_s: num(msg.u_s),
wav_steps: wav, err_steps: err });
while (samples.length > MAX_POINTS) samples.shift();
const f3 = (v) => (Number.isFinite(v) ? v.toFixed(3) : "—");
curH1El.textContent = `${f3(prefixMean(wav, H1))}/${f3(prefixMean(err, H1))}`;
curH2El.textContent = `${f3(prefixMean(wav, HMAX))}/${f3(prefixMean(err, HMAX))}`;
redraw();
const t = (typeof msg.task === "string") ? msg.task : null;
if (pendingTask && t === pendingTask) pendingTask = null;
if (t && !pendingTask && taskSelectEl.value !== t) taskSelectEl.value = t;
}
function connect() {
pendingTask = null;
setLed("connecting", "connecting…");
const proto = location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.binaryType = "arraybuffer";
ws.onopen = () => setLed("live", "connected");
ws.onclose = () => { releaseAll(); setLed("", "disconnected"); };
ws.onerror = () => setLed("", "connection error");
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
let m;
try { m = JSON.parse(ev.data); } catch (e) { return; } // malformed frame: skip
if (m && m.type === "status") {
try {
onStatus(m);
} catch (e) {
// A bug in the render path used to be swallowed here, so the panels
// sat frozen while data kept arriving — indistinguishable from a
// stalled feed. Surface it instead of hiding it.
console.error("onStatus failed:", e);
if (!uiError) {
uiError = true;
statusTextEl.textContent = `UI error (see console): ${e.message}`;
}
}
}
return;
}
const url = URL.createObjectURL(new Blob([ev.data], { type: "image/jpeg" }));
const img = $("frame");
img.onload = () => { if (img.dataset.last) URL.revokeObjectURL(img.dataset.last); img.dataset.last = url; };
img.src = url;
};
}
// ---------- controls ----------
for (const t of TASK_SET) {
const o = document.createElement("option"); o.value = t; o.textContent = t;
taskSelectEl.appendChild(o);
}
taskSelectEl.value = INITIAL_TASK;
taskSelectEl.addEventListener("change", () => {
pendingTask = taskSelectEl.value;
send({ type: "set_task", task: pendingTask });
clearHist();
});
window.addEventListener("keydown", (e) => {
const k = keyName(e);
if (!CAPTURE.includes(k)) return;
e.preventDefault();
if (k === "Space") return send({ type: "toggle_pause" });
if (k === "r" || k === "R") { send({ type: "reset" }); return clearHist(); }
if (k === "q" || k === "Q" || k === "Escape") return send({ type: "disconnect" });
if (!down.has(k)) { down.add(k); send({ type: "keydown", key: k }); }
}, { passive: false });
window.addEventListener("keyup", (e) => {
const k = keyName(e);
if (!CAPTURE.includes(k)) return;
e.preventDefault();
if (["Space","r","R","q","Q","Escape"].includes(k)) return;
if (down.has(k)) { down.delete(k); send({ type: "keyup", key: k }); }
}, { passive: false });
window.addEventListener("blur", releaseAll);
$("stage").addEventListener("pointerdown", () => send({ type: "toggle_pause" }));
$("btnReset").onclick = () => { send({ type: "reset" }); clearHist(); };
$("btnClear").onclick = clearHist;
$("btnReconnect").onclick = connect;
$("btnTable").onclick = (e) => {
showTable = !showTable;
$("tableWrap").style.display = showTable ? "" : "none";
e.currentTarget.setAttribute("aria-pressed", String(showTable));
};
const nsRangeEl = $("nsRange"), nsValEl = $("nsVal"), nsLblEl = $("nsLbl");
nsRangeEl.oninput = () => {
const v = parseInt(nsRangeEl.value, 10);
NSAMP = (v >= parseInt(nsRangeEl.max, 10)) ? 0 : v; // far right = all
const label = NSAMP === 0 ? "all" : String(NSAMP);
nsValEl.textContent = label;
nsLblEl.textContent = label;
redraw(); // rescored from samples already held
};
hRangeEl.oninput = () => {
H1 = parseInt(hRangeEl.value, 10);
hValEl.textContent = String(H1);
$("h1Lbl").textContent = String(H1);
redraw(); // prefix means over samples already held — no refetch
};
$("btnTheme").onclick = () => {
const dark = document.documentElement.getAttribute("data-theme") === "dark"
|| (!document.documentElement.hasAttribute("data-theme")
&& matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.setAttribute("data-theme", dark ? "light" : "dark");
panelsBuilt = false; // swatches carry inline colours; rebuild them
buildLegends(); redraw();
};
console.log("page build __BUILD__");
buildLegends();
redraw();
connect();
window.addEventListener("resize", redraw);
})();
</script>
</body>
</html>