Spaces:
Running on Zero
Running on Zero
| <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 · 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> · <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> | |