Skip to main content

ferrox_models/
config.rs

1//! Architecture configs. Prefer GGUF / config.json over preset defaults.
2//! Unconfirmed preset fields must be listed in `best_effort_fields`.
3//! What actually runs: `docs/MODELS.md`.
4
5use ferrox_moe::{GatingFunction, MoeLayerConfig};
6
7/// Model-level (not per-layer) tensors `ModelConfig::from_gguf` reads.
8///
9/// The config is parsed from its own file handle, so these lookups are
10/// invisible to the handle the weight loader tracks consumption on.
11/// `loader::assert_every_tensor_consumed` replays them; anything added
12/// here must actually be *used*, not merely read, or the gate stops
13/// meaning what it says.
14pub const MODEL_LEVEL_TENSORS_READ_BY_CONFIG: &[&str] = &[
15    "rope_freqs.weight",
16    "rope_factors_long.weight",
17    "rope_factors_short.weight",
18];
19
20/// Which attention mechanism a model uses. `Gqa` (grouped-query
21/// attention + RoPE, uniform across every layer) is the only variant
22/// `ferrox-core`/`ferrox-models::decoder` actually implement today --
23/// it's what every preset runs through, including the two whose real
24/// published attention differs (DeepSeek V4 Pro's CSA/HCA, Kimi K3's
25/// hybrid KDA/Gated-MLA). `KimiHybrid` exists so Kimi K3's real,
26/// cited attention hyperparameters are captured accurately rather than
27/// silently discarded, even though `Decoder` itself still runs the
28/// GQA path for every layer (the dedicated Kimi decoder is the one
29/// consumer of the hybrid variant today).
30#[derive(Debug, Clone)]
31pub enum AttentionKind {
32    Gqa,
33    KimiHybrid(KimiHybridAttention),
34}
35
36/// Which concrete attention mechanism a single 0-indexed layer uses --
37/// the resolved answer `Decoder` needs per layer once it dispatches on
38/// `AttentionKind` instead of always running GQA (see
39/// `ModelConfig::layer_attention_kind`; only the dedicated Kimi
40/// decoder actually dispatches on it today).
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum LayerAttentionKind {
43    Gqa,
44    /// KDA (Kimi Delta Attention) -- see `ferrox_models::kda`.
45    KimiKda,
46    /// Gated MLA -- see `ferrox_models::mla`.
47    KimiMla,
48}
49
50/// Kimi K3's real attention topology, transcribed from the published
51/// `huggingface.co/moonshotai/Kimi-K3/config.json`'s `linear_attn_config`
52/// block. `kda_layers`/`full_attn_layers` are kept exactly as published
53/// -- **1-indexed** (layer 1 is the model's first transformer layer),
54/// not `ferrox`'s usual 0-indexed `layers` slice -- so a caller wiring
55/// this into `Decoder` must subtract 1 before indexing.
56#[derive(Debug, Clone)]
57pub struct KimiHybridAttention {
58    /// 1-indexed layers using KDA (Kimi Delta Attention: gated
59    /// linear/recurrent attention with a short causal conv). 69 of 93
60    /// layers.
61    pub kda_layers: Vec<usize>,
62    /// 1-indexed layers using Gated MLA (DeepSeek-style multi-head
63    /// latent attention with an output gate). 24 of 93 layers.
64    pub full_attn_layers: Vec<usize>,
65    pub mla: MlaConfig,
66    pub kda: KdaConfig,
67}
68
69/// Gated MLA (multi-head latent attention) hyperparameters, verified
70/// against Kimi K3's real `config.json` `text_config` block and the
71/// real `KimiMLAAttention` reference implementation
72/// (`modeling_kimi_linear.py`).
73#[derive(Debug, Clone)]
74pub struct MlaConfig {
75    pub num_heads: usize,
76    pub q_lora_rank: usize,
77    pub kv_lora_rank: usize,
78    pub qk_nope_head_dim: usize,
79    pub qk_rope_head_dim: usize,
80    pub v_head_dim: usize,
81    /// Kimi K3's addition on top of standard DeepSeek-style MLA:
82    /// `attn_output *= sigmoid(g_proj(hidden_states))` before `o_proj`.
83    pub use_output_gate: bool,
84    /// `None` reproduces Kimi K3's real, confirmed behavior: no rotary
85    /// embedding at all (`mla.rs`'s module doc comment; the real
86    /// `KimiMLAAttention.forward` asserts `use_nope` and never calls a
87    /// rotary function). `Some` is for architectures whose decoupled
88    /// `q_rot`/`k_rot` slices genuinely are position-rotated -- e.g.
89    /// GLM-5.2, whose real `config.json` (`zai-org/GLM-5.2`) sets
90    /// `rope_interleave: true` for its main attention (confirmed
91    /// against llama.cpp PR #25407's `LLAMA_ROPE_TYPE_NORM` rope call
92    /// on `q_pe`/`k_pe` in `src/models/glm-dsa.cpp`).
93    pub rope: Option<MlaRopeConfig>,
94}
95
96/// RoPE parameters for the decoupled `q_rot`/`k_rot` slices of an MLA
97/// attention layer that does apply rotation (unlike Kimi K3 -- see
98/// `MlaConfig::rope`'s doc comment). Always the interleaved convention
99/// (`ferrox_core::attention::apply_rope_interleaved`) for every real
100/// architecture confirmed so far to use this (GLM-5.2's
101/// `rope_interleave: true`); a separate split-half variant isn't wired
102/// in here since no confirmed real user of it exists yet.
103#[derive(Debug, Clone, Copy)]
104pub struct MlaRopeConfig {
105    pub theta: f32,
106}
107
108/// KDA (Kimi Delta Attention) hyperparameters, verified against Kimi
109/// K3's real `config.json` `linear_attn_config` block and the real
110/// gated delta-rule reference implementation in
111/// `fla-org/flash-linear-attention`'s `fla/ops/kda/naive.py` (the
112/// exact recurrence: decay state by `exp(g)`, then add a rank-1
113/// `beta * k ⊗ (v - kᵀS)` correction, then read `o = qᵀS`) and
114/// `fla/ops/kda/gate.py` (the lower-bounded gate:
115/// `g = gate_lower_bound * sigmoid(exp(A_log) * (raw_g + dt_bias))`,
116/// and `beta = sigmoid(raw_beta)`).
117#[derive(Debug, Clone)]
118pub struct KdaConfig {
119    pub num_heads: usize,
120    pub head_dim: usize,
121    pub short_conv_kernel_size: usize,
122    pub gate_lower_bound: f32,
123    pub use_full_rank_gate: bool,
124}
125
126/// Which RoPE pairing convention a model uses. Confirmed against
127/// llama.cpp's `llama_model_rope_type` (`src/llama-model.cpp`):
128/// `Norm` is adjacent-pair / GPT-J (`LLAMA_ROPE_TYPE_NORM`); `Neox` is
129/// split-half / GPT-NeoX (`LLAMA_ROPE_TYPE_NEOX`). Getting this wrong
130/// silently produces fluent-but-wrong logits (the real Llama-3.1-8B
131/// early-stop bug: ferrox applied NeoX to a Norm architecture).
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum RopeLayout {
134    /// Adjacent pairs `(2*i, 2*i+1)` -- llama.cpp `LLAMA_ROPE_TYPE_NORM`.
135    /// Used by `llama` (including Llama 3/3.1/3.2), `deepseek2`,
136    /// `mistral3`, and related families. (`llama4` is DedicatedOnly — MoE
137    /// graph — but its RoPE type in the inventory is still Norm.)
138    Norm,
139    /// Split-half pairs `(i, i+half)` -- llama.cpp `LLAMA_ROPE_TYPE_NEOX`.
140    /// Used by `olmoe`, `qwen2`/`qwen2moe`/`qwen3`, `phi3`, `gemma*`, and
141    /// related families. Ferrox's historical default before architecture-
142    /// aware dispatch existed.
143    Neox,
144}
145
146impl RopeLayout {
147    /// Maps a GGUF `general.architecture` string onto the RoPE pairing
148    /// llama.cpp selects for that family. Prefer
149    /// [`crate::capability::resolve_architecture`] for load-time
150    /// decisions — unknown architectures must fail closed there rather
151    /// than guessing. This helper remains for tests and call sites that
152    /// already know the arch is registered; unknowns still return `Neox`
153    /// only as a last-resort historical default.
154    pub fn for_gguf_architecture(arch: &str) -> Self {
155        match crate::capability::resolve_profile(arch) {
156            Some(p) => p.rope,
157            // Unknown: do not invent Norm for a Qwen/Phi/Gemma-shaped
158            // string that happened to miss the registry.
159            None => RopeLayout::Neox,
160        }
161    }
162}
163
164#[derive(Debug, Clone)]
165pub struct ModelConfig {
166    pub name: &'static str,
167    pub n_layers: usize,
168    pub hidden_dim: usize,
169    pub n_heads: usize,
170    pub n_kv_heads: usize,
171    pub head_dim: usize,
172    pub vocab_size: usize,
173    pub rope_theta: f32,
174    pub rms_norm_eps: f32,
175    pub moe: MoeLayerConfig,
176    /// `Gqa` for every preset except Kimi K3. `Decoder`'s forward pass
177    /// does not yet branch on this -- see `AttentionKind`'s doc
178    /// comment.
179    pub attention: AttentionKind,
180    /// Mistral/Mixtral/Qwen2-family sliding-window attention: when
181    /// set, every layer attends only to the most recent `N` cached
182    /// positions instead of the full causal history (see
183    /// `ferrox_core::attention::causal_gqa_attention_windowed`'s doc
184    /// comment for the real source citations). `None` for every
185    /// architecture that doesn't use this (most models, including
186    /// Qwen1.5/Qwen2-MoE's real published config, which sets
187    /// `use_sliding_window: false` despite carrying a `sliding_window`
188    /// value -- so this field being `None`/`Some` must come from that
189    /// enable flag, not just the window-size field's presence).
190    pub sliding_window: Option<usize>,
191    /// How many of the model's *first* layers use an ordinary dense
192    /// FFN (no expert routing at all) rather than the model's MoE
193    /// topology. Found by reading ik_llama.cpp's real GGUF
194    /// hparams-loading source (`LLM_KV_LEADING_DENSE_BLOCK_COUNT`):
195    /// DeepSeek-2/3-family models don't apply MoE uniformly to every
196    /// layer -- the first few layers are always dense. Zero means
197    /// "every layer uses this model's MoE topology," the default for
198    /// architectures that don't do this.
199    pub n_dense_leading_layers: usize,
200    /// Llama 3/3.1/3.2's real per-band RoPE frequency correction (the
201    /// `rope_freqs.weight` GGUF tensor, `head_dim/2` elements,
202    /// `TENSOR_NOT_REQUIRED` so most architectures leave this `None`).
203    /// See `ferrox_core::attention::apply_rope_with_freq_factors`'s doc
204    /// comment for the real source and the real bug this closes: without
205    /// it, every RoPE angle for a Llama-3-family checkpoint is computed
206    /// slightly wrong, an error that compounds with position and
207    /// eventually produces wrong logits (a spurious early EOS was the
208    /// observed real symptom).
209    ///
210    /// Not only a tensor: this is the *resolved* per-band divisor array,
211    /// so a checkpoint declaring `rope.scaling.type = "yarn"` gets its
212    /// YaRN frequency rewrite folded in here too (see
213    /// `ferrox_core::attention::yarn_freq_factors`, and
214    /// `loader::yarn_scaling_from_gguf` for what the file has to declare
215    /// before that happens). A file carrying both a tensor and a YaRN
216    /// declaration composes them by multiplication, as llama.cpp does
217    /// (`ggml_rope_cache_init` divides by `freq_factors` and *then*
218    /// runs `rope_yarn`). Consumers must therefore treat this as "the
219    /// correction to apply", not as "the tensor this file shipped".
220    pub rope_freqs: Option<Vec<f32>>,
221    /// LongRoPE's two candidate factor sets, kept so the choice between
222    /// them can be made when the *run's* context size is known rather
223    /// than at parse time. llama.cpp picks per request
224    /// (`llama_model::get_rope_factors` reads `cparams.n_ctx_seq`), and
225    /// the two sets are not interchangeable: Phi-4-mini's short set is
226    /// all ones (no correction at all) while its long set reaches 47.
227    /// Choosing from the checkpoint's advertised 131072 when the user
228    /// runs at 4096 is a different model.
229    pub rope_freqs_long: Option<Vec<f32>>,
230    pub rope_freqs_short: Option<Vec<f32>>,
231    /// `<arch>.rope.scaling.original_context_length` — the threshold the
232    /// choice above is made against.
233    pub rope_orig_ctx: Option<usize>,
234    /// Rotary width when it is narrower than `head_dim`
235    /// (`<arch>.rope.dimension_count`, llama.cpp `hparams.n_rot`).
236    /// `None` means the whole head rotates, which is the common case.
237    /// Phi-3/Phi-4 rotate 96 of 128.
238    pub rope_dim: Option<usize>,
239    /// LongRoPE/YaRN magnitude scaling (`<arch>.rope.scaling.attn_factor`,
240    /// llama.cpp `hparams.rope_attn_factor` folded into
241    /// `cparams.yarn_attn_factor` at `llama-context.cpp:231`, then applied
242    /// as ggml `rope_yarn`'s `mscale`, which multiplies *both* `cos` and
243    /// `sin` — so it scales the RoPE'd vector, at every position, whether
244    /// or not any frequency correction is active.
245    ///
246    /// Phi-4-mini ships `1.1902381`. Ignoring it does not merely change
247    /// long-context behaviour: q and k are both scaled, so every attention
248    /// logit is off by `attn_factor²` and the softmax is sharper than the
249    /// model's. Measured symptom: ferrox and llama.cpp diverge from the
250    /// eighth token of a greedy completion on the same GGUF.
251    ///
252    /// `1.0` for every architecture that does not set the key.
253    pub rope_attn_factor: f32,
254    /// RoPE pairing convention for this architecture -- see
255    /// `RopeLayout`. Independently of `rope_freqs`: a Llama checkpoint
256    /// needs both `Norm` pairing *and* the per-band frequency factors.
257    pub rope_layout: RopeLayout,
258    /// How Q/K RMSNorm weights are applied when present (see
259    /// [`crate::capability::QkNormStyle`]).
260    pub qk_norm_style: crate::capability::QkNormStyle,
261    /// Gemma 2+/3 alternating SWA period. When `Some(p)`, layer `il`
262    /// uses sliding-window attention iff `(il + 1) % p != 0` (llama.cpp
263    /// `set_swa_pattern` convention); every `p`-th layer is full-attn.
264    pub swa_pattern: Option<usize>,
265    /// Attention logit soft-capping (Gemma 2+). Applied as
266    /// `softcap * tanh(score / softcap)` before softmax.
267    pub attn_logit_softcap: Option<f32>,
268    /// Final logit soft-capping (Gemma 2+). Applied to lm_head output.
269    pub final_logit_softcap: Option<f32>,
270    /// Input embedding scale (Gemma: `sqrt(hidden_dim)`).
271    pub embedding_scale: Option<f32>,
272    /// Optional override for the attention score scale baked into Q
273    /// *instead of* the kernel's default `1/sqrt(head_dim)`. When set,
274    /// callers must pass `score_scale = 1.0` into the attention kernel
275    /// (llama.cpp Gemma: scale Q then `build_attn(..., 1.0f)`). Prefer
276    /// leaving this `None` when the override equals `1/sqrt(head_dim)`.
277    pub attention_scale: Option<f32>,
278    /// RoPE base used on SWA layers (Gemma 3: defaults to `10000` when
279    /// the GGUF omits `rope.freq_base_swa`; full-attn layers keep
280    /// [`Self::rope_theta`]).
281    pub rope_theta_swa: Option<f32>,
282    /// Dense/MoE FFN activation pairing.
283    pub ffn_activation: FfnActivation,
284    /// Every field on this config that is a best-effort estimate rather
285    /// than a confirmed value from an official config.json / GGUF file.
286    pub best_effort_fields: &'static [&'static str],
287}
288
289/// Dense / expert FFN non-linearity used by the generic decoder.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
291pub enum FfnActivation {
292    /// `silu(gate) * up` with separate gate/up matrices (Llama / Qwen).
293    #[default]
294    Swiglu,
295    /// Phi-3 fused gate+up in one `ffn_up` matrix (`2 * n_ff` rows).
296    SwigluFused,
297    /// Gemma GeGLU: `gelu(gate) * up`.
298    Gelu,
299}
300
301impl ModelConfig {
302    /// Re-picks the LongRoPE factor set now that the run's context size
303    /// is known, matching llama.cpp `llama_model::get_rope_factors`:
304    /// `rope_freqs.weight` (Llama 3) always wins; otherwise the long set
305    /// applies only when the context exceeds
306    /// `rope.scaling.original_context_length`, and the short set
307    /// otherwise.
308    ///
309    /// A no-op for every checkpoint that ships neither set, which is all
310    /// of them except the Phi-3/Phi-4 family today.
311    ///
312    /// Because it re-picks `rope_freqs` wholesale it would also discard
313    /// a YaRN rewrite folded into that field at parse time (see
314    /// [`Self::rope_freqs`]). No real checkpoint hits that: LongRoPE
315    /// files declare `rope.scaling.type = "longrope"`, which the loader's
316    /// YaRN arm deliberately does not claim, so the two never populate
317    /// the field on the same file.
318    pub fn apply_runtime_context(&mut self, ctx: usize) {
319        let (Some(orig), true) = (
320            self.rope_orig_ctx,
321            self.rope_freqs_long.is_some() || self.rope_freqs_short.is_some(),
322        ) else {
323            return;
324        };
325        let picked = if ctx > orig {
326            self.rope_freqs_long.as_ref()
327        } else {
328            self.rope_freqs_short.as_ref()
329        };
330        if let Some(f) = picked
331            .or(self.rope_freqs_long.as_ref())
332            .or(self.rope_freqs_short.as_ref())
333        {
334            self.rope_freqs = Some(f.clone());
335        }
336    }
337
338    /// True if layer `layer_idx` (0-indexed) should be built as an
339    /// ordinary dense FFN rather than this model's MoE topology.
340    pub fn layer_is_dense(&self, layer_idx: usize) -> bool {
341        layer_idx < self.n_dense_leading_layers
342    }
343
344    /// Sliding-window size for layer `il`, honouring Gemma-style
345    /// alternating SWA patterns. `None` means full causal attention.
346    pub fn layer_sliding_window(&self, layer_idx: usize) -> Option<usize> {
347        let window = self.sliding_window?;
348        match self.swa_pattern {
349            None => Some(window),
350            Some(period) if period > 1 => {
351                // llama.cpp `set_swa_pattern` (dense_first=false):
352                // `is_swa = (il % period) < (period - 1)` — equivalent to
353                // full attention when `(il + 1) % period == 0`.
354                if (layer_idx + 1).is_multiple_of(period) {
355                    None
356                } else {
357                    Some(window)
358                }
359            }
360            Some(_) => Some(window),
361        }
362    }
363
364    /// The narrowest sliding window any layer of this model uses, or
365    /// `None` if every layer is full-causal.
366    ///
367    /// For an alternating-SWA model (gpt-oss, Gemma-3) the
368    /// full-attention layers impose no constraint on the KV block
369    /// layout and the sliding ones impose the window -- so the model's
370    /// constraint is simply the window, present as soon as *any* layer
371    /// slides. A model that is 5/6 full-attention is not 5/6 exempt:
372    /// one mis-aligned sliding layer corrupts the answer.
373    pub fn kv_block_window(&self) -> Option<usize> {
374        (0..self.n_layers).find_map(|il| self.layer_sliding_window(il))
375    }
376
377    /// The window EVERY layer slides by, or `None` if any layer attends
378    /// over the whole history.
379    ///
380    /// This is the opposite question to [`Self::kv_block_window`], and
381    /// the difference is the whole reason both exist. That one asks
382    /// "does any layer constrain the block layout", so one sliding layer
383    /// is enough. This one asks "may a page that has fallen behind the
384    /// window be taken away", and there one *full-attention* layer is
385    /// enough to say no.
386    ///
387    /// A page group holds one block in every layer and is freed as a
388    /// unit, so on an alternating-SWA model (gpt-oss, Gemma-3) freeing
389    /// the group behind the window would take the full-attention layers'
390    /// block with it -- and those layers still read position 0 at every
391    /// step. The result is not a crash: the block is reused by another
392    /// request and the full layers attend over its bytes. So this
393    /// returns `None` for the alternating case, and a mixed-window model
394    /// (were one to appear) gets `None` too rather than the narrowest
395    /// window, because the widest is the one that must still be readable.
396    pub fn uniform_sliding_window(&self) -> Option<usize> {
397        let first = self.layer_sliding_window(0)?;
398        (1..self.n_layers)
399            .all(|il| self.layer_sliding_window(il) == Some(first))
400            .then_some(first)
401    }
402
403    /// The KV cache block layout to use for this model, given the block
404    /// size an operator asked for.
405    ///
406    /// The requested size is rounded *down* to something that divides
407    /// the window (see [`ferrox_core::kv_swa`]), so a config that would
408    /// straddle the window boundary becomes a smaller block rather than
409    /// a startup failure or -- much worse -- a silently wrong mask.
410    pub fn kv_block_layout(&self, desired_block_size: usize) -> ferrox_core::BlockLayout {
411        let window = self.kv_block_window();
412        let block_size = ferrox_core::aligned_block_size(desired_block_size, window);
413        ferrox_core::BlockLayout::new(block_size, window)
414            .expect("aligned_block_size returns a size BlockLayout accepts")
415    }
416
417    /// RoPE frequency base for layer `il` (SWA layers may differ).
418    pub fn layer_rope_theta(&self, layer_idx: usize) -> f32 {
419        match (self.layer_sliding_window(layer_idx), self.rope_theta_swa) {
420            (Some(_), Some(theta)) => theta,
421            _ => self.rope_theta,
422        }
423    }
424
425    /// Which attention mechanism layer `layer_idx` (0-indexed, ferrox's
426    /// usual convention) uses. For `AttentionKind::Gqa` every layer is
427    /// `LayerAttentionKind::Gqa`; for `AttentionKind::KimiHybrid`, looks
428    /// up `layer_idx + 1` (the real `kda_layers`/`full_attn_layers`
429    /// lists are 1-indexed -- see `KimiHybridAttention`'s doc comment)
430    /// in those real per-layer lists.
431    ///
432    /// # Panics
433    /// If `layer_idx` isn't covered by either list of a `KimiHybrid`
434    /// config -- can't happen for `kimi_k3()`, whose lists are tested
435    /// (`kimi_k3_hybrid_attention_layers_partition_every_layer_exactly_once`)
436    /// to partition every layer with no gaps, but a caller building a
437    /// custom `KimiHybridAttention` must uphold the same invariant.
438    pub fn layer_attention_kind(&self, layer_idx: usize) -> LayerAttentionKind {
439        match &self.attention {
440            AttentionKind::Gqa => LayerAttentionKind::Gqa,
441            AttentionKind::KimiHybrid(hybrid) => {
442                let one_indexed = layer_idx + 1;
443                if hybrid.kda_layers.contains(&one_indexed) {
444                    LayerAttentionKind::KimiKda
445                } else if hybrid.full_attn_layers.contains(&one_indexed) {
446                    LayerAttentionKind::KimiMla
447                } else {
448                    panic!(
449                        "layer {layer_idx} (1-indexed {one_indexed}) is in neither \
450                         kda_layers nor full_attn_layers"
451                    )
452                }
453            }
454        }
455    }
456
457    /// Total parameter count implied by the MoE config, as a sanity
458    /// check against the publicly reported total (this is an order of
459    /// magnitude check, not an exact parameter-count reproduction).
460    pub fn approx_active_params_per_token(&self) -> usize {
461        let attn_params_per_layer = 4 * self.hidden_dim * self.hidden_dim; // q,k,v,o (rough)
462        let active_experts = self.moe.n_experts_active + self.moe.n_shared_experts;
463        let expert_params = active_experts * 3 * self.moe.hidden_dim * self.moe.expert_ffn_dim; // gate,up,down
464        self.n_layers * (attn_params_per_layer + expert_params)
465    }
466}
467
468/// GLM-5.2 (Z.ai) **structural sketch only** — not a supported real
469/// inference path. Real DSA lives in `glm_dsa` / `glm52_decoder` and is
470/// not wired into `Decoder` / `ferrox-server`. This preset drives
471/// smoke/bench with synthetic GQA weights only (~744B / ~40B active
472/// hparams as published placeholders).
473pub fn glm_5_2() -> ModelConfig {
474    ModelConfig {
475        sliding_window: None,
476        name: "glm-5.2",
477        attention: AttentionKind::Gqa,
478        n_layers: 92,
479        hidden_dim: 6144,
480        n_heads: 48,
481        n_kv_heads: 8,
482        head_dim: 128,
483        vocab_size: 151552,
484        rope_theta: 1_000_000.0,
485        rms_norm_eps: 1e-5,
486        moe: MoeLayerConfig {
487            expert_weights_scale: 1.0,
488            n_experts: 256,
489            n_experts_active: 8,
490            n_shared_experts: 1,
491            hidden_dim: 6144,
492            expert_ffn_dim: 2048,
493            // Sigmoid, not softmax: reading ik_llama.cpp's real GGUF
494            // hparams-loading source (llama-hparams.cpp,
495            // LLM_ARCH_GLM4_MOE case) directly showed GLM4-MoE-family
496            // models default to sigmoid gating with post-selection
497            // score renormalization. GLM-5.2 is presumed to continue
498            // this lineage; not confirmed against GLM-5.2's own
499            // config.json (unavailable in this environment).
500            gating: GatingFunction::Sigmoid,
501            norm_topk_prob: true,
502         expert_group_count: None, expert_group_used_count: None,},
503        // No evidence found (via ik_llama.cpp source or public
504        // reporting) that GLM-5.2 skips MoE on any leading layers;
505        // defaulting to 0 (every layer uses this model's MoE
506        // topology) rather than assuming DeepSeek's convention
507        // applies here too.
508        n_dense_leading_layers: 0,
509        rope_freqs: None,
510        rope_attn_factor: 1.0,
511        rope_dim: None,
512        rope_freqs_long: None,
513        rope_freqs_short: None,
514        rope_orig_ctx: None,
515        // Placeholder GQA path; real GLM-5.2 DSA uses interleaved RoPE
516        // via `glm_dsa`/`mla`, not this preset's Decoder path.
517        rope_layout: RopeLayout::Neox,
518        qk_norm_style: crate::capability::QkNormStyle::WholeVector,
519        swa_pattern: None,
520        attn_logit_softcap: None,
521        final_logit_softcap: None,
522        embedding_scale: None,
523        attention_scale: None,
524        rope_theta_swa: None,
525        ffn_activation: FfnActivation::Swiglu,
526        best_effort_fields: &[
527            "n_layers",
528            "hidden_dim",
529            "n_heads",
530            "n_kv_heads",
531            "head_dim",
532            "rope_theta",
533            "moe.expert_ffn_dim",
534            "moe.n_shared_experts",
535            "moe.gating (sigmoid assumed from GLM4-MoE-family convention found in ik_llama.cpp source, not confirmed for GLM-5.2 specifically)",
536        ],
537    }
538}
539
540/// DeepSeek V4 Pro **structural sketch only** — CSA/HCA is not on this
541/// GQA `Decoder` path. Real primitives live under
542/// `deepseek_v4_attention` / `hyper_connections` and are not assembled
543/// into a served decoder yet. Hparams (~1.6T / ~49B active) are
544/// placeholders for smoke/bench.
545pub fn deepseek_v4_pro() -> ModelConfig {
546    ModelConfig {
547        sliding_window: None,
548        name: "deepseek-v4-pro",
549        attention: AttentionKind::Gqa,
550        n_layers: 96,
551        hidden_dim: 7168,
552        n_heads: 56,
553        n_kv_heads: 8,
554        head_dim: 128,
555        vocab_size: 129280,
556        rope_theta: 1_000_000.0,
557        rms_norm_eps: 1e-6,
558        moe: MoeLayerConfig {
559            expert_weights_scale: 1.0,
560            n_experts: 385,
561            n_experts_active: 6,
562            n_shared_experts: 1,
563            hidden_dim: 7168,
564            expert_ffn_dim: 2048,
565            // Sigmoid, not softmax: this is the stronger-confidence of
566            // the two sigmoid-gating corrections in this file.
567            // DeepSeek-V3's own published technical report explicitly
568            // documents computing per-expert affinity via sigmoid and
569            // renormalizing only the selected experts' scores to sum
570            // to one; reading ik_llama.cpp's real GGUF hparams-loading
571            // source (llama-hparams.cpp, LLM_ARCH_DEEPSEEK2 case)
572            // confirmed this is exactly what that code path defaults
573            // to for the DeepSeek-2/3 lineage. DeepSeek V4 Pro is
574            // presumed to continue using sigmoid gating for the same
575            // reason; not confirmed against V4 Pro's own config.json.
576            gating: GatingFunction::Sigmoid,
577            norm_topk_prob: true,
578         expert_group_count: None, expert_group_used_count: None,},
579        // DeepSeek-V3's own published technical report documents the
580        // first 3 transformer layers as dense (ordinary FFN, no
581        // expert routing), with MoE starting from layer 4 onward;
582        // ik_llama.cpp's real hparams-loading source
583        // (LLM_KV_LEADING_DENSE_BLOCK_COUNT) confirms this is a real,
584        // loaded GGUF metadata field for the DeepSeek-2/3 lineage.
585        // DeepSeek V4 Pro is presumed to continue this convention;
586        // not confirmed against V4 Pro's own config.json.
587        n_dense_leading_layers: 3,
588        rope_freqs: None,
589        rope_attn_factor: 1.0,
590        rope_dim: None,
591        rope_freqs_long: None,
592        rope_freqs_short: None,
593        rope_orig_ctx: None,
594        // llama.cpp maps LLM_ARCH_DEEPSEEK4 -> LLAMA_ROPE_TYPE_NORM.
595        rope_layout: RopeLayout::Norm,
596        qk_norm_style: crate::capability::QkNormStyle::WholeVector,
597        swa_pattern: None,
598        attn_logit_softcap: None,
599        final_logit_softcap: None,
600        embedding_scale: None,
601        attention_scale: None,
602        rope_theta_swa: None,
603        ffn_activation: FfnActivation::Swiglu,
604        best_effort_fields: &[
605            "n_layers",
606            "hidden_dim",
607            "n_heads",
608            "n_kv_heads",
609            "head_dim",
610            "moe.expert_ffn_dim",
611            "attention_variant (CSA/HCA hybrid NOT implemented, GQA fallback in use)",
612            "moe.gating (sqrtsoftplus: confirmed for real V4 in llama.cpp PR #24162; this preset still uses Sigmoid on the wrong GQA sketch path)",
613            "n_dense_leading_layers (3: same confidence basis as gating above, DeepSeek-V3 technical report + ik_llama.cpp source, not confirmed for V4 Pro)",
614        ],
615    }
616}
617
618/// Kimi K3 **structural sketch only** for the generic GQA `Decoder`.
619/// Real checkpoint work uses the dedicated Kimi stack (`kimi_loader` /
620/// `KimiEngine`); slice-verified, not a full end-to-end run. Do not
621/// treat this preset as a runnable Kimi substitute.
622pub fn kimi_k3() -> ModelConfig {
623    ModelConfig {
624        sliding_window: None,
625        name: "kimi-k3",
626        n_layers: 93,
627        hidden_dim: 7168,
628        // n_heads/n_kv_heads/head_dim describe the Gqa fallback
629        // Decoder actually runs today, not Kimi K3's real attention
630        // (see `attention` below) -- kept at reasonable stand-in
631        // values (matching MLA's num_heads=96 and combined
632        // qk_nope+qk_rope head dim) rather than deleted, so the
633        // placeholder path stays runnable.
634        n_heads: 96,
635        n_kv_heads: 96,
636        head_dim: 192,
637        vocab_size: 163840,
638        // Not present in the published text_config; RoPE only ever
639        // applies to Gated MLA's 64-dim qk_rope_head_dim slice in the
640        // real architecture, and Decoder doesn't implement that slicing
641        // yet, so this remains an unconfirmed placeholder.
642        rope_theta: 1_000_000.0,
643        rms_norm_eps: 1e-5,
644        moe: MoeLayerConfig {
645            expert_weights_scale: 1.0,
646            n_experts: 896,
647            n_experts_active: 16,
648            n_shared_experts: 2,
649            hidden_dim: 7168,
650            expert_ffn_dim: 3072,
651            // Confirmed directly from the real config.json:
652            // "moe_router_activation_func": "sigmoid".
653            gating: GatingFunction::Sigmoid,
654            norm_topk_prob: true,
655         expert_group_count: None, expert_group_used_count: None,},
656        // Confirmed directly from the real config.json:
657        // "first_k_dense_replace": 1.
658        n_dense_leading_layers: 1,
659        // Kimi K3's real, published attention topology (verified
660        // against huggingface.co/moonshotai/Kimi-K3/config.json's
661        // linear_attn_config block and the real KimiDeltaAttention /
662        // KimiMLAAttention reference implementations in
663        // modeling_kimi_linear.py) -- not yet wired into Decoder's
664        // forward pass, which still runs the Gqa placeholder above for
665        // every layer regardless of this field.
666        attention: AttentionKind::KimiHybrid(KimiHybridAttention {
667            kda_layers: vec![
668                1, 2, 3, 5, 6, 7, 9, 10, 11, 13, 14, 15, 17, 18, 19, 21, 22, 23, 25, 26, 27, 29,
669                30, 31, 33, 34, 35, 37, 38, 39, 41, 42, 43, 45, 46, 47, 49, 50, 51, 53, 54, 55,
670                57, 58, 59, 61, 62, 63, 65, 66, 67, 69, 70, 71, 73, 74, 75, 77, 78, 79, 81, 82,
671                83, 85, 86, 87, 89, 90, 91,
672            ],
673            full_attn_layers: vec![
674                4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84,
675                88, 92, 93,
676            ],
677            mla: MlaConfig {
678                num_heads: 96,
679                q_lora_rank: 1536,
680                kv_lora_rank: 512,
681                qk_nope_head_dim: 128,
682                qk_rope_head_dim: 64,
683                v_head_dim: 128,
684                use_output_gate: true,
685                // Real, confirmed: Kimi K3's `KimiMLAAttention.forward`
686                // never rotates -- see `MlaConfig::rope`'s doc comment.
687                rope: None,
688            },
689            kda: KdaConfig {
690                num_heads: 96,
691                head_dim: 128,
692                short_conv_kernel_size: 4,
693                gate_lower_bound: -5.0,
694                use_full_rank_gate: true,
695            },
696        }),
697        rope_freqs: None,
698        rope_attn_factor: 1.0,
699        rope_dim: None,
700        rope_freqs_long: None,
701        rope_freqs_short: None,
702        rope_orig_ctx: None,
703        // GQA placeholder path only; real Kimi attention is rope-less MLA
704        // or KDA and never reaches Decoder::apply_rope_head.
705        rope_layout: RopeLayout::Neox,
706        qk_norm_style: crate::capability::QkNormStyle::WholeVector,
707        swa_pattern: None,
708        attn_logit_softcap: None,
709        final_logit_softcap: None,
710        embedding_scale: None,
711        attention_scale: None,
712        rope_theta_swa: None,
713        ffn_activation: FfnActivation::Swiglu,
714        best_effort_fields: &[
715            "n_heads/n_kv_heads/head_dim (describe the unimplemented Gqa placeholder, not Kimi K3's real MLA/KDA attention -- see `attention` field)",
716            "rope_theta (not present in the published config; real architecture only applies RoPE to Gated MLA's qk_rope_head_dim slice, which Decoder doesn't implement)",
717            "entire preset beyond hyperparameters (the real 2.8T-parameter checkpoint has not been run end to end; only real slices have, via the dedicated kimi_decoder/kimi_loader stack -- see docs/MODELS.md)",
718        ],
719    }
720}
721
722/// Matches the generated on-disk fixture exactly (hidden_dim, head
723/// counts, ffn_dim, vocab, rope_theta, eps).
724/// Used by `ferrox inspect-run` and the cross-validation test in
725/// `crates/ferrox-models/tests/gguf_roundtrip.rs` to prove the real
726/// GGUF loader + forward pass produce the same numbers as an
727/// independent NumPy reference implementation reading the same file.
728pub fn test_dense_fixture() -> ModelConfig {
729    ModelConfig {
730        sliding_window: None,
731        name: "ferrox-test-dense",
732        attention: AttentionKind::Gqa,
733        n_layers: 2,
734        hidden_dim: 32,
735        n_heads: 4,
736        n_kv_heads: 2,
737        head_dim: 8,
738        vocab_size: 32,
739        rope_theta: 10000.0,
740        rms_norm_eps: 1e-5,
741        moe: MoeLayerConfig {
742            expert_weights_scale: 1.0,
743            n_experts: 1,
744            n_experts_active: 1,
745            n_shared_experts: 0,
746            hidden_dim: 32,
747            expert_ffn_dim: 32,
748            gating: GatingFunction::Softmax,
749            norm_topk_prob: true,
750            expert_group_count: None,
751            expert_group_used_count: None,
752        },
753        n_dense_leading_layers: 0,
754        rope_freqs: None,
755        rope_attn_factor: 1.0,
756        rope_dim: None,
757        rope_freqs_long: None,
758        rope_freqs_short: None,
759        rope_orig_ctx: None,
760        // Matches the independent reference's split-half apply_rope.
761        rope_layout: RopeLayout::Neox,
762        qk_norm_style: crate::capability::QkNormStyle::WholeVector,
763        swa_pattern: None,
764        attn_logit_softcap: None,
765        final_logit_softcap: None,
766        embedding_scale: None,
767        attention_scale: None,
768        rope_theta_swa: None,
769        ffn_activation: FfnActivation::Swiglu,
770        best_effort_fields: &["this is a synthetic test fixture, not a real model"],
771    }
772}
773
774/// Matches the generated on-disk multi-expert MoE fixture: 4 experts,
775/// top-2 routing, 1 shared
776/// expert, packed 3D expert tensors. Used to verify the previously-
777/// untested multi-expert loading path (`split_expert_tensor` in
778/// `ferrox-models::loader`) against a real file, the same way
779/// `test_dense_fixture` verifies the single-expert path.
780pub fn test_moe_fixture() -> ModelConfig {
781    ModelConfig {
782        sliding_window: None,
783        name: "ferrox-test-moe",
784        attention: AttentionKind::Gqa,
785        n_layers: 2,
786        hidden_dim: 32,
787        n_heads: 4,
788        n_kv_heads: 2,
789        head_dim: 8,
790        vocab_size: 32,
791        rope_theta: 10000.0,
792        rms_norm_eps: 1e-5,
793        moe: MoeLayerConfig {
794            expert_weights_scale: 1.0,
795            n_experts: 4,
796            n_experts_active: 2,
797            n_shared_experts: 1,
798            hidden_dim: 32,
799            expert_ffn_dim: 32,
800            gating: GatingFunction::Softmax,
801            norm_topk_prob: true,
802            expert_group_count: None,
803            expert_group_used_count: None,
804        },
805        n_dense_leading_layers: 0,
806        rope_freqs: None,
807        rope_attn_factor: 1.0,
808        rope_dim: None,
809        rope_freqs_long: None,
810        rope_freqs_short: None,
811        rope_orig_ctx: None,
812        rope_layout: RopeLayout::Neox,
813        qk_norm_style: crate::capability::QkNormStyle::WholeVector,
814        swa_pattern: None,
815        attn_logit_softcap: None,
816        final_logit_softcap: None,
817        embedding_scale: None,
818        attention_scale: None,
819        rope_theta_swa: None,
820        ffn_activation: FfnActivation::Swiglu,
821        best_effort_fields: &["this is a synthetic multi-expert test fixture, not a real model"],
822    }
823}
824
825/// Matches the generated on-disk mixed-topology fixture: 3 layers, the
826/// first of which is
827/// an ordinary dense FFN and the remaining two are genuine MoE (3
828/// experts, top-1 routing, 1 shared expert each). Used to verify the
829/// "leading dense layers" loading path
830/// (`ModelConfig::layer_is_dense`) against a real file -- the pattern
831/// found in DeepSeek-2/3-family models via ik_llama.cpp's source
832/// (`LLM_KV_LEADING_DENSE_BLOCK_COUNT`), which was previously only
833/// documented, not implemented or tested.
834pub fn test_mixed_fixture() -> ModelConfig {
835    ModelConfig {
836        sliding_window: None,
837        name: "ferrox-test-mixed",
838        attention: AttentionKind::Gqa,
839        n_layers: 3,
840        hidden_dim: 32,
841        n_heads: 4,
842        n_kv_heads: 2,
843        head_dim: 8,
844        vocab_size: 32,
845        rope_theta: 10000.0,
846        rms_norm_eps: 1e-5,
847        moe: MoeLayerConfig {
848            expert_weights_scale: 1.0,
849            n_experts: 3,
850            n_experts_active: 1,
851            n_shared_experts: 1,
852            hidden_dim: 32,
853            expert_ffn_dim: 32,
854            gating: GatingFunction::Softmax,
855            norm_topk_prob: true,
856            expert_group_count: None,
857            expert_group_used_count: None,
858        },
859        n_dense_leading_layers: 1,
860        rope_freqs: None,
861        rope_attn_factor: 1.0,
862        rope_dim: None,
863        rope_freqs_long: None,
864        rope_freqs_short: None,
865        rope_orig_ctx: None,
866        rope_layout: RopeLayout::Neox,
867        qk_norm_style: crate::capability::QkNormStyle::WholeVector,
868        swa_pattern: None,
869        attn_logit_softcap: None,
870        final_logit_softcap: None,
871        embedding_scale: None,
872        attention_scale: None,
873        rope_theta_swa: None,
874        ffn_activation: FfnActivation::Swiglu,
875        best_effort_fields: &["this is a synthetic mixed dense/MoE test fixture, not a real model"],
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    #[test]
884    fn rope_layout_for_gguf_architecture_matches_llama_cpp() {
885        // Confirmed against llama.cpp's llama_model_rope_type
886        // (src/llama-model.cpp): llama -> NORM, olmoe/qwen2/phi3/gemma -> NEOX.
887        assert_eq!(RopeLayout::for_gguf_architecture("llama"), RopeLayout::Norm);
888        assert_eq!(
889            RopeLayout::for_gguf_architecture("llama4"),
890            RopeLayout::Norm
891        );
892        assert_eq!(
893            RopeLayout::for_gguf_architecture("deepseek2"),
894            RopeLayout::Norm
895        );
896        assert_eq!(RopeLayout::for_gguf_architecture("olmoe"), RopeLayout::Neox);
897        assert_eq!(RopeLayout::for_gguf_architecture("qwen2"), RopeLayout::Neox);
898        assert_eq!(
899            RopeLayout::for_gguf_architecture("qwen2moe"),
900            RopeLayout::Neox
901        );
902        assert_eq!(RopeLayout::for_gguf_architecture("qwen3"), RopeLayout::Neox);
903        assert_eq!(RopeLayout::for_gguf_architecture("phi3"), RopeLayout::Neox);
904        assert_eq!(
905            RopeLayout::for_gguf_architecture("gemma3"),
906            RopeLayout::Neox
907        );
908        // Unknown architectures keep the historical Neox default at this
909        // helper only; load-time uses capability::resolve_architecture and
910        // fails closed instead of guessing.
911        assert_eq!(
912            RopeLayout::for_gguf_architecture("totally-unknown-arch"),
913            RopeLayout::Neox
914        );
915    }
916
917    /// gpt-oss's real shape: a 128-token window on every other layer.
918    /// A KV block size of 128 or any divisor of it is fine; 48 or 256
919    /// are not, and the config layer must round down rather than hand
920    /// the cache something it will refuse (or, worse, accept).
921    #[test]
922    fn an_alternating_swa_model_constrains_the_block_layout() {
923        let mut cfg = test_dense_fixture();
924        cfg.n_layers = 24;
925        cfg.sliding_window = Some(128);
926        cfg.swa_pattern = Some(2);
927
928        // Half the layers are full-attention, but the model is still
929        // constrained: one mis-aligned sliding layer is enough.
930        assert!(cfg.layer_sliding_window(1).is_none() || cfg.layer_sliding_window(0).is_none());
931        assert_eq!(cfg.kv_block_window(), Some(128));
932
933        let layout = cfg.kv_block_layout(256);
934        assert_eq!(layout.block_size(), 128, "256 must round down, not up");
935        assert_eq!(layout.sliding_window(), Some(128));
936        assert_eq!(layout.blocks_per_window(), Some(1));
937
938        assert_eq!(cfg.kv_block_layout(48).block_size(), 32);
939        assert_eq!(cfg.kv_block_layout(32).block_size(), 32);
940    }
941
942    /// Gemma-3: window 512, every 6th layer full-attention.
943    #[test]
944    fn a_gemma3_shaped_model_takes_its_window_from_the_sliding_layers() {
945        let mut cfg = test_dense_fixture();
946        cfg.n_layers = 30;
947        cfg.sliding_window = Some(512);
948        cfg.swa_pattern = Some(6);
949        assert!(
950            cfg.layer_sliding_window(5).is_none(),
951            "every 6th layer is full-attention"
952        );
953        assert_eq!(cfg.kv_block_window(), Some(512));
954        assert_eq!(cfg.kv_block_layout(100).block_size(), 64);
955        assert_eq!(cfg.kv_block_layout(64).blocks_per_window(), Some(8));
956    }
957
958    /// The two window questions give OPPOSITE answers on an alternating
959    /// model, and that is the point of having both.
960    ///
961    /// "Does any layer constrain the block layout" is yes, so the block
962    /// size rounds down to the window. "May a page behind the window be
963    /// taken away" is no, because the group holds the full-attention
964    /// layers' blocks too and those layers still read position 0. A
965    /// serving path that read `kv_block_window` for the second question
966    /// would free pages half the layers are still attending over -- not
967    /// a crash, just another request's bytes in this one's answer.
968    #[test]
969    fn only_a_uniformly_windowed_model_may_give_a_page_back() {
970        let mut alternating = test_dense_fixture();
971        alternating.n_layers = 24;
972        alternating.sliding_window = Some(128);
973        alternating.swa_pattern = Some(2);
974        assert_eq!(alternating.kv_block_window(), Some(128));
975        assert_eq!(
976            alternating.uniform_sliding_window(),
977            None,
978            "a full-attention layer forbids the slide"
979        );
980
981        let mut uniform = test_dense_fixture();
982        uniform.n_layers = 24;
983        uniform.sliding_window = Some(128);
984        uniform.swa_pattern = None;
985        assert_eq!(uniform.uniform_sliding_window(), Some(128));
986
987        // `swa_pattern = Some(1)` is every layer sliding, spelled as a
988        // pattern -- `layer_sliding_window` already treats it that way,
989        // and the two answers must agree with it.
990        let mut period_one = uniform.clone();
991        period_one.swa_pattern = Some(1);
992        assert_eq!(period_one.uniform_sliding_window(), Some(128));
993
994        let mut full = test_dense_fixture();
995        full.sliding_window = None;
996        assert_eq!(full.uniform_sliding_window(), None);
997    }
998
999    #[test]
1000    fn a_full_causal_model_keeps_the_block_size_it_was_given() {
1001        let mut cfg = test_dense_fixture();
1002        cfg.sliding_window = None;
1003        cfg.swa_pattern = None;
1004        assert_eq!(cfg.kv_block_window(), None);
1005        let layout = cfg.kv_block_layout(48);
1006        assert_eq!(layout.block_size(), 48);
1007        assert_eq!(layout.sliding_window(), None);
1008    }
1009
1010    #[test]
1011    fn all_presets_have_consistent_moe_hidden_dim() {
1012        for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1013            assert_eq!(
1014                cfg.hidden_dim, cfg.moe.hidden_dim,
1015                "{}: attention hidden_dim and MoE hidden_dim must match",
1016                cfg.name
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn all_presets_route_fewer_experts_than_total() {
1023        for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1024            assert!(
1025                cfg.moe.n_experts_active < cfg.moe.n_experts,
1026                "{}: active experts must be a sparse subset of total experts",
1027                cfg.name
1028            );
1029        }
1030    }
1031
1032    #[test]
1033    fn all_presets_have_divisible_heads() {
1034        for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1035            assert_eq!(
1036                cfg.n_heads % cfg.n_kv_heads,
1037                0,
1038                "{}: n_heads must be a multiple of n_kv_heads for GQA grouping",
1039                cfg.name
1040            );
1041        }
1042    }
1043
1044    #[test]
1045    fn every_preset_declares_its_uncertain_fields() {
1046        // This is a documentation-honesty test: any preset with zero
1047        // best_effort_fields would be silently overclaiming precision
1048        // we don't have. Fail loudly if that ever happens.
1049        for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1050            assert!(
1051                !cfg.best_effort_fields.is_empty(),
1052                "{}: must disclose which fields are unconfirmed estimates",
1053                cfg.name
1054            );
1055        }
1056    }
1057
1058    /// Kimi K3's `kda_layers`/`full_attn_layers` were transcribed by
1059    /// hand from the real published config.json; this test guards
1060    /// against a transcription slip (duplicate, out-of-range, or
1061    /// missing layer index) rather than trusting the transcription.
1062    #[test]
1063    fn kimi_k3_hybrid_attention_layers_partition_every_layer_exactly_once() {
1064        let cfg = kimi_k3();
1065        let AttentionKind::KimiHybrid(hybrid) = &cfg.attention else {
1066            panic!("kimi_k3() must use AttentionKind::KimiHybrid");
1067        };
1068
1069        let mut seen = std::collections::HashSet::new();
1070        for &l in hybrid
1071            .kda_layers
1072            .iter()
1073            .chain(hybrid.full_attn_layers.iter())
1074        {
1075            assert!(
1076                (1..=cfg.n_layers).contains(&l),
1077                "layer {l} is out of the published 1..={} range",
1078                cfg.n_layers
1079            );
1080            assert!(
1081                seen.insert(l),
1082                "layer {l} appears in both/either list twice"
1083            );
1084        }
1085        // Dense-vs-MoE (n_dense_leading_layers) and attention-type
1086        // (KDA vs Gated MLA) are independent per-layer properties in
1087        // the real config -- e.g. layer 1 is both the sole dense
1088        // leading layer *and* a KDA layer -- so every one of the 93
1089        // layers, dense or not, is covered by exactly one of these two
1090        // lists (confirmed: 69 + 24 == 93, not 93 - 1).
1091        assert_eq!(
1092            hybrid.kda_layers.len() + hybrid.full_attn_layers.len(),
1093            cfg.n_layers,
1094            "every layer must be assigned exactly one of KDA or Gated MLA"
1095        );
1096        assert_eq!(
1097            hybrid.kda_layers.len(),
1098            69,
1099            "expected 69 KDA layers per the published config"
1100        );
1101        assert_eq!(
1102            hybrid.full_attn_layers.len(),
1103            24,
1104            "expected 24 Gated MLA layers per the published config"
1105        );
1106    }
1107
1108    #[test]
1109    fn layer_attention_kind_is_gqa_for_every_layer_of_a_gqa_model() {
1110        let cfg = glm_5_2();
1111        for l in 0..cfg.n_layers {
1112            assert_eq!(cfg.layer_attention_kind(l), LayerAttentionKind::Gqa);
1113        }
1114    }
1115
1116    #[test]
1117    fn layer_attention_kind_classifies_every_kimi_k3_layer_without_panicking() {
1118        let cfg = kimi_k3();
1119        let AttentionKind::KimiHybrid(hybrid) = &cfg.attention else {
1120            panic!("kimi_k3() must use AttentionKind::KimiHybrid");
1121        };
1122        for l in 0..cfg.n_layers {
1123            let kind = cfg.layer_attention_kind(l);
1124            let one_indexed = l + 1;
1125            if hybrid.kda_layers.contains(&one_indexed) {
1126                assert_eq!(kind, LayerAttentionKind::KimiKda);
1127            } else {
1128                assert_eq!(kind, LayerAttentionKind::KimiMla);
1129            }
1130        }
1131    }
1132
1133    #[test]
1134    fn layer_attention_kind_matches_the_real_published_layer_1_and_4() {
1135        // Layer 1 (1-indexed, so index 0 here) is published as KDA;
1136        // layer 4 (index 3) is published as the first Gated MLA layer.
1137        let cfg = kimi_k3();
1138        assert_eq!(cfg.layer_attention_kind(0), LayerAttentionKind::KimiKda);
1139        assert_eq!(cfg.layer_attention_kind(3), LayerAttentionKind::KimiMla);
1140    }
1141
1142    #[test]
1143    fn kimi_k3_mla_q_head_dim_matches_gqa_placeholder_head_dim() {
1144        // The Gqa-placeholder head_dim above is deliberately set to
1145        // Gated MLA's combined q_head_dim (qk_nope + qk_rope) so the
1146        // placeholder path at least reflects a real dimension from the
1147        // published config rather than an arbitrary guess.
1148        let cfg = kimi_k3();
1149        let AttentionKind::KimiHybrid(hybrid) = &cfg.attention else {
1150            panic!("kimi_k3() must use AttentionKind::KimiHybrid");
1151        };
1152        assert_eq!(
1153            cfg.head_dim,
1154            hybrid.mla.qk_nope_head_dim + hybrid.mla.qk_rope_head_dim
1155        );
1156    }
1157
1158    #[test]
1159    fn approx_active_params_is_nonzero_and_finite_order_of_magnitude() {
1160        for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1161            let approx = cfg.approx_active_params_per_token();
1162            // Sanity band: active params/token for these models is
1163            // reported in the tens of billions; this is a loose
1164            // order-of-magnitude check (1e9 to 1e12), not a precise
1165            // parameter-count reproduction.
1166            assert!(
1167                approx > 1_000_000_000 && approx < 1_000_000_000_000,
1168                "{}: approx_active_params_per_token={approx} is outside a plausible range",
1169                cfg.name
1170            );
1171        }
1172    }
1173}
1174
1175#[cfg(test)]
1176mod longrope_tests {
1177    use super::*;
1178
1179    fn cfg_with_factors() -> ModelConfig {
1180        let mut c = test_dense_fixture();
1181        c.rope_orig_ctx = Some(4096);
1182        c.rope_freqs_short = Some(vec![1.0; 48]);
1183        c.rope_freqs_long = Some((0..48).map(|i| 1.0 + i as f32).collect());
1184        c.rope_freqs = None;
1185        c
1186    }
1187
1188    /// llama.cpp `llama_model::get_rope_factors`: long only when the
1189    /// run's context exceeds `original_context_length`. Phi-4-mini's
1190    /// short set is all ones, so picking long at 4096 would apply a
1191    /// correction the model never asked for at that length.
1192    #[test]
1193    fn long_set_only_above_the_original_context() {
1194        let mut c = cfg_with_factors();
1195        c.apply_runtime_context(4096);
1196        assert_eq!(
1197            c.rope_freqs.as_ref().unwrap()[1],
1198            1.0,
1199            "at the threshold, short"
1200        );
1201
1202        let mut c = cfg_with_factors();
1203        c.apply_runtime_context(4097);
1204        assert_eq!(c.rope_freqs.as_ref().unwrap()[1], 2.0, "above it, long");
1205
1206        let mut c = cfg_with_factors();
1207        c.apply_runtime_context(1024);
1208        assert_eq!(c.rope_freqs.as_ref().unwrap()[1], 1.0, "below it, short");
1209    }
1210
1211    /// `rope_freqs.weight` (Llama 3) is not a LongRoPE set and outranks
1212    /// one, the same precedence llama.cpp gives it. The loader encodes
1213    /// that by leaving the long/short pair empty whenever the explicit
1214    /// tensor is present, so the runtime re-pick has nothing to apply.
1215    #[test]
1216    fn an_explicit_rope_freqs_tensor_is_never_overridden() {
1217        let mut c = test_dense_fixture();
1218        c.rope_freqs = Some(vec![7.0; 48]);
1219        c.rope_orig_ctx = Some(4096);
1220        c.rope_freqs_long = None;
1221        c.rope_freqs_short = None;
1222        c.apply_runtime_context(131072);
1223        assert_eq!(c.rope_freqs.as_ref().unwrap()[0], 7.0);
1224    }
1225
1226    /// A checkpoint with neither set must come back untouched, so the
1227    /// call is free to sit on every load path.
1228    #[test]
1229    fn models_without_longrope_are_untouched() {
1230        let mut c = test_dense_fixture();
1231        c.rope_freqs = None;
1232        c.apply_runtime_context(8192);
1233        assert!(c.rope_freqs.is_none());
1234        assert!(c.rope_orig_ctx.is_none());
1235    }
1236}