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