Skip to main content

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}