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}