Upload folder using huggingface_hub
Browse files- README.md +85 -4
- data/bit_flip.json +0 -0
- data/clean.json +0 -0
- data/data_poison.json +0 -0
- data/index.json +183 -0
- data/lr_spike_loud.json +0 -0
- data/lr_spike_subtle.json +0 -0
- data/overflow.json +0 -0
- detector.js +224 -0
- index.html +523 -17
README.md
CHANGED
|
@@ -1,10 +1,91 @@
|
|
| 1 |
---
|
| 2 |
title: Flashback
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
<span style="color:var(--loss)">β</span> where the loss curve notices
|
| 390 |
+
<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(" ") +
|
| 430 |
+
(thr !== null ? ` <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 |
+
` 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>
|