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