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