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