mlx-bun 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +175 -93
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/adapters-end-to-end.md +50 -46
  4. package/docs/design/compat-cli-surface-design.md +10 -8
  5. package/docs/design/docs-reorg-plan.md +14 -1
  6. package/docs/design/hlg-sampling.md +12 -2
  7. package/docs/design/memory-system.md +193 -295
  8. package/docs/design/optimization_plan.md +4 -2
  9. package/docs/design/orpo-dynamic-lambda.md +179 -0
  10. package/docs/design/orpo-future-enhancements.md +340 -0
  11. package/docs/design/orpo-training.md +1212 -0
  12. package/docs/design/parallel-slots.md +34 -26
  13. package/docs/design/parity-tier-dag.md +143 -0
  14. package/docs/design/runtime-isolation.md +1 -1
  15. package/docs/design/segmented-backward-training.md +58 -39
  16. package/docs/design/tauri-desktop-app.md +127 -0
  17. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  18. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  19. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  20. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  21. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  22. package/docs/investigations/starter-model-port-handoff.md +32 -13
  23. package/docs/investigations/steel-flash-cce-handoff.md +489 -0
  24. package/docs/investigations/trainer-validation-experiment.md +173 -0
  25. package/docs/planning/IDEAS.md +29 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/journal.md +161 -0
  29. package/docs/reference/distribution.md +61 -5
  30. package/docs/reference/library-api.md +29 -14
  31. package/docs/reference/memory.md +77 -0
  32. package/docs/reference/orpo-quickstart.md +130 -0
  33. package/docs/reference/server-api.md +105 -7
  34. package/docs/reference/server-config.md +12 -5
  35. package/docs/reference/training.md +296 -0
  36. package/package.json +1 -1
  37. package/src/cli.ts +651 -18
  38. package/src/eval/ifeval.ts +138 -0
  39. package/src/eval/runner.ts +76 -6
  40. package/src/eval/tasks/bfcl.ts +49 -15
  41. package/src/eval/tasks/gsm8k.ts +21 -9
  42. package/src/eval/tasks/hashhop.ts +33 -4
  43. package/src/eval/tasks/humaneval.ts +30 -8
  44. package/src/eval/tasks/ifeval.ts +52 -18
  45. package/src/eval/tasks/mmlu.ts +85 -74
  46. package/src/eval/ultrafeedback.ts +60 -0
  47. package/src/jobs/runner.ts +6 -0
  48. package/src/lora.ts +12 -7
  49. package/src/memory/chunk.ts +25 -0
  50. package/src/memory/cluster.ts +24 -0
  51. package/src/memory/db.ts +114 -0
  52. package/src/memory/ingest.ts +22 -0
  53. package/src/memory/pipeline.ts +63 -0
  54. package/src/memory/prompts.ts +23 -0
  55. package/src/memory/schedule.ts +163 -0
  56. package/src/memory/synthesize.ts +29 -0
  57. package/src/memory/tools.ts +399 -0
  58. package/src/memory/vault.ts +694 -0
  59. package/src/memory/wikify.ts +23 -0
  60. package/src/mlx/metal-kernel.ts +31 -4
  61. package/src/model/flash-attention.ts +37 -19
  62. package/src/model/fused-geglu-kernel.ts +111 -0
  63. package/src/model/gemma4-base.ts +39 -1
  64. package/src/model/gemma4.ts +196 -21
  65. package/src/model/minicpm5.ts +44 -3
  66. package/src/pi-provider.ts +29 -13
  67. package/src/pi-session.ts +81 -0
  68. package/src/pi-terminal.ts +13 -24
  69. package/src/pi-web.ts +246 -166
  70. package/src/quantize/job.ts +17 -2
  71. package/src/quantize/quantizer.ts +26 -2
  72. package/src/server.ts +216 -47
  73. package/src/tokenizer.ts +6 -2
  74. package/src/train/dataset.ts +29 -6
  75. package/src/train/flash-cce.ts +1175 -0
  76. package/src/train/job.ts +64 -3
  77. package/src/train/lora-params.ts +53 -4
  78. package/src/train/loss.ts +947 -0
  79. package/src/train/optimizer.ts +10 -3
  80. package/src/train/prefix-shared.ts +435 -0
  81. package/src/train/rank.ts +36 -0
  82. package/src/train/segmented.ts +1148 -3
  83. package/src/train/steel-qmm-header.ts +1461 -0
  84. package/src/train/trainer.ts +570 -54
  85. package/src/train/watch.ts +542 -0
  86. package/src/web/app.html +285 -19
  87. package/src/web/skills/memory/SKILL.md +97 -0
  88. package/src/web/skills/web-research/SKILL.md +3 -1
  89. package/src/web/skills.ts +46 -8
  90. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -0,0 +1,232 @@
1
+ # Research spike — W4A16 head GEMM (compute precision, not storage)
2
+
3
+ **Status:** proposed (not started) · **Opened:** 2026-06-19 · **Owner:** Josh
4
+ **Surface:** `src/train/flash-cce.ts` (steel + SG kernels) · **Gate:** the existing
5
+ 1e-5 CCE parity gate + greedy-prefix logit parity.
6
+
7
+ > One-line thesis (Josh): *we've maxed **storage** quant (4-bit weights), but we
8
+ > dequant into fp32 and run the matmul in fp32 — so the head GEMM is **W4A32**.
9
+ > The untouched lever is **compute** precision: dequant 4-bit → half, matmul in
10
+ > `half` operands with an fp32 accumulator (**W4A16**), gate on parity. Same 4-bit
11
+ > storage, untouched. The only change is the float width we dequant into and
12
+ > multiply in.*
13
+
14
+ ---
15
+
16
+ ## TL;DR
17
+
18
+ - The flash-CCE head (forward logit GEMM `E@Wᵀ` and backward phase-2 `coeffᵀ@W`)
19
+ currently runs **W4A32**: 4-bit weights are dequantized into **fp32** threadgroup
20
+ tiles and multiplied with **fp32** simdgroup matrices. Verified in source (below).
21
+ - The 4-bit packing bought the cheap HBM read; the fp32 matmul throws away the
22
+ compute advantage. Apple GPUs run **fp16 FMA at ~2× fp32** on the regular SIMD
23
+ ALUs (no tensor cores). Both head GEMMs are **compute-bound** (see roofline note),
24
+ so half operands are the lever.
25
+ - **Industry-standard for the forward** (MLX `quantized_matmul`, llama.cpp, every
26
+ on-device 4-bit stack do exactly W4A16). Low risk; do it first.
27
+ - **Backward needs the mixed-precision caveat** — fp32 accumulate is non-negotiable
28
+ (already planned), and the **operands** (`coeffᵀ`, `h`) need a dynamic-range probe
29
+ before trusting fp16, specifically the near-zero coeff tails after the skips fire.
30
+ - **M1 Max constraint:** no `bf16` simdgroup_matrix (M2+ only), so **fp16 is the
31
+ only half option** — which makes the range-probe matter more here than on M3.
32
+ - Gate exactly like the existing skip epsilons: **fp32 stays the default-exact
33
+ parity mode**; half is the training default; the 1e-5 gate test runs against fp32.
34
+
35
+ ---
36
+
37
+ ## Current state — verified W4A32
38
+
39
+ The default production path for **both** forward and backward is the **steel**
40
+ kernel (MLX's verbatim `BlockMMA` GEMM, `steel-qmm-header.ts`), selected when
41
+ `H,V,blockV0 % 32 == 0` (`flash-cce.ts:1034`, `:1130`). The `simdgroup_float8x8`
42
+ SG kernels and the scalar/lane kernels are fallbacks for non-32-divisible shapes or
43
+ explicit `MLX_BUN_CCE_*` overrides.
44
+
45
+ Every one of these instantiations is fp32-operand:
46
+
47
+ | path | site | instantiation | operand width |
48
+ |---|---|---|---|
49
+ | steel fwd (default) | `flash-cce.ts:950` | `BlockMMA<float,float,…>` | fp32 |
50
+ | steel bwd phase-1 (default) | `flash-cce.ts:799` | `BlockMMA<float,float,…>` (lmma_t) | fp32 |
51
+ | steel bwd phase-2 (default) | `flash-cce.ts:800` | `BlockMMA<float,float,…>` (dmma_t) | fp32 |
52
+ | steel staging tiles | `:596–598`, loaders `:801` | `threadgroup float` / `BlockLoader<float,…>` | fp32 |
53
+ | SG fwd (fallback) | `:251–254` | `simdgroup_float8x8`, `Csh`/`Esh` = `threadgroup float` | fp32 |
54
+ | SG bwd (fallback) | `:617`, `:684–704` | `simdgroup_float8x8`, `Wsh` = `threadgroup float` | fp32 |
55
+
56
+ The dequant itself (`Csh[…] = sc * q + bi`, `flash-cce.ts:244`) computes into fp32
57
+ and stores fp32. The dh accumulator is already fp32 (`MMATile<float,…>`,
58
+ `flash-cce.ts:828`) — i.e. **the accumulator is already where it should be**; only
59
+ the matmul *operands* are wider than they need to be.
60
+
61
+ ---
62
+
63
+ ## The lever — what actually changes
64
+
65
+ W4A16 = **same 4-bit storage, half-width operands, fp32 accumulate.** Concretely:
66
+
67
+ 1. **Steel operand type** — the `BlockMMA<float,float,…>` first template param is the
68
+ GEMM input/operand type; flip operand to `half` while keeping the
69
+ accumulator/fragment in `float`. (**Spike task 0: confirm the exact MLX steel
70
+ template param that selects operand vs accumulator width** in
71
+ `steel-qmm-header.ts` before editing — don't assume the param order.)
72
+ 2. **Staging tiles** — `Esh`, `Cstg`, `Wsh`, `Csh` go `threadgroup half`; dequant
73
+ arithmetic can stay fp32 then narrow on store, or compute in half.
74
+ 3. **Loaders** — `BlockLoader<float,…>` → `BlockLoader<half,…>` to stage half tiles.
75
+ 4. **SG fallback** — `simdgroup_float8x8` → `simdgroup_half8x8` for the operands,
76
+ keep an `simdgroup_float8x8` accumulator (Apple supports the mixed `half×half→float`
77
+ `simdgroup_multiply_accumulate`).
78
+
79
+ Everything reduction-shaped (the online-softmax `m`/`d` recurrence, the dh accum,
80
+ logsumexp) **stays fp32**.
81
+
82
+ ---
83
+
84
+ ## First thoughts (mine) — what to add to Josh's analysis
85
+
86
+ The analysis is right and the change is well-targeted. Five additions, each grounded
87
+ in the current source:
88
+
89
+ **1. Aim at the steel kernel, not the SG snippet.** Josh's note led with the SG path
90
+ (`simdgroup_float8x8`, `Csh`). That path is a *fallback*. The lever that moves the
91
+ measured 180 ms fwd / 754 ms bwd (e4b) numbers is the **steel `BlockMMA<float,float>`**
92
+ instantiations (`:950`, `:799`, `:800`) and their `BlockLoader<float>`/staging tiles.
93
+ Do the SG path too for completeness, but the steel path is where the production time is.
94
+
95
+ **2. The 2× is an upper bound gated by the FMA fraction — get the roofline split
96
+ first.** The whole flash-CCE design is `[M,V]`-free and amortizes dequant across
97
+ `BLOCK_B` tokens specifically because the **dequant ALU** was "the dominant cost in
98
+ v1" (`flash-cce.ts:59`). The runtime splits into (a) nibble-unpack + `sc·q+bi`
99
+ dequant and (b) the FMA matmul. **W4A16 only speeds up (b).** The kernel is
100
+ unambiguously compute-bound, not BW-bound — **confirmed live** (see the measured box
101
+ below), and Josh flagged it from `mactop` before this doc existed. *But* the win
102
+ ceiling is the FMA share of that ALU time. The dequant-amortization work was done
103
+ precisely to shrink (a)'s share, which *should* leave (b) dominant and favorable — but
104
+ **measure the (a)/(b) split before predicting 2×.** The live numbers prove the
105
+ *whole kernel* is ALU-bound; they do **not** resolve the intra-kernel (a)/(b) split,
106
+ which is what actually bounds the reclaim. If the kernel is still dequant-ALU-bound,
107
+ half operands move it less.
108
+
109
+ > **Live roofline — measured (M1 Max 32 GB, MiniCPM5-1B ORPO, `--no-segment`, seq 4096):**
110
+ > GPU **100% @ 1296 MHz, 81 °C, Nominal** (sustained, not throttling) · memory
111
+ > **BW 129.2 GB/s ≈ ⅓ of the M1 Max's ~400 GB/s ceiling** (bus two-thirds idle) ·
112
+ > mem **17.37 / 32 GB (54%)**, swap 0 · CPU ~5%, ANE 0%. The training `bun` proc is
113
+ > 92.6% GPU. **GPU pegged + memory bus mostly idle = ALU-bound.** This is the regime
114
+ > where W4A16 pays; if it were BW-bound (BW near 400, GPU spiky) the spike would be
115
+ > moot. The 4-bit storage already did its job — *that's why BW is low* — and the GPU
116
+ > is now spending its cycles on the fp32 FMAs.
117
+
118
+ **3. Threadgroup memory is a free secondary win — possibly a compounding one.** The
119
+ staging tiles (`Esh`/`Cstg`/`Wsh`/`Csh`) are the kernel's threadgroup-memory pressure,
120
+ and tile sizing is *already* occupancy-constrained — see the note at `flash-cce.ts:71`
121
+ ("the 8KB `Csh` tile measured SLOWER"). Half-width staging **halves that footprint**,
122
+ which can either raise occupancy or admit larger tiles. So W4A16 may buy more than the
123
+ raw 2× FMA: it could relieve the exact constraint that capped the current tiling.
124
+ Worth re-sweeping `BLOCK_B`/tile dims *after* the precision change, not just measuring
125
+ the drop-in.
126
+
127
+ **4. SDPA in the segmented forward is probably already half — don't chase it.** Josh's
128
+ aside ("if your SDPA tiles are also fp32, same logic"). The segmented forward uses
129
+ `ops.sdpa` — MLX's own fused flash kernel ([[opssdpa-dk-vjp-bug]]) — which runs in the
130
+ model's dtype (bf16), **not** our fp32 tiles. So that lever is likely already pulled by
131
+ delegating to MLX. One-line confirm, then drop it; the head GEMM is the real target.
132
+
133
+ **5. Free instrumentation hooks already exist for the backward range-probe.** The
134
+ backward already computes `cmax = simd_max(coeff)` for the FILTER_EPS skip
135
+ (`flash-cce.ts:682`). That same reduction is the natural hook to log the coeff dynamic
136
+ range (min nonzero |coeff|) across a real run *before* committing the operand to fp16 —
137
+ the underflow-to-zero case Josh flagged. Pair it with a max-abs probe on `h`: at e4b's
138
+ `scale=1.0` peaked attention the residual stream can carry outliers
139
+ ([[e4b-forward-length-sensitivity]], [[siglip-vision-parity-cross-build]]), and with no
140
+ bf16 on M1 Max we can't lean on a wider exponent to eat them.
141
+
142
+ ---
143
+
144
+ ## Risk analysis (preserving the forward/backward split)
145
+
146
+ **Forward — low risk, boring industry default.** Logits are well-scaled; the
147
+ online-softmax max-subtraction bounds the `exp` args. W4A16 here is what the dense
148
+ path already assumes. Validate against the 1e-5 gate (fp16 operands won't hold 1e-5 —
149
+ that's expected; the **fp32 path stays the exact-parity gate**, half is graded by
150
+ greedy-prefix logit agreement, same as the vision tier-a bar).
151
+
152
+ **Backward — fp16 matmul is mainstream, but as *mixed precision*, with eyes on
153
+ operand range.** What makes the forward safe (well-scaled, max-subtracted) is **not**
154
+ automatically true here:
155
+ - `coeff_v = g·(onehot − softmax)·sech²` — after the skips fire, the survivors are the
156
+ **near-zero tails**. Small magnitudes are exactly where fp16's ~3-digit mantissa and
157
+ narrow range (subnormals < ~6e-5) bite. **`coeffᵀ` as an fp16 operand is the highest
158
+ risk** — tail coeffs underflowing to zero silently drop gradient contribution. Probe
159
+ the range first (task 5 above).
160
+ - `h` (activations) as fp16 operand — residual outliers; the classic bf16-beats-fp16
161
+ case, but bf16 isn't available on M1 Max. Range-check before trusting.
162
+ - **fp32 accumulate is non-negotiable** (already planned; `MMATile<float>` already is).
163
+ - **Validate grads against autograd, not a hand-derived backward** ([[opssdpa-dk-vjp-bug]]).
164
+
165
+ ---
166
+
167
+ ## Spike plan
168
+
169
+ Sequenced so each step is independently measurable and revertible (Josh: *"Don't flip
170
+ both at once — you'll learn more, and if parity drifts you'll know which GEMM did it."*).
171
+
172
+ - [ ] **0. Confirm the steel template semantics.** In `steel-qmm-header.ts`, identify
173
+ which `BlockMMA`/`BlockLoader` template params are operand vs accumulator width.
174
+ Exit: a one-line mapping, no code change.
175
+ - [x] **1a. Whole-kernel roofline — DONE (live).** GPU 100% / BW 129 GB/s (~⅓ of 400)
176
+ on M1 Max confirms the kernel is ALU-bound, not BW-bound (see the measured box above).
177
+ The spike is worth chasing.
178
+ - [ ] **1b. Intra-kernel split (still open).** Profile the steel fwd (180 ms) and bwd
179
+ (754 ms, e4b) into dequant-ALU vs FMA fractions — 1a says the GPU is the wall, this
180
+ says *which part* of the GPU work. Exit: a number that bounds the expected win. *If
181
+ FMA < ~40% of either kernel, reset expectations before coding.*
182
+ - [ ] **2. Add the precision gate.** New codegen `#define` (e.g. `CCE_A_HALF`) +
183
+ env flag (e.g. `MLX_BUN_CCE_A16`), mirroring `CCE_BWD_FILTER_EPS`/`CCE_BWD_BLOCK_EPS`
184
+ (`flash-cce.ts:732`,`:739`). Default: fp32 (exact) for the parity suite; half is the
185
+ training default. Per [[three-level-fidelity-tree-model]] the exact path must stay
186
+ selectable.
187
+ - [ ] **3. Forward → half operands.** Steel fwd `BlockMMA`/loader/staging to half.
188
+ Validate: 1e-5 gate (fp32 path) + greedy-prefix logit parity (half path) + measure
189
+ the fwd GEMM delta. Land as its own change.
190
+ - [ ] **4. Backward range probe.** Instrument coeff (via the `simd_max` hook) and `h`
191
+ dynamic range across a real MiniCPM5/e4b ORPO step. Exit: min-nonzero-coeff and
192
+ max-|h| numbers + a go/no-go on fp16 operands.
193
+ - [ ] **5. Backward → half operands** (only if 4 says go). `coeffᵀ`/`h`/`W` operands
194
+ half, dh accumulator stays fp32. Validate grads **vs autograd** + measure bwd delta.
195
+ - [ ] **6. Re-sweep tiling.** With half-width staging, re-check `BLOCK_B`/tile dims and
196
+ occupancy (first-thought #3) — the precision win may unlock a tiling win.
197
+
198
+ **Exit criteria for the spike:** a measured fwd/bwd speedup on e4b (V=262144) and
199
+ MiniCPM5 (V=130560) on the bench machine (M4 Pro 24 GB; numbers verified against
200
+ `~/.cache/mlx-bun/evals.sqlite`, not docs — [[verify-benchmarks-against-evaldb-not-docs]]),
201
+ with the fp32 exact path still passing the 1e-5 gate and the half training path inside
202
+ the greedy-prefix parity bar. Either it lands as the training default behind the gate,
203
+ or the roofline/range data explains why it didn't pay and the spike closes with that.
204
+
205
+ ---
206
+
207
+ ## Open questions
208
+
209
+ - Steel `BlockMMA` mixed operand/accum: does MLX's steel already support
210
+ `half`-operand / `float`-accum, or does it need a local template tweak? (task 0)
211
+ - Is the dequant cheaper to do **in half** (fewer ALU bits) or **in fp32 then narrow**
212
+ (preserves the scale/bias precision)? A/B inside task 3.
213
+ - Does the half staging change the `+4-float` padding trick (`flash-cce.ts:197`, the
214
+ BK_padded bank-conflict avoidance)? Half-width may re-introduce or shift bank
215
+ conflicts — re-derive the pad.
216
+
217
+ ---
218
+
219
+ ## References
220
+
221
+ **Code:** `src/train/flash-cce.ts` — steel fwd `:937–1028`, steel bwd `:763–935`, SG
222
+ fwd `:174–270`, SG bwd `:561–726`, gates `:732`/`:739`/`:1034`/`:1130`; staging tiles
223
+ `:596–598`; dequant `:244`; coeff `simd_max` hook `:682`; dh fp32 accum `:828`.
224
+ `src/train/steel-qmm-header.ts` (the verbatim MLX BlockMMA). `src/mlx/metal-kernel.ts`
225
+ (kernel infra).
226
+
227
+ **Cross-links:** [[flash-cce-metal-kernel-prototype]] · [[three-level-fidelity-tree-model]]
228
+ · [[opssdpa-dk-vjp-bug]] · [[e4b-forward-length-sensitivity]] ·
229
+ [[verify-benchmarks-against-evaldb-not-docs]] · `docs/investigations/steel-flash-cce-handoff.md`.
230
+
231
+ **Sibling deferred lever** (queued together, post-run): `docs/design/orpo-future-enhancements.md`
232
+ → "Deferred levers" (expose `--grad-accum`).
@@ -0,0 +1,120 @@
1
+ # ORPO from-base on UltraFeedback (mixed-5bpw) — results + research directions
2
+
3
+ **Status:** experiment complete (2026-06-23). This is the validation run **and** the
4
+ research program it surfaced. Detailed research notes for the session of 2026-06-21…23.
5
+
6
+ **Companion docs:**
7
+ - [orpo-dynamic-lambda.md](../design/orpo-dynamic-lambda.md) — the adaptive-λ / PID controller (extended below).
8
+ - [orpo-future-enhancements.md](../design/orpo-future-enhancements.md) — SimPO + objective variants.
9
+ - [orpo-uf-testing-handoff.md](orpo-uf-testing-handoff.md) — the **earlier** from-*instruct* UF run (4-bit, seq 2048, no-segment). **Distinct** from this run.
10
+ - [segmented-backward-training.md](../design/segmented-backward-training.md), [steel-flash-cce-handoff.md](steel-flash-cce-handoff.md) — the memory/kernel stack this run exercised.
11
+
12
+ ---
13
+
14
+ ## 1. The run
15
+
16
+ From-**base** (true base, not instruct) ORPO on UltraFeedback, MiniCPM5-1B.
17
+
18
+ - **Base:** `openbmb/MiniCPM5-1B-Base-OptiQ-mixed-5bpw` — the **true base**, mixed-precision quant (unblocked by the quantization fix). NOT the instruct model the earlier run used.
19
+ - **Data:** `uf-binarized-chat` — UltraFeedback as `{prompt:[{role,content}], chosen, rejected}`, **60,630 train pairs**. Prompt is a structured message array; chosen/rejected are raw response strings.
20
+ - **Config:** ORPO, **1 epoch (3,790 optimizer steps)**, B=1, **seq 4096**, rank 16 / scale 2, **λ=0.1**, lr **1e-5**, **segmented backward (2 layers/seg)**, **flash-CCE head**, **grad-accum 16** (eff. batch 16), coeff filter ON (`MLX_BUN_CCE_BWD_FILTER_EPS`, real-data ~0.16%).
21
+ - **Template:** the base quant has **no `tokenizer_config.chat_template`**; `ChatTemplate.load` falls back to **`chat_template.jinja`** (which *we* dropped into the quant when building it). It's **ChatML** (`<|im_start|>{role}\n…<|im_end|>`) **+ MiniCPM's XML tool format** (`<function><param>…</param></function>`, CDATA) **+ the hybrid `<think>` block**. We rendered `enableThinking:false` (injects an empty `<think>\n</think>\n\n` before the response).
22
+ - **Recipe fidelity:** lr 1e-5 + λ 0.1 (NOT the saturating 5e-5); **mean-logp verified length-normalized** over response tokens (`responseOnlyLogpMean`: `sum(logp)/M`, M = response-token count) — this is the single most common ORPO parity break and we are on the right side of it, including in the flash-CCE head (parity-validated to 0.21% vs the full-logits mean path).
23
+
24
+ ## 2. Memory validation (the headline systems result)
25
+
26
+ - **Peak 4.82 GB** — the legitimate high-watermark, driven by the longest example (~2,354 tok chosen+rejected, idx 5582, **+ the prompt**), **not** the 4096 cap (all UF < 4096; 0 examples exceed it — that's *why* 4096 was chosen). My earlier ~4 GB estimate was ~0.5 GB low (slope under-call), not a leak.
27
+ - **Active flat at 0.892 GB** across ~3,300+ steps — **bit-identical early-to-recent, zero drift → NO leak.** Active (the baseline), not peak (example-length-driven), is the leak signal. The ~0.4 MB/step leak we caught earlier would've surfaced as **~1.3 GB of drift** over the run; we saw **0**. So any residual leak is bounded *below detectability*.
28
+ - The whole stack — flash-CCE, steel GEMM, segmented backward, AdamW, grad-accum, the coeff filter + block-skip — ran **dispose-balanced end-to-end** for a full epoch. **A 1B ORPO epoch in < 5 GB, holding flat.** This retires the memory risk for the bigger runs below.
29
+
30
+ **Predictive memory model (validated):** peak ≈ resting + (2-layer-segment activation) × (longest sequence). Deterministic, bounded; plateaus once the longest example runs. Watch `active_gb` for drift = the leak detector.
31
+
32
+ ## 3. Results — the model
33
+
34
+ - **Val preference accuracy FLAT ~55.5%** across every eval (500→3000: 55.1, 55.1, 56.3, 55.9, 56.3, 55.9). Plateaued by step 500, **never strengthened**. The 55→56 wobble is 1–3 of 256 examples — inside the ±3.1% SE. n=256.
35
+ - **Val loss** crept down (1.356 → 1.305 — the NLL/format half); **train margin** rose slightly (mean 0.19 → 0.25). A **weak, stable preference** reached early and held.
36
+ - This is the **two-ORPO-halves-decouple** result again: the SFT/NLL half learns the format fast; the preference half barely generalizes on a 1B with subtle UF pairs.
37
+
38
+ ## 4. Vibe-check — 20 questions through base+UF
39
+
40
+ **Single-turn (fresh context each):**
41
+ - **Good:** simple **code** (prime function correct, sqrt-optimized), **arithmetic** (17×23=391 ✓, 3+5−2=6 ✓), **creative persona** (the pirate plays the role).
42
+ - **Weak/wrong:** **factual recall** ("capital of Australia" → *"there is no specific capital"*; "most recent election" → *"Trump won 2020"*), **translation** ("I love programming" → *"Je love à programmer"*), **identity/privacy/weather** (hedging non-answers), **instruction constraints** ("list exactly three… and nothing else" → preambled anyway).
43
+
44
+ **Two failure MODES (the actually-useful findings):**
45
+ 1. **Multi-turn copying loop** — in a running conversation, once a generic disclaimer enters the history, the model **parrots its own previous assistant turn** for nearly every subsequent question (Q4–9, 15–20 were byte-identical). **Root cause: UF is single-turn preference data** — it never learned to hold a conversation, so the path of least resistance is copy-the-last-turn.
46
+ 2. **Hedging-disclaimer boilerplate** ("I'm here to help… I am a language model… respectful and safe… I will never fab[ricate]") — over-learned from UF's RLHF-safety filler.
47
+
48
+ ## 5. Conclusions
49
+
50
+ 1. **VALIDATION SUCCESS.** The chain works end-to-end: quant **base** loads → trains clean a full epoch → **zero leak** → **chats**. ORPO replicated at ~1/60th scale, **on a laptop**, single-stage base→assistant.
51
+ 2. **Modest-as-predicted.** base + 1 epoch UF + λ=0.1 = a weak preference (flat 55.5%) and modest quality. We forecast this; it is the expected outcome, **not a miss**.
52
+ 3. **The failure modes are DATA artifacts, not method artifacts.** Multi-turn collapse ← single-turn UF; hedging ← UF's disclaimer filler. Both fixable by **changing the data** → this *reinforces* the specialist-curation direction (we control the data). The vibe-check effectively handed us the **curation spec**: add multi-turn examples, filter the disclaimer filler, use our tools in our format.
53
+ 4. **CONFOUND CORRECTION (load-bearing).** The gap vs `MiniCPM5-1B-SFT` is **NOT** evidence that single-stage can't replace SFT. We changed **method AND data simultaneously** — 60k UF pairs vs ~400B-token SFT (~1000×). The data difference dominates. The only clean claim is the near-tautological **"60k UF pairs ≠ 400B-token SFT"** (a *data* statement). The **method** question (single-stage vs multi-stage) is **untested** — it requires holding data constant (see §6.5).
54
+
55
+ ## 6. Research directions (crystallized this session)
56
+
57
+ These form a build: each removes a confound or fixes a measured weakness of the prior.
58
+
59
+ ### 6.1 SimPO, and why it's nearly free for us
60
+ SimPO = reference-free, length-normalized, `−log σ(β·(ℓw−ℓl) − γ)` with a **target margin γ** and **no SFT term** (pure preference; applied *after* SFT). We already compute the length-normalized `ℓw`, `ℓl` (`branchLogpMean`), so SimPO is a **one-term loss swap** → a `--method simpo` + `--beta`/`--gamma`. Natural fit for **SimPO-on-`MiniCPM5-1B-SFT`** (the staged comparison).
61
+
62
+ ### 6.2 ORPO + SimPO's margin (the γ-hybrid) — the fix aimed at *our* result
63
+ For our regime (ℓw≈ℓl), ORPO's odds-ratio ≈ the logp difference already; what SimPO *adds* is the **target margin γ**. ORPO's `−log σ(separation)` gradient **vanishes once chosen ≥ rejected** (separation ≥ 0) → the model plateaus at tiny separation = **our flat margin**. The γ margin **moves the saturation point from 0 to γ**, keeping gradient alive. So: `L = NLL(chosen) + λ·[−log σ(β·(ℓw−ℓl) − γ)]` — ORPO's SFT anchor + SimPO's non-saturating push. Aimed directly at the flat-55.5% we measured.
64
+
65
+ ### 6.3 SFT + SimPO single-stage (the SimPO analog of ORPO)
66
+ "SFT + SimPO at the same time," the way ORPO is "SFT + DPO at the same time." Composes cleanly (both NLL and SimPO push chosen-logp **up** → reinforce). **Watch-out:** SimPO also pushes `logp(rejected)` **down**, and the NLL anchor grounds chosen but **not** rejected — over-aggressive β/γ over-suppresses coherent rejected text → fluency erosion. Tune so the push doesn't outrun the anchor.
67
+
68
+ ### 6.4 Dynamic preference controller (PID/AIMD) — see [orpo-dynamic-lambda.md](../design/orpo-dynamic-lambda.md)
69
+ The flat margin looks like **saturation**, and the *optimal* preference pressure is **non-stationary** (fragile early, robust late) → **no fixed λ is optimal**; a controller tracks the moving target and **beats any fixed value** (a sweep finds the best *constant*; this finds the best *trajectory*). Reframed this session as a **control problem**: NLL = the process variable, the preference weight = the actuator, AIMD/PID = the law. The integral term (the "missing letter") kills chronic steady-state degradation; the derivative (slope) is a **leading** indicator that catches the helping→hurting inflection *before* the level rises. Build **P → PD → PID**, each term earning its place against an observed failure of the prior. Folds the λ/β/γ sweep into one run. Detail + the control-theory framing now in orpo-dynamic-lambda.md §"Control-theory framing (PID/AIMD)".
70
+
71
+ ### 6.5 SFT-as-preference / SPIN comparison — the confound-free method test
72
+ **The interesting one.** Take a **chunk of `UltraData-SFT-2605`** (CPM5's *own* SFT data), make preference pairs **SPIN-style** (chosen = the gold answer, **rejected = on-policy: the model's own generation** for that prompt), train single-stage. Removes the data confound (same data CPM5-SFT saw). Two comparisons:
73
+ - **Method-isolating (rigorous):** plain SFT vs single-stage-preference on the **same chunk** → proves "preference-formatted SFT ≥/= SFT" with no confound. *Why it could be ≥:* ORPO on (gold vs self-gen) = NLL does the SFT **+** the preference term adds "commit to gold over your own output."
74
+ - **"Approach CPM5-SFT" (aspirational, data-gated):** needs a large fraction of 400B tokens → the data gap re-enters; don't let it quietly re-import the confound.
75
+ - Smart first cut: a **single-domain chunk** (knowledge or instruction-following — where base+UF was weakest) — small, legible.
76
+ - **NOT an ORPO-paper replication** — a *different*, arguably more novel comparison.
77
+
78
+ ### 6.6 Scale-up to a standard 7B, on the laptop
79
+ Memory math (validated): 7B resting ~4.4 GB + 2-layer-segment activations → **~8–12 GB peak at seg 2** on UF-length data; **seg 1** halves the activation term (more recompute = slower) for headroom. **Fits 32 GB with room** — the segmented backward was *built* for exactly this (e4b targeted ~10 GB @ 8192; UF < 4096 is the easy end). **Time, not memory, is the cost** (~7× FLOPs/token → days on a laptop). Prefer a **standard dense 7B (Mistral-7B-base** — the ORPO paper's flagship) over e4b for direct literature comparison; it's standard Llama-arch (≈ MiniCPM5's family) so the handler is ~a day. **Strategic flip:** "7B preference-aligned on a 32 GB consumer laptop" *is* the local-to-you thesis at scale — scale-up strengthens the accessibility pitch instead of diluting it.
80
+
81
+ ### 6.7 Diffusion-LM frontier (high-novelty, low *marginal* cost)
82
+ Apply ORPO/SimPO/the controller to a **diffusion language model** (e.g. a Gemma-diffusion). Likely **open territory** (DPO-for-diffusion exists for *images* — Diffusion-DPO — not for diffusion *LMs* + single-stage/dynamic). The diffusion **forward is product-justified anyway** (mlx-optiq has it, mlx-lm doesn't, mlx-vlm does) → once it exists, the preference objective is the same **loss-head swap**. Technical crux: the likelihood is a **stochastic ELBO** (not exact logp), so `ℓw`/`ℓl` are noisy MC estimates → **the dynamic controller's noise-robustness is a *synergy*, not a mismatch.** Length normalization must be rederived for the diffusion loss. Base quality lags AR (24B-diff ≈ 12B-AR), so the contribution is *"does single-stage preference-opt work on diffusion LMs,"* novel regardless.
83
+
84
+ ## 7. Template note (for any future run)
85
+
86
+ - For **portability / paper-grade** runs, impose a **clean plain-ChatML** template (no MiniCPM XML-tool / `<think>` extensions) at data-prep instead of falling back to the model's custom one. Trivial (drop a clean `chat_template.jinja` / override `renderDpoPrompt`).
87
+ - The model's **native tool format is the XML `<function>/<param>`** one — any tool-use training data must use **that**, not OpenAI JSON `tool_calls`.
88
+
89
+ ## 8. The data — `UltraData-SFT-2605` (the substrate for §6.5)
90
+
91
+ [openbmb/UltraData-SFT-2605](https://huggingface.co/datasets/openbmb/UltraData-SFT-2605) — the actual SFT data behind `MiniCPM5-1B-SFT`:
92
+ - **15,036,178 examples · 319 GB · ~400B tokens** (200B deep-thinking + 200B hybrid-thinking). **Apache 2.0.**
93
+ - **Single-response (gold only) — NOT preference pairs** → the rejected must be manufactured (SPIN-style on-policy).
94
+ - **Single-turn** (no multi-turn) → won't, by itself, fix the multi-turn copying loop.
95
+ - Domains: math / code / knowledge / instruction-following. Thinking split **6.5M deep / 8.5M non-thinking**. **Chunked by domain** → take a slice, never the full 319 GB. (Multi-GB download is the user's to kick off, not a session's.)
96
+ - The three published models: `MiniCPM5-1B-Base` → `MiniCPM5-1B-SFT` → `MiniCPM5-1B` (instruct, RL+OPD on top of SFT).
97
+
98
+ ## 9. The framing that ties it together
99
+
100
+ - **What SFT builds vs what preference-opt aligns:** our failure list (multi-turn, instruction discipline, breadth, `<think>`) is ~the SFT stage's job description — but per §5.4 that's mostly a **data** statement (400B curated tokens), not proof the SFT *method* is irreplaceable.
101
+ - **Where single-stage genuinely wins:** specialists (narrow curated data — no foundation breadth needed) and as a **final alignment stage on a good SFT**. NOT a foundation-SFT replacement — *and our own run is the evidence for that boundary, honestly scoped.*
102
+ - **The narrow specialist is the product** (welcome-agent / memory-agent on our closed tool surface); the general UF model was always the **validation**, and it handed us the curation spec for free.
103
+
104
+ ## 10. Choosing what's next — the three lenses (NO decision made yet)
105
+
106
+ Apply all three to any §6 direction; they point different ways:
107
+
108
+ 1. **Will it work + be useful to US?** (the *product* lens — primary.) Does it improve the specialists we actually ship? Usually gated by a **cheap diagnostic first** — e.g. for the controller: does a *higher fixed λ* or the **γ-margin** even unstick the flat preference? If a fixed push does nothing, *no controller can.* Run the cheap fixed-knob version before the fancy one.
109
+ 2. **Interesting paper?** (read / replicated / adds to the ORPO-style discourse.) Strong hooks (control-theory/PID, SFT-as-preference, diffusion-LM preference-opt), but it's a **crowded, competitive space** → the result has to be the non-stationarity / confound-free kind to be more than "auto-tuning," and credibility likely needs **scale** (7B+, standard model, standard benches).
110
+ 3. **Fundable / by whom?** The *technique* funds itself via the **product** (cheaper specialist training), independent of any paper. As a paper/grant: compute-cost-sensitive trainers + the local-AI community + project credibility. It's a *method* contribution, not a product.
111
+
112
+ **The trap + the rule:** don't let the *paper* lens drive the work. **Let the product need drive it** (lens 1), run the cheap diagnostics first, and treat the **paper as a harvest** if the result turns out strong — the experiments that make it a good feature are the same ones that'd make it a paper, so the lens-2/3 decision can wait until lens 1 reports.
113
+
114
+ **Rough risk / novelty / cost of the §6 directions (for when we pick):**
115
+ - **§6.2 γ-hybrid / higher-λ diagnostic** — lowest cost, directly targets the measured flat preference. *The cheap first cut.*
116
+ - **§6.1/6.3 SimPO-on-SFT / SFT+SimPO** — low (loss-swap), needs the SFT checkpoint pulled.
117
+ - **§6.4 PID controller** — medium (build + tune), highest method-novelty of the AR options.
118
+ - **§6.5 SPIN-on-UltraData** — medium (chunk + on-policy generation), the **confound-free method test**, distinct from ORPO replication.
119
+ - **§6.6 7B-Mistral-on-laptop** — ~a day handler + days of compute; the credibility + thesis-at-scale move.
120
+ - **§6.7 diffusion** — highest novelty, lift mostly paid by the product (the forward exists anyway); the ambitious swing.
@@ -0,0 +1,167 @@
1
+ # flash-CCE host-buffer pin leak — post-mortem + L3 verification approach
2
+
3
+ **Date:** 2026-06-21 · **Status:** fixed (`fix(train): stop host-buffer pin leak…`,
4
+ commit `2592959`) + hardened (`harden(train): anchor ptr()'d FFI buffers…`, `6491696`).
5
+
6
+ ## Symptom
7
+
8
+ ORPO LoRA training (MiniCPM5-1B, flash-CCE head + prefix-share + segmented
9
+ backward) crashed **natively** (segfault / "panic: A C++ exception occurred",
10
+ SIGTRAP — *not* an MLX-reported error) after a few hundred steps:
11
+
12
+ | config | crashed at |
13
+ |---|---|
14
+ | accum-4 + seg-2 (most ops/step) | step **363** |
15
+ | accum-4 + no-seg | step **783** |
16
+ | accum-1 + no-seg (fewest ops/step) | step **≥915** |
17
+
18
+ Tells: **deterministic** (same step across runs), **monotonic in ops/step**
19
+ (more kernel ops per step → earlier crash), and MLX `active` memory **flat**
20
+ (~1.3 GB) the whole time. Crucially, **it ran the entire training with zero
21
+ crashes on a 32 GB M1 Max**, and crashed on a 24 GB M4 Pro.
22
+
23
+ ## Wrong turns (recorded so we don't repeat them)
24
+
25
+ 1. **Agent sandbox** — the first crash was on a session-launched run, so the
26
+ sandbox looked guilty. Ruled out: a user-launched run crashed at the *same*
27
+ step 783.
28
+ 2. **GPU-allocator fragmentation / OOM** — `peak` sat at 16.7 GB on a 24 GB
29
+ box. Ruled out: with segmentation `peak` was only 2.9 GB and it crashed
30
+ **earlier** (363), and the same 16.7 GB peak ran fine post-fix. Not memory.
31
+ 3. **prefix-share fallback / templated-data seam** — a real new behavior (the
32
+ `</think>\n\n` seam trips the byte-identical prompt check → two-forward
33
+ fallback) but not the cause.
34
+ 4. **grad-accum / grad-clip (the new settings this session)** — the right
35
+ *thread* (look at what changed), wrong leaf. Both were exonerated.
36
+
37
+ ## Root cause
38
+
39
+ `u32()` in `flash-cce.ts` (and the twin in `model/flash-attention.ts`) built the
40
+ tiny `shape` / `targets` kernel-argument arrays with **`MlxArray.fromView`** —
41
+ the **zero-copy pinning** path. `fromView` inserts the host `Uint8Array` into a
42
+ module-level `pinned` Map and hands MLX a raw pointer with an **async**
43
+ `unpinCallback` ("mlx may release buffers from its eval thread"). These args feed
44
+ a **lazy** kernel and are `dispose()`d in a `finally` *before the kernel
45
+ evaluates* — so the pin is never reclaimed in time. The pins **leaked**.
46
+
47
+ **Proof (the smoking gun).** Instrumenting `pinnedBufferCount()` per step on the
48
+ buggy code:
49
+
50
+ ```
51
+ step 1: pinned 32 active 1.087GB
52
+ step 2: pinned 64 active 1.088GB
53
+ ...
54
+ step 32: pinned 1024 active 1.099GB → +32 per step, never released
55
+ ```
56
+
57
+ Perfectly linear `+32/step`, `active` dead flat. The leaked buffers live in the
58
+ JS heap + a threadsafe-JSCallback dtor queue, **outside MLX's `active`
59
+ accounting** — which is exactly why every symptom held: per-op accumulation (more
60
+ kernel calls/step → faster growth → earlier crash), `active` flat, native crash
61
+ (host allocator / callback queue corruption), and **machine-dependence** (a
62
+ 24 GB box reuses the freed-but-pinned buffer sooner → a latent use-after-free
63
+ becomes a fatal read; 32 GB had the headroom to never hit it).
64
+
65
+ ## Fix
66
+
67
+ `u32()` → **`fromBytesCopy`** (`mlx_array_new_data`, which **copies**). These
68
+ few-int arrays feed a lazy kernel and are disposed before it runs, so they must
69
+ be **MLX-owned**, not host-pinned. A few-int copy is free.
70
+
71
+ **Verification:** `pinned` goes `+32/step → flat 0`; the previously-fatal
72
+ seg-2/accum-4 config ran to **step 548** (past 363) clean; numerics unchanged
73
+ (copy vs pin = same bytes).
74
+
75
+ ## Hardening (defense-in-depth)
76
+
77
+ - **`MetalKernel.apply` keepAlive** — anchor every `ptr()`'d JS buffer (output
78
+ shapes, template-arg names, the input-handle array, the result slot) in a
79
+ `keepAlive` list referenced after the FFI calls. `bun:ffi` `ptr()` does not
80
+ retain its argument; this closes the same lifetime class. Pure-additive.
81
+ - Added `pinned: pinnedBufferCount()` to the per-step train metric as a permanent
82
+ **canary** for this bug class (visible in `train-watch`).
83
+
84
+ ## Lesson for the codebase
85
+
86
+ `fromView` (zero-copy pin) is only safe for buffers that **outlive the MLX
87
+ array** (mmap'd weights — `fromPointer`/`fromView` with process-lifetime memory).
88
+ For any **transient** host buffer handed to a **lazy** op and disposed before
89
+ eval, use the **copying** constructor (`fromBytesCopy`/`fromInt32`/`fromFloat32`).
90
+ Grep for `fromView` in hot paths when adding kernels.
91
+
92
+ ---
93
+
94
+ # L3 verification approach (no oracle exists)
95
+
96
+ This bug also clarified how we *should* verify the flash-CCE ORPO head, because
97
+ "bf16-class error" was the wrong framing.
98
+
99
+ ## The parity tiers
100
+
101
+ - **L1** — bit-for-bit drop-in for **mlx-lm** (proven: 8192/8192 greedy tokens
102
+ identical).
103
+ - **L2** — bit-for-bit drop-in for **mlx-optiq**.
104
+ - **L3** — mlx-bun originals (the flash-CCE ORPO training head). **Neither
105
+ mlx-lm nor optiq ships ORPO+CCE training — there is no oracle to be bit-exact
106
+ against.**
107
+
108
+ So the "0.28% dh" figure is **not an error rate or a degradation.** It is the
109
+ **floating-point reassociation gap between two *correct* implementations** — the
110
+ flash kernel (tiled, online-softmax, fp32) vs the full-logits MLX head (a
111
+ different reduction order) — and the full-logits head is itself only a *proxy*,
112
+ not an oracle. Two correct reductions in different orders cannot be bit-identical;
113
+ 0.28% is the inherent floor, not slop.
114
+
115
+ **Correctness in L3 is proven by what does NOT need a reference implementation:**
116
+
117
+ 1. **Finite-difference gradient check** (`flash-fd-check.ts`) — the kernel's `dh`
118
+ vs numerical ground truth to ~4 decimals. This is the real proof: it says the
119
+ kernel computes the *right gradient*, independent of any other implementation.
120
+ 2. **By-hand math audit** of the dequant, online-LSE recurrence, `coeff =
121
+ onehot − softmax`, dh accumulation, and the `1/M` cotangent (done; clean).
122
+ 3. **Agreement with the full-logits proxy** to fp-reassociation precision
123
+ (logp 0.21%, dh 0.28%) — corroboration, not the proof.
124
+
125
+ ## The coeff filter — measure on REAL data, not synthetic
126
+
127
+ `flash-cce-parity.ts` uses a **synthetic random hidden**, which gives a
128
+ **flat-ish** softmax (high entropy) → gradient mass spread across many tokens →
129
+ filtering the near-zero coeffs costs accuracy (the recorded 0.66%→2.7%). **That
130
+ is the filter's worst case and not what it's for.** On real trained-model
131
+ outputs the next-token distribution is **sharply peaked** (low entropy):
132
+ `coeff_v = c·(onehot_v − softmax_v)`, and for almost every non-target token
133
+ `softmax_v ≈ 0`, so its coeff ≈ 0 and dropping it costs ~nothing — the gradient
134
+ mass is in the target + the handful of high-softmax tokens. **The filter is
135
+ designed exactly for the peaky real-data regime; the synthetic benchmark
136
+ maximizes its apparent cost.**
137
+
138
+ **MEASURED (2026-06-22, `scripts/experiments/flash-cce-filter-realdata.ts`) — the
139
+ hypothesis holds.** On 8629 real response tokens from real examples (real model
140
+ forward, **real next-token targets**; mean P(true token) = 53.9% → genuinely
141
+ peaked), the filter's dh error vs exact:
142
+
143
+ | eps | dh error | backward speedup |
144
+ |---|---|---|
145
+ | 1e-6 | 0.024% | 1.15× |
146
+ | **1e-5** | **0.162%** (below the 0.33% bf16 floor → indistinguishable) | **1.35×** |
147
+ | 1e-4 | 0.681% | 1.47× |
148
+ | 1e-3 | 1.943% | 1.52× |
149
+
150
+ The synthetic `REAL=1` test (random targets) showed **21.4% @ 1e-4** — a ~30×
151
+ overstatement, an artifact of the onehot landing on a ~0-softmax random token.
152
+ **Verdict: enable the filter at eps ~1e-5** — free speed (error under the
153
+ reassociation floor) on real data. Re-measure as the model trains (peakedness
154
+ shifts), and always with REAL targets, never synthetic.
155
+
156
+ ## Standing L3 gates (what to run, since there's no oracle)
157
+
158
+ Per the project's parity-as-correctness model (bit-exact where an ancestor
159
+ overlaps; KL + quality + benchmarks where it doesn't):
160
+
161
+ 1. **Filter-on-real-data** — ✅ DONE (above): near-free at eps ~1e-5 on real
162
+ targets (0.16% dh, 1.35× backward). `flash-cce-filter-realdata.ts`.
163
+ 2. **Teacher-forced grad fidelity** — flash head vs full-logits head `dh`
164
+ (gradient cosine + relnorm) on real data, as the standing L3 regression gate
165
+ (replaces the synthetic-data parity number for *quality* claims).
166
+ 3. **End-to-end quality eval** — the completed training run's downstream task
167
+ quality (the real proof that L3 trains correctly).