| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Flashback — find the step your training run broke on</title> |
| <style> |
| :root { |
| --bg: #ffffff; --fg: #1a1a1a; --muted: #666; --line: #e3e3e6; |
| --card: #fafafa; --accent: #1f77b4; --loss: #c0392b; --ok: #2ca02c; |
| --warn: #e08a1e; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| } |
| @media (prefers-color-scheme: dark) { |
| :root { --bg:#0f1115; --fg:#e8e8ea; --muted:#9aa0a6; --line:#2a2d34; |
| --card:#161920; --accent:#5aa9e6; --loss:#ff6b5a; --ok:#5ec26a; } |
| } |
| * { box-sizing: border-box; } |
| body { margin:0; background:var(--bg); color:var(--fg); |
| font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } |
| .wrap { max-width: 1100px; margin: 0 auto; padding: 28px 20px 80px; } |
| h1 { font-size: 30px; margin: 0 0 6px; letter-spacing: -0.02em; } |
| h2 { font-size: 19px; margin: 34px 0 10px; letter-spacing: -0.01em; } |
| h3 { font-size: 15px; margin: 20px 0 8px; } |
| .lede { color: var(--muted); font-size: 16px; margin: 0 0 4px; } |
| a { color: var(--accent); } |
| .card { background: var(--card); border: 1px solid var(--line); |
| border-radius: 10px; padding: 16px 18px; } |
| .row { display: flex; flex-wrap: wrap; gap: 10px; } |
| button, select, input[type=number] { |
| font: inherit; color: var(--fg); background: var(--bg); |
| border: 1px solid var(--line); border-radius: 7px; padding: 7px 12px; cursor: pointer; } |
| button:hover { border-color: var(--accent); } |
| button.sel { background: var(--accent); color: #fff; border-color: var(--accent); } |
| button.primary { background: var(--accent); color:#fff; border-color: var(--accent); |
| font-weight: 600; } |
| label { font-size: 13px; color: var(--muted); display: flex; flex-direction: column; |
| gap: 4px; } |
| table { border-collapse: collapse; width: 100%; font-size: 13.5px; } |
| th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line); } |
| th { color: var(--muted); font-weight: 600; } |
| td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } |
| code, .mono { font-family: var(--mono); font-size: 12.5px; } |
| .pill { display:inline-block; padding: 2px 9px; border-radius: 999px; font-size: 12px; |
| font-weight: 600; } |
| .pill.ok { background: rgba(44,160,44,.15); color: var(--ok); } |
| .pill.bad { background: rgba(192,57,43,.15); color: var(--loss); } |
| .pill.warn { background: rgba(224,138,30,.15); color: var(--warn); } |
| .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } |
| @media (max-width: 820px) { .grid2 { grid-template-columns: 1fr; } } |
| .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 14px; } |
| .kv dt { color: var(--muted); } |
| .kv dd { margin: 0; font-variant-numeric: tabular-nums; } |
| .muted { color: var(--muted); } |
| .small { font-size: 13px; } |
| svg { width: 100%; height: auto; display: block; } |
| .axis { stroke: var(--line); stroke-width: 1; } |
| .tick { fill: var(--muted); font-size: 10px; } |
| .scroll { overflow-x: auto; } |
| footer { margin-top: 48px; padding-top: 18px; border-top: 1px solid var(--line); |
| color: var(--muted); font-size: 13.5px; } |
| </style> |
| </head> |
| <body> |
| <div class="wrap"> |
|
|
| <h1>Flashback</h1> |
| <p class="lede">Find the step a training run broke on, from |
| <strong>452 bytes per step</strong>.</p> |
| <p class="small muted" style="max-width:78ch"> |
| The top chart is what you would see on your dashboard. The bottom chart is what a |
| 452-byte-per-step sketch saw at the same steps. Everything below is computed in your |
| browser from <em>recorded telemetry of real training runs with real injected faults</em> |
| — the ground truth was written down before any detector looked at the data. |
| </p> |
|
|
| <div id="scenarios" class="row" style="margin:18px 0 14px"></div> |
| <p id="blurb" class="small muted" style="margin:0 0 16px"></p> |
|
|
| <div class="card"> |
| <div class="row" style="align-items:flex-end"> |
| <label>metric |
| <select id="metric"><option value="auto">auto — test all of them</option></select> |
| </label> |
| <label>baseline window |
| <input id="win" type="number" min="8" max="128" step="4" value="32" style="width:92px"> |
| </label> |
| <label>metrics required to agree |
| <input id="votes" type="number" min="1" max="12" value="3" style="width:92px"> |
| </label> |
| <label>vote window |
| <input id="vwin" type="number" min="0" max="20" value="4" style="width:92px"> |
| </label> |
| <label>mode |
| <select id="mode"> |
| <option value="diff">diff — regime change</option> |
| <option value="level">level — excursion</option> |
| </select> |
| </label> |
| <label style="flex-direction:row;align-items:center;gap:7px"> |
| <input id="strict" type="checkbox"> report nothing without consensus |
| </label> |
| <button id="run" class="primary">Run bisect</button> |
| </div> |
| </div> |
|
|
| <div id="result" class="card" style="margin-top:14px"></div> |
|
|
| <h2>What the loss curve shows</h2> |
| <div id="lossChart"></div> |
|
|
| <h2>What the sketch shows at the same steps</h2> |
| <div id="zChart"></div> |
| <p class="small muted" id="zlegend"></p> |
|
|
| <h2>Earliest detection, by metric</h2> |
| <p id="metricsNote" class="small muted" style="margin:0 0 10px;max-width:78ch"></p> |
| <div class="scroll"><table id="metrics"><tbody></tbody></table></div> |
|
|
| <h2>Bisect by hand</h2> |
| <p class="small muted" style="max-width:78ch"> |
| Each probe asks the <code>git bisect</code> question — |
| <strong>has anything gone wrong at or before this step?</strong> — and halves the |
| bracket. That phrasing matters: <em>“is <strong>this step</strong> bad?”</em> is not |
| monotone (a flipped bit is bad at exactly one step), so a binary search on it walks off |
| the end of the run. <em>“has it gone bad <strong>yet</strong>?”</em> is monotone — and it |
| is also the question you can afford to ask expensively, since in the deterministic replay |
| mode each answer costs one state reconstruction. |
| </p> |
| <div class="card"> |
| <div class="row" style="align-items:flex-end"> |
| <label>metric to probe |
| <select id="mmetric" style="max-width:280px"></select> |
| </label> |
| <label>probe this step |
| <input id="mstep" type="number" value="0" style="width:110px"> |
| </label> |
| <button id="mprobe" class="primary">Probe</button> |
| <button id="mreset">Start over</button> |
| </div> |
| <div id="manual" style="margin-top:14px"></div> |
| </div> |
|
|
| <footer> |
| <p><strong>What this demo is honest about.</strong> |
| The detector needs a healthy stretch to calibrate against — a run broken from step 0 has |
| no baseline. It detects <em>regime changes</em>, so a fault that ramps in over hundreds of |
| steps has no single correct answer. No bitwise determinism is claimed anywhere: this is a |
| statistical detector over recorded statistics. The demo model is tiny |
| (<span id="fparams"></span>) so the page stays small; the mechanism is size-independent |
| and the repository's benchmark runs the same experiments up to ~30M parameters.</p> |
| <p> |
| <a href="https://github.com/NagaYu/flashback">Code on GitHub</a> · |
| <a href="https://huggingface.co/datasets/NagaYu/flashback-forensics">Dataset</a> · |
| <a href="https://huggingface.co/NagaYu/flashback-first-bad-step">Detector model</a> |
| · Apache-2.0 |
| </p> |
| <p id="parity" class="small"></p> |
| </footer> |
| </div> |
|
|
| <script type="module"> |
| import { detectOnSeries, zSeries, bisectFirstBad, prefixBad, HARD_SIGNALS } |
| from "./detector.js"; |
| |
| const $ = (id) => document.getElementById(id); |
| const state = { index: null, data: null, key: null, manual: null }; |
| |
| const fmt = (v) => (v === null || v === undefined) ? "—" |
| : (!Number.isFinite(v) ? "∞" : (Math.abs(v) >= 1000 ? v.toExponential(2) |
| : (Number.isInteger(v) ? String(v) : v.toFixed(2)))); |
| |
| |
| const revive = (arr) => Float64Array.from(arr, (v) => (v === null ? NaN : v)); |
| |
| async function boot() { |
| state.index = await (await fetch("data/index.json")).json(); |
| const bar = $("scenarios"); |
| state.index.scenarios.forEach((s, i) => { |
| const b = document.createElement("button"); |
| b.textContent = s.title; |
| b.onclick = () => select(s.key); |
| b.dataset.key = s.key; |
| bar.appendChild(b); |
| if (i === 0) b.classList.add("sel"); |
| }); |
| const msel = $("metric"), mm = $("mmetric"); |
| for (const spec of state.index.metrics) { |
| msel.appendChild(new Option(spec, spec)); |
| mm.appendChild(new Option(spec, spec)); |
| } |
| mm.value = "grad_norm"; |
| $("run").onclick = render; |
| $("mprobe").onclick = manualProbe; |
| $("mreset").onclick = manualReset; |
| for (const id of ["metric", "win", "votes", "vwin", "mode", "strict"]) { |
| $(id).onchange = render; |
| } |
| $("mmetric").onchange = manualReset; |
| await select(state.index.scenarios[0].key); |
| } |
| |
| async function select(key) { |
| document.querySelectorAll("#scenarios button") |
| .forEach((b) => b.classList.toggle("sel", b.dataset.key === key)); |
| const raw = await (await fetch(`data/${key}.json`)).json(); |
| raw.metricsF = {}; |
| for (const [k, v] of Object.entries(raw.metrics)) raw.metricsF[k] = revive(v); |
| state.data = raw; |
| state.key = key; |
| $("blurb").textContent = raw.blurb; |
| $("fparams").textContent = |
| `${raw.preset}, ${(raw.param_count / 1e6).toFixed(2)}M parameters, ${raw.n_steps} steps`; |
| $("win").value = raw.win; |
| render(); |
| manualReset(); |
| } |
| |
| function opts() { |
| return { |
| win: +$("win").value, gap: 1, mode: $("mode").value, |
| minVotes: +$("votes").value, voteWindow: +$("vwin").value, |
| requireConsensus: $("strict").checked, |
| }; |
| } |
| |
| function render() { |
| const d = state.data; |
| if (!d) return; |
| const o = opts(); |
| const steps = d.steps; |
| const chosen = $("metric").value; |
| const specs = chosen === "auto" ? state.index.metrics : [chosen]; |
| const res = bisectFirstBad(d.metricsF, steps, specs, o); |
| const loss = detectOnSeries(d.metricsF["loss"], steps, "loss", o); |
| |
| const err = (v) => (d.truth === null || v === null || v === undefined) |
| ? "" : ` <span class="muted">(error <strong>${Math.abs(v - d.truth)}</strong> steps)</span>`; |
| const truthTxt = d.truth === null |
| ? '<span class="pill ok">none — this run is healthy</span>' : `<strong>${d.truth}</strong>`; |
| |
| |
| |
| const weak = res.step !== null && res.votes < o.minVotes; |
| const fbTxt = res.step === null |
| ? '<span class="pill bad">no detection</span>' |
| : (weak |
| ? `<span class="muted" style="font-size:16px">${res.step}</span> |
| <span class="pill warn">low confidence — only ${res.votes} of |
| ${o.minVotes} required metrics agree</span>` |
| : `<strong style="font-size:18px">${res.step}</strong>` + err(res.step)); |
| const lossTxt = loss.detected |
| ? `<strong>${loss.step}</strong>` + err(loss.step) |
| : '<span class="pill bad">never detected</span>'; |
| |
| $("result").innerHTML = ` |
| <dl class="kv"> |
| <dt>ground truth</dt><dd>${truthTxt}</dd> |
| <dt>Flashback bisect</dt><dd>${fbTxt}</dd> |
| <dt>loss curve alone</dt><dd>${lossTxt}</dd> |
| <dt>metric that fired first</dt> |
| <dd><code>${res.metric}</code> — z = ${fmt(res.z)}, threshold ${fmt(res.threshold)}</dd> |
| <dt>metrics that agree</dt> |
| <dd>${res.votes} of ${state.index.metrics.length} recorded |
| ${weak ? '<span class="muted">— below the ' + o.minVotes + |
| ' required, so this is not a finding</span>' : ""}</dd> |
| <dt>cost</dt><dd>${res.indexReads.toLocaleString()} index reads, |
| <strong>${res.stateProbes} state reconstructions</strong></dd> |
| <dt>sketch size</dt><dd>${d.bytes_per_step} B/step |
| (${(d.total_bytes / 1024).toFixed(0)} KiB for the whole run)</dd> |
| </dl> |
| ${res.note ? `<p class="small muted" style="margin:10px 0 0">▸ ${res.note}</p>` : ""}`; |
| |
| drawLoss(d, steps, res.step, loss.detected ? loss.step : null); |
| |
| |
| |
| |
| |
| const inWindow = res.index === undefined ? [] |
| : res.candidates.filter((c) => c.index >= res.index && c.index <= res.index + o.voteWindow); |
| const ranked = (inWindow.length ? inWindow : res.candidates) |
| .slice() |
| .sort((a, b) => (b.z === a.z ? 0 : (b.z > a.z ? 1 : -1))); |
| const picks = []; |
| for (const c of ranked) { |
| if (!picks.includes(c.spec)) picks.push(c.spec); |
| if (picks.length >= 4) break; |
| } |
| for (const f of ["grad_norm", "grad_max_abs", "update_max_abs", "sign_flip_rate"]) { |
| if (picks.length < 4 && !picks.includes(f)) picks.push(f); |
| } |
| drawZ(d, steps, picks, res.step, o); |
| |
| const rows = []; |
| const seen = new Set(); |
| const votedFor = new Set(inWindow.map((c) => c.spec)); |
| for (const c of res.candidates) { |
| if (seen.has(c.spec)) continue; |
| seen.add(c.spec); |
| const tag = votedFor.has(c.spec) |
| ? '<span class="pill ok">voted</span>' |
| : '<span class="pill warn">isolated</span>'; |
| rows.push(`<tr><td class="num">${c.step}</td><td class="num">${fmt(c.z)}</td> |
| <td>${tag}</td><td><code>${c.spec}</code></td></tr>`); |
| if (rows.length >= 14) break; |
| } |
| $("metrics").innerHTML = |
| `<thead><tr><th class="num">step</th><th class="num">z</th><th></th> |
| <th>metric</th></tr></thead> |
| <tbody>${rows.join("") || '<tr><td colspan="4" class="muted">nothing crossed its threshold</td></tr>'}</tbody>`; |
| $("metricsNote").innerHTML = votedFor.size |
| ? `<strong>${votedFor.size}</strong> metrics fired within ${o.voteWindow} steps of each |
| other and carried the answer (<span class="pill ok">voted</span>). |
| The <span class="pill warn">isolated</span> ones fired earlier but alone — across |
| ${state.index.metrics.length} correlated statistics a few always will, which is |
| exactly why a single loud metric is not allowed to decide.` |
| : "No metric reached consensus with any other."; |
| |
| checkParity(res, loss); |
| } |
| |
| |
| function chart(w, h, pad) { |
| return { w, h, pad, parts: [] }; |
| } |
| function axes(c, xmin, xmax, ymin, ymax, ylog, ylabel) { |
| const { w, h, pad } = c; |
| c.x = (v) => pad.l + (v - xmin) / (xmax - xmin || 1) * (w - pad.l - pad.r); |
| const ly = (v) => ylog ? Math.log10(Math.max(v, ymin)) : v; |
| const a = ly(ymin), b = ly(ymax); |
| c.y = (v) => h - pad.b - (ly(v) - a) / ((b - a) || 1) * (h - pad.t - pad.b); |
| c.parts.push(`<line class="axis" x1="${pad.l}" y1="${h - pad.b}" x2="${w - pad.r}" |
| y2="${h - pad.b}"/>`); |
| for (let k = 0; k <= 4; k++) { |
| const xv = xmin + (xmax - xmin) * k / 4; |
| c.parts.push(`<text class="tick" x="${c.x(xv)}" y="${h - pad.b + 14}" |
| text-anchor="middle">${Math.round(xv)}</text>`); |
| } |
| const ticks = ylog |
| ? Array.from({ length: Math.min(7, Math.max(2, Math.ceil(b) - Math.floor(a) + 1)) }, |
| (_, i) => Math.pow(10, Math.floor(a) + i)).filter((v) => v <= ymax * 1.001) |
| : [ymin, (ymin + ymax) / 2, ymax]; |
| for (const t of ticks) { |
| c.parts.push(`<line class="axis" x1="${pad.l}" y1="${c.y(t)}" x2="${w - pad.r}" |
| y2="${c.y(t)}" stroke-opacity="0.35"/>`); |
| c.parts.push(`<text class="tick" x="${pad.l - 6}" y="${c.y(t) + 3}" |
| text-anchor="end">${t >= 1000 || (t > 0 && t < 0.01) |
| ? t.toExponential(0) : (+t.toPrecision(3))}</text>`); |
| } |
| if (ylabel) { |
| c.parts.push(`<text class="tick" x="12" y="${h / 2}" text-anchor="middle" |
| transform="rotate(-90 12 ${h / 2})">${ylabel}</text>`); |
| } |
| } |
| function line(c, xs, ys, color, width = 1.1) { |
| let d = "", pen = false; |
| for (let i = 0; i < xs.length; i++) { |
| const v = ys[i]; |
| if (!Number.isFinite(v) || v === null) { pen = false; continue; } |
| const X = c.x(xs[i]).toFixed(1), Y = c.y(v).toFixed(1); |
| d += (pen ? "L" : "M") + X + " " + Y + " "; |
| pen = true; |
| } |
| c.parts.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="${width}"/>`); |
| } |
| function vline(c, x, color, dash) { |
| if (x === null || x === undefined) return; |
| c.parts.push(`<line x1="${c.x(x)}" y1="${c.pad.t}" x2="${c.x(x)}" |
| y2="${c.h - c.pad.b}" stroke="${color}" stroke-width="2" |
| ${dash ? `stroke-dasharray="${dash}"` : ""} opacity="0.9"/>`); |
| } |
| function svg(c) { |
| return `<svg viewBox="0 0 ${c.w} ${c.h}" preserveAspectRatio="xMidYMid meet"> |
| ${c.parts.join("")}</svg>`; |
| } |
| |
| function drawLoss(d, steps, fb, lossStep) { |
| const ys = d.metricsF["loss"]; |
| let lo = Infinity, hi = -Infinity; |
| for (const v of ys) if (Number.isFinite(v) && v > 0) { lo = Math.min(lo, v); hi = Math.max(hi, v); } |
| const c = chart(1000, 250, { l: 58, r: 16, t: 12, b: 26 }); |
| axes(c, steps[0], steps[steps.length - 1], lo * 0.95, hi * 1.05, true, "loss (log)"); |
| vline(c, d.truth, "var(--fg)", "7 5"); |
| vline(c, lossStep, "var(--loss)", "2 4"); |
| vline(c, fb, "var(--accent)"); |
| line(c, steps, ys, "var(--loss)", 1.1); |
| $("lossChart").innerHTML = svg(c) + `<p class="small muted" style="margin:6px 0 0"> |
| <span style="color:var(--fg)">▎</span> ground truth |
| <span style="color:var(--loss)">▎</span> where the loss curve notices |
| <span style="color:var(--accent)">▎</span> Flashback</p>`; |
| } |
| |
| function drawZ(d, steps, picks, fb, o) { |
| const colors = ["var(--accent)", "#2ca02c", "#9467bd", "#17becf"]; |
| const c = chart(1000, 300, { l: 58, r: 16, t: 12, b: 26 }); |
| let hi = 10, thr = null; |
| const series = []; |
| picks.forEach((spec, i) => { |
| const raw = d.metricsF[spec]; |
| if (!raw) return; |
| const zs = zSeries(raw, spec, o); |
| const plot = new Float64Array(zs.z.length); |
| for (let j = 0; j < zs.z.length; j++) { |
| plot[j] = zs.ok[j] ? (Number.isFinite(zs.z[j]) ? Math.max(zs.z[j], 1e-3) : 1e7) : NaN; |
| if (Number.isFinite(plot[j])) hi = Math.max(hi, plot[j]); |
| } |
| if (thr === null && Number.isFinite(zs.threshold)) thr = zs.threshold; |
| const det = detectOnSeries(raw, steps, spec, o); |
| series.push({ spec, plot, color: colors[i % colors.length], det }); |
| }); |
| axes(c, steps[0], steps[steps.length - 1], 1e-3, hi * 3, true, "robust z (log)"); |
| if (thr !== null) { |
| c.parts.push(`<line class="axis" x1="${c.pad.l}" y1="${c.y(thr)}" |
| x2="${c.w - c.pad.r}" y2="${c.y(thr)}" stroke="var(--muted)" stroke-width="1.2" |
| stroke-dasharray="6 4"/>`); |
| } |
| vline(c, d.truth, "var(--fg)", "7 5"); |
| for (const s of series) line(c, steps, s.plot, s.color, 1.0); |
| for (const s of series) { |
| if (!s.det.detected) continue; |
| const yv = Number.isFinite(s.det.z) ? Math.max(s.det.z, 1e-3) : 1e7; |
| c.parts.push(`<circle cx="${c.x(s.det.step)}" cy="${c.y(yv)}" r="5" |
| fill="${s.color}" stroke="var(--bg)" stroke-width="1.5"/>`); |
| } |
| $("zChart").innerHTML = svg(c); |
| $("zlegend").innerHTML = series.map((s) => |
| `<span style="color:${s.color}">▎</span> <code>${s.spec}</code>` + |
| (s.det.detected ? ` → fires at <strong>${s.det.step}</strong>` : " (no detection)") |
| ).join(" ") + |
| (thr !== null ? ` <span class="muted">— — calibrated threshold ${fmt(thr)}</span>` : ""); |
| } |
| |
| |
| function manualReset() { |
| const d = state.data; |
| if (!d) return; |
| const spec = $("mmetric").value; |
| state.manual = { lo: 0, hi: d.n_steps - 1, probes: 0, log: [], spec, |
| zs: zSeries(d.metricsF[spec], spec, opts()) }; |
| $("mstep").value = Math.floor((state.manual.lo + state.manual.hi) / 2); |
| drawManual(); |
| } |
| |
| function manualProbe() { |
| const m = state.manual, d = state.data; |
| if (!m) return; |
| const i = Math.max(0, Math.min(d.n_steps - 1, +$("mstep").value)); |
| const { bad, z } = prefixBad(m.zs, i); |
| m.probes++; |
| m.log.push({ i, bad, z }); |
| if (bad) m.hi = i; else m.lo = Math.min(i + 1, m.hi); |
| $("mstep").value = Math.floor((m.lo + m.hi) / 2); |
| drawManual(); |
| } |
| |
| function drawManual() { |
| const m = state.manual, d = state.data; |
| const width = m.hi - m.lo + 1; |
| const ideal = Math.ceil(Math.log2(Math.max(2, d.n_steps))); |
| let html = `<dl class="kv"> |
| <dt>bracket</dt><dd>steps <code>${m.lo} … ${m.hi}</code> (${width} wide)</dd> |
| <dt>probes used</dt><dd>${m.probes} |
| <span class="muted">— a binary search over ${d.n_steps} steps needs about ${ideal}</span></dd>`; |
| if (d.truth !== null) { |
| const inside = m.lo <= d.truth && d.truth <= m.hi; |
| html += `<dt>ground truth</dt><dd>${d.truth} ${inside |
| ? '<span class="pill ok">inside the bracket</span>' |
| : '<span class="pill warn">outside — a probe answered differently</span>'}</dd>`; |
| } |
| html += "</dl>"; |
| if (width <= 1) { |
| const e = d.truth === null ? "" : |
| ` error <strong>${Math.abs(m.lo - d.truth)}</strong> steps`; |
| html += `<p style="margin:12px 0 0"><span class="pill ok">converged</span> |
| first bad step = <strong style="font-size:17px">${m.lo}</strong>${e}</p>`; |
| } |
| if (m.log.length) { |
| html += `<div class="scroll" style="margin-top:12px"><table> |
| <thead><tr><th class="num">probe</th><th class="num">step</th><th>verdict</th> |
| <th class="num">worst z so far</th></tr></thead><tbody>` + |
| m.log.map((r, k) => `<tr><td class="num">${k + 1}</td><td class="num">${r.i}</td> |
| <td>${r.bad ? '<span class="pill bad">BAD</span>' : '<span class="pill ok">ok</span>'}</td> |
| <td class="num">${fmt(r.z)}</td></tr>`).join("") + "</tbody></table></div>"; |
| } |
| $("manual").innerHTML = html; |
| } |
| |
| |
| function checkParity(res, loss) { |
| const d = state.data; |
| const o = opts(); |
| const isDefault = o.win === d.win && o.mode === "diff" && o.minVotes === 3 && |
| o.voteWindow === 4 && !o.requireConsensus && |
| $("metric").value === "auto"; |
| if (!isDefault) { |
| $("parity").innerHTML = `<span class="pill warn">settings changed</span> |
| Computed in-browser. The reference check below only applies at the default settings.`; |
| return; |
| } |
| const ref = d.reference; |
| const agreeStep = ref.consensus.step === res.step; |
| const agreeLoss = (ref.loss_only.step ?? null) === (loss.detected ? loss.step : null); |
| let mismatches = 0, checked = 0; |
| for (const [spec, r] of Object.entries(ref.per_metric)) { |
| const got = detectOnSeries(d.metricsF[spec], d.steps, spec, o); |
| checked++; |
| if (!got.detected || got.step !== r.step) mismatches++; |
| } |
| const ok = agreeStep && agreeLoss && mismatches === 0; |
| $("parity").innerHTML = ok |
| ? `<span class="pill ok">verified</span> This page's JavaScript detector reproduces |
| <code>flashback.bisect</code> exactly on this run: same consensus step |
| (${fmt(ref.consensus.step)}), same loss-curve verdict, and the same first-firing step |
| for all ${checked} metrics that fire. The Python answers are shipped in |
| <code>data/${d.key}.json</code> and checked on every render.` |
| : `<span class="pill bad">mismatch</span> The in-browser port disagrees with Python on |
| this run (${mismatches}/${checked} metrics differ${agreeStep ? "" : ", consensus step differs"}). |
| Trust <code>flashback.bisect</code>, not this page, and please |
| <a href="https://github.com/NagaYu/flashback/issues">file an issue</a>.`; |
| } |
| |
| boot(); |
| </script> |
| </body> |
| </html> |
|
|