Skip to main content

ferrox_models/
deepseek_v4_decoder.rs

1//! A dedicated decoder skeleton for DeepSeek V4's real architecture,
2//! separate from `ferrox-models::decoder::Decoder` (the generic GQA path
3//! every other preset uses) — analogous to `glm52_decoder.rs` /
4//! `kimi_decoder.rs`: composes the already-independently-tested mHC,
5//! CSA/HCA compression + attention, derope, grouped output projection,
6//! and sqrtsoftplus MoE primitives into one synthetic forward pass.
7//!
8//! **Synthetic weights only — not a real checkpoint path.** No GGUF
9//! loader, no `Engine` wiring, no claim of oracle-correct output against
10//! a production DeepSeek V4 file. This module exists to prove the real
11//! primitives compose into a finite forward pass end-to-end on tiny dims,
12//! the same rigor `glm52_decoder.rs` applies before a real loader lands.
13//!
14//! Real per-layer flow (simplified to one layer here, transcribed from
15//! llama.cpp PR #24162 `deepseek4.cpp`'s layer loop):
16//!
17//! ```text
18//! attn_in  = mHC_pre(hc_streams, attn_hc_pre)
19//! attn_out = rms_norm(attn_in) |> HCA/CSA attention |> derope |> grouped wo_a/wo_b
20//! hc_streams = mHC_post(attn_out, ...)
21//! ffn_in   = mHC_pre(hc_streams, ffn_hc_pre)
22//! ffn_out  = rms_norm(ffn_in) |> MoE (sqrtsoftplus routing)
23//! hc_streams = mHC_post(ffn_out, ...)
24//! hidden   = mHC_head(hc_streams)
25//! logits   = output_head(rms_norm(hidden))
26//! ```
27//!
28//! Deliberately **not** implemented in this skeleton (real, cited scope
29//! for later slices): incremental DSV4 KV/compressor state
30//! (`llama-kv-cache-dsv4.cpp`), CSA's `coff=2` dual-role projection,
31//! hash-based first-layer MoE selection, and multi-layer stacking.
32
33use ferrox_core::attention::apply_rope_back;
34use ferrox_core::csa_hca_compress::compress_block;
35use ferrox_core::deepseek_v4_attention::{csa_attention, hca_attention};
36use ferrox_core::matmul::rms_norm;
37use ferrox_core::tensor::Tensor;
38use ferrox_core::weight_matrix::WeightMatrix;
39use ferrox_edge::dsv4::LayerCompressor;
40use ferrox_moe::{combine_expert_outputs, route_top_k, run_expert, ExpertWeights, GatingFunction};
41
42use crate::hyper_connections::{
43    head as hc_head, post as hc_post, pre as hc_pre, HyperConnectionHeadWeights,
44    HyperConnectionPreWeights, HC_MULT,
45};
46use crate::output_projection::grouped_output_projection;
47
48/// A CSA layer's extras: the doubled role projection and the Lightning
49/// Indexer. Present only on a [`LayerCompressor::Csa`] layer, and its
50/// absence there is a configuration error rather than a default.
51///
52/// # The doubled projection, which is the whole point of the split
53///
54/// On a CSA layer each raw token is projected **twice**: once for its
55/// role as the tail of the block ending at it, and once as the head of
56/// the next, overlapping block -- two different learned projections of
57/// the same token, not one reused twice (llama.cpp
58/// `load_arch_tensors`' `coff = ratio == 4 ? 2 : 1`, and
59/// `build_overlap_compressed_kv_from_state`'s
60/// `GGML_ASSERT(kv_state->ne[0] == 2*n_embd_head)`). A stack that
61/// applies one uniform ratio gives every CSA layer a single-width
62/// projection, which runs and produces numbers.
63pub struct DeepseekV4CsaWeights {
64    /// `qk_head_dim -> 2 * qk_head_dim`: the head-role projection in the
65    /// leading half, the tail-role projection in the trailing half.
66    pub role_proj: WeightMatrix,
67    /// The same split for `attn_comp_wgate`'s scores, so every block row
68    /// carries the gate belonging to the role it was projected for.
69    pub role_gate: WeightMatrix,
70    /// One indexer key per compressed entry.
71    ///
72    /// **A skeleton simplification, named rather than hidden**: upstream
73    /// runs a *separate* compressor (`indexer_comp_*`) over the raw
74    /// indexer projections, where this projects the already-compressed
75    /// entry. The indexer keys are compressed representations either
76    /// way, which is what the top-k selection needs; the second
77    /// compressor's own pooling is not reproduced here.
78    pub indexer_key_proj: WeightMatrix,
79    /// The query side, `qk_head_dim -> n_index_heads * index_head_dim`.
80    pub indexer_q_proj: WeightMatrix,
81    /// One weight per index head; its length is `n_index_heads`.
82    pub indexer_head_weights: Vec<f32>,
83    /// How many compressed entries survive selection.
84    pub indexer_top_k: usize,
85}
86
87/// One layer's attention-side weights (synthetic, tiny-dim fixtures only).
88pub struct DeepseekV4AttnWeights {
89    pub q_proj: WeightMatrix,
90    pub k_proj: WeightMatrix,
91    pub v_proj: WeightMatrix,
92    /// Per-group down-projections (`wo_a`), one per contiguous head block.
93    pub group_down: Vec<WeightMatrix>,
94    pub wo_b: WeightMatrix,
95    /// Block-compression gate (`attn_comp_wgate`) and norm for HCA pooling.
96    pub comp_gate: WeightMatrix,
97    pub comp_norm: Vec<f32>,
98    /// One learned logit per query head, joining the softmax denominator
99    /// with a zero value vector so a head can decline to attend rather
100    /// than being forced to spend a full unit of weight on the keys it
101    /// has. `None` for a checkpoint that ships no sinks.
102    pub attn_sinks: Option<Vec<f32>>,
103    /// Present iff this layer's compressor is [`LayerCompressor::Csa`].
104    pub csa: Option<DeepseekV4CsaWeights>,
105}
106
107/// MoE FFN weights for one layer (`sqrtsoftplus` gating, no hash routing).
108pub struct DeepseekV4MoeFfnWeights {
109    pub router_weight: WeightMatrix,
110    pub experts: Vec<ExpertWeights>,
111    pub shared_expert: ExpertWeights,
112}
113
114pub struct DeepseekV4DecoderLayerWeights {
115    pub attn_hc_pre: HyperConnectionPreWeights,
116    pub attn_norm_weight: Vec<f32>,
117    pub attn: DeepseekV4AttnWeights,
118    pub ffn_hc_pre: HyperConnectionPreWeights,
119    pub ffn_norm_weight: Vec<f32>,
120    pub ffn: DeepseekV4MoeFfnWeights,
121}
122
123pub struct DeepseekV4DecoderWeights {
124    pub embedding: Tensor,
125    pub layer: DeepseekV4DecoderLayerWeights,
126    pub hc_head: HyperConnectionHeadWeights,
127    pub final_norm_weight: Vec<f32>,
128    pub output_head: WeightMatrix,
129}
130
131pub struct DeepseekV4DecoderConfig {
132    pub rms_norm_eps: f32,
133    pub hc_sinkhorn_iters: u32,
134    pub hc_eps: f32,
135    pub n_heads: usize,
136    pub qk_head_dim: usize,
137    pub v_head_dim: usize,
138    pub qk_rope: usize,
139    pub compress_rope_theta: f32,
140    pub n_experts_active: usize,
141    pub moe_renormalize: bool,
142    /// Which compressor this layer runs.
143    ///
144    /// A mechanism, not a scalar ratio, and that is the correction:
145    /// upstream ships a per-layer array (`0, 0, 4, 128, 4, 128, 4, 0`)
146    /// where `0` means no compressor at all, `4` means CSA -- overlapping
147    /// blocks, a doubled projection width, and a Lightning Indexer --
148    /// and `128` means HCA, non-overlapping and dense with no indexer.
149    /// One uniform ratio across the stack builds an indexer on the HCA
150    /// layers or none on the CSA ones, and gives half of them the wrong
151    /// compressor width; all three failures run and produce numbers.
152    /// [`LayerCompressor`] derives every one of those parameters from
153    /// the mechanism, so they cannot be set inconsistently here.
154    pub compressor: LayerCompressor,
155}
156
157/// Minimal per-layer state: raw K/V for the SWA window plus optional
158/// compressed entries. No incremental DSV4 cache yet — callers append
159/// one token at a time and pool when the layer's compressor has enough
160/// raw positions for its next block.
161pub struct DeepseekV4LayerState {
162    hc_streams: [Vec<f32>; HC_MULT],
163    raw_k: Vec<f32>,
164    raw_v: Vec<f32>,
165    compressed_k: Vec<f32>,
166    compressed_v: Vec<f32>,
167    /// CSA only: each raw token's `2 * qk_head_dim` role projection and
168    /// its matching role gate, kept because a CSA block reaches back
169    /// into the *previous* half-block's head-role rows.
170    role_kv: Vec<f32>,
171    role_scores: Vec<f32>,
172    token_count: usize,
173}
174
175impl DeepseekV4LayerState {
176    pub fn new(hidden_dim: usize) -> Self {
177        let zero = vec![0.0; hidden_dim];
178        Self {
179            hc_streams: std::array::from_fn(|_| zero.clone()),
180            raw_k: Vec::new(),
181            raw_v: Vec::new(),
182            compressed_k: Vec::new(),
183            compressed_v: Vec::new(),
184            role_kv: Vec::new(),
185            role_scores: Vec::new(),
186            token_count: 0,
187        }
188    }
189
190    fn reset_hc_from_hidden(&mut self, hidden: &[f32]) {
191        for stream in self.hc_streams.iter_mut() {
192            stream.copy_from_slice(hidden);
193        }
194    }
195}
196
197pub struct DeepseekV4DecodeState {
198    layer: DeepseekV4LayerState,
199}
200
201impl DeepseekV4DecodeState {
202    pub fn new(hidden_dim: usize) -> Self {
203        Self {
204            layer: DeepseekV4LayerState::new(hidden_dim),
205        }
206    }
207}
208
209fn derope_attn_out(
210    attn_out: &mut [f32],
211    n_heads: usize,
212    v_head_dim: usize,
213    qk_rope: usize,
214    pos: usize,
215    theta: f32,
216) {
217    assert!(qk_rope <= v_head_dim);
218    for h in 0..n_heads {
219        let head_start = h * v_head_dim;
220        let rope_start = head_start + v_head_dim - qk_rope;
221        apply_rope_back(
222            &mut attn_out[rope_start..head_start + v_head_dim],
223            pos,
224            theta,
225        );
226    }
227}
228
229/// Pools one HCA block: `ratio` consecutive raw positions, gated by
230/// `attn_comp_wgate`, into a single compressed entry.
231///
232/// Non-overlapping, which is the whole difference from CSA: block `j`
233/// reads only its own `ratio` positions and never reaches back.
234fn hca_block(
235    weights: &DeepseekV4AttnWeights,
236    cfg: &DeepseekV4DecoderConfig,
237    state: &mut DeepseekV4LayerState,
238    ratio: usize,
239) {
240    let n_raw = state.token_count;
241    let per_token_k = cfg.n_heads * cfg.qk_head_dim;
242    let per_token_v = cfg.n_heads * cfg.v_head_dim;
243    let block_index = n_raw / ratio;
244
245    let kv_block: Vec<Vec<f32>> = state.raw_k[(n_raw - ratio) * per_token_k..n_raw * per_token_k]
246        .chunks(cfg.qk_head_dim)
247        .map(|row| row.to_vec())
248        .collect();
249    let score_block: Vec<Vec<f32>> = kv_block
250        .iter()
251        .map(|row| weights.comp_gate.apply(row))
252        .collect();
253    let compressed_k = compress_block(
254        &kv_block,
255        &score_block,
256        &weights.comp_norm,
257        cfg.rms_norm_eps,
258        cfg.qk_rope,
259        block_index,
260        cfg.compress_rope_theta,
261    );
262    let v_block: Vec<Vec<f32>> = state.raw_v[(n_raw - ratio) * per_token_v..n_raw * per_token_v]
263        .chunks(cfg.v_head_dim)
264        .map(|row| row.to_vec())
265        .collect();
266    let compressed_v = compress_block(
267        &v_block,
268        &score_block,
269        &weights.comp_norm,
270        cfg.rms_norm_eps,
271        cfg.qk_rope,
272        block_index,
273        cfg.compress_rope_theta,
274    );
275    state.compressed_k.extend_from_slice(&compressed_k);
276    state.compressed_v.extend_from_slice(&compressed_v);
277}
278
279/// Pools one CSA block: the `2 * ratio`-row concatenation of the
280/// PREVIOUS half-block's head-role rows and the CURRENT half-block's
281/// tail-role rows.
282///
283/// # Where the extra `ratio` rows come from
284///
285/// This is the overlap. Block `j` covers raw positions
286/// `[(j-1)*ratio, j*ratio)` through their head-role projection and
287/// `[j*ratio, (j+1)*ratio)` through their tail-role projection -- the
288/// same token contributing to two blocks under two different learned
289/// projections, which is what `coff = 2` buys and what a uniform ratio
290/// silently drops.
291///
292/// # The first block reaches off the start of the sequence
293///
294/// Block 0 has no previous half-block. The real implementation pads
295/// out-of-range reads with a zero-KV, `-inf`-score phantom row
296/// (`dsv4_append_zero_row`), and `ferrox_core::csa_hca_compress`'s
297/// module docs require callers to apply the same convention by hand.
298/// A `-inf` score contributes exactly zero weight after the softmax, so
299/// the phantom rows are present in the block and absent from the
300/// result -- which is not the same as shortening the block, because the
301/// per-channel softmax normalizes over whatever rows it is given.
302fn csa_block(
303    weights: &DeepseekV4AttnWeights,
304    cfg: &DeepseekV4DecoderConfig,
305    state: &mut DeepseekV4LayerState,
306    ratio: usize,
307) {
308    assert_eq!(
309        cfg.n_heads, 1,
310        "this skeleton compresses one head: a block is assembled as rows of qk_head_dim, so \
311         more heads would pool across them into a single entry and leave n_compressed \
312         fractional. Multi-head compression is real scope, not a silent approximation"
313    );
314    let n_raw = state.token_count;
315    // `blocks_closed` counts blocks including this one, so the block
316    // being built is ordinal `blocks_closed - 1` and its own tokens are
317    // the LAST `ratio` appended -- not the next `ratio`, which is the
318    // off-by-one that indexes past the end of the state.
319    let blocks_closed = n_raw / ratio;
320    let block_ord = blocks_closed - 1;
321    let role_width = 2 * cfg.qk_head_dim;
322
323    let mut kv_block: Vec<Vec<f32>> = Vec::with_capacity(2 * ratio);
324    let mut score_block: Vec<Vec<f32>> = Vec::with_capacity(2 * ratio);
325    let mut v_block: Vec<Vec<f32>> = Vec::with_capacity(2 * ratio);
326
327    // The previous half-block, through its HEAD-role projection -- or
328    // phantom rows when block 0 reaches off the start of the sequence.
329    for step in 0..ratio {
330        if block_ord == 0 {
331            kv_block.push(vec![0.0; cfg.qk_head_dim]);
332            score_block.push(vec![f32::NEG_INFINITY; cfg.qk_head_dim]);
333            v_block.push(vec![0.0; cfg.v_head_dim]);
334            continue;
335        }
336        let token = (block_ord - 1) * ratio + step;
337        let at = token * role_width;
338        kv_block.push(state.role_kv[at..at + cfg.qk_head_dim].to_vec());
339        score_block.push(state.role_scores[at..at + cfg.qk_head_dim].to_vec());
340        let v_at = token * cfg.v_head_dim;
341        v_block.push(state.raw_v[v_at..v_at + cfg.v_head_dim].to_vec());
342    }
343    // The current half-block, through its TAIL-role projection.
344    for step in 0..ratio {
345        let token = block_ord * ratio + step;
346        let at = token * role_width + cfg.qk_head_dim;
347        kv_block.push(state.role_kv[at..at + cfg.qk_head_dim].to_vec());
348        score_block.push(state.role_scores[at..at + cfg.qk_head_dim].to_vec());
349        let v_at = token * cfg.v_head_dim;
350        v_block.push(state.raw_v[v_at..v_at + cfg.v_head_dim].to_vec());
351    }
352
353    let compressed_k = compress_block(
354        &kv_block,
355        &score_block,
356        &weights.comp_norm,
357        cfg.rms_norm_eps,
358        cfg.qk_rope,
359        blocks_closed,
360        cfg.compress_rope_theta,
361    );
362    // V follows the same index structure but is not role-projected: the
363    // doubled state the reference asserts on is the shared MLA latent,
364    // and `v_head_dim` need not equal `qk_head_dim`, so a role split
365    // here would be this skeleton's invention rather than a
366    // transcription.
367    let compressed_v = compress_block(
368        &v_block,
369        &score_block,
370        &weights.comp_norm,
371        cfg.rms_norm_eps,
372        cfg.qk_rope,
373        blocks_closed,
374        cfg.compress_rope_theta,
375    );
376    state.compressed_k.extend_from_slice(&compressed_k);
377    state.compressed_v.extend_from_slice(&compressed_v);
378}
379
380fn attn_forward_token(
381    weights: &DeepseekV4AttnWeights,
382    cfg: &DeepseekV4DecoderConfig,
383    attn_in: &[f32],
384    state: &mut DeepseekV4LayerState,
385) -> Vec<f32> {
386    let q = weights.q_proj.apply(attn_in);
387    let k = weights.k_proj.apply(attn_in);
388    let v = weights.v_proj.apply(attn_in);
389    debug_assert_eq!(q.len(), cfg.n_heads * cfg.qk_head_dim);
390    debug_assert_eq!(k.len(), cfg.n_heads * cfg.qk_head_dim);
391    debug_assert_eq!(v.len(), cfg.n_heads * cfg.v_head_dim);
392
393    state.raw_k.extend_from_slice(&k);
394    state.raw_v.extend_from_slice(&v);
395    state.token_count += 1;
396    let n_raw = state.token_count;
397
398    // A CSA layer keeps every token's two role projections, because the
399    // block that closes at token t reaches back into the head-role rows
400    // of the tokens before it.
401    if let Some(csa) = weights.csa.as_ref() {
402        for head in k.chunks(cfg.qk_head_dim) {
403            state.role_kv.extend_from_slice(&csa.role_proj.apply(head));
404            state
405                .role_scores
406                .extend_from_slice(&csa.role_gate.apply(head));
407        }
408    }
409
410    let ratio = cfg.compressor.ratio() as usize;
411    if ratio > 0 && n_raw.is_multiple_of(ratio) {
412        match cfg.compressor {
413            LayerCompressor::Hca => hca_block(weights, cfg, state, ratio),
414            LayerCompressor::Csa => {
415                csa_block(weights, cfg, state, ratio);
416            }
417            LayerCompressor::None => unreachable!("ratio 0 is filtered above"),
418        }
419    }
420
421    let n_compressed = if cfg.qk_head_dim > 0 {
422        state.compressed_k.len() / (cfg.n_heads * cfg.qk_head_dim)
423    } else {
424        0
425    };
426    let sinks = weights.attn_sinks.as_deref();
427
428    // The three-way dispatch. A layer with no compressor sees the raw
429    // window and nothing else -- a real entry in the shipped schedule
430    // rather than a disabled state, so it runs the dense path over an
431    // empty compressed set rather than being skipped.
432    let mut attn_out = match cfg.compressor {
433        LayerCompressor::None | LayerCompressor::Hca => hca_attention(
434            &q,
435            &state.raw_k,
436            &state.raw_v,
437            n_raw,
438            &state.compressed_k,
439            &state.compressed_v,
440            n_compressed,
441            cfg.n_heads,
442            cfg.qk_head_dim,
443            cfg.v_head_dim,
444            sinks,
445        ),
446        LayerCompressor::Csa => {
447            let csa = weights
448                .csa
449                .as_ref()
450                .expect("a CSA layer must carry its role projections and indexer");
451            let n_index_heads = csa.indexer_head_weights.len();
452            // Projected once. Splitting the width across the index
453            // heads needs the length, and computing it by projecting a
454            // second time would run the indexer's matvec twice on every
455            // decode step of every CSA layer.
456            let projected = csa.indexer_q_proj.apply(&q[..cfg.qk_head_dim]);
457            // `checked_div` rather than a guarded `/`: a layer that
458            // declares no index heads has no width to split, and zero
459            // is the honest answer rather than a panic.
460            let index_head_dim = projected.len().checked_div(n_index_heads).unwrap_or(0);
461            let indexer_q: Vec<Vec<f32>> = projected
462                .chunks(index_head_dim.max(1))
463                .map(|c| c.to_vec())
464                .collect();
465            let indexer_keys: Vec<Vec<f32>> = state
466                .compressed_k
467                .chunks(cfg.qk_head_dim)
468                .take(n_compressed)
469                .map(|entry| csa.indexer_key_proj.apply(entry))
470                .collect();
471            csa_attention(
472                &q,
473                &state.raw_k,
474                &state.raw_v,
475                n_raw,
476                &state.compressed_k,
477                &state.compressed_v,
478                n_compressed,
479                &indexer_q,
480                &indexer_keys,
481                &csa.indexer_head_weights,
482                csa.indexer_top_k,
483                cfg.n_heads,
484                cfg.qk_head_dim,
485                cfg.v_head_dim,
486                sinks,
487            )
488        }
489    };
490
491    derope_attn_out(
492        &mut attn_out,
493        cfg.n_heads,
494        cfg.v_head_dim,
495        cfg.qk_rope,
496        state.token_count.saturating_sub(1),
497        cfg.compress_rope_theta,
498    );
499
500    grouped_output_projection(&attn_out, &weights.group_down, &weights.wo_b)
501}
502
503fn moe_ffn_forward(
504    weights: &DeepseekV4MoeFfnWeights,
505    cfg: &DeepseekV4DecoderConfig,
506    x: &[f32],
507) -> Vec<f32> {
508    let router_logits = weights.router_weight.apply(x);
509    let decision = route_top_k(
510        &router_logits,
511        cfg.n_experts_active,
512        GatingFunction::SqrtSoftplus,
513        cfg.moe_renormalize,
514    );
515    let routed_outputs: Vec<(Vec<f32>, f32)> = decision
516        .expert_ids
517        .iter()
518        .zip(decision.weights.iter())
519        .map(|(&e, &w)| (run_expert(x, &weights.experts[e]), w))
520        .collect();
521    let shared_out = run_expert(x, &weights.shared_expert);
522    combine_expert_outputs(&routed_outputs, &[shared_out], x.len())
523}
524
525/// One decode step through the single synthetic layer, then final norm +
526/// output projection. `token_id` indexes the embedding table.
527pub fn deepseek_v4_forward_token(
528    weights: &DeepseekV4DecoderWeights,
529    cfg: &DeepseekV4DecoderConfig,
530    token_id: usize,
531    state: &mut DeepseekV4DecodeState,
532) -> Vec<f32> {
533    let hidden_dim = weights.embedding.cols();
534    let hidden = weights.embedding.row(token_id).to_vec();
535    state.layer.reset_hc_from_hidden(&hidden);
536    let layer = &weights.layer;
537
538    let hc_residual = state.layer.hc_streams.clone();
539    let (attn_in, attn_post, attn_comb) = hc_pre(
540        &layer.attn_hc_pre,
541        &hc_residual,
542        cfg.rms_norm_eps,
543        cfg.hc_sinkhorn_iters,
544        cfg.hc_eps,
545    );
546    let attn_normed = rms_norm(&attn_in, &layer.attn_norm_weight, cfg.rms_norm_eps);
547    let attn_out = attn_forward_token(&layer.attn, cfg, &attn_normed, &mut state.layer);
548    state.layer.hc_streams = hc_post(&attn_out, &hc_residual, &attn_post, &attn_comb);
549
550    let hc_residual = state.layer.hc_streams.clone();
551    let (ffn_in, ffn_post, ffn_comb) = hc_pre(
552        &layer.ffn_hc_pre,
553        &hc_residual,
554        cfg.rms_norm_eps,
555        cfg.hc_sinkhorn_iters,
556        cfg.hc_eps,
557    );
558    let ffn_normed = rms_norm(&ffn_in, &layer.ffn_norm_weight, cfg.rms_norm_eps);
559    let ffn_out = moe_ffn_forward(&layer.ffn, cfg, &ffn_normed);
560    state.layer.hc_streams = hc_post(&ffn_out, &hc_residual, &ffn_post, &ffn_comb);
561
562    let collapsed = hc_head(
563        &weights.hc_head,
564        &state.layer.hc_streams,
565        cfg.rms_norm_eps,
566        cfg.hc_eps,
567    );
568    let final_normed = rms_norm(&collapsed, &weights.final_norm_weight, cfg.rms_norm_eps);
569    assert_eq!(final_normed.len(), hidden_dim);
570    weights.output_head.apply(&final_normed)
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::hyper_connections::HyperConnectionPreWeights;
577
578    const HIDDEN_DIM: usize = 8;
579    const EPS: f32 = 1e-5;
580    const NUM_HEADS: usize = 1;
581    const QK_HEAD_DIM: usize = 4;
582    const V_HEAD_DIM: usize = 4;
583    const QK_ROPE: usize = 2;
584    const N_GROUPS: usize = 1;
585    const O_LORA_RANK: usize = 2;
586    const O_GROUP_DIM: usize = (NUM_HEADS * V_HEAD_DIM) / N_GROUPS;
587    const N_EXPERTS: usize = 4;
588    const N_EXPERTS_ACTIVE: usize = 2;
589    const MOE_FFN_DIM: usize = 3;
590    const OUTPUT_VOCAB: usize = 5;
591    const HC_FLAT: usize = HC_MULT * HIDDEN_DIM;
592    const N_INDEX_HEADS: usize = 2;
593    const INDEX_HEAD_DIM: usize = 2;
594
595    fn wm(data: Vec<f32>, rows: usize, cols: usize) -> WeightMatrix {
596        assert_eq!(data.len(), rows * cols);
597        WeightMatrix::F32(Tensor::new(data, vec![rows, cols]))
598    }
599
600    fn synth(seed: usize, n: usize) -> Vec<f32> {
601        (0..n)
602            .map(|i| (((seed * 131 + i * 17 + 7) % 23) as f32 * 0.05) - 0.55)
603            .collect()
604    }
605
606    fn make_hc_pre(seed: usize) -> HyperConnectionPreWeights {
607        HyperConnectionPreWeights {
608            fn_proj: wm(
609                synth(seed, (2 + HC_MULT) * HC_MULT * HC_FLAT),
610                (2 + HC_MULT) * HC_MULT,
611                HC_FLAT,
612            ),
613            scale: [0.5, 0.5, 0.5],
614            base_pre: [0.1; HC_MULT],
615            base_post: [0.2; HC_MULT],
616            base_comb: [0.01; HC_MULT * HC_MULT],
617        }
618    }
619
620    fn make_hc_head(seed: usize) -> HyperConnectionHeadWeights {
621        HyperConnectionHeadWeights {
622            fn_proj: wm(synth(seed, HC_MULT * HC_FLAT), HC_MULT, HC_FLAT),
623            scale: 0.5,
624            base: [0.1; HC_MULT],
625        }
626    }
627
628    fn make_weights_for(csa: bool, sinks: Option<Vec<f32>>) -> DeepseekV4DecoderWeights {
629        let expert = |seed: usize| ExpertWeights {
630            gate: wm(
631                synth(seed, MOE_FFN_DIM * HIDDEN_DIM),
632                MOE_FFN_DIM,
633                HIDDEN_DIM,
634            ),
635            up: wm(
636                synth(seed + 1, MOE_FFN_DIM * HIDDEN_DIM),
637                MOE_FFN_DIM,
638                HIDDEN_DIM,
639            ),
640            down: wm(
641                synth(seed + 2, HIDDEN_DIM * MOE_FFN_DIM),
642                HIDDEN_DIM,
643                MOE_FFN_DIM,
644            ),
645        };
646
647        DeepseekV4DecoderWeights {
648            embedding: Tensor::new(
649                synth(1000, OUTPUT_VOCAB * HIDDEN_DIM),
650                vec![OUTPUT_VOCAB, HIDDEN_DIM],
651            ),
652            layer: DeepseekV4DecoderLayerWeights {
653                attn_hc_pre: make_hc_pre(100),
654                attn_norm_weight: vec![1.0; HIDDEN_DIM],
655                attn: DeepseekV4AttnWeights {
656                    q_proj: wm(
657                        synth(110, NUM_HEADS * QK_HEAD_DIM * HIDDEN_DIM),
658                        NUM_HEADS * QK_HEAD_DIM,
659                        HIDDEN_DIM,
660                    ),
661                    k_proj: wm(
662                        synth(111, NUM_HEADS * QK_HEAD_DIM * HIDDEN_DIM),
663                        NUM_HEADS * QK_HEAD_DIM,
664                        HIDDEN_DIM,
665                    ),
666                    v_proj: wm(
667                        synth(112, NUM_HEADS * V_HEAD_DIM * HIDDEN_DIM),
668                        NUM_HEADS * V_HEAD_DIM,
669                        HIDDEN_DIM,
670                    ),
671                    group_down: (0..N_GROUPS)
672                        .map(|g| {
673                            wm(
674                                synth(120 + g, O_LORA_RANK * O_GROUP_DIM),
675                                O_LORA_RANK,
676                                O_GROUP_DIM,
677                            )
678                        })
679                        .collect(),
680                    wo_b: wm(
681                        synth(130, HIDDEN_DIM * O_LORA_RANK * N_GROUPS),
682                        HIDDEN_DIM,
683                        O_LORA_RANK * N_GROUPS,
684                    ),
685                    comp_gate: wm(
686                        synth(140, QK_HEAD_DIM * QK_HEAD_DIM),
687                        QK_HEAD_DIM,
688                        QK_HEAD_DIM,
689                    ),
690                    comp_norm: vec![1.0; QK_HEAD_DIM],
691                    attn_sinks: sinks,
692                    csa: csa.then(|| DeepseekV4CsaWeights {
693                        role_proj: wm(
694                            synth(150, 2 * QK_HEAD_DIM * QK_HEAD_DIM),
695                            2 * QK_HEAD_DIM,
696                            QK_HEAD_DIM,
697                        ),
698                        role_gate: wm(
699                            synth(151, 2 * QK_HEAD_DIM * QK_HEAD_DIM),
700                            2 * QK_HEAD_DIM,
701                            QK_HEAD_DIM,
702                        ),
703                        indexer_key_proj: wm(
704                            synth(152, INDEX_HEAD_DIM * QK_HEAD_DIM),
705                            INDEX_HEAD_DIM,
706                            QK_HEAD_DIM,
707                        ),
708                        indexer_q_proj: wm(
709                            synth(153, N_INDEX_HEADS * INDEX_HEAD_DIM * QK_HEAD_DIM),
710                            N_INDEX_HEADS * INDEX_HEAD_DIM,
711                            QK_HEAD_DIM,
712                        ),
713                        indexer_head_weights: vec![1.0; N_INDEX_HEADS],
714                        indexer_top_k: 1,
715                    }),
716                },
717                ffn_hc_pre: make_hc_pre(200),
718                ffn_norm_weight: vec![1.0; HIDDEN_DIM],
719                ffn: DeepseekV4MoeFfnWeights {
720                    router_weight: wm(synth(300, N_EXPERTS * HIDDEN_DIM), N_EXPERTS, HIDDEN_DIM),
721                    experts: (0..N_EXPERTS).map(|e| expert(400 + e * 10)).collect(),
722                    shared_expert: expert(900),
723                },
724            },
725            hc_head: make_hc_head(500),
726            final_norm_weight: vec![1.0; HIDDEN_DIM],
727            output_head: wm(
728                synth(1100, OUTPUT_VOCAB * HIDDEN_DIM),
729                OUTPUT_VOCAB,
730                HIDDEN_DIM,
731            ),
732        }
733    }
734
735    fn make_weights() -> DeepseekV4DecoderWeights {
736        make_weights_for(false, None)
737    }
738
739    fn decoder_cfg_for(compressor: LayerCompressor) -> DeepseekV4DecoderConfig {
740        DeepseekV4DecoderConfig {
741            rms_norm_eps: EPS,
742            hc_sinkhorn_iters: 4,
743            hc_eps: 1e-6,
744            n_heads: NUM_HEADS,
745            qk_head_dim: QK_HEAD_DIM,
746            v_head_dim: V_HEAD_DIM,
747            qk_rope: QK_ROPE,
748            compress_rope_theta: 1_000_000.0,
749            n_experts_active: N_EXPERTS_ACTIVE,
750            moe_renormalize: true,
751            compressor,
752        }
753    }
754
755    fn decoder_cfg() -> DeepseekV4DecoderConfig {
756        decoder_cfg_for(LayerCompressor::Hca)
757    }
758
759    /// All three arms of the schedule run and stay finite. The point of
760    /// the dispatch is that these are three different mechanisms, so
761    /// each has to be exercised as itself rather than one standing in
762    /// for the others.
763    #[test]
764    fn every_compressor_in_the_schedule_produces_finite_logits() {
765        for compressor in [
766            LayerCompressor::None,
767            LayerCompressor::Csa,
768            LayerCompressor::Hca,
769        ] {
770            let weights = make_weights_for(compressor == LayerCompressor::Csa, None);
771            let cfg = decoder_cfg_for(compressor);
772            let mut state = DeepseekV4DecodeState::new(HIDDEN_DIM);
773            // Enough tokens for CSA to close two blocks; HCA's 128 needs
774            // more than any of these, which is itself the correct
775            // behaviour for a layer whose first block has not closed.
776            for token_id in 0..9 {
777                let logits =
778                    deepseek_v4_forward_token(&weights, &cfg, token_id % OUTPUT_VOCAB, &mut state);
779                assert!(
780                    logits.iter().all(|v| v.is_finite()),
781                    "{compressor:?} token {token_id}: {logits:?}"
782                );
783            }
784        }
785    }
786
787    /// Ratio 0 is a real entry in the shipped schedule, not a disabled
788    /// state: the layer runs, and it never builds a compressed entry at
789    /// any length. A uniform ratio gives these layers a compressor they
790    /// do not have.
791    #[test]
792    fn a_layer_with_no_compressor_never_builds_a_compressed_entry() {
793        let weights = make_weights_for(false, None);
794        let cfg = decoder_cfg_for(LayerCompressor::None);
795        let mut state = DeepseekV4DecodeState::new(HIDDEN_DIM);
796        for token_id in 0..20 {
797            deepseek_v4_forward_token(&weights, &cfg, token_id % OUTPUT_VOCAB, &mut state);
798        }
799        assert_eq!(state.layer.token_count, 20);
800        assert!(
801            state.layer.compressed_k.is_empty() && state.layer.compressed_v.is_empty(),
802            "a ratio-0 layer compressed something"
803        );
804    }
805
806    /// A compressed entry appears exactly when a block closes, and one
807    /// per closed block -- for both mechanisms, at their own ratios.
808    /// `LayerCompressor::visible_compressed` is the same count stated
809    /// independently, so the two agreeing is the check.
810    #[test]
811    fn one_compressed_entry_appears_per_closed_block() {
812        let weights = make_weights_for(true, None);
813        let cfg = decoder_cfg_for(LayerCompressor::Csa);
814        let mut state = DeepseekV4DecodeState::new(HIDDEN_DIM);
815        for token in 0..12 {
816            deepseek_v4_forward_token(&weights, &cfg, token % OUTPUT_VOCAB, &mut state);
817            let entries = state.layer.compressed_k.len() / (NUM_HEADS * QK_HEAD_DIM);
818            assert_eq!(
819                entries,
820                LayerCompressor::Csa.visible_compressed(token),
821                "after token {token}"
822            );
823        }
824    }
825
826    /// The overlap: a CSA block spans `2 * ratio` rows, reaching back
827    /// into the previous half-block through the head-role projection.
828    /// Block 0 has no previous half-block, so it is padded with
829    /// zero-KV/`-inf`-score phantom rows -- present in the block and
830    /// absent from the result, which is not the same as shortening it,
831    /// because the per-channel softmax normalizes over whatever rows it
832    /// is given.
833    ///
834    /// Checked by construction: a block that did NOT reach back would
835    /// be unaffected by the earlier tokens' role projections, so
836    /// changing only those must change the second compressed entry.
837    #[test]
838    fn a_csa_block_reaches_back_into_the_previous_half_block() {
839        let cfg = decoder_cfg_for(LayerCompressor::Csa);
840        let ratio = LayerCompressor::Csa.ratio() as usize;
841
842        let run = |first_tokens: &[usize]| -> Vec<f32> {
843            let weights = make_weights_for(true, None);
844            let mut state = DeepseekV4DecodeState::new(HIDDEN_DIM);
845            for &t in first_tokens {
846                deepseek_v4_forward_token(&weights, &cfg, t, &mut state);
847            }
848            // Then a fixed second half-block, so only the FIRST block's
849            // tokens differ between the two runs.
850            for t in 0..ratio {
851                deepseek_v4_forward_token(&weights, &cfg, t % OUTPUT_VOCAB, &mut state);
852            }
853            let per_entry = NUM_HEADS * QK_HEAD_DIM;
854            assert_eq!(state.layer.compressed_k.len() / per_entry, 2);
855            state.layer.compressed_k[per_entry..].to_vec()
856        };
857
858        let a = run(&[0, 1, 2, 3]);
859        let b = run(&[4, 3, 2, 1]);
860        assert_ne!(
861            a, b,
862            "the second block must depend on the first half-block it overlaps"
863        );
864    }
865
866    /// A per-head sink changes the answer, which is the whole reason to
867    /// carry it: without one, every head must spend a full unit of
868    /// weight on the keys it has.
869    #[test]
870    fn a_per_head_attention_sink_changes_the_output() {
871        let cfg = decoder_cfg_for(LayerCompressor::None);
872        let logits_for = |sinks: Option<Vec<f32>>| {
873            let weights = make_weights_for(false, sinks);
874            let mut state = DeepseekV4DecodeState::new(HIDDEN_DIM);
875            let mut last = Vec::new();
876            for token in 0..4 {
877                last = deepseek_v4_forward_token(&weights, &cfg, token % OUTPUT_VOCAB, &mut state);
878            }
879            last
880        };
881
882        let plain = logits_for(None);
883        let negligible = logits_for(Some(vec![-40.0; NUM_HEADS]));
884        let dominant = logits_for(Some(vec![40.0; NUM_HEADS]));
885
886        for (p, n) in plain.iter().zip(negligible.iter()) {
887            assert!(
888                (p - n).abs() < 1e-4,
889                "a sink far below every score: {p} vs {n}"
890            );
891        }
892        assert!(
893            plain
894                .iter()
895                .zip(dominant.iter())
896                .any(|(p, d)| (p - d).abs() > 1e-3),
897            "a dominant sink must change the answer"
898        );
899        assert!(dominant.iter().all(|v| v.is_finite()));
900    }
901
902    #[test]
903    fn one_layer_synthetic_forward_produces_finite_logits() {
904        let weights = make_weights();
905        let cfg = decoder_cfg();
906        let mut state = DeepseekV4DecodeState::new(HIDDEN_DIM);
907
908        for token_id in 0..3 {
909            let logits =
910                deepseek_v4_forward_token(&weights, &cfg, token_id % OUTPUT_VOCAB, &mut state);
911            assert_eq!(logits.len(), OUTPUT_VOCAB);
912            assert!(
913                logits.iter().all(|v| v.is_finite()),
914                "token {token_id}: logits must be finite, got {logits:?}"
915            );
916            assert!(
917                !logits.iter().any(|v| v.is_nan()),
918                "token {token_id}: logits must not contain NaN, got {logits:?}"
919            );
920        }
921    }
922}