frink_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 frink_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/// `frink-core`/`frink-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 `frink_models::kda`.
45 KimiKda,
46 /// Gated MLA -- see `frink_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 `frink`'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/// (`frink_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: frink 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. Frink'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 /// Decoder layers: llama.cpp's `n_layer()`, which is the file's
168 /// `block_count` MINUS [`Self::n_mtp_blocks`].
169 pub n_layers: usize,
170 /// NextN / MTP blocks the file appends after the trunk, inside its
171 /// `block_count`, which llama.cpp creates `TENSOR_SKIP` and never
172 /// runs (`crate::mtp_blocks`). Their tensors are `blk.N.*` for
173 /// `n_layers <= N < n_layers + n_mtp_blocks`; the loader marks them
174 /// deliberately unread. Zero for every architecture whose graph
175 /// does not read `nextn_predict_layers`.
176 pub n_mtp_blocks: usize,
177 pub hidden_dim: usize,
178 /// Query heads of the WIDEST layer. Every layer's for a uniform
179 /// model, which is every model but the per-layer-shape ones
180 /// (`crate::layer_shapes`); a layer body must read its own count
181 /// through [`Self::layer_shape`], never this field.
182 pub n_heads: usize,
183 /// KV heads of the WIDEST layer, so that a budget priced from it
184 /// over-counts rather than under-counts a heterogeneous model.
185 /// Same rule as `n_heads`: per-layer computation reads
186 /// [`Self::layer_shape`]; caches come from [`Self::new_kv_caches`].
187 pub n_kv_heads: usize,
188 /// The K head width (`attention.key_length`, llama.cpp
189 /// `n_embd_head_k`): the width of every Q and K head, the width RoPE
190 /// rotates within, and the `1/sqrt` of the attention scale.
191 pub head_dim: usize,
192 /// The V head width (`attention.value_length`, `n_embd_head_v`)
193 /// WHEN IT DIFFERS from [`Self::head_dim`]; `None` means V heads are
194 /// K's width, which is every architecture but MiMo-V2 (`head_dim:
195 /// 192, v_head_dim: 128`). Read it through [`Self::v_head_dim`],
196 /// never here: an `Option` rather than a second `usize` so that a
197 /// config whose `head_dim` is set or changed cannot leave a stale V
198 /// width beside it -- the two-fields-that-must-agree shape. See
199 /// [`crate::kv_head_dims`] for which architectures may declare them
200 /// apart and which fused paths refuse when they are.
201 pub v_head_dim: Option<usize>,
202 pub vocab_size: usize,
203 pub rope_theta: f32,
204 pub rms_norm_eps: f32,
205 /// The epsilon the POST-attention and POST-FFN norms run at.
206 ///
207 /// Equal to [`Self::rms_norm_eps`] for every architecture but the
208 /// one whose graph writes a literal (`crate::norm::
209 /// POST_NORM_EPS_LITERAL`, `muse-glimmer.cpp:63`). Set by the
210 /// loader from that table so the two cannot be given different
211 /// answers by two callers, and read through
212 /// [`Self::post_norm_eps`].
213 pub post_norm_eps: f32,
214 /// The norm FUNCTION every weighted site applies
215 /// (`crate::norm::norm_function`): the architecture's, or, for
216 /// `crate::norm::NORM_BY_RMS_EPS_KEY`, the file's.
217 pub norm_function: crate::norm::NormFunction,
218 pub moe: MoeLayerConfig,
219 /// `Gqa` for every preset except Kimi K3. `Decoder`'s forward pass
220 /// does not yet branch on this -- see `AttentionKind`'s doc
221 /// comment.
222 pub attention: AttentionKind,
223 /// Mistral/Mixtral/Qwen2-family sliding-window attention: when
224 /// set, every layer attends only to the most recent `N` cached
225 /// positions instead of the full causal history (see
226 /// `frink_core::attention::causal_gqa_attention_windowed`'s doc
227 /// comment for the real source citations). `None` for every
228 /// architecture that doesn't use this (most models, including
229 /// Qwen1.5/Qwen2-MoE's real published config, which sets
230 /// `use_sliding_window: false` despite carrying a `sliding_window`
231 /// value -- so this field being `None`/`Some` must come from that
232 /// enable flag, not just the window-size field's presence).
233 pub sliding_window: Option<usize>,
234 /// How many of the model's *first* layers use an ordinary dense
235 /// FFN (no expert routing at all) rather than the model's MoE
236 /// topology. Found by reading ik_llama.cpp's real GGUF
237 /// hparams-loading source (`LLM_KV_LEADING_DENSE_BLOCK_COUNT`):
238 /// DeepSeek-2/3-family models don't apply MoE uniformly to every
239 /// layer -- the first few layers are always dense. Zero means
240 /// "every layer uses this model's MoE topology," the default for
241 /// architectures that don't do this.
242 pub n_dense_leading_layers: usize,
243 /// `{arch}.interleave_moe_layer_step` where the loader honours it
244 /// (`crate::moe_interleave::INTERLEAVE_STEP_HONOURED_BY_LOADER`):
245 /// layer `il` is MoE when `(il + 1) % step == 0`. `None` everywhere
246 /// else, including the ERNIE files whose step is 1.
247 pub moe_interleave_step: Option<usize>,
248 /// Llama 3/3.1/3.2's real per-band RoPE frequency correction (the
249 /// `rope_freqs.weight` GGUF tensor, `head_dim/2` elements,
250 /// `TENSOR_NOT_REQUIRED` so most architectures leave this `None`).
251 /// See `frink_core::attention::apply_rope_with_freq_factors`'s doc
252 /// comment for the real source and the real bug this closes: without
253 /// it, every RoPE angle for a Llama-3-family checkpoint is computed
254 /// slightly wrong, an error that compounds with position and
255 /// eventually produces wrong logits (a spurious early EOS was the
256 /// observed real symptom).
257 ///
258 /// Not only a tensor: this is the *resolved* per-band divisor array,
259 /// so a checkpoint declaring `rope.scaling.type = "yarn"` gets its
260 /// YaRN frequency rewrite folded in here too (see
261 /// `frink_core::attention::yarn_freq_factors`, and
262 /// `loader::yarn_scaling_from_gguf` for what the file has to declare
263 /// before that happens). A file carrying both a tensor and a YaRN
264 /// declaration composes them by multiplication, as llama.cpp does
265 /// (`ggml_rope_cache_init` divides by `freq_factors` and *then*
266 /// runs `rope_yarn`). Consumers must therefore treat this as "the
267 /// correction to apply", not as "the tensor this file shipped".
268 ///
269 /// Per-LAYER, because llama.cpp's is: see [`RopeFreqs`]. Read it
270 /// through [`Self::layer_rope`], never field-by-field.
271 pub rope_freqs: Option<RopeFreqs>,
272 /// LongRoPE's two candidate factor sets, kept so the choice between
273 /// them can be made when the *run's* context size is known rather
274 /// than at parse time. llama.cpp picks per request
275 /// (`llama_model::get_rope_factors` reads `cparams.n_ctx_seq`), and
276 /// the two sets are not interchangeable: Phi-4-mini's short set is
277 /// all ones (no correction at all) while its long set reaches 47.
278 /// Choosing from the checkpoint's advertised 131072 when the user
279 /// runs at 4096 is a different model.
280 pub rope_freqs_long: Option<Vec<f32>>,
281 pub rope_freqs_short: Option<Vec<f32>>,
282 /// `<arch>.rope.scaling.original_context_length` — the threshold the
283 /// choice above is made against.
284 pub rope_orig_ctx: Option<usize>,
285 /// Rotary width when it is narrower than `head_dim`
286 /// (`<arch>.rope.dimension_count`, llama.cpp `hparams.n_rot`).
287 /// `None` means the whole head rotates, which is the common case.
288 /// Phi-3/Phi-4 rotate 96 of 128.
289 ///
290 /// This is the FULL-attention layers' width, llama.cpp's
291 /// `n_rot_full`; [`Self::rope_dim_swa`] is the sliding layers'.
292 pub rope_dim: Option<usize>,
293 /// The SLIDING layers' rotary width when it differs from
294 /// [`Self::rope_dim`] -- llama.cpp's `n_rot_swa`, read from
295 /// `rope.dimension_count_swa` or halved-from-full for `step35`
296 /// (`crate::swa_geometry`), consumed through `n_rot(il)`
297 /// (`llama-hparams.cpp:85-91`). `None` means the sliding layers
298 /// rotate the same width as the full ones, which is every
299 /// architecture but the ones the table names. `Some(head_dim)` is
300 /// the whole head, and [`Self::layer_rope`] normalises it.
301 pub rope_dim_swa: Option<usize>,
302 /// LongRoPE/YaRN magnitude scaling (`<arch>.rope.scaling.attn_factor`,
303 /// llama.cpp `hparams.rope_attn_factor` folded into
304 /// `cparams.yarn_attn_factor` at `llama-context.cpp:231`, then applied
305 /// as ggml `rope_yarn`'s `mscale`, which multiplies *both* `cos` and
306 /// `sin` — so it scales the RoPE'd vector, at every position, whether
307 /// or not any frequency correction is active.
308 ///
309 /// Phi-4-mini ships `1.1902381`. Ignoring it does not merely change
310 /// long-context behaviour: q and k are both scaled, so every attention
311 /// logit is off by `attn_factor²` and the softmax is sharper than the
312 /// model's. Measured symptom: frink and llama.cpp diverge from the
313 /// eighth token of a greedy completion on the same GGUF.
314 ///
315 /// `1.0` for every architecture that does not set the key.
316 pub rope_attn_factor: f32,
317 /// RoPE pairing convention for this architecture -- see
318 /// `RopeLayout`. Independently of `rope_freqs`: a Llama checkpoint
319 /// needs both `Norm` pairing *and* the per-band frequency factors.
320 pub rope_layout: RopeLayout,
321 /// How Q/K RMSNorm weights are applied when present (see
322 /// [`crate::capability::QkNormStyle`]).
323 pub qk_norm_style: crate::capability::QkNormStyle,
324 /// WHICH LAYERS SLIDE -- llama.cpp's `is_swa_impl[il]`, as a
325 /// period with a phase, the file's own per-layer array, or every
326 /// layer. See [`crate::swa_layers`]. Meaningless without
327 /// [`Self::sliding_window`]; [`Self::layer_sliding_window`] is the
328 /// one accessor that combines the two.
329 ///
330 /// Getting the phase wrong is not a near miss: on a 32-layer
331 /// period-4 model the two phases disagree about SIXTEEN layers,
332 /// each of which then attends over the wrong span at full speed.
333 /// `capability::default_swa_layout` carries the per-arch value,
334 /// transcribed from llama.cpp.
335 pub swa_layers: crate::swa_layers::SwaLayers,
336 /// WHICH LAYERS ROTATE -- llama.cpp's per-layer `use_rope`.
337 ///
338 /// [`crate::rope_layers::RopeLayers::All`] for every architecture
339 /// that writes no gate, which is 134 of llama.cpp's 140. The rule
340 /// and the table that assigns it live in [`crate::rope_layers`];
341 /// nothing else in this crate may branch on an architecture name to
342 /// decide it, and [`Self::layer_rope`] returning `None` is the only
343 /// way a call site learns of it.
344 pub rope_layers: crate::rope_layers::RopeLayers,
345 /// WHICH LAYERS HAVE WHICH SHAPE -- llama.cpp's `n_head(il)`,
346 /// `n_head_kv(il)` and `n_ff(il)`.
347 ///
348 /// `Uniform` for every architecture whose graph reads layer 0, which
349 /// is all but the rows in `layer_shapes::PER_LAYER_SHAPE_ARCHS`.
350 /// [`Self::layer_shape`] is the one accessor; the fused Metal
351 /// launches and the CUDA resident KV are fenced off any model that
352 /// is not `Uniform`, because each holds one geometry.
353 pub layer_shapes: crate::layer_shapes::LayerShapes,
354 /// Attention logit soft-capping (Gemma 2+). Applied as
355 /// `softcap * tanh(score / softcap)` before softmax.
356 pub attn_logit_softcap: Option<f32>,
357 /// Final logit soft-capping (Gemma 2+). Applied to lm_head output.
358 pub final_logit_softcap: Option<f32>,
359 /// Input embedding scale (Gemma: `sqrt(hidden_dim)`; Granite:
360 /// `{arch}.embedding_scale`).
361 pub embedding_scale: Option<f32>,
362 /// Multiplier applied to EVERY branch output -- attention and FFN
363 /// alike -- immediately before it rejoins the residual stream
364 /// (Granite `residual_multiplier`, `src/models/granite.cpp:235-238`
365 /// and `:288-292`).
366 ///
367 /// `None` means the plain `hidden += branch` every other
368 /// architecture computes. The decoder never applies this field
369 /// itself: [`crate::scalar_multipliers::residual_add`] is the one
370 /// residual add, and it takes this value as a parameter, because
371 /// `decoder.rs` spells the add out eighteen times and eighteen
372 /// hand-written copies that must agree about one scalar is the
373 /// defect shape this repo keeps paying for.
374 pub residual_scale: Option<f32>,
375 /// `Some(s)`: each sublayer's PRE-NORM OUTPUT, times `s`, REPLACES
376 /// the residual stream its branch joins, and the layer input is
377 /// discarded (`crate::normed_residual`; `minimax-01.cpp:249,428`).
378 ///
379 /// Never `Some` together with [`Self::residual_scale`] -- one
380 /// column of `MultiplierSupport` resolves both -- and `Some(1.0)`
381 /// is a real value here, because the field carries the topology as
382 /// well as the multiplier.
383 pub normed_residual_scale: Option<f32>,
384 /// Multiplier applied to the lm_head's output, after the projection
385 /// and before [`Self::final_logit_softcap`].
386 ///
387 /// Already resolved into a MULTIPLIER at load time, whichever
388 /// direction the architecture's graph states it in: Granite divides
389 /// by `{arch}.logit_scale` (`granite.cpp:180`), so this field holds
390 /// `1.0 / logit_scale`. Keeping the direction in
391 /// [`crate::scalar_multipliers`] rather than here is what lets the
392 /// decoder have exactly one multiply, and stops a second
393 /// architecture with the opposite convention from needing a second
394 /// field.
395 ///
396 /// Guaranteed positive when `Some`, and that is load-bearing rather
397 /// than incidental: a Metal decode stack may fold the lm_head and
398 /// return an argmax token id, which is only sound while every
399 /// post-head transform is monotone increasing.
400 pub logit_multiplier: Option<f32>,
401 /// Optional override for the attention score scale baked into Q
402 /// *instead of* the kernel's default `1/sqrt(head_dim)`. When set,
403 /// callers must pass `score_scale = 1.0` into the attention kernel
404 /// (llama.cpp Gemma: scale Q then `build_attn(..., 1.0f)`). Prefer
405 /// leaving this `None` when the override equals `1/sqrt(head_dim)`.
406 pub attention_scale: Option<f32>,
407 /// Symmetric clamp on the Q, K and V projections
408 /// (`{arch}.attention.clamp_kqv`), applied after the QKV bias and
409 /// before the QK-norm and RoPE -- llama.cpp's `build_qkv`
410 /// (`llama-graph.cpp:1611-1652`).
411 ///
412 /// `Some(c)` only when the architecture's graph clamps AND the file
413 /// declares a positive value; llama.cpp's own test is `> 0.0f`, so
414 /// zero and a negative value are "no clamp" and resolve to `None`
415 /// here rather than to a clamp that zeroes every projection. The
416 /// resolution lives in [`crate::clamp_kqv`]; the decoder applies it
417 /// through ONE helper shared by every host body, and the fused
418 /// Metal launches are fenced off by `Decoder::metal_can_serve_model`
419 /// because no kernel implements it.
420 pub clamp_kqv: Option<f32>,
421 /// Per-position attention temperature -- llama.cpp's
422 /// `llm_graph_input_attn_temp`, the `[n_tokens]` vector
423 /// `log(floor((pos + offset) / floor_scale) + 1) * scale + 1` that
424 /// `mistral3.cpp:153-156` multiplies into Q after RoPE, before
425 /// `build_attn`, with `kq_scale` untouched. See
426 /// [`crate::attn_temperature`] for the census (three graphs of 155)
427 /// and the resolution.
428 ///
429 /// `Some` only for an architecture whose graph builds the input AND
430 /// a file declaring a nonzero `attention.temperature_scale`; the
431 /// key on any other architecture is dead metadata upstream and is
432 /// ignored here the same way. Applied through ONE helper,
433 /// `Decoder::apply_attn_temperature`, on every host body, and
434 /// fenced off the fused Metal launches by
435 /// `Decoder::metal_can_serve_model`, because none has a per-token Q
436 /// scale uniform.
437 pub attn_temperature: Option<crate::attn_temperature::AttnTemperature>,
438 /// WHICH TENSOR THE MoE ROUTER READS -- the normed FFN input for
439 /// every graph but one, the raw layer input for `smallthinker`
440 /// (`smallthinker.cpp:111`). See [`crate::router_input`] for the
441 /// census (four graphs of 155 pass a precomputed `probs_in`, one on
442 /// the generic path) and the seam. `Decoder::router_operand` is the
443 /// ONE place the operand is captured, and the GPU router paths
444 /// refuse a model whose operand they cannot read
445 /// (`Decoder::gpu_router_matches_host_routing`).
446 pub router_input: crate::router_input::RouterInput,
447 /// Whether this model's blocks norm INSIDE the two sublayers:
448 /// BitNet's `attn_sub_norm` (on the attention output, BEFORE `wo`)
449 /// and `ffn_sub_norm` (on `silu(gate) * up`, BEFORE `down`),
450 /// `bitnet.cpp:24,36,101-106,135-140`. See [`crate::sub_norms`] for
451 /// the census (one graph of 155) and the two readers: the loader,
452 /// which REQUIRES the pair when this is set, and
453 /// `Decoder::metal_can_serve_model`, which refuses every fused
454 /// launch, since none has a norm at either site.
455 pub block_sub_norms: bool,
456 /// Whether any layer of this model is a PARALLEL residual,
457 /// `x + attn(norm(x)) + ffn(norm(x))` (`crate::parallel_residual`;
458 /// `gptneox` under its key, `plamo` always, `stablelm` per layer by
459 /// tensor presence). The per-layer fact is `MoeWeights::parallel`;
460 /// this is the model-level one `Decoder::metal_can_serve_model`
461 /// reads, because every fused Metal launch bakes the pre-FFN norm
462 /// over the post-attention residual into its kernel.
463 pub parallel_residual: bool,
464 /// Whether this model adds a learned position table to its token
465 /// embeddings (`crate::position_embd`; `gpt2`, `starcoder`). The
466 /// table itself is `Decoder::position_embd`; this is the model-level
467 /// fact `Decoder::metal_can_serve_model` reads, because the GPU
468 /// embedding gather has no add and the fused stacks never see `pos`.
469 pub learned_positions: bool,
470 /// `{arch}.attention.value_scale`: MiMo-V2 multiplies the attention
471 /// branch by it AFTER `wo` (`mimo2.cpp:180-183`; every real export
472 /// carries `0.707`). `None` for no scale; see
473 /// [`crate::attn_value_scale`] for the one reader and the values
474 /// that mean none. Applied in `Decoder::attn_out_to_residual_rows`;
475 /// the fused Metal launches refuse a model that has one.
476 pub attn_value_scale: Option<f32>,
477 /// llama.cpp's `f_max_alibi_bias` when it is positive: the model
478 /// positions by ALiBi and rotates nothing (`crate::alibi` for which
479 /// graphs and where each gets the number; `frink_core::alibi` for
480 /// the per-head slopes, which `Decoder::alibi_slopes` holds). `None`
481 /// for every other model. The fused Metal launches and the CUDA
482 /// resident attention refuse a model that has one: their kernels
483 /// add no per-key bias.
484 pub alibi_max_bias: Option<f32>,
485 /// Nanbeige's `num_loops`: `Some` when the model's logical layers
486 /// are several passes over its physical ones (`nanbeige.cpp:19-31`).
487 /// [`Self::n_layers`] is then the LOGICAL count, `Decoder::layers`
488 /// stays physical, and `Decoder::layer_for` maps one to the other.
489 /// See [`crate::layer_loops`]; the fused Metal launches refuse a
490 /// looped model.
491 pub layer_loops: Option<crate::layer_loops::LayerLoops>,
492 /// Talkie's embedding skip stream (`talkie.cpp:50-52,123-126`): the
493 /// embeddings are RMS-normed without a weight before layer 0 and
494 /// every layer adds that vector, times its own
495 /// `layer_output_scale`, after its FFN residual. See
496 /// [`crate::skip_stream`]; the fused Metal launches refuse a model
497 /// that has one.
498 pub skip_stream: bool,
499 /// Every attention layer ALSO runs a Mamba-2 block on the same
500 /// normed input, the two outputs summed (`falcon-h1.cpp:137-161`;
501 /// `crate::mamba2::PARALLEL_WITH_ATTENTION`). The layer's cache
502 /// holds the attention rows AND the block's `RecurrentState`, so
503 /// [`Self::has_recurrent_layers`] is true and the fused Metal
504 /// launches refuse the model.
505 pub parallel_ssm: bool,
506 /// The sliding layers' window is a CHUNK (`crate::chunked_swa`): a
507 /// query sees its own `sliding_window`-sized chunk and nothing
508 /// before it. [`Self::layer_window_for_query`] is the per-query
509 /// window the single-query kernels take for it; the fused Metal
510 /// launches refuse the model.
511 pub swa_chunked: bool,
512 /// A per-head RMSNorm with no weight on Q and K after RoPE, on the
513 /// layers that rotate (`crate::weightless_qk_norm`, Llama 4's
514 /// `Llama4TextL2Norm`). Applied at the post-RoPE QK-norm hook;
515 /// the fused Metal launches refuse the model.
516 pub weightless_qk_norm: bool,
517 /// RoPE base used on SWA layers (Gemma 3: defaults to `10000` when
518 /// the GGUF omits `rope.freq_base_swa`; full-attn layers keep
519 /// [`Self::rope_theta`]).
520 pub rope_theta_swa: Option<f32>,
521 /// Dense/MoE FFN activation pairing.
522 pub ffn_activation: FfnActivation,
523 /// Every field on this config that is a best-effort estimate rather
524 /// than a confirmed value from an official config.json / GGUF file.
525 pub best_effort_fields: &'static [&'static str],
526}
527
528/// One layer's RoPE, as [`ModelConfig::layer_rope`] hands it out: the
529/// three things llama.cpp's `ggml_rope_ext` call takes per layer that
530/// vary by layer.
531///
532/// A struct rather than a tuple so that a consumer names every field
533/// it takes; the Metal side destructures it exhaustively and refuses a
534/// `rot_dim` its one-uniform kernels cannot honour per layer.
535#[derive(Debug, Clone, Copy, PartialEq)]
536pub struct LayerRopeParams<'a> {
537 /// This layer's frequency base (`rope_theta`, or `rope_theta_swa`
538 /// on a sliding layer).
539 pub theta: f32,
540 /// This layer's per-band divisors, `rot_dim/2` long, or `None` to
541 /// divide by nothing.
542 pub freq_factors: Option<&'a [f32]>,
543 /// This layer's rotary width when narrower than `head_dim`; `None`
544 /// rotates the whole head. llama.cpp's `n_rot(il)`.
545 pub rot_dim: Option<usize>,
546}
547
548/// The resolved per-band RoPE divisors, for BOTH kinds of layer.
549///
550/// llama.cpp splits RoPE per layer in two places, not one:
551///
552/// ```cpp
553/// // src/llama-model.cpp:2029-2035
554/// float llama_model::get_rope_freq_base (const llama_cparams & cparams, int il) const {
555/// return hparams.is_swa(il) ? hparams.rope_freq_base_train_swa : cparams.rope_freq_base;
556/// }
557/// float llama_model::get_rope_freq_scale(const llama_cparams & cparams, int il) const {
558/// return hparams.is_swa(il) ? hparams.rope_freq_scale_train_swa : cparams.rope_freq_scale;
559/// }
560/// ```
561///
562/// and every alternating-SWA graph calls both, per layer
563/// (`gemma3.cpp:112-121`, `gemma2.cpp:79-80`, `laguna.cpp:182-183`).
564/// frink folds llama.cpp's `freq_scale` into these divisors -- linear
565/// scaling by `s` is exactly "divide every band by `s`" -- so the SCALE
566/// half of that split has to live here, beside the BASE half in
567/// [`ModelConfig::rope_theta_swa`].
568///
569/// It did not, and Gemma-3 4B/12B/27B paid for it: their headers declare
570/// `rope.scaling.type = linear, factor = 8`, `gemma3.cpp` never assigns
571/// `rope_freq_scale_train_swa` so it keeps its `1.0f` default
572/// (`src/llama-hparams.h:129`), and five layers in every six are sliding
573/// (`sliding_window_pattern = 6`, last-dense). frink rotated all of
574/// them at `p/8` where llama.cpp rotates at `p` -- fluent, and worse the
575/// longer the prompt. Invisible to the audit because the fixture is
576/// Gemma-3-1B, the one size with no `rope_scaling` at all.
577///
578/// The two fields are one struct so that answering the base question
579/// without answering the scale question does not compile.
580#[derive(Debug, Clone, PartialEq)]
581pub struct RopeFreqs {
582 /// What the FULL-ATTENTION layers divide each band's theta by.
583 pub full: Vec<f32>,
584 /// What the SLIDING layers divide by, when the architecture does not
585 /// let them inherit the model's trained RoPE scale
586 /// (`capability::swa_rope_scale_follows_model`). `None` means they
587 /// inherit [`Self::full`], which is llama.cpp's behaviour for every
588 /// architecture that assigns `rope_freq_scale_train_swa` from
589 /// `rope_freq_scale_train`.
590 ///
591 /// "No divisors at all" is spelled as an all-ones vector rather than
592 /// a third state: dividing by one is exactly not dividing, and one
593 /// fewer state is one fewer thing two call sites can disagree about.
594 pub swa: Option<Vec<f32>>,
595}
596
597impl RopeFreqs {
598 /// The divisors layer `il` uses, given whether it slides.
599 pub fn for_layer(&self, sliding: bool) -> &[f32] {
600 match (sliding, &self.swa) {
601 (true, Some(swa)) => swa,
602 _ => &self.full,
603 }
604 }
605
606 /// True when the sliding layers use a different set from the full
607 /// ones, i.e. when one `freq_factors` buffer cannot serve a whole
608 /// stack of layers.
609 pub fn varies_by_layer(&self) -> bool {
610 self.swa.as_ref().is_some_and(|swa| *swa != self.full)
611 }
612}
613
614/// Dense / expert FFN non-linearity used by the generic decoder.
615///
616/// Not `Copy` and not `Eq` since [`FfnActivation::Xielu`]: that
617/// variant CARRIES its per-layer parameters, so the kind and the
618/// parameters cannot disagree, and a `ModelConfig` clone shares them
619/// through an `Arc`. [`ModelConfig::layer_ffn_act`] is how a layer
620/// body turns this into the `GluAct` it runs.
621#[derive(Debug, Clone, PartialEq, Default)]
622pub enum FfnActivation {
623 /// `silu(gate) * up` with separate gate/up matrices (Llama / Qwen).
624 #[default]
625 Swiglu,
626 /// Phi-3 fused gate+up in one `ffn_up` matrix (`2 * n_ff` rows).
627 SwigluFused,
628 /// Gemma GeGLU: `gelu(gate) * up`.
629 Gelu,
630 /// UNGATED ReLU-squared: `down(relu(up(x))^2)`, two matrices in
631 /// sequence and no `ffn_gate` at all -- llama.cpp's
632 /// `LLM_FFN_RELU_SQR` under `LLM_FFN_SEQ` with a null gate
633 /// (`arcee.cpp:39-40,123-128`; also `plm`, `nemotron`, `jais2`,
634 /// `nemotron-h`, each of which needs more than this).
635 ///
636 /// The loader ALIASES the expert's `gate` to its `up` matrix (a
637 /// zero-copy view of the same bytes) and this maps to
638 /// `frink_moe::GluAct::ReluSqr`, which reads `up` alone. That is
639 /// what lets every gated path serve it unchanged; the dense hot
640 /// paths skip the aliased matmul through `GluAct::ungated`, and no
641 /// fused device kernel spells it, so `fused_kernel_gelu_flag` is
642 /// `None`.
643 ReluSqr,
644 /// UNGATED GELU: `down(gelu(up(x)))`, two matrices in sequence and
645 /// no `ffn_gate` -- llama.cpp's `LLM_FFN_GELU` under `LLM_FFN_SEQ`
646 /// with a null gate (`starcoder2.cpp:125-131`, `codeshell.cpp:
647 /// 120-126`; eleven graphs of 155 pass the pair, measured,
648 /// `capability::uses_gelu_ungated`). Aliased and served exactly as
649 /// [`Self::ReluSqr`], mapping to `frink_moe::GluAct::GeluUngated`;
650 /// no fused device kernel spells it. `ggml_gelu` is the tanh form
651 /// with an f16 table on the CPU, so its goldens hold at the GeGLU
652 /// tolerance.
653 GeluUngated,
654 /// GATED ReLU, `down(relu(gate(x)) * up(x))` with a REAL gate
655 /// matrix -- llama.cpp's `LLM_FFN_RELU` under `build_moe_ffn` with
656 /// `gate_exps` present, which takes `ggml_reglu_split(gate, up)`
657 /// (`llama-graph.cpp:2195-2197`; `smallthinker.cpp:158`, the only
658 /// graph of 155 that passes it there -- `capability::uses_reglu`).
659 ///
660 /// `frink_moe::GluAct::Reglu`, on a pair the loader did NOT alias.
661 /// Two variants rather than [`Self::ReluSqr`] with a flag, because
662 /// `ffn_is_ungated` (the loader's aliasing decision) and
663 /// `layer_ffn_acts` (the body) must agree about which of the two a
664 /// file is, and a variant is the one spelling both read. The
665 /// `GluAct` side is two variants for the same reason: it used to
666 /// be one, `ungated()` answered `relu(up)^2` for it, and the dense
667 /// hot path skipped a gate that was real (the SmallThinker fixture
668 /// found it; `frink_moe::GluAct` says how). No fused device kernel
669 /// spells it, so `fused_kernel_gelu_flag` is `None` and every Metal
670 /// launch refuses.
671 Reglu,
672 /// UNGATED xIELU with PER-LAYER parameters: `down(xielu_il(up(x)))`
673 /// -- llama.cpp's `ggml_xielu(up, alpha_n[il], alpha_p[il],
674 /// beta[il], eps[il])` (`apertus.cpp:132-138`), the four read as
675 /// `n_layer`-long arrays or broadcast scalars (`:6-9`).
676 ///
677 /// The table IS the variant, so there is no second field for it to
678 /// disagree with. `crate::act_layers` reads it and hands layer
679 /// `il`'s set out through [`ModelConfig::layer_ffn_act`]; the
680 /// loader aliases gate to up exactly as for [`Self::ReluSqr`], and
681 /// `frink_moe::GluAct::Xielu` reads the `up` operand alone. No
682 /// fused device kernel spells it, so every Metal launch refuses it.
683 Xielu(crate::act_layers::XieluLayers),
684 /// SwiGLU with a PER-LAYER, PER-SITE clamp: llama.cpp's
685 /// `swiglu_clamp_exp[il]` on the routed experts and
686 /// `swiglu_clamp_shexp[il]` on the dense layers and shared experts
687 /// (`step35.cpp:28-29`; applied at `llama-graph.cpp:2146-2164` and
688 /// `:1751-1768` as `min(silu(gate), l) * clamp(up, -l, l)`). A zero
689 /// entry is plain SwiGLU on that site; `frink_moe::GluAct::
690 /// SwigluClamped` is the body. No fused device kernel spells it.
691 SwigluClamped(crate::act_layers::SwigluClamps),
692}
693
694/// [`ModelConfig::batch_window`]'s answer.
695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum BatchWindow {
697 /// Every query in the batch takes this window (`None`: full causal).
698 Uniform(Option<usize>),
699 /// Each query takes [`ModelConfig::layer_window_for_query`] at its
700 /// own position.
701 PerQuery,
702}
703
704impl ModelConfig {
705 /// The V head width: the width of every V head, of each head's
706 /// attention output, and so of `o_proj`'s input (`n_heads *
707 /// v_head_dim()`). [`Self::head_dim`] unless the file declared
708 /// `attention.value_length` apart from `attention.key_length` on an
709 /// architecture that sizes them apart (`crate::kv_head_dims`).
710 #[inline]
711 pub fn v_head_dim(&self) -> usize {
712 self.v_head_dim.unwrap_or(self.head_dim)
713 }
714
715 /// Whether V heads are a different width from K heads. The fact
716 /// every fused path refuses on.
717 #[inline]
718 pub fn kv_head_dims_split(&self) -> bool {
719 self.v_head_dim() != self.head_dim
720 }
721
722 /// Re-picks the LongRoPE factor set now that the run's context size
723 /// is known, matching llama.cpp `llama_model::get_rope_factors`:
724 /// `rope_freqs.weight` (Llama 3) always wins; otherwise the long set
725 /// applies only when the context exceeds
726 /// `rope.scaling.original_context_length`, and the short set
727 /// otherwise.
728 ///
729 /// A no-op for every checkpoint that ships neither set, which is all
730 /// of them except the Phi-3/Phi-4 family today.
731 ///
732 /// Because it re-picks `rope_freqs` wholesale it would also discard
733 /// a YaRN rewrite folded into that field at parse time (see
734 /// [`Self::rope_freqs`]). No real checkpoint hits that: LongRoPE
735 /// files declare `rope.scaling.type = "longrope"`, which the loader's
736 /// YaRN arm deliberately does not claim, so the two never populate
737 /// the field on the same file. The same caveat now covers
738 /// [`RopeFreqs::swa`], and for the same reason: no LongRoPE
739 /// checkpoint has alternating SWA layers.
740 pub fn apply_runtime_context(&mut self, ctx: usize) {
741 let (Some(orig), true) = (
742 self.rope_orig_ctx,
743 self.rope_freqs_long.is_some() || self.rope_freqs_short.is_some(),
744 ) else {
745 return;
746 };
747 let picked = if ctx > orig {
748 self.rope_freqs_long.as_ref()
749 } else {
750 self.rope_freqs_short.as_ref()
751 };
752 if let Some(f) = picked
753 .or(self.rope_freqs_long.as_ref())
754 .or(self.rope_freqs_short.as_ref())
755 {
756 self.rope_freqs = Some(RopeFreqs {
757 full: f.clone(),
758 swa: None,
759 });
760 }
761 }
762
763 /// True if layer `layer_idx` (0-indexed) should be built as an
764 /// ordinary dense FFN rather than this model's MoE topology: the
765 /// leading-dense prefix, and, where the loader honours the
766 /// interleave step (`crate::moe_interleave`, `llama4.cpp:64`), a
767 /// layer with `(il + 1) % step != 0`.
768 pub fn layer_is_dense(&self, layer_idx: usize) -> bool {
769 layer_idx < self.n_dense_leading_layers
770 || self
771 .moe_interleave_step
772 .is_some_and(|step| !(layer_idx + 1).is_multiple_of(step))
773 }
774
775 /// Sliding-window size for layer `il`, honouring Gemma-style
776 /// alternating SWA patterns. `None` means full causal attention.
777 pub fn layer_sliding_window(&self, layer_idx: usize) -> Option<usize> {
778 // llama.cpp's `is_swa(il)`, which `set_swa_pattern`
779 // (`src/llama-hparams.cpp:8-22`) or the file's own array fills
780 // in; `crate::swa_layers` is the one implementation of both.
781 let window = self.sliding_window?;
782 self.swa_layers.slides(layer_idx).then_some(window)
783 }
784
785 /// The window the single-query kernels take for a query at `pos`
786 /// on layer `il`: the layer's sliding window, or, when the window
787 /// is chunked (`crate::chunked_swa`), the `pos % chunk + 1`
788 /// positions of the query's own chunk. `None` for a full layer.
789 pub fn layer_window_for_query(&self, il: usize, pos: usize) -> Option<usize> {
790 let w = self.layer_sliding_window(il)?;
791 Some(if self.swa_chunked { pos % w + 1 } else { w })
792 }
793
794 /// The window a batch of `batch_size` queries starting at
795 /// `start_pos` takes on layer `il`, for the batched prefill body:
796 /// one window for the blocked kernel, or one per query where a
797 /// chunked layer's queries do not share a chunk start.
798 pub fn batch_window(&self, il: usize, start_pos: usize, batch_size: usize) -> BatchWindow {
799 match self.layer_sliding_window(il) {
800 None => BatchWindow::Uniform(None),
801 Some(w) if !self.swa_chunked => BatchWindow::Uniform(Some(w)),
802 // Every query in the first chunk sees its whole causal
803 // prefix: `pos / chunk == 0` for all of them
804 // (`llama-hparams.h:419-425`), which is the full mask.
805 Some(chunk) if start_pos + batch_size <= chunk => BatchWindow::Uniform(None),
806 Some(_) => BatchWindow::PerQuery,
807 }
808 }
809
810 /// The narrowest sliding window any layer of this model uses, or
811 /// `None` if every layer is full-causal.
812 ///
813 /// For an alternating-SWA model (gpt-oss, Gemma-3) the
814 /// full-attention layers impose no constraint on the KV block
815 /// layout and the sliding ones impose the window -- so the model's
816 /// constraint is simply the window, present as soon as *any* layer
817 /// slides. A model that is 5/6 full-attention is not 5/6 exempt:
818 /// one mis-aligned sliding layer corrupts the answer.
819 pub fn kv_block_window(&self) -> Option<usize> {
820 (0..self.n_layers).find_map(|il| self.layer_sliding_window(il))
821 }
822
823 /// The window EVERY layer slides by, or `None` if any layer attends
824 /// over the whole history.
825 ///
826 /// This is the opposite question to [`Self::kv_block_window`], and
827 /// the difference is the whole reason both exist. That one asks
828 /// "does any layer constrain the block layout", so one sliding layer
829 /// is enough. This one asks "may a page that has fallen behind the
830 /// window be taken away", and there one *full-attention* layer is
831 /// enough to say no.
832 ///
833 /// A page group holds one block in every layer and is freed as a
834 /// unit, so on an alternating-SWA model (gpt-oss, Gemma-3) freeing
835 /// the group behind the window would take the full-attention layers'
836 /// block with it -- and those layers still read position 0 at every
837 /// step. The result is not a crash: the block is reused by another
838 /// request and the full layers attend over its bytes. So this
839 /// returns `None` for the alternating case, and a mixed-window model
840 /// (were one to appear) gets `None` too rather than the narrowest
841 /// window, because the widest is the one that must still be readable.
842 pub fn uniform_sliding_window(&self) -> Option<usize> {
843 let first = self.layer_sliding_window(0)?;
844 (1..self.n_layers)
845 .all(|il| self.layer_sliding_window(il) == Some(first))
846 .then_some(first)
847 }
848
849 /// The KV cache block layout to use for this model, given the block
850 /// size an operator asked for.
851 ///
852 /// The requested size is rounded *down* to something that divides
853 /// the window (see [`frink_core::kv_swa`]), so a config that would
854 /// straddle the window boundary becomes a smaller block rather than
855 /// a startup failure or -- much worse -- a silently wrong mask.
856 pub fn kv_block_layout(&self, desired_block_size: usize) -> frink_core::BlockLayout {
857 let window = self.kv_block_window();
858 let block_size = frink_core::aligned_block_size(desired_block_size, window);
859 frink_core::BlockLayout::new(block_size, window)
860 .expect("aligned_block_size returns a size BlockLayout accepts")
861 }
862
863 /// ALL THREE halves of layer `il`'s RoPE: the frequency base, the
864 /// per-band divisors, which llama.cpp varies per layer together
865 /// (`llama-model.cpp:2029-2035`, and see [`RopeFreqs`]), and the
866 /// rotary WIDTH, which it varies by the same sliding-or-full fact
867 /// (`n_rot(il)`, `llama-hparams.cpp:85-91`; [`Self::rope_dim_swa`]).
868 ///
869 /// Every RoPE call site takes the pair from here. Splitting them was
870 /// the defect: `layer_rope_theta` varied the base per layer while
871 /// `rope_freqs` was one global vector, so Gemma-3 4B/12B/27B roped
872 /// their sliding layers at scaled positions llama.cpp leaves
873 /// unscaled.
874 ///
875 /// **`None` means this layer does not rotate at all**, which is
876 /// llama.cpp's per-layer `use_rope` gate --
877 /// [`crate::rope_layers`] holds the rule and the six architectures
878 /// that have one. It is an `Option` rather than a separate
879 /// predicate beside the pair precisely so that a call site cannot
880 /// take the base and the divisors without also answering "does this
881 /// layer rotate": that is the third thing the three had to agree
882 /// about, and two of them were already one value for this reason.
883 /// The epsilon the post-attention / post-FFN norms run at. One
884 /// accessor so a site cannot read the model's epsilon by habit.
885 pub fn post_norm_eps(&self) -> f32 {
886 self.post_norm_eps
887 }
888
889 pub fn layer_rope(&self, layer_idx: usize) -> Option<LayerRopeParams<'_>> {
890 let sliding = self.layer_sliding_window(layer_idx).is_some();
891 if !self.rope_layers.rotates(layer_idx, sliding) {
892 return None;
893 }
894 let theta = match (sliding, self.rope_theta_swa) {
895 (true, Some(theta)) => theta,
896 _ => self.rope_theta,
897 };
898 let rot_dim = match (sliding, self.rope_dim_swa) {
899 (true, Some(w)) => Some(w),
900 _ => self.rope_dim,
901 }
902 // The whole head is spelled `None`, whichever key said so, so
903 // nothing downstream special-cases "narrower by zero".
904 .filter(|w| *w < self.head_dim);
905 Some(LayerRopeParams {
906 theta,
907 freq_factors: self.rope_freqs.as_ref().map(|f| f.for_layer(sliding)),
908 rot_dim,
909 })
910 }
911
912 /// True when the sliding layers rotate a different width from the
913 /// full ones -- the whole-model fact the fused Metal launches refuse
914 /// on, since each takes ONE `rot_dim` uniform for every layer.
915 ///
916 /// Derived from [`Self::layer_rope`] rather than from the field, so
917 /// a `rope_dim_swa` that merely restates `rope_dim` (or the whole
918 /// head) is not a difference.
919 pub fn rope_dim_varies_by_layer(&self) -> bool {
920 let widths: Vec<Option<usize>> = (0..self.n_layers)
921 .filter_map(|il| self.layer_rope(il).map(|r| r.rot_dim))
922 .collect();
923 widths.windows(2).any(|w| w[0] != w[1])
924 }
925
926 /// Does layer `il` rotate at all? Derived from [`Self::layer_rope`]
927 /// rather than restated beside it, so the two can never disagree.
928 pub fn layer_rotates(&self, layer_idx: usize) -> bool {
929 self.layer_rope(layer_idx).is_some()
930 }
931
932 /// True when at least one layer of this model gets no rotation --
933 /// the whole-model question, for the eligibility checks and the
934 /// receipts that want it once rather than per layer.
935 pub fn any_layer_unrotated(&self) -> bool {
936 (0..self.n_layers).any(|il| !self.layer_rotates(il))
937 }
938
939 /// RoPE frequency base for layer `il` (SWA layers may differ), or
940 /// `None` where the layer does not rotate.
941 ///
942 /// Prefer [`Self::layer_rope`] anywhere the divisors are needed too,
943 /// which is every site that actually rotates something. This one is
944 /// for the callers that only report or compare the base.
945 pub fn layer_rope_theta(&self, layer_idx: usize) -> Option<f32> {
946 self.layer_rope(layer_idx).map(|r| r.theta)
947 }
948
949 /// True when the sliding layers need different per-band divisors
950 /// from the full-attention ones, i.e. when one `freq_factors` slice
951 /// cannot describe every layer of this model. Gemma-3 4B/12B/27B
952 /// are the shape that answers yes.
953 ///
954 /// It is NOT an eligibility check any more. It was one: the fused
955 /// Metal stacks took a single slice for a whole run of layers and
956 /// refused a model that answered yes here. They now take a
957 /// `frink_metal::attn::LayerRope` per layer, so this is a statement
958 /// about the checkpoint and nothing else -- which is all the loader
959 /// tests ever wanted from it.
960 pub fn rope_freqs_vary_by_layer(&self) -> bool {
961 self.rope_freqs
962 .as_ref()
963 .is_some_and(RopeFreqs::varies_by_layer)
964 // A model whose every layer slides, or none, uses one set
965 // whatever the two vectors hold.
966 && (0..self.n_layers).any(|il| self.layer_sliding_window(il).is_some())
967 && (0..self.n_layers).any(|il| self.layer_sliding_window(il).is_none())
968 }
969
970 /// Which attention mechanism layer `layer_idx` (0-indexed, frink's
971 /// usual convention) uses. For `AttentionKind::Gqa` every layer is
972 /// `LayerAttentionKind::Gqa`; for `AttentionKind::KimiHybrid`, looks
973 /// up `layer_idx + 1` (the real `kda_layers`/`full_attn_layers`
974 /// lists are 1-indexed -- see `KimiHybridAttention`'s doc comment)
975 /// in those real per-layer lists.
976 ///
977 /// # Panics
978 /// If `layer_idx` isn't covered by either list of a `KimiHybrid`
979 /// config -- can't happen for `kimi_k3()`, whose lists are tested
980 /// (`kimi_k3_hybrid_attention_layers_partition_every_layer_exactly_once`)
981 /// to partition every layer with no gaps, but a caller building a
982 /// custom `KimiHybridAttention` must uphold the same invariant.
983 pub fn layer_attention_kind(&self, layer_idx: usize) -> LayerAttentionKind {
984 match &self.attention {
985 AttentionKind::Gqa => LayerAttentionKind::Gqa,
986 AttentionKind::KimiHybrid(hybrid) => {
987 let one_indexed = layer_idx + 1;
988 if hybrid.kda_layers.contains(&one_indexed) {
989 LayerAttentionKind::KimiKda
990 } else if hybrid.full_attn_layers.contains(&one_indexed) {
991 LayerAttentionKind::KimiMla
992 } else {
993 panic!(
994 "layer {layer_idx} (1-indexed {one_indexed}) is in neither \
995 kda_layers nor full_attn_layers"
996 )
997 }
998 }
999 }
1000 }
1001
1002 /// Total parameter count implied by the MoE config, as a sanity
1003 /// check against the publicly reported total (this is an order of
1004 /// magnitude check, not an exact parameter-count reproduction).
1005 pub fn approx_active_params_per_token(&self) -> usize {
1006 let attn_params_per_layer = 4 * self.hidden_dim * self.hidden_dim; // q,k,v,o (rough)
1007 let active_experts = self.moe.n_experts_active + self.moe.n_shared_experts;
1008 let expert_params = active_experts * 3 * self.moe.hidden_dim * self.moe.expert_ffn_dim; // gate,up,down
1009 self.n_layers * (attn_params_per_layer + expert_params)
1010 }
1011}
1012
1013/// GLM-5.2 (Z.ai) **structural sketch only** — not a supported real
1014/// inference path. Real DSA lives in `glm_dsa` / `glm52_decoder` and is
1015/// not wired into `Decoder` / `frink-server`. This preset drives
1016/// smoke/bench with synthetic GQA weights only (~744B / ~40B active
1017/// hparams as published placeholders).
1018pub fn glm_5_2() -> ModelConfig {
1019 ModelConfig {
1020 sliding_window: None,
1021 name: "glm-5.2",
1022 attention: AttentionKind::Gqa,
1023 n_layers: 92,
1024 n_mtp_blocks: 0,
1025 hidden_dim: 6144,
1026 n_heads: 48,
1027 n_kv_heads: 8,
1028 head_dim: 128,
1029 v_head_dim: None,
1030 vocab_size: 151552,
1031 rope_theta: 1_000_000.0,
1032 rms_norm_eps: 1e-5,
1033 post_norm_eps: 1e-5,
1034 moe: MoeLayerConfig {
1035 expert_weights_scale: 1.0,
1036 routed_weight_before_ffn: false,
1037 n_experts: 256,
1038 n_experts_active: 8,
1039 n_shared_experts: 1,
1040 hidden_dim: 6144,
1041 expert_ffn_dim: 2048,
1042 // Sigmoid, not softmax: reading ik_llama.cpp's real GGUF
1043 // hparams-loading source (llama-hparams.cpp,
1044 // LLM_ARCH_GLM4_MOE case) directly showed GLM4-MoE-family
1045 // models default to sigmoid gating with post-selection
1046 // score renormalization. GLM-5.2 is presumed to continue
1047 // this lineage; not confirmed against GLM-5.2's own
1048 // config.json (unavailable in this environment).
1049 gating: GatingFunction::Sigmoid,
1050 norm_topk_prob: true,
1051 expert_group_count: None, expert_group_used_count: None,},
1052 // No evidence found (via ik_llama.cpp source or public
1053 // reporting) that GLM-5.2 skips MoE on any leading layers;
1054 // defaulting to 0 (every layer uses this model's MoE
1055 // topology) rather than assuming DeepSeek's convention
1056 // applies here too.
1057 n_dense_leading_layers: 0,
1058 moe_interleave_step: None,
1059 norm_function: crate::norm::NormFunction::Rms,
1060 rope_freqs: None,
1061 rope_attn_factor: 1.0,
1062 rope_dim: None,
1063 rope_dim_swa: None,
1064 rope_freqs_long: None,
1065 rope_freqs_short: None,
1066 rope_orig_ctx: None,
1067 // Placeholder GQA path; real GLM-5.2 DSA uses interleaved RoPE
1068 // via `glm_dsa`/`mla`, not this preset's Decoder path.
1069 rope_layout: RopeLayout::Neox,
1070 qk_norm_style: crate::capability::QkNormStyle::WholeVector,
1071 swa_layers: crate::swa_layers::SwaLayers::All,
1072 rope_layers: crate::rope_layers::RopeLayers::All,
1073 layer_shapes: crate::layer_shapes::LayerShapes::Uniform,
1074 attn_logit_softcap: None,
1075 final_logit_softcap: None,
1076 embedding_scale: None,
1077 residual_scale: None,
1078 normed_residual_scale: None,
1079 clamp_kqv: None,
1080 attn_temperature: None,
1081 router_input: crate::router_input::RouterInput::NormedFfnInput,
1082 block_sub_norms: false,
1083 parallel_residual: false,
1084 learned_positions: false,
1085 attn_value_scale: None,
1086 alibi_max_bias: None,
1087 layer_loops: None,
1088 skip_stream: false,
1089 parallel_ssm: false,
1090 swa_chunked: false,
1091 weightless_qk_norm: false,
1092 logit_multiplier: None,
1093 attention_scale: None,
1094 rope_theta_swa: None,
1095 ffn_activation: FfnActivation::Swiglu,
1096 best_effort_fields: &[
1097 "n_layers",
1098 "hidden_dim",
1099 "n_heads",
1100 "n_kv_heads",
1101 "head_dim",
1102 "rope_theta",
1103 "moe.expert_ffn_dim",
1104 "moe.n_shared_experts",
1105 "moe.gating (sigmoid assumed from GLM4-MoE-family convention found in ik_llama.cpp source, not confirmed for GLM-5.2 specifically)",
1106 ],
1107 }
1108}
1109
1110/// DeepSeek V4 Pro **structural sketch only** — CSA/HCA is not on this
1111/// GQA `Decoder` path. Real primitives live under
1112/// `deepseek_v4_attention` / `hyper_connections` and are not assembled
1113/// into a served decoder yet. Hparams (~1.6T / ~49B active) are
1114/// placeholders for smoke/bench.
1115pub fn deepseek_v4_pro() -> ModelConfig {
1116 ModelConfig {
1117 sliding_window: None,
1118 name: "deepseek-v4-pro",
1119 attention: AttentionKind::Gqa,
1120 n_layers: 96,
1121 n_mtp_blocks: 0,
1122 hidden_dim: 7168,
1123 n_heads: 56,
1124 n_kv_heads: 8,
1125 head_dim: 128,
1126 v_head_dim: None,
1127 vocab_size: 129280,
1128 rope_theta: 1_000_000.0,
1129 rms_norm_eps: 1e-6,
1130 post_norm_eps: 1e-6,
1131 moe: MoeLayerConfig {
1132 expert_weights_scale: 1.0,
1133 routed_weight_before_ffn: false,
1134 n_experts: 385,
1135 n_experts_active: 6,
1136 n_shared_experts: 1,
1137 hidden_dim: 7168,
1138 expert_ffn_dim: 2048,
1139 // Sigmoid, not softmax: this is the stronger-confidence of
1140 // the two sigmoid-gating corrections in this file.
1141 // DeepSeek-V3's own published technical report explicitly
1142 // documents computing per-expert affinity via sigmoid and
1143 // renormalizing only the selected experts' scores to sum
1144 // to one; reading ik_llama.cpp's real GGUF hparams-loading
1145 // source (llama-hparams.cpp, LLM_ARCH_DEEPSEEK2 case)
1146 // confirmed this is exactly what that code path defaults
1147 // to for the DeepSeek-2/3 lineage. DeepSeek V4 Pro is
1148 // presumed to continue using sigmoid gating for the same
1149 // reason; not confirmed against V4 Pro's own config.json.
1150 gating: GatingFunction::Sigmoid,
1151 norm_topk_prob: true,
1152 expert_group_count: None, expert_group_used_count: None,},
1153 // DeepSeek-V3's own published technical report documents the
1154 // first 3 transformer layers as dense (ordinary FFN, no
1155 // expert routing), with MoE starting from layer 4 onward;
1156 // ik_llama.cpp's real hparams-loading source
1157 // (LLM_KV_LEADING_DENSE_BLOCK_COUNT) confirms this is a real,
1158 // loaded GGUF metadata field for the DeepSeek-2/3 lineage.
1159 // DeepSeek V4 Pro is presumed to continue this convention;
1160 // not confirmed against V4 Pro's own config.json.
1161 n_dense_leading_layers: 3,
1162 moe_interleave_step: None,
1163 norm_function: crate::norm::NormFunction::Rms,
1164 rope_freqs: None,
1165 rope_attn_factor: 1.0,
1166 rope_dim: None,
1167 rope_dim_swa: None,
1168 rope_freqs_long: None,
1169 rope_freqs_short: None,
1170 rope_orig_ctx: None,
1171 // llama.cpp maps LLM_ARCH_DEEPSEEK4 -> LLAMA_ROPE_TYPE_NORM.
1172 rope_layout: RopeLayout::Norm,
1173 qk_norm_style: crate::capability::QkNormStyle::WholeVector,
1174 swa_layers: crate::swa_layers::SwaLayers::All,
1175 rope_layers: crate::rope_layers::RopeLayers::All,
1176 layer_shapes: crate::layer_shapes::LayerShapes::Uniform,
1177 attn_logit_softcap: None,
1178 final_logit_softcap: None,
1179 embedding_scale: None,
1180 residual_scale: None,
1181 normed_residual_scale: None,
1182 clamp_kqv: None,
1183 attn_temperature: None,
1184 router_input: crate::router_input::RouterInput::NormedFfnInput,
1185 block_sub_norms: false,
1186 parallel_residual: false,
1187 learned_positions: false,
1188 attn_value_scale: None,
1189 alibi_max_bias: None,
1190 layer_loops: None,
1191 skip_stream: false,
1192 parallel_ssm: false,
1193 swa_chunked: false,
1194 weightless_qk_norm: false,
1195 logit_multiplier: None,
1196 attention_scale: None,
1197 rope_theta_swa: None,
1198 ffn_activation: FfnActivation::Swiglu,
1199 best_effort_fields: &[
1200 "n_layers",
1201 "hidden_dim",
1202 "n_heads",
1203 "n_kv_heads",
1204 "head_dim",
1205 "moe.expert_ffn_dim",
1206 "attention_variant (CSA/HCA hybrid NOT implemented, GQA fallback in use)",
1207 "moe.gating (sqrtsoftplus: confirmed for real V4 in llama.cpp PR #24162; this preset still uses Sigmoid on the wrong GQA sketch path)",
1208 "n_dense_leading_layers (3: same confidence basis as gating above, DeepSeek-V3 technical report + ik_llama.cpp source, not confirmed for V4 Pro)",
1209 ],
1210 }
1211}
1212
1213/// Kimi K3 **structural sketch only** for the generic GQA `Decoder`.
1214/// Real checkpoint work uses the dedicated Kimi stack (`kimi_loader` /
1215/// `KimiEngine`); slice-verified, not a full end-to-end run. Do not
1216/// treat this preset as a runnable Kimi substitute.
1217pub fn kimi_k3() -> ModelConfig {
1218 ModelConfig {
1219 sliding_window: None,
1220 name: "kimi-k3",
1221 n_layers: 93,
1222 n_mtp_blocks: 0,
1223 hidden_dim: 7168,
1224 // n_heads/n_kv_heads/head_dim describe the Gqa fallback
1225 // Decoder actually runs today, not Kimi K3's real attention
1226 // (see `attention` below) -- kept at reasonable stand-in
1227 // values (matching MLA's num_heads=96 and combined
1228 // qk_nope+qk_rope head dim) rather than deleted, so the
1229 // placeholder path stays runnable.
1230 n_heads: 96,
1231 n_kv_heads: 96,
1232 head_dim: 192,
1233 v_head_dim: None,
1234 vocab_size: 163840,
1235 // Not present in the published text_config; RoPE only ever
1236 // applies to Gated MLA's 64-dim qk_rope_head_dim slice in the
1237 // real architecture, and Decoder doesn't implement that slicing
1238 // yet, so this remains an unconfirmed placeholder.
1239 rope_theta: 1_000_000.0,
1240 rms_norm_eps: 1e-5,
1241 post_norm_eps: 1e-5,
1242 moe: MoeLayerConfig {
1243 expert_weights_scale: 1.0,
1244 routed_weight_before_ffn: false,
1245 n_experts: 896,
1246 n_experts_active: 16,
1247 n_shared_experts: 2,
1248 hidden_dim: 7168,
1249 expert_ffn_dim: 3072,
1250 // Confirmed directly from the real config.json:
1251 // "moe_router_activation_func": "sigmoid".
1252 gating: GatingFunction::Sigmoid,
1253 norm_topk_prob: true,
1254 expert_group_count: None, expert_group_used_count: None,},
1255 // Confirmed directly from the real config.json:
1256 // "first_k_dense_replace": 1.
1257 n_dense_leading_layers: 1,
1258 moe_interleave_step: None,
1259 norm_function: crate::norm::NormFunction::Rms,
1260 // Kimi K3's real, published attention topology (verified
1261 // against huggingface.co/moonshotai/Kimi-K3/config.json's
1262 // linear_attn_config block and the real KimiDeltaAttention /
1263 // KimiMLAAttention reference implementations in
1264 // modeling_kimi_linear.py) -- not yet wired into Decoder's
1265 // forward pass, which still runs the Gqa placeholder above for
1266 // every layer regardless of this field.
1267 attention: AttentionKind::KimiHybrid(KimiHybridAttention {
1268 kda_layers: vec![
1269 1, 2, 3, 5, 6, 7, 9, 10, 11, 13, 14, 15, 17, 18, 19, 21, 22, 23, 25, 26, 27, 29,
1270 30, 31, 33, 34, 35, 37, 38, 39, 41, 42, 43, 45, 46, 47, 49, 50, 51, 53, 54, 55,
1271 57, 58, 59, 61, 62, 63, 65, 66, 67, 69, 70, 71, 73, 74, 75, 77, 78, 79, 81, 82,
1272 83, 85, 86, 87, 89, 90, 91,
1273 ],
1274 full_attn_layers: vec![
1275 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84,
1276 88, 92, 93,
1277 ],
1278 mla: MlaConfig {
1279 num_heads: 96,
1280 q_lora_rank: 1536,
1281 kv_lora_rank: 512,
1282 qk_nope_head_dim: 128,
1283 qk_rope_head_dim: 64,
1284 v_head_dim: 128,
1285 use_output_gate: true,
1286 // Real, confirmed: Kimi K3's `KimiMLAAttention.forward`
1287 // never rotates -- see `MlaConfig::rope`'s doc comment.
1288 rope: None,
1289 },
1290 kda: KdaConfig {
1291 num_heads: 96,
1292 head_dim: 128,
1293 short_conv_kernel_size: 4,
1294 gate_lower_bound: -5.0,
1295 use_full_rank_gate: true,
1296 },
1297 }),
1298 rope_freqs: None,
1299 rope_attn_factor: 1.0,
1300 rope_dim: None,
1301 rope_dim_swa: None,
1302 rope_freqs_long: None,
1303 rope_freqs_short: None,
1304 rope_orig_ctx: None,
1305 // GQA placeholder path only; real Kimi attention is rope-less MLA
1306 // or KDA and never reaches Decoder::apply_rope_head.
1307 rope_layout: RopeLayout::Neox,
1308 qk_norm_style: crate::capability::QkNormStyle::WholeVector,
1309 swa_layers: crate::swa_layers::SwaLayers::All,
1310 rope_layers: crate::rope_layers::RopeLayers::All,
1311 layer_shapes: crate::layer_shapes::LayerShapes::Uniform,
1312 attn_logit_softcap: None,
1313 final_logit_softcap: None,
1314 embedding_scale: None,
1315 residual_scale: None,
1316 normed_residual_scale: None,
1317 clamp_kqv: None,
1318 attn_temperature: None,
1319 router_input: crate::router_input::RouterInput::NormedFfnInput,
1320 block_sub_norms: false,
1321 parallel_residual: false,
1322 learned_positions: false,
1323 attn_value_scale: None,
1324 alibi_max_bias: None,
1325 layer_loops: None,
1326 skip_stream: false,
1327 parallel_ssm: false,
1328 swa_chunked: false,
1329 weightless_qk_norm: false,
1330 logit_multiplier: None,
1331 attention_scale: None,
1332 rope_theta_swa: None,
1333 ffn_activation: FfnActivation::Swiglu,
1334 best_effort_fields: &[
1335 "n_heads/n_kv_heads/head_dim (describe the unimplemented Gqa placeholder, not Kimi K3's real MLA/KDA attention -- see `attention` field)",
1336 "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)",
1337 "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)",
1338 ],
1339 }
1340}
1341
1342/// Matches the generated on-disk fixture exactly (hidden_dim, head
1343/// counts, ffn_dim, vocab, rope_theta, eps).
1344/// Used by `frink inspect-run` and the cross-validation test in
1345/// `crates/frink-models/tests/gguf_roundtrip.rs` to prove the real
1346/// GGUF loader + forward pass produce the same numbers as an
1347/// independent NumPy reference implementation reading the same file.
1348pub fn test_dense_fixture() -> ModelConfig {
1349 ModelConfig {
1350 sliding_window: None,
1351 name: "frink-test-dense",
1352 attention: AttentionKind::Gqa,
1353 n_layers: 2,
1354 n_mtp_blocks: 0,
1355 hidden_dim: 32,
1356 n_heads: 4,
1357 n_kv_heads: 2,
1358 head_dim: 8,
1359 v_head_dim: None,
1360 vocab_size: 32,
1361 rope_theta: 10000.0,
1362 rms_norm_eps: 1e-5,
1363 post_norm_eps: 1e-5,
1364 moe: MoeLayerConfig {
1365 expert_weights_scale: 1.0,
1366 routed_weight_before_ffn: false,
1367 n_experts: 1,
1368 n_experts_active: 1,
1369 n_shared_experts: 0,
1370 hidden_dim: 32,
1371 expert_ffn_dim: 32,
1372 gating: GatingFunction::Softmax,
1373 norm_topk_prob: true,
1374 expert_group_count: None,
1375 expert_group_used_count: None,
1376 },
1377 n_dense_leading_layers: 0,
1378 moe_interleave_step: None,
1379 norm_function: crate::norm::NormFunction::Rms,
1380 rope_freqs: None,
1381 rope_attn_factor: 1.0,
1382 rope_dim: None,
1383 rope_dim_swa: None,
1384 rope_freqs_long: None,
1385 rope_freqs_short: None,
1386 rope_orig_ctx: None,
1387 // Matches the independent reference's split-half apply_rope.
1388 rope_layout: RopeLayout::Neox,
1389 qk_norm_style: crate::capability::QkNormStyle::WholeVector,
1390 swa_layers: crate::swa_layers::SwaLayers::All,
1391 rope_layers: crate::rope_layers::RopeLayers::All,
1392 layer_shapes: crate::layer_shapes::LayerShapes::Uniform,
1393 attn_logit_softcap: None,
1394 final_logit_softcap: None,
1395 embedding_scale: None,
1396 residual_scale: None,
1397 normed_residual_scale: None,
1398 clamp_kqv: None,
1399 attn_temperature: None,
1400 router_input: crate::router_input::RouterInput::NormedFfnInput,
1401 block_sub_norms: false,
1402 parallel_residual: false,
1403 learned_positions: false,
1404 attn_value_scale: None,
1405 alibi_max_bias: None,
1406 layer_loops: None,
1407 skip_stream: false,
1408 parallel_ssm: false,
1409 swa_chunked: false,
1410 weightless_qk_norm: false,
1411 logit_multiplier: None,
1412 attention_scale: None,
1413 rope_theta_swa: None,
1414 ffn_activation: FfnActivation::Swiglu,
1415 best_effort_fields: &["this is a synthetic test fixture, not a real model"],
1416 }
1417}
1418
1419/// Matches the generated on-disk multi-expert MoE fixture: 4 experts,
1420/// top-2 routing, 1 shared
1421/// expert, packed 3D expert tensors. Used to verify the previously-
1422/// untested multi-expert loading path (`split_expert_tensor` in
1423/// `frink-models::loader`) against a real file, the same way
1424/// `test_dense_fixture` verifies the single-expert path.
1425pub fn test_moe_fixture() -> ModelConfig {
1426 ModelConfig {
1427 sliding_window: None,
1428 name: "frink-test-moe",
1429 attention: AttentionKind::Gqa,
1430 n_layers: 2,
1431 n_mtp_blocks: 0,
1432 hidden_dim: 32,
1433 n_heads: 4,
1434 n_kv_heads: 2,
1435 head_dim: 8,
1436 v_head_dim: None,
1437 vocab_size: 32,
1438 rope_theta: 10000.0,
1439 rms_norm_eps: 1e-5,
1440 post_norm_eps: 1e-5,
1441 moe: MoeLayerConfig {
1442 expert_weights_scale: 1.0,
1443 routed_weight_before_ffn: false,
1444 n_experts: 4,
1445 n_experts_active: 2,
1446 n_shared_experts: 1,
1447 hidden_dim: 32,
1448 expert_ffn_dim: 32,
1449 gating: GatingFunction::Softmax,
1450 norm_topk_prob: true,
1451 expert_group_count: None,
1452 expert_group_used_count: None,
1453 },
1454 n_dense_leading_layers: 0,
1455 moe_interleave_step: None,
1456 norm_function: crate::norm::NormFunction::Rms,
1457 rope_freqs: None,
1458 rope_attn_factor: 1.0,
1459 rope_dim: None,
1460 rope_dim_swa: None,
1461 rope_freqs_long: None,
1462 rope_freqs_short: None,
1463 rope_orig_ctx: None,
1464 rope_layout: RopeLayout::Neox,
1465 qk_norm_style: crate::capability::QkNormStyle::WholeVector,
1466 swa_layers: crate::swa_layers::SwaLayers::All,
1467 rope_layers: crate::rope_layers::RopeLayers::All,
1468 layer_shapes: crate::layer_shapes::LayerShapes::Uniform,
1469 attn_logit_softcap: None,
1470 final_logit_softcap: None,
1471 embedding_scale: None,
1472 residual_scale: None,
1473 normed_residual_scale: None,
1474 clamp_kqv: None,
1475 attn_temperature: None,
1476 router_input: crate::router_input::RouterInput::NormedFfnInput,
1477 block_sub_norms: false,
1478 parallel_residual: false,
1479 learned_positions: false,
1480 attn_value_scale: None,
1481 alibi_max_bias: None,
1482 layer_loops: None,
1483 skip_stream: false,
1484 parallel_ssm: false,
1485 swa_chunked: false,
1486 weightless_qk_norm: false,
1487 logit_multiplier: None,
1488 attention_scale: None,
1489 rope_theta_swa: None,
1490 ffn_activation: FfnActivation::Swiglu,
1491 best_effort_fields: &["this is a synthetic multi-expert test fixture, not a real model"],
1492 }
1493}
1494
1495/// Matches the generated on-disk mixed-topology fixture: 3 layers, the
1496/// first of which is
1497/// an ordinary dense FFN and the remaining two are genuine MoE (3
1498/// experts, top-1 routing, 1 shared expert each). Used to verify the
1499/// "leading dense layers" loading path
1500/// (`ModelConfig::layer_is_dense`) against a real file -- the pattern
1501/// found in DeepSeek-2/3-family models via ik_llama.cpp's source
1502/// (`LLM_KV_LEADING_DENSE_BLOCK_COUNT`), which was previously only
1503/// documented, not implemented or tested.
1504pub fn test_mixed_fixture() -> ModelConfig {
1505 ModelConfig {
1506 sliding_window: None,
1507 name: "frink-test-mixed",
1508 attention: AttentionKind::Gqa,
1509 n_layers: 3,
1510 n_mtp_blocks: 0,
1511 hidden_dim: 32,
1512 n_heads: 4,
1513 n_kv_heads: 2,
1514 head_dim: 8,
1515 v_head_dim: None,
1516 vocab_size: 32,
1517 rope_theta: 10000.0,
1518 rms_norm_eps: 1e-5,
1519 post_norm_eps: 1e-5,
1520 moe: MoeLayerConfig {
1521 expert_weights_scale: 1.0,
1522 routed_weight_before_ffn: false,
1523 n_experts: 3,
1524 n_experts_active: 1,
1525 n_shared_experts: 1,
1526 hidden_dim: 32,
1527 expert_ffn_dim: 32,
1528 gating: GatingFunction::Softmax,
1529 norm_topk_prob: true,
1530 expert_group_count: None,
1531 expert_group_used_count: None,
1532 },
1533 n_dense_leading_layers: 1,
1534 moe_interleave_step: None,
1535 norm_function: crate::norm::NormFunction::Rms,
1536 rope_freqs: None,
1537 rope_attn_factor: 1.0,
1538 rope_dim: None,
1539 rope_dim_swa: None,
1540 rope_freqs_long: None,
1541 rope_freqs_short: None,
1542 rope_orig_ctx: None,
1543 rope_layout: RopeLayout::Neox,
1544 qk_norm_style: crate::capability::QkNormStyle::WholeVector,
1545 swa_layers: crate::swa_layers::SwaLayers::All,
1546 rope_layers: crate::rope_layers::RopeLayers::All,
1547 layer_shapes: crate::layer_shapes::LayerShapes::Uniform,
1548 attn_logit_softcap: None,
1549 final_logit_softcap: None,
1550 embedding_scale: None,
1551 residual_scale: None,
1552 normed_residual_scale: None,
1553 clamp_kqv: None,
1554 attn_temperature: None,
1555 router_input: crate::router_input::RouterInput::NormedFfnInput,
1556 block_sub_norms: false,
1557 parallel_residual: false,
1558 learned_positions: false,
1559 attn_value_scale: None,
1560 alibi_max_bias: None,
1561 layer_loops: None,
1562 skip_stream: false,
1563 parallel_ssm: false,
1564 swa_chunked: false,
1565 weightless_qk_norm: false,
1566 logit_multiplier: None,
1567 attention_scale: None,
1568 rope_theta_swa: None,
1569 ffn_activation: FfnActivation::Swiglu,
1570 best_effort_fields: &["this is a synthetic mixed dense/MoE test fixture, not a real model"],
1571 }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576 use super::*;
1577
1578 #[test]
1579 fn rope_layout_for_gguf_architecture_matches_llama_cpp() {
1580 // Confirmed against llama.cpp's llama_model_rope_type
1581 // (src/llama-model.cpp): llama -> NORM, olmoe/qwen2/phi3/gemma -> NEOX.
1582 assert_eq!(RopeLayout::for_gguf_architecture("llama"), RopeLayout::Norm);
1583 assert_eq!(
1584 RopeLayout::for_gguf_architecture("llama4"),
1585 RopeLayout::Norm
1586 );
1587 assert_eq!(
1588 RopeLayout::for_gguf_architecture("deepseek2"),
1589 RopeLayout::Norm
1590 );
1591 assert_eq!(RopeLayout::for_gguf_architecture("olmoe"), RopeLayout::Neox);
1592 assert_eq!(RopeLayout::for_gguf_architecture("qwen2"), RopeLayout::Neox);
1593 assert_eq!(
1594 RopeLayout::for_gguf_architecture("qwen2moe"),
1595 RopeLayout::Neox
1596 );
1597 assert_eq!(RopeLayout::for_gguf_architecture("qwen3"), RopeLayout::Neox);
1598 assert_eq!(RopeLayout::for_gguf_architecture("phi3"), RopeLayout::Neox);
1599 assert_eq!(
1600 RopeLayout::for_gguf_architecture("gemma3"),
1601 RopeLayout::Neox
1602 );
1603 // Unknown architectures keep the historical Neox default at this
1604 // helper only; load-time uses capability::resolve_architecture and
1605 // fails closed instead of guessing.
1606 assert_eq!(
1607 RopeLayout::for_gguf_architecture("totally-unknown-arch"),
1608 RopeLayout::Neox
1609 );
1610 }
1611
1612 /// gpt-oss's real shape: a 128-token window on every other layer.
1613 /// A KV block size of 128 or any divisor of it is fine; 48 or 256
1614 /// are not, and the config layer must round down rather than hand
1615 /// the cache something it will refuse (or, worse, accept).
1616 #[test]
1617 fn an_alternating_swa_model_constrains_the_block_layout() {
1618 let mut cfg = test_dense_fixture();
1619 cfg.n_layers = 24;
1620 cfg.sliding_window = Some(128);
1621 cfg.swa_layers = crate::swa_layers::SwaLayers::period(2, false);
1622
1623 // Half the layers are full-attention, but the model is still
1624 // constrained: one mis-aligned sliding layer is enough.
1625 assert!(cfg.layer_sliding_window(1).is_none() || cfg.layer_sliding_window(0).is_none());
1626 assert_eq!(cfg.kv_block_window(), Some(128));
1627
1628 let layout = cfg.kv_block_layout(256);
1629 assert_eq!(layout.block_size(), 128, "256 must round down, not up");
1630 assert_eq!(layout.sliding_window(), Some(128));
1631 assert_eq!(layout.blocks_per_window(), Some(1));
1632
1633 assert_eq!(cfg.kv_block_layout(48).block_size(), 32);
1634 assert_eq!(cfg.kv_block_layout(32).block_size(), 32);
1635 }
1636
1637 /// Gemma-3: window 512, every 6th layer full-attention.
1638 #[test]
1639 fn a_gemma3_shaped_model_takes_its_window_from_the_sliding_layers() {
1640 let mut cfg = test_dense_fixture();
1641 cfg.n_layers = 30;
1642 cfg.sliding_window = Some(512);
1643 cfg.swa_layers = crate::swa_layers::SwaLayers::period(6, false);
1644 assert!(
1645 cfg.layer_sliding_window(5).is_none(),
1646 "every 6th layer is full-attention"
1647 );
1648 assert_eq!(cfg.kv_block_window(), Some(512));
1649 assert_eq!(cfg.kv_block_layout(100).block_size(), 64);
1650 assert_eq!(cfg.kv_block_layout(64).blocks_per_window(), Some(8));
1651 }
1652
1653 /// The two window questions give OPPOSITE answers on an alternating
1654 /// model, and that is the point of having both.
1655 ///
1656 /// "Does any layer constrain the block layout" is yes, so the block
1657 /// size rounds down to the window. "May a page behind the window be
1658 /// taken away" is no, because the group holds the full-attention
1659 /// layers' blocks too and those layers still read position 0. A
1660 /// serving path that read `kv_block_window` for the second question
1661 /// would free pages half the layers are still attending over -- not
1662 /// a crash, just another request's bytes in this one's answer.
1663 #[test]
1664 fn only_a_uniformly_windowed_model_may_give_a_page_back() {
1665 let mut alternating = test_dense_fixture();
1666 alternating.n_layers = 24;
1667 alternating.sliding_window = Some(128);
1668 alternating.swa_layers = crate::swa_layers::SwaLayers::period(2, false);
1669 assert_eq!(alternating.kv_block_window(), Some(128));
1670 assert_eq!(
1671 alternating.uniform_sliding_window(),
1672 None,
1673 "a full-attention layer forbids the slide"
1674 );
1675
1676 let mut uniform = test_dense_fixture();
1677 uniform.n_layers = 24;
1678 uniform.sliding_window = Some(128);
1679 uniform.swa_layers = crate::swa_layers::SwaLayers::All;
1680 assert_eq!(uniform.uniform_sliding_window(), Some(128));
1681
1682 // `Some(0)` is llama.cpp's spelling of "every layer slides"
1683 // (`set_swa_pattern(0)`), and it is the one that may give a page
1684 // back. `Some(1)` is the OPPOSITE -- no layer slides -- and this
1685 // used to assert the two were the same, which is how the
1686 // inversion stayed invisible.
1687 let mut period_zero = uniform.clone();
1688 period_zero.swa_layers = crate::swa_layers::SwaLayers::period(0, false);
1689 assert_eq!(period_zero.uniform_sliding_window(), Some(128));
1690
1691 let mut period_one = uniform.clone();
1692 period_one.swa_layers = crate::swa_layers::SwaLayers::period(1, false);
1693 assert_eq!(
1694 period_one.uniform_sliding_window(),
1695 None,
1696 "period 1 windows no layer, so there is no window to slide"
1697 );
1698 assert_eq!(period_one.kv_block_window(), None);
1699
1700 let mut full = test_dense_fixture();
1701 full.sliding_window = None;
1702 assert_eq!(full.uniform_sliding_window(), None);
1703 }
1704
1705 #[test]
1706 fn a_full_causal_model_keeps_the_block_size_it_was_given() {
1707 let mut cfg = test_dense_fixture();
1708 cfg.sliding_window = None;
1709 cfg.swa_layers = crate::swa_layers::SwaLayers::All;
1710 assert_eq!(cfg.kv_block_window(), None);
1711 let layout = cfg.kv_block_layout(48);
1712 assert_eq!(layout.block_size(), 48);
1713 assert_eq!(layout.sliding_window(), None);
1714 }
1715
1716 #[test]
1717 fn all_presets_have_consistent_moe_hidden_dim() {
1718 for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1719 assert_eq!(
1720 cfg.hidden_dim, cfg.moe.hidden_dim,
1721 "{}: attention hidden_dim and MoE hidden_dim must match",
1722 cfg.name
1723 );
1724 }
1725 }
1726
1727 #[test]
1728 fn all_presets_route_fewer_experts_than_total() {
1729 for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1730 assert!(
1731 cfg.moe.n_experts_active < cfg.moe.n_experts,
1732 "{}: active experts must be a sparse subset of total experts",
1733 cfg.name
1734 );
1735 }
1736 }
1737
1738 #[test]
1739 fn all_presets_have_divisible_heads() {
1740 for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1741 assert_eq!(
1742 cfg.n_heads % cfg.n_kv_heads,
1743 0,
1744 "{}: n_heads must be a multiple of n_kv_heads for GQA grouping",
1745 cfg.name
1746 );
1747 }
1748 }
1749
1750 #[test]
1751 fn every_preset_declares_its_uncertain_fields() {
1752 // This is a documentation-honesty test: any preset with zero
1753 // best_effort_fields would be silently overclaiming precision
1754 // we don't have. Fail loudly if that ever happens.
1755 for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1756 assert!(
1757 !cfg.best_effort_fields.is_empty(),
1758 "{}: must disclose which fields are unconfirmed estimates",
1759 cfg.name
1760 );
1761 }
1762 }
1763
1764 /// Kimi K3's `kda_layers`/`full_attn_layers` were transcribed by
1765 /// hand from the real published config.json; this test guards
1766 /// against a transcription slip (duplicate, out-of-range, or
1767 /// missing layer index) rather than trusting the transcription.
1768 #[test]
1769 fn kimi_k3_hybrid_attention_layers_partition_every_layer_exactly_once() {
1770 let cfg = kimi_k3();
1771 let AttentionKind::KimiHybrid(hybrid) = &cfg.attention else {
1772 panic!("kimi_k3() must use AttentionKind::KimiHybrid");
1773 };
1774
1775 let mut seen = std::collections::HashSet::new();
1776 for &l in hybrid
1777 .kda_layers
1778 .iter()
1779 .chain(hybrid.full_attn_layers.iter())
1780 {
1781 assert!(
1782 (1..=cfg.n_layers).contains(&l),
1783 "layer {l} is out of the published 1..={} range",
1784 cfg.n_layers
1785 );
1786 assert!(
1787 seen.insert(l),
1788 "layer {l} appears in both/either list twice"
1789 );
1790 }
1791 // Dense-vs-MoE (n_dense_leading_layers) and attention-type
1792 // (KDA vs Gated MLA) are independent per-layer properties in
1793 // the real config -- e.g. layer 1 is both the sole dense
1794 // leading layer *and* a KDA layer -- so every one of the 93
1795 // layers, dense or not, is covered by exactly one of these two
1796 // lists (confirmed: 69 + 24 == 93, not 93 - 1).
1797 assert_eq!(
1798 hybrid.kda_layers.len() + hybrid.full_attn_layers.len(),
1799 cfg.n_layers,
1800 "every layer must be assigned exactly one of KDA or Gated MLA"
1801 );
1802 assert_eq!(
1803 hybrid.kda_layers.len(),
1804 69,
1805 "expected 69 KDA layers per the published config"
1806 );
1807 assert_eq!(
1808 hybrid.full_attn_layers.len(),
1809 24,
1810 "expected 24 Gated MLA layers per the published config"
1811 );
1812 }
1813
1814 #[test]
1815 fn layer_attention_kind_is_gqa_for_every_layer_of_a_gqa_model() {
1816 let cfg = glm_5_2();
1817 for l in 0..cfg.n_layers {
1818 assert_eq!(cfg.layer_attention_kind(l), LayerAttentionKind::Gqa);
1819 }
1820 }
1821
1822 #[test]
1823 fn layer_attention_kind_classifies_every_kimi_k3_layer_without_panicking() {
1824 let cfg = kimi_k3();
1825 let AttentionKind::KimiHybrid(hybrid) = &cfg.attention else {
1826 panic!("kimi_k3() must use AttentionKind::KimiHybrid");
1827 };
1828 for l in 0..cfg.n_layers {
1829 let kind = cfg.layer_attention_kind(l);
1830 let one_indexed = l + 1;
1831 if hybrid.kda_layers.contains(&one_indexed) {
1832 assert_eq!(kind, LayerAttentionKind::KimiKda);
1833 } else {
1834 assert_eq!(kind, LayerAttentionKind::KimiMla);
1835 }
1836 }
1837 }
1838
1839 #[test]
1840 fn layer_attention_kind_matches_the_real_published_layer_1_and_4() {
1841 // Layer 1 (1-indexed, so index 0 here) is published as KDA;
1842 // layer 4 (index 3) is published as the first Gated MLA layer.
1843 let cfg = kimi_k3();
1844 assert_eq!(cfg.layer_attention_kind(0), LayerAttentionKind::KimiKda);
1845 assert_eq!(cfg.layer_attention_kind(3), LayerAttentionKind::KimiMla);
1846 }
1847
1848 #[test]
1849 fn kimi_k3_mla_q_head_dim_matches_gqa_placeholder_head_dim() {
1850 // The Gqa-placeholder head_dim above is deliberately set to
1851 // Gated MLA's combined q_head_dim (qk_nope + qk_rope) so the
1852 // placeholder path at least reflects a real dimension from the
1853 // published config rather than an arbitrary guess.
1854 let cfg = kimi_k3();
1855 let AttentionKind::KimiHybrid(hybrid) = &cfg.attention else {
1856 panic!("kimi_k3() must use AttentionKind::KimiHybrid");
1857 };
1858 assert_eq!(
1859 cfg.head_dim,
1860 hybrid.mla.qk_nope_head_dim + hybrid.mla.qk_rope_head_dim
1861 );
1862 }
1863
1864 #[test]
1865 fn approx_active_params_is_nonzero_and_finite_order_of_magnitude() {
1866 for cfg in [glm_5_2(), deepseek_v4_pro(), kimi_k3()] {
1867 let approx = cfg.approx_active_params_per_token();
1868 // Sanity band: active params/token for these models is
1869 // reported in the tens of billions; this is a loose
1870 // order-of-magnitude check (1e9 to 1e12), not a precise
1871 // parameter-count reproduction.
1872 assert!(
1873 approx > 1_000_000_000 && approx < 1_000_000_000_000,
1874 "{}: approx_active_params_per_token={approx} is outside a plausible range",
1875 cfg.name
1876 );
1877 }
1878 }
1879}
1880
1881#[cfg(test)]
1882mod longrope_tests {
1883 use super::*;
1884
1885 fn cfg_with_factors() -> ModelConfig {
1886 let mut c = test_dense_fixture();
1887 c.rope_orig_ctx = Some(4096);
1888 c.rope_freqs_short = Some(vec![1.0; 48]);
1889 c.rope_freqs_long = Some((0..48).map(|i| 1.0 + i as f32).collect());
1890 c.rope_freqs = None;
1891 c
1892 }
1893
1894 /// llama.cpp `llama_model::get_rope_factors`: long only when the
1895 /// run's context exceeds `original_context_length`. Phi-4-mini's
1896 /// short set is all ones, so picking long at 4096 would apply a
1897 /// correction the model never asked for at that length.
1898 #[test]
1899 fn long_set_only_above_the_original_context() {
1900 let mut c = cfg_with_factors();
1901 c.apply_runtime_context(4096);
1902 assert_eq!(
1903 c.rope_freqs.as_ref().unwrap().full[1],
1904 1.0,
1905 "at the threshold, short"
1906 );
1907
1908 let mut c = cfg_with_factors();
1909 c.apply_runtime_context(4097);
1910 assert_eq!(
1911 c.rope_freqs.as_ref().unwrap().full[1],
1912 2.0,
1913 "above it, long"
1914 );
1915
1916 let mut c = cfg_with_factors();
1917 c.apply_runtime_context(1024);
1918 assert_eq!(
1919 c.rope_freqs.as_ref().unwrap().full[1],
1920 1.0,
1921 "below it, short"
1922 );
1923 }
1924
1925 /// `rope_freqs.weight` (Llama 3) is not a LongRoPE set and outranks
1926 /// one, the same precedence llama.cpp gives it. The loader encodes
1927 /// that by leaving the long/short pair empty whenever the explicit
1928 /// tensor is present, so the runtime re-pick has nothing to apply.
1929 #[test]
1930 fn an_explicit_rope_freqs_tensor_is_never_overridden() {
1931 let mut c = test_dense_fixture();
1932 c.rope_freqs = Some(RopeFreqs {
1933 full: vec![7.0; 48],
1934 swa: None,
1935 });
1936 c.rope_orig_ctx = Some(4096);
1937 c.rope_freqs_long = None;
1938 c.rope_freqs_short = None;
1939 c.apply_runtime_context(131072);
1940 assert_eq!(c.rope_freqs.as_ref().unwrap().full[0], 7.0);
1941 }
1942
1943 /// A checkpoint with neither set must come back untouched, so the
1944 /// call is free to sit on every load path.
1945 #[test]
1946 fn models_without_longrope_are_untouched() {
1947 let mut c = test_dense_fixture();
1948 c.rope_freqs = None;
1949 c.apply_runtime_context(8192);
1950 assert!(c.rope_freqs.is_none());
1951 assert!(c.rope_orig_ctx.is_none());
1952 }
1953}