/* A faithful JavaScript port of flashback/bisect.py. * * Hugging Face hosts static Spaces free for everyone but charges for Gradio * ones, so the public demo runs the detector in the browser. A port is only * worth anything if it agrees with the original, so every function below * mirrors a named Python function, and index.html checks the results against * the answers Python computed for the same data (shipped in data/*.json). * * Python's numeric conventions that matter here: * - np.nanmedian ignores non-finite entries in a window * - np.percentile uses linear interpolation * - non-finite metric values mean "bad" and score +Infinity */ export const MAD_TO_SIGMA = 1.4826; export const HARD_SIGNALS = new Set([ "nonfinite_grad", "nonfinite_param", "group:all:nonfinite", ]); function median(sorted) { const n = sorted.length; if (n === 0) return NaN; const m = n >> 1; return n % 2 ? sorted[m] : 0.5 * (sorted[m - 1] + sorted[m]); } function medianOf(values) { const f = []; for (const v of values) if (Number.isFinite(v)) f.push(v); f.sort((a, b) => a - b); return median(f); } /** numpy.percentile(a, q) with the default 'linear' method. */ function percentile(sortedAsc, q) { const n = sortedAsc.length; if (n === 0) return NaN; const pos = (q / 100) * (n - 1); const lo = Math.floor(pos), hi = Math.ceil(pos); if (lo === hi) return sortedAsc[lo]; return sortedAsc[lo] + (sortedAsc[hi] - sortedAsc[lo]) * (pos - lo); } /** Port of flashback.bisect.prepare_series. */ export function prepareSeries(x, mode = "diff") { const n = x.length; if (mode === "level" || n < 3) return Float64Array.from(x); let work = Float64Array.from(x); let allPositive = true, any = false, mn = Infinity, mx = -Infinity; for (const v of x) { if (!Number.isFinite(v)) continue; any = true; if (v <= 0) allPositive = false; if (v < mn) mn = v; if (v > mx) mx = v; } if (any && allPositive) { const span = mx / Math.max(mn, 1e-300); if (span > 100) for (let i = 0; i < n; i++) work[i] = Math.log(Math.max(work[i], 1e-300)); } const d = new Float64Array(n); for (let i = 1; i < n; i++) d[i] = work[i] - work[i - 1]; // A step whose *original* value is non-finite stays non-finite, so the // hard-signal and nonfinite_is_bad paths still see it. for (let i = 0; i < n; i++) if (!Number.isFinite(x[i])) d[i] = Infinity; return d; } /** Port of flashback.bisect.rolling_z. Returns {z, ok}. */ export function rollingZ(series, win = 64, gap = 1, relFloor = 1e-3, globalFloorFrac = 1e-2, absFloor = 1e-12) { const n = series.length; const z = new Float64Array(n); const ok = new Uint8Array(n); if (n < win + gap + 2) return { z, ok }; let gmax = 0; for (const v of series) if (Number.isFinite(v)) gmax = Math.max(gmax, Math.abs(v)); const floor = Math.max(gmax * globalFloorFrac, absFloor); for (let i = win + gap; i < n; i++) { const start = i - gap - win; const w = []; for (let j = start; j < start + win; j++) if (Number.isFinite(series[j])) w.push(series[j]); let center = 0, scale = 0; if (w.length) { w.sort((a, b) => a - b); center = median(w); const dev = w.map((v) => Math.abs(v - center)).sort((a, b) => a - b); scale = median(dev) * MAD_TO_SIGMA; } if (!Number.isFinite(center)) center = 0; if (!Number.isFinite(scale)) scale = 0; scale = Math.max(scale, Math.max(Math.abs(center) * relFloor, floor)); const x = series[i]; let zi = Number.isFinite(x) ? (x - center) / scale : Infinity; if (Number.isNaN(zi)) zi = 0; z[i] = zi; ok[i] = 1; } return { z, ok }; } /** Port of flashback.bisect.calibrate (healthy_upto is unused by the demo). */ export function calibrate(z, ok, kCal = 12.0, kMin = 8.0) { const a = []; for (let i = 0; i < z.length; i++) { if (!ok[i]) continue; const v = Math.abs(z[i]); if (Number.isFinite(v)) a.push(v); } if (a.length < 8) return { threshold: Infinity, usable: false }; a.sort((x, y) => x - y); const med = median(a); const dev = a.map((v) => Math.abs(v - med)).sort((x, y) => x - y); let sc = median(dev) * MAD_TO_SIGMA; if (!(sc > 0)) sc = (percentile(a, 90) - med) || 1.0; return { threshold: Math.max(kMin, med + kCal * sc), usable: true, median: med, scale: sc }; } /** Port of flashback.bisect.detect_on_series (two_sided, min_run=1). */ export function detectOnSeries(raw, steps, spec, opts = {}) { const { win = 32, gap = 1, mode = "diff", kCal = 12.0, kMin = 8.0 } = opts; const n = raw.length; if (HARD_SIGNALS.has(spec)) { for (let i = 0; i < n; i++) { if ((Number.isFinite(raw[i]) && raw[i] > 0) || !Number.isFinite(raw[i])) { return { detected: true, index: i, step: steps[i], z: Infinity, threshold: 0, spec }; } } return { detected: false, spec, threshold: 0 }; } const w = Math.max(8, Math.min(win, Math.max(8, Math.floor(n / 4)))); const { z, ok } = rollingZ(prepareSeries(raw, mode), w, gap); const prof = calibrate(z, ok, kCal, kMin); if (!prof.usable) return { detected: false, spec, threshold: prof.threshold }; for (let i = 0; i < n; i++) { if (!ok[i]) continue; const zz = Math.abs(z[i]); if (zz > prof.threshold || !Number.isFinite(raw[i])) { return { detected: true, index: i, step: steps[i], z: Number.isFinite(raw[i]) ? zz : Infinity, threshold: prof.threshold, spec }; } } return { detected: false, spec, threshold: prof.threshold }; } /** |z| series for plotting, matching what detectOnSeries scores. */ export function zSeries(raw, spec, opts = {}) { const { win = 32, gap = 1, mode = "diff" } = opts; const n = raw.length; if (HARD_SIGNALS.has(spec)) { const z = new Float64Array(n), ok = new Uint8Array(n); for (let i = 0; i < n; i++) { ok[i] = 1; z[i] = ((Number.isFinite(raw[i]) && raw[i] > 0) || !Number.isFinite(raw[i])) ? Infinity : 0; } return { z, ok, threshold: 0 }; } const w = Math.max(8, Math.min(win, Math.max(8, Math.floor(n / 4)))); const { z, ok } = rollingZ(prepareSeries(raw, mode), w, gap); const az = new Float64Array(n); for (let i = 0; i < n; i++) az[i] = Number.isFinite(raw[i]) ? Math.abs(z[i]) : Infinity; const prof = calibrate(z, ok, opts.kCal ?? 12.0, opts.kMin ?? 8.0); return { z: az, ok, threshold: prof.threshold }; } /** * Port of flashback.bisect.bisect_first_bad(metric="auto", strategy="scan"). * Returns the earliest step at which `minVotes` metrics agree within * `voteWindow`, falling back to the earliest single detection unless * `requireConsensus`. */ export function bisectFirstBad(table, steps, specs, opts = {}) { const { minVotes = 3, voteWindow = 4, requireConsensus = false } = opts; const dets = []; for (const spec of specs) { const s = table[spec]; if (!s) continue; const d = detectOnSeries(s, steps, spec, opts); if (d.detected) dets.push(d); } const indexReads = steps.length * specs.length; if (!dets.length) { return { step: null, votes: 0, metric: "auto", indexReads, stateProbes: 0, candidates: [], note: "no metric crossed threshold" }; } dets.sort((a, b) => (a.index - b.index) || (b.z - a.z)); const idxs = dets.map((d) => d.index); for (const d of dets) { const votes = idxs.filter((i) => i >= d.index && i <= d.index + voteWindow).length; if (votes >= minVotes) { return { step: d.step, index: d.index, metric: d.spec, z: d.z, threshold: d.threshold, votes, indexReads, stateProbes: 0, candidates: dets, note: "" }; } } const note = `no ${minVotes}-metric consensus; reporting earliest single detection`; if (requireConsensus) { return { step: null, votes: 0, metric: "auto", indexReads, stateProbes: 0, candidates: dets, note: note + " (suppressed: require consensus)" }; } const b = dets[0]; return { step: b.step, index: b.index, metric: b.spec, z: b.z, threshold: b.threshold, votes: 1, indexReads, stateProbes: 0, candidates: dets, note }; } /** * The `git bisect` predicate: has anything gone wrong at or *before* this step? * Only the cumulative form is monotone -- "is THIS step bad?" is false again * one step after a transient fault, and a binary search on it walks off the end * of the run. */ export function prefixBad(zs, i) { let worst = 0; for (let j = 0; j <= i && j < zs.z.length; j++) { if (!zs.ok[j]) continue; if (zs.z[j] > worst) worst = zs.z[j]; } return { bad: worst > zs.threshold, z: worst }; }