flashback / index.html
NagaYu's picture
Upload folder using huggingface_hub
37a0e27 verified
Raw
History Blame Contribute Delete
24 kB
<!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))));
/* JSON has no Inf/NaN, so the exporter writes null for every non-finite value. */
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>`;
// A single metric crossing its threshold is not a finding. Say so next to
// the number, not only in a footnote -- on the healthy control run this is
// the difference between "no fault" and an apparent false positive.
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);
// Plot the metrics that voted for the ANSWER, strongest first -- not the
// globally earliest detections. Across 124 correlated statistics a couple
// will always twitch early; charting those instead of the ones that actually
// localise the fault would make the picture argue against itself.
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);
}
/* --- charts ------------------------------------------------------------- */
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
&nbsp; <span style="color:var(--loss)">▎</span> where the loss curve notices
&nbsp; <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(" &nbsp;&nbsp; ") +
(thr !== null ? ` &nbsp;&nbsp; <span class="muted">— — calibrated threshold ${fmt(thr)}</span>` : "");
}
/* --- manual bisect ------------------------------------------------------ */
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 ? "" :
` &nbsp; 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;
}
/* --- self-check against the Python reference ---------------------------- */
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>