Skip to main content

ferrox_models/
mla_gguf_loader.rs

1//! DeepSeek-2 / Mistral-4 GGUF → [`crate::engine::MlaEngine`].
2//!
3//! Tensor names follow llama.cpp `deepseek2` / `mistral4` (same graph):
4//! `blk.{i}.attn_q_a|attn_q_b|attn_kv_a_mqa|attn_kv_b|attn_output` plus
5//! optional `attn_q_a_norm` / `attn_kv_a_norm`. Dense FFN:
6//! `ffn_{gate,up,down}`. MoE after `leading_dense_block_count` uses
7//! `ffn_gate_inp` + packed `ffn_{gate,up,down}_exps` + shared
8//! `ffn_{gate,up,down}_shexp` (fail-closed if any are missing).
9//!
10//! `use_output_gate` is off (classic DeepSeek-2). RoPE uses interleaved
11//! Norm layout via [`crate::config::MlaRopeConfig`].
12
13use ferrox_gguf::TensorSource;
14use ferrox_moe::GatingFunction;
15
16use crate::config::{MlaConfig, MlaRopeConfig};
17use crate::engine::{
18    MlaDenseFfn, MlaEngine, MlaLayerFfn, MlaLayerWeights, MlaMoeFfn, MlaMoeRuntime,
19};
20use crate::loader::LoadError;
21use crate::loader::{load_f32_vec, load_weight_matrix, split_expert_tensor};
22use crate::mla::MlaAttnWeights;
23
24/// Hyperparameters read from `{arch}.*` GGUF metadata.
25#[derive(Debug, Clone)]
26pub struct Deepseek2Hparams {
27    pub arch: String,
28    pub n_layer: usize,
29    pub hidden_dim: usize,
30    pub ffn_dim: usize,
31    pub n_heads: usize,
32    pub q_lora_rank: usize,
33    pub kv_lora_rank: usize,
34    pub qk_nope_head_dim: usize,
35    pub qk_rope_head_dim: usize,
36    pub v_head_dim: usize,
37    pub rms_norm_eps: f32,
38    pub rope_theta: f32,
39    /// Layers `[0, leading_dense)` use dense SwiGLU; rest require MoE.
40    pub leading_dense_block_count: usize,
41    pub n_expert: usize,
42    pub n_expert_used: usize,
43    pub n_shared_experts: usize,
44    pub expert_ffn_dim: usize,
45    pub gating: GatingFunction,
46    pub norm_topk_prob: bool,
47    pub expert_weights_scale: f32,
48}
49
50fn meta_u64(file: &impl TensorSource, key: &str) -> Result<u64, LoadError> {
51    file.metadata_u64(key)
52        .ok_or_else(|| LoadError::MissingHparam(key.to_string()))
53}
54
55fn meta_f32(file: &impl TensorSource, key: &str, default: f32) -> f32 {
56    file.metadata_f32(key).unwrap_or(default)
57}
58
59/// Read DeepSeek-2 / Mistral-4 hparams from an opened GGUF.
60pub fn read_deepseek2_hparams(file: &impl TensorSource) -> Result<Deepseek2Hparams, LoadError> {
61    let arch = file
62        .metadata_str("general.architecture")
63        .ok_or_else(|| LoadError::MissingHparam("general.architecture".into()))?
64        .to_string();
65    if arch != "deepseek2" && arch != "mistral4" {
66        return Err(LoadError::UnsupportedArchitecture(arch));
67    }
68    let p = |suffix: &str| format!("{arch}.{suffix}");
69    let n_layer = meta_u64(file, &p("block_count"))? as usize;
70    let hidden_dim = meta_u64(file, &p("embedding_length"))? as usize;
71    let ffn_dim = meta_u64(file, &p("feed_forward_length"))? as usize;
72    let n_heads = meta_u64(file, &p("attention.head_count"))? as usize;
73    let q_lora_rank = meta_u64(file, &p("attention.q_lora_rank"))? as usize;
74    let kv_lora_rank = meta_u64(file, &p("attention.kv_lora_rank"))? as usize;
75    // `attention.qk_nope_head_dim` and `attention.qk_rope_head_dim` ARE
76    // NOT GGUF KEYS. Neither string appears in llama.cpp's
77    // `LLM_KV_NAMES` or anywhere in `gguf-py`; they are HF
78    // `config.json` field names. Requiring them meant DeepSeek-V2,
79    // V2.5, V3 and R1 -- the largest open models people run -- all
80    // failed with "missing hparam deepseek2.attention.qk_nope_head_dim",
81    // a true statement about a key no converter has ever written. The
82    // same shape as `glm4moe` being sent to an MLA loader for a
83    // `q_lora_rank` it does not have.
84    //
85    // What a real file carries, and how llama.cpp derives the per-head
86    // dims from it (`src/models/deepseek2.cpp:77-82`):
87    //
88    //   qk_rope = rope.dimension_count                  (`n_rot`)
89    //   qk_nope = attention.key_length_mla - qk_rope
90    //   v_head  = attention.value_length_mla
91    //
92    // `attention.key_length` / `value_length` are NOT these: for an MLA
93    // checkpoint they hold the COMPRESSED MQA widths
94    // (`kv_lora_rank + qk_rope` and `kv_lora_rank`), so reading them as
95    // per-head dims silently builds a differently shaped model.
96    //
97    // The HF spellings are still accepted, because ferrox's own
98    // synthetic fixtures were written against them, but they are the
99    // fallback rather than the contract.
100    let qk_rope_head_dim = meta_u64(file, &p("rope.dimension_count"))
101        .or_else(|_| meta_u64(file, &p("attention.qk_rope_head_dim")))?
102        as usize;
103    let qk_nope_head_dim = match meta_u64(file, &p("attention.key_length_mla")) {
104        Ok(k_mla) => (k_mla as usize)
105            .checked_sub(qk_rope_head_dim)
106            .filter(|&nope| nope >= 1)
107            .ok_or_else(|| {
108                LoadError::MissingHparam(format!(
109                    "{arch}.attention.key_length_mla ({k_mla}) must exceed \
110                     rope.dimension_count ({qk_rope_head_dim})"
111                ))
112            })?,
113        Err(_) => meta_u64(file, &p("attention.qk_nope_head_dim"))? as usize,
114    };
115    let v_head_dim = meta_u64(file, &p("attention.value_length_mla"))
116        .or_else(|_| meta_u64(file, &p("attention.v_head_dim")))
117        .unwrap_or(qk_nope_head_dim as u64) as usize;
118    let leading_dense = file
119        .metadata_u64(&p("leading_dense_block_count"))
120        .unwrap_or(n_layer as u64) as usize;
121    let n_expert = file.metadata_u64(&p("expert_count")).unwrap_or(0) as usize;
122    let n_expert_used = file
123        .metadata_u64(&p("expert_used_count"))
124        .unwrap_or(if n_expert > 0 { 6 } else { 0 }) as usize;
125    let n_shared_experts = file.metadata_u64(&p("expert_shared_count")).unwrap_or(1) as usize;
126    let expert_ffn_dim = file
127        .metadata_u64(&p("expert_feed_forward_length"))
128        .unwrap_or(ffn_dim as u64) as usize;
129    let rms_norm_eps = meta_f32(file, &p("attention.layer_norm_rms_epsilon"), 1e-6);
130    let rope_theta = meta_f32(file, &p("rope.freq_base"), 10000.0);
131    // llama.cpp deepseek2: default Softmax unless expert_gating_func set
132    // (1=softmax, 2=sigmoid); special-case GLM 4.7 Lite sigmoid when absent.
133    let gating = match file.metadata_u64(&p("expert_gating_func")) {
134        Some(2) => GatingFunction::Sigmoid,
135        Some(1) => GatingFunction::Softmax,
136        _ if (n_layer == 47 || n_layer == 48)
137            && file
138                .find_tensor("token_embd.weight")
139                .map(|t| t.shape.last().copied().unwrap_or(0) == 154880)
140                .unwrap_or(false) =>
141        {
142            GatingFunction::Sigmoid
143        }
144        _ => GatingFunction::Softmax,
145    };
146    let norm_topk_prob = file
147        .metadata_u64(&p("expert_weights_norm"))
148        .map(|v| v != 0)
149        .unwrap_or(true);
150    let expert_weights_scale = meta_f32(file, &p("expert_weights_scale"), 1.0);
151    Ok(Deepseek2Hparams {
152        arch,
153        n_layer,
154        hidden_dim,
155        ffn_dim,
156        n_heads,
157        q_lora_rank,
158        kv_lora_rank,
159        qk_nope_head_dim,
160        qk_rope_head_dim,
161        v_head_dim,
162        rms_norm_eps,
163        rope_theta,
164        leading_dense_block_count: leading_dense.min(n_layer),
165        n_expert,
166        n_expert_used: n_expert_used.min(n_expert.max(1)),
167        n_shared_experts: n_shared_experts.max(1),
168        expert_ffn_dim,
169        gating,
170        norm_topk_prob,
171        expert_weights_scale,
172    })
173}
174
175fn load_f32_vec_optional(
176    file: &impl TensorSource,
177    name: &str,
178) -> Result<Option<Vec<f32>>, LoadError> {
179    if file.find_tensor(name).is_none() {
180        return Ok(None);
181    }
182    Ok(Some(load_f32_vec(file, name)?))
183}
184
185fn load_mla_attn(
186    file: &impl TensorSource,
187    layer_idx: usize,
188    hp: &Deepseek2Hparams,
189) -> Result<MlaAttnWeights, LoadError> {
190    let l = layer_idx;
191    let q_head_dim = hp.qk_nope_head_dim + hp.qk_rope_head_dim;
192    let q_a_proj = load_weight_matrix(file, &format!("blk.{l}.attn_q_a.weight"))?;
193    let q_b_proj = load_weight_matrix(file, &format!("blk.{l}.attn_q_b.weight"))?;
194    let kv_a = load_weight_matrix(file, &format!("blk.{l}.attn_kv_a_mqa.weight"))?;
195    let o_proj = load_weight_matrix(file, &format!("blk.{l}.attn_output.weight"))?;
196
197    // Prefer combined `attn_kv_b`; else refuse split k_b/v_b until concat lands.
198    let kv_b_proj = if file
199        .find_tensor(&format!("blk.{l}.attn_kv_b.weight"))
200        .is_some()
201    {
202        load_weight_matrix(file, &format!("blk.{l}.attn_kv_b.weight"))?
203    } else {
204        return Err(LoadError::Gguf(ferrox_gguf::GgufError::TensorNotFound(
205            format!(
206                "blk.{l}.attn_kv_b.weight (split attn_k_b/attn_v_b not wired for MlaEngine yet)"
207            ),
208        )));
209    };
210
211    let q_a_ln = load_f32_vec_optional(file, &format!("blk.{l}.attn_q_a_norm.weight"))?
212        .unwrap_or_else(|| vec![1.0; hp.q_lora_rank]);
213    let kv_a_ln = load_f32_vec_optional(file, &format!("blk.{l}.attn_kv_a_norm.weight"))?
214        .unwrap_or_else(|| vec![1.0; hp.kv_lora_rank]);
215
216    let _ = (q_head_dim,);
217    Ok(MlaAttnWeights {
218        q_a_proj,
219        q_a_layernorm: q_a_ln,
220        q_b_proj,
221        kv_a_proj_with_mqa: kv_a,
222        kv_a_layernorm: kv_a_ln,
223        kv_b_proj,
224        o_proj,
225        g_proj: None,
226    })
227}
228
229fn require_tensor(file: &impl TensorSource, name: &str) -> Result<(), LoadError> {
230    if file.find_tensor(name).is_none() {
231        return Err(LoadError::Gguf(ferrox_gguf::GgufError::TensorNotFound(
232            name.to_string(),
233        )));
234    }
235    Ok(())
236}
237
238fn load_dense_ffn(file: &impl TensorSource, layer_idx: usize) -> Result<MlaDenseFfn, LoadError> {
239    let l = layer_idx;
240    Ok(MlaDenseFfn {
241        gate: load_weight_matrix(file, &format!("blk.{l}.ffn_gate.weight"))?,
242        up: load_weight_matrix(file, &format!("blk.{l}.ffn_up.weight"))?,
243        down: load_weight_matrix(file, &format!("blk.{l}.ffn_down.weight"))?,
244    })
245}
246
247fn load_moe_ffn(
248    file: &impl TensorSource,
249    layer_idx: usize,
250    hp: &Deepseek2Hparams,
251) -> Result<MlaMoeFfn, LoadError> {
252    let l = layer_idx;
253    // Fail-closed: every MoE tensor must be present (no silent dense fallback).
254    for name in [
255        format!("blk.{l}.ffn_gate_inp.weight"),
256        format!("blk.{l}.ffn_gate_exps.weight"),
257        format!("blk.{l}.ffn_up_exps.weight"),
258        format!("blk.{l}.ffn_down_exps.weight"),
259        format!("blk.{l}.ffn_gate_shexp.weight"),
260        format!("blk.{l}.ffn_up_shexp.weight"),
261        format!("blk.{l}.ffn_down_shexp.weight"),
262    ] {
263        require_tensor(file, &name)?;
264    }
265    let gate_exps =
266        split_expert_tensor(file, &format!("blk.{l}.ffn_gate_exps.weight"), hp.n_expert)?;
267    let up_exps = split_expert_tensor(file, &format!("blk.{l}.ffn_up_exps.weight"), hp.n_expert)?;
268    let down_exps =
269        split_expert_tensor(file, &format!("blk.{l}.ffn_down_exps.weight"), hp.n_expert)?;
270    let experts = gate_exps
271        .into_iter()
272        .zip(up_exps)
273        .zip(down_exps)
274        .map(|((gate, up), down)| ferrox_moe::ExpertWeights { gate, up, down })
275        .collect();
276    let shared_expert = ferrox_moe::ExpertWeights {
277        gate: load_weight_matrix(file, &format!("blk.{l}.ffn_gate_shexp.weight"))?,
278        up: load_weight_matrix(file, &format!("blk.{l}.ffn_up_shexp.weight"))?,
279        down: load_weight_matrix(file, &format!("blk.{l}.ffn_down_shexp.weight"))?,
280    };
281    // See the note in `glm52_gguf_loader`: the on-disk name has no
282    // `ffn_` prefix. Optional here on purpose -- llama.cpp declares it
283    // TENSOR_NOT_REQUIRED for `deepseek2`, which also covers V2-era
284    // checkpoints with no routing bias at all -- which is exactly why the
285    // wrong name was silent rather than a load error, and a real
286    // DeepSeek-V3 checkpoint routed with its bias dropped.
287    let exp_probs_bias = load_f32_vec_optional(file, &format!("blk.{l}.exp_probs_b.bias"))?;
288    Ok(MlaMoeFfn {
289        router: load_weight_matrix(file, &format!("blk.{l}.ffn_gate_inp.weight"))?,
290        experts,
291        shared_expert,
292        exp_probs_bias,
293    })
294}
295
296fn load_layer(
297    file: &impl TensorSource,
298    layer_idx: usize,
299    hp: &Deepseek2Hparams,
300) -> Result<MlaLayerWeights, LoadError> {
301    let l = layer_idx;
302    let ffn = if layer_idx < hp.leading_dense_block_count || hp.n_expert == 0 {
303        MlaLayerFfn::Dense(load_dense_ffn(file, layer_idx)?)
304    } else {
305        MlaLayerFfn::Moe(load_moe_ffn(file, layer_idx, hp)?)
306    };
307    Ok(MlaLayerWeights {
308        attn_norm: load_f32_vec(file, &format!("blk.{l}.attn_norm.weight"))?,
309        attn: load_mla_attn(file, layer_idx, hp)?,
310        ffn_norm: load_f32_vec(file, &format!("blk.{l}.ffn_norm.weight"))?,
311        ffn,
312    })
313}
314
315/// Load a DeepSeek-2 / Mistral-4 GGUF into [`MlaEngine`] (dense lead + MoE tail).
316pub fn load_mla_engine(file: &impl TensorSource) -> Result<MlaEngine, LoadError> {
317    let hp = read_deepseek2_hparams(file)?;
318    if hp.n_expert > 0 && hp.leading_dense_block_count >= hp.n_layer {
319        // Experts declared but every layer is still dense — ignore MoE.
320    } else if hp.n_expert > 0 && hp.n_expert_used == 0 {
321        return Err(LoadError::UnsupportedArchitecture(format!(
322            "{}: expert_count={} but expert_used_count is 0",
323            hp.arch, hp.n_expert
324        )));
325    }
326    if hp.n_layer == 0 {
327        return Err(LoadError::UnsupportedArchitecture(format!(
328            "{}: no layers to load",
329            hp.arch
330        )));
331    }
332
333    let embedding = if file.find_tensor("token_embd.weight").is_some() {
334        load_weight_matrix(file, "token_embd.weight")?
335    } else {
336        return Err(LoadError::Gguf(ferrox_gguf::GgufError::TensorNotFound(
337            "token_embd.weight".into(),
338        )));
339    };
340    let final_norm = load_f32_vec(file, "output_norm.weight")?;
341    let output_head = match load_weight_matrix(file, "output.weight") {
342        Ok(w) => w,
343        Err(_) => load_weight_matrix(file, "token_embd.weight")?,
344    };
345
346    let mut layers = Vec::with_capacity(hp.n_layer);
347    for i in 0..hp.n_layer {
348        layers.push(load_layer(file, i, &hp)?);
349    }
350    let has_moe = layers.iter().any(|l| matches!(l.ffn, MlaLayerFfn::Moe(_)));
351    let moe = if has_moe {
352        Some(MlaMoeRuntime {
353            n_experts_active: hp.n_expert_used,
354            gating: hp.gating,
355            norm_topk_prob: hp.norm_topk_prob,
356            expert_weights_scale: hp.expert_weights_scale,
357        })
358    } else {
359        None
360    };
361
362    Ok(MlaEngine {
363        embedding,
364        layers,
365        final_norm,
366        output_head,
367        mla_cfg: MlaConfig {
368            num_heads: hp.n_heads,
369            q_lora_rank: hp.q_lora_rank,
370            kv_lora_rank: hp.kv_lora_rank,
371            qk_nope_head_dim: hp.qk_nope_head_dim,
372            qk_rope_head_dim: hp.qk_rope_head_dim,
373            v_head_dim: hp.v_head_dim,
374            use_output_gate: false,
375            rope: Some(MlaRopeConfig {
376                theta: hp.rope_theta,
377            }),
378        },
379        rms_norm_eps: hp.rms_norm_eps,
380        hidden_dim: hp.hidden_dim,
381        moe,
382    })
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::engine::Engine;
389    use byteorder::{LittleEndian, WriteBytesExt};
390    use ferrox_gguf::GgufFile;
391    use std::io::Write;
392
393    struct FixtureTensor {
394        name: String,
395        shape: Vec<u64>,
396        bytes: Vec<u8>,
397    }
398
399    fn f32_bytes(v: &[f32]) -> Vec<u8> {
400        let mut b = Vec::with_capacity(v.len() * 4);
401        for x in v {
402            b.write_f32::<LittleEndian>(*x).unwrap();
403        }
404        b
405    }
406
407    fn f32_tensor(name: &str, shape: Vec<u64>, values: Vec<f32>) -> FixtureTensor {
408        FixtureTensor {
409            name: name.into(),
410            shape,
411            bytes: f32_bytes(&values),
412        }
413    }
414
415    fn build_gguf(
416        arch: &str,
417        kv: &[(&str, u64)],
418        fkv: &[(&str, f32)],
419        tensors: &[FixtureTensor],
420    ) -> Vec<u8> {
421        let mut buf = Vec::new();
422        buf.write_u32::<LittleEndian>(ferrox_gguf::GGUF_MAGIC)
423            .unwrap();
424        buf.write_u32::<LittleEndian>(3).unwrap();
425        buf.write_u64::<LittleEndian>(tensors.len() as u64).unwrap();
426        // general.architecture + uint + float kvs
427        let kv_count = 1 + kv.len() + fkv.len();
428        buf.write_u64::<LittleEndian>(kv_count as u64).unwrap();
429
430        let write_string = |buf: &mut Vec<u8>, s: &str| {
431            buf.write_u64::<LittleEndian>(s.len() as u64).unwrap();
432            buf.write_all(s.as_bytes()).unwrap();
433        };
434        write_string(&mut buf, "general.architecture");
435        buf.write_u32::<LittleEndian>(8).unwrap();
436        write_string(&mut buf, arch);
437        for &(k, v) in kv {
438            write_string(&mut buf, k);
439            buf.write_u32::<LittleEndian>(10).unwrap(); // UINT64
440            buf.write_u64::<LittleEndian>(v).unwrap();
441        }
442        for &(k, v) in fkv {
443            write_string(&mut buf, k);
444            buf.write_u32::<LittleEndian>(6).unwrap(); // FLOAT32
445            buf.write_f32::<LittleEndian>(v).unwrap();
446        }
447
448        let mut offset = 0u64;
449        let mut offsets = Vec::with_capacity(tensors.len());
450        for t in tensors {
451            write_string(&mut buf, &t.name);
452            buf.write_u32::<LittleEndian>(t.shape.len() as u32).unwrap();
453            for &d in t.shape.iter().rev() {
454                buf.write_u64::<LittleEndian>(d).unwrap();
455            }
456            buf.write_u32::<LittleEndian>(0).unwrap();
457            offsets.push(offset);
458            buf.write_u64::<LittleEndian>(offset).unwrap();
459            offset += (t.bytes.len().div_ceil(32) * 32) as u64;
460        }
461        while buf.len() % 32 != 0 {
462            buf.push(0);
463        }
464        let data_start = buf.len();
465        for (t, &off) in tensors.iter().zip(offsets.iter()) {
466            while buf.len() < data_start + off as usize {
467                buf.push(0);
468            }
469            buf.extend_from_slice(&t.bytes);
470            while buf.len() % 32 != 0 {
471                buf.push(0);
472            }
473        }
474        buf
475    }
476
477    #[test]
478    fn load_synthetic_deepseek2_dense_and_forward() {
479        let h = 16usize;
480        let n_heads = 2usize;
481        let q_lora = 8usize;
482        let kv_lora = 4usize;
483        let qk_nope = 4usize;
484        let qk_rope = 2usize;
485        let v_dim = 4usize;
486        let ffn = 32usize;
487        let vocab = 8usize;
488        let q_head = qk_nope + qk_rope;
489        let arch = "deepseek2";
490
491        let mut tensors = vec![
492            f32_tensor(
493                "token_embd.weight",
494                vec![vocab as u64, h as u64],
495                vec![0.01; h * vocab],
496            ),
497            f32_tensor("output_norm.weight", vec![h as u64], vec![1.0; h]),
498            f32_tensor(
499                "output.weight",
500                vec![vocab as u64, h as u64],
501                vec![0.02; h * vocab],
502            ),
503        ];
504        for l in 0..2usize {
505            tensors.push(f32_tensor(
506                &format!("blk.{l}.attn_norm.weight"),
507                vec![h as u64],
508                vec![1.0; h],
509            ));
510            tensors.push(f32_tensor(
511                &format!("blk.{l}.ffn_norm.weight"),
512                vec![h as u64],
513                vec![1.0; h],
514            ));
515            tensors.push(f32_tensor(
516                &format!("blk.{l}.attn_q_a.weight"),
517                vec![q_lora as u64, h as u64],
518                vec![0.01; h * q_lora],
519            ));
520            tensors.push(f32_tensor(
521                &format!("blk.{l}.attn_q_b.weight"),
522                vec![(n_heads * q_head) as u64, q_lora as u64],
523                vec![0.01; q_lora * n_heads * q_head],
524            ));
525            tensors.push(f32_tensor(
526                &format!("blk.{l}.attn_kv_a_mqa.weight"),
527                vec![(kv_lora + qk_rope) as u64, h as u64],
528                vec![0.01; h * (kv_lora + qk_rope)],
529            ));
530            tensors.push(f32_tensor(
531                &format!("blk.{l}.attn_kv_b.weight"),
532                vec![(n_heads * (qk_nope + v_dim)) as u64, kv_lora as u64],
533                vec![0.01; kv_lora * n_heads * (qk_nope + v_dim)],
534            ));
535            tensors.push(f32_tensor(
536                &format!("blk.{l}.attn_output.weight"),
537                vec![h as u64, (n_heads * v_dim) as u64],
538                vec![0.01; n_heads * v_dim * h],
539            ));
540            tensors.push(f32_tensor(
541                &format!("blk.{l}.ffn_gate.weight"),
542                vec![ffn as u64, h as u64],
543                vec![0.01; h * ffn],
544            ));
545            tensors.push(f32_tensor(
546                &format!("blk.{l}.ffn_up.weight"),
547                vec![ffn as u64, h as u64],
548                vec![0.01; h * ffn],
549            ));
550            tensors.push(f32_tensor(
551                &format!("blk.{l}.ffn_down.weight"),
552                vec![h as u64, ffn as u64],
553                vec![0.01; ffn * h],
554            ));
555        }
556
557        let kv = [
558            ("deepseek2.block_count", 2u64),
559            ("deepseek2.embedding_length", h as u64),
560            ("deepseek2.feed_forward_length", ffn as u64),
561            ("deepseek2.attention.head_count", n_heads as u64),
562            ("deepseek2.attention.q_lora_rank", q_lora as u64),
563            ("deepseek2.attention.kv_lora_rank", kv_lora as u64),
564            ("deepseek2.attention.qk_nope_head_dim", qk_nope as u64),
565            ("deepseek2.attention.qk_rope_head_dim", qk_rope as u64),
566            ("deepseek2.attention.v_head_dim", v_dim as u64),
567            ("deepseek2.leading_dense_block_count", 2u64),
568            ("deepseek2.expert_count", 0u64),
569        ];
570        let fkv = [
571            ("deepseek2.attention.layer_norm_rms_epsilon", 1e-5f32),
572            ("deepseek2.rope.freq_base", 10000.0f32),
573        ];
574        let bytes = build_gguf(arch, &kv, &fkv, &tensors);
575        let path =
576            std::env::temp_dir().join(format!("ferrox_mla_gguf_{}.gguf", std::process::id()));
577        std::fs::write(&path, &bytes).unwrap();
578        let file = GgufFile::open(&path).unwrap();
579        let engine = load_mla_engine(&file).expect("load mla");
580        assert_eq!(engine.layers.len(), 2);
581        assert_eq!(engine.vocab_size(), vocab);
582        let mut state = engine.new_state();
583        let logits = engine.forward_token(0, 0, &mut state);
584        assert_eq!(logits.len(), vocab);
585        assert!(logits.iter().all(|x| x.is_finite()));
586        let _ = std::fs::remove_file(&path);
587    }
588
589    #[allow(clippy::too_many_arguments)] // test fixture: mirrors the MLA tensor shape set
590    fn push_mla_attn_tensors(
591        tensors: &mut Vec<FixtureTensor>,
592        l: usize,
593        h: usize,
594        n_heads: usize,
595        q_lora: usize,
596        kv_lora: usize,
597        qk_nope: usize,
598        qk_rope: usize,
599        v_dim: usize,
600    ) {
601        let q_head = qk_nope + qk_rope;
602        tensors.push(f32_tensor(
603            &format!("blk.{l}.attn_norm.weight"),
604            vec![h as u64],
605            vec![1.0; h],
606        ));
607        tensors.push(f32_tensor(
608            &format!("blk.{l}.ffn_norm.weight"),
609            vec![h as u64],
610            vec![1.0; h],
611        ));
612        tensors.push(f32_tensor(
613            &format!("blk.{l}.attn_q_a.weight"),
614            vec![q_lora as u64, h as u64],
615            vec![0.01; h * q_lora],
616        ));
617        tensors.push(f32_tensor(
618            &format!("blk.{l}.attn_q_b.weight"),
619            vec![(n_heads * q_head) as u64, q_lora as u64],
620            vec![0.01; q_lora * n_heads * q_head],
621        ));
622        tensors.push(f32_tensor(
623            &format!("blk.{l}.attn_kv_a_mqa.weight"),
624            vec![(kv_lora + qk_rope) as u64, h as u64],
625            vec![0.01; h * (kv_lora + qk_rope)],
626        ));
627        tensors.push(f32_tensor(
628            &format!("blk.{l}.attn_kv_b.weight"),
629            vec![(n_heads * (qk_nope + v_dim)) as u64, kv_lora as u64],
630            vec![0.01; kv_lora * n_heads * (qk_nope + v_dim)],
631        ));
632        tensors.push(f32_tensor(
633            &format!("blk.{l}.attn_output.weight"),
634            vec![h as u64, (n_heads * v_dim) as u64],
635            vec![0.01; n_heads * v_dim * h],
636        ));
637    }
638
639    #[test]
640    fn load_synthetic_deepseek2_moe_after_dense_and_forward() {
641        let h = 16usize;
642        let n_heads = 2usize;
643        let q_lora = 8usize;
644        let kv_lora = 4usize;
645        let qk_nope = 4usize;
646        let qk_rope = 2usize;
647        let v_dim = 4usize;
648        let ffn = 32usize;
649        let exp_ff = 16usize;
650        let n_exp = 4usize;
651        let vocab = 8usize;
652        let arch = "deepseek2";
653
654        let mut tensors = vec![
655            f32_tensor(
656                "token_embd.weight",
657                vec![vocab as u64, h as u64],
658                vec![0.01; h * vocab],
659            ),
660            f32_tensor("output_norm.weight", vec![h as u64], vec![1.0; h]),
661            f32_tensor(
662                "output.weight",
663                vec![vocab as u64, h as u64],
664                vec![0.02; h * vocab],
665            ),
666        ];
667        // Layer 0: dense
668        push_mla_attn_tensors(
669            &mut tensors,
670            0,
671            h,
672            n_heads,
673            q_lora,
674            kv_lora,
675            qk_nope,
676            qk_rope,
677            v_dim,
678        );
679        tensors.push(f32_tensor(
680            "blk.0.ffn_gate.weight",
681            vec![ffn as u64, h as u64],
682            vec![0.01; h * ffn],
683        ));
684        tensors.push(f32_tensor(
685            "blk.0.ffn_up.weight",
686            vec![ffn as u64, h as u64],
687            vec![0.01; h * ffn],
688        ));
689        tensors.push(f32_tensor(
690            "blk.0.ffn_down.weight",
691            vec![h as u64, ffn as u64],
692            vec![0.01; ffn * h],
693        ));
694        // Layer 1: MoE
695        push_mla_attn_tensors(
696            &mut tensors,
697            1,
698            h,
699            n_heads,
700            q_lora,
701            kv_lora,
702            qk_nope,
703            qk_rope,
704            v_dim,
705        );
706        tensors.push(f32_tensor(
707            "blk.1.ffn_gate_inp.weight",
708            vec![n_exp as u64, h as u64],
709            vec![0.01; h * n_exp],
710        ));
711        // Packed expert tensors: logical [n_experts, out, in] → GGUF shape write uses rev
712        // so pass shape as [n_experts, out, in] matching other fixtures' logical order.
713        tensors.push(f32_tensor(
714            "blk.1.ffn_gate_exps.weight",
715            vec![n_exp as u64, exp_ff as u64, h as u64],
716            vec![0.01; n_exp * exp_ff * h],
717        ));
718        tensors.push(f32_tensor(
719            "blk.1.ffn_up_exps.weight",
720            vec![n_exp as u64, exp_ff as u64, h as u64],
721            vec![0.01; n_exp * exp_ff * h],
722        ));
723        tensors.push(f32_tensor(
724            "blk.1.ffn_down_exps.weight",
725            vec![n_exp as u64, h as u64, exp_ff as u64],
726            vec![0.01; n_exp * h * exp_ff],
727        ));
728        tensors.push(f32_tensor(
729            "blk.1.ffn_gate_shexp.weight",
730            vec![exp_ff as u64, h as u64],
731            vec![0.01; h * exp_ff],
732        ));
733        tensors.push(f32_tensor(
734            "blk.1.ffn_up_shexp.weight",
735            vec![exp_ff as u64, h as u64],
736            vec![0.01; h * exp_ff],
737        ));
738        tensors.push(f32_tensor(
739            "blk.1.ffn_down_shexp.weight",
740            vec![h as u64, exp_ff as u64],
741            vec![0.01; exp_ff * h],
742        ));
743
744        let kv = [
745            ("deepseek2.block_count", 2u64),
746            ("deepseek2.embedding_length", h as u64),
747            ("deepseek2.feed_forward_length", ffn as u64),
748            ("deepseek2.attention.head_count", n_heads as u64),
749            ("deepseek2.attention.q_lora_rank", q_lora as u64),
750            ("deepseek2.attention.kv_lora_rank", kv_lora as u64),
751            ("deepseek2.attention.qk_nope_head_dim", qk_nope as u64),
752            ("deepseek2.attention.qk_rope_head_dim", qk_rope as u64),
753            ("deepseek2.attention.v_head_dim", v_dim as u64),
754            ("deepseek2.leading_dense_block_count", 1u64),
755            ("deepseek2.expert_count", n_exp as u64),
756            ("deepseek2.expert_used_count", 2u64),
757            ("deepseek2.expert_shared_count", 1u64),
758            ("deepseek2.expert_feed_forward_length", exp_ff as u64),
759            ("deepseek2.expert_gating_func", 1u64), // softmax
760        ];
761        let fkv = [
762            ("deepseek2.attention.layer_norm_rms_epsilon", 1e-5f32),
763            ("deepseek2.rope.freq_base", 10000.0f32),
764            ("deepseek2.expert_weights_scale", 1.0f32),
765        ];
766        let bytes = build_gguf(arch, &kv, &fkv, &tensors);
767        let path =
768            std::env::temp_dir().join(format!("ferrox_mla_moe_gguf_{}.gguf", std::process::id()));
769        std::fs::write(&path, &bytes).unwrap();
770        let file = GgufFile::open(&path).unwrap();
771        let engine = load_mla_engine(&file).expect("load mla moe");
772        assert_eq!(engine.layers.len(), 2);
773        assert!(matches!(
774            engine.layers[0].ffn,
775            crate::engine::MlaLayerFfn::Dense(_)
776        ));
777        assert!(matches!(
778            engine.layers[1].ffn,
779            crate::engine::MlaLayerFfn::Moe(_)
780        ));
781        assert!(engine.moe.is_some());
782        let mut state = engine.new_state();
783        let logits = engine.forward_token(0, 0, &mut state);
784        assert_eq!(logits.len(), vocab);
785        assert!(logits.iter().all(|x| x.is_finite()));
786        let _ = std::fs::remove_file(&path);
787    }
788
789    #[test]
790    fn moe_after_dense_fails_closed_without_expert_tensors() {
791        let h = 16usize;
792        let n_heads = 2usize;
793        let q_lora = 8usize;
794        let kv_lora = 4usize;
795        let qk_nope = 4usize;
796        let qk_rope = 2usize;
797        let v_dim = 4usize;
798        let ffn = 32usize;
799        let vocab = 8usize;
800        let arch = "deepseek2";
801
802        let mut tensors = vec![
803            f32_tensor(
804                "token_embd.weight",
805                vec![vocab as u64, h as u64],
806                vec![0.01; h * vocab],
807            ),
808            f32_tensor("output_norm.weight", vec![h as u64], vec![1.0; h]),
809            f32_tensor(
810                "output.weight",
811                vec![vocab as u64, h as u64],
812                vec![0.02; h * vocab],
813            ),
814        ];
815        for l in 0..2usize {
816            push_mla_attn_tensors(
817                &mut tensors,
818                l,
819                h,
820                n_heads,
821                q_lora,
822                kv_lora,
823                qk_nope,
824                qk_rope,
825                v_dim,
826            );
827            // Only dense FFN tensors — MoE layer 1 will fail closed.
828            tensors.push(f32_tensor(
829                &format!("blk.{l}.ffn_gate.weight"),
830                vec![ffn as u64, h as u64],
831                vec![0.01; h * ffn],
832            ));
833            tensors.push(f32_tensor(
834                &format!("blk.{l}.ffn_up.weight"),
835                vec![ffn as u64, h as u64],
836                vec![0.01; h * ffn],
837            ));
838            tensors.push(f32_tensor(
839                &format!("blk.{l}.ffn_down.weight"),
840                vec![h as u64, ffn as u64],
841                vec![0.01; ffn * h],
842            ));
843        }
844        let kv = [
845            ("deepseek2.block_count", 2u64),
846            ("deepseek2.embedding_length", h as u64),
847            ("deepseek2.feed_forward_length", ffn as u64),
848            ("deepseek2.attention.head_count", n_heads as u64),
849            ("deepseek2.attention.q_lora_rank", q_lora as u64),
850            ("deepseek2.attention.kv_lora_rank", kv_lora as u64),
851            ("deepseek2.attention.qk_nope_head_dim", qk_nope as u64),
852            ("deepseek2.attention.qk_rope_head_dim", qk_rope as u64),
853            ("deepseek2.attention.v_head_dim", v_dim as u64),
854            ("deepseek2.leading_dense_block_count", 1u64),
855            ("deepseek2.expert_count", 4u64),
856            ("deepseek2.expert_used_count", 2u64),
857        ];
858        let fkv = [
859            ("deepseek2.attention.layer_norm_rms_epsilon", 1e-5f32),
860            ("deepseek2.rope.freq_base", 10000.0f32),
861        ];
862        let bytes = build_gguf(arch, &kv, &fkv, &tensors);
863        let path = std::env::temp_dir().join(format!(
864            "ferrox_mla_moe_missing_{}.gguf",
865            std::process::id()
866        ));
867        std::fs::write(&path, &bytes).unwrap();
868        let file = GgufFile::open(&path).unwrap();
869        let err = match load_mla_engine(&file) {
870            Err(e) => e,
871            Ok(_) => panic!("expected missing MoE tensors to fail closed"),
872        };
873        let msg = format!("{err}");
874        assert!(
875            msg.contains("ffn_gate_inp") || msg.contains("TensorNotFound"),
876            "unexpected error: {msg}"
877        );
878        let _ = std::fs::remove_file(&path);
879    }
880}