NagaYu commited on
Commit
37a0e27
Β·
verified Β·
1 Parent(s): 26bffb8

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,10 +1,91 @@
1
  ---
2
  title: Flashback
3
- emoji: πŸ“š
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: static
 
7
  pinned: false
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Flashback
3
+ emoji: πŸ”¦
4
+ colorFrom: indigo
5
+ colorTo: gray
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ license: apache-2.0
10
+ short_description: Find the step your training run broke on, from 452 B/step
11
+ tags:
12
+ - machine-learning
13
+ - distributed-training
14
+ - checkpointing
15
+ - observability
16
+ - debugging
17
+ - anomaly-detection
18
+ - pytorch
19
  ---
20
 
21
+ # Flashback β€” find the step your training run broke on
22
+
23
+ **Finding the step a training run broke on should cost kilobytes, not terabytes.**
24
+
25
+ A large run fails. Somewhere in the last 40,000 steps a learning rate spiked, a bit
26
+ flipped, a shard of the dataloader started serving garbage. Today you answer *"when did
27
+ this start?"* by squinting at a loss curve and reloading whichever checkpoint happens to be
28
+ nearest β€” so your answer is quantised to the checkpoint interval, and only exists at all if
29
+ the loss actually moved.
30
+
31
+ Flashback keeps a **452-byte sketch of every single step** β€” 128 internal statistics that
32
+ usually move long before the loss does β€” and bisects that index with **zero state
33
+ reconstructions**.
34
+
35
+ ## What this Space is
36
+
37
+ The telemetry here is **real**: recorded from actual training runs of a small Transformer
38
+ in which a specific fault was injected at a specific step, with the ground truth written
39
+ down before any detector looked at the data. The page reimplements
40
+ [`flashback.bisect`](https://github.com/NagaYu/flashback/blob/main/flashback/bisect.py) in
41
+ JavaScript so it can run for free as a static Space, and **checks itself against the answers
42
+ Python computed for the same data** β€” the verification badge at the bottom of the page says
43
+ whether the port currently agrees.
44
+
45
+ Pick a fault, then compare the two charts. For the bit-flip scenario the loss curve never
46
+ crosses its own anomaly threshold at any point in the run; the sketch crosses it on the
47
+ exact step.
48
+
49
+ ## Measured results
50
+
51
+ 12 runs across 6 fault types, ground truth recorded before any detector runs:
52
+
53
+ | condition | mean error | exact hits | never detected |
54
+ |---|---:|---:|---:|
55
+ | (A) full checkpoint every 100 steps + loss curve | 20.0 | 0/12 | 4 |
56
+ | (B) full checkpoint every step + loss curve (ideal) | 0.5 | 4/12 | 4 |
57
+ | **(C) Flashback sketch + bisect** | **0.0** | **12/12** | **0** |
58
+
59
+ ## Add it to your own run
60
+
61
+ ```python
62
+ from flashback.integrations import FlashbackCallback
63
+
64
+ trainer = Trainer(..., callbacks=[FlashbackCallback("runs/my-run")])
65
+ ```
66
+
67
+ ```bash
68
+ flashback bisect my-run --metric grad_norm
69
+ ```
70
+
71
+ ## Links
72
+
73
+ - **Code**: <https://github.com/NagaYu/flashback>
74
+ - **Dataset**: <https://huggingface.co/datasets/NagaYu/flashback-forensics>
75
+ - **Detector model**: <https://huggingface.co/NagaYu/flashback-first-bad-step>
76
+
77
+ ## What this demo is honest about
78
+
79
+ - The detector needs a **healthy stretch** to calibrate against. A run broken from step 0
80
+ has no baseline and will not be localised.
81
+ - It detects **regime changes**, so a fault that ramps in smoothly over hundreds of steps
82
+ has no single correct answer. The `data poisoning` scenario here is off by ~36 steps at
83
+ this model size, and the page shows that rather than hiding it.
84
+ - **No bitwise determinism is claimed.** This is a statistical detector over recorded
85
+ statistics.
86
+ - The demo model is tiny (0.13M parameters, 600 steps) so the page stays a few megabytes.
87
+ The mechanism is size-independent; the repository's benchmark runs the same experiments up
88
+ to ~30M parameters.
89
+ - A full Gradio version of this demo β€” which additionally generates fresh runs on demand β€”
90
+ is in the repository as `app.py`. Hugging Face requires a PRO subscription to host Gradio
91
+ Spaces, so the free public demo is this static one.
data/bit_flip.json ADDED
The diff for this file is too large to render. See raw diff
 
data/clean.json ADDED
The diff for this file is too large to render. See raw diff
 
data/data_poison.json ADDED
The diff for this file is too large to render. See raw diff
 
data/index.json ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "scenarios": [
3
+ {
4
+ "key": "bit_flip",
5
+ "title": "bit flip (silent data corruption)",
6
+ "blurb": "One flipped exponent bit in one gradient element. Adam normalises the update away, so the loss curve usually never reacts at all.",
7
+ "truth": 372,
8
+ "bytes": 609898
9
+ },
10
+ {
11
+ "key": "lr_spike_loud",
12
+ "title": "learning-rate spike (loud)",
13
+ "blurb": "The easy case, included on purpose: the loss spikes, so watching the curve works here too.",
14
+ "truth": 372,
15
+ "bytes": 610213
16
+ },
17
+ {
18
+ "key": "lr_spike_subtle",
19
+ "title": "learning-rate drift (subtle)",
20
+ "blurb": "A sustained 4x learning rate. The loss degrades gradually, so 'when did it start?' is genuinely hard by eye.",
21
+ "truth": 372,
22
+ "bytes": 608000
23
+ },
24
+ {
25
+ "key": "overflow",
26
+ "title": "fp8-range overflow",
27
+ "blurb": "Gradients pushed outside an emulated fp8 range produce Inf, which propagates into the optimizer state.",
28
+ "truth": 372,
29
+ "bytes": 461278
30
+ },
31
+ {
32
+ "key": "data_poison",
33
+ "title": "data poisoning (12% of each batch)",
34
+ "blurb": "A small contamination, inside the loss curve's own noise band.",
35
+ "truth": 372,
36
+ "bytes": 607198
37
+ },
38
+ {
39
+ "key": "clean",
40
+ "title": "healthy run (no fault)",
41
+ "blurb": "A control. A good detector must report nothing here.",
42
+ "truth": null,
43
+ "bytes": 609191
44
+ }
45
+ ],
46
+ "n_metrics": 124,
47
+ "metrics": [
48
+ "loss",
49
+ "grad_norm",
50
+ "update_norm",
51
+ "param_norm",
52
+ "grad_max_abs",
53
+ "update_max_abs",
54
+ "param_max_abs",
55
+ "nonfinite_grad",
56
+ "nonfinite_param",
57
+ "sign_flip_rate",
58
+ "cos_grad_prev",
59
+ "update_param_ratio",
60
+ "grad_mean",
61
+ "grad_var",
62
+ "grad_absmean",
63
+ "grad_norm_tensor_max",
64
+ "grad_norm_tensor_min",
65
+ "grad_rms",
66
+ "loss_delta",
67
+ "update_norm_tensor_max",
68
+ "group:all:mean",
69
+ "group:all:var",
70
+ "group:all:max_abs",
71
+ "group:all:grad_l2",
72
+ "group:all:update_l2",
73
+ "group:all:sign_flip",
74
+ "group:all:nonfinite",
75
+ "group:all:upd_param_ratio",
76
+ "group:embed:mean",
77
+ "group:embed:var",
78
+ "group:embed:max_abs",
79
+ "group:embed:grad_l2",
80
+ "group:embed:update_l2",
81
+ "group:embed:sign_flip",
82
+ "group:embed:nonfinite",
83
+ "group:embed:upd_param_ratio",
84
+ "group:attn_qkv:mean",
85
+ "group:attn_qkv:var",
86
+ "group:attn_qkv:max_abs",
87
+ "group:attn_qkv:grad_l2",
88
+ "group:attn_qkv:update_l2",
89
+ "group:attn_qkv:sign_flip",
90
+ "group:attn_qkv:nonfinite",
91
+ "group:attn_qkv:upd_param_ratio",
92
+ "group:attn_out:mean",
93
+ "group:attn_out:var",
94
+ "group:attn_out:max_abs",
95
+ "group:attn_out:grad_l2",
96
+ "group:attn_out:update_l2",
97
+ "group:attn_out:sign_flip",
98
+ "group:attn_out:nonfinite",
99
+ "group:attn_out:upd_param_ratio",
100
+ "group:mlp_in:mean",
101
+ "group:mlp_in:var",
102
+ "group:mlp_in:max_abs",
103
+ "group:mlp_in:grad_l2",
104
+ "group:mlp_in:update_l2",
105
+ "group:mlp_in:sign_flip",
106
+ "group:mlp_in:nonfinite",
107
+ "group:mlp_in:upd_param_ratio",
108
+ "group:mlp_out:mean",
109
+ "group:mlp_out:var",
110
+ "group:mlp_out:max_abs",
111
+ "group:mlp_out:grad_l2",
112
+ "group:mlp_out:update_l2",
113
+ "group:mlp_out:sign_flip",
114
+ "group:mlp_out:nonfinite",
115
+ "group:mlp_out:upd_param_ratio",
116
+ "group:norm:mean",
117
+ "group:norm:var",
118
+ "group:norm:max_abs",
119
+ "group:norm:grad_l2",
120
+ "group:norm:update_l2",
121
+ "group:norm:sign_flip",
122
+ "group:norm:nonfinite",
123
+ "group:norm:upd_param_ratio",
124
+ "group:head:mean",
125
+ "group:head:var",
126
+ "group:head:max_abs",
127
+ "group:head:grad_l2",
128
+ "group:head:update_l2",
129
+ "group:head:sign_flip",
130
+ "group:head:nonfinite",
131
+ "group:head:upd_param_ratio",
132
+ "hist_grad:0",
133
+ "hist_grad:1",
134
+ "hist_grad:2",
135
+ "hist_grad:3",
136
+ "hist_grad:4",
137
+ "hist_grad:5",
138
+ "hist_grad:6",
139
+ "hist_grad:7",
140
+ "hist_grad:8",
141
+ "hist_grad:9",
142
+ "hist_grad:10",
143
+ "hist_grad:11",
144
+ "hist_grad:12",
145
+ "hist_grad:13",
146
+ "hist_grad:14",
147
+ "hist_grad:15",
148
+ "hist_update:0",
149
+ "hist_update:1",
150
+ "hist_update:2",
151
+ "hist_update:3",
152
+ "hist_update:4",
153
+ "hist_update:5",
154
+ "hist_update:6",
155
+ "hist_update:7",
156
+ "hist_update:8",
157
+ "hist_update:9",
158
+ "hist_update:10",
159
+ "hist_update:11",
160
+ "hist_update:12",
161
+ "hist_update:13",
162
+ "hist_update:14",
163
+ "hist_update:15",
164
+ "qnorm:0",
165
+ "qnorm:1",
166
+ "qnorm:2",
167
+ "qnorm:3",
168
+ "qnorm:4",
169
+ "qnorm:5",
170
+ "qnorm:6",
171
+ "qnorm:7"
172
+ ],
173
+ "bytes_per_step": 452,
174
+ "win": 32,
175
+ "preset": "nano",
176
+ "steps": 600,
177
+ "exclude": [
178
+ "lr",
179
+ "step_wall_ms",
180
+ "batch_id_mean",
181
+ "batch_uniq_frac"
182
+ ]
183
+ }
data/lr_spike_loud.json ADDED
The diff for this file is too large to render. See raw diff
 
data/lr_spike_subtle.json ADDED
The diff for this file is too large to render. See raw diff
 
data/overflow.json ADDED
The diff for this file is too large to render. See raw diff
 
detector.js ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* A faithful JavaScript port of flashback/bisect.py.
2
+ *
3
+ * Hugging Face hosts static Spaces free for everyone but charges for Gradio
4
+ * ones, so the public demo runs the detector in the browser. A port is only
5
+ * worth anything if it agrees with the original, so every function below
6
+ * mirrors a named Python function, and index.html checks the results against
7
+ * the answers Python computed for the same data (shipped in data/*.json).
8
+ *
9
+ * Python's numeric conventions that matter here:
10
+ * - np.nanmedian ignores non-finite entries in a window
11
+ * - np.percentile uses linear interpolation
12
+ * - non-finite metric values mean "bad" and score +Infinity
13
+ */
14
+
15
+ export const MAD_TO_SIGMA = 1.4826;
16
+ export const HARD_SIGNALS = new Set([
17
+ "nonfinite_grad", "nonfinite_param", "group:all:nonfinite",
18
+ ]);
19
+
20
+ function median(sorted) {
21
+ const n = sorted.length;
22
+ if (n === 0) return NaN;
23
+ const m = n >> 1;
24
+ return n % 2 ? sorted[m] : 0.5 * (sorted[m - 1] + sorted[m]);
25
+ }
26
+
27
+ function medianOf(values) {
28
+ const f = [];
29
+ for (const v of values) if (Number.isFinite(v)) f.push(v);
30
+ f.sort((a, b) => a - b);
31
+ return median(f);
32
+ }
33
+
34
+ /** numpy.percentile(a, q) with the default 'linear' method. */
35
+ function percentile(sortedAsc, q) {
36
+ const n = sortedAsc.length;
37
+ if (n === 0) return NaN;
38
+ const pos = (q / 100) * (n - 1);
39
+ const lo = Math.floor(pos), hi = Math.ceil(pos);
40
+ if (lo === hi) return sortedAsc[lo];
41
+ return sortedAsc[lo] + (sortedAsc[hi] - sortedAsc[lo]) * (pos - lo);
42
+ }
43
+
44
+ /** Port of flashback.bisect.prepare_series. */
45
+ export function prepareSeries(x, mode = "diff") {
46
+ const n = x.length;
47
+ if (mode === "level" || n < 3) return Float64Array.from(x);
48
+ let work = Float64Array.from(x);
49
+ let allPositive = true, any = false, mn = Infinity, mx = -Infinity;
50
+ for (const v of x) {
51
+ if (!Number.isFinite(v)) continue;
52
+ any = true;
53
+ if (v <= 0) allPositive = false;
54
+ if (v < mn) mn = v;
55
+ if (v > mx) mx = v;
56
+ }
57
+ if (any && allPositive) {
58
+ const span = mx / Math.max(mn, 1e-300);
59
+ if (span > 100) for (let i = 0; i < n; i++) work[i] = Math.log(Math.max(work[i], 1e-300));
60
+ }
61
+ const d = new Float64Array(n);
62
+ for (let i = 1; i < n; i++) d[i] = work[i] - work[i - 1];
63
+ // A step whose *original* value is non-finite stays non-finite, so the
64
+ // hard-signal and nonfinite_is_bad paths still see it.
65
+ for (let i = 0; i < n; i++) if (!Number.isFinite(x[i])) d[i] = Infinity;
66
+ return d;
67
+ }
68
+
69
+ /** Port of flashback.bisect.rolling_z. Returns {z, ok}. */
70
+ export function rollingZ(series, win = 64, gap = 1,
71
+ relFloor = 1e-3, globalFloorFrac = 1e-2, absFloor = 1e-12) {
72
+ const n = series.length;
73
+ const z = new Float64Array(n);
74
+ const ok = new Uint8Array(n);
75
+ if (n < win + gap + 2) return { z, ok };
76
+
77
+ let gmax = 0;
78
+ for (const v of series) if (Number.isFinite(v)) gmax = Math.max(gmax, Math.abs(v));
79
+ const floor = Math.max(gmax * globalFloorFrac, absFloor);
80
+
81
+ for (let i = win + gap; i < n; i++) {
82
+ const start = i - gap - win;
83
+ const w = [];
84
+ for (let j = start; j < start + win; j++) if (Number.isFinite(series[j])) w.push(series[j]);
85
+ let center = 0, scale = 0;
86
+ if (w.length) {
87
+ w.sort((a, b) => a - b);
88
+ center = median(w);
89
+ const dev = w.map((v) => Math.abs(v - center)).sort((a, b) => a - b);
90
+ scale = median(dev) * MAD_TO_SIGMA;
91
+ }
92
+ if (!Number.isFinite(center)) center = 0;
93
+ if (!Number.isFinite(scale)) scale = 0;
94
+ scale = Math.max(scale, Math.max(Math.abs(center) * relFloor, floor));
95
+ const x = series[i];
96
+ let zi = Number.isFinite(x) ? (x - center) / scale : Infinity;
97
+ if (Number.isNaN(zi)) zi = 0;
98
+ z[i] = zi;
99
+ ok[i] = 1;
100
+ }
101
+ return { z, ok };
102
+ }
103
+
104
+ /** Port of flashback.bisect.calibrate (healthy_upto is unused by the demo). */
105
+ export function calibrate(z, ok, kCal = 12.0, kMin = 8.0) {
106
+ const a = [];
107
+ for (let i = 0; i < z.length; i++) {
108
+ if (!ok[i]) continue;
109
+ const v = Math.abs(z[i]);
110
+ if (Number.isFinite(v)) a.push(v);
111
+ }
112
+ if (a.length < 8) return { threshold: Infinity, usable: false };
113
+ a.sort((x, y) => x - y);
114
+ const med = median(a);
115
+ const dev = a.map((v) => Math.abs(v - med)).sort((x, y) => x - y);
116
+ let sc = median(dev) * MAD_TO_SIGMA;
117
+ if (!(sc > 0)) sc = (percentile(a, 90) - med) || 1.0;
118
+ return { threshold: Math.max(kMin, med + kCal * sc), usable: true, median: med, scale: sc };
119
+ }
120
+
121
+ /** Port of flashback.bisect.detect_on_series (two_sided, min_run=1). */
122
+ export function detectOnSeries(raw, steps, spec, opts = {}) {
123
+ const { win = 32, gap = 1, mode = "diff", kCal = 12.0, kMin = 8.0 } = opts;
124
+ const n = raw.length;
125
+
126
+ if (HARD_SIGNALS.has(spec)) {
127
+ for (let i = 0; i < n; i++) {
128
+ if ((Number.isFinite(raw[i]) && raw[i] > 0) || !Number.isFinite(raw[i])) {
129
+ return { detected: true, index: i, step: steps[i], z: Infinity, threshold: 0, spec };
130
+ }
131
+ }
132
+ return { detected: false, spec, threshold: 0 };
133
+ }
134
+
135
+ const w = Math.max(8, Math.min(win, Math.max(8, Math.floor(n / 4))));
136
+ const { z, ok } = rollingZ(prepareSeries(raw, mode), w, gap);
137
+ const prof = calibrate(z, ok, kCal, kMin);
138
+ if (!prof.usable) return { detected: false, spec, threshold: prof.threshold };
139
+ for (let i = 0; i < n; i++) {
140
+ if (!ok[i]) continue;
141
+ const zz = Math.abs(z[i]);
142
+ if (zz > prof.threshold || !Number.isFinite(raw[i])) {
143
+ return { detected: true, index: i, step: steps[i],
144
+ z: Number.isFinite(raw[i]) ? zz : Infinity,
145
+ threshold: prof.threshold, spec };
146
+ }
147
+ }
148
+ return { detected: false, spec, threshold: prof.threshold };
149
+ }
150
+
151
+ /** |z| series for plotting, matching what detectOnSeries scores. */
152
+ export function zSeries(raw, spec, opts = {}) {
153
+ const { win = 32, gap = 1, mode = "diff" } = opts;
154
+ const n = raw.length;
155
+ if (HARD_SIGNALS.has(spec)) {
156
+ const z = new Float64Array(n), ok = new Uint8Array(n);
157
+ for (let i = 0; i < n; i++) {
158
+ ok[i] = 1;
159
+ z[i] = ((Number.isFinite(raw[i]) && raw[i] > 0) || !Number.isFinite(raw[i])) ? Infinity : 0;
160
+ }
161
+ return { z, ok, threshold: 0 };
162
+ }
163
+ const w = Math.max(8, Math.min(win, Math.max(8, Math.floor(n / 4))));
164
+ const { z, ok } = rollingZ(prepareSeries(raw, mode), w, gap);
165
+ const az = new Float64Array(n);
166
+ for (let i = 0; i < n; i++) az[i] = Number.isFinite(raw[i]) ? Math.abs(z[i]) : Infinity;
167
+ const prof = calibrate(z, ok, opts.kCal ?? 12.0, opts.kMin ?? 8.0);
168
+ return { z: az, ok, threshold: prof.threshold };
169
+ }
170
+
171
+ /**
172
+ * Port of flashback.bisect.bisect_first_bad(metric="auto", strategy="scan").
173
+ * Returns the earliest step at which `minVotes` metrics agree within
174
+ * `voteWindow`, falling back to the earliest single detection unless
175
+ * `requireConsensus`.
176
+ */
177
+ export function bisectFirstBad(table, steps, specs, opts = {}) {
178
+ const { minVotes = 3, voteWindow = 4, requireConsensus = false } = opts;
179
+ const dets = [];
180
+ for (const spec of specs) {
181
+ const s = table[spec];
182
+ if (!s) continue;
183
+ const d = detectOnSeries(s, steps, spec, opts);
184
+ if (d.detected) dets.push(d);
185
+ }
186
+ const indexReads = steps.length * specs.length;
187
+ if (!dets.length) {
188
+ return { step: null, votes: 0, metric: "auto", indexReads, stateProbes: 0,
189
+ candidates: [], note: "no metric crossed threshold" };
190
+ }
191
+ dets.sort((a, b) => (a.index - b.index) || (b.z - a.z));
192
+ const idxs = dets.map((d) => d.index);
193
+ for (const d of dets) {
194
+ const votes = idxs.filter((i) => i >= d.index && i <= d.index + voteWindow).length;
195
+ if (votes >= minVotes) {
196
+ return { step: d.step, index: d.index, metric: d.spec, z: d.z,
197
+ threshold: d.threshold, votes, indexReads, stateProbes: 0,
198
+ candidates: dets, note: "" };
199
+ }
200
+ }
201
+ const note = `no ${minVotes}-metric consensus; reporting earliest single detection`;
202
+ if (requireConsensus) {
203
+ return { step: null, votes: 0, metric: "auto", indexReads, stateProbes: 0,
204
+ candidates: dets, note: note + " (suppressed: require consensus)" };
205
+ }
206
+ const b = dets[0];
207
+ return { step: b.step, index: b.index, metric: b.spec, z: b.z, threshold: b.threshold,
208
+ votes: 1, indexReads, stateProbes: 0, candidates: dets, note };
209
+ }
210
+
211
+ /**
212
+ * The `git bisect` predicate: has anything gone wrong at or *before* this step?
213
+ * Only the cumulative form is monotone -- "is THIS step bad?" is false again
214
+ * one step after a transient fault, and a binary search on it walks off the end
215
+ * of the run.
216
+ */
217
+ export function prefixBad(zs, i) {
218
+ let worst = 0;
219
+ for (let j = 0; j <= i && j < zs.z.length; j++) {
220
+ if (!zs.ok[j]) continue;
221
+ if (zs.z[j] > worst) worst = zs.z[j];
222
+ }
223
+ return { bad: worst > zs.threshold, z: worst };
224
+ }
index.html CHANGED
@@ -1,19 +1,525 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Flashback β€” find the step your training run broke on</title>
7
+ <style>
8
+ :root {
9
+ --bg: #ffffff; --fg: #1a1a1a; --muted: #666; --line: #e3e3e6;
10
+ --card: #fafafa; --accent: #1f77b4; --loss: #c0392b; --ok: #2ca02c;
11
+ --warn: #e08a1e; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
12
+ }
13
+ @media (prefers-color-scheme: dark) {
14
+ :root { --bg:#0f1115; --fg:#e8e8ea; --muted:#9aa0a6; --line:#2a2d34;
15
+ --card:#161920; --accent:#5aa9e6; --loss:#ff6b5a; --ok:#5ec26a; }
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body { margin:0; background:var(--bg); color:var(--fg);
19
+ font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
20
+ .wrap { max-width: 1100px; margin: 0 auto; padding: 28px 20px 80px; }
21
+ h1 { font-size: 30px; margin: 0 0 6px; letter-spacing: -0.02em; }
22
+ h2 { font-size: 19px; margin: 34px 0 10px; letter-spacing: -0.01em; }
23
+ h3 { font-size: 15px; margin: 20px 0 8px; }
24
+ .lede { color: var(--muted); font-size: 16px; margin: 0 0 4px; }
25
+ a { color: var(--accent); }
26
+ .card { background: var(--card); border: 1px solid var(--line);
27
+ border-radius: 10px; padding: 16px 18px; }
28
+ .row { display: flex; flex-wrap: wrap; gap: 10px; }
29
+ button, select, input[type=number] {
30
+ font: inherit; color: var(--fg); background: var(--bg);
31
+ border: 1px solid var(--line); border-radius: 7px; padding: 7px 12px; cursor: pointer; }
32
+ button:hover { border-color: var(--accent); }
33
+ button.sel { background: var(--accent); color: #fff; border-color: var(--accent); }
34
+ button.primary { background: var(--accent); color:#fff; border-color: var(--accent);
35
+ font-weight: 600; }
36
+ label { font-size: 13px; color: var(--muted); display: flex; flex-direction: column;
37
+ gap: 4px; }
38
+ table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
39
+ th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line); }
40
+ th { color: var(--muted); font-weight: 600; }
41
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
42
+ code, .mono { font-family: var(--mono); font-size: 12.5px; }
43
+ .pill { display:inline-block; padding: 2px 9px; border-radius: 999px; font-size: 12px;
44
+ font-weight: 600; }
45
+ .pill.ok { background: rgba(44,160,44,.15); color: var(--ok); }
46
+ .pill.bad { background: rgba(192,57,43,.15); color: var(--loss); }
47
+ .pill.warn { background: rgba(224,138,30,.15); color: var(--warn); }
48
+ .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
49
+ @media (max-width: 820px) { .grid2 { grid-template-columns: 1fr; } }
50
+ .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 14px; }
51
+ .kv dt { color: var(--muted); }
52
+ .kv dd { margin: 0; font-variant-numeric: tabular-nums; }
53
+ .muted { color: var(--muted); }
54
+ .small { font-size: 13px; }
55
+ svg { width: 100%; height: auto; display: block; }
56
+ .axis { stroke: var(--line); stroke-width: 1; }
57
+ .tick { fill: var(--muted); font-size: 10px; }
58
+ .scroll { overflow-x: auto; }
59
+ footer { margin-top: 48px; padding-top: 18px; border-top: 1px solid var(--line);
60
+ color: var(--muted); font-size: 13.5px; }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <div class="wrap">
65
+
66
+ <h1>Flashback</h1>
67
+ <p class="lede">Find the step a training run broke on, from
68
+ <strong>452 bytes per step</strong>.</p>
69
+ <p class="small muted" style="max-width:78ch">
70
+ The top chart is what you would see on your dashboard. The bottom chart is what a
71
+ 452-byte-per-step sketch saw at the same steps. Everything below is computed in your
72
+ browser from <em>recorded telemetry of real training runs with real injected faults</em>
73
+ β€” the ground truth was written down before any detector looked at the data.
74
+ </p>
75
+
76
+ <div id="scenarios" class="row" style="margin:18px 0 14px"></div>
77
+ <p id="blurb" class="small muted" style="margin:0 0 16px"></p>
78
+
79
+ <div class="card">
80
+ <div class="row" style="align-items:flex-end">
81
+ <label>metric
82
+ <select id="metric"><option value="auto">auto β€” test all of them</option></select>
83
+ </label>
84
+ <label>baseline window
85
+ <input id="win" type="number" min="8" max="128" step="4" value="32" style="width:92px">
86
+ </label>
87
+ <label>metrics required to agree
88
+ <input id="votes" type="number" min="1" max="12" value="3" style="width:92px">
89
+ </label>
90
+ <label>vote window
91
+ <input id="vwin" type="number" min="0" max="20" value="4" style="width:92px">
92
+ </label>
93
+ <label>mode
94
+ <select id="mode">
95
+ <option value="diff">diff β€” regime change</option>
96
+ <option value="level">level β€” excursion</option>
97
+ </select>
98
+ </label>
99
+ <label style="flex-direction:row;align-items:center;gap:7px">
100
+ <input id="strict" type="checkbox"> report nothing without consensus
101
+ </label>
102
+ <button id="run" class="primary">Run bisect</button>
103
+ </div>
104
+ </div>
105
+
106
+ <div id="result" class="card" style="margin-top:14px"></div>
107
+
108
+ <h2>What the loss curve shows</h2>
109
+ <div id="lossChart"></div>
110
+
111
+ <h2>What the sketch shows at the same steps</h2>
112
+ <div id="zChart"></div>
113
+ <p class="small muted" id="zlegend"></p>
114
+
115
+ <h2>Earliest detection, by metric</h2>
116
+ <p id="metricsNote" class="small muted" style="margin:0 0 10px;max-width:78ch"></p>
117
+ <div class="scroll"><table id="metrics"><tbody></tbody></table></div>
118
+
119
+ <h2>Bisect by hand</h2>
120
+ <p class="small muted" style="max-width:78ch">
121
+ Each probe asks the <code>git bisect</code> question β€”
122
+ <strong>has anything gone wrong at or before this step?</strong> β€” and halves the
123
+ bracket. That phrasing matters: <em>β€œis <strong>this step</strong> bad?”</em> is not
124
+ monotone (a flipped bit is bad at exactly one step), so a binary search on it walks off
125
+ the end of the run. <em>β€œhas it gone bad <strong>yet</strong>?”</em> is monotone β€” and it
126
+ is also the question you can afford to ask expensively, since in the deterministic replay
127
+ mode each answer costs one state reconstruction.
128
+ </p>
129
+ <div class="card">
130
+ <div class="row" style="align-items:flex-end">
131
+ <label>metric to probe
132
+ <select id="mmetric" style="max-width:280px"></select>
133
+ </label>
134
+ <label>probe this step
135
+ <input id="mstep" type="number" value="0" style="width:110px">
136
+ </label>
137
+ <button id="mprobe" class="primary">Probe</button>
138
+ <button id="mreset">Start over</button>
139
+ </div>
140
+ <div id="manual" style="margin-top:14px"></div>
141
+ </div>
142
+
143
+ <footer>
144
+ <p><strong>What this demo is honest about.</strong>
145
+ The detector needs a healthy stretch to calibrate against β€” a run broken from step 0 has
146
+ no baseline. It detects <em>regime changes</em>, so a fault that ramps in over hundreds of
147
+ steps has no single correct answer. No bitwise determinism is claimed anywhere: this is a
148
+ statistical detector over recorded statistics. The demo model is tiny
149
+ (<span id="fparams"></span>) so the page stays small; the mechanism is size-independent
150
+ and the repository's benchmark runs the same experiments up to ~30M parameters.</p>
151
+ <p>
152
+ <a href="https://github.com/NagaYu/flashback">Code on GitHub</a> Β·
153
+ <a href="https://huggingface.co/datasets/NagaYu/flashback-forensics">Dataset</a> Β·
154
+ <a href="https://huggingface.co/NagaYu/flashback-first-bad-step">Detector model</a>
155
+ Β· Apache-2.0
156
+ </p>
157
+ <p id="parity" class="small"></p>
158
+ </footer>
159
+ </div>
160
+
161
+ <script type="module">
162
+ import { detectOnSeries, zSeries, bisectFirstBad, prefixBad, HARD_SIGNALS }
163
+ from "./detector.js";
164
+
165
+ const $ = (id) => document.getElementById(id);
166
+ const state = { index: null, data: null, key: null, manual: null };
167
+
168
+ const fmt = (v) => (v === null || v === undefined) ? "β€”"
169
+ : (!Number.isFinite(v) ? "∞" : (Math.abs(v) >= 1000 ? v.toExponential(2)
170
+ : (Number.isInteger(v) ? String(v) : v.toFixed(2))));
171
+
172
+ /* JSON has no Inf/NaN, so the exporter writes null for every non-finite value. */
173
+ const revive = (arr) => Float64Array.from(arr, (v) => (v === null ? NaN : v));
174
+
175
+ async function boot() {
176
+ state.index = await (await fetch("data/index.json")).json();
177
+ const bar = $("scenarios");
178
+ state.index.scenarios.forEach((s, i) => {
179
+ const b = document.createElement("button");
180
+ b.textContent = s.title;
181
+ b.onclick = () => select(s.key);
182
+ b.dataset.key = s.key;
183
+ bar.appendChild(b);
184
+ if (i === 0) b.classList.add("sel");
185
+ });
186
+ const msel = $("metric"), mm = $("mmetric");
187
+ for (const spec of state.index.metrics) {
188
+ msel.appendChild(new Option(spec, spec));
189
+ mm.appendChild(new Option(spec, spec));
190
+ }
191
+ mm.value = "grad_norm";
192
+ $("run").onclick = render;
193
+ $("mprobe").onclick = manualProbe;
194
+ $("mreset").onclick = manualReset;
195
+ for (const id of ["metric", "win", "votes", "vwin", "mode", "strict"]) {
196
+ $(id).onchange = render;
197
+ }
198
+ $("mmetric").onchange = manualReset;
199
+ await select(state.index.scenarios[0].key);
200
+ }
201
+
202
+ async function select(key) {
203
+ document.querySelectorAll("#scenarios button")
204
+ .forEach((b) => b.classList.toggle("sel", b.dataset.key === key));
205
+ const raw = await (await fetch(`data/${key}.json`)).json();
206
+ raw.metricsF = {};
207
+ for (const [k, v] of Object.entries(raw.metrics)) raw.metricsF[k] = revive(v);
208
+ state.data = raw;
209
+ state.key = key;
210
+ $("blurb").textContent = raw.blurb;
211
+ $("fparams").textContent =
212
+ `${raw.preset}, ${(raw.param_count / 1e6).toFixed(2)}M parameters, ${raw.n_steps} steps`;
213
+ $("win").value = raw.win;
214
+ render();
215
+ manualReset();
216
+ }
217
+
218
+ function opts() {
219
+ return {
220
+ win: +$("win").value, gap: 1, mode: $("mode").value,
221
+ minVotes: +$("votes").value, voteWindow: +$("vwin").value,
222
+ requireConsensus: $("strict").checked,
223
+ };
224
+ }
225
+
226
+ function render() {
227
+ const d = state.data;
228
+ if (!d) return;
229
+ const o = opts();
230
+ const steps = d.steps;
231
+ const chosen = $("metric").value;
232
+ const specs = chosen === "auto" ? state.index.metrics : [chosen];
233
+ const res = bisectFirstBad(d.metricsF, steps, specs, o);
234
+ const loss = detectOnSeries(d.metricsF["loss"], steps, "loss", o);
235
+
236
+ const err = (v) => (d.truth === null || v === null || v === undefined)
237
+ ? "" : ` <span class="muted">(error <strong>${Math.abs(v - d.truth)}</strong> steps)</span>`;
238
+ const truthTxt = d.truth === null
239
+ ? '<span class="pill ok">none β€” this run is healthy</span>' : `<strong>${d.truth}</strong>`;
240
+ // A single metric crossing its threshold is not a finding. Say so next to
241
+ // the number, not only in a footnote -- on the healthy control run this is
242
+ // the difference between "no fault" and an apparent false positive.
243
+ const weak = res.step !== null && res.votes < o.minVotes;
244
+ const fbTxt = res.step === null
245
+ ? '<span class="pill bad">no detection</span>'
246
+ : (weak
247
+ ? `<span class="muted" style="font-size:16px">${res.step}</span>
248
+ <span class="pill warn">low confidence β€” only ${res.votes} of
249
+ ${o.minVotes} required metrics agree</span>`
250
+ : `<strong style="font-size:18px">${res.step}</strong>` + err(res.step));
251
+ const lossTxt = loss.detected
252
+ ? `<strong>${loss.step}</strong>` + err(loss.step)
253
+ : '<span class="pill bad">never detected</span>';
254
+
255
+ $("result").innerHTML = `
256
+ <dl class="kv">
257
+ <dt>ground truth</dt><dd>${truthTxt}</dd>
258
+ <dt>Flashback bisect</dt><dd>${fbTxt}</dd>
259
+ <dt>loss curve alone</dt><dd>${lossTxt}</dd>
260
+ <dt>metric that fired first</dt>
261
+ <dd><code>${res.metric}</code> β€” z = ${fmt(res.z)}, threshold ${fmt(res.threshold)}</dd>
262
+ <dt>metrics that agree</dt>
263
+ <dd>${res.votes} of ${state.index.metrics.length} recorded
264
+ ${weak ? '<span class="muted">β€” below the ' + o.minVotes +
265
+ ' required, so this is not a finding</span>' : ""}</dd>
266
+ <dt>cost</dt><dd>${res.indexReads.toLocaleString()} index reads,
267
+ <strong>${res.stateProbes} state reconstructions</strong></dd>
268
+ <dt>sketch size</dt><dd>${d.bytes_per_step} B/step
269
+ (${(d.total_bytes / 1024).toFixed(0)} KiB for the whole run)</dd>
270
+ </dl>
271
+ ${res.note ? `<p class="small muted" style="margin:10px 0 0">β–Έ ${res.note}</p>` : ""}`;
272
+
273
+ drawLoss(d, steps, res.step, loss.detected ? loss.step : null);
274
+
275
+ // Plot the metrics that voted for the ANSWER, strongest first -- not the
276
+ // globally earliest detections. Across 124 correlated statistics a couple
277
+ // will always twitch early; charting those instead of the ones that actually
278
+ // localise the fault would make the picture argue against itself.
279
+ const inWindow = res.index === undefined ? []
280
+ : res.candidates.filter((c) => c.index >= res.index && c.index <= res.index + o.voteWindow);
281
+ const ranked = (inWindow.length ? inWindow : res.candidates)
282
+ .slice()
283
+ .sort((a, b) => (b.z === a.z ? 0 : (b.z > a.z ? 1 : -1)));
284
+ const picks = [];
285
+ for (const c of ranked) {
286
+ if (!picks.includes(c.spec)) picks.push(c.spec);
287
+ if (picks.length >= 4) break;
288
+ }
289
+ for (const f of ["grad_norm", "grad_max_abs", "update_max_abs", "sign_flip_rate"]) {
290
+ if (picks.length < 4 && !picks.includes(f)) picks.push(f);
291
+ }
292
+ drawZ(d, steps, picks, res.step, o);
293
+
294
+ const rows = [];
295
+ const seen = new Set();
296
+ const votedFor = new Set(inWindow.map((c) => c.spec));
297
+ for (const c of res.candidates) {
298
+ if (seen.has(c.spec)) continue;
299
+ seen.add(c.spec);
300
+ const tag = votedFor.has(c.spec)
301
+ ? '<span class="pill ok">voted</span>'
302
+ : '<span class="pill warn">isolated</span>';
303
+ rows.push(`<tr><td class="num">${c.step}</td><td class="num">${fmt(c.z)}</td>
304
+ <td>${tag}</td><td><code>${c.spec}</code></td></tr>`);
305
+ if (rows.length >= 14) break;
306
+ }
307
+ $("metrics").innerHTML =
308
+ `<thead><tr><th class="num">step</th><th class="num">z</th><th></th>
309
+ <th>metric</th></tr></thead>
310
+ <tbody>${rows.join("") || '<tr><td colspan="4" class="muted">nothing crossed its threshold</td></tr>'}</tbody>`;
311
+ $("metricsNote").innerHTML = votedFor.size
312
+ ? `<strong>${votedFor.size}</strong> metrics fired within ${o.voteWindow} steps of each
313
+ other and carried the answer (<span class="pill ok">voted</span>).
314
+ The <span class="pill warn">isolated</span> ones fired earlier but alone β€” across
315
+ ${state.index.metrics.length} correlated statistics a few always will, which is
316
+ exactly why a single loud metric is not allowed to decide.`
317
+ : "No metric reached consensus with any other.";
318
+
319
+ checkParity(res, loss);
320
+ }
321
+
322
+ /* --- charts ------------------------------------------------------------- */
323
+ function chart(w, h, pad) {
324
+ return { w, h, pad, parts: [] };
325
+ }
326
+ function axes(c, xmin, xmax, ymin, ymax, ylog, ylabel) {
327
+ const { w, h, pad } = c;
328
+ c.x = (v) => pad.l + (v - xmin) / (xmax - xmin || 1) * (w - pad.l - pad.r);
329
+ const ly = (v) => ylog ? Math.log10(Math.max(v, ymin)) : v;
330
+ const a = ly(ymin), b = ly(ymax);
331
+ c.y = (v) => h - pad.b - (ly(v) - a) / ((b - a) || 1) * (h - pad.t - pad.b);
332
+ c.parts.push(`<line class="axis" x1="${pad.l}" y1="${h - pad.b}" x2="${w - pad.r}"
333
+ y2="${h - pad.b}"/>`);
334
+ for (let k = 0; k <= 4; k++) {
335
+ const xv = xmin + (xmax - xmin) * k / 4;
336
+ c.parts.push(`<text class="tick" x="${c.x(xv)}" y="${h - pad.b + 14}"
337
+ text-anchor="middle">${Math.round(xv)}</text>`);
338
+ }
339
+ const ticks = ylog
340
+ ? Array.from({ length: Math.min(7, Math.max(2, Math.ceil(b) - Math.floor(a) + 1)) },
341
+ (_, i) => Math.pow(10, Math.floor(a) + i)).filter((v) => v <= ymax * 1.001)
342
+ : [ymin, (ymin + ymax) / 2, ymax];
343
+ for (const t of ticks) {
344
+ c.parts.push(`<line class="axis" x1="${pad.l}" y1="${c.y(t)}" x2="${w - pad.r}"
345
+ y2="${c.y(t)}" stroke-opacity="0.35"/>`);
346
+ c.parts.push(`<text class="tick" x="${pad.l - 6}" y="${c.y(t) + 3}"
347
+ text-anchor="end">${t >= 1000 || (t > 0 && t < 0.01)
348
+ ? t.toExponential(0) : (+t.toPrecision(3))}</text>`);
349
+ }
350
+ if (ylabel) {
351
+ c.parts.push(`<text class="tick" x="12" y="${h / 2}" text-anchor="middle"
352
+ transform="rotate(-90 12 ${h / 2})">${ylabel}</text>`);
353
+ }
354
+ }
355
+ function line(c, xs, ys, color, width = 1.1) {
356
+ let d = "", pen = false;
357
+ for (let i = 0; i < xs.length; i++) {
358
+ const v = ys[i];
359
+ if (!Number.isFinite(v) || v === null) { pen = false; continue; }
360
+ const X = c.x(xs[i]).toFixed(1), Y = c.y(v).toFixed(1);
361
+ d += (pen ? "L" : "M") + X + " " + Y + " ";
362
+ pen = true;
363
+ }
364
+ c.parts.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="${width}"/>`);
365
+ }
366
+ function vline(c, x, color, dash) {
367
+ if (x === null || x === undefined) return;
368
+ c.parts.push(`<line x1="${c.x(x)}" y1="${c.pad.t}" x2="${c.x(x)}"
369
+ y2="${c.h - c.pad.b}" stroke="${color}" stroke-width="2"
370
+ ${dash ? `stroke-dasharray="${dash}"` : ""} opacity="0.9"/>`);
371
+ }
372
+ function svg(c) {
373
+ return `<svg viewBox="0 0 ${c.w} ${c.h}" preserveAspectRatio="xMidYMid meet">
374
+ ${c.parts.join("")}</svg>`;
375
+ }
376
+
377
+ function drawLoss(d, steps, fb, lossStep) {
378
+ const ys = d.metricsF["loss"];
379
+ let lo = Infinity, hi = -Infinity;
380
+ for (const v of ys) if (Number.isFinite(v) && v > 0) { lo = Math.min(lo, v); hi = Math.max(hi, v); }
381
+ const c = chart(1000, 250, { l: 58, r: 16, t: 12, b: 26 });
382
+ axes(c, steps[0], steps[steps.length - 1], lo * 0.95, hi * 1.05, true, "loss (log)");
383
+ vline(c, d.truth, "var(--fg)", "7 5");
384
+ vline(c, lossStep, "var(--loss)", "2 4");
385
+ vline(c, fb, "var(--accent)");
386
+ line(c, steps, ys, "var(--loss)", 1.1);
387
+ $("lossChart").innerHTML = svg(c) + `<p class="small muted" style="margin:6px 0 0">
388
+ <span style="color:var(--fg)">β–Ž</span> ground truth
389
+ &nbsp; <span style="color:var(--loss)">β–Ž</span> where the loss curve notices
390
+ &nbsp; <span style="color:var(--accent)">β–Ž</span> Flashback</p>`;
391
+ }
392
+
393
+ function drawZ(d, steps, picks, fb, o) {
394
+ const colors = ["var(--accent)", "#2ca02c", "#9467bd", "#17becf"];
395
+ const c = chart(1000, 300, { l: 58, r: 16, t: 12, b: 26 });
396
+ let hi = 10, thr = null;
397
+ const series = [];
398
+ picks.forEach((spec, i) => {
399
+ const raw = d.metricsF[spec];
400
+ if (!raw) return;
401
+ const zs = zSeries(raw, spec, o);
402
+ const plot = new Float64Array(zs.z.length);
403
+ for (let j = 0; j < zs.z.length; j++) {
404
+ plot[j] = zs.ok[j] ? (Number.isFinite(zs.z[j]) ? Math.max(zs.z[j], 1e-3) : 1e7) : NaN;
405
+ if (Number.isFinite(plot[j])) hi = Math.max(hi, plot[j]);
406
+ }
407
+ if (thr === null && Number.isFinite(zs.threshold)) thr = zs.threshold;
408
+ const det = detectOnSeries(raw, steps, spec, o);
409
+ series.push({ spec, plot, color: colors[i % colors.length], det });
410
+ });
411
+ axes(c, steps[0], steps[steps.length - 1], 1e-3, hi * 3, true, "robust z (log)");
412
+ if (thr !== null) {
413
+ c.parts.push(`<line class="axis" x1="${c.pad.l}" y1="${c.y(thr)}"
414
+ x2="${c.w - c.pad.r}" y2="${c.y(thr)}" stroke="var(--muted)" stroke-width="1.2"
415
+ stroke-dasharray="6 4"/>`);
416
+ }
417
+ vline(c, d.truth, "var(--fg)", "7 5");
418
+ for (const s of series) line(c, steps, s.plot, s.color, 1.0);
419
+ for (const s of series) {
420
+ if (!s.det.detected) continue;
421
+ const yv = Number.isFinite(s.det.z) ? Math.max(s.det.z, 1e-3) : 1e7;
422
+ c.parts.push(`<circle cx="${c.x(s.det.step)}" cy="${c.y(yv)}" r="5"
423
+ fill="${s.color}" stroke="var(--bg)" stroke-width="1.5"/>`);
424
+ }
425
+ $("zChart").innerHTML = svg(c);
426
+ $("zlegend").innerHTML = series.map((s) =>
427
+ `<span style="color:${s.color}">β–Ž</span> <code>${s.spec}</code>` +
428
+ (s.det.detected ? ` β†’ fires at <strong>${s.det.step}</strong>` : " (no detection)")
429
+ ).join(" &nbsp;&nbsp; ") +
430
+ (thr !== null ? ` &nbsp;&nbsp; <span class="muted">β€” β€” calibrated threshold ${fmt(thr)}</span>` : "");
431
+ }
432
+
433
+ /* --- manual bisect ------------------------------------------------------ */
434
+ function manualReset() {
435
+ const d = state.data;
436
+ if (!d) return;
437
+ const spec = $("mmetric").value;
438
+ state.manual = { lo: 0, hi: d.n_steps - 1, probes: 0, log: [], spec,
439
+ zs: zSeries(d.metricsF[spec], spec, opts()) };
440
+ $("mstep").value = Math.floor((state.manual.lo + state.manual.hi) / 2);
441
+ drawManual();
442
+ }
443
+
444
+ function manualProbe() {
445
+ const m = state.manual, d = state.data;
446
+ if (!m) return;
447
+ const i = Math.max(0, Math.min(d.n_steps - 1, +$("mstep").value));
448
+ const { bad, z } = prefixBad(m.zs, i);
449
+ m.probes++;
450
+ m.log.push({ i, bad, z });
451
+ if (bad) m.hi = i; else m.lo = Math.min(i + 1, m.hi);
452
+ $("mstep").value = Math.floor((m.lo + m.hi) / 2);
453
+ drawManual();
454
+ }
455
+
456
+ function drawManual() {
457
+ const m = state.manual, d = state.data;
458
+ const width = m.hi - m.lo + 1;
459
+ const ideal = Math.ceil(Math.log2(Math.max(2, d.n_steps)));
460
+ let html = `<dl class="kv">
461
+ <dt>bracket</dt><dd>steps <code>${m.lo} … ${m.hi}</code> (${width} wide)</dd>
462
+ <dt>probes used</dt><dd>${m.probes}
463
+ <span class="muted">β€” a binary search over ${d.n_steps} steps needs about ${ideal}</span></dd>`;
464
+ if (d.truth !== null) {
465
+ const inside = m.lo <= d.truth && d.truth <= m.hi;
466
+ html += `<dt>ground truth</dt><dd>${d.truth} ${inside
467
+ ? '<span class="pill ok">inside the bracket</span>'
468
+ : '<span class="pill warn">outside β€” a probe answered differently</span>'}</dd>`;
469
+ }
470
+ html += "</dl>";
471
+ if (width <= 1) {
472
+ const e = d.truth === null ? "" :
473
+ ` &nbsp; error <strong>${Math.abs(m.lo - d.truth)}</strong> steps`;
474
+ html += `<p style="margin:12px 0 0"><span class="pill ok">converged</span>
475
+ first bad step = <strong style="font-size:17px">${m.lo}</strong>${e}</p>`;
476
+ }
477
+ if (m.log.length) {
478
+ html += `<div class="scroll" style="margin-top:12px"><table>
479
+ <thead><tr><th class="num">probe</th><th class="num">step</th><th>verdict</th>
480
+ <th class="num">worst z so far</th></tr></thead><tbody>` +
481
+ m.log.map((r, k) => `<tr><td class="num">${k + 1}</td><td class="num">${r.i}</td>
482
+ <td>${r.bad ? '<span class="pill bad">BAD</span>' : '<span class="pill ok">ok</span>'}</td>
483
+ <td class="num">${fmt(r.z)}</td></tr>`).join("") + "</tbody></table></div>";
484
+ }
485
+ $("manual").innerHTML = html;
486
+ }
487
+
488
+ /* --- self-check against the Python reference ---------------------------- */
489
+ function checkParity(res, loss) {
490
+ const d = state.data;
491
+ const o = opts();
492
+ const isDefault = o.win === d.win && o.mode === "diff" && o.minVotes === 3 &&
493
+ o.voteWindow === 4 && !o.requireConsensus &&
494
+ $("metric").value === "auto";
495
+ if (!isDefault) {
496
+ $("parity").innerHTML = `<span class="pill warn">settings changed</span>
497
+ Computed in-browser. The reference check below only applies at the default settings.`;
498
+ return;
499
+ }
500
+ const ref = d.reference;
501
+ const agreeStep = ref.consensus.step === res.step;
502
+ const agreeLoss = (ref.loss_only.step ?? null) === (loss.detected ? loss.step : null);
503
+ let mismatches = 0, checked = 0;
504
+ for (const [spec, r] of Object.entries(ref.per_metric)) {
505
+ const got = detectOnSeries(d.metricsF[spec], d.steps, spec, o);
506
+ checked++;
507
+ if (!got.detected || got.step !== r.step) mismatches++;
508
+ }
509
+ const ok = agreeStep && agreeLoss && mismatches === 0;
510
+ $("parity").innerHTML = ok
511
+ ? `<span class="pill ok">verified</span> This page's JavaScript detector reproduces
512
+ <code>flashback.bisect</code> exactly on this run: same consensus step
513
+ (${fmt(ref.consensus.step)}), same loss-curve verdict, and the same first-firing step
514
+ for all ${checked} metrics that fire. The Python answers are shipped in
515
+ <code>data/${d.key}.json</code> and checked on every render.`
516
+ : `<span class="pill bad">mismatch</span> The in-browser port disagrees with Python on
517
+ this run (${mismatches}/${checked} metrics differ${agreeStep ? "" : ", consensus step differs"}).
518
+ Trust <code>flashback.bisect</code>, not this page, and please
519
+ <a href="https://github.com/NagaYu/flashback/issues">file an issue</a>.`;
520
+ }
521
+
522
+ boot();
523
+ </script>
524
+ </body>
525
  </html>