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