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}
41
42impl Default for BakeHyper {
43    fn default() -> Self {
44        Self {
45            steps_a: 240,
46            steps_b: 120,
47            l1_init: 0.01,
48            l1_step: 0.005,
49            eval_every: 30,
50            lr_a: 0.1,
51            lr_b: 1e-5,
52            tau: 0.5,
53            fcd_layers: 4,
54            seed: 0,
55        }
56    }
57}
58
59/// What the bake measured and produced.
60pub struct BakeReport {
61    /// Held-out PPL of the untouched backbone.
62    pub backbone: f64,
63    /// Held-out PPL with the best hard mask (the denoising bottom).
64    pub masked: f64,
65    /// Held-out PPL after FCD (the final specialist).
66    pub overlaid: f64,
67    pub pruned_ratio: f64,
68    pub kept_per_layer: Vec<usize>,
69    pub sec: f64,
70}
71
72/// The trained artifacts: everything the defrag writer needs, f32.
73pub struct BakeArtifacts {
74    /// Per-layer live-neuron flags (true = keep).
75    pub keep: Vec<Vec<bool>>,
76    /// Per-layer down_proj `[hidden, inter]` with dead columns zeroed
77    /// (FCD layers: the trained weights; others: the backbone's).
78    pub down: Vec<Vec<f32>>,
79    /// Trained gate/up for the FCD layers (`None` elsewhere).
80    pub gate_up: Vec<Option<(Vec<f32>, Vec<f32>)>>,
81    /// Which layers went through Phase B.
82    pub fcd_layers: Vec<usize>,
83}
84
85const CLIP: f64 = 1.0;
86const B1: f64 = 0.9;
87const B2: f64 = 0.999;
88const EPS: f64 = 1e-8;
89
90/// Plain Adam over a set of f32 tensors (masks are tiny, FFN mid-size).
91struct Adam {
92    m: Vec<Vec<f64>>,
93    v: Vec<Vec<f64>>,
94    t: i32,
95    lr: f64,
96}
97
98impl Adam {
99    fn new(sizes: &[usize], lr: f64) -> Self {
100        Self {
101            m: sizes.iter().map(|&n| vec![0.0; n]).collect(),
102            v: sizes.iter().map(|&n| vec![0.0; n]).collect(),
103            t: 0,
104            lr,
105        }
106    }
107
108    /// Global-norm clip + Adam step. `params[i].len() == grads[i].len()`.
109    fn step(&mut self, params: &mut [&mut [f32]], grads: &[Vec<f64>], lr_scale: f64) {
110        let gn: f64 = grads
111            .iter()
112            .flat_map(|g| g.iter().map(|x| x * x))
113            .sum::<f64>()
114            .sqrt();
115        let clip = if gn > CLIP { CLIP / gn } else { 1.0 };
116        self.t += 1;
117        let (bc1, bc2) = (1.0 - B1.powi(self.t), 1.0 - B2.powi(self.t));
118        for (pi, p) in params.iter_mut().enumerate() {
119            for j in 0..p.len() {
120                let g = grads[pi][j] * clip;
121                let m = &mut self.m[pi][j];
122                let v = &mut self.v[pi][j];
123                *m = B1 * *m + (1.0 - B1) * g;
124                *v = B2 * *v + (1.0 - B2) * g * g;
125                let upd = (*m / bc1) / ((*v / bc2).sqrt() + EPS);
126                p[j] -= (self.lr * lr_scale * upd) as f32;
127            }
128        }
129    }
130}
131
132fn sigmoid(x: f32) -> f32 {
133    1.0 / (1.0 + (-x).exp())
134}
135
136/// One forward + CE(+optionally backward through the FFN chain).
137/// Returns (nll_sum, tokens). `dmask`/`dffn` accumulate when given.
138struct Pass<'a> {
139    fm: &'a FcdModel,
140    tau: f32,
141    /// σ(m) per layer when soft; binarized when `hard`.
142    logits: &'a [Vec<f32>],
143    hard: bool,
144    /// Phase-B replacement FFN weights per layer (trained copies).
145    ffn: &'a [Option<(Vec<f32>, Vec<f32>, Vec<f32>)>],
146}
147
148impl Pass<'_> {
149    fn gates(&self, li: usize) -> Vec<f32> {
150        self.logits[li]
151            .iter()
152            .map(|&l| {
153                let s = sigmoid(l);
154                if self.hard {
155                    if s > self.tau { 1.0 } else { 0.0 }
156                } else {
157                    s
158                }
159            })
160            .collect()
161    }
162
163    fn wts<'b>(&'b self, li: usize) -> LnFfn<'b> {
164        let l = &self.fm.layers[li];
165        match &self.ffn[li] {
166            Some((g, u, d)) => LnFfn {
167                iln: &l.iln,
168                pln: &l.pln,
169                gate: g,
170                up: u,
171                down: d,
172            },
173            None => LnFfn {
174                iln: &l.iln,
175                pln: &l.pln,
176                gate: &l.gate,
177                up: &l.up,
178                down: &l.down,
179            },
180        }
181    }
182
183    /// Teacher-forced NLL over one chunk; when `grad` is set, backprop
184    /// through the FFN chain into the mask grads (and FFN grads for
185    /// Phase-B layers).
186    #[allow(clippy::too_many_arguments)]
187    fn chunk(
188        &self,
189        ids: &[u32],
190        grad: Option<(
191            &mut [Vec<f64>],
192            &mut [Option<(Vec<f64>, Vec<f64>, Vec<f64>)>],
193        )>,
194    ) -> (f64, usize) {
195        let fm = self.fm;
196        let (t, hsz) = (ids.len(), fm.hidden);
197        let nl = fm.layers.len();
198        // Embed.
199        let mut h = vec![0f32; t * hsz];
200        for (r, &id) in ids.iter().enumerate() {
201            h[r * hsz..(r + 1) * hsz]
202                .copy_from_slice(&fm.embed[id as usize * hsz..(id as usize + 1) * hsz]);
203        }
204        // Forward, keeping per-layer inputs + activations.
205        let mut h_ins = Vec::with_capacity(nl);
206        let mut acts = Vec::with_capacity(nl);
207        let mut masks = Vec::with_capacity(nl);
208        for li in 0..nl {
209            let g = self.gates(li);
210            let wts = self.wts(li);
211            let want = grad.is_some();
212            let (h2, a) = fm.layer_forward_scaled(li, &h, 1, t, &wts, false, want, Some(&g));
213            h_ins.push(if want { h } else { Vec::new() });
214            acts.push(a);
215            masks.push(g);
216            h = h2;
217        }
218        // Final norm + tied LM head, CE summed over positions 1..t.
219        let mut hn = vec![0f32; t * hsz];
220        let mut inv = vec![0f32; t];
221        ops::rmsnorm_fwd(&h, &fm.final_norm, fm.eps, fm.gemma, &mut hn, &mut inv);
222        let lm: &[f32] = fm.lm_head.as_deref().unwrap_or(&fm.embed);
223        let vocab = lm.len() / hsz;
224        let pool = fm.pool.as_deref();
225        let mut nll = 0f64;
226        let mut dh_n = vec![0f32; t * hsz]; // dL/d hn
227        // Chunk the vocab matmul over positions to bound the logits buf.
228        const POS_CHUNK: usize = 32;
229        let scored = t - 1;
230        let mut p0 = 0usize;
231        while p0 < scored {
232            let pc = POS_CHUNK.min(scored - p0);
233            let mut logits = vec![0f32; pc * vocab];
234            ops::gemm_nt(
235                &hn[p0 * hsz..(p0 + pc) * hsz],
236                lm,
237                &mut logits,
238                pc,
239                hsz,
240                vocab,
241                pool,
242            );
243            for r in 0..pc {
244                let target = ids[p0 + r + 1] as usize;
245                let row = &mut logits[r * vocab..(r + 1) * vocab];
246                let mx = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as f64;
247                let mut sum = 0f64;
248                for v in row.iter() {
249                    sum += ((*v as f64) - mx).exp();
250                }
251                nll += mx + sum.ln() - row[target] as f64;
252                if grad.is_some() {
253                    // dCE/dlogit = softmax − onehot, scaled by 1/scored.
254                    let inv_n = 1.0 / scored as f64;
255                    for v in row.iter_mut() {
256                        *v = ((((*v as f64) - mx).exp() / sum) * inv_n) as f32;
257                    }
258                    row[target] -= inv_n as f32;
259                }
260            }
261            if grad.is_some() {
262                ops::gemm_dx(
263                    &logits,
264                    lm,
265                    &mut dh_n[p0 * hsz..(p0 + pc) * hsz],
266                    pc,
267                    hsz,
268                    vocab,
269                    pool,
270                );
271            }
272            p0 += pc;
273        }
274        let Some((dmask, dffn)) = grad else {
275            return (nll, scored);
276        };
277        // Backward: final norm, then the FFN chain layer by layer.
278        let mut dh = vec![0f32; t * hsz];
279        ops::rmsnorm_bwd(&h, &fm.final_norm, &inv, &dh_n, fm.gemma, &mut dh, None);
280        for li in (0..nl).rev() {
281            let a = acts[li].as_ref().expect("acts saved in grad mode");
282            let g = &masks[li];
283            let inter = fm.layers[li].inter;
284            let wts = self.wts(li);
285            // h2 = h1 + act2 @ downᵀ  →  dact2 = dh @ down.
286            let mut dact2 = vec![0f32; t * inter];
287            ops::gemm_dx(&dh, wts.down, &mut dact2, t, inter, hsz, fm.pool.as_deref());
288            if let Some((_, _, dd)) = dffn[li].as_mut() {
289                // dW_down += dhᵀ · act2 (act2 = act·g).
290                let mut act2 = a.act.clone();
291                for r in 0..t {
292                    for (x, &gv) in act2[r * inter..(r + 1) * inter].iter_mut().zip(g) {
293                        *x *= gv;
294                    }
295                }
296                let mut dw = vec![0f32; hsz * inter];
297                ops::gemm_dw(&dh, &act2, &mut dw, t, inter, hsz, fm.pool.as_deref());
298                for (o, &x) in dd.iter_mut().zip(&dw) {
299                    *o += x as f64;
300                }
301            }
302            // Mask grad: dm = Σ_t dact2·act · σ'(m)  (soft; STE-equal).
303            {
304                let dm = &mut dmask[li];
305                for r in 0..t {
306                    let da = &dact2[r * inter..(r + 1) * inter];
307                    let aa = &a.act[r * inter..(r + 1) * inter];
308                    for j in 0..inter {
309                        dm[j] += da[j] as f64 * aa[j] as f64;
310                    }
311                }
312                // σ'(m) folded in once per chunk (constant per neuron).
313                for (j, d) in dm.iter_mut().enumerate() {
314                    let _ = j;
315                    let _ = d;
316                }
317            }
318            // dact = dact2 · g;  silu·mul backward.
319            let mut dg_pre = vec![0f32; t * inter];
320            let mut du_pre = vec![0f32; t * inter];
321            for r in 0..t {
322                for j in 0..inter {
323                    let i = r * inter + j;
324                    let da = dact2[i] * g[j];
325                    let sg = ops::silu(a.gpre[i]);
326                    dg_pre[i] = da * a.upre[i] * ops::silu_bwd(a.gpre[i]);
327                    du_pre[i] = da * sg;
328                }
329            }
330            // dn2 = dg_pre @ gate + du_pre @ up.
331            let mut dn2 = vec![0f32; t * hsz];
332            ops::gemm_dx(
333                &dg_pre,
334                wts.gate,
335                &mut dn2,
336                t,
337                hsz,
338                inter,
339                fm.pool.as_deref(),
340            );
341            let mut dn2b = vec![0f32; t * hsz];
342            ops::gemm_dx(
343                &du_pre,
344                wts.up,
345                &mut dn2b,
346                t,
347                hsz,
348                inter,
349                fm.pool.as_deref(),
350            );
351            for (x, &y) in dn2.iter_mut().zip(&dn2b) {
352                *x += y;
353            }
354            if let Some((dgw, duw, _)) = dffn[li].as_mut() {
355                let mut dw = vec![0f32; inter * hsz];
356                ops::gemm_dw(&dg_pre, &a.n2, &mut dw, t, hsz, inter, fm.pool.as_deref());
357                for (o, &x) in dgw.iter_mut().zip(&dw) {
358                    *o += x as f64;
359                }
360                dw.fill(0.0);
361                ops::gemm_dw(&du_pre, &a.n2, &mut dw, t, hsz, inter, fm.pool.as_deref());
362                for (o, &x) in duw.iter_mut().zip(&dw) {
363                    *o += x as f64;
364                }
365            }
366            // Post-norm backward into h1; the attention branch carries
367            // no gradient (frozen), so dh1 flows straight to dh_in.
368            let mut dh1 = dh.clone(); // residual h2 = h1 + ffn
369            ops::rmsnorm_bwd(&a.h1, wts.pln, &a.inv2, &dn2, fm.gemma, &mut dh1, None);
370            dh = dh1;
371            let _ = &h_ins[li];
372        }
373        (nll, scored)
374    }
375}
376
377/// Held-out PPL with the hard mask (and Phase-B weights when present).
378fn held_ppl(pass: &Pass, held: &[Vec<u32>]) -> f64 {
379    let mut nll = 0f64;
380    let mut n = 0usize;
381    for c in held {
382        let (l, k) = pass.chunk(c, None);
383        nll += l;
384        n += k;
385    }
386    (nll / n.max(1) as f64).exp()
387}
388
389/// The whole recipe. `log` receives progress lines.
390pub fn skill_bake(
391    model: &Arc<CmfModel>,
392    chunks: &[Vec<u32>],
393    held_n: usize,
394    hy: &BakeHyper,
395    mut log: impl FnMut(&str),
396) -> Result<(BakeReport, BakeArtifacts), String> {
397    let t0 = std::time::Instant::now();
398    let o1_off = crate::nystrom::O1Cfg {
399        layers: crate::nystrom::O1Layers::List(Vec::new()),
400        m: 4,
401        w: 8,
402        sink: 1,
403        rect: crate::nystrom::O1_DEFAULT_RECT,
404    };
405    let fm = FcdModel::from_cmf(model, &o1_off)?;
406    let nl = fm.layers.len();
407    let inter = fm.layers.iter().map(|l| l.inter).max().unwrap_or(0);
408    if fm.layers.iter().any(|l| l.inter != inter) {
409        return Err("skill bake: non-uniform FFN widths".into());
410    }
411    let held: Vec<Vec<u32>> = chunks[..held_n.min(chunks.len())].to_vec();
412    let calib: Vec<Vec<u32>> = chunks[held_n.min(chunks.len())..].to_vec();
413    if calib.len() < 12 {
414        return Err(format!(
415            "skill bake: corpus too small ({} calib chunks)",
416            calib.len()
417        ));
418    }
419    let fcd: Vec<usize> = (nl.saturating_sub(hy.fcd_layers)..nl).collect();
420    let _rng = SplitMix64::new(hy.seed);
421
422    // Trainables.
423    let mut logits: Vec<Vec<f32>> = vec![vec![2.0; inter]; nl];
424    let mut ffn: Vec<Option<(Vec<f32>, Vec<f32>, Vec<f32>)>> = vec![None; nl];
425
426    // Baseline (no mask): σ(2.0)≈0.88 is NOT identity, so measure with
427    // gates forced open via hard mask over +∞… simplest: logits +50.
428    let open: Vec<Vec<f32>> = vec![vec![50.0; inter]; nl];
429    let base_pass = Pass {
430        fm: &fm,
431        tau: hy.tau,
432        logits: &open,
433        hard: true,
434        ffn: &ffn,
435    };
436    let backbone = held_ppl(&base_pass, &held);
437    log(&format!("baseline (full): {backbone:.3}"));
438
439    // ── Phase A: mask training ──
440    let mut adam_a = Adam::new(&vec![inter; nl], hy.lr_a);
441    let mut l1 = hy.l1_init;
442    let mut best: (f64, Option<Vec<Vec<f32>>>, f64) = (backbone, None, 0.0);
443    for step in 0..hy.steps_a {
444        let chunk = &calib[step % calib.len()];
445        let mut dmask: Vec<Vec<f64>> = vec![vec![0.0; inter]; nl];
446        let mut dffn: Vec<Option<(Vec<f64>, Vec<f64>, Vec<f64>)>> = vec![None; nl];
447        let pass = Pass {
448            fm: &fm,
449            tau: hy.tau,
450            logits: &logits,
451            hard: false,
452            ffn: &ffn,
453        };
454        let _ = pass.chunk(chunk, Some((&mut dmask, &mut dffn)));
455        // Fold σ'(m) into the mask grads + add the L1 term.
456        let l1_per = l1 / (inter as f64 * nl as f64);
457        for li in 0..nl {
458            for j in 0..inter {
459                let s = sigmoid(logits[li][j]) as f64;
460                dmask[li][j] = dmask[li][j] * s * (1.0 - s) + l1_per * s * (1.0 - s);
461            }
462        }
463        let mut params: Vec<&mut [f32]> = logits.iter_mut().map(|v| v.as_mut_slice()).collect();
464        adam_a.step(&mut params, &dmask, 1.0);
465        if (step + 1) % hy.eval_every == 0 {
466            l1 += hy.l1_step;
467            let pass = Pass {
468                fm: &fm,
469                tau: hy.tau,
470                logits: &logits,
471                hard: true,
472                ffn: &ffn,
473            };
474            let hp = held_ppl(&pass, &held);
475            let alive: usize = logits
476                .iter()
477                .map(|l| l.iter().filter(|&&x| sigmoid(x) > hy.tau).count())
478                .sum();
479            let sp = 1.0 - alive as f64 / (nl * inter) as f64;
480            if hp < best.0 {
481                best = (hp, Some(logits.clone()), sp);
482            }
483            log(&format!(
484                "  [A] step {}: L1={l1:.3} pruned={:.0}% hard-PPL={hp:.3} (bottom {:.3}@{:.0}%)",
485                step + 1,
486                sp * 100.0,
487                best.0,
488                best.2 * 100.0
489            ));
490        }
491    }
492    if let Some(b) = best.1.take() {
493        logits = b;
494    }
495    let pass = Pass {
496        fm: &fm,
497        tau: hy.tau,
498        logits: &logits,
499        hard: true,
500        ffn: &ffn,
501    };
502    let masked = held_ppl(&pass, &held);
503    log(&format!(
504        "[A] {:.0}s: masked-PPL {masked:.3}",
505        t0.elapsed().as_secs_f64()
506    ));
507
508    // ── Phase B: FCD of the last N layers' FFN (hard mask active) ──
509    for &li in &fcd {
510        let l = &fm.layers[li];
511        ffn[li] = Some((l.gate.clone(), l.up.clone(), l.down.clone()));
512    }
513    let sizes: Vec<usize> = fcd
514        .iter()
515        .flat_map(|&li| {
516            let l = &fm.layers[li];
517            [l.gate.len(), l.up.len(), l.down.len()]
518        })
519        .collect();
520    let mut adam_b = Adam::new(&sizes, hy.lr_b);
521    let mut best_b: (f64, Option<Vec<Option<(Vec<f32>, Vec<f32>, Vec<f32>)>>>) = (masked, None);
522    for step in 0..hy.steps_b {
523        let chunk = &calib[step % calib.len()];
524        let mut dmask: Vec<Vec<f64>> = vec![vec![0.0; inter]; nl];
525        let mut dffn: Vec<Option<(Vec<f64>, Vec<f64>, Vec<f64>)>> = (0..nl)
526            .map(|li| {
527                ffn[li]
528                    .as_ref()
529                    .map(|(g, u, d)| (vec![0.0; g.len()], vec![0.0; u.len()], vec![0.0; d.len()]))
530            })
531            .collect();
532        let pass = Pass {
533            fm: &fm,
534            tau: hy.tau,
535            logits: &logits,
536            hard: true,
537            ffn: &ffn,
538        };
539        let _ = pass.chunk(chunk, Some((&mut dmask, &mut dffn)));
540        // Cosine LR.
541        let lr_scale = 0.5 * (1.0 + (std::f64::consts::PI * step as f64 / hy.steps_b as f64).cos());
542        let first_fcd = fcd[0];
543        let mut params: Vec<&mut [f32]> = Vec::new();
544        let mut grads: Vec<Vec<f64>> = Vec::new();
545        for (off, slot) in ffn[first_fcd..].iter_mut().enumerate() {
546            let li = first_fcd + off;
547            let Some((g, u, d)) = slot.as_mut() else {
548                continue;
549            };
550            let (dg, du, dd) = dffn[li].take().unwrap();
551            params.push(g.as_mut_slice());
552            grads.push(dg);
553            params.push(u.as_mut_slice());
554            grads.push(du);
555            params.push(d.as_mut_slice());
556            grads.push(dd);
557        }
558        adam_b.step(&mut params, &grads, lr_scale);
559        if (step + 1) % hy.eval_every == 0 {
560            let pass = Pass {
561                fm: &fm,
562                tau: hy.tau,
563                logits: &logits,
564                hard: true,
565                ffn: &ffn,
566            };
567            let cur = held_ppl(&pass, &held);
568            if cur < best_b.0 {
569                best_b = (cur, Some(ffn.clone()));
570            }
571            log(&format!(
572                "  [B] step {}: held-PPL {cur:.3} (best {:.3})",
573                step + 1,
574                best_b.0
575            ));
576        }
577    }
578    if let Some(b) = best_b.1.take() {
579        ffn = b;
580    }
581    let overlaid = best_b.0;
582
583    // ── Export artifacts ──
584    let mut keep = Vec::with_capacity(nl);
585    let mut down_out = Vec::with_capacity(nl);
586    let mut gate_up = Vec::with_capacity(nl);
587    let mut kept_per_layer = Vec::with_capacity(nl);
588    for li in 0..nl {
589        let alive: Vec<bool> = logits[li].iter().map(|&x| sigmoid(x) > hy.tau).collect();
590        kept_per_layer.push(alive.iter().filter(|&&a| a).count());
591        let l = &fm.layers[li];
592        let mut down = match &ffn[li] {
593            Some((_, _, d)) => d.clone(),
594            None => l.down.clone(),
595        };
596        let hsz = fm.hidden;
597        for r in 0..hsz {
598            for (c, &a) in alive.iter().enumerate() {
599                if !a {
600                    down[r * inter + c] = 0.0;
601                }
602            }
603        }
604        gate_up.push(ffn[li].as_ref().map(|(g, u, _)| (g.clone(), u.clone())));
605        down_out.push(down);
606        keep.push(alive);
607    }
608    let total: usize = kept_per_layer.iter().sum();
609    let report = BakeReport {
610        backbone,
611        masked,
612        overlaid,
613        pruned_ratio: 1.0 - total as f64 / (nl * inter) as f64,
614        kept_per_layer,
615        sec: t0.elapsed().as_secs_f64(),
616    };
617    let arts = BakeArtifacts {
618        keep,
619        down: down_out,
620        gate_up,
621        fcd_layers: fcd,
622    };
623    Ok((report, arts))
624}