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