Skip to main content

cortiq_engine/
skillbake.rs

1//! Native DTG-MA skill bake (Patent 2) — no Python, no torch.
2//!
3//! The certified recipe of `converter/make_skill_l1fcd.py`, in Rust on
4//! the `FcdModel` f32 replica:
5//!
6//! - **Phase A** — a trainable L1 mask over FFN neurons (one logit per
7//!   neuron, applied to the input of down_proj as σ(m)): pure LM loss
8//!   on the task corpus + a progressive L1 penalty. Every 30 steps the
9//!   binarized mask (σ>τ) is scored on held-out chunks; the best
10//!   checkpoint — the *denoising bottom* — is restored at the end.
11//!   Pruning noise neurons IMPROVES the model before it starts to hurt.
12//! - **Phase B** — FCD: the FFN of the last N layers trains against the
13//!   same LM loss with the hard mask active (cosine LR), held-out
14//!   gated, best checkpoint restored.
15//!
16//! Attention (softmax and GDN alike) is FROZEN and carries no gradient
17//! — exactly like the reference recipe (`torch.no_grad()` around the
18//! attention branch): the backward walks the residual stream through
19//! the FFN chain only, which is what makes a pure-Rust backward small.
20
21use crate::fcd::{FcdModel, LnFfn};
22use crate::fcd_ops as ops;
23use crate::sampler::SplitMix64;
24use cortiq_core::CmfModel;
25use std::sync::Arc;
26
27/// Hyper-parameters — defaults are the certified recipe.
28#[derive(Clone, Debug)]
29pub struct BakeHyper {
30    pub steps_a: usize,
31    pub steps_b: usize,
32    pub l1_init: f64,
33    pub l1_step: f64,
34    pub eval_every: usize,
35    pub lr_a: f64,
36    pub lr_b: f64,
37    pub tau: f32,
38    pub fcd_layers: usize,
39    pub seed: u64,
40    /// Target sparsity (0..1). When >0, the best checkpoint must have
41    /// at least this fraction of neurons pruned; if none qualifies the
42    /// highest-sparsity checkpoint is used.
43    pub target_sparsity: f64,
44    /// L1 aggression multiplier: scales both l1_init and l1_step.
45    /// >1.0 = harder pruning push, <1.0 = softer.
46    pub l1_mult: f64,
47    /// Round each layer's kept-neuron count UP to a multiple of this
48    /// (0/1 = off). 32 keeps the defragged FFN on grouped codecs
49    /// (in % 32 == 0) and SIMD kernels off their scalar tails.
50    pub align: usize,
51    /// Force one FFN width across all layers (the max aligned count) —
52    /// the whole-token GPU graphs require a uniform intermediate size.
53    pub uniform_inter: bool,
54}
55
56impl Default for BakeHyper {
57    fn default() -> Self {
58        Self {
59            steps_a: 240,
60            steps_b: 120,
61            l1_init: 0.01,
62            l1_step: 0.005,
63            eval_every: 30,
64            lr_a: 0.1,
65            lr_b: 1e-5,
66            tau: 0.5,
67            fcd_layers: 4,
68            seed: 0,
69            target_sparsity: 0.0,
70            l1_mult: 1.0,
71            align: 32,
72            uniform_inter: false,
73        }
74    }
75}
76
77/// What the bake measured and produced.
78pub struct BakeReport {
79    /// Held-out PPL of the untouched backbone.
80    pub backbone: f64,
81    /// Held-out PPL with the best hard mask (the denoising bottom).
82    pub masked: f64,
83    /// Held-out PPL after FCD (the final specialist).
84    pub overlaid: f64,
85    pub pruned_ratio: f64,
86    pub kept_per_layer: Vec<usize>,
87    pub sec: f64,
88}
89
90/// The trained artifacts: everything the defrag writer needs, f32.
91pub struct BakeArtifacts {
92    /// Per-PHYSICAL-layer live flags: the union over visits — a weight
93    /// row is removable from disk only when no visit keeps it.
94    pub keep: Vec<Vec<bool>>,
95    /// Per-VIRTUAL-layer live flags (physical × loops, pass-major): the
96    /// mask the file ships and the runtime applies per visit.
97    pub keep_visits: Vec<Vec<bool>>,
98    /// Per-layer down_proj `[hidden, inter]` with dead columns zeroed
99    /// (FCD layers: the trained weights; others: the backbone's).
100    pub down: Vec<Vec<f32>>,
101    /// Trained gate/up for the FCD layers (`None` elsewhere).
102    pub gate_up: Vec<Option<(Vec<f32>, Vec<f32>)>>,
103    /// Which layers went through Phase B.
104    pub fcd_layers: Vec<usize>,
105}
106
107const CLIP: f64 = 1.0;
108const B1: f64 = 0.9;
109const B2: f64 = 0.999;
110const EPS: f64 = 1e-8;
111
112/// Plain Adam over a set of f32 tensors (masks are tiny, FFN mid-size).
113struct Adam {
114    m: Vec<Vec<f64>>,
115    v: Vec<Vec<f64>>,
116    t: i32,
117    lr: f64,
118}
119
120impl Adam {
121    fn new(sizes: &[usize], lr: f64) -> Self {
122        Self {
123            m: sizes.iter().map(|&n| vec![0.0; n]).collect(),
124            v: sizes.iter().map(|&n| vec![0.0; n]).collect(),
125            t: 0,
126            lr,
127        }
128    }
129
130    /// Global-norm clip + Adam step. `params[i].len() == grads[i].len()`.
131    fn step(&mut self, params: &mut [&mut [f32]], grads: &[Vec<f64>], lr_scale: f64) {
132        let gn: f64 = grads
133            .iter()
134            .flat_map(|g| g.iter().map(|x| x * x))
135            .sum::<f64>()
136            .sqrt();
137        let clip = if gn > CLIP { CLIP / gn } else { 1.0 };
138        self.t += 1;
139        let (bc1, bc2) = (1.0 - B1.powi(self.t), 1.0 - B2.powi(self.t));
140        for (pi, p) in params.iter_mut().enumerate() {
141            for j in 0..p.len() {
142                let g = grads[pi][j] * clip;
143                let m = &mut self.m[pi][j];
144                let v = &mut self.v[pi][j];
145                *m = B1 * *m + (1.0 - B1) * g;
146                *v = B2 * *v + (1.0 - B2) * g * g;
147                let upd = (*m / bc1) / ((*v / bc2).sqrt() + EPS);
148                p[j] -= (self.lr * lr_scale * upd) as f32;
149            }
150        }
151    }
152}
153
154/// Mask logit at step zero, solved for the loop depth.
155///
156/// The gate multiplies the FFN once per VISIT, so a Looped Transformer
157/// applies it `loops` times per token and the factor compounds. What
158/// must be held constant across depths is the EFFECTIVE start — the
159/// product the stack actually sees — at the value the recipe was
160/// validated with on ordinary models, σ(2.0) = 0.881:
161///
162/// ```text
163/// σ(m0)^loops = σ(2.0)   →   m0 = logit( σ(2.0)^(1/loops) )
164/// ```
165///
166/// `loops = 1` returns 2.0 exactly, so nothing regresses. Two known
167/// wrong answers this replaces: the old hardcoded 2.0, which at two
168/// visits compounds to 0.776 and took Nanbeige 4.2 from a baseline of
169/// 4.187 to 278.4 at step 30; and a start pushed to identity, which
170/// cannot learn because the update carries σ'(m) = σ(1−σ), worth 5e-4
171/// at σ = 0.9995 against 0.105 at 2.0.
172pub fn mask_init_logit(loops: usize) -> f32 {
173    let base = 1.0f32 / (1.0 + (-2.0f32).exp());
174    let per_visit = base.powf(1.0 / loops.max(1) as f32);
175    (per_visit / (1.0 - per_visit)).ln()
176}
177
178/// Learning-rate scale for the mask step, given the loop depth.
179///
180/// The backward accumulates every visit of a physical layer into the
181/// same mask gradient, so an unscaled step is `loops` times the tuned
182/// one. One step should mean one token's worth of movement at any depth.
183pub fn mask_step_scale(loops: usize) -> f64 {
184    1.0 / loops.max(1) as f64
185}
186
187fn sigmoid(x: f32) -> f32 {
188    1.0 / (1.0 + (-x).exp())
189}
190
191/// One forward + CE(+optionally backward through the FFN chain).
192/// Returns (nll_sum, tokens). `dmask`/`dffn` accumulate when given.
193struct Pass<'a> {
194    fm: &'a FcdModel,
195    tau: f32,
196    /// σ(m) per layer when soft; binarized when `hard`.
197    logits: &'a [Vec<f32>],
198    hard: bool,
199    /// Phase-B replacement FFN weights per layer (trained copies).
200    ffn: &'a [Option<(Vec<f32>, Vec<f32>, Vec<f32>)>],
201}
202
203impl Pass<'_> {
204    fn gates(&self, li: usize) -> Vec<f32> {
205        self.logits[li]
206            .iter()
207            .map(|&l| {
208                let s = sigmoid(l);
209                if self.hard {
210                    if s > self.tau { 1.0 } else { 0.0 }
211                } else {
212                    s
213                }
214            })
215            .collect()
216    }
217
218    fn wts<'b>(&'b self, li: usize, mats: &'b crate::fcd::LayerMats) -> LnFfn<'b> {
219        let l = &self.fm.layers[li];
220        match &self.ffn[li] {
221            Some((g, u, d)) => LnFfn {
222                iln: &l.iln,
223                pln: &l.pln,
224                gate: g,
225                up: u,
226                down: d,
227                // Trained copies move every Adam step — no prebuilt concat.
228                gu: None,
229            },
230            None => LnFfn {
231                iln: &l.iln,
232                pln: &l.pln,
233                gate: &[],
234                up: &[],
235                down: &mats.down,
236                gu: Some(&mats.gu),
237            },
238        }
239    }
240
241    /// Teacher-forced NLL over one chunk; when `grad` is set, backprop
242    /// through the FFN chain into the mask grads (and FFN grads for
243    /// Phase-B layers).
244    #[allow(clippy::too_many_arguments)]
245    fn chunk(
246        &self,
247        ids: &[u32],
248        grad: Option<(
249            &mut [Vec<f64>],
250            &mut [Option<(Vec<f64>, Vec<f64>, Vec<f64>)>],
251        )>,
252    ) -> (f64, usize) {
253        self.chunk_batch(ids, 1, grad)
254    }
255
256    /// `chunk` over `b` equal-length sequences flattened into `ids`.
257    ///
258    /// Everything under this level was batch-aware all along
259    /// (`layer_forward_scaled` and every attention fwd/bwd take `b`);
260    /// only this wrapper hardcoded 1. Evaluation is where it pays: the
261    /// held set is 12 chunks scored one at a time, which on a 4 B model
262    /// meant 12× the GEMM submits for the same arithmetic.
263    fn chunk_batch(
264        &self,
265        ids: &[u32],
266        b: usize,
267        grad: Option<(
268            &mut [Vec<f64>],
269            &mut [Option<(Vec<f64>, Vec<f64>, Vec<f64>)>],
270        )>,
271    ) -> (f64, usize) {
272        let fm = self.fm;
273        let hsz = fm.hidden;
274        debug_assert!(ids.len() % b.max(1) == 0, "ragged batch");
275        // Training differentiates one chunk at a time — the recipe's
276        // gradient accumulation is per-chunk by design.
277        debug_assert!(grad.is_none() || b == 1, "grads are per-chunk");
278        let t = ids.len() / b.max(1);
279        let n = b * t;
280        let nl = fm.layers.len();
281        // Embed.
282        let mut h = vec![0f32; n * hsz];
283        for (r, &id) in ids.iter().enumerate() {
284            h[r * hsz..(r + 1) * hsz]
285                .copy_from_slice(&fm.embed[id as usize * hsz..(id as usize + 1) * hsz]);
286        }
287        // Forward over VIRTUAL layers: a Looped Transformer runs the
288        // stack `fm.loops` times, with a final_norm at each loop
289        // boundary when the file says so. Everything below indexes
290        // activations by the virtual step and weights/grads by the
291        // physical layer `vl % nl` — so a physical layer visited twice
292        // accumulates both visits' gradients, which is what the loop
293        // means mathematically.
294        let loops = fm.loops.max(1);
295        let vn = nl * loops;
296        let mut h_ins = Vec::with_capacity(vn);
297        let mut acts = Vec::with_capacity(vn);
298        let mut masks = Vec::with_capacity(vn);
299        // Loop-boundary norms, saved for the backward: (input, inv).
300        let mut lnorms: Vec<Option<(Vec<f32>, Vec<f32>)>> = vec![None; vn];
301        for vl in 0..vn {
302            let li = vl % nl;
303            // The gate is PER VISIT: the two passes of a loop are
304            // different computations sharing one set of weights, so the
305            // mask must be allowed to differ between them. Weights stay
306            // indexed by the physical layer.
307            let g = self.gates(vl);
308            let mats_hold = fm.mats(li).expect("layer mats");
309            let wts = self.wts(li, &mats_hold);
310            let want = grad.is_some();
311            let (h2, a) = fm.layer_forward_scaled(li, &h, b, t, &wts, false, want, Some(&g));
312            h_ins.push(if want { h } else { Vec::new() });
313            acts.push(a);
314            masks.push(g);
315            h = h2;
316            // Mid-stack norm at every loop boundary except the last —
317            // the final one folds into the head below.
318            if fm.loop_norm && li + 1 == nl && vl + 1 < vn {
319                let mut hn = vec![0f32; n * hsz];
320                let mut inv = vec![0f32; n];
321                ops::rmsnorm_fwd(&h, &fm.final_norm, fm.eps, fm.gemma, &mut hn, &mut inv);
322                if want {
323                    lnorms[vl] = Some((h, inv));
324                }
325                h = hn;
326            }
327        }
328        // Final norm + tied LM head, CE summed over positions 1..t.
329        let mut hn = vec![0f32; n * hsz];
330        let mut inv = vec![0f32; n];
331        ops::rmsnorm_fwd(&h, &fm.final_norm, fm.eps, fm.gemma, &mut hn, &mut inv);
332        let lm: &[f32] = fm.lm_head.as_deref().unwrap_or(&fm.embed);
333        let vocab = lm.len() / hsz;
334        let pool = fm.pool.as_deref();
335        let mut nll = 0f64;
336        let mut dh_n = vec![0f32; n * hsz]; // dL/d hn
337        // Chunk the vocab matmul over positions to bound the logits buf.
338        // Positions are walked PER SEQUENCE: the last position of chunk
339        // i must not be scored against the first token of chunk i+1.
340        const POS_CHUNK: usize = 64;
341        let scored = b * (t - 1);
342        for bi in 0..b {
343        let base = bi * t;
344        let mut p0 = 0usize;
345        while p0 < t - 1 {
346            let pc = POS_CHUNK.min(t - 1 - p0);
347            let mut logits = vec![0f32; pc * vocab];
348            ops::gemm_nt(
349                &hn[(base + p0) * hsz..(base + p0 + pc) * hsz],
350                lm,
351                &mut logits,
352                pc,
353                hsz,
354                vocab,
355                pool,
356            );
357            for r in 0..pc {
358                let target = ids[base + p0 + r + 1] as usize;
359                let row = &mut logits[r * vocab..(r + 1) * vocab];
360                let mx = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as f64;
361                let mut sum = 0f64;
362                for v in row.iter() {
363                    sum += ((*v as f64) - mx).exp();
364                }
365                nll += mx + sum.ln() - row[target] as f64;
366                if grad.is_some() {
367                    // dCE/dlogit = softmax − onehot, scaled by 1/scored.
368                    let inv_n = 1.0 / scored as f64;
369                    for v in row.iter_mut() {
370                        *v = ((((*v as f64) - mx).exp() / sum) * inv_n) as f32;
371                    }
372                    row[target] -= inv_n as f32;
373                }
374            }
375            if grad.is_some() {
376                ops::gemm_dx(
377                    &logits,
378                    lm,
379                    &mut dh_n[(base + p0) * hsz..(base + p0 + pc) * hsz],
380                    pc,
381                    hsz,
382                    vocab,
383                    pool,
384                );
385            }
386            p0 += pc;
387        }
388        }
389        let Some((dmask, dffn)) = grad else {
390            return (nll, scored);
391        };
392        // Backward: final norm, then the FFN chain layer by layer.
393        let t_bwd = std::time::Instant::now();
394        let mut dh = vec![0f32; n * hsz];
395        ops::rmsnorm_bwd(&h, &fm.final_norm, &inv, &dh_n, fm.gemma, &mut dh, None);
396        for vl in (0..vn).rev() {
397            let li = vl % nl;
398            // Undo the loop-boundary norm this step fed into.
399            if let Some((hb, inv)) = lnorms[vl].as_ref() {
400                let mut dprev = vec![0f32; n * hsz];
401                ops::rmsnorm_bwd(hb, &fm.final_norm, inv, &dh, fm.gemma, &mut dprev, None);
402                dh = dprev;
403            }
404            let a = acts[vl].as_ref().expect("acts saved in grad mode");
405            let g = &masks[vl];
406            let inter = fm.layers[li].inter;
407            let mats_hold = fm.mats(li).expect("layer mats");
408            let wts = self.wts(li, &mats_hold);
409            // h2 = h1 + act2 @ downᵀ  →  dact2 = dh @ down.
410            let mut dact2 = vec![0f32; t * inter];
411            ops::gemm_dx(&dh, wts.down, &mut dact2, t, inter, hsz, fm.pool.as_deref());
412            if let Some((_, _, dd)) = dffn[li].as_mut() {
413                // dW_down += dhᵀ · act2 (act2 = act·g).
414                let mut act2 = a.act.clone();
415                for r in 0..t {
416                    for (x, &gv) in act2[r * inter..(r + 1) * inter].iter_mut().zip(g) {
417                        *x *= gv;
418                    }
419                }
420                let mut dw = vec![0f32; hsz * inter];
421                ops::gemm_dw(&dh, &act2, &mut dw, t, inter, hsz, fm.pool.as_deref());
422                for (o, &x) in dd.iter_mut().zip(&dw) {
423                    *o += x as f64;
424                }
425            }
426            // Mask grad: dm = Σ_t dact2·act · σ'(m)  (soft; STE-equal).
427            // Indexed by the VIRTUAL layer: each visit's mask row gets
428            // exactly its own visit's gradient, no cross-visit sum.
429            {
430                let dm = &mut dmask[vl];
431                for r in 0..t {
432                    let da = &dact2[r * inter..(r + 1) * inter];
433                    let aa = &a.act[r * inter..(r + 1) * inter];
434                    for j in 0..inter {
435                        dm[j] += da[j] as f64 * aa[j] as f64;
436                    }
437                }
438                // σ'(m) folded in once per chunk (constant per neuron).
439                for (j, d) in dm.iter_mut().enumerate() {
440                    let _ = j;
441                    let _ = d;
442                }
443            }
444            // dact = dact2 · g;  silu·mul backward.
445            let mut dg_pre = vec![0f32; t * inter];
446            let mut du_pre = vec![0f32; t * inter];
447            for r in 0..t {
448                for j in 0..inter {
449                    let i = r * inter + j;
450                    let da = dact2[i] * g[j];
451                    let sg = ops::silu(a.gpre[i]);
452                    dg_pre[i] = da * a.upre[i] * ops::silu_bwd(a.gpre[i]);
453                    du_pre[i] = da * sg;
454                }
455            }
456            // dn2 = dg_pre @ gate + du_pre @ up — one fused submit when
457            // the frozen concat exists; the trained-copy path keeps two.
458            let mut dn2 = vec![0f32; t * hsz];
459            if let Some(gu) = wts.gu {
460                let mut dgu = vec![0f32; t * 2 * inter];
461                for r in 0..t {
462                    let row = &mut dgu[r * 2 * inter..(r + 1) * 2 * inter];
463                    row[..inter].copy_from_slice(&dg_pre[r * inter..(r + 1) * inter]);
464                    row[inter..].copy_from_slice(&du_pre[r * inter..(r + 1) * inter]);
465                }
466                ops::gemm_dx(&dgu, gu, &mut dn2, t, hsz, 2 * inter, fm.pool.as_deref());
467            } else {
468                ops::gemm_dx(
469                    &dg_pre,
470                    wts.gate,
471                    &mut dn2,
472                    t,
473                    hsz,
474                    inter,
475                    fm.pool.as_deref(),
476                );
477                let mut dn2b = vec![0f32; t * hsz];
478                ops::gemm_dx(
479                    &du_pre,
480                    wts.up,
481                    &mut dn2b,
482                    t,
483                    hsz,
484                    inter,
485                    fm.pool.as_deref(),
486                );
487                for (x, &y) in dn2.iter_mut().zip(&dn2b) {
488                    *x += y;
489                }
490            }
491            if let Some((dgw, duw, _)) = dffn[li].as_mut() {
492                let mut dw = vec![0f32; inter * hsz];
493                ops::gemm_dw(&dg_pre, &a.n2, &mut dw, t, hsz, inter, fm.pool.as_deref());
494                for (o, &x) in dgw.iter_mut().zip(&dw) {
495                    *o += x as f64;
496                }
497                dw.fill(0.0);
498                ops::gemm_dw(&du_pre, &a.n2, &mut dw, t, hsz, inter, fm.pool.as_deref());
499                for (o, &x) in duw.iter_mut().zip(&dw) {
500                    *o += x as f64;
501                }
502            }
503            // Post-norm backward into h1; the attention branch carries
504            // no gradient (frozen), so dh1 flows straight to dh_in.
505            let mut dh1 = dh.clone(); // residual h2 = h1 + ffn
506            ops::rmsnorm_bwd(&a.h1, wts.pln, &a.inv2, &dn2, fm.gemma, &mut dh1, None);
507            dh = dh1;
508            let _ = &h_ins[vl];
509        }
510        crate::fcd::prof::add(&crate::fcd::prof::BWD, t_bwd);
511        (nll, scored)
512    }
513}
514
515/// Held-out PPL with the hard mask (and Phase-B weights when present).
516fn held_ppl(pass: &Pass, held: &[Vec<u32>]) -> f64 {
517    // One batched pass over the whole held set: same arithmetic, one
518    // GEMM per weight instead of one per chunk. On the 4 B looped model
519    // the sequential version spent 12× the submits for identical math.
520    if held.is_empty() {
521        return f64::NAN;
522    }
523    let t = held[0].len();
524    if held.iter().all(|c| c.len() == t) {
525        let flat: Vec<u32> = held.iter().flatten().copied().collect();
526        let (l, k) = pass.chunk_batch(&flat, held.len(), None);
527        return (l / k.max(1) as f64).exp();
528    }
529    let mut nll = 0f64;
530    let mut n = 0usize;
531    for c in held {
532        let (l, k) = pass.chunk(c, None);
533        nll += l;
534        n += k;
535    }
536    (nll / n.max(1) as f64).exp()
537}
538
539/// The whole recipe. `log` receives progress lines.
540pub fn skill_bake(
541    model: &Arc<CmfModel>,
542    chunks: &[Vec<u32>],
543    held_n: usize,
544    hy: &BakeHyper,
545    mut log: impl FnMut(&str),
546) -> Result<(BakeReport, BakeArtifacts), String> {
547    let t0 = std::time::Instant::now();
548    let o1_off = crate::nystrom::O1Cfg {
549        layers: crate::nystrom::O1Layers::List(Vec::new()),
550        m: 4,
551        w: 8,
552        sink: 1,
553        rect: crate::nystrom::O1_DEFAULT_RECT,
554    };
555    let fm = FcdModel::from_cmf(model, &o1_off)?;
556    let nl = fm.layers.len();
557    let inter = fm.layers.iter().map(|l| l.inter).max().unwrap_or(0);
558    if fm.layers.iter().any(|l| l.inter != inter) {
559        return Err("skill bake: non-uniform FFN widths".into());
560    }
561    let held: Vec<Vec<u32>> = chunks[..held_n.min(chunks.len())].to_vec();
562    let calib: Vec<Vec<u32>> = chunks[held_n.min(chunks.len())..].to_vec();
563    if calib.len() < 12 {
564        return Err(format!(
565            "skill bake: corpus too small ({} calib chunks)",
566            calib.len()
567        ));
568    }
569    let fcd: Vec<usize> = (nl.saturating_sub(hy.fcd_layers)..nl).collect();
570    let _rng = SplitMix64::new(hy.seed);
571
572    // Trainables. The gate starts as close to OPEN as the arithmetic
573    // allows, because step zero must be the backbone and nothing else.
574    //
575    // It used to start at 2.0, and σ(2.0) = 0.881 — every FFN neuron
576    // scaled to seven eighths before a single gradient. On an ordinary
577    // stack that costs a few percent of perplexity and hides. On a
578    // LOOPED Transformer it does not: Nanbeige 4.2 runs its 22 layers
579    // twice, so each physical FFN is visited twice and the factor
580    // compounds to 0.881² = 0.776 per layer over 44 visits. Measured:
581    // baseline 4.187 → 278.4 at step 30 with 0% pruned. Nothing had been
582    // pruned; the mask had simply turned the model down.
583    //
584    // So solve for the init instead of hardcoding it — but solve for the
585    // right target. Pushing σ(m0)^loops to 0.999 starts at the backbone
586    // and cannot move: the gradient carries σ'(m) = σ(1−σ), which at
587    // σ = 0.9995 is 5e-4 against 0.105 at the old 2.0, and BOTH the data
588    // term and the L1 term are scaled by it (see the update below). That
589    // was tried: 60 steps, 0% pruned, hard-PPL equal to the baseline to
590    // three digits. Identity that cannot learn is not an improvement.
591    //
592    // The quantity to preserve is the EFFECTIVE start — what the stack
593    // actually multiplies by, once per visit compounded over the loop —
594    // at the value the recipe was validated with on ordinary models:
595    // σ(m0)^loops = σ(2.0) = 0.881. One loop reproduces the old constant
596    // exactly, so nothing regresses; two loops open the per-visit gate to
597    // 0.9385 so the compounded factor is again 0.881, with σ' = 0.058
598    // rather than 0.0005.
599    let loops = fm.loops.max(1);
600    let m0 = mask_init_logit(loops);
601    // One mask row per VIRTUAL layer: nl × loops. Unlooped: vn == nl.
602    let vn = nl * loops;
603    let mut logits: Vec<Vec<f32>> = vec![vec![m0; inter]; vn];
604    let mut ffn: Vec<Option<(Vec<f32>, Vec<f32>, Vec<f32>)>> = vec![None; nl];
605
606    // Baseline (no mask): even σ(m0) is not exactly 1, so measure with
607    // gates forced open via hard mask over +∞… simplest: logits +50.
608    let open: Vec<Vec<f32>> = vec![vec![50.0; inter]; vn];
609    let base_pass = Pass {
610        fm: &fm,
611        tau: hy.tau,
612        logits: &open,
613        hard: true,
614        ffn: &ffn,
615    };
616    let backbone = held_ppl(&base_pass, &held);
617    log(&format!("baseline (full): {backbone:.3}"));
618
619    // ── Phase A: mask training ──
620    let mut adam_a = Adam::new(&vec![inter; vn], hy.lr_a);
621    let mut l1 = hy.l1_init * hy.l1_mult;
622    let l1_step_eff = hy.l1_step * hy.l1_mult;
623    // best = (ppl, logits_snapshot, sparsity)
624    let mut best: (f64, Option<Vec<Vec<f32>>>, f64) = (f64::MAX, None, 0.0);
625    // Track the highest-sparsity checkpoint as fallback.
626    let mut max_sp: (f64, Option<Vec<Vec<f32>>>, f64) = (f64::MAX, None, 0.0);
627    let mut prev_alive: Option<Vec<Vec<bool>>> = None;
628    // In-process phase timers: this loop was estimated three different
629    // ways and every estimate came out under a fifth of the measured
630    // step time. Measure, then optimize the top line, not the guess.
631    let mut acc_chunk = 0f64;
632    let mut acc_adam = 0f64;
633    // Phase A trains in strict f32: the mask SELECTS neurons by its
634    // gradient, and f16 operand rounding on that signal — fine for every
635    // forward and eval in this file — compounds over ~90 steps into
636    // closing the wrong neurons (measured: hard-PPL 5.207 vs 4.293 at the
637    // same 2.56% sparsity; the f32 run retraces the reference trajectory
638    // to the third decimal). Evals inside the loop lift the restriction —
639    // their tensor-core numbers match f32 at print precision.
640    crate::gpu::bake_precision_strict(true);
641    for step in 0..hy.steps_a {
642        let t_step = std::time::Instant::now();
643        let chunk = &calib[step % calib.len()];
644        let mut dmask: Vec<Vec<f64>> = vec![vec![0.0; inter]; vn];
645        let mut dffn: Vec<Option<(Vec<f64>, Vec<f64>, Vec<f64>)>> = vec![None; nl];
646        let pass = Pass {
647            fm: &fm,
648            tau: hy.tau,
649            logits: &logits,
650            hard: false,
651            ffn: &ffn,
652        };
653        let _ = pass.chunk(chunk, Some((&mut dmask, &mut dffn)));
654        // Fold σ'(m) into the mask grads + add the L1 term.
655        let l1_per = l1 / (inter as f64 * nl as f64);
656        for li in 0..vn {
657            for j in 0..inter {
658                let s = sigmoid(logits[li][j]) as f64;
659                dmask[li][j] = dmask[li][j] * s * (1.0 - s) + l1_per * s * (1.0 - s);
660            }
661        }
662        // One gradient per VISIT, one step per token. A Looped
663        // Transformer visits each physical layer `loops` times and the
664        // backward accumulates every visit into the same mask, so an
665        // unnormalised step is `loops` times the one the recipe was
666        // tuned with — the mask overshoots, neurons cross tau within the
667        // first evaluation window, and each of them is then missing from
668        // both passes. Dividing by the visit count makes a step mean the
669        // same thing at any loop depth.
670        // Per-visit rows: each mask logit receives exactly one visit's
671        // gradient, so the step needs no visit normalisation here — that
672        // scale now belongs to Phase B alone, where the FFN weights ARE
673        // shared across visits.
674        let t_chunk = t_step.elapsed().as_secs_f64();
675        let mut params: Vec<&mut [f32]> = logits.iter_mut().map(|v| v.as_mut_slice()).collect();
676        adam_a.step(&mut params, &dmask, 1.0);
677        acc_chunk += t_chunk;
678        acc_adam += t_step.elapsed().as_secs_f64() - t_chunk;
679        if (step + 1) % hy.eval_every == 0 {
680            l1 += l1_step_eff;
681            let pass = Pass {
682                fm: &fm,
683                tau: hy.tau,
684                logits: &logits,
685                hard: true,
686                ffn: &ffn,
687            };
688            crate::gpu::bake_precision_strict(false);
689            let hp = held_ppl(&pass, &held);
690            crate::gpu::bake_precision_strict(true);
691            // Name the neurons that crossed τ since the last eval. At
692            // 0.01% pruned = ~24 neurons for a 135 held-PPL, WHICH 24 is
693            // the whole diagnosis: it decides between "this model has no
694            // noise neurons" and "a shared mask cannot spare a neuron
695            // that only one visit of the loop needs".
696            let cur: Vec<Vec<bool>> = logits
697                .iter()
698                .map(|l| l.iter().map(|&x| sigmoid(x) > hy.tau).collect())
699                .collect();
700            if let Some(prev) = &prev_alive {
701                let died: Vec<String> = cur
702                    .iter()
703                    .zip(prev)
704                    .enumerate()
705                    .flat_map(|(li, (c, p))| {
706                        c.iter()
707                            .zip(p.iter())
708                            .enumerate()
709                            .filter(|&(_, (&cj, &pj))| pj && !cj)
710                            .map(move |(j, _)| format!("L{li}:{j}"))
711                    })
712                    .collect();
713                if !died.is_empty() {
714                    log(&format!(
715                        "    closed since last eval: {}: {}{}",
716                        died.len(),
717                        died.iter().take(32).cloned().collect::<Vec<_>>().join(" "),
718                        if died.len() > 32 { " …" } else { "" }
719                    ));
720                }
721            }
722            let alive: usize = cur.iter().map(|l| l.iter().filter(|&&b| b).count()).sum();
723            prev_alive = Some(cur);
724            let sp = 1.0 - alive as f64 / (vn * inter) as f64;
725            // Track highest-sparsity checkpoint.
726            if sp > max_sp.2 {
727                max_sp = (hp, Some(logits.clone()), sp);
728            }
729            // Best checkpoint selection: respect target_sparsity.
730            if hy.target_sparsity > 0.0 {
731                if sp >= hy.target_sparsity && hp < best.0 {
732                    best = (hp, Some(logits.clone()), sp);
733                }
734            } else if hp < best.0 {
735                best = (hp, Some(logits.clone()), sp);
736            }
737            log(&format!(
738                "  [A] step {}: L1={l1:.3} pruned={:.2}% hard-PPL={hp:.3} (bottom {}@{:.2}%) [fwd+bwd {:.1}s, adam {:.2}s per step]",
739                step + 1,
740                sp * 100.0,
741                if best.0 == f64::MAX {
742                    "—".to_string()
743                } else {
744                    format!("{:.3}", best.0)
745                },
746                best.2 * 100.0,
747                acc_chunk / (step + 1) as f64,
748                acc_adam / (step + 1) as f64
749            ));
750        }
751    }
752    // If target_sparsity was set but no checkpoint qualified, fall back
753    // to the highest-sparsity checkpoint.
754    // Phase A is over — phase B and every eval after run on the fast arms.
755    crate::gpu::bake_precision_strict(false);
756    if hy.target_sparsity > 0.0 && best.1.is_none() {
757        log(&format!(
758            "[A] target sparsity {:.0}% not reached; using max-sparsity checkpoint ({:.0}%)",
759            hy.target_sparsity * 100.0,
760            max_sp.2 * 100.0
761        ));
762        best = max_sp;
763    }
764    // Phase totals, printed unconditionally — a 5-step measurement run
765    // must report even though no eval fired.
766    {
767        use crate::fcd::prof;
768        let (a, f, bw, g, gc) = (
769            prof::take(&prof::ATTN_FWD),
770            prof::take(&prof::FFN_FWD),
771            prof::take(&prof::BWD),
772            prof::take(&prof::GEMM),
773            prof::GEMM_CALLS.swap(0, std::sync::atomic::Ordering::Relaxed),
774        );
775        log(&format!(
776            "[prof] phase A over {} step(s): attn-fwd {a:.1}s | ffn-fwd {f:.1}s | bwd {bw:.1}s |              gemm total {g:.1}s in {gc} calls ({:.1} ms/call)",
777            hy.steps_a,
778            if gc > 0 { g * 1000.0 / gc as f64 } else { 0.0 }
779        ));
780        log(&format!("[prof] gemm shapes:\n{}", prof::shape_report(6)));
781    }
782    if let Some(b) = best.1.take() {
783        logits = b;
784    }
785    let pass = Pass {
786        fm: &fm,
787        tau: hy.tau,
788        logits: &logits,
789        hard: true,
790        ffn: &ffn,
791    };
792    let masked = held_ppl(&pass, &held);
793    log(&format!(
794        "[A] {:.0}s: masked-PPL {masked:.3}",
795        t0.elapsed().as_secs_f64()
796    ));
797
798    // ── Phase B: FCD of the last N layers' FFN (hard mask active) ──
799    for &li in &fcd {
800        let p = format!("model.layers.{li}.");
801        ffn[li] = Some((
802            crate::fcd::deq_pub(&fm.src, &format!("{p}mlp.gate_proj.weight"))
803                .map_err(|e| format!("phase-B gate: {e}"))?,
804            crate::fcd::deq_pub(&fm.src, &format!("{p}mlp.up_proj.weight"))
805                .map_err(|e| format!("phase-B up: {e}"))?,
806            crate::fcd::deq_pub(&fm.src, &format!("{p}mlp.down_proj.weight"))
807                .map_err(|e| format!("phase-B down: {e}"))?,
808        ));
809    }
810    let sizes: Vec<usize> = fcd
811        .iter()
812        .flat_map(|&li| {
813            let (g, u, d) = ffn[li].as_ref().expect("phase-B masters");
814            [g.len(), u.len(), d.len()]
815        })
816        .collect();
817    let mut adam_b = Adam::new(&sizes, hy.lr_b);
818    let mut best_b: (f64, Option<Vec<Option<(Vec<f32>, Vec<f32>, Vec<f32>)>>>) = (masked, None);
819    for step in 0..hy.steps_b {
820        let chunk = &calib[step % calib.len()];
821        let mut dmask: Vec<Vec<f64>> = vec![vec![0.0; inter]; vn];
822        let mut dffn: Vec<Option<(Vec<f64>, Vec<f64>, Vec<f64>)>> = (0..nl)
823            .map(|li| {
824                ffn[li]
825                    .as_ref()
826                    .map(|(g, u, d)| (vec![0.0; g.len()], vec![0.0; u.len()], vec![0.0; d.len()]))
827            })
828            .collect();
829        let pass = Pass {
830            fm: &fm,
831            tau: hy.tau,
832            logits: &logits,
833            hard: true,
834            ffn: &ffn,
835        };
836        let _ = pass.chunk(chunk, Some((&mut dmask, &mut dffn)));
837        // Cosine LR.
838        let lr_scale = 0.5 * (1.0 + (std::f64::consts::PI * step as f64 / hy.steps_b as f64).cos());
839        let first_fcd = fcd[0];
840        let mut params: Vec<&mut [f32]> = Vec::new();
841        let mut grads: Vec<Vec<f64>> = Vec::new();
842        for (off, slot) in ffn[first_fcd..].iter_mut().enumerate() {
843            let li = first_fcd + off;
844            let Some((g, u, d)) = slot.as_mut() else {
845                continue;
846            };
847            let (dg, du, dd) = dffn[li].take().unwrap();
848            params.push(g.as_mut_slice());
849            grads.push(dg);
850            params.push(u.as_mut_slice());
851            grads.push(du);
852            params.push(d.as_mut_slice());
853            grads.push(dd);
854        }
855        // Same visit normalisation as Phase A: dffn accumulates every
856        // visit of a physical layer, and an FFN update perturbs BOTH
857        // passes of the loop, so per-step damage is `loops` times what
858        // lr_b was tuned for on ordinary stacks.
859        adam_b.step(&mut params, &grads, lr_scale * mask_step_scale(loops));
860        if (step + 1) % hy.eval_every == 0 {
861            let pass = Pass {
862                fm: &fm,
863                tau: hy.tau,
864                logits: &logits,
865                hard: true,
866                ffn: &ffn,
867            };
868            let cur = held_ppl(&pass, &held);
869            if cur < best_b.0 {
870                best_b = (cur, Some(ffn.clone()));
871            }
872            log(&format!(
873                "  [B] step {}: held-PPL {cur:.3} (best {:.3})",
874                step + 1,
875                best_b.0
876            ));
877        }
878    }
879    if let Some(b) = best_b.1.take() {
880        ffn = b;
881    }
882    let overlaid = best_b.0;
883
884    // ── Export artifacts ──
885    // Per-visit keep flags are the mask that ships; the PHYSICAL keep is
886    // their union, because a weight row can only be removed from disk if
887    // no visit needs it.
888    let keep_visits = keep_masks(&logits, hy.tau, hy.align, hy.uniform_inter);
889    let keep: Vec<Vec<bool>> = (0..nl)
890        .map(|li| {
891            (0..inter)
892                .map(|j| (0..loops).any(|v| keep_visits[v * nl + li][j]))
893                .collect()
894        })
895        .collect();
896    if hy.align > 1 || hy.uniform_inter {
897        let raw: usize = logits
898            .iter()
899            .map(|l| l.iter().filter(|&&x| sigmoid(x) > hy.tau).count())
900            .sum();
901        // Compare like with like: raw σ-counts are over the VIRTUAL
902        // rows, so the padded count must be too — the union rows are
903        // fewer and the subtraction would underflow.
904        let padded: usize = keep_visits
905            .iter()
906            .map(|a| a.iter().filter(|&&x| x).count())
907            .sum::<usize>()
908            .saturating_sub(raw);
909        log(&format!(
910            "align: +{padded} neurons resurrected (align {}, uniform {})",
911            hy.align, hy.uniform_inter
912        ));
913    }
914    let mut down_out = Vec::with_capacity(nl);
915    let mut gate_up = Vec::with_capacity(nl);
916    let mut kept_per_layer = Vec::with_capacity(nl);
917    for li in 0..nl {
918        let alive = &keep[li];
919        kept_per_layer.push(alive.iter().filter(|&&a| a).count());
920        let mut down = match &ffn[li] {
921            Some((_, _, d)) => d.clone(),
922            None => fm.mats(li).expect("layer mats").down.clone(),
923        };
924        let hsz = fm.hidden;
925        for r in 0..hsz {
926            for (c, &a) in alive.iter().enumerate() {
927                if !a {
928                    down[r * inter + c] = 0.0;
929                }
930            }
931        }
932        gate_up.push(ffn[li].as_ref().map(|(g, u, _)| (g.clone(), u.clone())));
933        down_out.push(down);
934    }
935    let total: usize = keep_visits
936        .iter()
937        .map(|a| a.iter().filter(|&&x| x).count())
938        .sum();
939    let report = BakeReport {
940        backbone,
941        masked,
942        overlaid,
943        pruned_ratio: 1.0 - total as f64 / (vn * inter) as f64,
944        kept_per_layer,
945        sec: t0.elapsed().as_secs_f64(),
946    };
947    let arts = BakeArtifacts {
948        keep,
949        keep_visits,
950        down: down_out,
951        gate_up,
952        fcd_layers: fcd,
953    };
954    Ok((report, arts))
955}
956
957/// Hard-threshold keep masks from the trained logits, then resurrect
958/// the highest-logit pruned neurons until each layer's kept count is a
959/// multiple of `align` (rounding UP — the resurrected neurons are the
960/// ones the mask ranked closest to the threshold, so this only moves
961/// toward the full backbone). `uniform` additionally raises every layer
962/// to the max layer's aligned count. A layer with 0 live neurons gets
963/// `align.max(1)` — the defrag writer rejects empty layers.
964fn keep_masks(logits: &[Vec<f32>], tau: f32, align: usize, uniform: bool) -> Vec<Vec<bool>> {
965    let inter = logits[0].len();
966    let round = |n: usize| -> usize {
967        let n = n.max(1);
968        if align <= 1 {
969            n.min(inter)
970        } else {
971            (n.div_ceil(align) * align).min(inter)
972        }
973    };
974    let mut want: Vec<usize> = logits
975        .iter()
976        .map(|l| round(l.iter().filter(|&&x| sigmoid(x) > tau).count()))
977        .collect();
978    if uniform {
979        let k = want.iter().copied().max().unwrap_or(inter);
980        want = vec![k; logits.len()];
981    }
982    logits
983        .iter()
984        .zip(&want)
985        .map(|(l, &k)| {
986            let mut idx: Vec<usize> = (0..inter).collect();
987            idx.sort_unstable_by(|&a, &b| l[b].total_cmp(&l[a]));
988            let mut alive = vec![false; inter];
989            for &i in idx.iter().take(k) {
990                alive[i] = true;
991            }
992            alive
993        })
994        .collect()
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    fn kept(masks: &[Vec<bool>]) -> Vec<usize> {
1002        masks
1003            .iter()
1004            .map(|m| m.iter().filter(|&&a| a).count())
1005            .collect()
1006    }
1007
1008    /// align=32 rounds each layer UP by resurrecting the largest
1009    /// pruned logits; the originally-alive set stays alive.
1010    #[test]
1011    fn keep_masks_aligns_up_and_preserves_alive() {
1012        let inter = 96;
1013        // Layer 0: 40 alive (logits > 0 → σ > 0.5), the rest ramp
1014        // below threshold so resurrection order is deterministic.
1015        let l0: Vec<f32> = (0..inter)
1016            .map(|i| if i < 40 { 1.0 } else { -1.0 - i as f32 * 0.01 })
1017            .collect();
1018        // Layer 1: 64 alive — already aligned, must stay exactly 64.
1019        let l1: Vec<f32> = (0..inter)
1020            .map(|i| if i < 64 { 2.0 } else { -3.0 })
1021            .collect();
1022        let masks = keep_masks(&[l0.clone(), l1], 0.5, 32, false);
1023        assert_eq!(kept(&masks), vec![64, 64]);
1024        // The 40 originally-alive stay; resurrected are the top pruned
1025        // logits (indices 40..64 — the least-negative of the ramp).
1026        for i in 0..64 {
1027            assert!(masks[0][i], "neuron {i} should be kept");
1028        }
1029        for i in 64..inter {
1030            assert!(!masks[0][i], "neuron {i} should stay pruned");
1031        }
1032    }
1033
1034    /// uniform=true raises every layer to the max aligned count.
1035    #[test]
1036    fn keep_masks_uniform_takes_max() {
1037        let inter = 96;
1038        let l0: Vec<f32> = (0..inter)
1039            .map(|i| if i < 10 { 1.0 } else { -2.0 })
1040            .collect();
1041        let l1: Vec<f32> = (0..inter)
1042            .map(|i| if i < 70 { 1.0 } else { -2.0 })
1043            .collect();
1044        let masks = keep_masks(&[l0, l1], 0.5, 32, true);
1045        assert_eq!(kept(&masks), vec![96, 96]);
1046    }
1047
1048    /// align capped at inter; align=1 (off) keeps the raw threshold
1049    /// count; an all-pruned layer still keeps at least one neuron.
1050    #[test]
1051    fn keep_masks_edges() {
1052        let inter = 48;
1053        let l: Vec<f32> = (0..inter)
1054            .map(|i| if i < 47 { 1.0 } else { -2.0 })
1055            .collect();
1056        let masks = keep_masks(&[l.clone()], 0.5, 32, false);
1057        assert_eq!(kept(&masks), vec![48]); // 47 → 64 capped to 48
1058        let masks = keep_masks(&[l], 0.5, 1, false);
1059        assert_eq!(kept(&masks), vec![47]);
1060        let dead: Vec<f32> = vec![-5.0; inter];
1061        let masks = keep_masks(&[dead], 0.5, 32, false);
1062        assert_eq!(kept(&masks), vec![32]); // max(1) → rounded to 32
1063    }
1064}