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