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-layer live-neuron flags (true = keep).
93    pub keep: Vec<Vec<bool>>,
94    /// Per-layer down_proj `[hidden, inter]` with dead columns zeroed
95    /// (FCD layers: the trained weights; others: the backbone's).
96    pub down: Vec<Vec<f32>>,
97    /// Trained gate/up for the FCD layers (`None` elsewhere).
98    pub gate_up: Vec<Option<(Vec<f32>, Vec<f32>)>>,
99    /// Which layers went through Phase B.
100    pub fcd_layers: Vec<usize>,
101}
102
103const CLIP: f64 = 1.0;
104const B1: f64 = 0.9;
105const B2: f64 = 0.999;
106const EPS: f64 = 1e-8;
107
108/// Plain Adam over a set of f32 tensors (masks are tiny, FFN mid-size).
109struct Adam {
110    m: Vec<Vec<f64>>,
111    v: Vec<Vec<f64>>,
112    t: i32,
113    lr: f64,
114}
115
116impl Adam {
117    fn new(sizes: &[usize], lr: f64) -> Self {
118        Self {
119            m: sizes.iter().map(|&n| vec![0.0; n]).collect(),
120            v: sizes.iter().map(|&n| vec![0.0; n]).collect(),
121            t: 0,
122            lr,
123        }
124    }
125
126    /// Global-norm clip + Adam step. `params[i].len() == grads[i].len()`.
127    fn step(&mut self, params: &mut [&mut [f32]], grads: &[Vec<f64>], lr_scale: f64) {
128        let gn: f64 = grads
129            .iter()
130            .flat_map(|g| g.iter().map(|x| x * x))
131            .sum::<f64>()
132            .sqrt();
133        let clip = if gn > CLIP { CLIP / gn } else { 1.0 };
134        self.t += 1;
135        let (bc1, bc2) = (1.0 - B1.powi(self.t), 1.0 - B2.powi(self.t));
136        for (pi, p) in params.iter_mut().enumerate() {
137            for j in 0..p.len() {
138                let g = grads[pi][j] * clip;
139                let m = &mut self.m[pi][j];
140                let v = &mut self.v[pi][j];
141                *m = B1 * *m + (1.0 - B1) * g;
142                *v = B2 * *v + (1.0 - B2) * g * g;
143                let upd = (*m / bc1) / ((*v / bc2).sqrt() + EPS);
144                p[j] -= (self.lr * lr_scale * upd) as f32;
145            }
146        }
147    }
148}
149
150fn sigmoid(x: f32) -> f32 {
151    1.0 / (1.0 + (-x).exp())
152}
153
154/// One forward + CE(+optionally backward through the FFN chain).
155/// Returns (nll_sum, tokens). `dmask`/`dffn` accumulate when given.
156struct Pass<'a> {
157    fm: &'a FcdModel,
158    tau: f32,
159    /// σ(m) per layer when soft; binarized when `hard`.
160    logits: &'a [Vec<f32>],
161    hard: bool,
162    /// Phase-B replacement FFN weights per layer (trained copies).
163    ffn: &'a [Option<(Vec<f32>, Vec<f32>, Vec<f32>)>],
164}
165
166impl Pass<'_> {
167    fn gates(&self, li: usize) -> Vec<f32> {
168        self.logits[li]
169            .iter()
170            .map(|&l| {
171                let s = sigmoid(l);
172                if self.hard {
173                    if s > self.tau { 1.0 } else { 0.0 }
174                } else {
175                    s
176                }
177            })
178            .collect()
179    }
180
181    fn wts<'b>(&'b self, li: usize) -> LnFfn<'b> {
182        let l = &self.fm.layers[li];
183        match &self.ffn[li] {
184            Some((g, u, d)) => LnFfn {
185                iln: &l.iln,
186                pln: &l.pln,
187                gate: g,
188                up: u,
189                down: d,
190            },
191            None => LnFfn {
192                iln: &l.iln,
193                pln: &l.pln,
194                gate: &l.gate,
195                up: &l.up,
196                down: &l.down,
197            },
198        }
199    }
200
201    /// Teacher-forced NLL over one chunk; when `grad` is set, backprop
202    /// through the FFN chain into the mask grads (and FFN grads for
203    /// Phase-B layers).
204    #[allow(clippy::too_many_arguments)]
205    fn chunk(
206        &self,
207        ids: &[u32],
208        grad: Option<(
209            &mut [Vec<f64>],
210            &mut [Option<(Vec<f64>, Vec<f64>, Vec<f64>)>],
211        )>,
212    ) -> (f64, usize) {
213        let fm = self.fm;
214        let (t, hsz) = (ids.len(), fm.hidden);
215        let nl = fm.layers.len();
216        // Embed.
217        let mut h = vec![0f32; t * hsz];
218        for (r, &id) in ids.iter().enumerate() {
219            h[r * hsz..(r + 1) * hsz]
220                .copy_from_slice(&fm.embed[id as usize * hsz..(id as usize + 1) * hsz]);
221        }
222        // Forward over VIRTUAL layers: a Looped Transformer runs the
223        // stack `fm.loops` times, with a final_norm at each loop
224        // boundary when the file says so. Everything below indexes
225        // activations by the virtual step and weights/grads by the
226        // physical layer `vl % nl` — so a physical layer visited twice
227        // accumulates both visits' gradients, which is what the loop
228        // means mathematically.
229        let loops = fm.loops.max(1);
230        let vn = nl * loops;
231        let mut h_ins = Vec::with_capacity(vn);
232        let mut acts = Vec::with_capacity(vn);
233        let mut masks = Vec::with_capacity(vn);
234        // Loop-boundary norms, saved for the backward: (input, inv).
235        let mut lnorms: Vec<Option<(Vec<f32>, Vec<f32>)>> = vec![None; vn];
236        for vl in 0..vn {
237            let li = vl % nl;
238            let g = self.gates(li);
239            let wts = self.wts(li);
240            let want = grad.is_some();
241            let (h2, a) = fm.layer_forward_scaled(li, &h, 1, t, &wts, false, want, Some(&g));
242            h_ins.push(if want { h } else { Vec::new() });
243            acts.push(a);
244            masks.push(g);
245            h = h2;
246            // Mid-stack norm at every loop boundary except the last —
247            // the final one folds into the head below.
248            if fm.loop_norm && li + 1 == nl && vl + 1 < vn {
249                let mut hn = vec![0f32; t * hsz];
250                let mut inv = vec![0f32; t];
251                ops::rmsnorm_fwd(&h, &fm.final_norm, fm.eps, fm.gemma, &mut hn, &mut inv);
252                if want {
253                    lnorms[vl] = Some((h, inv));
254                }
255                h = hn;
256            }
257        }
258        // Final norm + tied LM head, CE summed over positions 1..t.
259        let mut hn = vec![0f32; t * hsz];
260        let mut inv = vec![0f32; t];
261        ops::rmsnorm_fwd(&h, &fm.final_norm, fm.eps, fm.gemma, &mut hn, &mut inv);
262        let lm: &[f32] = fm.lm_head.as_deref().unwrap_or(&fm.embed);
263        let vocab = lm.len() / hsz;
264        let pool = fm.pool.as_deref();
265        let mut nll = 0f64;
266        let mut dh_n = vec![0f32; t * hsz]; // dL/d hn
267        // Chunk the vocab matmul over positions to bound the logits buf.
268        const POS_CHUNK: usize = 32;
269        let scored = t - 1;
270        let mut p0 = 0usize;
271        while p0 < scored {
272            let pc = POS_CHUNK.min(scored - p0);
273            let mut logits = vec![0f32; pc * vocab];
274            ops::gemm_nt(
275                &hn[p0 * hsz..(p0 + pc) * hsz],
276                lm,
277                &mut logits,
278                pc,
279                hsz,
280                vocab,
281                pool,
282            );
283            for r in 0..pc {
284                let target = ids[p0 + r + 1] as usize;
285                let row = &mut logits[r * vocab..(r + 1) * vocab];
286                let mx = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as f64;
287                let mut sum = 0f64;
288                for v in row.iter() {
289                    sum += ((*v as f64) - mx).exp();
290                }
291                nll += mx + sum.ln() - row[target] as f64;
292                if grad.is_some() {
293                    // dCE/dlogit = softmax − onehot, scaled by 1/scored.
294                    let inv_n = 1.0 / scored as f64;
295                    for v in row.iter_mut() {
296                        *v = ((((*v as f64) - mx).exp() / sum) * inv_n) as f32;
297                    }
298                    row[target] -= inv_n as f32;
299                }
300            }
301            if grad.is_some() {
302                ops::gemm_dx(
303                    &logits,
304                    lm,
305                    &mut dh_n[p0 * hsz..(p0 + pc) * hsz],
306                    pc,
307                    hsz,
308                    vocab,
309                    pool,
310                );
311            }
312            p0 += pc;
313        }
314        let Some((dmask, dffn)) = grad else {
315            return (nll, scored);
316        };
317        // Backward: final norm, then the FFN chain layer by layer.
318        let mut dh = vec![0f32; t * hsz];
319        ops::rmsnorm_bwd(&h, &fm.final_norm, &inv, &dh_n, fm.gemma, &mut dh, None);
320        for vl in (0..vn).rev() {
321            let li = vl % nl;
322            // Undo the loop-boundary norm this step fed into.
323            if let Some((hb, inv)) = lnorms[vl].as_ref() {
324                let mut dprev = vec![0f32; t * hsz];
325                ops::rmsnorm_bwd(hb, &fm.final_norm, inv, &dh, fm.gemma, &mut dprev, None);
326                dh = dprev;
327            }
328            let a = acts[vl].as_ref().expect("acts saved in grad mode");
329            let g = &masks[vl];
330            let inter = fm.layers[li].inter;
331            let wts = self.wts(li);
332            // h2 = h1 + act2 @ downᵀ  →  dact2 = dh @ down.
333            let mut dact2 = vec![0f32; t * inter];
334            ops::gemm_dx(&dh, wts.down, &mut dact2, t, inter, hsz, fm.pool.as_deref());
335            if let Some((_, _, dd)) = dffn[li].as_mut() {
336                // dW_down += dhᵀ · act2 (act2 = act·g).
337                let mut act2 = a.act.clone();
338                for r in 0..t {
339                    for (x, &gv) in act2[r * inter..(r + 1) * inter].iter_mut().zip(g) {
340                        *x *= gv;
341                    }
342                }
343                let mut dw = vec![0f32; hsz * inter];
344                ops::gemm_dw(&dh, &act2, &mut dw, t, inter, hsz, fm.pool.as_deref());
345                for (o, &x) in dd.iter_mut().zip(&dw) {
346                    *o += x as f64;
347                }
348            }
349            // Mask grad: dm = Σ_t dact2·act · σ'(m)  (soft; STE-equal).
350            {
351                let dm = &mut dmask[li];
352                for r in 0..t {
353                    let da = &dact2[r * inter..(r + 1) * inter];
354                    let aa = &a.act[r * inter..(r + 1) * inter];
355                    for j in 0..inter {
356                        dm[j] += da[j] as f64 * aa[j] as f64;
357                    }
358                }
359                // σ'(m) folded in once per chunk (constant per neuron).
360                for (j, d) in dm.iter_mut().enumerate() {
361                    let _ = j;
362                    let _ = d;
363                }
364            }
365            // dact = dact2 · g;  silu·mul backward.
366            let mut dg_pre = vec![0f32; t * inter];
367            let mut du_pre = vec![0f32; t * inter];
368            for r in 0..t {
369                for j in 0..inter {
370                    let i = r * inter + j;
371                    let da = dact2[i] * g[j];
372                    let sg = ops::silu(a.gpre[i]);
373                    dg_pre[i] = da * a.upre[i] * ops::silu_bwd(a.gpre[i]);
374                    du_pre[i] = da * sg;
375                }
376            }
377            // dn2 = dg_pre @ gate + du_pre @ up.
378            let mut dn2 = vec![0f32; t * hsz];
379            ops::gemm_dx(
380                &dg_pre,
381                wts.gate,
382                &mut dn2,
383                t,
384                hsz,
385                inter,
386                fm.pool.as_deref(),
387            );
388            let mut dn2b = vec![0f32; t * hsz];
389            ops::gemm_dx(
390                &du_pre,
391                wts.up,
392                &mut dn2b,
393                t,
394                hsz,
395                inter,
396                fm.pool.as_deref(),
397            );
398            for (x, &y) in dn2.iter_mut().zip(&dn2b) {
399                *x += y;
400            }
401            if let Some((dgw, duw, _)) = dffn[li].as_mut() {
402                let mut dw = vec![0f32; inter * hsz];
403                ops::gemm_dw(&dg_pre, &a.n2, &mut dw, t, hsz, inter, fm.pool.as_deref());
404                for (o, &x) in dgw.iter_mut().zip(&dw) {
405                    *o += x as f64;
406                }
407                dw.fill(0.0);
408                ops::gemm_dw(&du_pre, &a.n2, &mut dw, t, hsz, inter, fm.pool.as_deref());
409                for (o, &x) in duw.iter_mut().zip(&dw) {
410                    *o += x as f64;
411                }
412            }
413            // Post-norm backward into h1; the attention branch carries
414            // no gradient (frozen), so dh1 flows straight to dh_in.
415            let mut dh1 = dh.clone(); // residual h2 = h1 + ffn
416            ops::rmsnorm_bwd(&a.h1, wts.pln, &a.inv2, &dn2, fm.gemma, &mut dh1, None);
417            dh = dh1;
418            let _ = &h_ins[vl];
419        }
420        (nll, scored)
421    }
422}
423
424/// Held-out PPL with the hard mask (and Phase-B weights when present).
425fn held_ppl(pass: &Pass, held: &[Vec<u32>]) -> f64 {
426    let mut nll = 0f64;
427    let mut n = 0usize;
428    for c in held {
429        let (l, k) = pass.chunk(c, None);
430        nll += l;
431        n += k;
432    }
433    (nll / n.max(1) as f64).exp()
434}
435
436/// The whole recipe. `log` receives progress lines.
437pub fn skill_bake(
438    model: &Arc<CmfModel>,
439    chunks: &[Vec<u32>],
440    held_n: usize,
441    hy: &BakeHyper,
442    mut log: impl FnMut(&str),
443) -> Result<(BakeReport, BakeArtifacts), String> {
444    let t0 = std::time::Instant::now();
445    let o1_off = crate::nystrom::O1Cfg {
446        layers: crate::nystrom::O1Layers::List(Vec::new()),
447        m: 4,
448        w: 8,
449        sink: 1,
450        rect: crate::nystrom::O1_DEFAULT_RECT,
451    };
452    let fm = FcdModel::from_cmf(model, &o1_off)?;
453    let nl = fm.layers.len();
454    let inter = fm.layers.iter().map(|l| l.inter).max().unwrap_or(0);
455    if fm.layers.iter().any(|l| l.inter != inter) {
456        return Err("skill bake: non-uniform FFN widths".into());
457    }
458    let held: Vec<Vec<u32>> = chunks[..held_n.min(chunks.len())].to_vec();
459    let calib: Vec<Vec<u32>> = chunks[held_n.min(chunks.len())..].to_vec();
460    if calib.len() < 12 {
461        return Err(format!(
462            "skill bake: corpus too small ({} calib chunks)",
463            calib.len()
464        ));
465    }
466    let fcd: Vec<usize> = (nl.saturating_sub(hy.fcd_layers)..nl).collect();
467    let _rng = SplitMix64::new(hy.seed);
468
469    // Trainables.
470    let mut logits: Vec<Vec<f32>> = vec![vec![2.0; inter]; nl];
471    let mut ffn: Vec<Option<(Vec<f32>, Vec<f32>, Vec<f32>)>> = vec![None; nl];
472
473    // Baseline (no mask): σ(2.0)≈0.88 is NOT identity, so measure with
474    // gates forced open via hard mask over +∞… simplest: logits +50.
475    let open: Vec<Vec<f32>> = vec![vec![50.0; inter]; nl];
476    let base_pass = Pass {
477        fm: &fm,
478        tau: hy.tau,
479        logits: &open,
480        hard: true,
481        ffn: &ffn,
482    };
483    let backbone = held_ppl(&base_pass, &held);
484    log(&format!("baseline (full): {backbone:.3}"));
485
486    // ── Phase A: mask training ──
487    let mut adam_a = Adam::new(&vec![inter; nl], hy.lr_a);
488    let mut l1 = hy.l1_init * hy.l1_mult;
489    let l1_step_eff = hy.l1_step * hy.l1_mult;
490    // best = (ppl, logits_snapshot, sparsity)
491    let mut best: (f64, Option<Vec<Vec<f32>>>, f64) = (f64::MAX, None, 0.0);
492    // Track the highest-sparsity checkpoint as fallback.
493    let mut max_sp: (f64, Option<Vec<Vec<f32>>>, f64) = (f64::MAX, None, 0.0);
494    for step in 0..hy.steps_a {
495        let chunk = &calib[step % calib.len()];
496        let mut dmask: Vec<Vec<f64>> = vec![vec![0.0; inter]; nl];
497        let mut dffn: Vec<Option<(Vec<f64>, Vec<f64>, Vec<f64>)>> = vec![None; nl];
498        let pass = Pass {
499            fm: &fm,
500            tau: hy.tau,
501            logits: &logits,
502            hard: false,
503            ffn: &ffn,
504        };
505        let _ = pass.chunk(chunk, Some((&mut dmask, &mut dffn)));
506        // Fold σ'(m) into the mask grads + add the L1 term.
507        let l1_per = l1 / (inter as f64 * nl as f64);
508        for li in 0..nl {
509            for j in 0..inter {
510                let s = sigmoid(logits[li][j]) as f64;
511                dmask[li][j] = dmask[li][j] * s * (1.0 - s) + l1_per * s * (1.0 - s);
512            }
513        }
514        let mut params: Vec<&mut [f32]> = logits.iter_mut().map(|v| v.as_mut_slice()).collect();
515        adam_a.step(&mut params, &dmask, 1.0);
516        if (step + 1) % hy.eval_every == 0 {
517            l1 += l1_step_eff;
518            let pass = Pass {
519                fm: &fm,
520                tau: hy.tau,
521                logits: &logits,
522                hard: true,
523                ffn: &ffn,
524            };
525            let hp = held_ppl(&pass, &held);
526            let alive: usize = logits
527                .iter()
528                .map(|l| l.iter().filter(|&&x| sigmoid(x) > hy.tau).count())
529                .sum();
530            let sp = 1.0 - alive as f64 / (nl * inter) as f64;
531            // Track highest-sparsity checkpoint.
532            if sp > max_sp.2 {
533                max_sp = (hp, Some(logits.clone()), sp);
534            }
535            // Best checkpoint selection: respect target_sparsity.
536            if hy.target_sparsity > 0.0 {
537                if sp >= hy.target_sparsity && hp < best.0 {
538                    best = (hp, Some(logits.clone()), sp);
539                }
540            } else if hp < best.0 {
541                best = (hp, Some(logits.clone()), sp);
542            }
543            log(&format!(
544                "  [A] step {}: L1={l1:.3} pruned={:.0}% hard-PPL={hp:.3} (bottom {}@{:.0}%)",
545                step + 1,
546                sp * 100.0,
547                if best.0 == f64::MAX {
548                    "—".to_string()
549                } else {
550                    format!("{:.3}", best.0)
551                },
552                best.2 * 100.0
553            ));
554        }
555    }
556    // If target_sparsity was set but no checkpoint qualified, fall back
557    // to the highest-sparsity checkpoint.
558    if hy.target_sparsity > 0.0 && best.1.is_none() {
559        log(&format!(
560            "[A] target sparsity {:.0}% not reached; using max-sparsity checkpoint ({:.0}%)",
561            hy.target_sparsity * 100.0,
562            max_sp.2 * 100.0
563        ));
564        best = max_sp;
565    }
566    if let Some(b) = best.1.take() {
567        logits = b;
568    }
569    let pass = Pass {
570        fm: &fm,
571        tau: hy.tau,
572        logits: &logits,
573        hard: true,
574        ffn: &ffn,
575    };
576    let masked = held_ppl(&pass, &held);
577    log(&format!(
578        "[A] {:.0}s: masked-PPL {masked:.3}",
579        t0.elapsed().as_secs_f64()
580    ));
581
582    // ── Phase B: FCD of the last N layers' FFN (hard mask active) ──
583    for &li in &fcd {
584        let l = &fm.layers[li];
585        ffn[li] = Some((l.gate.clone(), l.up.clone(), l.down.clone()));
586    }
587    let sizes: Vec<usize> = fcd
588        .iter()
589        .flat_map(|&li| {
590            let l = &fm.layers[li];
591            [l.gate.len(), l.up.len(), l.down.len()]
592        })
593        .collect();
594    let mut adam_b = Adam::new(&sizes, hy.lr_b);
595    let mut best_b: (f64, Option<Vec<Option<(Vec<f32>, Vec<f32>, Vec<f32>)>>>) = (masked, None);
596    for step in 0..hy.steps_b {
597        let chunk = &calib[step % calib.len()];
598        let mut dmask: Vec<Vec<f64>> = vec![vec![0.0; inter]; nl];
599        let mut dffn: Vec<Option<(Vec<f64>, Vec<f64>, Vec<f64>)>> = (0..nl)
600            .map(|li| {
601                ffn[li]
602                    .as_ref()
603                    .map(|(g, u, d)| (vec![0.0; g.len()], vec![0.0; u.len()], vec![0.0; d.len()]))
604            })
605            .collect();
606        let pass = Pass {
607            fm: &fm,
608            tau: hy.tau,
609            logits: &logits,
610            hard: true,
611            ffn: &ffn,
612        };
613        let _ = pass.chunk(chunk, Some((&mut dmask, &mut dffn)));
614        // Cosine LR.
615        let lr_scale = 0.5 * (1.0 + (std::f64::consts::PI * step as f64 / hy.steps_b as f64).cos());
616        let first_fcd = fcd[0];
617        let mut params: Vec<&mut [f32]> = Vec::new();
618        let mut grads: Vec<Vec<f64>> = Vec::new();
619        for (off, slot) in ffn[first_fcd..].iter_mut().enumerate() {
620            let li = first_fcd + off;
621            let Some((g, u, d)) = slot.as_mut() else {
622                continue;
623            };
624            let (dg, du, dd) = dffn[li].take().unwrap();
625            params.push(g.as_mut_slice());
626            grads.push(dg);
627            params.push(u.as_mut_slice());
628            grads.push(du);
629            params.push(d.as_mut_slice());
630            grads.push(dd);
631        }
632        adam_b.step(&mut params, &grads, lr_scale);
633        if (step + 1) % hy.eval_every == 0 {
634            let pass = Pass {
635                fm: &fm,
636                tau: hy.tau,
637                logits: &logits,
638                hard: true,
639                ffn: &ffn,
640            };
641            let cur = held_ppl(&pass, &held);
642            if cur < best_b.0 {
643                best_b = (cur, Some(ffn.clone()));
644            }
645            log(&format!(
646                "  [B] step {}: held-PPL {cur:.3} (best {:.3})",
647                step + 1,
648                best_b.0
649            ));
650        }
651    }
652    if let Some(b) = best_b.1.take() {
653        ffn = b;
654    }
655    let overlaid = best_b.0;
656
657    // ── Export artifacts ──
658    let keep = keep_masks(&logits, hy.tau, hy.align, hy.uniform_inter);
659    if hy.align > 1 || hy.uniform_inter {
660        let raw: usize = logits
661            .iter()
662            .map(|l| l.iter().filter(|&&x| sigmoid(x) > hy.tau).count())
663            .sum();
664        let padded: usize = keep
665            .iter()
666            .map(|a| a.iter().filter(|&&x| x).count())
667            .sum::<usize>()
668            - raw;
669        log(&format!(
670            "align: +{padded} neurons resurrected (align {}, uniform {})",
671            hy.align, hy.uniform_inter
672        ));
673    }
674    let mut down_out = Vec::with_capacity(nl);
675    let mut gate_up = Vec::with_capacity(nl);
676    let mut kept_per_layer = Vec::with_capacity(nl);
677    for li in 0..nl {
678        let alive = &keep[li];
679        kept_per_layer.push(alive.iter().filter(|&&a| a).count());
680        let l = &fm.layers[li];
681        let mut down = match &ffn[li] {
682            Some((_, _, d)) => d.clone(),
683            None => l.down.clone(),
684        };
685        let hsz = fm.hidden;
686        for r in 0..hsz {
687            for (c, &a) in alive.iter().enumerate() {
688                if !a {
689                    down[r * inter + c] = 0.0;
690                }
691            }
692        }
693        gate_up.push(ffn[li].as_ref().map(|(g, u, _)| (g.clone(), u.clone())));
694        down_out.push(down);
695    }
696    let total: usize = kept_per_layer.iter().sum();
697    let report = BakeReport {
698        backbone,
699        masked,
700        overlaid,
701        pruned_ratio: 1.0 - total as f64 / (nl * inter) as f64,
702        kept_per_layer,
703        sec: t0.elapsed().as_secs_f64(),
704    };
705    let arts = BakeArtifacts {
706        keep,
707        down: down_out,
708        gate_up,
709        fcd_layers: fcd,
710    };
711    Ok((report, arts))
712}
713
714/// Hard-threshold keep masks from the trained logits, then resurrect
715/// the highest-logit pruned neurons until each layer's kept count is a
716/// multiple of `align` (rounding UP — the resurrected neurons are the
717/// ones the mask ranked closest to the threshold, so this only moves
718/// toward the full backbone). `uniform` additionally raises every layer
719/// to the max layer's aligned count. A layer with 0 live neurons gets
720/// `align.max(1)` — the defrag writer rejects empty layers.
721fn keep_masks(logits: &[Vec<f32>], tau: f32, align: usize, uniform: bool) -> Vec<Vec<bool>> {
722    let inter = logits[0].len();
723    let round = |n: usize| -> usize {
724        let n = n.max(1);
725        if align <= 1 {
726            n.min(inter)
727        } else {
728            (n.div_ceil(align) * align).min(inter)
729        }
730    };
731    let mut want: Vec<usize> = logits
732        .iter()
733        .map(|l| round(l.iter().filter(|&&x| sigmoid(x) > tau).count()))
734        .collect();
735    if uniform {
736        let k = want.iter().copied().max().unwrap_or(inter);
737        want = vec![k; logits.len()];
738    }
739    logits
740        .iter()
741        .zip(&want)
742        .map(|(l, &k)| {
743            let mut idx: Vec<usize> = (0..inter).collect();
744            idx.sort_unstable_by(|&a, &b| l[b].total_cmp(&l[a]));
745            let mut alive = vec![false; inter];
746            for &i in idx.iter().take(k) {
747                alive[i] = true;
748            }
749            alive
750        })
751        .collect()
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    fn kept(masks: &[Vec<bool>]) -> Vec<usize> {
759        masks
760            .iter()
761            .map(|m| m.iter().filter(|&&a| a).count())
762            .collect()
763    }
764
765    /// align=32 rounds each layer UP by resurrecting the largest
766    /// pruned logits; the originally-alive set stays alive.
767    #[test]
768    fn keep_masks_aligns_up_and_preserves_alive() {
769        let inter = 96;
770        // Layer 0: 40 alive (logits > 0 → σ > 0.5), the rest ramp
771        // below threshold so resurrection order is deterministic.
772        let l0: Vec<f32> = (0..inter)
773            .map(|i| if i < 40 { 1.0 } else { -1.0 - i as f32 * 0.01 })
774            .collect();
775        // Layer 1: 64 alive — already aligned, must stay exactly 64.
776        let l1: Vec<f32> = (0..inter)
777            .map(|i| if i < 64 { 2.0 } else { -3.0 })
778            .collect();
779        let masks = keep_masks(&[l0.clone(), l1], 0.5, 32, false);
780        assert_eq!(kept(&masks), vec![64, 64]);
781        // The 40 originally-alive stay; resurrected are the top pruned
782        // logits (indices 40..64 — the least-negative of the ramp).
783        for i in 0..64 {
784            assert!(masks[0][i], "neuron {i} should be kept");
785        }
786        for i in 64..inter {
787            assert!(!masks[0][i], "neuron {i} should stay pruned");
788        }
789    }
790
791    /// uniform=true raises every layer to the max aligned count.
792    #[test]
793    fn keep_masks_uniform_takes_max() {
794        let inter = 96;
795        let l0: Vec<f32> = (0..inter)
796            .map(|i| if i < 10 { 1.0 } else { -2.0 })
797            .collect();
798        let l1: Vec<f32> = (0..inter)
799            .map(|i| if i < 70 { 1.0 } else { -2.0 })
800            .collect();
801        let masks = keep_masks(&[l0, l1], 0.5, 32, true);
802        assert_eq!(kept(&masks), vec![96, 96]);
803    }
804
805    /// align capped at inter; align=1 (off) keeps the raw threshold
806    /// count; an all-pruned layer still keeps at least one neuron.
807    #[test]
808    fn keep_masks_edges() {
809        let inter = 48;
810        let l: Vec<f32> = (0..inter)
811            .map(|i| if i < 47 { 1.0 } else { -2.0 })
812            .collect();
813        let masks = keep_masks(&[l.clone()], 0.5, 32, false);
814        assert_eq!(kept(&masks), vec![48]); // 47 → 64 capped to 48
815        let masks = keep_masks(&[l], 0.5, 1, false);
816        assert_eq!(kept(&masks), vec![47]);
817        let dead: Vec<f32> = vec![-5.0; inter];
818        let masks = keep_masks(&[dead], 0.5, 32, false);
819        assert_eq!(kept(&masks), vec![32]); // max(1) → rounded to 32
820    }
821}