Skip to main content

cortiq_engine/
fcd_ops.rs

1//! FCD polish — hand-rolled forward/backward operators.
2//!
3//! The training graph is FIXED (docs/RUST_FCD.md), so there is no
4//! autograd and no tape: every operator here is a (forward, backward)
5//! pair written out by hand, llm.c style. Ops are generic over the
6//! minimal `Fp` float trait so the SAME code that trains in f32 is
7//! gradchecked in f64 against central finite differences
8//! (tests/fcd_gradcheck.rs, rel err < 1e-3).
9//!
10//! Conventions:
11//! - all matrices are row-major; weight matrices use the runtime layout
12//!   `[out_dim, in_dim]` (a matvec is out[o] = dot(w_row_o, x));
13//! - every backward ACCUMULATES into its output gradient buffers
14//!   (`+=`) — callers zero them once per step, residual branches then
15//!   just add up naturally;
16//! - the attention-weight ops (`attn_head_*`, `nystrom_head_*`) are
17//!   meant to run in f64: the certified CPU probe computed the T×T
18//!   weight matrices in f64, which also makes raw `exp(±40)` safe with
19//!   no flash-shift bookkeeping.
20
21use crate::pool::Pool;
22
23// ─────────────────────────── float trait ───────────────────────────
24
25/// Minimal float abstraction: just enough for the fixed graph. Not a
26/// general numeric tower — two impls (f32/f64), no external crates.
27pub trait Fp:
28    Copy
29    + PartialOrd
30    + core::ops::Add<Output = Self>
31    + core::ops::Sub<Output = Self>
32    + core::ops::Mul<Output = Self>
33    + core::ops::Div<Output = Self>
34    + core::ops::Neg<Output = Self>
35    + core::ops::AddAssign
36    + core::ops::MulAssign
37    + Send
38    + Sync
39    + 'static
40{
41    const ZERO: Self;
42    const ONE: Self;
43    fn exp(self) -> Self;
44    fn sqrt(self) -> Self;
45    fn maxf(self, o: Self) -> Self;
46    fn fromf(x: f64) -> Self;
47    fn f64(self) -> f64;
48}
49
50impl Fp for f32 {
51    const ZERO: Self = 0.0;
52    const ONE: Self = 1.0;
53    #[inline]
54    fn exp(self) -> Self {
55        f32::exp(self)
56    }
57    #[inline]
58    fn sqrt(self) -> Self {
59        f32::sqrt(self)
60    }
61    #[inline]
62    fn maxf(self, o: Self) -> Self {
63        f32::max(self, o)
64    }
65    #[inline]
66    fn fromf(x: f64) -> Self {
67        x as f32
68    }
69    #[inline]
70    fn f64(self) -> f64 {
71        self as f64
72    }
73}
74
75impl Fp for f64 {
76    const ZERO: Self = 0.0;
77    const ONE: Self = 1.0;
78    #[inline]
79    fn exp(self) -> Self {
80        f64::exp(self)
81    }
82    #[inline]
83    fn sqrt(self) -> Self {
84        f64::sqrt(self)
85    }
86    #[inline]
87    fn maxf(self, o: Self) -> Self {
88        f64::max(self, o)
89    }
90    #[inline]
91    fn fromf(x: f64) -> Self {
92        x
93    }
94    #[inline]
95    fn f64(self) -> f64 {
96        self
97    }
98}
99
100#[inline]
101fn dot<F: Fp>(a: &[F], b: &[F]) -> F {
102    let mut s = F::ZERO;
103    for (x, y) in a.iter().zip(b) {
104        s += *x * *y;
105    }
106    s
107}
108
109// ─────────────────────── generic matmul (nt) ───────────────────────
110
111/// y[n,m] = x[n,k] · w[m,k]ᵀ (serial reference; the f32 hot path is
112/// `gemm_nt` below — same math, blocked + pooled).
113pub fn matmul_nt<F: Fp>(x: &[F], w: &[F], y: &mut [F], n: usize, k: usize, m: usize) {
114    for i in 0..n {
115        let xr = &x[i * k..(i + 1) * k];
116        for o in 0..m {
117            y[i * m + o] = dot(xr, &w[o * k..(o + 1) * k]);
118        }
119    }
120}
121
122/// dX += dY[n,m] · W[m,k] — the input-gradient half of matmul_nt.
123pub fn matmul_nt_dx<F: Fp>(dy: &[F], w: &[F], dx: &mut [F], n: usize, k: usize, m: usize) {
124    for i in 0..n {
125        let dxr = &mut dx[i * k..(i + 1) * k];
126        for o in 0..m {
127            let g = dy[i * m + o];
128            for (d, wv) in dxr.iter_mut().zip(&w[o * k..(o + 1) * k]) {
129                *d += g * *wv;
130            }
131        }
132    }
133}
134
135/// dW += dYᵀ[m,n] · X[n,k] — the weight-gradient half of matmul_nt.
136pub fn matmul_nt_dw<F: Fp>(dy: &[F], x: &[F], dw: &mut [F], n: usize, k: usize, m: usize) {
137    for i in 0..n {
138        let xr = &x[i * k..(i + 1) * k];
139        for o in 0..m {
140            let g = dy[i * m + o];
141            for (d, xv) in dw[o * k..(o + 1) * k].iter_mut().zip(xr) {
142                *d += g * *xv;
143            }
144        }
145    }
146}
147
148// ───────────────────── pooled f32 GEMM hot path ─────────────────────
149
150/// Row block: the block's X stays cache-resident while W streams once
151/// per block (a per-row W stream would move gigabytes per matmul).
152const GEMM_BLOCK: usize = 128;
153
154/// Pointer wrapper for disjoint parallel writes (same pattern as the
155/// pipeline's scatter — workers touch disjoint index ranges).
156struct SendMut<T>(*mut T);
157unsafe impl<T> Send for SendMut<T> {}
158unsafe impl<T> Sync for SendMut<T> {}
159impl<T> SendMut<T> {
160    #[inline]
161    // Deliberate unsynchronized scatter: pool workers write disjoint
162    // ranges in parallel, so returning `&mut` from `&self` is
163    // intentional here (same pattern as the pipeline's SendMut).
164    #[allow(clippy::mut_from_ref)]
165    unsafe fn slice(&self, off: usize, len: usize) -> &mut [T] {
166        unsafe { std::slice::from_raw_parts_mut(self.0.add(off), len) }
167    }
168}
169
170/// y[n,m] = x[n,k] · w[m,k]ᵀ — parallel over row blocks; bit-identical
171/// to `matmul_nt` (disjoint rows, same dot kernel regrouped by NEON).
172pub fn gemm_nt(x: &[f32], w: &[f32], y: &mut [f32], n: usize, k: usize, m: usize, pool: Option<&Pool>) {
173    debug_assert_eq!(x.len(), n * k);
174    debug_assert_eq!(w.len(), m * k);
175    debug_assert_eq!(y.len(), n * m);
176    let nb = n.div_ceil(GEMM_BLOCK);
177    let block = |r0: usize, r1: usize, y: &mut [f32]| {
178        for o in 0..m {
179            let wr = &w[o * k..(o + 1) * k];
180            for i in r0..r1 {
181                y[(i - r0) * m + o] = crate::attention::dot_f32(&x[i * k..(i + 1) * k], wr);
182            }
183        }
184    };
185    match pool {
186        Some(p) if nb > 1 => {
187            let yp = SendMut(y.as_mut_ptr());
188            p.run(&|widx, nw| {
189                for bi in (widx..nb).step_by(nw) {
190                    let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
191                    // SAFETY: blocks are disjoint row ranges of y.
192                    let ys = unsafe { yp.slice(r0 * m, (r1 - r0) * m) };
193                    block(r0, r1, ys);
194                }
195            });
196        }
197        _ => {
198            for bi in 0..nb {
199                let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
200                block(r0, r1, &mut y[r0 * m..r1 * m]);
201            }
202        }
203    }
204}
205
206/// dX += dY[n,m] · W[m,k] — parallel over row blocks (disjoint dX rows).
207pub fn gemm_dx(dy: &[f32], w: &[f32], dx: &mut [f32], n: usize, k: usize, m: usize, pool: Option<&Pool>) {
208    debug_assert_eq!(dy.len(), n * m);
209    debug_assert_eq!(w.len(), m * k);
210    debug_assert_eq!(dx.len(), n * k);
211    let nb = n.div_ceil(GEMM_BLOCK);
212    let block = |r0: usize, r1: usize, dxs: &mut [f32]| {
213        for o in 0..m {
214            let wr = &w[o * k..(o + 1) * k];
215            for i in r0..r1 {
216                let g = dy[i * m + o];
217                if g != 0.0 {
218                    crate::attention::axpy_f32(&mut dxs[(i - r0) * k..(i - r0 + 1) * k], wr, g);
219                }
220            }
221        }
222    };
223    match pool {
224        Some(p) if nb > 1 => {
225            let dxp = SendMut(dx.as_mut_ptr());
226            p.run(&|widx, nw| {
227                for bi in (widx..nb).step_by(nw) {
228                    let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
229                    // SAFETY: blocks are disjoint row ranges of dx.
230                    let dxs = unsafe { dxp.slice(r0 * k, (r1 - r0) * k) };
231                    block(r0, r1, dxs);
232                }
233            });
234        }
235        _ => {
236            for bi in 0..nb {
237                let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
238                block(r0, r1, &mut dx[r0 * k..r1 * k]);
239            }
240        }
241    }
242}
243
244/// dW += dYᵀ · X — parallel over dW ROW ranges (each worker owns a
245/// disjoint slice of output neurons; X is shared read-only).
246pub fn gemm_dw(dy: &[f32], x: &[f32], dw: &mut [f32], n: usize, k: usize, m: usize, pool: Option<&Pool>) {
247    debug_assert_eq!(dy.len(), n * m);
248    debug_assert_eq!(x.len(), n * k);
249    debug_assert_eq!(dw.len(), m * k);
250    let range = |o0: usize, o1: usize, dws: &mut [f32]| {
251        // i-blocked so the X block stays in cache across the o loop.
252        let nb = n.div_ceil(GEMM_BLOCK);
253        for bi in 0..nb {
254            let (r0, r1) = (bi * GEMM_BLOCK, ((bi + 1) * GEMM_BLOCK).min(n));
255            for o in o0..o1 {
256                let dwr = &mut dws[(o - o0) * k..(o - o0 + 1) * k];
257                for i in r0..r1 {
258                    let g = dy[i * m + o];
259                    if g != 0.0 {
260                        crate::attention::axpy_f32(dwr, &x[i * k..(i + 1) * k], g);
261                    }
262                }
263            }
264        }
265    };
266    match pool {
267        Some(p) if m >= 8 => {
268            let dwp = SendMut(dw.as_mut_ptr());
269            p.run(&|widx, nw| {
270                let (o0, o1) = (widx * m / nw, (widx + 1) * m / nw);
271                if o0 < o1 {
272                    // SAFETY: workers own disjoint dW row ranges.
273                    let dws = unsafe { dwp.slice(o0 * k, (o1 - o0) * k) };
274                    range(o0, o1, dws);
275                }
276            });
277        }
278        _ => range(0, m, dw),
279    }
280}
281
282// ───────────────────────────── SiLU ─────────────────────────────
283
284#[inline]
285pub fn silu<F: Fp>(x: F) -> F {
286    x / (F::ONE + (-x).exp())
287}
288
289/// d silu / dx = σ(x)·(1 + x·(1−σ(x))).
290#[inline]
291pub fn silu_bwd<F: Fp>(x: F) -> F {
292    let s = F::ONE / (F::ONE + (-x).exp());
293    s * (F::ONE + x * (F::ONE - s))
294}
295
296// ──────────────────────────── RMSNorm ────────────────────────────
297
298/// RMSNorm over `n` rows of width `d = w.len()`:
299/// Qwen style y = x̂·w, Gemma style y = x̂·(1+w), x̂ = x/√(mean x² + eps).
300/// Sum-of-squares accumulates in f64 (runtime discipline). `inv_out`
301/// stores the per-row 1/rms for the backward.
302pub fn rmsnorm_fwd<F: Fp>(x: &[F], w: &[F], eps: f64, gemma: bool, y: &mut [F], inv_out: &mut [F]) {
303    let d = w.len();
304    let n = x.len() / d;
305    for r in 0..n {
306        let xr = &x[r * d..(r + 1) * d];
307        let mut ss = 0f64;
308        for v in xr {
309            ss += v.f64() * v.f64();
310        }
311        let inv = F::fromf(1.0 / (ss / d as f64 + eps).sqrt());
312        inv_out[r] = inv;
313        let yr = &mut y[r * d..(r + 1) * d];
314        for j in 0..d {
315            let weff = if gemma { F::ONE + w[j] } else { w[j] };
316            yr[j] = xr[j] * inv * weff;
317        }
318    }
319}
320
321/// RMSNorm backward: through-grad into `dx` (+=) and, when the gain is
322/// trainable, gain grad into `dw` (+=). `inv` is the saved 1/rms.
323///
324/// dx_j = inv·w_j·dy_j − x_j·inv³/d · Σ_i dy_i·w_i·x_i
325/// dw_j += dy_j·x_j·inv (identical for both styles: ∂y/∂w = x̂).
326pub fn rmsnorm_bwd<F: Fp>(
327    x: &[F],
328    w: &[F],
329    inv: &[F],
330    dy: &[F],
331    gemma: bool,
332    dx: &mut [F],
333    mut dw: Option<&mut [F]>,
334) {
335    let d = w.len();
336    let n = x.len() / d;
337    for r in 0..n {
338        let xr = &x[r * d..(r + 1) * d];
339        let dyr = &dy[r * d..(r + 1) * d];
340        let iv = inv[r];
341        let mut s = 0f64;
342        for j in 0..d {
343            let weff = if gemma { F::ONE + w[j] } else { w[j] };
344            s += (dyr[j] * weff * xr[j]).f64();
345        }
346        let coef = F::fromf(s / d as f64) * iv * iv * iv;
347        let dxr = &mut dx[r * d..(r + 1) * d];
348        for j in 0..d {
349            let weff = if gemma { F::ONE + w[j] } else { w[j] };
350            dxr[j] += iv * weff * dyr[j] - xr[j] * coef;
351        }
352        if let Some(dwv) = dw.as_deref_mut() {
353            for j in 0..d {
354                dwv[j] += dyr[j] * xr[j] * iv;
355            }
356        }
357    }
358}
359
360// ───────────────────────────── RoPE ─────────────────────────────
361
362/// Rotate the first `2·inv_freq.len()` dims of one head vector in place
363/// (half-split pairing, same convention as `attention::rope_rotate`).
364pub fn rope_fwd<F: Fp>(x: &mut [F], position: usize, inv_freq: &[f64]) {
365    let half = inv_freq.len();
366    for (i, &freq) in inv_freq.iter().enumerate() {
367        let angle = position as f64 * freq;
368        let (sin, cos) = (F::fromf(angle.sin()), F::fromf(angle.cos()));
369        let x0 = x[i];
370        let x1 = x[i + half];
371        x[i] = x0 * cos - x1 * sin;
372        x[i + half] = x0 * sin + x1 * cos;
373    }
374}
375
376/// RoPE through-grad: a rotation's Jacobian is the rotation itself, so
377/// dL/dx = R(−θ)·dL/dy — the inverse rotation, in place.
378pub fn rope_bwd<F: Fp>(dy: &mut [F], position: usize, inv_freq: &[f64]) {
379    let half = inv_freq.len();
380    for (i, &freq) in inv_freq.iter().enumerate() {
381        let angle = position as f64 * freq;
382        let (sin, cos) = (F::fromf(angle.sin()), F::fromf(angle.cos()));
383        let g0 = dy[i];
384        let g1 = dy[i + half];
385        dy[i] = g0 * cos + g1 * sin;
386        dy[i + half] = -g0 * sin + g1 * cos;
387    }
388}
389
390// ─────────────────────── landmark segment means ───────────────────────
391
392/// Contiguous segment means (Nyströmformer landmark recipe), the same
393/// integer split (i·t)/m as `nystrom::seg_means` and the torch probes.
394pub fn seg_means<F: Fp>(x: &[F], t: usize, d: usize, m: usize, out: &mut [F]) {
395    for i in 0..m {
396        let (lo, hi) = (i * t / m, (i + 1) * t / m);
397        let or = &mut out[i * d..(i + 1) * d];
398        for v in or.iter_mut() {
399            *v = F::ZERO;
400        }
401        for j in lo..hi {
402            for c in 0..d {
403                or[c] += x[j * d + c];
404            }
405        }
406        let inv = F::fromf(1.0 / (hi - lo) as f64);
407        for v in or.iter_mut() {
408            *v *= inv;
409        }
410    }
411}
412
413/// Segment-mean backward: the mean is linear, so the landmark grad
414/// scatters back uniformly over its segment (dx += dl/seg_len).
415pub fn seg_means_bwd<F: Fp>(dl: &[F], t: usize, d: usize, m: usize, dx: &mut [F]) {
416    for i in 0..m {
417        let (lo, hi) = (i * t / m, (i + 1) * t / m);
418        let inv = F::fromf(1.0 / (hi - lo) as f64);
419        let dlr = &dl[i * d..(i + 1) * d];
420        for j in lo..hi {
421            for c in 0..d {
422                dx[j * d + c] += dlr[c] * inv;
423            }
424        }
425    }
426}
427
428// ────────────────── exact causal softmax attention ──────────────────
429
430/// Exact per-head causal attention: out[t] = softmax(q_t·Kᵀ/√d)·V over
431/// j ≤ t. `q`,`k` are `[t,d]`, `v` is `[t,dv]`, `out` is `[t,dv]`.
432#[allow(clippy::needless_range_loop)] // row[j] pairs with k-row j — indices are the clearer form
433pub fn attn_head_fwd<F: Fp>(q: &[F], k: &[F], v: &[F], t: usize, d: usize, dv: usize, out: &mut [F]) {
434    let scale = F::fromf(1.0 / (d as f64).sqrt());
435    let mut row = vec![F::ZERO; t];
436    for ti in 0..t {
437        let qr = &q[ti * d..(ti + 1) * d];
438        let mut mx = F::fromf(f64::NEG_INFINITY);
439        for j in 0..=ti {
440            let s = dot(qr, &k[j * d..(j + 1) * d]) * scale;
441            row[j] = s;
442            mx = mx.maxf(s);
443        }
444        let mut den = F::ZERO;
445        for j in 0..=ti {
446            row[j] = (row[j] - mx).exp();
447            den += row[j];
448        }
449        let or = &mut out[ti * dv..(ti + 1) * dv];
450        for o in or.iter_mut() {
451            *o = F::ZERO;
452        }
453        for j in 0..=ti {
454            let p = row[j] / den;
455            for (o, vv) in or.iter_mut().zip(&v[j * dv..(j + 1) * dv]) {
456                *o += p * *vv;
457            }
458        }
459    }
460}
461
462/// Exact-attention backward (probs recomputed row by row — the trainer
463/// is layer-checkpointed, nothing is stored). Standard softmax chain:
464/// ds_j = p_j·(dp_j − Σ p·dp), dp_j = dout·v_j.
465#[allow(clippy::too_many_arguments, clippy::needless_range_loop)]
466pub fn attn_head_bwd<F: Fp>(
467    q: &[F],
468    k: &[F],
469    v: &[F],
470    dout: &[F],
471    t: usize,
472    d: usize,
473    dv: usize,
474    dq: &mut [F],
475    dk: &mut [F],
476    dvv: &mut [F],
477) {
478    let scale = F::fromf(1.0 / (d as f64).sqrt());
479    let mut row = vec![F::ZERO; t];
480    for ti in 0..t {
481        let qr = &q[ti * d..(ti + 1) * d];
482        let mut mx = F::fromf(f64::NEG_INFINITY);
483        for j in 0..=ti {
484            let s = dot(qr, &k[j * d..(j + 1) * d]) * scale;
485            row[j] = s;
486            mx = mx.maxf(s);
487        }
488        let mut den = F::ZERO;
489        for j in 0..=ti {
490            row[j] = (row[j] - mx).exp();
491            den += row[j];
492        }
493        let dor = &dout[ti * dv..(ti + 1) * dv];
494        // dp_j and the softmax dot Σ p·dp in one pass.
495        let mut pdp = F::ZERO;
496        let mut dp = vec![F::ZERO; ti + 1];
497        for j in 0..=ti {
498            let p = row[j] / den;
499            row[j] = p; // row now holds probabilities
500            dp[j] = dot(dor, &v[j * dv..(j + 1) * dv]);
501            pdp += p * dp[j];
502        }
503        let dqr = &mut dq[ti * d..(ti + 1) * d];
504        for j in 0..=ti {
505            let p = row[j];
506            // dV
507            for (dvo, o) in dvv[j * dv..(j + 1) * dv].iter_mut().zip(dor) {
508                *dvo += p * *o;
509            }
510            // d logits → dq, dk
511            let ds = p * (dp[j] - pdp) * scale;
512            let kr = &k[j * d..(j + 1) * d];
513            for c in 0..d {
514                dqr[c] += ds * kr[c];
515            }
516            let dkr = &mut dk[j * d..(j + 1) * d];
517            for c in 0..d {
518                dkr[c] += ds * qr[c];
519            }
520        }
521    }
522}
523
524// ─────────────────── Nyström joint attention (matrix form) ───────────────────
525
526/// Nyström joint kernel geometry — matches `nystrom::O1Cfg` semantics.
527#[derive(Clone, Copy, Debug)]
528pub struct NysCfg {
529    /// Landmark budget (m_eff = clamp(t_prefill/8, 4, m), as sealed).
530    pub m: usize,
531    /// Exact-window width.
532    pub w: usize,
533    /// Permanent exact sink keys.
534    pub sink: usize,
535    /// Tokens of each training window that run the EXACT prompt pass
536    /// before the O(1) seal. `None` = half the window.
537    ///
538    /// This is not a free knob: it is the train/serve contract. The
539    /// runtime (`nystrom::NystromState::prefill`) freezes landmarks from
540    /// the PROMPT ONLY and then serves every later position off that
541    /// frozen skeleton, so a trainer that seals from the whole window is
542    /// optimizing a model that is never served. The default mirrors
543    /// `cortiq ppl --o1`'s own default (`o1_prefill = len/2`), which is
544    /// the discipline every published o1 quality number was measured
545    /// with — keep them equal or the polish and the gate disagree.
546    pub prefill: Option<usize>,
547}
548
549impl NysCfg {
550    /// Prompt length for a `t`-token window: the seal point. Clamped to
551    /// [1, t] so a caller cannot ask for a prefix longer than the window.
552    #[inline]
553    pub fn prefill_len(&self, t: usize) -> usize {
554        self.prefill.unwrap_or(t / 2).clamp(1, t)
555    }
556}
557
558/// Joint-denominator floor (mirrors the reference probe / runtime).
559const NYS_DEN_EPS: f64 = 1e-30;
560
561/// Everything the forward computes that the backward reuses. All T×T
562/// buffers — call this with F = f64 (raw exp of real logits overflows
563/// f32; the certified CPU probe ran the skeleton in f64).
564struct NysGraph<F: Fp> {
565    m_eff: usize,
566    /// Seal point: rows < tp are exact (the prompt pass), rows ≥ tp are
567    /// served off the frozen skeleton.
568    tp: usize,
569    q_l: Vec<F>,
570    k_l: Vec<F>,
571    mu: Vec<F>,
572    fu: Vec<F>,
573    e: Vec<F>,
574    fumu: Vec<F>,
575    /// Per row: did the AGGREGATE far denominator survive the guard?
576    /// False ⇒ this row's far field is dropped entirely (weights and
577    /// gradient alike) — see `nys_graph`.
578    ///
579    /// This replaces the raw skeleton estimate `a`, which the backward
580    /// used to carry ONLY to evaluate the per-(t,j) clamp's `[a>0]`
581    /// subgradient mask. The shipped operator gates per ROW, so the mask
582    /// — and the whole T×T buffer behind it — is gone.
583    far_keep: Vec<bool>,
584    /// Final joint weights: exp(lg−c) on near, a·e^{−c} on a kept far
585    /// field, 0 on a dropped one.
586    wmat: Vec<F>,
587    c_row: Vec<F>,
588    den: Vec<F>,
589}
590
591/// Is this row served exactly? Positions before the seal are the
592/// runtime's PROMPT PASS: `Pipeline::nll_ids_o1` runs them through
593/// ordinary full-KV attention and only then calls `o1_seal`, so the
594/// skeleton must not touch them.
595#[inline]
596fn nys_exact_row(ti: usize, tp: usize) -> bool {
597    ti < tp
598}
599
600/// near mask (spec §5b): (t−j < W) OR (j < sink), causal.
601#[inline]
602fn nys_near(ti: usize, j: usize, w: usize, sink: usize) -> bool {
603    ti - j < w || j < sink
604}
605
606/// Build the joint weight matrix (docs/RUST_FCD.md §2.2). M comes from
607/// the RUNTIME's ridge pseudo-inverse and is CONSTANT in backward.
608/// `mu_override` freezes M explicitly (gradcheck of the constant-M
609/// convention); None recomputes it from the landmarks.
610fn nys_graph<F: Fp>(
611    q: &[F],
612    k: &[F],
613    t: usize,
614    d: usize,
615    cfg: &NysCfg,
616    mu_override: Option<&[F]>,
617) -> NysGraph<F> {
618    let scale = 1.0 / (d as f64).sqrt();
619    let fscale = F::fromf(scale);
620    // Landmarks are sealed from the PROMPT PREFIX, exactly as
621    // `NystromState::prefill` does — including m_eff, which the runtime
622    // derives from the prompt length it sealed at, not from how far the
623    // sequence later runs.
624    let tp = cfg.prefill_len(t);
625    let m_eff = (tp / 8).clamp(4, cfg.m);
626    let mut q_l = vec![F::ZERO; m_eff * d];
627    let mut k_l = vec![F::ZERO; m_eff * d];
628    seg_means(&q[..tp * d], tp, d, m_eff, &mut q_l);
629    seg_means(&k[..tp * d], tp, d, m_eff, &mut k_l);
630
631    // Au and its ridge pinv in f64 — one m×m solve, constant in backward.
632    let mut au = vec![0f64; m_eff * m_eff];
633    for i in 0..m_eff {
634        for j in 0..m_eff {
635            let mut s = 0f64;
636            for c in 0..d {
637                s += q_l[i * d + c].f64() * k_l[j * d + c].f64();
638            }
639            au[i * m_eff + j] = (s * scale).exp();
640        }
641    }
642    let mu: Vec<F> = match mu_override {
643        Some(m) => m.to_vec(),
644        None => crate::nystrom::ridge_pinv(&au, m_eff)
645            .iter()
646            .map(|&x| F::fromf(x))
647            .collect(),
648    };
649
650    // Landmark score factors.
651    let mut fu = vec![F::ZERO; t * m_eff];
652    for ti in 0..t {
653        for i in 0..m_eff {
654            fu[ti * m_eff + i] =
655                (dot(&q[ti * d..(ti + 1) * d], &k_l[i * d..(i + 1) * d]) * fscale).exp();
656        }
657    }
658    let mut e = vec![F::ZERO; m_eff * t];
659    for i in 0..m_eff {
660        for j in 0..t {
661            e[i * t + j] =
662                (dot(&q_l[i * d..(i + 1) * d], &k[j * d..(j + 1) * d]) * fscale).exp();
663        }
664    }
665    let mut fumu = vec![F::ZERO; t * m_eff];
666    matmul_nt(
667        &fu,
668        // mu is [m,m] row-major; matmul_nt wants W[m_out, k] rows = muᵀ
669        // columns… avoid transposition juggling: do it directly.
670        &transpose(&mu, m_eff, m_eff),
671        &mut fumu,
672        t,
673        m_eff,
674        m_eff,
675    );
676    // a[t,j] = Σ_i fumu[t,i]·e[i,j] — e is [m,t], so column j of e is
677    // strided; loop with accumulation over i keeps rows contiguous.
678    // Rows before the seal are exact, so their skeleton is never
679    // evaluated (and stays ZERO — the backward relies on that).
680    let mut a = vec![F::ZERO; t * t];
681    for ti in tp..t {
682        let fr = &fumu[ti * m_eff..(ti + 1) * m_eff];
683        let ar = &mut a[ti * t..ti * t + ti + 1]; // causal: j ≤ ti
684        for (i, &f) in fr.iter().enumerate() {
685            let er = &e[i * t..i * t + ti + 1];
686            for (av, ev) in ar.iter_mut().zip(er) {
687                *av += f * *ev;
688            }
689        }
690    }
691
692    // Joint weights with the per-row shift c (constant in backward —
693    // it multiplies numerator and denominator identically).
694    let mut wmat = vec![F::ZERO; t * t];
695    let mut c_row = vec![F::ZERO; t];
696    let mut den = vec![F::ZERO; t];
697    let mut far_keep = vec![false; t];
698    let mut lg_row = vec![F::ZERO; t];
699    for ti in 0..t {
700        let qr = &q[ti * d..(ti + 1) * d];
701        let mut c = F::fromf(f64::NEG_INFINITY);
702        for j in 0..=ti {
703            let s = dot(qr, &k[j * d..(j + 1) * d]) * fscale;
704            lg_row[j] = s;
705            c = c.maxf(s);
706        }
707        c_row[ti] = c;
708        let emc = (-c).exp();
709        let exact_row = nys_exact_row(ti, tp);
710
711        // AGGREGATE guard (`O1Rect::Aggregate`, the shipped default).
712        // The runtime never materializes a per-(t,j) weight — it only
713        // ever holds the far field already contracted into accumulators,
714        // so the ONLY quantity it can test is the row's total far
715        // denominator:
716        //   far_den = Σ_b u[b]·ẑ[b] = e^{−c_all}·Σ_{j far} a[t,j]
717        // (`nystrom.rs::step`). e^{−c_all} > 0, so the runtime's
718        // `far_den >= 0` predicate is exactly `Σ_{j far} a[t,j] >= 0`
719        // here. A row that fails drops its far field ENTIRELY; a row
720        // that passes keeps every far weight RAW — negative per-key mass
721        // included. That is not an oversight: clamping per key (what the
722        // torch probe did, and what this trainer used to do) is a
723        // strictly different, measurably worse operator (`O1Rect::Fm`,
724        // ×1.414 vs ×1.296) that no streaming kernel can execute.
725        let mut far_sum = F::ZERO;
726        if !exact_row {
727            for j in 0..=ti {
728                if !nys_near(ti, j, cfg.w, cfg.sink) {
729                    far_sum += a[ti * t + j];
730                }
731            }
732        }
733        let keep = !exact_row && far_sum.f64() >= 0.0;
734        far_keep[ti] = keep;
735
736        let wr = &mut wmat[ti * t..(ti + 1) * t];
737        let mut dsum = F::ZERO;
738        for j in 0..=ti {
739            let wv = if exact_row || nys_near(ti, j, cfg.w, cfg.sink) {
740                (lg_row[j] - c).exp()
741            } else if keep {
742                a[ti * t + j] * emc
743            } else {
744                F::ZERO
745            };
746            wr[j] = wv;
747            dsum += wv;
748        }
749        den[ti] = dsum.maxf(F::fromf(NYS_DEN_EPS));
750    }
751    NysGraph { m_eff, tp, q_l, k_l, mu, fu, e, fumu, far_keep, wmat, c_row, den }
752}
753
754fn transpose<F: Fp>(x: &[F], rows: usize, cols: usize) -> Vec<F> {
755    let mut out = vec![F::ZERO; rows * cols];
756    for r in 0..rows {
757        for c in 0..cols {
758            out[c * rows + r] = x[r * cols + c];
759        }
760    }
761    out
762}
763
764/// Nyström joint forward (teacher-forced matrix form). Falls back to
765/// exact attention for short windows (runtime guard: t ≤ W+sink+8).
766#[allow(clippy::too_many_arguments)]
767pub fn nystrom_head_fwd<F: Fp>(
768    q: &[F],
769    k: &[F],
770    v: &[F],
771    t: usize,
772    d: usize,
773    dv: usize,
774    cfg: &NysCfg,
775    out: &mut [F],
776) {
777    if nys_degenerate(t, cfg) {
778        attn_head_fwd(q, k, v, t, d, dv, out);
779        return;
780    }
781    nystrom_head_fwd_mu(q, k, v, t, d, dv, cfg, None, out);
782}
783
784/// Does the runtime skip the skeleton for this window entirely?
785///
786/// `NystromState::prefill` decides `exact_only` from the PROMPT length
787/// (`t <= w + sink + EXACT_SLACK`), not from how long the sequence
788/// eventually grows: a short prompt seals no skeleton, so every later
789/// key stays a permanent exact key. Keying this off the full window `t`
790/// (as this did before) would build a skeleton for windows the runtime
791/// serves exactly.
792#[inline]
793fn nys_degenerate(t: usize, cfg: &NysCfg) -> bool {
794    cfg.prefill_len(t) <= cfg.w + cfg.sink + 8
795}
796
797/// The landmark mixing matrix M of this (q, k) — gradcheck hook for
798/// freezing M across finite-difference perturbations.
799#[doc(hidden)]
800pub fn nystrom_mu_for_test<F: Fp>(q: &[F], k: &[F], t: usize, d: usize, cfg: &NysCfg) -> Vec<F> {
801    nys_graph(q, k, t, d, cfg, None).mu
802}
803
804/// Forward with an explicitly frozen M (gradcheck of the constant-M
805/// convention; the trainer always passes through `nystrom_head_fwd`).
806#[doc(hidden)]
807#[allow(clippy::too_many_arguments)]
808pub fn nystrom_head_fwd_mu<F: Fp>(
809    q: &[F],
810    k: &[F],
811    v: &[F],
812    t: usize,
813    d: usize,
814    dv: usize,
815    cfg: &NysCfg,
816    mu_override: Option<&[F]>,
817    out: &mut [F],
818) {
819    let g = nys_graph(q, k, t, d, cfg, mu_override);
820    for ti in 0..t {
821        let wr = &g.wmat[ti * t..(ti + 1) * t];
822        let den = g.den[ti];
823        let or = &mut out[ti * dv..(ti + 1) * dv];
824        for o in or.iter_mut() {
825            *o = F::ZERO;
826        }
827        for j in 0..=ti {
828            let p = wr[j] / den;
829            if p.f64() != 0.0 {
830                for (o, vv) in or.iter_mut().zip(&v[j * dv..(j + 1) * dv]) {
831                    *o += p * *vv;
832                }
833            }
834        }
835    }
836}
837
838/// Nyström joint backward: dq/dk/dv (+=) with M constant. The whole
839/// graph is recomputed (layer checkpointing) — see docs/RUST_FCD.md
840/// §2.2 for the derivation. Chain summary:
841///   dw[t,j]   = dout_t·(v_j − out_t)/den_t
842///   near:  dlg = dw·w                     → dq, dk (±scale)
843///   far:   da  = dw·e^{−c}·[row kept]     → dFu, dE through the two
844///          matmuls with M constant        → dq, dk, dQ̃, dK̃
845///   landmarks: segment-mean scatter back into the PREFIX rows of
846///          dq, dk (the seal only saw those).
847/// The far gate is per ROW (the aggregate guard), not per key.
848#[allow(clippy::too_many_arguments)]
849pub fn nystrom_head_bwd<F: Fp>(
850    q: &[F],
851    k: &[F],
852    v: &[F],
853    dout: &[F],
854    t: usize,
855    d: usize,
856    dv: usize,
857    cfg: &NysCfg,
858    dq: &mut [F],
859    dk: &mut [F],
860    dvv: &mut [F],
861) {
862    if nys_degenerate(t, cfg) {
863        attn_head_bwd(q, k, v, dout, t, d, dv, dq, dk, dvv);
864        return;
865    }
866    nystrom_head_bwd_mu(q, k, v, dout, t, d, dv, cfg, None, dq, dk, dvv);
867}
868
869/// Backward with an explicitly frozen M (see `nystrom_head_fwd_mu`).
870#[doc(hidden)]
871#[allow(clippy::too_many_arguments)]
872pub fn nystrom_head_bwd_mu<F: Fp>(
873    q: &[F],
874    k: &[F],
875    v: &[F],
876    dout: &[F],
877    t: usize,
878    d: usize,
879    dv: usize,
880    cfg: &NysCfg,
881    mu_override: Option<&[F]>,
882    dq: &mut [F],
883    dk: &mut [F],
884    dvv: &mut [F],
885) {
886    let scale = F::fromf(1.0 / (d as f64).sqrt());
887    let g = nys_graph(q, k, t, d, cfg, mu_override);
888    let m_eff = g.m_eff;
889
890    // Recompute out rows (needed inside dw), then dv and dw in one pass.
891    let mut dwmat = vec![F::ZERO; t * t];
892    let mut out_row = vec![F::ZERO; dv];
893    for ti in 0..t {
894        let wr = &g.wmat[ti * t..(ti + 1) * t];
895        let den = g.den[ti];
896        for o in out_row.iter_mut() {
897            *o = F::ZERO;
898        }
899        for j in 0..=ti {
900            let p = wr[j] / den;
901            if p.f64() != 0.0 {
902                for (o, vv) in out_row.iter_mut().zip(&v[j * dv..(j + 1) * dv]) {
903                    *o += p * *vv;
904                }
905            }
906        }
907        let dor = &dout[ti * dv..(ti + 1) * dv];
908        let dwr = &mut dwmat[ti * t..(ti + 1) * t];
909        for j in 0..=ti {
910            // dV: p·dout
911            let p = wr[j] / den;
912            if p.f64() != 0.0 {
913                for (dvo, o) in dvv[j * dv..(j + 1) * dv].iter_mut().zip(dor) {
914                    *dvo += p * *o;
915                }
916            }
917            // dw = dout·(v_j − out)/den
918            let mut s = F::ZERO;
919            for c in 0..dv {
920                s += dor[c] * (v[j * dv + c] - out_row[c]);
921            }
922            dwr[j] = s / den;
923        }
924    }
925
926    // Near half: dlg = dw·w, straight to dq/dk. A pre-seal row is
927    // exact, so EVERY causal j is a near key for it.
928    for ti in 0..t {
929        let qr = &q[ti * d..(ti + 1) * d];
930        let dqr_base = ti * d;
931        for j in 0..=ti {
932            if !(nys_exact_row(ti, g.tp) || nys_near(ti, j, cfg.w, cfg.sink)) {
933                continue;
934            }
935            let dlg = dwmat[ti * t + j] * g.wmat[ti * t + j] * scale;
936            if dlg.f64() == 0.0 {
937                continue;
938            }
939            let kr = &k[j * d..(j + 1) * d];
940            for c in 0..d {
941                dq[dqr_base + c] += dlg * kr[c];
942            }
943            let dkr = &mut dk[j * d..(j + 1) * d];
944            for c in 0..d {
945                dkr[c] += dlg * qr[c];
946            }
947        }
948    }
949
950    // Far half: da gated by the AGGREGATE guard, then back through the
951    // skeleton.
952    //
953    // Gradient of the guard: the far field enters as the piecewise
954    // function  far(row) = [Σ_far a ≥ 0] · a·e^{−c}, i.e. a per-ROW
955    // switch, not a per-key clamp. On the kept branch it is the identity
956    // in a — so the far grad flows RAW, with no [a>0] mask (that mask
957    // belonged to the per-key clamp this replaces and would now zero the
958    // gradient of exactly the negative-mass keys the shipped operator
959    // keeps). On the dropped branch the far field is identically 0 in a
960    // neighbourhood of a, so its gradient is 0. The switching boundary
961    // itself (Σ_far a = 0) is measure-zero and, like any subgradient
962    // convention, is not differentiated — at that point far ≡ 0 anyway,
963    // so the two branches agree in value and only the derivative jumps.
964    let mut da = vec![F::ZERO; t * t];
965    for ti in g.tp..t {
966        if !g.far_keep[ti] {
967            continue; // dropped row: zero gradient through its far field
968        }
969        let emc = (-g.c_row[ti]).exp();
970        for j in 0..=ti {
971            if nys_near(ti, j, cfg.w, cfg.sink) {
972                continue;
973            }
974            da[ti * t + j] = dwmat[ti * t + j] * emc;
975        }
976    }
977    // dFuMu[t,i] = Σ_j da[t,j]·e[i,j]
978    let mut dfumu = vec![F::ZERO; t * m_eff];
979    for ti in 0..t {
980        let dar = &da[ti * t..ti * t + ti + 1];
981        let dfr = &mut dfumu[ti * m_eff..(ti + 1) * m_eff];
982        for (i, df) in dfr.iter_mut().enumerate() {
983            let er = &g.e[i * t..i * t + ti + 1];
984            let mut s = F::ZERO;
985            for (av, ev) in dar.iter().zip(er) {
986                s += *av * *ev;
987            }
988            *df = s;
989        }
990    }
991    // dFu = dFuMu·Muᵀ  (M constant)
992    let mut dfu = vec![F::ZERO; t * m_eff];
993    matmul_nt(&dfumu, &g.mu, &mut dfu, t, m_eff, m_eff);
994    // dE[i,j] = Σ_t fumu[t,i]·da[t,j]
995    let mut de = vec![F::ZERO; m_eff * t];
996    for ti in 0..t {
997        let dar = &da[ti * t..ti * t + ti + 1];
998        let fr = &g.fumu[ti * m_eff..(ti + 1) * m_eff];
999        for (i, &f) in fr.iter().enumerate() {
1000            if f.f64() == 0.0 {
1001                continue;
1002            }
1003            let der = &mut de[i * t..i * t + ti + 1];
1004            for (dev, av) in der.iter_mut().zip(dar) {
1005                *dev += f * *av;
1006            }
1007        }
1008    }
1009    // Chain through the two exponentials into dq/dk and the landmark
1010    // grads dQ̃/dK̃.
1011    let mut dq_l = vec![F::ZERO; m_eff * d];
1012    let mut dk_l = vec![F::ZERO; m_eff * d];
1013    for ti in 0..t {
1014        let qr = &q[ti * d..(ti + 1) * d];
1015        for i in 0..m_eff {
1016            let dlg = dfu[ti * m_eff + i] * g.fu[ti * m_eff + i] * scale;
1017            if dlg.f64() == 0.0 {
1018                continue;
1019            }
1020            let klr = &g.k_l[i * d..(i + 1) * d];
1021            for c in 0..d {
1022                dq[ti * d + c] += dlg * klr[c];
1023            }
1024            let dklr = &mut dk_l[i * d..(i + 1) * d];
1025            for c in 0..d {
1026                dklr[c] += dlg * qr[c];
1027            }
1028        }
1029    }
1030    for i in 0..m_eff {
1031        let qlr = &g.q_l[i * d..(i + 1) * d];
1032        for j in 0..t {
1033            let dlg = de[i * t + j] * g.e[i * t + j] * scale;
1034            if dlg.f64() == 0.0 {
1035                continue;
1036            }
1037            let kr = &k[j * d..(j + 1) * d];
1038            let dqlr = &mut dq_l[i * d..(i + 1) * d];
1039            for c in 0..d {
1040                dqlr[c] += dlg * kr[c];
1041            }
1042            for c in 0..d {
1043                dk[j * d + c] += dlg * qlr[c];
1044            }
1045        }
1046    }
1047    // Landmarks were sealed from the prompt prefix, so their gradient
1048    // scatters back into those rows ONLY — a post-seal token cannot move
1049    // a landmark it never contributed to.
1050    let tp = g.tp;
1051    seg_means_bwd(&dq_l, tp, d, m_eff, &mut dq[..tp * d]);
1052    seg_means_bwd(&dk_l, tp, d, m_eff, &mut dk[..tp * d]);
1053}
1054
1055// ───────────────────────────── losses ─────────────────────────────
1056
1057/// One position of the polish loss (docs/RUST_FCD.md §2.4):
1058/// L = (1−klw)·CE(student, target) + klw·KL(teacher‖student), both
1059/// per-position; `inv_n` = 1/(B·T) folds the batch mean into dlogits.
1060/// Returns (ce, kl) UNWEIGHTED for logging; dlogits gets the combined
1061/// gradient (+= is NOT used — each position owns its slice).
1062pub fn ce_kl_position<F: Fp>(
1063    s_logits: &[F],
1064    t_logits: &[F],
1065    target: usize,
1066    kl_w: f64,
1067    inv_n: f64,
1068    dlogits: &mut [F],
1069) -> (f64, f64) {
1070    let vsz = s_logits.len();
1071    debug_assert_eq!(t_logits.len(), vsz);
1072    // Student log-softmax in f64.
1073    let mut smax = f64::NEG_INFINITY;
1074    let mut tmax = f64::NEG_INFINITY;
1075    for i in 0..vsz {
1076        smax = smax.max(s_logits[i].f64());
1077        tmax = tmax.max(t_logits[i].f64());
1078    }
1079    let mut ssum = 0f64;
1080    let mut tsum = 0f64;
1081    for i in 0..vsz {
1082        ssum += (s_logits[i].f64() - smax).exp();
1083        tsum += (t_logits[i].f64() - tmax).exp();
1084    }
1085    let slz = smax + ssum.ln();
1086    let tlz = tmax + tsum.ln();
1087    let ce = slz - s_logits[target].f64();
1088    let mut kl = 0f64;
1089    for i in 0..vsz {
1090        let ls = s_logits[i].f64() - slz;
1091        let lt = t_logits[i].f64() - tlz;
1092        let pt = lt.exp();
1093        let ps = ls.exp();
1094        if pt > 0.0 {
1095            kl += pt * (lt - ls);
1096        }
1097        let mut gd = (1.0 - kl_w) * ps + kl_w * (ps - pt);
1098        if i == target {
1099            gd -= 1.0 - kl_w;
1100        }
1101        dlogits[i] = F::fromf(gd * inv_n);
1102    }
1103    (ce, kl)
1104}
1105
1106// ─────────────────── GatedDeltaNet through-backward ───────────────────
1107//
1108// Faithful BPTT through `linear_core::gdn_step` semantics (docs/
1109// RUST_FCD.md §3): depthwise causal conv + SiLU, per-group l2-normalized
1110// q/k, gates g = exp(−exp(A_log)·softplus(a+dt_bias)) and β = σ(b), the
1111// delta-rule recurrence S ← g·S; kv = Sᵀk̂; S += k̂⊗β(v−kv); o = Sᵀq̂, and
1112// the gated per-head RMSNorm output x̂·w·silu(z). Through-grad ONLY —
1113// every GDN weight stays frozen (the FCD policy: attention operators
1114// are closed-form/frozen, training touches LN+FFN of converted layers).
1115//
1116// The backward stores the full per-head state history S_0..S_T (one
1117// head at a time: T·dk·dv floats — ~67 MB f64 at Qwen3.5-0.8B geometry,
1118// freed per head). Larger models would switch to segment checkpoints;
1119// the entry points below don't change for that.
1120
1121/// GDN geometry + frozen elementwise weights for the sequence ops.
1122pub struct GdnSeqCfg<'a> {
1123    pub nv: usize,
1124    pub nk: usize,
1125    pub dk: usize,
1126    pub dv: usize,
1127    pub kk: usize,
1128    pub rms_eps: f64,
1129    /// Depthwise conv taps `[c_dim × kk]`, oldest→newest (tap kk−1
1130    /// multiplies the current position) — `GdnWeights::conv1d` layout.
1131    pub conv: &'a [f32],
1132    /// Per-v-head decay parameter A_log `[nv]`.
1133    pub a_log: &'a [f32],
1134    /// Per-v-head dt bias `[nv]`.
1135    pub dt_bias: &'a [f32],
1136    /// Gated-RMSNorm gain `[dv]` (plain x̂·w, norm-before-gate).
1137    pub norm: &'a [f32],
1138}
1139
1140impl GdnSeqCfg<'_> {
1141    pub fn c_dim(&self) -> usize {
1142        2 * self.nk * self.dk + self.nv * self.dv
1143    }
1144}
1145
1146#[inline]
1147fn softplus_f<F: Fp>(x: F) -> F {
1148    // Same threshold as linear_core::softplus — parity matters more
1149    // than elegance (σ(20) ≈ 1 − 2e-9, consistent with the cutoff).
1150    if x.f64() > 20.0 {
1151        x
1152    } else {
1153        F::fromf(x.f64().exp().ln_1p())
1154    }
1155}
1156
1157#[inline]
1158fn sigmoid_f<F: Fp>(x: F) -> F {
1159    F::ONE / (F::ONE + (-x).exp())
1160}
1161
1162/// Depthwise causal conv + SiLU over the whole window: raw `[t, c_dim]`
1163/// → (pre-activation `[t, c_dim]`, cq = silu(pre)). Ring semantics of
1164/// `gdn_step` from a fresh state: positions before 0 are zeros.
1165pub fn gdn_conv_fwd<F: Fp>(
1166    raw: &[F],
1167    t: usize,
1168    c_dim: usize,
1169    kk: usize,
1170    conv: &[f32],
1171    pre: &mut [F],
1172    cq: &mut [F],
1173) {
1174    for ti in 0..t {
1175        for c in 0..c_dim {
1176            let taps = &conv[c * kk..(c + 1) * kk];
1177            let mut acc = F::ZERO;
1178            for (j, &tap) in taps.iter().enumerate() {
1179                // tap j multiplies raw position ti − (kk−1) + j.
1180                let p = ti as isize - (kk as isize - 1) + j as isize;
1181                if p >= 0 {
1182                    acc += raw[p as usize * c_dim + c] * F::fromf(tap as f64);
1183                }
1184            }
1185            pre[ti * c_dim + c] = acc;
1186            cq[ti * c_dim + c] = silu(acc);
1187        }
1188    }
1189}
1190
1191/// Conv+SiLU backward: dcq → draw (+=). Taps frozen (through-grad only).
1192pub fn gdn_conv_bwd<F: Fp>(
1193    pre: &[F],
1194    t: usize,
1195    c_dim: usize,
1196    kk: usize,
1197    conv: &[f32],
1198    dcq: &[F],
1199    draw: &mut [F],
1200) {
1201    for ti in 0..t {
1202        for c in 0..c_dim {
1203            let g = dcq[ti * c_dim + c];
1204            if g.f64() == 0.0 {
1205                continue;
1206            }
1207            let dp = g * silu_bwd(pre[ti * c_dim + c]);
1208            let taps = &conv[c * kk..(c + 1) * kk];
1209            for (j, &tap) in taps.iter().enumerate() {
1210                let p = ti as isize - (kk as isize - 1) + j as isize;
1211                if p >= 0 {
1212                    draw[p as usize * c_dim + c] += dp * F::fromf(tap as f64);
1213                }
1214            }
1215        }
1216    }
1217}
1218
1219/// l2-normalization factors of one q/k vector, matching gdn_step:
1220/// invq = 1/(√(Σq²+1e-6)·√dk), invk = 1/√(Σk²+1e-6).
1221#[inline]
1222fn gdn_inv<F: Fp>(x: &[F], extra_scale: f64) -> (F, F) {
1223    let mut n2 = F::ZERO;
1224    for v in x {
1225        n2 += *v * *v;
1226    }
1227    let n2e = n2 + F::fromf(1e-6);
1228    let inv = F::ONE / (n2e.sqrt() * F::fromf(extra_scale));
1229    (inv, n2e)
1230}
1231
1232/// One GQA group (k-head `ko`, its rep = nv/nk v-heads) forward over
1233/// the window. Writes `out[t, nv·dv]` slices of its v-heads only.
1234#[allow(clippy::too_many_arguments)]
1235pub fn gdn_group_fwd<F: Fp>(
1236    cq: &[F],
1237    z: &[F],
1238    a: &[F],
1239    b: &[F],
1240    t: usize,
1241    cfg: &GdnSeqCfg,
1242    ko: usize,
1243    out: &mut [F],
1244) {
1245    let (nv, nk, dk, dv) = (cfg.nv, cfg.nk, cfg.dk, cfg.dv);
1246    let c_dim = cfg.c_dim();
1247    let kd = nk * dk;
1248    let rep = nv / nk;
1249    let vd = nv * dv;
1250    let sqdk = (dk as f64).sqrt();
1251    for hh in 0..rep {
1252        let h = ko * rep + hh;
1253        let ea = F::fromf((cfg.a_log[h] as f64).exp());
1254        let mut s = vec![F::ZERO; dk * dv];
1255        let mut kv = vec![F::ZERO; dv];
1256        let mut o = vec![F::ZERO; dv];
1257        for ti in 0..t {
1258            let qrow = &cq[ti * c_dim + ko * dk..ti * c_dim + (ko + 1) * dk];
1259            let krow = &cq[ti * c_dim + kd + ko * dk..ti * c_dim + kd + (ko + 1) * dk];
1260            let vrow = &cq[ti * c_dim + 2 * kd + h * dv..ti * c_dim + 2 * kd + (h + 1) * dv];
1261            let (invq, _) = gdn_inv(qrow, sqdk);
1262            let (invk, _) = gdn_inv(krow, 1.0);
1263            let g = (-ea * softplus_f(a[ti * nv + h] + F::fromf(cfg.dt_bias[h] as f64))).exp();
1264            let beta = sigmoid_f(b[ti * nv + h]);
1265            // S ← g·S; kv = Sᵀk̂; S += k̂ ⊗ β(v − kv); o = Sᵀq̂.
1266            for x in kv.iter_mut() {
1267                *x = F::ZERO;
1268            }
1269            for di in 0..dk {
1270                let kf = krow[di] * invk;
1271                let row = &mut s[di * dv..(di + 1) * dv];
1272                for dj in 0..dv {
1273                    row[dj] *= g;
1274                    kv[dj] += row[dj] * kf;
1275                }
1276            }
1277            for x in o.iter_mut() {
1278                *x = F::ZERO;
1279            }
1280            for di in 0..dk {
1281                let kf = krow[di] * invk;
1282                let qf = qrow[di] * invq;
1283                let row = &mut s[di * dv..(di + 1) * dv];
1284                for dj in 0..dv {
1285                    row[dj] += kf * (vrow[dj] - kv[dj]) * beta;
1286                    o[dj] += qf * row[dj];
1287                }
1288            }
1289            // Gated per-head RMSNorm: x̂·w·silu(z), norm BEFORE gate.
1290            let mut ss = 0f64;
1291            for v in &o {
1292                ss += v.f64() * v.f64();
1293            }
1294            let inv = F::fromf(1.0 / (ss / dv as f64 + cfg.rms_eps).sqrt());
1295            for dj in 0..dv {
1296                let zv = z[ti * vd + h * dv + dj];
1297                out[ti * vd + h * dv + dj] =
1298                    o[dj] * inv * F::fromf(cfg.norm[dj] as f64) * silu(zv);
1299            }
1300        }
1301    }
1302}
1303
1304/// One GQA group backward: BPTT over the window given `dout` rows of
1305/// this group's v-heads. Accumulates (+=) into dcq (q/k channels of
1306/// `ko`, v channels of its v-heads), dz, da, db. All weights frozen.
1307#[allow(clippy::too_many_arguments)]
1308pub fn gdn_group_bwd<F: Fp>(
1309    cq: &[F],
1310    z: &[F],
1311    a: &[F],
1312    b: &[F],
1313    t: usize,
1314    cfg: &GdnSeqCfg,
1315    ko: usize,
1316    dout: &[F],
1317    dcq: &mut [F],
1318    dz: &mut [F],
1319    da: &mut [F],
1320    db: &mut [F],
1321) {
1322    let (nv, nk, dk, dv) = (cfg.nv, cfg.nk, cfg.dk, cfg.dv);
1323    let c_dim = cfg.c_dim();
1324    let kd = nk * dk;
1325    let rep = nv / nk;
1326    let vd = nv * dv;
1327    let sqdk = (dk as f64).sqrt();
1328    for hh in 0..rep {
1329        let h = ko * rep + hh;
1330        let ea = F::fromf((cfg.a_log[h] as f64).exp());
1331
1332        // ── replay forward, keeping the state history + per-step scalars ──
1333        let mut s_hist = vec![F::ZERO; (t + 1) * dk * dv]; // S_0 = 0
1334        let mut kv_hist = vec![F::ZERO; t * dv];
1335        let mut o_hist = vec![F::ZERO; t * dv];
1336        let mut g_v = vec![F::ZERO; t];
1337        let mut beta_v = vec![F::ZERO; t];
1338        let mut sp_arg = vec![F::ZERO; t]; // a + dt_bias (for σ in the chain)
1339        for ti in 0..t {
1340            let qrow = &cq[ti * c_dim + ko * dk..ti * c_dim + (ko + 1) * dk];
1341            let krow = &cq[ti * c_dim + kd + ko * dk..ti * c_dim + kd + (ko + 1) * dk];
1342            let vrow = &cq[ti * c_dim + 2 * kd + h * dv..ti * c_dim + 2 * kd + (h + 1) * dv];
1343            let (invq, _) = gdn_inv(qrow, sqdk);
1344            let (invk, _) = gdn_inv(krow, 1.0);
1345            let arg = a[ti * nv + h] + F::fromf(cfg.dt_bias[h] as f64);
1346            let g = (-ea * softplus_f(arg)).exp();
1347            let beta = sigmoid_f(b[ti * nv + h]);
1348            sp_arg[ti] = arg;
1349            g_v[ti] = g;
1350            beta_v[ti] = beta;
1351            let (prev, cur) = s_hist.split_at_mut((ti + 1) * dk * dv);
1352            let sp = &prev[ti * dk * dv..];
1353            let sn = &mut cur[..dk * dv];
1354            let kvr = &mut kv_hist[ti * dv..(ti + 1) * dv];
1355            for di in 0..dk {
1356                let kf = krow[di] * invk;
1357                for dj in 0..dv {
1358                    let dec = sp[di * dv + dj] * g;
1359                    sn[di * dv + dj] = dec;
1360                    kvr[dj] += dec * kf;
1361                }
1362            }
1363            let or = &mut o_hist[ti * dv..(ti + 1) * dv];
1364            for di in 0..dk {
1365                let kf = krow[di] * invk;
1366                let qf = qrow[di] * invq;
1367                for dj in 0..dv {
1368                    let sv = sn[di * dv + dj] + kf * (vrow[dj] - kvr[dj]) * beta_v[ti];
1369                    sn[di * dv + dj] = sv;
1370                    or[dj] += qf * sv;
1371                }
1372            }
1373        }
1374
1375        // ── reverse sweep ──
1376        let mut ds = vec![F::ZERO; dk * dv];
1377        let mut do_o = vec![F::ZERO; dv];
1378        let mut du = vec![F::ZERO; dv];
1379        let mut dkv = vec![F::ZERO; dv];
1380        let mut dqh = vec![F::ZERO; dk];
1381        let mut dkh = vec![F::ZERO; dk];
1382        for ti in (0..t).rev() {
1383            let qrow = &cq[ti * c_dim + ko * dk..ti * c_dim + (ko + 1) * dk];
1384            let krow = &cq[ti * c_dim + kd + ko * dk..ti * c_dim + kd + (ko + 1) * dk];
1385            let vrow = &cq[ti * c_dim + 2 * kd + h * dv..ti * c_dim + 2 * kd + (h + 1) * dv];
1386            let (invq, nq2) = gdn_inv(qrow, sqdk);
1387            let (invk, nk2) = gdn_inv(krow, 1.0);
1388            let g = g_v[ti];
1389            let beta = beta_v[ti];
1390            let s_t = &s_hist[(ti + 1) * dk * dv..(ti + 2) * dk * dv];
1391            let s_prev = &s_hist[ti * dk * dv..(ti + 1) * dk * dv];
1392            let kvr = &kv_hist[ti * dv..(ti + 1) * dv];
1393            let or = &o_hist[ti * dv..(ti + 1) * dv];
1394
1395            // 1. Gated RMSNorm output: of = (o·inv)·w·silu(z).
1396            let mut ss = 0f64;
1397            for v in or {
1398                ss += v.f64() * v.f64();
1399            }
1400            let inv = F::fromf(1.0 / (ss / dv as f64 + cfg.rms_eps).sqrt());
1401            let dofr = &dout[ti * vd + h * dv..ti * vd + (h + 1) * dv];
1402            // dz and the effective-gain through-grad in one pass.
1403            let mut sdot = 0f64; // Σ dof·weff·o (for the rms through term)
1404            for dj in 0..dv {
1405                let zv = z[ti * vd + h * dv + dj];
1406                let w = F::fromf(cfg.norm[dj] as f64);
1407                let weff = w * silu(zv);
1408                sdot += (dofr[dj] * weff * or[dj]).f64();
1409                dz[ti * vd + h * dv + dj] += dofr[dj] * or[dj] * inv * w * silu_bwd(zv);
1410            }
1411            let coef = F::fromf(sdot / dv as f64) * inv * inv * inv;
1412            for dj in 0..dv {
1413                let zv = z[ti * vd + h * dv + dj];
1414                let weff = F::fromf(cfg.norm[dj] as f64) * silu(zv);
1415                do_o[dj] = inv * weff * dofr[dj] - or[dj] * coef;
1416            }
1417
1418            // 2. o = S_tᵀ q̂ → dS += q̂ ⊗ do, dq̂ = S_t·do.
1419            for x in dqh.iter_mut() {
1420                *x = F::ZERO;
1421            }
1422            for di in 0..dk {
1423                let qf = qrow[di] * invq;
1424                let row = &s_t[di * dv..(di + 1) * dv];
1425                let dsr = &mut ds[di * dv..(di + 1) * dv];
1426                let mut acc = F::ZERO;
1427                for dj in 0..dv {
1428                    dsr[dj] += qf * do_o[dj];
1429                    acc += row[dj] * do_o[dj];
1430                }
1431                dqh[di] = acc;
1432            }
1433
1434            // 3. S_t = S_pre + k̂ ⊗ u, u = β(v − kv):
1435            //    du = dSᵀk̂; dk̂ = dS·u; dβ = du·(v−kv); dv = β·du; dkv = −β·du.
1436            for x in du.iter_mut() {
1437                *x = F::ZERO;
1438            }
1439            for x in dkh.iter_mut() {
1440                *x = F::ZERO;
1441            }
1442            for di in 0..dk {
1443                let kf = krow[di] * invk;
1444                let dsr = &ds[di * dv..(di + 1) * dv];
1445                let mut acc = F::ZERO;
1446                for dj in 0..dv {
1447                    du[dj] += dsr[dj] * kf;
1448                    acc += dsr[dj] * (vrow[dj] - kvr[dj]) * beta;
1449                }
1450                dkh[di] = acc;
1451            }
1452            let mut dbeta = F::ZERO;
1453            for dj in 0..dv {
1454                dbeta += du[dj] * (vrow[dj] - kvr[dj]);
1455                // v channels of cq
1456                dcq[ti * c_dim + 2 * kd + h * dv + dj] += beta * du[dj];
1457                dkv[dj] = -(beta * du[dj]);
1458            }
1459
1460            // 4. kv = S_preᵀk̂ → dS_pre = dS + k̂ ⊗ dkv; dk̂ += S_pre·dkv.
1461            //    5. S_pre = g·S_{t−1} → dg = ⟨dS_pre, S_{t−1}⟩; carry
1462            //    dS = g·dS_pre to the previous step. (S_pre = g·s_prev
1463            //    is rebuilt on the fly.)
1464            let mut dg = F::ZERO;
1465            for di in 0..dk {
1466                let kf = krow[di] * invk;
1467                let spr = &s_prev[di * dv..(di + 1) * dv];
1468                let dsr = &mut ds[di * dv..(di + 1) * dv];
1469                let mut acc = F::ZERO;
1470                for dj in 0..dv {
1471                    let dspre = dsr[dj] + kf * dkv[dj];
1472                    acc += (spr[dj] * g) * dkv[dj];
1473                    dg += dspre * spr[dj];
1474                    dsr[dj] = g * dspre;
1475                }
1476                dkh[di] += acc;
1477            }
1478
1479            // 6. Gates: g = exp(−e_A·softplus(arg)), β = σ(b).
1480            let sig = sigmoid_f(sp_arg[ti]);
1481            da[ti * nv + h] += dg * g * (-ea) * sig;
1482            db[ti * nv + h] += dbeta * beta * (F::ONE - beta);
1483
1484            // 7. l2-norm through-grads into the shared q/k channels.
1485            //    q̂ = q·invq with invq = 1/(√(Σq²+eps)·√dk):
1486            //    dq = invq·dq̂ − q·(dq̂·q)·invq/(Σq²+eps).
1487            let mut qdot = F::ZERO;
1488            let mut kdot = F::ZERO;
1489            for di in 0..dk {
1490                qdot += dqh[di] * qrow[di];
1491                kdot += dkh[di] * krow[di];
1492            }
1493            for di in 0..dk {
1494                dcq[ti * c_dim + ko * dk + di] +=
1495                    invq * dqh[di] - qrow[di] * qdot * invq / nq2;
1496                dcq[ti * c_dim + kd + ko * dk + di] +=
1497                    invk * dkh[di] - krow[di] * kdot * invk / nk2;
1498            }
1499        }
1500    }
1501}
1502
1503/// Whole-layer GDN sequence forward (serial over groups): raw
1504/// projections → out `[t, nv·dv]`. The trainer parallelizes groups on
1505/// the pool with the same `gdn_group_*` entry points.
1506pub fn gdn_seq_fwd<F: Fp>(
1507    qkv: &[F],
1508    z: &[F],
1509    a: &[F],
1510    b: &[F],
1511    t: usize,
1512    cfg: &GdnSeqCfg,
1513    out: &mut [F],
1514) {
1515    let c_dim = cfg.c_dim();
1516    let mut pre = vec![F::ZERO; t * c_dim];
1517    let mut cq = vec![F::ZERO; t * c_dim];
1518    gdn_conv_fwd(qkv, t, c_dim, cfg.kk, cfg.conv, &mut pre, &mut cq);
1519    for ko in 0..cfg.nk {
1520        gdn_group_fwd(&cq, z, a, b, t, cfg, ko, out);
1521    }
1522}
1523
1524/// Whole-layer GDN sequence backward: through-grads (+=) into the four
1525/// projection streams.
1526#[allow(clippy::too_many_arguments)]
1527pub fn gdn_seq_bwd<F: Fp>(
1528    qkv: &[F],
1529    z: &[F],
1530    a: &[F],
1531    b: &[F],
1532    t: usize,
1533    cfg: &GdnSeqCfg,
1534    dout: &[F],
1535    dqkv: &mut [F],
1536    dz: &mut [F],
1537    da: &mut [F],
1538    db: &mut [F],
1539) {
1540    let c_dim = cfg.c_dim();
1541    let mut pre = vec![F::ZERO; t * c_dim];
1542    let mut cq = vec![F::ZERO; t * c_dim];
1543    gdn_conv_fwd(qkv, t, c_dim, cfg.kk, cfg.conv, &mut pre, &mut cq);
1544    let mut dcq = vec![F::ZERO; t * c_dim];
1545    for ko in 0..cfg.nk {
1546        gdn_group_bwd(&cq, z, a, b, t, cfg, ko, dout, &mut dcq, dz, da, db);
1547    }
1548    gdn_conv_bwd(&pre, t, c_dim, cfg.kk, cfg.conv, &dcq, dqkv);
1549}