Skip to main content

ferrox_models/
kda.rs

1//! Kimi K3's KDA (Kimi Delta Attention): a gated delta-rule linear
2//! attention mechanism used on the majority of Kimi K3's layers
3//! (69 of 93, per `AttentionKind::KimiHybrid`), interleaved with Gated
4//! MLA (`ferrox_models::mla`) on the remainder.
5//!
6//! Transcribed directly from real reference source fetched live (not
7//! guessed, not derived by analogy to other gated-linear-attention
8//! designs):
9//! - `moonshotai/Kimi-K3`'s `modeling_kimi_linear.py`,
10//!   `KimiDeltaAttention` (q/k/v projections, short causal convolutions,
11//!   decay-gate and beta projections, output gate, `RMSNormGated`).
12//! - `fla-org/flash-linear-attention`'s `fla/ops/kda/naive.py`
13//!   (`naive_recurrent_kda`) for the core state recurrence: per position,
14//!   decay the state by `exp(g)`, add a rank-1 correction
15//!   `beta * k ⊗ (v - kᵀS)`, read the output as `qᵀS`.
16//! - That same project's `fla/ops/kda/fused_recurrent.py` Triton kernel
17//!   source (the kernel actually invoked in decode, read directly since
18//!   `naive_recurrent_kda` takes its inputs pre-transformed) for the
19//!   exact preprocessing: L2-normalize q/k (`eps=1e-6`) before the
20//!   recurrence, then scale q by `head_dim^-0.5`.
21//! - `fla`'s `fla/modules/conv/short_conv.py` (`ShortConvolution`: a
22//!   depthwise causal `Conv1d`, `padding=kernel_size-1`, no bias) and
23//!   `fla/modules/fused_norm_gate.py` (`FusedRMSNormGated`) for the short
24//!   causal conv and output-gate formulas.
25//!
26//! Two real, non-obvious facts confirmed by reading source rather than
27//! assuming standard conventions:
28//!
29//! 1. The decay gate `g` is per-(head, key-dim), not one scalar per
30//!    head: `g = gate_lower_bound * sigmoid(exp(A_log) * (raw_g +
31//!    dt_bias))`, with `A_log`/`gate_lower_bound` per-head but
32//!    `raw_g`/`dt_bias` per-(head, dim). State decay `S *= exp(g)` is
33//!    applied per key dimension, broadcast across the value dimension
34//!    (`S` is `[head_dim, head_dim]` per head here, since KDA's K and V
35//!    head dims are both `head_dim` — unlike Gated MLA, where they
36//!    differ).
37//! 2. `FusedRMSNormGated`'s output-gate activation is **sigmoid**, not
38//!    silu/swish (the more common choice in gated-linear-attention
39//!    literature) — confirmed directly by `KimiDeltaAttention.__init__`
40//!    passing `activation='sigmoid'` explicitly.
41//!
42//! `KdaConfig::use_full_rank_gate` is `true` for Kimi K3's real
43//! configuration, so only that output-gate path (`g_proj` projecting
44//! `hidden_size -> num_heads*head_dim` directly) is implemented; the
45//! real reference's low-rank alternative (`g_a_proj`/`g_b_proj`) is
46//! unused by Kimi K3 and intentionally not implemented here.
47//!
48//! Not yet wired into `Decoder`'s forward pass. Tested here against
49//! synthetic weights, cross-validated against an independent Python
50//! transcription of the same real algorithm run
51//! one position at a time (matching this module's incremental decode
52//! API) over a 5-position sequence — long enough to exercise the short
53//! causal conv's full window (`short_conv_kernel_size` = 4) past its
54//! initial zero-padded steps.
55
56use ferrox_core::matmul::{rms_norm, silu};
57use ferrox_core::weight_matrix::WeightMatrix;
58
59use crate::config::KdaConfig;
60
61pub struct KdaAttnWeights {
62    pub q_proj: WeightMatrix, // [n_heads*head_dim, hidden_dim]
63    pub k_proj: WeightMatrix, // [n_heads*head_dim, hidden_dim]
64    pub v_proj: WeightMatrix, // [n_heads*head_dim, hidden_dim]
65    /// Depthwise causal conv taps, row-major `[n_heads*head_dim,
66    /// short_conv_kernel_size]` — one independent kernel per channel.
67    pub q_conv_weight: Vec<f32>,
68    pub k_conv_weight: Vec<f32>,
69    pub v_conv_weight: Vec<f32>,
70    pub a_log: Vec<f32>,         // [n_heads]
71    pub f_a_proj: WeightMatrix,  // [head_dim, hidden_dim]
72    pub f_b_proj: WeightMatrix,  // [n_heads*head_dim, head_dim]
73    pub dt_bias: Vec<f32>,       // [n_heads*head_dim]
74    pub b_proj: WeightMatrix,    // [n_heads, hidden_dim]
75    pub g_proj: WeightMatrix,    // [n_heads*head_dim, hidden_dim] (full-rank output gate)
76    pub o_norm_weight: Vec<f32>, // [head_dim]
77    pub o_proj: WeightMatrix,    // [hidden_dim, n_heads*head_dim]
78}
79
80/// Per-layer decode-time state: the short causal convs' recent-input
81/// history (up to `short_conv_kernel_size - 1` raw projected vectors
82/// each) and the recurrent state `S` (`[n_heads, head_dim, head_dim]`,
83/// flattened, zero-initialized) -- fundamentally different from
84/// `ferrox_core::cache::KvCache`'s growing K/V history, since KDA's
85/// per-layer state is fixed-size regardless of sequence length.
86pub struct KdaState {
87    conv_hist_q: Vec<f32>,
88    conv_hist_k: Vec<f32>,
89    conv_hist_v: Vec<f32>,
90    recurrent: Vec<f32>,
91}
92
93impl KdaState {
94    pub fn new(cfg: &KdaConfig) -> Self {
95        KdaState {
96            conv_hist_q: Vec::new(),
97            conv_hist_k: Vec::new(),
98            conv_hist_v: Vec::new(),
99            recurrent: vec![0f32; cfg.num_heads * cfg.head_dim * cfg.head_dim],
100        }
101    }
102}
103
104/// One depthwise causal-conv step for all `dim` channels at once, plus
105/// SiLU activation. `history` holds up to `kernel_size - 1` previous raw
106/// (pre-activation) projected vectors, oldest first, flattened; updated
107/// in place to hold the trailing window after this call. `weight` is
108/// row-major `[dim, kernel_size]`.
109///
110/// Matches `ShortConvolution`'s real math exactly: `y[d] = silu(sum_{j=0
111/// ..kernel_size} weight[d,j] * x[t-(kernel_size-1)+j, d])`, treating
112/// any position before the start of the sequence as zero (the same
113/// effect as `nn.Conv1d`'s `padding=kernel_size-1` followed by dropping
114/// the trailing, non-causal output positions).
115fn causal_conv_step(
116    weight: &[f32],
117    history: &mut Vec<f32>,
118    current: &[f32],
119    kernel_size: usize,
120    dim: usize,
121) -> Vec<f32> {
122    let hist_len = history.len() / dim;
123    let missing = (kernel_size - 1).saturating_sub(hist_len);
124
125    let mut y = vec![0f32; dim];
126    for j in 0..kernel_size {
127        if j < missing {
128            continue; // implicit zero (before the start of the sequence)
129        }
130        let src: &[f32] = if j == kernel_size - 1 {
131            current
132        } else {
133            let hist_idx = j - missing;
134            &history[hist_idx * dim..(hist_idx + 1) * dim]
135        };
136        for d in 0..dim {
137            y[d] += weight[d * kernel_size + j] * src[d];
138        }
139    }
140    for v in y.iter_mut() {
141        *v = silu(*v);
142    }
143
144    history.extend_from_slice(current);
145    let max_hist_len = (kernel_size - 1) * dim;
146    if history.len() > max_hist_len {
147        let excess = history.len() - max_hist_len;
148        history.drain(0..excess);
149    }
150    y
151}
152
153fn l2_normalize(v: &mut [f32], eps: f32) {
154    let norm_sq: f32 = v.iter().map(|x| x * x).sum();
155    let scale = 1.0 / (norm_sq + eps).sqrt();
156    for x in v.iter_mut() {
157        *x *= scale;
158    }
159}
160
161fn sigmoid(x: f32) -> f32 {
162    1.0 / (1.0 + (-x).exp())
163}
164
165/// One decode step.
166pub fn kda_forward_token(
167    weights: &KdaAttnWeights,
168    cfg: &KdaConfig,
169    hidden: &[f32],
170    rms_norm_eps: f32,
171    state: &mut KdaState,
172) -> Vec<f32> {
173    let projection_size = cfg.num_heads * cfg.head_dim;
174    let k_size = cfg.short_conv_kernel_size;
175
176    let q_lin = weights.q_proj.apply(hidden);
177    let k_lin = weights.k_proj.apply(hidden);
178    let v_lin = weights.v_proj.apply(hidden);
179
180    let q = causal_conv_step(
181        &weights.q_conv_weight,
182        &mut state.conv_hist_q,
183        &q_lin,
184        k_size,
185        projection_size,
186    );
187    let k = causal_conv_step(
188        &weights.k_conv_weight,
189        &mut state.conv_hist_k,
190        &k_lin,
191        k_size,
192        projection_size,
193    );
194    let v = causal_conv_step(
195        &weights.v_conv_weight,
196        &mut state.conv_hist_v,
197        &v_lin,
198        k_size,
199        projection_size,
200    );
201
202    let f_a = weights.f_a_proj.apply(hidden); // [head_dim]
203    let g_raw_full = weights.f_b_proj.apply(&f_a); // [projection_size]
204    let beta_raw = weights.b_proj.apply(hidden); // [n_heads]
205
206    let scale = 1.0 / (cfg.head_dim as f32).sqrt();
207    let mut attn_out = vec![0f32; projection_size];
208
209    // `h` indexes several independent slices (q/k/v, weights.a_log,
210    // beta_raw, state.recurrent, attn_out) at once, which doesn't map
211    // cleanly onto a single `.iter().enumerate()`.
212    #[allow(clippy::needless_range_loop)]
213    for h in 0..cfg.num_heads {
214        let base = h * cfg.head_dim;
215        let mut q_h = q[base..base + cfg.head_dim].to_vec();
216        let mut k_h = k[base..base + cfg.head_dim].to_vec();
217        let v_h = &v[base..base + cfg.head_dim];
218
219        l2_normalize(&mut q_h, 1e-6);
220        l2_normalize(&mut k_h, 1e-6);
221        for x in q_h.iter_mut() {
222            *x *= scale;
223        }
224
225        let a_log_h_exp = weights.a_log[h].exp();
226        let beta_h = sigmoid(beta_raw[h]);
227
228        let s_base = h * cfg.head_dim * cfg.head_dim;
229        let s = &mut state.recurrent[s_base..s_base + cfg.head_dim * cfg.head_dim];
230
231        let mut retrieval = vec![0f32; cfg.head_dim];
232        for k_idx in 0..cfg.head_dim {
233            let raw_gate = g_raw_full[base + k_idx] + weights.dt_bias[base + k_idx];
234            let gate = cfg.gate_lower_bound * sigmoid(a_log_h_exp * raw_gate);
235            let decay = gate.exp();
236            for v_idx in 0..cfg.head_dim {
237                let cell = &mut s[k_idx * cfg.head_dim + v_idx];
238                *cell *= decay;
239                retrieval[v_idx] += k_h[k_idx] * *cell;
240            }
241        }
242
243        let mut v_scaled = vec![0f32; cfg.head_dim];
244        for v_idx in 0..cfg.head_dim {
245            v_scaled[v_idx] = (v_h[v_idx] - retrieval[v_idx]) * beta_h;
246        }
247
248        for k_idx in 0..cfg.head_dim {
249            for v_idx in 0..cfg.head_dim {
250                s[k_idx * cfg.head_dim + v_idx] += k_h[k_idx] * v_scaled[v_idx];
251            }
252        }
253
254        for v_idx in 0..cfg.head_dim {
255            let mut acc = 0f32;
256            for k_idx in 0..cfg.head_dim {
257                acc += q_h[k_idx] * s[k_idx * cfg.head_dim + v_idx];
258            }
259            attn_out[base + v_idx] = acc;
260        }
261    }
262
263    let g_out_raw = weights.g_proj.apply(hidden); // [projection_size]
264    let mut gated = vec![0f32; projection_size];
265    for h in 0..cfg.num_heads {
266        let base = h * cfg.head_dim;
267        let o_h = &attn_out[base..base + cfg.head_dim];
268        let o_normed = rms_norm(o_h, &weights.o_norm_weight, rms_norm_eps);
269        for d in 0..cfg.head_dim {
270            gated[base + d] = o_normed[d] * sigmoid(g_out_raw[base + d]);
271        }
272    }
273
274    weights.o_proj.apply(&gated)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use ferrox_core::tensor::Tensor;
281
282    const HIDDEN_SIZE: usize = 8;
283    const NUM_HEADS: usize = 2;
284    const HEAD_DIM: usize = 3;
285    const PROJECTION_SIZE: usize = NUM_HEADS * HEAD_DIM;
286    const CONV_SIZE: usize = 4;
287    const GATE_LOWER_BOUND: f32 = -5.0;
288    const EPS: f32 = 1e-5;
289
290    fn wm(data: &[f32], rows: usize, cols: usize) -> WeightMatrix {
291        assert_eq!(data.len(), rows * cols);
292        WeightMatrix::F32(Tensor::new(data.to_vec(), vec![rows, cols]))
293    }
294
295    fn cfg() -> KdaConfig {
296        KdaConfig {
297            num_heads: NUM_HEADS,
298            head_dim: HEAD_DIM,
299            short_conv_kernel_size: CONV_SIZE,
300            gate_lower_bound: GATE_LOWER_BOUND,
301            use_full_rank_gate: true,
302        }
303    }
304
305    // Generated by an independent Python reference -- do not hand-edit.
306    const KDA_Q_PROJ: [f32; 48] = [
307        -0.237937, 0.0721714, -0.568898, 0.418732, 0.191488, -0.0876142, -0.0935848, 0.0911506,
308        -0.0802981, -0.0677727, 0.21602, 0.154412, -0.0192384, -0.025643, 0.0482749, -0.184206,
309        -0.121125, 0.164478, -0.0391448, -0.412328, -0.143184, 0.196986, -0.0696848, -0.0446198,
310        0.192551, 0.547383, -0.213957, 0.404462, -0.369004, 0.0524933, -0.350859, 0.405437,
311        0.250177, 0.341315, -0.26566, 0.205367, -0.155704, -0.137216, 0.151961, 0.263015,
312        0.0613261, -0.188396, -0.247745, 0.433295, 0.178184, 0.215918, 0.655046, -0.244759,
313    ];
314    const KDA_K_PROJ: [f32; 48] = [
315        0.767837,
316        0.945273,
317        0.485524,
318        0.248132,
319        -0.199147,
320        0.298346,
321        -0.132808,
322        -0.00649524,
323        -0.08713,
324        0.085149,
325        0.386423,
326        -0.166675,
327        -0.295621,
328        -0.300887,
329        -0.290483,
330        -0.429331,
331        -0.27388,
332        0.38798,
333        -0.177994,
334        0.0771323,
335        -0.365068,
336        0.0508952,
337        -0.522234,
338        -0.209627,
339        0.676362,
340        -0.174891,
341        0.335993,
342        0.136516,
343        -0.0458957,
344        -0.195632,
345        0.386073,
346        -0.053215,
347        0.458227,
348        -0.215724,
349        0.0172017,
350        0.13965,
351        0.111948,
352        -0.37014,
353        -0.199219,
354        -0.0587941,
355        -0.25611,
356        0.203198,
357        0.176406,
358        -0.587125,
359        -0.541576,
360        -0.384469,
361        0.0351779,
362        0.609952,
363    ];
364    const KDA_V_PROJ: [f32; 48] = [
365        -0.114707,
366        0.0751952,
367        -0.318934,
368        -0.314051,
369        -0.587168,
370        -0.00850327,
371        0.284165,
372        -0.107014,
373        0.418936,
374        0.0593565,
375        -0.0109237,
376        0.155176,
377        0.146213,
378        0.344342,
379        -0.240586,
380        -0.686415,
381        0.034424,
382        -0.183503,
383        -0.00815316,
384        0.49929,
385        -0.330853,
386        0.229439,
387        0.28376,
388        0.138221,
389        0.335552,
390        -0.137509,
391        -0.204453,
392        0.311695,
393        0.21609,
394        0.417113,
395        0.0636869,
396        0.487069,
397        -0.0846241,
398        -0.318099,
399        -0.611127,
400        -0.33042,
401        0.245551,
402        -0.439479,
403        -0.135709,
404        0.631975,
405        0.254039,
406        0.537295,
407        -0.297469,
408        -0.737993,
409        0.454914,
410        -0.425083,
411        0.0272771,
412        0.065128,
413    ];
414    const KDA_Q_CONV_W: [f32; 24] = [
415        -0.379559, 0.621053, 0.619735, 0.220578, -0.0117963, 0.0765747, -0.438363, -0.0956255,
416        -0.0419733, -0.340893, 0.339686, -0.570593, -0.181368, -0.901151, 0.198727, 0.297042,
417        0.248517, 1.16723, 0.375916, -0.376796, 1.06542, -0.368127, 0.315495, 0.297251,
418    ];
419    const KDA_K_CONV_W: [f32; 24] = [
420        -0.351889, 0.55814, 0.141837, 0.228382, -0.270293, 0.521053, -0.344375, -0.711664,
421        0.570051, -0.170692, 0.017348, 0.769103, 0.454597, 0.253084, 0.427966, -0.291003, 0.301235,
422        0.443975, 0.302169, -0.0816539, -1.49897, 0.222921, 0.0459506, -0.147821,
423    ];
424    const KDA_V_CONV_W: [f32; 24] = [
425        0.311239, 0.106281, 0.0815823, -0.593436, -0.583729, 0.148214, 0.0304228, 0.0554964,
426        0.0665052, 0.164583, 0.0144263, 0.0631083, 0.102446, 0.371764, -0.205876, 0.314558,
427        0.254553, 0.0421608, -0.190045, 0.147656, 0.345589, -0.429991, 0.0248583, 0.0258976,
428    ];
429    const KDA_A_LOG: [f32; 2] = [1.39784, 2.63702];
430    const KDA_F_A_PROJ: [f32; 24] = [
431        -0.465344, -0.330938, -0.461243, -0.184972, -0.167996, -0.162386, 0.111553, -0.05627,
432        -0.218674, -0.216565, -0.339453, -0.0588504, -0.0642164, 0.486887, 0.460246, 0.443371,
433        0.623773, -0.36512, 0.201895, 0.228316, 0.142865, 0.638001, 0.761363, 0.29381,
434    ];
435    const KDA_F_B_PROJ: [f32; 18] = [
436        0.195944, -0.336719, -0.336108, 0.210067, -0.0226898, -0.399168, -0.0372073, 0.108002,
437        0.280778, -0.0323038, -0.0807652, 0.00186235, 0.051895, -0.34894, 0.249993, 0.657098,
438        0.24426, -0.434459,
439    ];
440    const KDA_DT_BIAS: [f32; 6] = [
441        0.0189017, -0.353126, -0.365842, 0.13185, 0.0155298, -0.137422,
442    ];
443    const KDA_B_PROJ: [f32; 16] = [
444        -0.626234, -0.38068, 0.0486588, 0.00956812, 0.20691, 0.457018, -0.101777, 0.14069,
445        -0.0413395, -0.148962, -0.0734372, 0.288226, -0.308526, 0.342312, 0.443186, 0.0107199,
446    ];
447    const KDA_G_PROJ: [f32; 48] = [
448        0.0764755, -0.42354, 0.70816, 0.293666, 0.0516828, 0.0172313, 0.135292, -0.195371,
449        0.0849785, -0.277073, 0.149196, -0.203464, 0.268656, -0.0361107, 0.0806238, 0.888267,
450        0.24127, 0.0803401, 0.0133166, -0.311749, 0.325266, 0.480702, 0.0193065, 0.503025,
451        -0.0336833, -0.531916, -0.195972, -0.317098, -0.082362, 0.188094, -0.278692, -0.0240735,
452        0.0598056, -0.219908, -0.272796, 0.0261548, 0.261711, -0.180968, -0.225418, -0.042376,
453        0.0869846, -0.162905, -0.100244, -0.0693806, -0.325737, -0.0532611, -0.168014, 0.475017,
454    ];
455    const KDA_O_NORM_W: [f32; 3] = [0.948268, 1.04571, 1.03795];
456    const KDA_O_PROJ: [f32; 48] = [
457        -0.33541, -0.374685, -0.133646, 0.198772, -0.171972, -0.196846, -0.870066, -0.119052,
458        0.0937437, 0.133406, 0.128758, -0.0983533, -0.0681474, 0.249683, -0.0407133, -0.690913,
459        0.306003, -0.323304, 0.412587, 0.508244, 0.228732, 0.271235, 0.55734, -0.330637, 0.0761556,
460        0.0717198, -0.290133, 0.603544, -0.174208, -0.219374, 0.0803143, 0.309335, -0.180314,
461        -0.17987, 0.151233, -0.427684, -0.231776, -0.251471, -0.1303, -0.0426799, 0.553263,
462        0.272208, 0.00671946, -0.0674106, 0.262305, 0.12852, 0.267363, 0.414293,
463    ];
464
465    const KDA_HIDDEN_0: [f32; 8] = [
466        -0.504092, -1.12643, -0.0109012, 0.0397307, 0.143389, -0.299412, -0.11631, 0.279368,
467    ];
468    const KDA_HIDDEN_1: [f32; 8] = [
469        0.306279, 0.665489, -1.08314, -0.824329, 0.730971, 0.304099, 0.396717, -0.142417,
470    ];
471    const KDA_HIDDEN_2: [f32; 8] = [
472        -0.237078, 0.0999019, -0.469894, -0.765081, -0.555285, 0.0582631, 0.318596, 0.82563,
473    ];
474    const KDA_HIDDEN_3: [f32; 8] = [
475        0.185911, 0.236259, 0.84407, -0.388328, -0.479646, -0.268966, -0.048112, 1.01162,
476    ];
477    const KDA_HIDDEN_4: [f32; 8] = [
478        -0.17442, 0.653557, -0.23633, 0.245708, 0.340658, 0.266476, 0.375523, -0.34452,
479    ];
480
481    const KDA_GOLDEN_OUT_0: [f32; 8] = [
482        0.334593, 0.599535, -0.141337, -0.849879, 0.0520445, -0.161366, -0.356024, -0.259803,
483    ];
484    const KDA_GOLDEN_OUT_1: [f32; 8] = [
485        -0.178719, -0.104539, 0.510691, 0.283403, -0.331164, 0.229022, 0.24393, 0.0242566,
486    ];
487    const KDA_GOLDEN_OUT_2: [f32; 8] = [
488        -0.0640815, -0.296412, 0.488677, 0.684492, 0.289487, 0.664769, -0.493374, -0.528477,
489    ];
490    const KDA_GOLDEN_OUT_3: [f32; 8] = [
491        0.286145, 0.513164, -0.532459, -1.06872, -0.442514, -0.844267, 0.659196, 0.568707,
492    ];
493    const KDA_GOLDEN_OUT_4: [f32; 8] = [
494        -0.0696118, -0.371894, -0.388843, -0.283232, -0.109181, -0.399766, 0.265563, 0.319472,
495    ];
496
497    fn make_weights() -> KdaAttnWeights {
498        KdaAttnWeights {
499            q_proj: wm(&KDA_Q_PROJ, PROJECTION_SIZE, HIDDEN_SIZE),
500            k_proj: wm(&KDA_K_PROJ, PROJECTION_SIZE, HIDDEN_SIZE),
501            v_proj: wm(&KDA_V_PROJ, PROJECTION_SIZE, HIDDEN_SIZE),
502            q_conv_weight: KDA_Q_CONV_W.to_vec(),
503            k_conv_weight: KDA_K_CONV_W.to_vec(),
504            v_conv_weight: KDA_V_CONV_W.to_vec(),
505            a_log: KDA_A_LOG.to_vec(),
506            f_a_proj: wm(&KDA_F_A_PROJ, HEAD_DIM, HIDDEN_SIZE),
507            f_b_proj: wm(&KDA_F_B_PROJ, PROJECTION_SIZE, HEAD_DIM),
508            dt_bias: KDA_DT_BIAS.to_vec(),
509            b_proj: wm(&KDA_B_PROJ, NUM_HEADS, HIDDEN_SIZE),
510            g_proj: wm(&KDA_G_PROJ, PROJECTION_SIZE, HIDDEN_SIZE),
511            o_norm_weight: KDA_O_NORM_W.to_vec(),
512            o_proj: wm(&KDA_O_PROJ, HIDDEN_SIZE, PROJECTION_SIZE),
513        }
514    }
515
516    #[test]
517    fn matches_independent_python_reference_across_five_decode_steps() {
518        let weights = make_weights();
519        let cfg = cfg();
520        let mut state = KdaState::new(&cfg);
521
522        let hiddens = [
523            &KDA_HIDDEN_0[..],
524            &KDA_HIDDEN_1[..],
525            &KDA_HIDDEN_2[..],
526            &KDA_HIDDEN_3[..],
527            &KDA_HIDDEN_4[..],
528        ];
529        let goldens = [
530            &KDA_GOLDEN_OUT_0[..],
531            &KDA_GOLDEN_OUT_1[..],
532            &KDA_GOLDEN_OUT_2[..],
533            &KDA_GOLDEN_OUT_3[..],
534            &KDA_GOLDEN_OUT_4[..],
535        ];
536
537        for (pos, (hidden, golden)) in hiddens.iter().zip(goldens.iter()).enumerate() {
538            let out = kda_forward_token(&weights, &cfg, hidden, EPS, &mut state);
539            assert_eq!(out.len(), golden.len());
540            for (i, (a, b)) in out.iter().zip(golden.iter()).enumerate() {
541                assert!(
542                    (a - b).abs() < 1e-3,
543                    "position {pos} element {i}: rust={a} python={b}"
544                );
545            }
546        }
547    }
548
549    #[test]
550    fn causal_conv_step_zero_pads_before_the_start_of_the_sequence() {
551        // With no history yet and kernel_size=3, only the tap aligned
552        // with the current position (the last one) should contribute.
553        let weight = vec![100.0, 100.0, 2.0]; // dim=1, kernel_size=3
554        let mut history = Vec::new();
555        let y = causal_conv_step(&weight, &mut history, &[3.0], 3, 1);
556        // silu(2.0 * 3.0) = silu(6.0)
557        let expected = silu(6.0);
558        assert!((y[0] - expected).abs() < 1e-5);
559        assert_eq!(history, vec![3.0]);
560    }
561
562    #[test]
563    fn causal_conv_step_history_caps_at_kernel_size_minus_one() {
564        let weight = vec![1.0, 1.0, 1.0];
565        let mut history = Vec::new();
566        for x in [1.0, 2.0, 3.0, 4.0] {
567            causal_conv_step(&weight, &mut history, &[x], 3, 1);
568        }
569        // Only the last (kernel_size - 1) = 2 raw values are retained.
570        assert_eq!(history, vec![3.0, 4.0]);
571    }
572}