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.iter().flat_map(|g| g.iter().map(|x| x * x)).sum::<f64>().sqrt();
111        let clip = if gn > CLIP { CLIP / gn } else { 1.0 };
112        self.t += 1;
113        let (bc1, bc2) = (1.0 - B1.powi(self.t), 1.0 - B2.powi(self.t));
114        for (pi, p) in params.iter_mut().enumerate() {
115            for j in 0..p.len() {
116                let g = grads[pi][j] * clip;
117                let m = &mut self.m[pi][j];
118                let v = &mut self.v[pi][j];
119                *m = B1 * *m + (1.0 - B1) * g;
120                *v = B2 * *v + (1.0 - B2) * g * g;
121                let upd = (*m / bc1) / ((*v / bc2).sqrt() + EPS);
122                p[j] -= (self.lr * lr_scale * upd) as f32;
123            }
124        }
125    }
126}
127
128fn sigmoid(x: f32) -> f32 {
129    1.0 / (1.0 + (-x).exp())
130}
131
132/// One forward + CE(+optionally backward through the FFN chain).
133/// Returns (nll_sum, tokens). `dmask`/`dffn` accumulate when given.
134struct Pass<'a> {
135    fm: &'a FcdModel,
136    tau: f32,
137    /// σ(m) per layer when soft; binarized when `hard`.
138    logits: &'a [Vec<f32>],
139    hard: bool,
140    /// Phase-B replacement FFN weights per layer (trained copies).
141    ffn: &'a [Option<(Vec<f32>, Vec<f32>, Vec<f32>)>],
142}
143
144impl Pass<'_> {
145    fn gates(&self, li: usize) -> Vec<f32> {
146        self.logits[li]
147            .iter()
148            .map(|&l| {
149                let s = sigmoid(l);
150                if self.hard {
151                    if s > self.tau {
152                        1.0
153                    } else {
154                        0.0
155                    }
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 { iln: &l.iln, pln: &l.pln, gate: g, up: u, down: d },
167            None => LnFfn { iln: &l.iln, pln: &l.pln, gate: &l.gate, up: &l.up, down: &l.down },
168        }
169    }
170
171    /// Teacher-forced NLL over one chunk; when `grad` is set, backprop
172    /// through the FFN chain into the mask grads (and FFN grads for
173    /// Phase-B layers).
174    #[allow(clippy::too_many_arguments)]
175    fn chunk(
176        &self,
177        ids: &[u32],
178        grad: Option<(&mut [Vec<f64>], &mut [Option<(Vec<f64>, Vec<f64>, Vec<f64>)>])>,
179    ) -> (f64, usize) {
180        let fm = self.fm;
181        let (t, hsz) = (ids.len(), fm.hidden);
182        let nl = fm.layers.len();
183        // Embed.
184        let mut h = vec![0f32; t * hsz];
185        for (r, &id) in ids.iter().enumerate() {
186            h[r * hsz..(r + 1) * hsz]
187                .copy_from_slice(&fm.embed[id as usize * hsz..(id as usize + 1) * hsz]);
188        }
189        // Forward, keeping per-layer inputs + activations.
190        let mut h_ins = Vec::with_capacity(nl);
191        let mut acts = Vec::with_capacity(nl);
192        let mut masks = Vec::with_capacity(nl);
193        for li in 0..nl {
194            let g = self.gates(li);
195            let wts = self.wts(li);
196            let want = grad.is_some();
197            let (h2, a) = fm.layer_forward_scaled(li, &h, 1, t, &wts, false, want, Some(&g));
198            h_ins.push(if want { h } else { Vec::new() });
199            acts.push(a);
200            masks.push(g);
201            h = h2;
202        }
203        // Final norm + tied LM head, CE summed over positions 1..t.
204        let mut hn = vec![0f32; t * hsz];
205        let mut inv = vec![0f32; t];
206        ops::rmsnorm_fwd(&h, &fm.final_norm, fm.eps, fm.gemma, &mut hn, &mut inv);
207        let lm: &[f32] = fm.lm_head.as_deref().unwrap_or(&fm.embed);
208        let vocab = lm.len() / hsz;
209        let pool = fm.pool.as_deref();
210        let mut nll = 0f64;
211        let mut dh_n = vec![0f32; t * hsz]; // dL/d hn
212        // Chunk the vocab matmul over positions to bound the logits buf.
213        const POS_CHUNK: usize = 32;
214        let scored = t - 1;
215        let mut p0 = 0usize;
216        while p0 < scored {
217            let pc = POS_CHUNK.min(scored - p0);
218            let mut logits = vec![0f32; pc * vocab];
219            ops::gemm_nt(&hn[p0 * hsz..(p0 + pc) * hsz], lm, &mut logits, pc, hsz, vocab, pool);
220            for r in 0..pc {
221                let target = ids[p0 + r + 1] as usize;
222                let row = &mut logits[r * vocab..(r + 1) * vocab];
223                let mx = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as f64;
224                let mut sum = 0f64;
225                for v in row.iter() {
226                    sum += ((*v as f64) - mx).exp();
227                }
228                nll += mx + sum.ln() - row[target] as f64;
229                if grad.is_some() {
230                    // dCE/dlogit = softmax − onehot, scaled by 1/scored.
231                    let inv_n = 1.0 / scored as f64;
232                    for v in row.iter_mut() {
233                        *v = ((((*v as f64) - mx).exp() / sum) * inv_n) as f32;
234                    }
235                    row[target] -= inv_n as f32;
236                }
237            }
238            if grad.is_some() {
239                ops::gemm_dx(&logits, lm, &mut dh_n[p0 * hsz..(p0 + pc) * hsz], pc, hsz, vocab, pool);
240            }
241            p0 += pc;
242        }
243        let Some((dmask, dffn)) = grad else {
244            return (nll, scored);
245        };
246        // Backward: final norm, then the FFN chain layer by layer.
247        let mut dh = vec![0f32; t * hsz];
248        ops::rmsnorm_bwd(&h, &fm.final_norm, &inv, &dh_n, fm.gemma, &mut dh, None);
249        for li in (0..nl).rev() {
250            let a = acts[li].as_ref().expect("acts saved in grad mode");
251            let g = &masks[li];
252            let inter = fm.layers[li].inter;
253            let wts = self.wts(li);
254            // h2 = h1 + act2 @ downᵀ  →  dact2 = dh @ down.
255            let mut dact2 = vec![0f32; t * inter];
256            ops::gemm_dx(&dh, wts.down, &mut dact2, t, inter, hsz, fm.pool.as_deref());
257            if let Some((_, _, dd)) = dffn[li].as_mut() {
258                // dW_down += dhᵀ · act2 (act2 = act·g).
259                let mut act2 = a.act.clone();
260                for r in 0..t {
261                    for (x, &gv) in act2[r * inter..(r + 1) * inter].iter_mut().zip(g) {
262                        *x *= gv;
263                    }
264                }
265                let mut dw = vec![0f32; hsz * inter];
266                ops::gemm_dw(&dh, &act2, &mut dw, t, inter, hsz, fm.pool.as_deref());
267                for (o, &x) in dd.iter_mut().zip(&dw) {
268                    *o += x as f64;
269                }
270            }
271            // Mask grad: dm = Σ_t dact2·act · σ'(m)  (soft; STE-equal).
272            {
273                let dm = &mut dmask[li];
274                for r in 0..t {
275                    let da = &dact2[r * inter..(r + 1) * inter];
276                    let aa = &a.act[r * inter..(r + 1) * inter];
277                    for j in 0..inter {
278                        dm[j] += da[j] as f64 * aa[j] as f64;
279                    }
280                }
281                // σ'(m) folded in once per chunk (constant per neuron).
282                for (j, d) in dm.iter_mut().enumerate() {
283                    let _ = j;
284                    let _ = d;
285                }
286            }
287            // dact = dact2 · g;  silu·mul backward.
288            let mut dg_pre = vec![0f32; t * inter];
289            let mut du_pre = vec![0f32; t * inter];
290            for r in 0..t {
291                for j in 0..inter {
292                    let i = r * inter + j;
293                    let da = dact2[i] * g[j];
294                    let sg = ops::silu(a.gpre[i]);
295                    dg_pre[i] = da * a.upre[i] * ops::silu_bwd(a.gpre[i]);
296                    du_pre[i] = da * sg;
297                }
298            }
299            // dn2 = dg_pre @ gate + du_pre @ up.
300            let mut dn2 = vec![0f32; t * hsz];
301            ops::gemm_dx(&dg_pre, wts.gate, &mut dn2, t, hsz, inter, fm.pool.as_deref());
302            let mut dn2b = vec![0f32; t * hsz];
303            ops::gemm_dx(&du_pre, wts.up, &mut dn2b, t, hsz, inter, fm.pool.as_deref());
304            for (x, &y) in dn2.iter_mut().zip(&dn2b) {
305                *x += y;
306            }
307            if let Some((dgw, duw, _)) = dffn[li].as_mut() {
308                let mut dw = vec![0f32; inter * hsz];
309                ops::gemm_dw(&dg_pre, &a.n2, &mut dw, t, hsz, inter, fm.pool.as_deref());
310                for (o, &x) in dgw.iter_mut().zip(&dw) {
311                    *o += x as f64;
312                }
313                dw.fill(0.0);
314                ops::gemm_dw(&du_pre, &a.n2, &mut dw, t, hsz, inter, fm.pool.as_deref());
315                for (o, &x) in duw.iter_mut().zip(&dw) {
316                    *o += x as f64;
317                }
318            }
319            // Post-norm backward into h1; the attention branch carries
320            // no gradient (frozen), so dh1 flows straight to dh_in.
321            let mut dh1 = dh.clone(); // residual h2 = h1 + ffn
322            ops::rmsnorm_bwd(&a.h1, wts.pln, &a.inv2, &dn2, fm.gemma, &mut dh1, None);
323            dh = dh1;
324            let _ = &h_ins[li];
325        }
326        (nll, scored)
327    }
328}
329
330/// Held-out PPL with the hard mask (and Phase-B weights when present).
331fn held_ppl(pass: &Pass, held: &[Vec<u32>]) -> f64 {
332    let mut nll = 0f64;
333    let mut n = 0usize;
334    for c in held {
335        let (l, k) = pass.chunk(c, None);
336        nll += l;
337        n += k;
338    }
339    (nll / n.max(1) as f64).exp()
340}
341
342/// The whole recipe. `log` receives progress lines.
343pub fn skill_bake(
344    model: &Arc<CmfModel>,
345    chunks: &[Vec<u32>],
346    held_n: usize,
347    hy: &BakeHyper,
348    mut log: impl FnMut(&str),
349) -> Result<(BakeReport, BakeArtifacts), String> {
350    let t0 = std::time::Instant::now();
351    let o1_off = crate::nystrom::O1Cfg {
352        layers: crate::nystrom::O1Layers::List(Vec::new()),
353        m: 4,
354        w: 8,
355        sink: 1,
356        rect: crate::nystrom::O1_DEFAULT_RECT,
357    };
358    let fm = FcdModel::from_cmf(model, &o1_off)?;
359    let nl = fm.layers.len();
360    let inter = fm.layers.iter().map(|l| l.inter).max().unwrap_or(0);
361    if fm.layers.iter().any(|l| l.inter != inter) {
362        return Err("skill bake: non-uniform FFN widths".into());
363    }
364    let held: Vec<Vec<u32>> = chunks[..held_n.min(chunks.len())].to_vec();
365    let calib: Vec<Vec<u32>> = chunks[held_n.min(chunks.len())..].to_vec();
366    if calib.len() < 12 {
367        return Err(format!("skill bake: corpus too small ({} calib chunks)", calib.len()));
368    }
369    let fcd: Vec<usize> = (nl.saturating_sub(hy.fcd_layers)..nl).collect();
370    let _rng = SplitMix64::new(hy.seed);
371
372    // Trainables.
373    let mut logits: Vec<Vec<f32>> = vec![vec![2.0; inter]; nl];
374    let mut ffn: Vec<Option<(Vec<f32>, Vec<f32>, Vec<f32>)>> = vec![None; nl];
375
376    // Baseline (no mask): σ(2.0)≈0.88 is NOT identity, so measure with
377    // gates forced open via hard mask over +∞… simplest: logits +50.
378    let open: Vec<Vec<f32>> = vec![vec![50.0; inter]; nl];
379    let base_pass = Pass { fm: &fm, tau: hy.tau, logits: &open, hard: true, ffn: &ffn };
380    let backbone = held_ppl(&base_pass, &held);
381    log(&format!("baseline (full): {backbone:.3}"));
382
383    // ── Phase A: mask training ──
384    let mut adam_a = Adam::new(&vec![inter; nl], hy.lr_a);
385    let mut l1 = hy.l1_init;
386    let mut best: (f64, Option<Vec<Vec<f32>>>, f64) = (backbone, None, 0.0);
387    for step in 0..hy.steps_a {
388        let chunk = &calib[step % calib.len()];
389        let mut dmask: Vec<Vec<f64>> = vec![vec![0.0; inter]; nl];
390        let mut dffn: Vec<Option<(Vec<f64>, Vec<f64>, Vec<f64>)>> = vec![None; nl];
391        let pass = Pass { fm: &fm, tau: hy.tau, logits: &logits, hard: false, ffn: &ffn };
392        let _ = pass.chunk(chunk, Some((&mut dmask, &mut dffn)));
393        // Fold σ'(m) into the mask grads + add the L1 term.
394        let l1_per = l1 / (inter as f64 * nl as f64);
395        for li in 0..nl {
396            for j in 0..inter {
397                let s = sigmoid(logits[li][j]) as f64;
398                dmask[li][j] = dmask[li][j] * s * (1.0 - s) + l1_per * s * (1.0 - s);
399            }
400        }
401        let mut params: Vec<&mut [f32]> = logits.iter_mut().map(|v| v.as_mut_slice()).collect();
402        adam_a.step(&mut params, &dmask, 1.0);
403        if (step + 1) % hy.eval_every == 0 {
404            l1 += hy.l1_step;
405            let pass = Pass { fm: &fm, tau: hy.tau, logits: &logits, hard: true, ffn: &ffn };
406            let hp = held_ppl(&pass, &held);
407            let alive: usize = logits
408                .iter()
409                .map(|l| l.iter().filter(|&&x| sigmoid(x) > hy.tau).count())
410                .sum();
411            let sp = 1.0 - alive as f64 / (nl * inter) as f64;
412            if hp < best.0 {
413                best = (hp, Some(logits.clone()), sp);
414            }
415            log(&format!(
416                "  [A] step {}: L1={l1:.3} pruned={:.0}% hard-PPL={hp:.3} (bottom {:.3}@{:.0}%)",
417                step + 1,
418                sp * 100.0,
419                best.0,
420                best.2 * 100.0
421            ));
422        }
423    }
424    if let Some(b) = best.1.take() {
425        logits = b;
426    }
427    let pass = Pass { fm: &fm, tau: hy.tau, logits: &logits, hard: true, ffn: &ffn };
428    let masked = held_ppl(&pass, &held);
429    log(&format!("[A] {:.0}s: masked-PPL {masked:.3}", t0.elapsed().as_secs_f64()));
430
431    // ── Phase B: FCD of the last N layers' FFN (hard mask active) ──
432    for &li in &fcd {
433        let l = &fm.layers[li];
434        ffn[li] = Some((l.gate.clone(), l.up.clone(), l.down.clone()));
435    }
436    let sizes: Vec<usize> = fcd
437        .iter()
438        .flat_map(|&li| {
439            let l = &fm.layers[li];
440            [l.gate.len(), l.up.len(), l.down.len()]
441        })
442        .collect();
443    let mut adam_b = Adam::new(&sizes, hy.lr_b);
444    let mut best_b: (f64, Option<Vec<Option<(Vec<f32>, Vec<f32>, Vec<f32>)>>>) = (masked, None);
445    for step in 0..hy.steps_b {
446        let chunk = &calib[step % calib.len()];
447        let mut dmask: Vec<Vec<f64>> = vec![vec![0.0; inter]; nl];
448        let mut dffn: Vec<Option<(Vec<f64>, Vec<f64>, Vec<f64>)>> = (0..nl)
449            .map(|li| {
450                ffn[li].as_ref().map(|(g, u, d)| {
451                    (vec![0.0; g.len()], vec![0.0; u.len()], vec![0.0; d.len()])
452                })
453            })
454            .collect();
455        let pass = Pass { fm: &fm, tau: hy.tau, logits: &logits, hard: true, ffn: &ffn };
456        let _ = pass.chunk(chunk, Some((&mut dmask, &mut dffn)));
457        // Cosine LR.
458        let lr_scale = 0.5 * (1.0 + (std::f64::consts::PI * step as f64 / hy.steps_b as f64).cos());
459        let first_fcd = fcd[0];
460        let mut params: Vec<&mut [f32]> = Vec::new();
461        let mut grads: Vec<Vec<f64>> = Vec::new();
462        for (off, slot) in ffn[first_fcd..].iter_mut().enumerate() {
463            let li = first_fcd + off;
464            let Some((g, u, d)) = slot.as_mut() else { continue };
465            let (dg, du, dd) = dffn[li].take().unwrap();
466            params.push(g.as_mut_slice());
467            grads.push(dg);
468            params.push(u.as_mut_slice());
469            grads.push(du);
470            params.push(d.as_mut_slice());
471            grads.push(dd);
472        }
473        adam_b.step(&mut params, &grads, lr_scale);
474        if (step + 1) % hy.eval_every == 0 {
475            let pass = Pass { fm: &fm, tau: hy.tau, logits: &logits, hard: true, ffn: &ffn };
476            let cur = held_ppl(&pass, &held);
477            if cur < best_b.0 {
478                best_b = (cur, Some(ffn.clone()));
479            }
480            log(&format!("  [B] step {}: held-PPL {cur:.3} (best {:.3})", step + 1, best_b.0));
481        }
482    }
483    if let Some(b) = best_b.1.take() {
484        ffn = b;
485    }
486    let overlaid = best_b.0;
487
488    // ── Export artifacts ──
489    let mut keep = Vec::with_capacity(nl);
490    let mut down_out = Vec::with_capacity(nl);
491    let mut gate_up = Vec::with_capacity(nl);
492    let mut kept_per_layer = Vec::with_capacity(nl);
493    for li in 0..nl {
494        let alive: Vec<bool> = logits[li].iter().map(|&x| sigmoid(x) > hy.tau).collect();
495        kept_per_layer.push(alive.iter().filter(|&&a| a).count());
496        let l = &fm.layers[li];
497        let mut down = match &ffn[li] {
498            Some((_, _, d)) => d.clone(),
499            None => l.down.clone(),
500        };
501        let hsz = fm.hidden;
502        for r in 0..hsz {
503            for (c, &a) in alive.iter().enumerate() {
504                if !a {
505                    down[r * inter + c] = 0.0;
506                }
507            }
508        }
509        gate_up.push(ffn[li].as_ref().map(|(g, u, _)| (g.clone(), u.clone())));
510        down_out.push(down);
511        keep.push(alive);
512    }
513    let total: usize = kept_per_layer.iter().sum();
514    let report = BakeReport {
515        backbone,
516        masked,
517        overlaid,
518        pruned_ratio: 1.0 - total as f64 / (nl * inter) as f64,
519        kept_per_layer,
520        sec: t0.elapsed().as_secs_f64(),
521    };
522    let arts = BakeArtifacts { keep, down: down_out, gate_up, fcd_layers: fcd };
523    Ok((report, arts))
524}