Skip to main content

cortiq_engine/
fcd.rs

1//! FCD polish trainer — native Rust quality-polish for O(1)-converted
2//! models (docs/RUST_FCD.md). Removes the last Python dependency from
3//! the `cortiq convert --o1` pipeline.
4//!
5//! Certified recipe (torch reference `nystrom_fcd_full2_06b.py`,
6//! Qwen3-0.6B 28/28): train ONLY the LN gains + FFN of the converted
7//! layers, loss = (1−0.7)·CE + 0.7·KL(teacher‖student), AdamW 5e-5
8//! (torch defaults), grad clip 1.0, batch 2×512 fresh random windows,
9//! quick deterministic val every 25 steps, restore the best checkpoint.
10//!
11//! Structure:
12//! - the whole model is dequantized to f32 once; teacher and student
13//!   SHARE the frozen set, trainables get separate master copies (the
14//!   KL anchor never drifts);
15//! - layer-level activation checkpointing: the forward keeps only each
16//!   layer's input hidden; the backward re-runs one layer at a time;
17//! - converted layers use the certified matrix form of the Nyström
18//!   joint kernel in f64 (fcd_ops), M constant in backward;
19//! - GDN (GatedDeltaNet) layers of Qwen3.5-class hybrids run frozen in
20//!   BOTH teacher and student, with a true BPTT through-backward
21//!   (fcd_ops::gdn_*) so trainable layers BELOW them still learn;
22//!   vmf_phase linear layers are refused (no backward yet).
23
24use crate::fcd_ops::{self as ops, NysCfg};
25use crate::nystrom::{O1Cfg, O1Layers};
26use crate::pipeline::{DenseFfn, FfnKind, Pipeline};
27use crate::pool::Pool;
28use crate::qtensor::QTensor;
29use crate::sampler::{SamplerConfig, SplitMix64};
30use cortiq_core::{CmfModel, LayerType, NormStyle, TensorDtype};
31use std::sync::Arc;
32
33/// Position-chunk size for the tied-head loss (never materializes the
34/// full [B·T, vocab] logits of teacher AND student together).
35const LM_CHUNK: usize = 32;
36
37/// AdamW hyper-parameters — torch defaults, part of the certified recipe.
38const ADAM_B1: f64 = 0.9;
39const ADAM_B2: f64 = 0.999;
40const ADAM_EPS: f64 = 1e-8;
41const ADAM_WD: f64 = 0.01;
42
43/// Training hyper-parameters (defaults = the certified recipe).
44#[derive(Clone, Debug)]
45pub struct FcdHyper {
46    pub steps: usize,
47    pub lr: f64,
48    pub kl_w: f64,
49    pub eval_every: usize,
50    pub bs: usize,
51    pub seq: usize,
52    pub seed: u64,
53}
54
55impl Default for FcdHyper {
56    fn default() -> Self {
57        Self {
58            steps: 300,
59            lr: 5e-5,
60            kl_w: 0.7,
61            eval_every: 25,
62            bs: 2,
63            seq: 512,
64            seed: 0,
65        }
66    }
67}
68
69/// What the polish measured — written into `provenance.fcd` and
70/// reported by the CLI.
71#[derive(Clone, Debug)]
72pub struct FcdReport {
73    pub converted: Vec<usize>,
74    /// Teacher (exact attention) quick-val ppl — the anchor.
75    pub teacher_ppl: f64,
76    /// Student quick-val ppl BEFORE training (zero-shot o1 damage).
77    pub ppl_start: f64,
78    /// Best quick-val ppl during training (the restored checkpoint).
79    pub ppl_best: f64,
80    pub best_step: usize,
81    /// Final val ppl of the restored checkpoint on the wider window set.
82    pub ppl_final: f64,
83    pub steps_run: usize,
84    pub sec_per_step: f64,
85    /// Per-step (ce, kl) — the training trajectory, unweighted.
86    pub losses: Vec<(f64, f64)>,
87    /// Generation-gate record (None = ppl-only selection).
88    pub gate: Option<GateReport>,
89}
90
91/// What the generation gate saw and decided.
92#[derive(Clone, Debug)]
93pub struct GateReport {
94    /// Zero-shot (step-0) loop scores per prompt — the baseline.
95    pub baseline: Vec<f64>,
96    /// Per eval checkpoint: (step, val ppl, loop scores, passed).
97    pub evals: Vec<(usize, f64, Vec<f64>, bool)>,
98    /// Step whose params were restored (None = identity: the polish
99    /// was rejected, the artifact carries the zero-shot state).
100    pub chosen: Option<usize>,
101}
102
103// ───────────────────── generation gate (claim 13) ─────────────────────
104
105/// Loopiness of a generated id sequence: 1 − unique 4-grams / total.
106/// 0 = no repeated 4-gram; near 1 = a tight loop.
107pub fn loop_score(ids: &[u32]) -> f64 {
108    if ids.len() < 5 {
109        return 0.0;
110    }
111    let grams: std::collections::HashSet<&[u32]> = ids.windows(4).collect();
112    1.0 - grams.len() as f64 / ids.windows(4).count() as f64
113}
114
115/// Generation-gate configuration (Patent 16 draft, claim 13:
116/// checkpoint selection gated on generation-behavior metrics measured
117/// through the SERVED kernel, not on the training objective alone).
118#[derive(Clone, Debug)]
119pub struct GenGateCfg {
120    /// Fixed long-context prompts (token ids), greedy-decoded at every
121    /// eval checkpoint.
122    pub prompts: Vec<Vec<u32>>,
123    pub gen_tokens: usize,
124    /// A checkpoint fails if ANY prompt's loop score exceeds this.
125    pub threshold: f64,
126    /// …or exceeds its zero-shot baseline by more than this.
127    pub baseline_slack: f64,
128}
129
130impl GenGateCfg {
131    /// The standard 3-prompt probe of the torch reference: 400-token
132    /// windows at L/10, L/2, 8L/10 of the val stream, greedy 60.
133    pub fn standard(va: &[u32]) -> Option<Self> {
134        let l = va.len().saturating_sub(500);
135        if l < 400 {
136            return None;
137        }
138        let prompts = [l / 10, l / 2, 8 * l / 10]
139            .iter()
140            .map(|&off| va[off..off + 400].to_vec())
141            .collect();
142        Some(Self {
143            prompts,
144            gen_tokens: 60,
145            threshold: 0.35,
146            baseline_slack: 0.10,
147        })
148    }
149}
150
151/// Gate predicate (Patent 16 draft, claim 13): a checkpoint PASSES iff
152/// no prompt's loop score exceeds `threshold` AND none exceeds its
153/// zero-shot baseline by more than `slack` — boundary values pass.
154pub fn gate_pass(scores: &[f64], baseline: &[f64], threshold: f64, slack: f64) -> bool {
155    scores
156        .iter()
157        .zip(baseline)
158        .all(|(&s, &b)| s <= threshold && s <= b + slack)
159}
160
161/// Checkpoint selection: lowest val ppl AMONG GATE-PASSING checkpoints
162/// (ties → earliest). None = nothing passed → the caller must restore
163/// the zero-shot state (identity polish): the stage must never make
164/// generation worse than conversion alone. (Patent 16 draft, claim 13.)
165pub fn select_checkpoint(
166    evals: &[(usize, f64, Vec<f64>)],
167    baseline: &[f64],
168    threshold: f64,
169    slack: f64,
170) -> Option<usize> {
171    let mut best: Option<usize> = None;
172    for (i, (_, ppl, scores)) in evals.iter().enumerate() {
173        if !gate_pass(scores, baseline, threshold, slack) {
174            continue;
175        }
176        if best.map(|b| *ppl < evals[b].1).unwrap_or(true) {
177            best = Some(i);
178        }
179    }
180    best
181}
182
183// ───────────────────────── model container ─────────────────────────
184
185/// Wall-clock phase counters for the bake profiler (CMF_BAKE_PROF=1
186/// prints them). Written from worker threads, so atomics; nanoseconds.
187pub mod prof {
188    use std::sync::atomic::{AtomicU64, Ordering};
189    pub static ATTN_FWD: AtomicU64 = AtomicU64::new(0);
190    pub static FFN_FWD: AtomicU64 = AtomicU64::new(0);
191    pub static BWD: AtomicU64 = AtomicU64::new(0);
192    pub static GEMM: AtomicU64 = AtomicU64::new(0);
193    pub static GEMM_CALLS: AtomicU64 = AtomicU64::new(0);
194    #[inline]
195    pub fn add(c: &AtomicU64, t: std::time::Instant) {
196        c.fetch_add(t.elapsed().as_nanos() as u64, Ordering::Relaxed);
197    }
198    /// Read and reset, in seconds.
199    pub fn take(c: &AtomicU64) -> f64 {
200        c.swap(0, Ordering::Relaxed) as f64 / 1e9
201    }
202
203    use std::sync::Mutex;
204    /// (n, k, m) → (calls, ns). A handful of shapes dominate a training
205    /// step; naming them decides which ones are worth a GPU submit and
206    /// which are cheaper computed in place.
207    pub static SHAPES: Mutex<Vec<((usize, usize, usize), (u64, u64))>> = Mutex::new(Vec::new());
208    pub fn gemm_shape(n: usize, k: usize, m: usize, t0: std::time::Instant) {
209        let ns = t0.elapsed().as_nanos() as u64;
210        let mut g = SHAPES.lock().unwrap();
211        match g.iter_mut().find(|(s, _)| *s == (n, k, m)) {
212            Some((_, (c, tt))) => {
213                *c += 1;
214                *tt += ns;
215            }
216            None => g.push(((n, k, m), (1, ns))),
217        }
218    }
219    pub fn shape_report(top: usize) -> String {
220        let mut g = SHAPES.lock().unwrap();
221        g.sort_by_key(|(_, (_, ns))| std::cmp::Reverse(*ns));
222        let out = g
223            .iter()
224            .take(top)
225            .map(|((n, k, m), (c, ns))| {
226                format!(
227                    "    [{n}x{k}x{m}] {c} calls, {:.1}s total, {:.1} ms/call",
228                    *ns as f64 / 1e9,
229                    *ns as f64 / 1e6 / *c as f64
230                )
231            })
232            .collect::<Vec<_>>()
233            .join("\n");
234        g.clear();
235        out
236    }
237}
238
239/// Frozen attention operator of one layer — the per-layer dispatch
240/// point for through-backwards (docs/RUST_FCD.md §3).
241enum FcdAttn {
242    Full {
243        /// The projections live in the streamed `LayerMats`, not here:
244        /// eagerly resident weights are what made a 4 B bake swap a
245        /// laptop. Only the small per-head metadata stays.
246        qrows: usize,
247        q_norm: Option<Vec<f32>>,
248        k_norm: Option<Vec<f32>>,
249        bias: Option<(Vec<f32>, Vec<f32>, Vec<f32>)>,
250        /// (bq|bk|bv) concatenated once at build: the device chain binds
251        /// it by a STABLE address, which is what keeps the fingerprinted
252        /// const cache from minting an entry per call.
253        bias_cat: Option<Vec<f32>>,
254        /// Qwen3.5: wq rows = 2·nh·hd, per-head [q; gate]; the head
255        /// outputs are multiplied by σ(gate) before o_proj.
256        output_gate: bool,
257    },
258    /// GatedDeltaNet (Qwen3.5 hybrids): never converted, never trained;
259    /// through-backward = BPTT over the window (fcd_ops::gdn_*).
260    Gdn {
261        wqkv: Vec<f32>,
262        wz: Vec<f32>,
263        wa: Vec<f32>,
264        wb: Vec<f32>,
265        conv: Vec<f32>,
266        a_log: Vec<f32>,
267        dt_bias: Vec<f32>,
268        norm: Vec<f32>,
269        wout: Vec<f32>,
270    },
271}
272
273pub(crate) struct FcdLayer {
274    attn: FcdAttn,
275    pub(crate) inter: usize,
276    // Frozen originals: the teacher's LN (small, eager). The big FFN
277    // matrices stream through `LayerMats`.
278    pub(crate) iln: Vec<f32>,
279    pub(crate) pln: Vec<f32>,
280}
281
282/// GDN geometry shared by every linear layer (arch.linear_* fields).
283#[derive(Clone, Copy)]
284struct GdnDims {
285    nv: usize,
286    nk: usize,
287    dk: usize,
288    dv: usize,
289    kk: usize,
290}
291
292impl GdnDims {
293    fn c_dim(&self) -> usize {
294        2 * self.nk * self.dk + self.nv * self.dv
295    }
296    fn vd(&self) -> usize {
297        self.nv * self.dv
298    }
299}
300
301/// The f32 training replica of a .cmf model (≤ 1B targets).
302pub struct FcdModel {
303    pub hidden: usize,
304    pub nh: usize,
305    pub nkv: usize,
306    pub hd: usize,
307    pub nl: usize,
308    pub vocab: usize,
309    pub(crate) eps: f64,
310    pub(crate) gemma: bool,
311    rotary_dim: usize,
312    inv_freq: Vec<f64>,
313    /// [vocab, hidden]; also the tied head when `lm_head` is None.
314    pub(crate) embed: Vec<f32>,
315    pub(crate) lm_head: Option<Vec<f32>>,
316    pub(crate) final_norm: Vec<f32>,
317    pub(crate) layers: Vec<FcdLayer>,
318    /// The container the replica came from — the streaming mats
319    /// dequantize from its mmap on demand.
320    pub(crate) src: std::sync::Arc<CmfModel>,
321    /// Per-layer big-matrix cache: filled on touch, evicted outside the
322    /// window. The f32 replica used to hold every matrix of every layer
323    /// eagerly — 16.7 GB for a 4 B model before the fused concats, ~25
324    /// after — which a 24 GB laptop answered with ten minutes of swap.
325    mats_cache: Vec<std::sync::Mutex<Option<std::sync::Arc<LayerMats>>>>,
326    /// cos/sin table for the device attention chain, keyed by t. Arc so
327    /// the underlying address stays STABLE across calls — the GPU const
328    /// cache dedups uploads by (pointer, fingerprint).
329    rope_tab: std::sync::Mutex<Option<(usize, std::sync::Arc<Vec<f32>>)>>,
330    /// How many layers stay resident (0 = all, big machines).
331    mats_window: usize,
332    /// Which layers run the Nyström kernel in the student forward.
333    o1_flags: Vec<bool>,
334    nys: NysCfg,
335    /// GDN geometry (present when the model has linear layers).
336    gdn: Option<GdnDims>,
337    /// Looped Transformer: how many times the layer stack runs per
338    /// token, and whether a final_norm is inserted at each loop
339    /// boundary. A replica that ignores these computes a DIFFERENT
340    /// model — on Nanbeige 4.2 (22 layers x 2 loops) the single-pass
341    /// forward measured perplexity 4773 where the engine reads 16.5,
342    /// and every gradient taken through it was fitted to that fiction.
343    pub(crate) loops: usize,
344    pub(crate) loop_norm: bool,
345    pub(crate) pool: Option<Arc<Pool>>,
346}
347
348fn deq(model: &CmfModel, name: &str) -> Result<Vec<f32>, String> {
349    let e = model
350        .tensor(name)
351        .ok_or_else(|| format!("tensor '{name}' not found"))?;
352    let mut out = vec![0f32; e.n_elems()];
353    cortiq_core::quant::dequant_tensor(e, model.entry_bytes(e), &mut out)?;
354    Ok(out)
355}
356
357/// Physical RAM total, for the hard replica ceiling.
358fn physical_total_bytes() -> Option<u64> {
359    #[cfg(target_os = "macos")]
360    {
361        let out = std::process::Command::new("sysctl")
362            .args(["-n", "hw.memsize"])
363            .output()
364            .ok()?;
365        return String::from_utf8_lossy(&out.stdout).trim().parse().ok();
366    }
367    #[cfg(target_os = "linux")]
368    {
369        let mem = std::fs::read_to_string("/proc/meminfo").ok()?;
370        let kb: u64 = mem
371            .lines()
372            .find(|l| l.starts_with("MemTotal:"))?
373            .split_whitespace()
374            .nth(1)?
375            .parse()
376            .ok()?;
377        return Some(kb * 1024);
378    }
379    #[allow(unreachable_code)]
380    None
381}
382
383/// AVAILABLE RAM in bytes, best effort — used only to refuse a bake
384/// whose f32 replica would swap instead of run.
385fn available_ram_bytes() -> Option<u64> {
386    #[cfg(target_os = "macos")]
387    {
388        // free% × total from the same counters the memory-pressure
389        // subsystem uses; hw.memsize alone lied by whatever the other
390        // apps were holding.
391        let total: u64 = {
392            let out = std::process::Command::new("sysctl")
393                .args(["-n", "hw.memsize"])
394                .output()
395                .ok()?;
396            String::from_utf8_lossy(&out.stdout).trim().parse().ok()?
397        };
398        let out = std::process::Command::new("memory_pressure")
399            .arg("-Q")
400            .output()
401            .ok()?;
402        let text = String::from_utf8_lossy(&out.stdout);
403        let pct: u64 = text
404            .lines()
405            .find(|l| l.contains("free percentage"))?
406            .split(':')
407            .nth(1)?
408            .trim()
409            .trim_end_matches('%')
410            .parse()
411            .ok()?;
412        return Some(total / 100 * pct);
413    }
414    #[cfg(target_os = "linux")]
415    {
416        let mem = std::fs::read_to_string("/proc/meminfo").ok()?;
417        let kb: u64 = mem
418            .lines()
419            .find(|l| l.starts_with("MemAvailable:"))?
420            .split_whitespace()
421            .nth(1)?
422            .parse()
423            .ok()?;
424        return Some(kb * 1024);
425    }
426    #[allow(unreachable_code)]
427    None
428}
429
430/// `deq` for sibling modules (the bake's Phase-B masters dequant once,
431/// straight from the source container).
432pub(crate) fn deq_pub(model: &CmfModel, name: &str) -> Result<Vec<f32>, String> {
433    deq(model, name)
434}
435
436/// The four big matrices the hot path reads for one layer, dequantized
437/// together: the fused attention projection, the attention output, the
438/// fused FFN gate+up, and the FFN down. Everything else a layer owns —
439/// norms, biases, GDN cores — is small and stays eagerly resident.
440pub(crate) struct LayerMats {
441    pub(crate) wqkv: Vec<f32>,
442    pub(crate) wo: Vec<f32>,
443    pub(crate) gu: Vec<f32>,
444    pub(crate) down: Vec<f32>,
445}
446
447impl FcdModel {
448    /// The layer's big matrices, dequantized on first touch. Sequential
449    /// sweeps (forward 0..nl, backward nl..0) hit the same physical
450    /// layer at most twice per virtual pass, so a small window streams
451    /// a model far bigger than RAM without swapping.
452    pub(crate) fn mats(&self, li: usize) -> Result<std::sync::Arc<LayerMats>, String> {
453        {
454            let slot = self.mats_cache[li].lock().unwrap();
455            if let Some(m) = slot.as_ref() {
456                return Ok(m.clone());
457            }
458        }
459        let p = format!("model.layers.{li}.");
460        let d = |name: String| -> Result<Vec<f32>, String> { deq(&self.src, &name) };
461        let built = if matches!(self.layers[li].attn, FcdAttn::Full { .. }) {
462            let wq = d(format!("{p}self_attn.q_proj.weight"))?;
463            let wk = d(format!("{p}self_attn.k_proj.weight"))?;
464            let wv = d(format!("{p}self_attn.v_proj.weight"))?;
465            let mut wqkv = Vec::with_capacity(wq.len() + wk.len() + wv.len());
466            wqkv.extend_from_slice(&wq);
467            wqkv.extend_from_slice(&wk);
468            wqkv.extend_from_slice(&wv);
469            let gate = d(format!("{p}mlp.gate_proj.weight"))?;
470            let up = d(format!("{p}mlp.up_proj.weight"))?;
471            let mut gu = Vec::with_capacity(gate.len() + up.len());
472            gu.extend_from_slice(&gate);
473            gu.extend_from_slice(&up);
474            LayerMats {
475                wqkv,
476                wo: d(format!("{p}self_attn.o_proj.weight"))?,
477                gu,
478                down: d(format!("{p}mlp.down_proj.weight"))?,
479            }
480        } else {
481            // GDN layers keep their attention core eagerly; only the FFN
482            // streams.
483            let gate = d(format!("{p}mlp.gate_proj.weight"))?;
484            let up = d(format!("{p}mlp.up_proj.weight"))?;
485            let mut gu = Vec::with_capacity(gate.len() + up.len());
486            gu.extend_from_slice(&gate);
487            gu.extend_from_slice(&up);
488            LayerMats {
489                wqkv: Vec::new(),
490                wo: Vec::new(),
491                gu,
492                down: d(format!("{p}mlp.down_proj.weight"))?,
493            }
494        };
495        let arc = std::sync::Arc::new(built);
496        *self.mats_cache[li].lock().unwrap() = Some(arc.clone());
497        // Evict outside the window — cheap, and the access pattern is
498        // sequential, so the evicted slots are exactly the cold ones.
499        if self.mats_window > 0 {
500            let w = self.mats_window;
501            for (j, slot) in self.mats_cache.iter().enumerate() {
502                let dist = li.abs_diff(j).min(self.layers.len() - li.abs_diff(j));
503                if dist > w {
504                    *slot.lock().unwrap() = None;
505                }
506            }
507        }
508        Ok(arc)
509    }
510}
511
512impl FcdModel {
513    /// Dequantize a model into the f32 training replica. Refuses what
514    /// the backward cannot honestly differentiate yet (loud, not silent).
515    pub fn from_cmf(model: &std::sync::Arc<CmfModel>, o1: &O1Cfg) -> Result<Self, String> {
516        let arch = model.arch().clone();
517        if arch.hidden_act != "silu" {
518            return Err(format!(
519                "fcd/skill-bake: hidden_act '{}' not supported yet (SiLU only)",
520                arch.hidden_act
521            ));
522        }
523        let has_linear = arch
524            .layer_types
525            .iter()
526            .any(|t| matches!(t, LayerType::LinearAttention));
527        let gdn = if has_linear {
528            let lc = arch
529                .linear_core
530                .as_ref()
531                .ok_or_else(|| "model has linear layers but no arch.linear_core".to_string())?;
532            if lc.kind != "gated_delta_net" {
533                return Err(format!(
534                    "linear core '{}' has no FCD backward (only gated_delta_net)",
535                    lc.kind
536                ));
537            }
538            Some(GdnDims {
539                nv: lc.num_heads,
540                nk: arch
541                    .linear_num_key_heads
542                    .ok_or("linear core needs arch.linear_num_key_heads")?,
543                dk: arch
544                    .linear_key_head_dim
545                    .ok_or("linear core needs arch.linear_key_head_dim")?,
546                dv: lc.value_head_dim,
547                kk: arch
548                    .linear_conv_kernel_dim
549                    .ok_or("linear core needs arch.linear_conv_kernel_dim")?,
550            })
551        } else {
552            None
553        };
554        let (nh, nkv, hd, h) = (
555            arch.num_attention_heads,
556            arch.num_kv_heads,
557            arch.head_dim,
558            arch.hidden_size,
559        );
560        let embed = deq(model, "model.embed_tokens.weight")?;
561        let lm_head = if model.tensor("lm_head.weight").is_some() {
562            Some(deq(model, "lm_head.weight")?)
563        } else if arch.tie_word_embeddings {
564            None
565        } else {
566            return Err("no lm_head.weight and tie_word_embeddings is false".into());
567        };
568        let final_norm = deq(model, "model.norm.weight")?;
569
570        let mut layers = Vec::with_capacity(arch.num_layers);
571        for li in 0..arch.num_layers {
572            let p = format!("model.layers.{li}.");
573            if model.tensor(&format!("{p}mlp.gate.weight")).is_some() {
574                return Err(format!(
575                    "layer {li} is MoE — FCD polish supports dense FFN only"
576                ));
577            }
578            let attn = match arch.layer_types.get(li) {
579                Some(LayerType::LinearAttention) => {
580                    let la = |n: &str| deq(model, &format!("{p}linear_attn.{n}"));
581                    FcdAttn::Gdn {
582                        wqkv: la("in_proj_qkv.weight")?,
583                        wz: la("in_proj_z.weight")?,
584                        wa: la("in_proj_a.weight")?,
585                        wb: la("in_proj_b.weight")?,
586                        conv: la("conv1d.weight")?,
587                        a_log: la("A_log")?,
588                        dt_bias: la("dt_bias")?,
589                        norm: la("norm.weight")?,
590                        wout: la("out_proj.weight")?,
591                    }
592                }
593                _ => {
594                    let wq = deq(model, &format!("{p}self_attn.q_proj.weight"))?;
595                    let output_gate = wq.len() == 2 * nh * hd * h;
596                    let opt = |n: &str| -> Option<Vec<f32>> {
597                        model
598                            .tensor(&format!("{p}self_attn.{n}"))
599                            .and_then(|_| deq(model, &format!("{p}self_attn.{n}")).ok())
600                    };
601                    let bias = match (opt("q_proj.bias"), opt("k_proj.bias"), opt("v_proj.bias")) {
602                        (Some(a), Some(b), Some(c)) => Some((a, b, c)),
603                        _ => None,
604                    };
605                    let qrows = wq.len() / h;
606                    drop(wq);
607                    let bias_cat = bias.as_ref().map(|(bq, bk, bv)| {
608                        let mut v = Vec::with_capacity(bq.len() + bk.len() + bv.len());
609                        v.extend_from_slice(bq);
610                        v.extend_from_slice(bk);
611                        v.extend_from_slice(bv);
612                        v
613                    });
614                    FcdAttn::Full {
615                        qrows,
616                        q_norm: opt("q_norm.weight"),
617                        k_norm: opt("k_norm.weight"),
618                        bias,
619                        bias_cat,
620                        output_gate,
621                    }
622                }
623            };
624            let gate = deq(model, &format!("{p}mlp.gate_proj.weight"))?;
625            let inter = gate.len() / h;
626            drop(gate);
627            layers.push(FcdLayer {
628                attn,
629                inter,
630                iln: deq(model, &format!("{p}input_layernorm.weight"))?,
631                pln: deq(model, &format!("{p}post_attention_layernorm.weight"))?,
632            });
633        }
634
635        let rotary_dim = ((hd as f32 * arch.partial_rotary_factor) as usize)
636            .max(2)
637            .min(hd);
638        let base = arch.rope_theta;
639        let inv_freq: Vec<f64> = (0..rotary_dim / 2)
640            .map(|i| 1.0 / base.powf(2.0 * i as f64 / rotary_dim as f64))
641            .collect();
642        let loops = arch.num_loops.max(1);
643        let loop_norm = arch.loop_final_norm;
644        let mut flags = o1.layer_flags(arch.num_layers);
645        flags.resize(arch.num_layers, false);
646        // Only full-attention layers are o1-convertible (same rule as
647        // Pipeline::set_o1) — a GDN layer keeps its own operator.
648        for (li, f) in flags.iter_mut().enumerate() {
649            if *f && !matches!(layers[li].attn, FcdAttn::Full { .. }) {
650                *f = false;
651            }
652        }
653        // Streaming window: how many layers' big matrices stay resident.
654        // Sized from available memory; CMF_BAKE_MATS_LAYERS overrides.
655        // 0 = everything resident (big machines keep today's speed).
656        let per_layer: u64 = layers
657            .first()
658            .map(|_| {
659                let qrows = match layers[0].attn {
660                    FcdAttn::Full { qrows, .. } => qrows,
661                    _ => 0,
662                };
663                ((qrows + 2 * (nkv * hd)) as u64 * h as u64      // wqkv
664                    + (h as u64 * nh as u64 * hd as u64)          // wo
665                    + 3 * (layers[0].inter as u64 * h as u64))    // gu + down
666                    * 4
667            })
668            .unwrap_or(0);
669        let mats_window = if let Ok(v) = std::env::var("CMF_BAKE_MATS_LAYERS") {
670            v.parse().unwrap_or(0)
671        } else {
672            let avail = available_ram_bytes().unwrap_or(u64::MAX);
673            let cap = physical_total_bytes()
674                .map(|t| t / 5 * 3)
675                .unwrap_or(u64::MAX)
676                .min(avail.saturating_sub(avail / 10));
677            let all = per_layer.saturating_mul(arch.num_layers as u64);
678            if all + 4 * 1024 * 1024 * 1024 <= cap {
679                0 // everything fits with headroom — stay eager-equivalent
680            } else {
681                let w = (cap.saturating_sub(3 * 1024 * 1024 * 1024) / per_layer.max(1))
682                    .clamp(2, arch.num_layers as u64) as usize;
683                tracing::info!(
684                    "fcd replica: streaming {w} of {} layers (~{:.1} GB resident of ~{:.1} GB total)",
685                    arch.num_layers,
686                    (w as u64 * per_layer) as f64 / 1e9,
687                    all as f64 / 1e9,
688                );
689                w
690            }
691        };
692        let mats_cache = (0..arch.num_layers)
693            .map(|_| std::sync::Mutex::new(None))
694            .collect();
695        Ok(Self {
696            src: model.clone(),
697            mats_cache,
698            rope_tab: std::sync::Mutex::new(None),
699            mats_window,
700            hidden: h,
701            nh,
702            nkv,
703            hd,
704            nl: arch.num_layers,
705            vocab: arch.vocab_size.min(embed.len() / h),
706            eps: arch.rms_norm_eps,
707            gemma: matches!(arch.norm_style, NormStyle::Gemma),
708            rotary_dim,
709            inv_freq,
710            embed,
711            lm_head,
712            final_norm,
713            layers,
714            o1_flags: flags,
715            // prefill: None = half the window, the same seal point
716            // `cortiq ppl --o1` defaults to (see NysCfg::prefill).
717            nys: NysCfg {
718                m: o1.m,
719                w: o1.w,
720                sink: o1.sink,
721                prefill: None,
722            },
723            gdn,
724            loops,
725            loop_norm,
726            pool: Pool::from_env(),
727        })
728    }
729
730    /// Converted (trainable) layer indices.
731    pub fn converted(&self) -> Vec<usize> {
732        (0..self.nl).filter(|&i| self.o1_flags[i]).collect()
733    }
734
735    fn head_weight(&self) -> &[f32] {
736        self.lm_head.as_deref().unwrap_or(&self.embed)
737    }
738}
739
740// ───────────────────────── trainable state ─────────────────────────
741
742/// Per converted layer, in this fixed order.
743const PARAMS_PER_LAYER: usize = 5; // iln, pln, gate, up, down
744
745/// Master copies + grads + AdamW moments of the trainable tensors.
746pub struct TrainState {
747    pub layers: Vec<usize>,
748    /// layers.len()·5 tensors, layer-major, [iln, pln, gate, up, down].
749    pub data: Vec<Vec<f32>>,
750    grad: Vec<Vec<f32>>,
751    m1: Vec<Vec<f32>>,
752    m2: Vec<Vec<f32>>,
753    step_t: u64,
754}
755
756impl TrainState {
757    pub fn new(fm: &FcdModel) -> Self {
758        let layers = fm.converted();
759        let mut data = Vec::with_capacity(layers.len() * PARAMS_PER_LAYER);
760        for &li in &layers {
761            let l = &fm.layers[li];
762            let p = format!("model.layers.{li}.");
763            data.push(l.iln.clone());
764            data.push(l.pln.clone());
765            // Trained masters dequant once, straight from the source —
766            // the streamed cache holds only the fused hot-path concats.
767            data.push(deq(&fm.src, &format!("{p}mlp.gate_proj.weight")).expect("gate"));
768            data.push(deq(&fm.src, &format!("{p}mlp.up_proj.weight")).expect("up"));
769            data.push(deq(&fm.src, &format!("{p}mlp.down_proj.weight")).expect("down"));
770        }
771        let zeros: Vec<Vec<f32>> = data.iter().map(|d| vec![0f32; d.len()]).collect();
772        Self {
773            layers,
774            grad: zeros.clone(),
775            m1: zeros.clone(),
776            m2: zeros,
777            data,
778            step_t: 0,
779        }
780    }
781
782    fn slot(&self, li: usize) -> Option<usize> {
783        self.layers.iter().position(|&x| x == li)
784    }
785
786    /// Read access to the accumulated gradients (gradcheck harness).
787    #[doc(hidden)]
788    pub fn grads(&self) -> &[Vec<f32>] {
789        &self.grad
790    }
791
792    fn zero_grad(&mut self) {
793        for g in &mut self.grad {
794            for v in g.iter_mut() {
795                *v = 0.0;
796            }
797        }
798    }
799
800    /// Global-norm clip (1.0) + one AdamW step (torch defaults,
801    /// decoupled weight decay).
802    fn clip_and_step(&mut self, lr: f64) -> f64 {
803        let mut sq = 0f64;
804        for g in &self.grad {
805            for &v in g {
806                sq += (v as f64) * (v as f64);
807            }
808        }
809        let gn = sq.sqrt();
810        let scale = if gn > 1.0 { 1.0 / (gn + 1e-6) } else { 1.0 };
811        self.step_t += 1;
812        let bc1 = 1.0 - ADAM_B1.powi(self.step_t as i32);
813        let bc2 = 1.0 - ADAM_B2.powi(self.step_t as i32);
814        for p in 0..self.data.len() {
815            let (d, g, m, v) = (
816                &mut self.data[p],
817                &self.grad[p],
818                &mut self.m1[p],
819                &mut self.m2[p],
820            );
821            for i in 0..d.len() {
822                let gi = g[i] as f64 * scale;
823                let mi = ADAM_B1 * m[i] as f64 + (1.0 - ADAM_B1) * gi;
824                let vi = ADAM_B2 * v[i] as f64 + (1.0 - ADAM_B2) * gi * gi;
825                m[i] = mi as f32;
826                v[i] = vi as f32;
827                let upd = (mi / bc1) / ((vi / bc2).sqrt() + ADAM_EPS) + ADAM_WD * d[i] as f64;
828                d[i] = (d[i] as f64 - lr * upd) as f32;
829            }
830        }
831        gn
832    }
833}
834
835/// LN/FFN weight view of one layer — frozen originals for the teacher
836/// (and non-converted student layers), master copies for trainables.
837#[derive(Clone, Copy)]
838pub(crate) struct LnFfn<'a> {
839    pub(crate) iln: &'a [f32],
840    pub(crate) pln: &'a [f32],
841    pub(crate) gate: &'a [f32],
842    pub(crate) up: &'a [f32],
843    pub(crate) down: &'a [f32],
844    /// `[gate; up]` rows concatenated — one GEMM submit instead of two.
845    /// `None` for Phase-B trained copies, whose rows move every step;
846    /// the frozen majority carries the concat prebuilt at load.
847    pub(crate) gu: Option<&'a [f32]>,
848}
849
850fn ln_ffn<'a>(
851    fm: &'a FcdModel,
852    ts: Option<&'a TrainState>,
853    li: usize,
854    mats: &'a LayerMats,
855) -> LnFfn<'a> {
856    if let Some(t) = ts {
857        if let Some(s) = t.slot(li) {
858            let b = s * PARAMS_PER_LAYER;
859            return LnFfn {
860                iln: &t.data[b],
861                pln: &t.data[b + 1],
862                gate: &t.data[b + 2],
863                up: &t.data[b + 3],
864                down: &t.data[b + 4],
865                gu: None,
866            };
867        }
868    }
869    let l = &fm.layers[li];
870    LnFfn {
871        iln: &l.iln,
872        pln: &l.pln,
873        // The fused concat carries both projections; the fallback slots
874        // are never read while `gu` is Some.
875        gate: &[],
876        up: &[],
877        down: &mats.down,
878        gu: Some(&mats.gu),
879    }
880}
881
882// ───────────────────── layer forward (+ recompute) ─────────────────────
883
884/// Intra-layer activations rebuilt during the checkpointed backward.
885enum AttnActs {
886    Full {
887        qpre: Vec<f32>,
888        kpre: Vec<f32>,
889        vproj: Vec<f32>,
890        qrot: Vec<f32>,
891        krot: Vec<f32>,
892        qinv: Vec<f32>,
893        kinv: Vec<f32>,
894        /// Pre-gate per-head attention outputs (needed for the output
895        /// gate's backward); always kept — transient per layer.
896        ao: Vec<f32>,
897        /// Raw gate half of q_proj (empty without an output gate).
898        gate_pre: Vec<f32>,
899    },
900    /// Raw projection streams — the GDN backward replays conv + the
901    /// recurrence from these.
902    Gdn {
903        qkv: Vec<f32>,
904        z: Vec<f32>,
905        a: Vec<f32>,
906        b: Vec<f32>,
907    },
908}
909
910pub(crate) struct LayerActs {
911    inv1: Vec<f32>,
912    attn: AttnActs,
913    pub(crate) h1: Vec<f32>,
914    pub(crate) n2: Vec<f32>,
915    pub(crate) inv2: Vec<f32>,
916    pub(crate) gpre: Vec<f32>,
917    pub(crate) upre: Vec<f32>,
918    pub(crate) act: Vec<f32>,
919}
920
921/// Disjoint-write pointer for pooled per-head scatter (pipeline pattern).
922struct SendMut<T>(*mut T);
923unsafe impl<T> Send for SendMut<T> {}
924unsafe impl<T> Sync for SendMut<T> {}
925impl<T> SendMut<T> {
926    #[inline]
927    unsafe fn at(&self, i: usize) -> *mut T {
928        unsafe { self.0.add(i) }
929    }
930}
931
932impl FcdModel {
933    /// Per-head RMS-norm (qk-norm) + partial RoPE for all rows of a
934    /// projection buffer. `heads` per row, `x` is `[n, heads·hd]`.
935    /// Saves the per-(row, head) rms inv when a norm gain is present.
936    fn qk_norm_rope(
937        &self,
938        x: &mut [f32],
939        norm: Option<&[f32]>,
940        heads: usize,
941        t: usize,
942        inv_out: &mut [f32],
943    ) {
944        let hd = self.hd;
945        let n = x.len() / (heads * hd);
946        for r in 0..n {
947            let pos = r % t;
948            for hh in 0..heads {
949                let s = (r * heads + hh) * hd;
950                let head = &mut x[s..s + hd];
951                if let Some(w) = norm {
952                    let mut inv = [0f32; 1];
953                    let mut y = [0f32; 256];
954                    debug_assert!(hd <= 256);
955                    ops::rmsnorm_fwd(head, w, self.eps, self.gemma, &mut y[..hd], &mut inv);
956                    head.copy_from_slice(&y[..hd]);
957                    inv_out[r * heads + hh] = inv[0];
958                }
959                ops::rope_fwd(&mut head[..self.rotary_dim], pos, &self.inv_freq);
960            }
961        }
962    }
963
964    /// One layer forward over `b` sequences of length `t` (rows are
965    /// b-major). `nystrom` switches converted (Full) student layers to
966    /// the certified matrix kernel (f64 per head); exact heads run in
967    /// f32; GDN layers run the frozen BPTT-capable operator. Returns
968    /// (h_out, intra-layer activations when `want_acts`).
969    #[allow(clippy::too_many_arguments)]
970    fn layer_forward(
971        &self,
972        li: usize,
973        h_in: &[f32],
974        b: usize,
975        t: usize,
976        wts: &LnFfn,
977        nystrom: bool,
978        want_acts: bool,
979    ) -> (Vec<f32>, Option<LayerActs>) {
980        self.layer_forward_scaled(li, h_in, b, t, wts, nystrom, want_acts, None)
981    }
982
983    /// `layer_forward` with an optional per-neuron FFN activation scale
984    /// (the DTG-MA mask σ(m), Patent 2): `act·scale` feeds down_proj.
985    /// `LayerActs.act` stays PRE-scale so the mask backward can read it.
986    #[allow(clippy::too_many_arguments)]
987    pub(crate) fn layer_forward_scaled(
988        &self,
989        li: usize,
990        h_in: &[f32],
991        b: usize,
992        t: usize,
993        wts: &LnFfn,
994        nystrom: bool,
995        want_acts: bool,
996        ffn_scale: Option<&[f32]>,
997    ) -> (Vec<f32>, Option<LayerActs>) {
998        let hsz = self.hidden;
999        let n = b * t;
1000        let l = &self.layers[li];
1001        let pool = self.pool.as_deref();
1002
1003        let mut n1 = vec![0f32; n * hsz];
1004        let mut inv1 = vec![0f32; n];
1005        ops::rmsnorm_fwd(h_in, wts.iln, self.eps, self.gemma, &mut n1, &mut inv1);
1006
1007        let mats = self.mats(li).expect("layer mats");
1008        let t_attn = std::time::Instant::now();
1009        let (attn_out, attn_acts) = match &l.attn {
1010            FcdAttn::Full { .. } => self.full_attn_fwd(&l.attn, &mats, &n1, b, t, nystrom),
1011            FcdAttn::Gdn { .. } => self.gdn_attn_fwd(&l.attn, &n1, b, t),
1012        };
1013        prof::add(&prof::ATTN_FWD, t_attn);
1014        let t_ffn = std::time::Instant::now();
1015
1016        let mut h1 = h_in.to_vec();
1017        for (a, &x) in h1.iter_mut().zip(&attn_out) {
1018            *a += x;
1019        }
1020
1021        let mut n2 = vec![0f32; n * hsz];
1022        let mut inv2 = vec![0f32; n];
1023        ops::rmsnorm_fwd(&h1, wts.pln, self.eps, self.gemma, &mut n2, &mut inv2);
1024
1025        let inter = l.inter;
1026        let mut gpre = vec![0f32; n * inter];
1027        let mut upre = vec![0f32; n * inter];
1028        let mut act = vec![0f32; n * inter];
1029        let mut ffn = vec![0f32; n * hsz];
1030        // The frozen fused-gu FFN rides the whole chain on the device when
1031        // the tensor-core arm is up: one submit, and the gate+up plane
1032        // only crosses PCIe when the backward pass will need it. An eval
1033        // call moves three matrices and reads back one.
1034        let mut fused = false;
1035        #[cfg(feature = "gpu")]
1036        if let Some(gu) = wts.gu {
1037            if crate::gpu::enabled_here() {
1038                let mut both = want_acts.then(|| vec![0f32; n * 2 * inter]);
1039                if crate::gpu_wgpu::ffn_chain_f32(
1040                    &n2,
1041                    gu,
1042                    wts.down,
1043                    ffn_scale,
1044                    both.as_deref_mut(),
1045                    want_acts.then_some(li),
1046                    &mut ffn,
1047                    n,
1048                    hsz,
1049                    inter,
1050                ) {
1051                    fused = true;
1052                    if let Some(b) = &both {
1053                        for r in 0..n {
1054                            let row = &b[r * 2 * inter..(r + 1) * 2 * inter];
1055                            gpre[r * inter..(r + 1) * inter].copy_from_slice(&row[..inter]);
1056                            upre[r * inter..(r + 1) * inter].copy_from_slice(&row[inter..]);
1057                        }
1058                        // PRE-scale, as the mask backward expects.
1059                        for i in 0..n * inter {
1060                            act[i] = ops::silu(gpre[i]) * upre[i];
1061                        }
1062                    }
1063                }
1064            }
1065        }
1066        if !fused {
1067            if let Some(gu) = wts.gu {
1068                // One submit; split back so everything downstream is
1069                // byte-identical to the two-call path.
1070                let mut both = vec![0f32; n * 2 * inter];
1071                ops::gemm_nt(&n2, gu, &mut both, n, hsz, 2 * inter, pool);
1072                for r in 0..n {
1073                    let row = &both[r * 2 * inter..(r + 1) * 2 * inter];
1074                    gpre[r * inter..(r + 1) * inter].copy_from_slice(&row[..inter]);
1075                    upre[r * inter..(r + 1) * inter].copy_from_slice(&row[inter..]);
1076                }
1077            } else {
1078                ops::gemm_nt(&n2, wts.gate, &mut gpre, n, hsz, inter, pool);
1079                ops::gemm_nt(&n2, wts.up, &mut upre, n, hsz, inter, pool);
1080            }
1081            for i in 0..n * inter {
1082                act[i] = ops::silu(gpre[i]) * upre[i];
1083            }
1084            match ffn_scale {
1085                Some(g) => {
1086                    debug_assert_eq!(g.len(), inter);
1087                    let mut act2 = act.clone();
1088                    for r in 0..n {
1089                        for (a, &gv) in act2[r * inter..(r + 1) * inter].iter_mut().zip(g) {
1090                            *a *= gv;
1091                        }
1092                    }
1093                    ops::gemm_nt(&act2, wts.down, &mut ffn, n, inter, hsz, pool);
1094                }
1095                None => ops::gemm_nt(&act, wts.down, &mut ffn, n, inter, hsz, pool),
1096            }
1097        }
1098        let mut h2 = h1.clone();
1099        for (a, &x) in h2.iter_mut().zip(&ffn) {
1100            *a += x;
1101        }
1102
1103        let acts = want_acts.then_some(LayerActs {
1104            inv1,
1105            attn: attn_acts,
1106            h1,
1107            n2,
1108            inv2,
1109            gpre,
1110            upre,
1111            act,
1112        });
1113        prof::add(&prof::FFN_FWD, t_ffn);
1114        (h2, acts)
1115    }
1116
1117    /// Full-attention forward: projections (+optional biases), optional
1118    /// per-head [q; gate] split (Qwen3.5 output gate), qk-norm + RoPE,
1119    /// per-head exact-or-Nyström attention, σ(gate) multiply, o_proj.
1120    fn full_attn_fwd(
1121        &self,
1122        attn: &FcdAttn,
1123        mats: &LayerMats,
1124        n1: &[f32],
1125        b: usize,
1126        t: usize,
1127        nystrom: bool,
1128    ) -> (Vec<f32>, AttnActs) {
1129        let (wqkv, wo) = (&mats.wqkv[..], &mats.wo[..]);
1130        let FcdAttn::Full {
1131            qrows: _,
1132            q_norm,
1133            k_norm,
1134            bias,
1135            bias_cat,
1136            output_gate,
1137        } = attn
1138        else {
1139            unreachable!("full_attn_fwd on a non-Full layer");
1140        };
1141        let (hsz, nh, nkv, hd) = (self.hidden, self.nh, self.nkv, self.hd);
1142        let n = b * t;
1143        let pool = self.pool.as_deref();
1144        let qdim = nh * hd;
1145        let kvdim = nkv * hd;
1146        let rep = nh / nkv;
1147        let qrows = if *output_gate { 2 * qdim } else { qdim };
1148
1149        let fused = qrows + 2 * kvdim;
1150        // ── device chain: the whole attention forward in one submit ──
1151        // Declines in strict-f32 mode, on Nyström layers (their kernel is
1152        // certified f64 and stays host), and wherever a shape steps
1153        // outside the kernels; the host path below is the fallback and
1154        // the reference.
1155        #[cfg(feature = "gpu")]
1156        if !nystrom && crate::gpu::enabled_here() {
1157            let want_acts = true; // the caller always builds acts today
1158            let rope = {
1159                let mut rt = self.rope_tab.lock().unwrap();
1160                match rt.as_ref() {
1161                    Some((tt, arc)) if *tt == t => arc.clone(),
1162                    _ => {
1163                        let half = self.rotary_dim / 2;
1164                        let mut tab = Vec::with_capacity(t * half * 2);
1165                        for pos in 0..t {
1166                            for &freq in self.inv_freq.iter() {
1167                                let a = pos as f64 * freq;
1168                                tab.push(a.cos() as f32);
1169                                tab.push(a.sin() as f32);
1170                            }
1171                        }
1172                        let arc = std::sync::Arc::new(tab);
1173                        *rt = Some((t, arc.clone()));
1174                        arc
1175                    }
1176                }
1177            };
1178            let cfg = crate::gpu_wgpu::AttnChainCfg {
1179                wqkv,
1180                wo,
1181                q_norm: q_norm.as_deref(),
1182                k_norm: k_norm.as_deref(),
1183                bias: bias_cat.as_deref(),
1184                output_gate: *output_gate,
1185                gemma: self.gemma,
1186                eps: self.eps as f32,
1187                rotary_half: self.rotary_dim / 2,
1188                rope: &rope,
1189                b,
1190                t,
1191                nh,
1192                nkv,
1193                hd,
1194                hsz: self.hidden,
1195            };
1196            let mut attn_out = vec![0f32; n * self.hidden];
1197            if let Some(ch) = crate::gpu_wgpu::attn_chain_f32(n1, &cfg, &mut attn_out, want_acts) {
1198                // Rebuild the host-visible acts from the raw plane, with
1199                // exactly the host split's bias/gate arithmetic.
1200                let mut qraw = vec![0f32; n * qrows];
1201                let mut kpre = vec![0f32; n * kvdim];
1202                let mut vproj = vec![0f32; n * kvdim];
1203                for r in 0..n {
1204                    let row = &ch.qkv_plane[r * fused..(r + 1) * fused];
1205                    qraw[r * qrows..(r + 1) * qrows].copy_from_slice(&row[..qrows]);
1206                    kpre[r * kvdim..(r + 1) * kvdim].copy_from_slice(&row[qrows..qrows + kvdim]);
1207                    vproj[r * kvdim..(r + 1) * kvdim].copy_from_slice(&row[qrows + kvdim..]);
1208                }
1209                if let Some((bq, bk, bv)) = bias {
1210                    for r in 0..n {
1211                        for (x, bb) in qraw[r * qrows..(r + 1) * qrows].iter_mut().zip(bq) {
1212                            *x += bb;
1213                        }
1214                        for (x, bb) in kpre[r * kvdim..(r + 1) * kvdim].iter_mut().zip(bk) {
1215                            *x += bb;
1216                        }
1217                        for (x, bb) in vproj[r * kvdim..(r + 1) * kvdim].iter_mut().zip(bv) {
1218                            *x += bb;
1219                        }
1220                    }
1221                }
1222                let (qpre, gate_pre) = if *output_gate {
1223                    let mut qh = vec![0f32; n * qdim];
1224                    let mut gp = vec![0f32; n * qdim];
1225                    for r in 0..n {
1226                        for h in 0..nh {
1227                            let src = r * qrows + 2 * h * hd;
1228                            let dst = r * qdim + h * hd;
1229                            qh[dst..dst + hd].copy_from_slice(&qraw[src..src + hd]);
1230                            gp[dst..dst + hd].copy_from_slice(&qraw[src + hd..src + 2 * hd]);
1231                        }
1232                    }
1233                    (qh, gp)
1234                } else {
1235                    (qraw, Vec::new())
1236                };
1237                return (
1238                    attn_out,
1239                    AttnActs::Full {
1240                        qpre,
1241                        kpre,
1242                        vproj,
1243                        qrot: ch.qrot,
1244                        krot: ch.krot,
1245                        qinv: ch.qinv,
1246                        kinv: ch.kinv,
1247                        ao: ch.ao,
1248                        gate_pre,
1249                    },
1250                );
1251            }
1252        }
1253
1254        // One fused submit; the split back into three buffers is a
1255        // memcpy that costs microseconds and keeps everything below
1256        // this line byte-identical to the unfused path.
1257        let mut qkv = vec![0f32; n * fused];
1258        ops::gemm_nt(n1, wqkv, &mut qkv, n, hsz, fused, pool);
1259        let mut qraw = vec![0f32; n * qrows];
1260        let mut kpre = vec![0f32; n * kvdim];
1261        let mut vproj = vec![0f32; n * kvdim];
1262        for r in 0..n {
1263            let row = &qkv[r * fused..(r + 1) * fused];
1264            qraw[r * qrows..(r + 1) * qrows].copy_from_slice(&row[..qrows]);
1265            kpre[r * kvdim..(r + 1) * kvdim].copy_from_slice(&row[qrows..qrows + kvdim]);
1266            vproj[r * kvdim..(r + 1) * kvdim].copy_from_slice(&row[qrows + kvdim..]);
1267        }
1268        if let Some((bq, bk, bv)) = bias {
1269            for r in 0..n {
1270                for (x, bb) in qraw[r * qrows..(r + 1) * qrows].iter_mut().zip(bq) {
1271                    *x += bb;
1272                }
1273                for (x, bb) in kpre[r * kvdim..(r + 1) * kvdim].iter_mut().zip(bk) {
1274                    *x += bb;
1275                }
1276                for (x, bb) in vproj[r * kvdim..(r + 1) * kvdim].iter_mut().zip(bv) {
1277                    *x += bb;
1278                }
1279            }
1280        }
1281        // Gate split: per-head [q(hd); gate(hd)] (runtime convention).
1282        let (qpre, gate_pre) = if *output_gate {
1283            let mut qh = vec![0f32; n * qdim];
1284            let mut gp = vec![0f32; n * qdim];
1285            for r in 0..n {
1286                for h in 0..nh {
1287                    let src = r * qrows + 2 * h * hd;
1288                    let dst = r * qdim + h * hd;
1289                    qh[dst..dst + hd].copy_from_slice(&qraw[src..src + hd]);
1290                    gp[dst..dst + hd].copy_from_slice(&qraw[src + hd..src + 2 * hd]);
1291                }
1292            }
1293            (qh, gp)
1294        } else {
1295            (qraw, Vec::new())
1296        };
1297
1298        let mut qrot = qpre.clone();
1299        let mut krot = kpre.clone();
1300        let mut qinv = vec![0f32; n * nh];
1301        let mut kinv = vec![0f32; n * nkv];
1302        self.qk_norm_rope(&mut qrot, q_norm.as_deref(), nh, t, &mut qinv);
1303        self.qk_norm_rope(&mut krot, k_norm.as_deref(), nkv, t, &mut kinv);
1304
1305        // ── attention heads: parallel over (sequence, head) ──
1306        let mut ao = vec![0f32; n * qdim];
1307        {
1308            let units = b * nh;
1309            let aop = SendMut(ao.as_mut_ptr());
1310            let qr = &qrot;
1311            let kr = &krot;
1312            let vr = &vproj;
1313            let nys = self.nys;
1314            let run_unit = |u: usize| {
1315                let (bi, h) = (u / nh, u % nh);
1316                let g = h / rep;
1317                if nystrom {
1318                    // Certified matrix kernel in f64 (docs/RUST_FCD.md §2.2).
1319                    let mut q64 = vec![0f64; t * hd];
1320                    let mut k64 = vec![0f64; t * hd];
1321                    let mut v64 = vec![0f64; t * hd];
1322                    for p in 0..t {
1323                        let r = bi * t + p;
1324                        for c in 0..hd {
1325                            q64[p * hd + c] = qr[r * qdim + h * hd + c] as f64;
1326                            k64[p * hd + c] = kr[r * kvdim + g * hd + c] as f64;
1327                            v64[p * hd + c] = vr[r * kvdim + g * hd + c] as f64;
1328                        }
1329                    }
1330                    let mut o64 = vec![0f64; t * hd];
1331                    ops::nystrom_head_fwd(&q64, &k64, &v64, t, hd, hd, &nys, &mut o64);
1332                    for p in 0..t {
1333                        let r = bi * t + p;
1334                        for c in 0..hd {
1335                            // SAFETY: (row, head) slices are disjoint per unit.
1336                            unsafe {
1337                                *aop.at(r * qdim + h * hd + c) = o64[p * hd + c] as f32;
1338                            }
1339                        }
1340                    }
1341                } else {
1342                    let mut q32 = vec![0f32; t * hd];
1343                    let mut k32 = vec![0f32; t * hd];
1344                    let mut v32 = vec![0f32; t * hd];
1345                    for p in 0..t {
1346                        let r = bi * t + p;
1347                        q32[p * hd..(p + 1) * hd]
1348                            .copy_from_slice(&qr[r * qdim + h * hd..r * qdim + (h + 1) * hd]);
1349                        k32[p * hd..(p + 1) * hd]
1350                            .copy_from_slice(&kr[r * kvdim + g * hd..r * kvdim + (g + 1) * hd]);
1351                        v32[p * hd..(p + 1) * hd]
1352                            .copy_from_slice(&vr[r * kvdim + g * hd..r * kvdim + (g + 1) * hd]);
1353                    }
1354                    let mut o32 = vec![0f32; t * hd];
1355                    ops::attn_head_fwd(&q32, &k32, &v32, t, hd, hd, &mut o32);
1356                    for p in 0..t {
1357                        let r = bi * t + p;
1358                        for c in 0..hd {
1359                            // SAFETY: disjoint (row, head) slices per unit.
1360                            unsafe {
1361                                *aop.at(r * qdim + h * hd + c) = o32[p * hd + c];
1362                            }
1363                        }
1364                    }
1365                }
1366            };
1367            match pool {
1368                Some(p) if units > 1 => p.run(&|widx, nw| {
1369                    for u in (widx..units).step_by(nw) {
1370                        run_unit(u);
1371                    }
1372                }),
1373                _ => {
1374                    for u in 0..units {
1375                        run_unit(u);
1376                    }
1377                }
1378            }
1379        }
1380
1381        // Output gate: multiply the head outputs by σ(gate) before o_proj.
1382        let ao_eff: Vec<f32> = if *output_gate {
1383            ao.iter()
1384                .zip(&gate_pre)
1385                .map(|(&a, &g)| a * (1.0 / (1.0 + (-g).exp())))
1386                .collect()
1387        } else {
1388            ao.clone()
1389        };
1390        let mut attn_out = vec![0f32; n * hsz];
1391        ops::gemm_nt(&ao_eff, wo, &mut attn_out, n, qdim, hsz, pool);
1392        (
1393            attn_out,
1394            AttnActs::Full {
1395                qpre,
1396                kpre,
1397                vproj,
1398                qrot,
1399                krot,
1400                qinv,
1401                kinv,
1402                ao,
1403                gate_pre,
1404            },
1405        )
1406    }
1407
1408    /// GDN forward (frozen operator, teacher AND student): batched
1409    /// projections → f64 conv+SiLU per sequence → pooled per-(seq,
1410    /// k-head) delta-rule recurrence → out_proj. Matches the runtime
1411    /// `gdn_forward` (parity-tested in fcd_gradcheck).
1412    fn gdn_attn_fwd(&self, attn: &FcdAttn, n1: &[f32], b: usize, t: usize) -> (Vec<f32>, AttnActs) {
1413        let FcdAttn::Gdn {
1414            wqkv,
1415            wz,
1416            wa,
1417            wb,
1418            conv,
1419            a_log,
1420            dt_bias,
1421            norm,
1422            wout,
1423        } = attn
1424        else {
1425            unreachable!("gdn_attn_fwd on a non-GDN layer");
1426        };
1427        let d = self.gdn.expect("gdn layer without gdn dims");
1428        let (hsz, n) = (self.hidden, b * t);
1429        let pool = self.pool.as_deref();
1430        let (c_dim, vd, nv) = (d.c_dim(), d.vd(), d.nv);
1431
1432        let mut qkv = vec![0f32; n * c_dim];
1433        ops::gemm_nt(n1, wqkv, &mut qkv, n, hsz, c_dim, pool);
1434        let mut z = vec![0f32; n * vd];
1435        ops::gemm_nt(n1, wz, &mut z, n, hsz, vd, pool);
1436        let mut a = vec![0f32; n * nv];
1437        ops::gemm_nt(n1, wa, &mut a, n, hsz, nv, pool);
1438        let mut bstr = vec![0f32; n * nv];
1439        ops::gemm_nt(n1, wb, &mut bstr, n, hsz, nv, pool);
1440
1441        let cfg = ops::GdnSeqCfg {
1442            nv: d.nv,
1443            nk: d.nk,
1444            dk: d.dk,
1445            dv: d.dv,
1446            kk: d.kk,
1447            rms_eps: self.eps,
1448            conv,
1449            a_log,
1450            dt_bias,
1451            norm,
1452        };
1453        // f64 streams (runtime-precision recurrence) + per-seq conv.
1454        let qkv64: Vec<f64> = qkv.iter().map(|&v| v as f64).collect();
1455        let z64: Vec<f64> = z.iter().map(|&v| v as f64).collect();
1456        let a64: Vec<f64> = a.iter().map(|&v| v as f64).collect();
1457        let b64: Vec<f64> = bstr.iter().map(|&v| v as f64).collect();
1458        let mut pre64 = vec![0f64; n * c_dim];
1459        let mut cq64 = vec![0f64; n * c_dim];
1460        for bi in 0..b {
1461            let r = bi * t * c_dim..(bi + 1) * t * c_dim;
1462            ops::gdn_conv_fwd(
1463                &qkv64[r.clone()],
1464                t,
1465                c_dim,
1466                d.kk,
1467                conv,
1468                &mut pre64[r.clone()],
1469                &mut cq64[r],
1470            );
1471        }
1472        let mut of = vec![0f32; n * vd];
1473        {
1474            let units = b * d.nk;
1475            let rep_v = d.nv / d.nk;
1476            let ofp = SendMut(of.as_mut_ptr());
1477            let (cqr, zr, ar, br) = (&cq64, &z64, &a64, &b64);
1478            let cfg_ref = &cfg;
1479            let run_unit = |u: usize| {
1480                let (bi, ko) = (u / d.nk, u % d.nk);
1481                let mut local = vec![0f64; t * vd];
1482                ops::gdn_group_fwd(
1483                    &cqr[bi * t * c_dim..(bi + 1) * t * c_dim],
1484                    &zr[bi * t * vd..(bi + 1) * t * vd],
1485                    &ar[bi * t * nv..(bi + 1) * t * nv],
1486                    &br[bi * t * nv..(bi + 1) * t * nv],
1487                    t,
1488                    cfg_ref,
1489                    ko,
1490                    &mut local,
1491                );
1492                for hh in 0..rep_v {
1493                    let h = ko * rep_v + hh;
1494                    for p in 0..t {
1495                        for dj in 0..d.dv {
1496                            // SAFETY: v-head columns are exclusive per unit.
1497                            unsafe {
1498                                *ofp.at((bi * t + p) * vd + h * d.dv + dj) =
1499                                    local[p * vd + h * d.dv + dj] as f32;
1500                            }
1501                        }
1502                    }
1503                }
1504            };
1505            match pool {
1506                Some(p) if units > 1 => p.run(&|widx, nw| {
1507                    for u in (widx..units).step_by(nw) {
1508                        run_unit(u);
1509                    }
1510                }),
1511                _ => {
1512                    for u in 0..units {
1513                        run_unit(u);
1514                    }
1515                }
1516            }
1517        }
1518        let mut attn_out = vec![0f32; n * hsz];
1519        ops::gemm_nt(&of, wout, &mut attn_out, n, vd, hsz, pool);
1520        (attn_out, AttnActs::Gdn { qkv, z, a, b: bstr })
1521    }
1522
1523    /// One layer backward (docs/RUST_FCD.md §2.3 chain), given the
1524    /// recomputed `acts`. Accumulates trainable grads when `grads` is
1525    /// Some; always produces the through-grad dh_in.
1526    #[allow(clippy::too_many_arguments)]
1527    fn layer_backward(
1528        &self,
1529        li: usize,
1530        h_in: &[f32],
1531        b: usize,
1532        t: usize,
1533        wts: &LnFfn,
1534        nystrom: bool,
1535        acts: &LayerActs,
1536        dh2: &[f32],
1537        mut grads: Option<&mut [Vec<f32>]>,
1538    ) -> Vec<f32> {
1539        let hsz = self.hidden;
1540        let n = b * t;
1541        let l = &self.layers[li];
1542        let pool = self.pool.as_deref();
1543        let inter = l.inter;
1544
1545        // ── FFN backward ──
1546        let mut dn2 = vec![0f32; n * hsz];
1547        // A frozen layer (no weight grads wanted) rides the device chain
1548        // fed by the plane its forward parked: only dh2 goes down and dn2
1549        // comes back, where the host path uploads the 80 MB dgu concat.
1550        let mut bwd_fused = false;
1551        #[cfg(feature = "gpu")]
1552        if grads.is_none() {
1553            if let Some(gu) = wts.gu {
1554                if crate::gpu::enabled_here()
1555                    && crate::gpu_wgpu::ffn_bwd_chain_f32(
1556                        dh2, wts.down, gu, li, &mut dn2, n, hsz, inter,
1557                    )
1558                {
1559                    bwd_fused = true;
1560                }
1561            }
1562        }
1563        if !bwd_fused {
1564            let mut dact = vec![0f32; n * inter];
1565            ops::gemm_dx(dh2, wts.down, &mut dact, n, inter, hsz, pool);
1566            if let Some(g) = grads.as_deref_mut() {
1567                ops::gemm_dw(dh2, &acts.act, &mut g[4], n, inter, hsz, pool);
1568            }
1569            let mut dg = vec![0f32; n * inter];
1570            let mut du = vec![0f32; n * inter];
1571            for i in 0..n * inter {
1572                dg[i] = dact[i] * acts.upre[i] * ops::silu_bwd(acts.gpre[i]);
1573                du[i] = dact[i] * ops::silu(acts.gpre[i]);
1574            }
1575            // Fused when the frozen concat exists (its gate/up slots are
1576            // empty by design); trained masters keep the two-call path.
1577            if let Some(gu) = wts.gu {
1578                let mut dgu = vec![0f32; n * 2 * inter];
1579                for r in 0..n {
1580                    let row = &mut dgu[r * 2 * inter..(r + 1) * 2 * inter];
1581                    row[..inter].copy_from_slice(&dg[r * inter..(r + 1) * inter]);
1582                    row[inter..].copy_from_slice(&du[r * inter..(r + 1) * inter]);
1583                }
1584                ops::gemm_dx(&dgu, gu, &mut dn2, n, hsz, 2 * inter, pool);
1585            } else {
1586                ops::gemm_dx(&dg, wts.gate, &mut dn2, n, hsz, inter, pool);
1587                ops::gemm_dx(&du, wts.up, &mut dn2, n, hsz, inter, pool);
1588            }
1589            if let Some(g) = grads.as_deref_mut() {
1590                ops::gemm_dw(&dg, &acts.n2, &mut g[2], n, hsz, inter, pool);
1591                ops::gemm_dw(&du, &acts.n2, &mut g[3], n, hsz, inter, pool);
1592            }
1593        }
1594
1595        let mut dh1 = dh2.to_vec();
1596        ops::rmsnorm_bwd(
1597            &acts.h1,
1598            wts.pln,
1599            &acts.inv2,
1600            &dn2,
1601            self.gemma,
1602            &mut dh1,
1603            grads.as_deref_mut().map(|g| &mut g[1][..]),
1604        );
1605
1606        // ── attention backward (dispatch) → dn1 ──
1607        let mats = self.mats(li).expect("layer mats");
1608        let dn1 = match &l.attn {
1609            FcdAttn::Full { .. } => {
1610                self.full_attn_bwd(&l.attn, &mats, &acts.attn, &dh1, b, t, nystrom)
1611            }
1612            FcdAttn::Gdn { .. } => self.gdn_attn_bwd(&l.attn, &acts.attn, &dh1, b, t),
1613        };
1614
1615        let mut dh_in = dh1.clone();
1616        ops::rmsnorm_bwd(
1617            h_in,
1618            wts.iln,
1619            &acts.inv1,
1620            &dn1,
1621            self.gemma,
1622            &mut dh_in,
1623            grads.map(|g| &mut g[0][..]),
1624        );
1625        dh_in
1626    }
1627
1628    /// Full-attention through-backward: o_proj → output gate →
1629    /// per-head attention (exact / Nyström-frozen-M) → RoPE → qk-norm →
1630    /// projections. Frozen weights: dX only.
1631    fn full_attn_bwd(
1632        &self,
1633        attn: &FcdAttn,
1634        mats: &LayerMats,
1635        acts: &AttnActs,
1636        dattn: &[f32],
1637        b: usize,
1638        t: usize,
1639        nystrom: bool,
1640    ) -> Vec<f32> {
1641        let (wqkv, wo) = (&mats.wqkv[..], &mats.wo[..]);
1642        let FcdAttn::Full {
1643            qrows: _,
1644            q_norm,
1645            k_norm,
1646            output_gate,
1647            ..
1648        } = attn
1649        else {
1650            unreachable!("full_attn_bwd on a non-Full layer");
1651        };
1652        let AttnActs::Full {
1653            qpre,
1654            kpre,
1655            vproj,
1656            qrot,
1657            krot,
1658            qinv,
1659            kinv,
1660            ao,
1661            gate_pre,
1662        } = acts
1663        else {
1664            unreachable!("acts mismatch");
1665        };
1666        let (hsz, nh, nkv, hd) = (self.hidden, self.nh, self.nkv, self.hd);
1667        let n = b * t;
1668        let pool = self.pool.as_deref();
1669        let qdim = nh * hd;
1670        let kvdim = nkv * hd;
1671        let rep = nh / nkv;
1672        let qrows = if *output_gate { 2 * qdim } else { qdim };
1673
1674        let mut dao_eff = vec![0f32; n * qdim];
1675        ops::gemm_dx(dattn, wo, &mut dao_eff, n, qdim, hsz, pool);
1676        // Output gate: ao_eff = ao·σ(g) → dao = d·σ(g), dg = d·ao·σ′(g).
1677        let (dao, dgate) = if *output_gate {
1678            let mut dao = vec![0f32; n * qdim];
1679            let mut dgp = vec![0f32; n * qdim];
1680            for i in 0..n * qdim {
1681                let sig = 1.0 / (1.0 + (-gate_pre[i]).exp());
1682                dao[i] = dao_eff[i] * sig;
1683                dgp[i] = dao_eff[i] * ao[i] * sig * (1.0 - sig);
1684            }
1685            (dao, dgp)
1686        } else {
1687            (dao_eff, Vec::new())
1688        };
1689
1690        let mut dqrot = vec![0f32; n * qdim];
1691        let mut dkrot = vec![0f32; n * kvdim];
1692        let mut dvproj = vec![0f32; n * kvdim];
1693        {
1694            // Parallel over (sequence, kv-group): a unit owns the dk/dv
1695            // slices of its group and the dq slices of its rep Q heads.
1696            let units = b * nkv;
1697            let dqp = SendMut(dqrot.as_mut_ptr());
1698            let dkp = SendMut(dkrot.as_mut_ptr());
1699            let dvp = SendMut(dvproj.as_mut_ptr());
1700            let (qr, kr, vr) = (qrot, krot, vproj);
1701            let daor = &dao;
1702            let nys = self.nys;
1703            let run_unit = |u: usize| {
1704                let (bi, g) = (u / nkv, u % nkv);
1705                let mut k64 = vec![0f64; t * hd];
1706                let mut v64 = vec![0f64; t * hd];
1707                for p in 0..t {
1708                    let r = bi * t + p;
1709                    for c in 0..hd {
1710                        k64[p * hd + c] = kr[r * kvdim + g * hd + c] as f64;
1711                        v64[p * hd + c] = vr[r * kvdim + g * hd + c] as f64;
1712                    }
1713                }
1714                let mut dk64 = vec![0f64; t * hd];
1715                let mut dv64 = vec![0f64; t * hd];
1716                let mut q64 = vec![0f64; t * hd];
1717                let mut do64 = vec![0f64; t * hd];
1718                let mut dq64 = vec![0f64; t * hd];
1719                for hh in 0..rep {
1720                    let h = g * rep + hh;
1721                    for p in 0..t {
1722                        let r = bi * t + p;
1723                        for c in 0..hd {
1724                            q64[p * hd + c] = qr[r * qdim + h * hd + c] as f64;
1725                            do64[p * hd + c] = daor[r * qdim + h * hd + c] as f64;
1726                        }
1727                    }
1728                    for v in dq64.iter_mut() {
1729                        *v = 0.0;
1730                    }
1731                    if nystrom {
1732                        ops::nystrom_head_bwd(
1733                            &q64, &k64, &v64, &do64, t, hd, hd, &nys, &mut dq64, &mut dk64,
1734                            &mut dv64,
1735                        );
1736                    } else {
1737                        ops::attn_head_bwd(
1738                            &q64, &k64, &v64, &do64, t, hd, hd, &mut dq64, &mut dk64, &mut dv64,
1739                        );
1740                    }
1741                    for p in 0..t {
1742                        let r = bi * t + p;
1743                        for c in 0..hd {
1744                            // SAFETY: disjoint (row, head) slices per unit.
1745                            unsafe {
1746                                *dqp.at(r * qdim + h * hd + c) = dq64[p * hd + c] as f32;
1747                            }
1748                        }
1749                    }
1750                }
1751                for p in 0..t {
1752                    let r = bi * t + p;
1753                    for c in 0..hd {
1754                        // SAFETY: disjoint (row, group) slices per unit.
1755                        unsafe {
1756                            *dkp.at(r * kvdim + g * hd + c) = dk64[p * hd + c] as f32;
1757                            *dvp.at(r * kvdim + g * hd + c) = dv64[p * hd + c] as f32;
1758                        }
1759                    }
1760                }
1761            };
1762            match pool {
1763                Some(p) if units > 1 => p.run(&|widx, nw| {
1764                    for u in (widx..units).step_by(nw) {
1765                        run_unit(u);
1766                    }
1767                }),
1768                _ => {
1769                    for u in 0..units {
1770                        run_unit(u);
1771                    }
1772                }
1773            }
1774        }
1775
1776        // qk-norm + RoPE through-grads (frozen gains → no dw).
1777        let mut dqpre = vec![0f32; n * qdim];
1778        let mut dkpre = vec![0f32; n * kvdim];
1779        for r in 0..n {
1780            let pos = r % t;
1781            for h in 0..nh {
1782                let s = r * qdim + h * hd;
1783                ops::rope_bwd(&mut dqrot[s..s + self.rotary_dim], pos, &self.inv_freq);
1784                match q_norm {
1785                    Some(w) => ops::rmsnorm_bwd(
1786                        &qpre[s..s + hd],
1787                        w,
1788                        &qinv[r * nh + h..r * nh + h + 1],
1789                        &dqrot[s..s + hd],
1790                        self.gemma,
1791                        &mut dqpre[s..s + hd],
1792                        None,
1793                    ),
1794                    None => dqpre[s..s + hd].copy_from_slice(&dqrot[s..s + hd]),
1795                }
1796            }
1797            for g in 0..nkv {
1798                let s = r * kvdim + g * hd;
1799                ops::rope_bwd(&mut dkrot[s..s + self.rotary_dim], pos, &self.inv_freq);
1800                match k_norm {
1801                    Some(w) => ops::rmsnorm_bwd(
1802                        &kpre[s..s + hd],
1803                        w,
1804                        &kinv[r * nkv + g..r * nkv + g + 1],
1805                        &dkrot[s..s + hd],
1806                        self.gemma,
1807                        &mut dkpre[s..s + hd],
1808                        None,
1809                    ),
1810                    None => dkpre[s..s + hd].copy_from_slice(&dkrot[s..s + hd]),
1811                }
1812            }
1813        }
1814
1815        // Re-interleave [dq; dgate] per head for gated projections.
1816        let dqraw: Vec<f32> = if *output_gate {
1817            let mut dq = vec![0f32; n * qrows];
1818            for r in 0..n {
1819                for h in 0..nh {
1820                    let dst = r * qrows + 2 * h * hd;
1821                    let src = r * qdim + h * hd;
1822                    dq[dst..dst + hd].copy_from_slice(&dqpre[src..src + hd]);
1823                    dq[dst + hd..dst + 2 * hd].copy_from_slice(&dgate[src..src + hd]);
1824                }
1825            }
1826            dq
1827        } else {
1828            dqpre
1829        };
1830
1831        // Projections (frozen weights → dX only; bias add is identity).
1832        // One fused submit: wqkv's row layout is exactly what gemm_dx
1833        // wants, so the sum dq·Wq + dk·Wk + dv·Wv is a concat of the
1834        // gradients and a single call.
1835        let fused = qrows + 2 * kvdim;
1836        let mut dqkv = vec![0f32; n * fused];
1837        for r in 0..n {
1838            let row = &mut dqkv[r * fused..(r + 1) * fused];
1839            row[..qrows].copy_from_slice(&dqraw[r * qrows..(r + 1) * qrows]);
1840            row[qrows..qrows + kvdim].copy_from_slice(&dkpre[r * kvdim..(r + 1) * kvdim]);
1841            row[qrows + kvdim..].copy_from_slice(&dvproj[r * kvdim..(r + 1) * kvdim]);
1842        }
1843        let mut dn1 = vec![0f32; n * hsz];
1844        ops::gemm_dx(&dqkv, wqkv, &mut dn1, n, hsz, fused, pool);
1845        dn1
1846    }
1847
1848    /// GDN through-backward: out_proj → pooled per-(seq, k-head) BPTT
1849    /// (fcd_ops::gdn_group_bwd) → conv backward → projections. Frozen
1850    /// weights: dX only.
1851    fn gdn_attn_bwd(
1852        &self,
1853        attn: &FcdAttn,
1854        acts: &AttnActs,
1855        dattn: &[f32],
1856        b: usize,
1857        t: usize,
1858    ) -> Vec<f32> {
1859        let FcdAttn::Gdn {
1860            wqkv,
1861            wz,
1862            wa,
1863            wb,
1864            conv,
1865            a_log,
1866            dt_bias,
1867            norm,
1868            wout,
1869        } = attn
1870        else {
1871            unreachable!("gdn_attn_bwd on a non-GDN layer");
1872        };
1873        let AttnActs::Gdn { qkv, z, a, b: bstr } = acts else {
1874            unreachable!("acts mismatch");
1875        };
1876        let d = self.gdn.expect("gdn layer without gdn dims");
1877        let (hsz, n) = (self.hidden, b * t);
1878        let pool = self.pool.as_deref();
1879        let (c_dim, vd, nv) = (d.c_dim(), d.vd(), d.nv);
1880
1881        let mut dof = vec![0f32; n * vd];
1882        ops::gemm_dx(dattn, wout, &mut dof, n, vd, hsz, pool);
1883
1884        let cfg = ops::GdnSeqCfg {
1885            nv: d.nv,
1886            nk: d.nk,
1887            dk: d.dk,
1888            dv: d.dv,
1889            kk: d.kk,
1890            rms_eps: self.eps,
1891            conv,
1892            a_log,
1893            dt_bias,
1894            norm,
1895        };
1896        let qkv64: Vec<f64> = qkv.iter().map(|&v| v as f64).collect();
1897        let z64: Vec<f64> = z.iter().map(|&v| v as f64).collect();
1898        let a64: Vec<f64> = a.iter().map(|&v| v as f64).collect();
1899        let b64: Vec<f64> = bstr.iter().map(|&v| v as f64).collect();
1900        let dof64: Vec<f64> = dof.iter().map(|&v| v as f64).collect();
1901        let mut pre64 = vec![0f64; n * c_dim];
1902        let mut cq64 = vec![0f64; n * c_dim];
1903        for bi in 0..b {
1904            let r = bi * t * c_dim..(bi + 1) * t * c_dim;
1905            ops::gdn_conv_fwd(
1906                &qkv64[r.clone()],
1907                t,
1908                c_dim,
1909                d.kk,
1910                conv,
1911                &mut pre64[r.clone()],
1912                &mut cq64[r],
1913            );
1914        }
1915
1916        let mut dcq64 = vec![0f64; n * c_dim];
1917        let mut dz64 = vec![0f64; n * vd];
1918        let mut da64 = vec![0f64; n * nv];
1919        let mut db64 = vec![0f64; n * nv];
1920        {
1921            let units = b * d.nk;
1922            let rep_v = d.nv / d.nk;
1923            let kd = d.nk * d.dk;
1924            let dcqp = SendMut(dcq64.as_mut_ptr());
1925            let dzp = SendMut(dz64.as_mut_ptr());
1926            let dap = SendMut(da64.as_mut_ptr());
1927            let dbp = SendMut(db64.as_mut_ptr());
1928            let (cqr, zr, ar, br, dor) = (&cq64, &z64, &a64, &b64, &dof64);
1929            let cfg_ref = &cfg;
1930            let run_unit = |u: usize| {
1931                let (bi, ko) = (u / d.nk, u % d.nk);
1932                // Full-width locals — the group only fills its own
1933                // channels; the scatter below copies exactly those.
1934                let mut dcq_l = vec![0f64; t * c_dim];
1935                let mut dz_l = vec![0f64; t * vd];
1936                let mut da_l = vec![0f64; t * nv];
1937                let mut db_l = vec![0f64; t * nv];
1938                ops::gdn_group_bwd(
1939                    &cqr[bi * t * c_dim..(bi + 1) * t * c_dim],
1940                    &zr[bi * t * vd..(bi + 1) * t * vd],
1941                    &ar[bi * t * nv..(bi + 1) * t * nv],
1942                    &br[bi * t * nv..(bi + 1) * t * nv],
1943                    t,
1944                    cfg_ref,
1945                    ko,
1946                    &dor[bi * t * vd..(bi + 1) * t * vd],
1947                    &mut dcq_l,
1948                    &mut dz_l,
1949                    &mut da_l,
1950                    &mut db_l,
1951                );
1952                // SAFETY of every store below: the written channel /
1953                // column ranges are exclusively owned by (bi, ko).
1954                for p in 0..t {
1955                    let row = (bi * t + p) * c_dim;
1956                    for c in ko * d.dk..(ko + 1) * d.dk {
1957                        unsafe {
1958                            *dcqp.at(row + c) = dcq_l[p * c_dim + c];
1959                            *dcqp.at(row + kd + c) = dcq_l[p * c_dim + kd + c];
1960                        }
1961                    }
1962                    for hh in 0..rep_v {
1963                        let h = ko * rep_v + hh;
1964                        for dj in 0..d.dv {
1965                            unsafe {
1966                                *dcqp.at(row + 2 * kd + h * d.dv + dj) =
1967                                    dcq_l[p * c_dim + 2 * kd + h * d.dv + dj];
1968                                *dzp.at((bi * t + p) * vd + h * d.dv + dj) =
1969                                    dz_l[p * vd + h * d.dv + dj];
1970                            }
1971                        }
1972                        unsafe {
1973                            *dap.at((bi * t + p) * nv + h) = da_l[p * nv + h];
1974                            *dbp.at((bi * t + p) * nv + h) = db_l[p * nv + h];
1975                        }
1976                    }
1977                }
1978            };
1979            match pool {
1980                Some(p) if units > 1 => p.run(&|widx, nw| {
1981                    for u in (widx..units).step_by(nw) {
1982                        run_unit(u);
1983                    }
1984                }),
1985                _ => {
1986                    for u in 0..units {
1987                        run_unit(u);
1988                    }
1989                }
1990            }
1991        }
1992
1993        let mut dqkv64 = vec![0f64; n * c_dim];
1994        for bi in 0..b {
1995            let r = bi * t * c_dim..(bi + 1) * t * c_dim;
1996            ops::gdn_conv_bwd(
1997                &pre64[r.clone()],
1998                t,
1999                c_dim,
2000                d.kk,
2001                conv,
2002                &dcq64[r.clone()],
2003                &mut dqkv64[r],
2004            );
2005        }
2006        let to32 = |v: &[f64]| -> Vec<f32> { v.iter().map(|&x| x as f32).collect() };
2007        let (dqkv, dz, da, db) = (to32(&dqkv64), to32(&dz64), to32(&da64), to32(&db64));
2008
2009        let mut dn1 = vec![0f32; n * hsz];
2010        ops::gemm_dx(&dqkv, wqkv, &mut dn1, n, hsz, c_dim, pool);
2011        ops::gemm_dx(&dz, wz, &mut dn1, n, hsz, vd, pool);
2012        ops::gemm_dx(&da, wa, &mut dn1, n, hsz, nv, pool);
2013        ops::gemm_dx(&db, wb, &mut dn1, n, hsz, nv, pool);
2014        dn1
2015    }
2016
2017    /// Full forward: embeddings → layers → final hidden [b·t, hidden].
2018    /// `student` switches converted layers to the Nyström kernel and
2019    /// reads trainable weights from `ts`; `keep` collects each layer's
2020    /// input hidden for the checkpointed backward.
2021    fn forward_hidden(
2022        &self,
2023        ids: &[u32],
2024        b: usize,
2025        t: usize,
2026        ts: Option<&TrainState>,
2027        student: bool,
2028        mut keep: Option<&mut Vec<Vec<f32>>>,
2029    ) -> Vec<f32> {
2030        let hsz = self.hidden;
2031        let mut h = vec![0f32; b * t * hsz];
2032        for (r, &id) in ids.iter().enumerate() {
2033            let src = (id as usize).min(self.embed.len() / hsz - 1) * hsz;
2034            h[r * hsz..(r + 1) * hsz].copy_from_slice(&self.embed[src..src + hsz]);
2035        }
2036        for li in 0..self.nl {
2037            if let Some(k) = keep.as_deref_mut() {
2038                k.push(h.clone());
2039            }
2040            let mats = self.mats(li).expect("layer mats");
2041            let wts = ln_ffn(self, if student { ts } else { None }, li, &mats);
2042            let nys = student && self.o1_flags[li];
2043            h = self.layer_forward(li, &h, b, t, &wts, nys, false).0;
2044        }
2045        h
2046    }
2047
2048    /// Loss head: chunked tied-lm_head CE+KL against the teacher hidden,
2049    /// returning (ce_mean, kl_mean, dHidden_student).
2050    fn loss_and_dhidden(
2051        &self,
2052        hs: &[f32],
2053        ht: &[f32],
2054        targets: &[u32],
2055        kl_w: f64,
2056    ) -> (f64, f64, Vec<f32>) {
2057        let hsz = self.hidden;
2058        let n = targets.len();
2059        let pool = self.pool.as_deref();
2060        let wh = self.head_weight();
2061        let vs = self.vocab;
2062
2063        let mut ns = vec![0f32; n * hsz];
2064        let mut invs = vec![0f32; n];
2065        ops::rmsnorm_fwd(
2066            hs,
2067            &self.final_norm,
2068            self.eps,
2069            self.gemma,
2070            &mut ns,
2071            &mut invs,
2072        );
2073        let mut nt = vec![0f32; n * hsz];
2074        let mut invt = vec![0f32; n];
2075        ops::rmsnorm_fwd(
2076            ht,
2077            &self.final_norm,
2078            self.eps,
2079            self.gemma,
2080            &mut nt,
2081            &mut invt,
2082        );
2083
2084        let inv_n = 1.0 / n as f64;
2085        let mut ce_sum = 0f64;
2086        let mut kl_sum = 0f64;
2087        let mut dns = vec![0f32; n * hsz];
2088        let mut ls = vec![0f32; LM_CHUNK * vs];
2089        let mut lt = vec![0f32; LM_CHUNK * vs];
2090        let mut dlg = vec![0f32; LM_CHUNK * vs];
2091        let mut r0 = 0usize;
2092        while r0 < n {
2093            let r1 = (r0 + LM_CHUNK).min(n);
2094            let c = r1 - r0;
2095            ops::gemm_nt(
2096                &ns[r0 * hsz..r1 * hsz],
2097                wh,
2098                &mut ls[..c * vs],
2099                c,
2100                hsz,
2101                vs,
2102                pool,
2103            );
2104            ops::gemm_nt(
2105                &nt[r0 * hsz..r1 * hsz],
2106                wh,
2107                &mut lt[..c * vs],
2108                c,
2109                hsz,
2110                vs,
2111                pool,
2112            );
2113            for r in 0..c {
2114                let (ce, kl) = ops::ce_kl_position(
2115                    &ls[r * vs..(r + 1) * vs],
2116                    &lt[r * vs..(r + 1) * vs],
2117                    targets[r0 + r] as usize,
2118                    kl_w,
2119                    inv_n,
2120                    &mut dlg[r * vs..(r + 1) * vs],
2121                );
2122                ce_sum += ce;
2123                kl_sum += kl;
2124            }
2125            ops::gemm_dx(
2126                &dlg[..c * vs],
2127                wh,
2128                &mut dns[r0 * hsz..r1 * hsz],
2129                c,
2130                hsz,
2131                vs,
2132                pool,
2133            );
2134            r0 = r1;
2135        }
2136
2137        let mut dhs = vec![0f32; n * hsz];
2138        ops::rmsnorm_bwd(
2139            hs,
2140            &self.final_norm,
2141            &invs,
2142            &dns,
2143            self.gemma,
2144            &mut dhs,
2145            None,
2146        );
2147        (ce_sum * inv_n, kl_sum * inv_n, dhs)
2148    }
2149
2150    /// Checkpointed backward: per layer, recompute the intra-layer
2151    /// activations and differentiate.
2152    fn backward(
2153        &self,
2154        b: usize,
2155        t: usize,
2156        keep: &[Vec<f32>],
2157        dh_last: Vec<f32>,
2158        ts: &mut TrainState,
2159    ) {
2160        // Split-borrow: the weight view reads `data`, the grads write
2161        // `grad` — disjoint fields of TrainState.
2162        let TrainState {
2163            layers, data, grad, ..
2164        } = ts;
2165        let mut dh = dh_last;
2166        for li in (0..self.nl).rev() {
2167            let h_in = &keep[li];
2168            let nys = self.o1_flags[li];
2169            let slot = layers.iter().position(|&x| x == li);
2170            let mats_hold = self.mats(li).expect("layer mats");
2171            let wts = match slot {
2172                Some(s) => {
2173                    let bi = s * PARAMS_PER_LAYER;
2174                    LnFfn {
2175                        iln: &data[bi],
2176                        pln: &data[bi + 1],
2177                        gate: &data[bi + 2],
2178                        up: &data[bi + 3],
2179                        down: &data[bi + 4],
2180                        gu: None,
2181                    }
2182                }
2183                None => {
2184                    let l = &self.layers[li];
2185                    LnFfn {
2186                        iln: &l.iln,
2187                        pln: &l.pln,
2188                        gate: &[],
2189                        up: &[],
2190                        down: &mats_hold.down,
2191                        gu: Some(&mats_hold.gu),
2192                    }
2193                }
2194            };
2195            let (_, acts) = self.layer_forward(li, h_in, b, t, &wts, nys, true);
2196            let acts = acts.expect("want_acts");
2197            dh = match slot {
2198                Some(s) => {
2199                    let gb = s * PARAMS_PER_LAYER;
2200                    let gr = &mut grad[gb..gb + PARAMS_PER_LAYER];
2201                    self.layer_backward(li, h_in, b, t, &wts, nys, &acts, &dh, Some(gr))
2202                }
2203                None => self.layer_backward(li, h_in, b, t, &wts, nys, &acts, &dh, None),
2204            };
2205        }
2206    }
2207
2208    /// Test-only: one full training-graph evaluation — teacher forward,
2209    /// student forward, CE+KL loss, checkpointed backward into the
2210    /// grads. Returns the weighted total loss. The block-level
2211    /// gradcheck runs finite differences over trainable weights through
2212    /// this, which exercises EVERY through-grad in the graph (layer-0
2213    /// gains flow through all attention/rope/qk-norm/GQA paths above).
2214    #[doc(hidden)]
2215    pub fn loss_and_grads_for_test(
2216        &self,
2217        ids: &[u32],
2218        tgt: &[u32],
2219        b: usize,
2220        t: usize,
2221        ts: &mut TrainState,
2222        kl_w: f64,
2223    ) -> f64 {
2224        let ht = self.forward_hidden(ids, b, t, None, false, None);
2225        let mut keep = Vec::with_capacity(self.nl);
2226        let hs = self.forward_hidden(ids, b, t, Some(ts), true, Some(&mut keep));
2227        let (ce, kl, dhs) = self.loss_and_dhidden(&hs, &ht, tgt, kl_w);
2228        ts.zero_grad();
2229        self.backward(b, t, &keep, dhs, ts);
2230        (1.0 - kl_w) * ce + kl_w * kl
2231    }
2232
2233    /// Teacher-forced CE perplexity on deterministic evenly-spaced val
2234    /// windows (`heal_hybridk_06b.py::val_ppl` discipline — random
2235    /// windows made gate comparisons ride ±15% noise).
2236    pub fn val_ppl(
2237        &self,
2238        va: &[u32],
2239        ts: Option<&TrainState>,
2240        student: bool,
2241        bs: usize,
2242        nrounds: usize,
2243        seq: usize,
2244    ) -> f64 {
2245        let nwin = nrounds * bs;
2246        if va.len() < seq + 2 || nwin == 0 {
2247            return f64::NAN;
2248        }
2249        let stride = (va.len() - seq - 1) / nwin;
2250        let hsz = self.hidden;
2251        let wh = self.head_weight();
2252        let vs = self.vocab;
2253        let pool = self.pool.as_deref();
2254        let mut nll = 0f64;
2255        let mut cnt = 0usize;
2256        for j in 0..nrounds {
2257            let mut ids = Vec::with_capacity(bs * seq);
2258            let mut tgt = Vec::with_capacity(bs * seq);
2259            for bi in 0..bs {
2260                let off = ((j * bs + bi) * stride.max(1)).min(va.len() - seq - 1);
2261                ids.extend_from_slice(&va[off..off + seq]);
2262                tgt.extend_from_slice(&va[off + 1..off + seq + 1]);
2263            }
2264            let h = self.forward_hidden(&ids, bs, seq, ts, student, None);
2265            let n = bs * seq;
2266            let mut ns = vec![0f32; n * hsz];
2267            let mut inv = vec![0f32; n];
2268            ops::rmsnorm_fwd(
2269                &h,
2270                &self.final_norm,
2271                self.eps,
2272                self.gemma,
2273                &mut ns,
2274                &mut inv,
2275            );
2276            let mut lg = vec![0f32; LM_CHUNK * vs];
2277            let mut r0 = 0usize;
2278            while r0 < n {
2279                let r1 = (r0 + LM_CHUNK).min(n);
2280                let c = r1 - r0;
2281                ops::gemm_nt(
2282                    &ns[r0 * hsz..r1 * hsz],
2283                    wh,
2284                    &mut lg[..c * vs],
2285                    c,
2286                    hsz,
2287                    vs,
2288                    pool,
2289                );
2290                for r in 0..c {
2291                    let row = &lg[r * vs..(r + 1) * vs];
2292                    let target = tgt[r0 + r] as usize;
2293                    let mut mx = f64::NEG_INFINITY;
2294                    for &v in row {
2295                        mx = mx.max(v as f64);
2296                    }
2297                    let mut s = 0f64;
2298                    for &v in row {
2299                        s += (v as f64 - mx).exp();
2300                    }
2301                    nll += mx + s.ln() - row[target.min(vs - 1)] as f64;
2302                    cnt += 1;
2303                }
2304                r0 = r1;
2305            }
2306        }
2307        (nll / cnt.max(1) as f64).exp()
2308    }
2309}
2310
2311// ─────────────────────────── training loop ───────────────────────────
2312
2313/// Run the full certified polish: train, early-stop/restore-best, and
2314/// write `<out>` (source tensors byte-copied, polished LN/FFN as f32).
2315///
2316/// With `gate` (Patent 16 draft, claim 13), every eval checkpoint is
2317/// additionally scored by greedy generation through the REAL streaming
2318/// O(1) runtime, and the restored checkpoint is the lowest-ppl one
2319/// AMONG GATE-PASSERS; if none passes, the zero-shot state is restored
2320/// (identity polish) — the stage never makes generation worse than
2321/// conversion alone.
2322pub fn run_polish(
2323    model: &Arc<CmfModel>,
2324    o1: &O1Cfg,
2325    hp: &FcdHyper,
2326    tr: &[u32],
2327    va: &[u32],
2328    out: &std::path::Path,
2329    gate: Option<&GenGateCfg>,
2330) -> Result<FcdReport, String> {
2331    if tr.len() < hp.seq + 2 {
2332        return Err(format!(
2333            "train corpus too small: {} tokens < seq+2 = {}",
2334            tr.len(),
2335            hp.seq + 2
2336        ));
2337    }
2338    let fm = FcdModel::from_cmf(model, o1)?;
2339    let converted = fm.converted();
2340    if converted.is_empty() {
2341        return Err("no converted layers under this --o1 spec (nothing to polish)".into());
2342    }
2343    tracing::info!(
2344        "fcd: {} layers converted ({} trainable tensors), m={} w={} sink={}, \
2345         corpus train {} / val {} tokens",
2346        converted.len(),
2347        converted.len() * PARAMS_PER_LAYER,
2348        fm.nys.m,
2349        fm.nys.w,
2350        fm.nys.sink,
2351        tr.len(),
2352        va.len()
2353    );
2354
2355    let mut ts = TrainState::new(&fm);
2356    let teacher_ppl = fm.val_ppl(va, None, false, hp.bs, 2, hp.seq);
2357    let ppl_start = fm.val_ppl(va, Some(&ts), true, hp.bs, 2, hp.seq);
2358    tracing::info!(
2359        "fcd: quick-val teacher ppl {teacher_ppl:.2} | zero-shot o1 student ppl {ppl_start:.2}"
2360    );
2361
2362    // ── generation gate (claim 13): baseline at step 0 ──
2363    let mut gate_state: Option<(Pipeline, Vec<f64>)> = match gate {
2364        Some(g) if !g.prompts.is_empty() => {
2365            let greedy = SamplerConfig {
2366                temperature: 0.0,
2367                top_p: 1.0,
2368                presence_penalty: 0.0,
2369                top_k: 0,
2370                repetition_penalty: 1.0,
2371                min_p: 0.0,
2372                seed: Some(0),
2373                suppress_tokens: Vec::new(),
2374            };
2375            let mut pipe = Pipeline::from_model(model, greedy)
2376                .map_err(|e| format!("gen-gate pipeline: {e}"))?;
2377            pipe.set_o1(Some(o1.clone()));
2378            apply_trainables(&mut pipe, &fm, &ts);
2379            let base = gate_gen_scores(&mut pipe, g)?;
2380            tracing::info!("fcd gen-gate baseline loop-scores: {base:?}");
2381            Some((pipe, base))
2382        }
2383        Some(_) => {
2384            tracing::warn!("fcd gen-gate requested but val stream too short — gate off");
2385            None
2386        }
2387        None => None,
2388    };
2389    // Identity fallback: the pre-training master copies.
2390    let init_snapshot: Option<Vec<Vec<f32>>> = gate_state.is_some().then(|| ts.data.clone());
2391    let mut gate_evals: Vec<(usize, f64, Vec<f64>, bool)> = Vec::new();
2392
2393    let mut rng = SplitMix64::new(hp.seed);
2394    let mut best: (f64, Option<Vec<Vec<f32>>>, usize) = (f64::INFINITY, None, 0);
2395    let mut losses: Vec<(f64, f64)> = Vec::with_capacity(hp.steps);
2396    let t0 = std::time::Instant::now();
2397    let n_per_step = hp.bs * hp.seq;
2398    for st in 1..=hp.steps {
2399        // Fresh random windows each step (the recipe; indices need not
2400        // match the torch RNG — the distribution does).
2401        let mut ids = Vec::with_capacity(n_per_step);
2402        let mut tgt = Vec::with_capacity(n_per_step);
2403        for _ in 0..hp.bs {
2404            let off = (rng.next_u64() as usize) % (tr.len() - hp.seq - 1);
2405            ids.extend_from_slice(&tr[off..off + hp.seq]);
2406            tgt.extend_from_slice(&tr[off + 1..off + hp.seq + 1]);
2407        }
2408
2409        let ht = fm.forward_hidden(&ids, hp.bs, hp.seq, None, false, None);
2410        let mut keep: Vec<Vec<f32>> = Vec::with_capacity(fm.nl);
2411        let hs = fm.forward_hidden(&ids, hp.bs, hp.seq, Some(&ts), true, Some(&mut keep));
2412        let (ce, kl, dhs) = fm.loss_and_dhidden(&hs, &ht, &tgt, hp.kl_w);
2413        ts.zero_grad();
2414        fm.backward(hp.bs, hp.seq, &keep, dhs, &mut ts);
2415        let gn = ts.clip_and_step(hp.lr);
2416        losses.push((ce, kl));
2417
2418        let el = t0.elapsed().as_secs_f64();
2419        tracing::info!(
2420            "fcd step {st}/{}: ce {ce:.3} kl {kl:.3} |g| {gn:.3} ({:.1}s/step)",
2421            hp.steps,
2422            el / st as f64
2423        );
2424        if hp.eval_every > 0 && st % hp.eval_every == 0 {
2425            let p = fm.val_ppl(va, Some(&ts), true, hp.bs, 2, hp.seq);
2426            match (&mut gate_state, gate) {
2427                (Some((pipe, base)), Some(g)) => {
2428                    apply_trainables(pipe, &fm, &ts);
2429                    let scores = gate_gen_scores(pipe, g)?;
2430                    let pass = gate_pass(&scores, base, g.threshold, g.baseline_slack);
2431                    let tag = if pass && p < best.0 {
2432                        best = (p, Some(ts.data.clone()), st);
2433                        " *best*"
2434                    } else {
2435                        ""
2436                    };
2437                    tracing::info!(
2438                        "fcd eval step {st}: val ppl {p:.2} | gen-gate {}                          (loop-scores {scores:?}){tag}",
2439                        if pass { "PASS" } else { "FAIL" }
2440                    );
2441                    gate_evals.push((st, p, scores, pass));
2442                }
2443                _ => {
2444                    let tag = if p < best.0 {
2445                        best = (p, Some(ts.data.clone()), st);
2446                        " *best*"
2447                    } else {
2448                        ""
2449                    };
2450                    tracing::info!("fcd eval step {st}: val ppl {p:.2}{tag}");
2451                }
2452            }
2453        }
2454    }
2455
2456    // Early stop: restore the best checkpoint (certified: best was step
2457    // 150 of 300 in the torch run). Under the gate, `best` only ever
2458    // held GATE-PASSING checkpoints; none passing → identity restore.
2459    let mut gate_chosen: Option<usize> = None;
2460    if let Some(snap) = best.1.take() {
2461        ts.data = snap;
2462        gate_chosen = Some(best.2);
2463        tracing::info!(
2464            "fcd: restored best checkpoint from step {} (val ppl {:.2})",
2465            best.2,
2466            best.0
2467        );
2468    } else if let Some(init) = init_snapshot {
2469        ts.data = init;
2470        tracing::info!(
2471            "fcd: polish rejected by generation gate — identity artifact              (zero-shot state written; claim 13 floor)"
2472        );
2473    }
2474    let ppl_final = fm.val_ppl(va, Some(&ts), true, hp.bs, 6, hp.seq);
2475    let report = FcdReport {
2476        converted: converted.clone(),
2477        teacher_ppl,
2478        ppl_start,
2479        ppl_best: best.0.min(ppl_final),
2480        best_step: best.2,
2481        ppl_final,
2482        steps_run: hp.steps,
2483        sec_per_step: t0.elapsed().as_secs_f64() / hp.steps.max(1) as f64,
2484        losses,
2485        gate: gate_state.map(|(_, base)| GateReport {
2486            baseline: base,
2487            evals: gate_evals,
2488            chosen: gate_chosen,
2489        }),
2490    };
2491    save_polished(model, out, &fm, &ts, o1, hp, &report)?;
2492    Ok(report)
2493}
2494
2495/// Hot-swap the trainable LN/FFN master copies into a runtime Pipeline
2496/// (frozen tensors stay mmap-backed — this reproduces the artifact the
2497/// polish would write, without writing it).
2498fn apply_trainables(pipe: &mut Pipeline, fm: &FcdModel, ts: &TrainState) {
2499    let hidden = fm.hidden;
2500    for (slot, &li) in ts.layers.iter().enumerate() {
2501        let b = slot * PARAMS_PER_LAYER;
2502        let inter = fm.layers[li].inter;
2503        let lw = &mut pipe.weights.layers[li];
2504        lw.input_norm = ts.data[b].clone();
2505        lw.post_norm = ts.data[b + 1].clone();
2506        lw.ffn = FfnKind::Dense(DenseFfn {
2507            gate_proj: QTensor::from_f32(ts.data[b + 2].clone(), inter, hidden),
2508            up_proj: QTensor::from_f32(ts.data[b + 3].clone(), inter, hidden),
2509            down_proj: QTensor::from_f32(ts.data[b + 4].clone(), hidden, inter),
2510            act: crate::pipeline::Act::Silu,
2511        });
2512    }
2513}
2514
2515/// Greedy loop-score probe through the streaming runtime.
2516fn gate_gen_scores(pipe: &mut Pipeline, g: &GenGateCfg) -> Result<Vec<f64>, String> {
2517    g.prompts
2518        .iter()
2519        .map(|p| {
2520            pipe.generate_from_ids(p, g.gen_tokens, None, None)
2521                .map(|r| loop_score(&r.token_ids))
2522        })
2523        .collect()
2524}
2525
2526/// Write the polished container: every source tensor byte-copied except
2527/// the converted layers' LN/FFN, which become f32 (per-tensor dtypes
2528/// are first-class in the directory — no requant noise on fresh
2529/// weights). Adds `provenance.o1_attn` + `provenance.fcd`.
2530fn save_polished(
2531    model: &CmfModel,
2532    out: &std::path::Path,
2533    fm: &FcdModel,
2534    ts: &TrainState,
2535    o1: &O1Cfg,
2536    hp: &FcdHyper,
2537    report: &FcdReport,
2538) -> Result<(), String> {
2539    use cortiq_core::format::TensorSpec;
2540    let mut replace: std::collections::HashMap<String, (usize, usize)> =
2541        std::collections::HashMap::new(); // name → (slot, param idx)
2542    for (s, &li) in ts.layers.iter().enumerate() {
2543        let p = format!("model.layers.{li}.");
2544        for (k, suffix) in [
2545            (0usize, "input_layernorm.weight"),
2546            (1, "post_attention_layernorm.weight"),
2547            (2, "mlp.gate_proj.weight"),
2548            (3, "mlp.up_proj.weight"),
2549            (4, "mlp.down_proj.weight"),
2550        ] {
2551            replace.insert(format!("{p}{suffix}"), (s, k));
2552        }
2553    }
2554    let mut specs = Vec::with_capacity(model.tensors.len());
2555    for t in &model.tensors {
2556        if let Some(&(s, k)) = replace.get(&t.name) {
2557            let data = &ts.data[s * PARAMS_PER_LAYER + k];
2558            let mut bytes = Vec::with_capacity(data.len() * 4);
2559            for v in data {
2560                bytes.extend_from_slice(&v.to_le_bytes());
2561            }
2562            specs.push(TensorSpec {
2563                name: t.name.clone(),
2564                dtype: TensorDtype::F32,
2565                shape: t.shape.clone(),
2566                data: bytes,
2567            });
2568        } else {
2569            specs.push(TensorSpec {
2570                name: t.name.clone(),
2571                dtype: t.dtype,
2572                shape: t.shape.clone(),
2573                data: model.entry_bytes(t).to_vec(),
2574            });
2575        }
2576    }
2577
2578    let mut header = model.header.clone();
2579    let mut prov = match header.provenance.take() {
2580        Some(serde_json::Value::Object(m)) => m,
2581        _ => serde_json::Map::new(),
2582    };
2583    let layers_json = match &o1.layers {
2584        O1Layers::All => serde_json::json!("all"),
2585        O1Layers::Deep(n) => serde_json::json!(format!("deep{n}")),
2586        O1Layers::List(v) => serde_json::json!(v),
2587    };
2588    prov.insert(
2589        "o1_attn".into(),
2590        serde_json::json!({
2591            "layers": layers_json, "m": o1.m, "w": o1.w, "sink": o1.sink
2592        }),
2593    );
2594    prov.insert(
2595        "fcd".into(),
2596        serde_json::json!({
2597            "steps": hp.steps, "lr": hp.lr, "kl_w": hp.kl_w,
2598            "bs": hp.bs, "seq": hp.seq,
2599            "teacher_ppl": report.teacher_ppl,
2600            "ppl_start": report.ppl_start,
2601            "ppl_final": report.ppl_final,
2602            "best_step": report.best_step,
2603            "converted_layers": report.converted,
2604        }),
2605    );
2606    header.provenance = Some(serde_json::Value::Object(prov));
2607    let _ = fm; // geometry only used for validation today
2608
2609    let masks = if model.masks.masks.is_empty() {
2610        None
2611    } else {
2612        Some(&model.masks)
2613    };
2614    CmfModel::write(out, &header, &specs, masks, model.vocab.as_deref())
2615        .map_err(|e| format!("writing polished cmf: {e}"))
2616}
2617
2618#[cfg(test)]
2619mod tests {
2620    use super::*;
2621
2622    /// Claim-13 selection: lowest ppl AMONG PASSING, not global lowest.
2623    #[test]
2624    fn gate_selects_lowest_ppl_among_passing() {
2625        let base = vec![0.10, 0.00, 0.20];
2626        let evals = vec![
2627            (25usize, 21.0, vec![0.10, 0.05, 0.20]), // pass
2628            (50, 18.0, vec![0.40, 0.00, 0.10]),      // fail: 0.40 > threshold
2629            (75, 19.0, vec![0.15, 0.05, 0.25]),      // pass — best passing
2630            (100, 18.5, vec![0.20, 0.30, 0.20]),     // fail: 0.30 > base+0.10
2631        ];
2632        let sel = select_checkpoint(&evals, &base, 0.35, 0.10);
2633        assert_eq!(sel, Some(2), "step 75 is the lowest-ppl PASSING checkpoint");
2634    }
2635
2636    /// All checkpoints fail → identity (None): the polish must never
2637    /// make generation worse than conversion alone.
2638    #[test]
2639    fn gate_all_fail_is_identity() {
2640        let base = vec![0.0, 0.0, 0.0];
2641        let evals = vec![
2642            (25usize, 15.0, vec![0.50, 0.0, 0.0]),
2643            (50, 14.0, vec![0.0, 0.36, 0.0]),
2644            (75, 13.0, vec![0.0, 0.0, 0.11]), // 0.11 > 0 + 0.10 slack
2645        ];
2646        assert_eq!(select_checkpoint(&evals, &base, 0.35, 0.10), None);
2647    }
2648
2649    /// Boundary discipline: scores AT the threshold / AT base+slack pass
2650    /// ("exceeds" is strict); ties in ppl resolve to the earliest step.
2651    #[test]
2652    fn gate_boundaries_and_tie_break() {
2653        let base = vec![0.25];
2654        assert!(gate_pass(&[0.35], &base, 0.35, 0.10), "== threshold passes");
2655        assert!(
2656            gate_pass(&[0.35], &[0.25], 0.35, 0.10),
2657            "== base+slack passes"
2658        );
2659        assert!(!gate_pass(&[0.351], &base, 0.35, 0.10));
2660        assert!(!gate_pass(&[0.30], &[0.10], 0.35, 0.10), "0.30 > 0.10+0.10");
2661        let evals = vec![(25usize, 20.0, vec![0.10]), (50, 20.0, vec![0.10])];
2662        assert_eq!(
2663            select_checkpoint(&evals, &base, 0.35, 0.10),
2664            Some(0),
2665            "equal ppl → earliest checkpoint"
2666        );
2667    }
2668}