Skip to main content

logicaffeine_compile/model/
spec.rs

1//! A decoder-only language model's architecture, as data.
2//!
3//! [`ModelSpec`] says what a checkpoint computes — the width, the vocabulary, and for every layer its token mixer
4//! (attention or a gated-delta recurrence), its feed-forward block (dense or a mixture of experts) and how its
5//! norms are arranged. It is read from the checkpoint's own metadata ([`ModelSpec::from_gguf`]), so bringing up a
6//! model is reading its file: the per-architecture knowledge lives in one match over `general.architecture`, and
7//! everything downstream (the authored `.lg`, the reference forward, the weight binding) is generic over the spec.
8//!
9//! The meaning of each field follows llama.cpp's graph for that architecture (`src/models/<arch>.cpp` at the pinned
10//! commit), which is the reference the conformance locks hold the model to.
11
12use super::gguf::{Gguf, MetaValue};
13pub use crate::tensor_recognize::qwen::Window;
14
15/// Qwen3.8-Flash-Next's model-wide parts from its keys (llama.cpp `qwen4exp`): the hyper-connections, the per-layer
16/// embedding (one layer, as the GGUF holds one table) and QSA's indexer, which selects on the attention layers whose
17/// compression ratio is not zero, one ratio for them all (the draft block's among them), as llama.cpp selects.
18#[allow(clippy::type_complexity)]
19fn qwen4exp_parts<S: SpecSource + ?Sized>(
20    k: &Keys<'_, S>,
21    layers: &[Layer],
22) -> Result<(Option<HyperConnections>, Option<Ple>, Option<Indexer>), SpecError> {
23    let hyper = HyperConnections { lanes: k.usize("hyper_connection.count")?, rank: k.usize("hyper_connection.low_rank")? };
24    if hyper.lanes < 2 || hyper.rank == 0 {
25        return Err(SpecError::Inconsistent(format!("hyper-connections of {} lanes over a rank of {}", hyper.lanes, hyper.rank)));
26    }
27    let ints = |suffix: &str| -> Result<Vec<u64>, SpecError> {
28        k.get(suffix)
29            .and_then(MetaValue::as_array)
30            .and_then(|a| a.iter().map(MetaValue::as_u64).collect::<Option<Vec<u64>>>())
31            .ok_or_else(|| SpecError::MissingKey(k.key(suffix)))
32    };
33    let ratios = ints("attention.compress_ratios")?;
34    if ratios.len() < layers.len() {
35        return Err(SpecError::Inconsistent(format!("{} compression ratios for {} layers", ratios.len(), layers.len())));
36    }
37    let mut block = None;
38    let mut selecting = Vec::new();
39    for (l, &r) in ratios.iter().enumerate() {
40        let r = r as usize;
41        if r == 0 {
42            continue;
43        }
44        if *block.get_or_insert(r) != r {
45            return Err(SpecError::Inconsistent(format!("QSA's blocks of {r} on block {l}, of {} before it", block.unwrap())));
46        }
47        match layers.get(l).map(|layer| &layer.mixer) {
48            Some(Mixer::Attention(_)) => selecting.push(l),
49            Some(_) => return Err(SpecError::Inconsistent(format!("layer {l} is not attention but compresses by {r}"))),
50            // The draft block's.
51            None => {}
52        }
53    }
54    let indexer = Indexer {
55        heads: k.usize("attention.indexer.head_count")?,
56        head_dim: k.usize("attention.indexer.key_length")?,
57        budget: k.usize("attention.indexer.top_k")?,
58        block: block.ok_or_else(|| SpecError::Inconsistent("QSA's indexer with no block that selects".into()))?,
59        layers: selecting,
60    };
61    if indexer.block < 2 || indexer.budget % indexer.block != 0 {
62        return Err(SpecError::Inconsistent(format!("QSA's budget of {} in blocks of {}", indexer.budget, indexer.block)));
63    }
64    let ple = match k.get("ple.layers").and_then(MetaValue::as_array) {
65        None => None,
66        Some(_) => {
67            let layer = match ints("ple.layers")?[..] {
68                [l] => l as usize,
69                ref other => return Err(SpecError::Inconsistent(format!("the per-layer embedding on layers {other:?}: one table, one layer"))),
70            };
71            if !matches!(layers.get(layer).map(|l| &l.mixer), Some(Mixer::GatedDelta(_))) {
72                return Err(SpecError::Inconsistent(format!("the per-layer embedding on layer {layer}, not a Gated DeltaNet layer")));
73            }
74            let ple = Ple {
75                layer,
76                ngram: k.usize("ple.ngram_size")?,
77                heads_per_ngram: k.usize("ple.heads_per_ngram")?,
78                head_dim: k.usize("embedding_length_per_layer_input")?,
79                conv_taps: k.usize("ple.conv_kernel")?,
80                eos: k.usize("ple.eos_token_id")? as u32,
81                multipliers: ints("ple.layer_multipliers")?,
82                head_offsets: ints("ple.head_offsets")?,
83                head_vocab: ints("ple.head_vocab_sizes")?,
84            };
85            let heads = (ple.ngram.max(1) - 1) * ple.heads_per_ngram;
86            if ple.ngram < 2 || ple.multipliers.len() != ple.ngram || ple.head_offsets.len() != heads || ple.head_vocab.len() != heads {
87                return Err(SpecError::Inconsistent(format!(
88                    "n-grams of {} over {heads} heads with {} multipliers, {} offsets and {} moduli",
89                    ple.ngram,
90                    ple.multipliers.len(),
91                    ple.head_offsets.len(),
92                    ple.head_vocab.len()
93                )));
94            }
95            Some(ple)
96        }
97    };
98    Ok((Some(hyper), ple, Some(indexer)))
99}
100
101/// What a checkpoint computes.
102#[derive(Clone, Debug, PartialEq)]
103pub struct ModelSpec {
104    /// `general.architecture`.
105    pub arch: String,
106    /// Hidden width.
107    pub d: usize,
108    /// Vocabulary size (rows of the token embedding).
109    pub vocab: usize,
110    /// The trained context window.
111    pub ctx_len: usize,
112    /// The RMSNorm epsilon every norm of the model uses.
113    pub rms_eps: f64,
114    /// A scale applied to the token embedding as it is read (Gemma: `√d`).
115    pub embedding_scale: Option<f64>,
116    /// The output projection is the token embedding (no `output.weight` in the file).
117    pub tied_output: bool,
118    /// Soft-capping of the final logits (`cap · tanh(x / cap)`).
119    pub final_logit_softcap: Option<f64>,
120    pub layers: Vec<Layer>,
121    /// Draft blocks for multi-token prediction appended after the model's layers.
122    pub mtp_blocks: usize,
123    /// The tensor stem of the norm on each block's FFN input: `ffn_norm`, or `post_attention_norm` in Qwen3.5,
124    /// whose converter names the pre-FFN norm after the attention it follows.
125    pub ffn_norm: &'static str,
126    /// The residual stream's hyper-connections, in place of every norm (Qwen3.8-Flash-Next); `None` for one residual.
127    pub hyper: Option<HyperConnections>,
128    /// The hashed n-gram embedding one layer's input takes (Qwen3.8-Flash-Next's per-layer embedding).
129    pub ple: Option<Ple>,
130    /// The sparse selection of the positions every attention layer's queries attend (Qwen's QSA).
131    pub indexer: Option<Indexer>,
132}
133
134/// Hyper-connections: the residual is `lanes` copies of the hidden state. Before each sublayer the lanes are normed
135/// each on its own (one weight across all of them), weighted by `σ(up·silu(down·x̂ / lanes))` through a bottleneck of
136/// `rank` and averaged into the sublayer's input; the sublayer's output joins every lane scaled by
137/// `2σ(inject·x̂ / lanes)`. A last mix with no injection feeds the head in place of an output norm.
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct HyperConnections {
140    pub lanes: usize,
141    pub rank: usize,
142}
143
144/// The per-layer n-gram embedding: each position's n-grams of 2 to `ngram` tokens, read back no further than the last
145/// end of document, are hashed `heads_per_ngram` ways each (`t₀·m₀ ⊕ t₁·m₁ ⊕ …` modulo a head's prime, from the head's
146/// offset in one table); the heads' rows, side by side, project to a value and to a key for each hyper-connection lane,
147/// the lane's normed state gating the value by `σ(sign(g)·√|g|)` of its agreement `g` with the key, and a dilated
148/// depthwise convolution over the normed gated values adds local context. Its sum joins the layer's residual input.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct Ple {
151    /// The layer whose input it joins.
152    pub layer: usize,
153    /// The longest n-gram: its context reaches `ngram - 1` tokens back, and the convolution's dilation is `ngram`.
154    pub ngram: usize,
155    pub heads_per_ngram: usize,
156    /// A head's row in the table.
157    pub head_dim: usize,
158    pub conv_taps: usize,
159    /// The token that ends a document: no n-gram reaches back across it, and it stands in before the first token.
160    pub eos: u32,
161    /// The odd multiplier each position back is hashed by.
162    pub multipliers: Vec<u64>,
163    /// Each head's first row in the table.
164    pub head_offsets: Vec<u64>,
165    /// Each head's prime modulus.
166    pub head_vocab: Vec<u64>,
167}
168
169/// Qwen's sparse attention selection: `heads` index queries of `head_dim` over one index key a position, the keys of
170/// each complete block of `block` positions averaged, normed and rotated at the block's first position, each block
171/// scored by `Σ_h relu(q_h·k̄)/√head_dim`; a query attends the best `budget / block` blocks and the positions after the
172/// last complete block, every visible position while the complete blocks are no more than that. The attention layers
173/// in `layers` select; any other attends every position.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct Indexer {
176    pub heads: usize,
177    pub head_dim: usize,
178    pub budget: usize,
179    pub block: usize,
180    pub layers: Vec<usize>,
181}
182
183/// One block: its token mixer, its feed-forward block, and its norms.
184#[derive(Clone, Debug, PartialEq)]
185pub struct Layer {
186    pub mixer: Mixer,
187    pub ffn: Ffn,
188    /// A norm applied to the mixer's output before the residual add (Gemma's sandwich norm,
189    /// `post_attention_norm`).
190    pub post_mixer_norm: bool,
191    /// A norm applied to the feed-forward output before the residual add (`post_ffw_norm`).
192    pub post_ffn_norm: bool,
193}
194
195/// How a block mixes information across positions.
196#[derive(Clone, Debug, PartialEq)]
197pub enum Mixer {
198    Attention(Attention),
199    GatedDelta(GatedDelta),
200    Mla(Mla),
201}
202
203/// Multi-head latent attention (DeepSeek V2/V3): the keys and values of every head are expanded from one shared
204/// low-rank latent per position (`kv_lora_rank` wide, RMS-normed), and each head's query and key carry a further
205/// `qk_rope` dimensions that rotate — the key's shared by every head. The query is a full projection, or itself
206/// low-rank (`q_lora_rank`, normed between its two halves).
207#[derive(Clone, Debug, PartialEq)]
208pub struct Mla {
209    pub heads: usize,
210    pub q_lora_rank: Option<usize>,
211    pub kv_lora_rank: usize,
212    /// Per-head query/key dimensions that do not rotate.
213    pub qk_nope: usize,
214    /// Per-head query/key dimensions that rotate (the trailing ones).
215    pub qk_rope: usize,
216    pub v_dim: usize,
217    pub rope: Rope,
218    /// The score scale: `1/√(qk_nope + qk_rope)`, times YaRN's magnitude squared.
219    pub scale: f64,
220    /// The checkpoint stores the latent's expansion split into its key half (`attn_k_b`, each head's `kv_lora_rank`
221    /// rows of `qk_nope`) and its value half (`attn_v_b`, each head's `v_dim` rows of `kv_lora_rank`), rather than as
222    /// one `attn_kv_b` of `qk_nope + v_dim` rows a head.
223    pub kv_b_split: bool,
224}
225
226/// Grouped-query attention, with the variants checkpoints use.
227#[derive(Clone, Debug, PartialEq)]
228pub struct Attention {
229    pub heads: usize,
230    pub kv_heads: usize,
231    /// Query/key head width.
232    pub head_dim: usize,
233    /// Value head width.
234    pub value_dim: usize,
235    /// The rotary embedding, or `None` for a layer without positions (NoPE).
236    pub rope: Option<Rope>,
237    /// Per-head RMSNorm of the queries and keys before the rotation.
238    pub qk_norm: bool,
239    /// The query projection also produces a per-element gate, and the attention output is multiplied by its
240    /// sigmoid (Qwen3.5/3.8).
241    pub output_gate: bool,
242    /// The projections are one fused tensor (`attn_qkv`) rather than three.
243    pub fused_qkv: bool,
244    /// Biases on the query, key and value projections.
245    pub bias: bool,
246    /// A bias on the output projection (`attn_output.bias`): gpt-oss's; Qwen2's attention has none.
247    pub output_bias: bool,
248    /// The positions each query keeps: the last `W` (Mistral, Gemma 2 and 3, gpt-oss), or its own chunk of `C`
249    /// (Llama 4's rotated layers).
250    pub window: Option<Window>,
251    /// A learned per-head logit joins each softmax's denominator (gpt-oss).
252    pub sinks: bool,
253    /// Soft-capping of the attention scores.
254    pub softcap: Option<f64>,
255    /// The score scale (`1/√head_dim` unless the checkpoint says otherwise).
256    pub scale: f64,
257    /// Each query scaled by a factor of its position before it scores (Llama 4's rotation-free layers).
258    pub temperature: Option<Temperature>,
259    /// A per-head RMSNorm of the queries and keys with no weight (Llama 4's rotated layers, after the rotation: the
260    /// same as before it, a rotation keeping each head's norm).
261    pub qk_unit_norm: bool,
262    /// With `qk_norm`, the queries and the keys are each normed across every head at once, their weights as wide as the
263    /// projections (MiniMax-M2's `qk_norm_type` "per_layer"), rather than head by head.
264    pub qk_norm_whole: bool,
265}
266
267/// Llama 4's attention temperature: the query at position `p` (0-based) scaled by `1 + scale·ln(⌊(p + 1)/floor⌋ + 1)`,
268/// so short contexts are untouched and long ones sharpen (`attn_temperature_tuning`).
269#[derive(Clone, Copy, Debug, PartialEq)]
270pub struct Temperature {
271    pub floor: f64,
272    pub scale: f64,
273}
274
275impl Temperature {
276    /// The factor a query at position `p` (0-based) is scaled by.
277    pub fn at(self, p: usize) -> f64 {
278        1.0 + self.scale * (((p as f64 + 1.0) / self.floor).floor() + 1.0).ln()
279    }
280}
281
282/// The rotary position embedding.
283#[derive(Clone, Debug, PartialEq)]
284pub struct Rope {
285    /// How many of each head's dimensions rotate (the rest pass through).
286    pub dims: usize,
287    pub theta: f64,
288    /// NEOX pairs dimension `i` with `i + dims/2`; the original form pairs `2i` with `2i+1`.
289    pub neox: bool,
290    pub scaling: RopeScaling,
291    /// Interleaved multi-section rotation (`rope.dimension_sections`, Qwen3.5): frequency `i` reads position stream
292    /// `i mod Σsections` as llama.cpp's IMROPE assigns it. A text token's streams are `[p, p, p, 0]`, so a frequency
293    /// assigned to the fourth stream does not turn.
294    pub sections: Option<[usize; 4]>,
295}
296
297impl Rope {
298    /// `(cos, sin)` of frequency `i` at text position `pos`, each scaled by the rotation's magnitude, as ggml's CPU
299    /// rope computes them (`rope_yarn`): the angle is `pos·θ^(−2i/dims)`, divided by the checkpoint's frequency
300    /// factor `factor` when it stores one; linear scaling divides it by its factor; YaRN blends the divided and the
301    /// undivided angle by a ramp over the correction dimensions and scales the pair by its magnitude.
302    pub fn rotation(&self, i: usize, pos: usize, factor: Option<f64>) -> (f64, f64) {
303        let mut freq = self.theta.powf(-2.0 * i as f64 / self.dims as f64);
304        if let Some(f) = factor {
305            freq /= f;
306        }
307        let extrapolated = self.text_position(i, pos) as f64 * freq;
308        let (angle, scale) = match self.scaling {
309            RopeScaling::Linear { factor } => (extrapolated / factor, 1.0),
310            RopeScaling::Yarn { factor, original_ctx, beta_fast, beta_slow, magnitude } => {
311                let dims = self.dims as f64;
312                let corr = |beta: f64| {
313                    dims * (original_ctx as f64 / (beta * 2.0 * std::f64::consts::PI)).ln() / (2.0 * self.theta.ln())
314                };
315                let low = corr(beta_fast).floor().max(0.0);
316                let high = corr(beta_slow).ceil().min(dims - 1.0);
317                let ramp = 1.0 - ((i as f64 - low) / (high - low).max(0.001)).clamp(0.0, 1.0);
318                (extrapolated / factor * (1.0 - ramp) + extrapolated * ramp, magnitude)
319            }
320            RopeScaling::None | RopeScaling::Factors => (extrapolated, 1.0),
321        };
322        let (s, c) = angle.sin_cos();
323        (c * scale, s * scale)
324    }
325
326    /// The position frequency `i` turns by for a text token at `pos`.
327    pub fn text_position(&self, i: usize, pos: usize) -> usize {
328        let Some(sec) = self.sections else { return pos };
329        let total: usize = sec.iter().sum();
330        let sector = i % total;
331        let streams = [pos, pos, pos, 0];
332        let stream = if sector % 3 == 1 && sector < 3 * sec[1] {
333            1
334        } else if sector % 3 == 2 && sector < 3 * sec[2] {
335            2
336        } else if sector % 3 == 0 && sector < 3 * sec[0] {
337            0
338        } else {
339            3
340        };
341        streams[stream]
342    }
343}
344
345/// Context extension applied to the rotary frequencies.
346#[derive(Clone, Debug, PartialEq)]
347pub enum RopeScaling {
348    None,
349    /// Per-frequency factors stored in the checkpoint (`rope_freqs.weight`, Llama 3.1+).
350    Factors,
351    Linear { factor: f64 },
352    /// YaRN as ggml applies it: each frequency blended between interpolated (`/factor`) and extrapolated by a ramp
353    /// over the correction dimensions `beta_fast`/`beta_slow` give at `original_ctx`, and the rotation scaled by
354    /// `magnitude` (llama.cpp's `get_mscale` ratio times `rope.scaling.attn_factor`, with its own cancelling factor
355    /// folded in).
356    Yarn {
357        factor: f64,
358        original_ctx: usize,
359        beta_fast: f64,
360        beta_slow: f64,
361        magnitude: f64,
362    },
363}
364
365/// The Gated DeltaNet recurrence (Qwen3.5/3.8 linear-attention layers).
366#[derive(Clone, Debug, PartialEq)]
367pub struct GatedDelta {
368    pub k_heads: usize,
369    pub v_heads: usize,
370    pub head_dim: usize,
371    pub conv_taps: usize,
372    /// The activation of the gate each head's normed read is multiplied by.
373    pub gate: GateActivation,
374}
375
376/// A gate's activation: SiLU (Qwen3.5's and Qwen3-Next's Gated DeltaNet) or the sigmoid (Qwen3.8-Flash-Next's).
377#[derive(Clone, Copy, Debug, PartialEq, Eq)]
378pub enum GateActivation {
379    Silu,
380    Sigmoid,
381}
382
383/// A block's feed-forward network.
384#[derive(Clone, Debug, PartialEq)]
385pub enum Ffn {
386    Dense {
387        dff: usize,
388        act: Act,
389    },
390    Moe(Moe),
391}
392
393/// A mixture of experts.
394#[derive(Clone, Debug, PartialEq)]
395pub struct Moe {
396    pub experts: usize,
397    pub top_k: usize,
398    pub expert_dff: usize,
399    pub act: Act,
400    pub gating: Gating,
401    /// The chosen experts' weights are renormalized to sum to one.
402    pub norm_top_k: bool,
403    /// A scale applied to the routing weights.
404    pub weights_scale: f64,
405    pub shared_experts: usize,
406    pub shared_dff: usize,
407    /// The shared experts' output scaled by `σ(x·g)`, `g` the checkpoint's `ffn_gate_inp_shexp` (Qwen's mixtures).
408    pub shared_gate: bool,
409    /// Biases on the router and on every expert's projections (gpt-oss).
410    pub biases: bool,
411    /// Experts chosen within groups (DeepSeek V3): the experts split into `groups` of equal size, each group scored
412    /// by its two best experts, only the best `used` groups' experts eligible for the top-k.
413    pub groups: Option<(usize, usize)>,
414}
415
416/// A gated activation `act(gate) · up`.
417#[derive(Clone, Debug, PartialEq)]
418pub enum Act {
419    Silu,
420    GeluTanh,
421    /// gpt-oss: `clamp(gate)·σ(α·gate) · (clamp(up)+1)`.
422    SwigluOai { alpha: f64, limit: f64 },
423}
424
425/// How a router turns logits into expert weights, and where a weight applies: to its expert's output, unless the gating
426/// says otherwise.
427#[derive(Clone, Debug, PartialEq)]
428pub enum Gating {
429    /// Softmax over all experts, then the top-k.
430    Softmax,
431    /// The top-k logits, then a softmax over them (gpt-oss).
432    SoftmaxOfTopK,
433    /// Sigmoid scores, optionally with a learned selection bias (DeepSeek V3).
434    Sigmoid { bias: bool },
435    /// The top-k logits, each chosen expert's INPUT scaled by the sigmoid of its logit and its output summed as it
436    /// stands (Llama 4; llama.cpp's `weight_before_ffn`).
437    SigmoidScaledInput,
438}
439
440/// Why a checkpoint is not a model this crate describes.
441#[derive(Clone, Debug, PartialEq)]
442pub enum SpecError {
443    /// `general.architecture` names an architecture no arm reads yet.
444    UnknownArchitecture(String),
445    /// A key the architecture requires is absent or of the wrong type.
446    MissingKey(String),
447    /// A tensor the architecture requires is absent.
448    MissingTensor(String),
449    /// The keys describe a shape the architecture's graph does not compute.
450    Inconsistent(String),
451}
452
453impl std::fmt::Display for SpecError {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        match self {
456            SpecError::UnknownArchitecture(a) => write!(f, "architecture `{a}` is not described yet"),
457            SpecError::MissingKey(k) => write!(f, "metadata key `{k}` is missing"),
458            SpecError::MissingTensor(t) => write!(f, "tensor `{t}` is missing"),
459            SpecError::Inconsistent(why) => write!(f, "the checkpoint is inconsistent: {why}"),
460        }
461    }
462}
463
464/// A float key's value. A converter writes the model's hyperparameters as decimals (`1e-6`) and GGUF stores most of
465/// them as f32, which cannot hold most decimals; the value meant is the shortest decimal that rounds to the stored
466/// f32 — so `rms_eps` reads as `1e-6`, not as the f32 nearest it.
467/// Which of `n_layers` blocks slide, as llama.cpp's `load_swa_pattern` reads it: `attention.sliding_window_pattern` as
468/// one flag a block, or as a period `every` (`default` where unwritten) in which all but the last block slide (every
469/// block at a period of 0) — none where the window is 0.
470fn sliding_layers<S: SpecSource + ?Sized>(k: &Keys<'_, S>, n_layers: usize, default: usize, window: usize) -> Result<Vec<bool>, SpecError> {
471    Ok(match k.get("attention.sliding_window_pattern") {
472        Some(MetaValue::Array { items: a, .. }) => a
473            .iter()
474            .take(n_layers)
475            .map(|v| v.as_bool().or_else(|| v.as_u64().map(|x| x != 0)))
476            .collect::<Option<_>>()
477            .ok_or_else(|| SpecError::MissingKey(k.key("attention.sliding_window_pattern")))?,
478        other => {
479            let every = other.and_then(MetaValue::as_u64).map_or(default, |v| v as usize);
480            (0..n_layers).map(|l| window > 0 && (every == 0 || l % every < every - 1)).collect()
481        }
482    })
483}
484
485fn decimal(v: &MetaValue) -> Option<f64> {
486    match *v {
487        MetaValue::F32(f) => f.to_string().parse().ok(),
488        _ => v.as_f64(),
489    }
490}
491
492/// What a checkpoint's architecture is read from: its architecture's name, its keys and its tensor directory — a
493/// GGUF checkpoint, or anything describing one.
494pub trait SpecSource {
495    /// `general.architecture`.
496    fn arch(&self) -> &str;
497    /// The architecture key `{arch}.{suffix}`.
498    fn arch_meta(&self, suffix: &str) -> Option<&MetaValue>;
499    /// A tensor's dims, `dims[0]` the fastest-varying.
500    fn dims(&self, name: &str) -> Option<&[u64]>;
501    /// Every tensor's name, in the directory's order.
502    fn tensor_names(&self) -> Vec<&str>;
503    fn has(&self, name: &str) -> bool {
504        self.dims(name).is_some()
505    }
506}
507
508impl SpecSource for Gguf {
509    fn arch(&self) -> &str {
510        &self.arch.arch
511    }
512    fn arch_meta(&self, suffix: &str) -> Option<&MetaValue> {
513        Gguf::arch_meta(self, suffix)
514    }
515    fn dims(&self, name: &str) -> Option<&[u64]> {
516        Gguf::dims(self, name)
517    }
518    fn tensor_names(&self) -> Vec<&str> {
519        self.tensors().iter().map(|t| t.name.as_str()).collect()
520    }
521    fn has(&self, name: &str) -> bool {
522        Gguf::has(self, name)
523    }
524}
525
526/// Typed reads of a checkpoint's architecture keys.
527struct Keys<'a, S: SpecSource + ?Sized> {
528    g: &'a S,
529}
530
531impl<S: SpecSource + ?Sized> Keys<'_, S> {
532    fn key(&self, suffix: &str) -> String {
533        format!("{}.{suffix}", self.g.arch())
534    }
535    fn get(&self, suffix: &str) -> Option<&MetaValue> {
536        self.g.arch_meta(suffix)
537    }
538    fn usize(&self, suffix: &str) -> Result<usize, SpecError> {
539        self.get(suffix)
540            .and_then(MetaValue::as_u64)
541            .map(|v| v as usize)
542            .ok_or_else(|| SpecError::MissingKey(self.key(suffix)))
543    }
544    fn usize_or(&self, suffix: &str, default: usize) -> usize {
545        self.get(suffix)
546            .and_then(MetaValue::as_u64)
547            .map_or(default, |v| v as usize)
548    }
549    fn f64(&self, suffix: &str) -> Result<f64, SpecError> {
550        self.get(suffix)
551            .and_then(decimal)
552            .ok_or_else(|| SpecError::MissingKey(self.key(suffix)))
553    }
554    fn f64_or(&self, suffix: &str, default: f64) -> f64 {
555        self.get(suffix).and_then(decimal).unwrap_or(default)
556    }
557}
558
559impl ModelSpec {
560    /// Read the architecture of a GGUF checkpoint.
561    pub fn from_gguf(g: &Gguf) -> Result<ModelSpec, SpecError> {
562        ModelSpec::from_source(g)
563    }
564
565    /// Read the architecture of a checkpoint from anything that describes it ([`SpecSource`]).
566    pub fn from_source<S: SpecSource + ?Sized>(g: &S) -> Result<ModelSpec, SpecError> {
567        let k = Keys { g };
568        let arch = g.arch().to_string();
569        let d = k.usize("embedding_length")?;
570        let vocab = g
571            .dims("token_embd.weight")
572            .and_then(|dims| dims.get(1).copied())
573            .ok_or_else(|| SpecError::MissingTensor("token_embd.weight".into()))? as usize;
574        let mtp_blocks = k.usize_or("nextn_predict_layers", 0);
575        let n_layers = k.usize("block_count")? - mtp_blocks;
576        let heads = k.usize("attention.head_count")?;
577        let kv_heads = k.usize_or("attention.head_count_kv", heads);
578        let head_dim = k.usize_or("attention.key_length", d / heads);
579        let value_dim = k.usize_or("attention.value_length", head_dim);
580        let sections = match k.get("rope.dimension_sections").and_then(MetaValue::as_array) {
581            None => None,
582            Some(a) => {
583                let v: Vec<usize> = a.iter().filter_map(MetaValue::as_u64).map(|x| x as usize).collect();
584                match v[..] {
585                    [a, b, c, d] if a + b + c + d > 0 => Some([a, b, c, d]),
586                    _ => return Err(SpecError::MissingKey(k.key("rope.dimension_sections"))),
587                }
588            }
589        };
590        let rope = |neox: bool| -> Result<Rope, SpecError> {
591            Ok(Rope {
592                dims: k.usize_or("rope.dimension_count", head_dim),
593                theta: k.f64_or("rope.freq_base", 10000.0),
594                neox,
595                scaling: rope_scaling(&k, g)?,
596                sections,
597            })
598        };
599        // The attention features are read off an attention layer's own tensors (`at`): in a hybrid model block 0
600        // may be a recurrent one.
601        let attention = |neox: bool, output_gate: bool, at: usize| -> Result<Attention, SpecError> {
602            Ok(Attention {
603                heads,
604                kv_heads,
605                head_dim,
606                value_dim,
607                rope: Some(rope(neox)?),
608                qk_norm: g.has(&format!("blk.{at}.attn_q_norm.weight")),
609                output_gate,
610                fused_qkv: g.has(&format!("blk.{at}.attn_qkv.weight")),
611                bias: g.has(&format!("blk.{at}.attn_q.bias")),
612                output_bias: g.has(&format!("blk.{at}.attn_output.bias")),
613                window: None,
614                sinks: false,
615                softcap: None,
616                scale: 1.0 / (head_dim as f64).sqrt(),
617                temperature: None,
618                qk_unit_norm: false,
619                qk_norm_whole: false,
620            })
621        };
622        let dense = |act: Act| -> Result<Ffn, SpecError> {
623            Ok(Ffn::Dense {
624                dff: k.usize("feed_forward_length")?,
625                act,
626            })
627        };
628        // DeepSeek's mixture (llama.cpp `build_moe_ffn` with `exp_probs_b`, `models/deepseek2.cpp` and
629        // `models/glm4-moe.cpp`): experts scored as `expert_gating_func` says (`gating` where it is unset), chosen by
630        // score plus the per-expert bias where the checkpoint carries one and within the best `expert_group_used_count`
631        // of `expert_group_count` groups where there is more than one, the kept weights renormalized as
632        // `expert_weights_norm` says and scaled by `expert_weights_scale`, beside `expert_shared_count` ungated shared
633        // experts as wide as one routed expert each.
634        let routed = |gating: usize| -> Result<Ffn, SpecError> {
635            let gating = match k.usize_or("expert_gating_func", gating) {
636                2 => Gating::Sigmoid { bias: g.has("blk.1.exp_probs_b.bias") || has_any(g, "exp_probs_b.bias") },
637                _ => Gating::Softmax,
638            };
639            let shared = k.usize_or("expert_shared_count", 0);
640            let expert_dff = k.usize("expert_feed_forward_length")?;
641            let groups = match (k.usize_or("expert_group_count", 0), k.usize_or("expert_group_used_count", 0)) {
642                (n, used) if n > 1 => Some((n, used)),
643                _ => None,
644            };
645            Ok(Ffn::Moe(Moe {
646                experts: k.usize("expert_count")?,
647                top_k: k.usize("expert_used_count")?,
648                expert_dff,
649                act: Act::Silu,
650                gating,
651                norm_top_k: k.get("expert_weights_norm").and_then(MetaValue::as_bool).unwrap_or(false),
652                weights_scale: k.f64_or("expert_weights_scale", 1.0),
653                shared_experts: shared,
654                shared_dff: expert_dff * shared,
655                shared_gate: false,
656                biases: false,
657                groups,
658            }))
659        };
660        // Qwen's mixture (llama.cpp `models/qwen2moe.cpp`, `models/qwen35moe.cpp`): a softmax router whose top-k weights
661        // are renormalized or kept as they are by architecture, each expert as wide as `expert_feed_forward_length`
662        // (block 0's of a per-block array, as llama.cpp reads it for every block; unset or 0, the FFN length over the
663        // experts a token takes), beside one shared expert as wide as `expert_shared_feed_forward_length` (unset or 0, the
664        // FFN length) scaled by `σ(x·g)`. llama.cpp reads no routed scale for it.
665        let qwen_mixture = |renormalized: bool| -> Result<Ffn, SpecError> {
666            let first = |suffix: &str| {
667                k.get(suffix)
668                    .and_then(|v| v.as_u64().or_else(|| v.as_array().and_then(|a| a.first()).and_then(MetaValue::as_u64)))
669                    .filter(|&w| w > 0)
670                    .map(|w| w as usize)
671            };
672            let experts = k.usize("expert_count")?;
673            let top_k = k.usize("expert_used_count")?;
674            if experts == 0 || top_k == 0 {
675                return Err(SpecError::Inconsistent(format!("{experts} experts, {top_k} a token: {arch} routes to at least one")));
676            }
677            let ff = || k.usize("feed_forward_length");
678            Ok(Ffn::Moe(Moe {
679                experts,
680                top_k,
681                expert_dff: match first("expert_feed_forward_length") {
682                    Some(w) => w,
683                    None => ff()? / top_k,
684                },
685                act: Act::Silu,
686                gating: Gating::Softmax,
687                norm_top_k: renormalized,
688                weights_scale: 1.0,
689                shared_experts: 1,
690                shared_dff: match first("expert_shared_feed_forward_length") {
691                    Some(w) => w,
692                    None => ff()?,
693                },
694                shared_gate: true,
695                biases: false,
696                groups: None,
697            }))
698        };
699        let layers: Vec<Layer> = match arch.as_str() {
700            // llama.cpp `models/qwen3.cpp`, `models/qwen2.cpp`, `models/llama.cpp`: attention with NEOX (Qwen) or
701            // interleaved (Llama) rotation — Qwen2's with biased projections, read off its tensors — and a SiLU-gated
702            // FFN: dense, or for a Llama checkpoint that counts experts (Mixtral; Qwen's mixtures have arches of their
703            // own) a softmax router whose top-k weights are renormalized, each expert as wide as
704            // `expert_feed_forward_length` or else the FFN length.
705            "qwen3" | "qwen2" | "llama" => {
706                let mixer = Mixer::Attention(attention(arch != "llama", false, 0)?);
707                let ffn = match (arch.as_str(), k.usize_or("expert_count", 0)) {
708                    ("llama", experts) if experts > 0 => Ffn::Moe(Moe {
709                        experts,
710                        top_k: k.usize("expert_used_count")?,
711                        expert_dff: match k.get("expert_feed_forward_length").and_then(MetaValue::as_u64) {
712                            Some(dff) => dff as usize,
713                            None => k.usize("feed_forward_length")?,
714                        },
715                        act: Act::Silu,
716                        gating: Gating::Softmax,
717                        norm_top_k: true,
718                        weights_scale: 1.0,
719                        shared_experts: 0,
720                        shared_dff: 0,
721                        shared_gate: false,
722                        biases: false,
723                        groups: None,
724                    }),
725                    _ => dense(Act::Silu)?,
726                };
727                (0..n_layers)
728                    .map(|_| Layer {
729                        mixer: mixer.clone(),
730                        ffn: ffn.clone(),
731                        post_mixer_norm: false,
732                        post_ffn_norm: false,
733                    })
734                    .collect()
735            }
736            // `models/minimax-m2.cpp`: attention with a partial NEOX rotation whose queries and keys are each normed across
737            // every head at once (`attn_q_norm` as wide as the query projection), and in every block experts scored as
738            // `expert_gating_func` says (MiniMax-M2's sigmoids), chosen by score plus `exp_probs_b` where the checkpoint
739            // carries it, the kept weights renormalized (the graph fixes it) and unscaled; no shared expert.
740            "minimax-m2" => {
741                let mut attn = attention(true, false, 0)?;
742                let width = |name: &str| g.dims(&format!("blk.0.{name}.weight")).and_then(|d| d.first().copied()).map(|w| w as usize);
743                attn.qk_norm_whole = attn.qk_norm && width("attn_q_norm") == Some(heads * head_dim);
744                if attn.qk_norm && !attn.qk_norm_whole && width("attn_q_norm") != Some(head_dim) {
745                    return Err(SpecError::Inconsistent(format!(
746                        "attn_q_norm is {:?} wide, neither a head of {head_dim} nor the {heads} heads' {}",
747                        width("attn_q_norm"),
748                        heads * head_dim
749                    )));
750                }
751                let gating = match k.usize_or("expert_gating_func", 2) {
752                    1 => Gating::Softmax,
753                    2 => Gating::Sigmoid { bias: has_any(g, "exp_probs_b.bias") },
754                    other => {
755                        return Err(SpecError::Inconsistent(format!("expert_gating_func {other} is neither a softmax (1) nor a sigmoid (2)")))
756                    }
757                };
758                let ffn = Ffn::Moe(Moe {
759                    experts: k.usize("expert_count")?,
760                    top_k: k.usize("expert_used_count")?,
761                    expert_dff: k.usize("expert_feed_forward_length")?,
762                    act: Act::Silu,
763                    gating,
764                    norm_top_k: true,
765                    weights_scale: 1.0,
766                    shared_experts: 0,
767                    shared_dff: 0,
768                    shared_gate: false,
769                    biases: false,
770                    groups: None,
771                });
772                (0..n_layers)
773                    .map(|_| Layer {
774                        mixer: Mixer::Attention(attn.clone()),
775                        ffn: ffn.clone(),
776                        post_mixer_norm: false,
777                        post_ffn_norm: false,
778                    })
779                    .collect()
780            }
781            // `models/glm4-moe.cpp`: attention with NEOX rotation over `rope.dimension_count` of each head (half, in
782            // GLM-4.5), its projections biased and its query and key normed per head where the checkpoint carries them; the
783            // first `leading_dense_block_count` blocks a dense SwiGLU, every other DeepSeek's mixture, its experts scored
784            // by sigmoids where `expert_gating_func` is unset. The checkpoint stores the FFN's norm as
785            // `post_attention_norm`, which `model::open_checkpoint` presents as `ffn_norm` (a checkpoint read raw is
786            // refused); the draft blocks after the stack are not read, and GLM-4.5V's multimodal rotation is refused.
787            "glm4moe" => {
788                if has_any(g, "post_attention_norm.weight") && !has_any(g, "ffn_norm.weight") {
789                    return Err(SpecError::Inconsistent(
790                        "a glm4moe checkpoint read raw: its FFN's norm is `post_attention_norm`; `model::open_checkpoint` \
791                         presents it as `ffn_norm`"
792                            .into(),
793                    ));
794                }
795                if sections.is_some() {
796                    return Err(SpecError::Inconsistent("GLM-4.5V's multimodal rotation (`rope.dimension_sections`) is not read yet".into()));
797                }
798                let attn = attention(true, false, 0)?;
799                let moe = routed(2)?;
800                let dense_lead = k.usize_or("leading_dense_block_count", 0);
801                let dense_ffn = dense(Act::Silu)?;
802                (0..n_layers)
803                    .map(|l| Layer {
804                        mixer: Mixer::Attention(attn.clone()),
805                        ffn: if l < dense_lead { dense_ffn.clone() } else { moe.clone() },
806                        post_mixer_norm: false,
807                        post_ffn_norm: false,
808                    })
809                    .collect()
810            }
811            // `models/phi3.cpp`: attention with NEOX rotation over `rope.dimension_count` of each head, its query, key and
812            // value one projection or three, a window where `attention.sliding_window` is short of the context (0 for
813            // none, Phi-4's), and a dense SwiGLU. The checkpoint keeps the gate and up projections as one `ffn_up`, which
814            // `model::open_checkpoint` presents apart; a checkpoint read raw is refused, and so are LongRoPE's factor
815            // tables (Phi-3.5 and Phi-4-mini), which are not read yet.
816            "phi3" => {
817                if has_any(g, "ffn_up.weight") && !has_any(g, "ffn_gate.weight") {
818                    return Err(SpecError::Inconsistent(
819                        "a phi3 checkpoint read raw: its gate and up projections are one `ffn_up`; `model::open_checkpoint` \
820                         presents them apart"
821                            .into(),
822                    ));
823                }
824                if g.has("rope_factors_long.weight") || g.has("rope_factors_short.weight") {
825                    return Err(SpecError::Inconsistent(
826                        "LongRoPE's factor tables (`rope_factors_long`, `rope_factors_short`) are not read yet".into(),
827                    ));
828                }
829                let mut attn = attention(true, false, 0)?;
830                let window = k.usize_or("attention.sliding_window", 0);
831                if window > 0 && window < k.usize_or("context_length", usize::MAX) {
832                    attn.window = Some(Window::Sliding(window));
833                }
834                let ffn = dense(Act::Silu)?;
835                (0..n_layers)
836                    .map(|_| Layer {
837                        mixer: Mixer::Attention(attn.clone()),
838                        ffn: ffn.clone(),
839                        post_mixer_norm: false,
840                        post_ffn_norm: false,
841                    })
842                    .collect()
843            }
844            // `models/glm4.cpp`: attention with interleaved rotation over `rope.dimension_count` of each head (half, in
845            // GLM-4), its projections biased where the checkpoint carries biases, a norm on the attention's output and on
846            // the FFN's (`post_attention_norm`, `post_ffw_norm`) before each residual add, and a dense SwiGLU whose gate and
847            // up projections the checkpoint keeps as one `ffn_up` (`model::layout` presents them apart; read raw, refused).
848            // GLM-4V's multimodal rotation is refused by name.
849            "glm4" => {
850                if has_any(g, "ffn_up.weight") && !has_any(g, "ffn_gate.weight") {
851                    return Err(SpecError::Inconsistent(
852                        "a glm4 checkpoint read raw: its gate and up projections are one `ffn_up`; `model::open_checkpoint` \
853                         presents them apart"
854                            .into(),
855                    ));
856                }
857                if sections.is_some() {
858                    return Err(SpecError::Inconsistent("GLM-4V's multimodal rotation (`rope.dimension_sections`) is not read yet".into()));
859                }
860                let attn = attention(false, false, 0)?;
861                let ffn = dense(Act::Silu)?;
862                let (post_mixer_norm, post_ffn_norm) = (has_any(g, "post_attention_norm.weight"), has_any(g, "post_ffw_norm.weight"));
863                (0..n_layers)
864                    .map(|_| Layer { mixer: Mixer::Attention(attn.clone()), ffn: ffn.clone(), post_mixer_norm, post_ffn_norm })
865                    .collect()
866            }
867            // `models/qwen3moe.cpp`: Qwen3 attention, a softmax router whose top-k weights are renormalized.
868            "qwen3moe" => {
869                let mixer = Mixer::Attention(attention(true, false, 0)?);
870                let ffn = Ffn::Moe(Moe {
871                    experts: k.usize("expert_count")?,
872                    top_k: k.usize("expert_used_count")?,
873                    expert_dff: k.usize("expert_feed_forward_length")?,
874                    act: Act::Silu,
875                    gating: Gating::Softmax,
876                    norm_top_k: true,
877                    weights_scale: 1.0,
878                    shared_experts: 0,
879                    shared_dff: 0,
880                    shared_gate: false,
881                    biases: false,
882                    groups: None,
883                });
884                (0..n_layers)
885                    .map(|_| Layer {
886                        mixer: mixer.clone(),
887                        ffn: ffn.clone(),
888                        post_mixer_norm: false,
889                        post_ffn_norm: false,
890                    })
891                    .collect()
892            }
893            // `models/qwen2moe.cpp`: Qwen2's attention (NEOX, its biased projections read off its tensors) and in every
894            // block Qwen's mixture, its kept softmax weights as they are.
895            "qwen2moe" => {
896                let mixer = Mixer::Attention(attention(true, false, 0)?);
897                let ffn = qwen_mixture(false)?;
898                (0..n_layers)
899                    .map(|_| Layer {
900                        mixer: mixer.clone(),
901                        ffn: ffn.clone(),
902                        post_mixer_norm: false,
903                        post_ffn_norm: false,
904                    })
905                    .collect()
906            }
907            // `models/qwen35.cpp`, `models/qwen35moe.cpp`, `models/qwen3next.cpp`: gated attention (partial NEOX
908            // rotation, Qwen3.5's with IMROPE sections, a sigmoid output gate) where `attention.recurrent_layers` says a
909            // block is not recurrent, else every `full_attention_interval`-th block (default 4); Gated DeltaNet
910            // elsewhere; a dense SiLU FFN everywhere, or for `qwen35moe` and `qwen3next` Qwen's mixture with its kept
911            // softmax weights renormalized. A Qwen3-Next checkpoint is read as `model::layout` presents it, in Qwen3.5's
912            // tiled layout; one still holding its grouped layout's `ssm_ba` is refused.
913            // `models/qwen4exp.cpp` reads Qwen3.5's hybrid with Gated DeltaNet's gate the sigmoid, Qwen's mixture in
914            // every block, and hyper-connections, QSA and the per-layer embedding read below.
915            "qwen35" | "qwen35moe" | "qwen3next" | "qwen4exp" => {
916                if arch == "qwen3next" && has_any(g, "ssm_ba.weight") {
917                    return Err(SpecError::Inconsistent(
918                        "a Qwen3-Next checkpoint read raw: its value heads are grouped by key head and its betas and alphas \
919                         one tensor (`ssm_ba`); `model::open_checkpoint` presents it in Qwen3.5's tiled layout"
920                            .into(),
921                    ));
922                }
923                let recurrent: Vec<bool> = match k.get("attention.recurrent_layers").and_then(MetaValue::as_array) {
924                    Some(a) => a
925                        .iter()
926                        .take(n_layers)
927                        .map(|v| v.as_bool().or_else(|| v.as_u64().map(|x| x != 0)))
928                        .collect::<Option<_>>()
929                        .ok_or_else(|| SpecError::MissingKey(k.key("attention.recurrent_layers")))?,
930                    None => {
931                        let every = k.usize_or("full_attention_interval", 4);
932                        (0..n_layers).map(|l| (l + 1) % every != 0).collect()
933                    }
934                };
935                let gdn = GatedDelta {
936                    k_heads: k.usize("ssm.group_count")?,
937                    v_heads: k.usize("ssm.time_step_rank")?,
938                    head_dim: k.usize("ssm.state_size")?,
939                    conv_taps: k.usize("ssm.conv_kernel")?,
940                    gate: if arch == "qwen4exp" { GateActivation::Sigmoid } else { GateActivation::Silu },
941                };
942                let inner = k.usize("ssm.inner_size")?;
943                if inner != gdn.v_heads * gdn.head_dim {
944                    return Err(SpecError::Inconsistent(format!(
945                        "ssm.inner_size {inner} is not {} value heads of {}",
946                        gdn.v_heads, gdn.head_dim
947                    )));
948                }
949                let at = recurrent.iter().position(|r| !r).unwrap_or(0);
950                let attn = attention(true, true, at)?;
951                let ffn = if arch == "qwen35" { dense(Act::Silu)? } else { qwen_mixture(true)? };
952                recurrent
953                    .iter()
954                    .map(|&r| Layer {
955                        mixer: if r { Mixer::GatedDelta(gdn.clone()) } else { Mixer::Attention(attn.clone()) },
956                        ffn: ffn.clone(),
957                        post_mixer_norm: false,
958                        post_ffn_norm: false,
959                    })
960                    .collect()
961            }
962            // `models/gemma3.cpp`: sliding-window attention on all but every `sliding_window_pattern`-th block
963            // (default 6), those turning by `rope.freq_base_swa` (default 10000) unscaled; QK-norm; a norm after the
964            // mixer and after the FFN as well as before each; a GELU-gated FFN; the embedding scaled by `√d`.
965            "gemma3" => {
966                let window = k.usize_or("attention.sliding_window", 0);
967                let swa = sliding_layers(&k, n_layers, 6, window)?;
968                let global = attention(true, false, 0)?;
969                let scale = if n_layers == 62 { 1.0 / ((d / heads) as f64).sqrt() } else { 1.0 / (head_dim as f64).sqrt() };
970                let local = Attention {
971                    window: Some(Window::Sliding(window)),
972                    rope: Some(Rope {
973                        theta: k.f64_or("rope.freq_base_swa", 10000.0),
974                        scaling: RopeScaling::None,
975                        ..global.rope.clone().expect("gemma3 rotates")
976                    }),
977                    scale,
978                    ..global.clone()
979                };
980                let global = Attention { scale, ..global };
981                let ffn = dense(Act::GeluTanh)?;
982                swa.iter()
983                    .map(|&s| Layer {
984                        mixer: Mixer::Attention(if s { local.clone() } else { global.clone() }),
985                        ffn: ffn.clone(),
986                        post_mixer_norm: true,
987                        post_ffn_norm: true,
988                    })
989                    .collect()
990            }
991            // `models/gemma.cpp`: one rotation, the scores scaled by `1/√head_dim`, a GELU-gated FFN, the embedding scaled
992            // by `√d`.
993            "gemma" => {
994                let attn = attention(true, false, 0)?;
995                let ffn = dense(Act::GeluTanh)?;
996                (0..n_layers)
997                    .map(|_| Layer { mixer: Mixer::Attention(attn.clone()), ffn: ffn.clone(), post_mixer_norm: false, post_ffn_norm: false })
998                    .collect()
999            }
1000            // `models/gemma2.cpp`: a sliding window (`attention.sliding_window`, default 4096) on all but every
1001            // `sliding_window_pattern`-th block (default 2: the first block local), one rotation, each score capped at
1002            // `attn_logit_softcapping` (default 50) and the logits at `final_logit_softcapping` (default 30), a norm
1003            // after the mixer and after the FFN as well as before each, a GELU-gated FFN, the embedding scaled by `√d`;
1004            // the scores scaled by `1/√(d/heads)` at 27B's 46 blocks, else by `1/√head_dim`.
1005            "gemma2" => {
1006                let window = k.usize_or("attention.sliding_window", 4096);
1007                let swa = sliding_layers(&k, n_layers, 2, window)?;
1008                let scale = if n_layers == 46 { 1.0 / ((d / heads) as f64).sqrt() } else { 1.0 / (head_dim as f64).sqrt() };
1009                let softcap = Some(k.f64_or("attn_logit_softcapping", 50.0));
1010                let global = Attention { scale, softcap, ..attention(true, false, 0)? };
1011                let local = Attention { window: Some(Window::Sliding(window)), ..global.clone() };
1012                let ffn = dense(Act::GeluTanh)?;
1013                swa.iter()
1014                    .map(|&s| Layer {
1015                        mixer: Mixer::Attention(if s { local.clone() } else { global.clone() }),
1016                        ffn: ffn.clone(),
1017                        post_mixer_norm: true,
1018                        post_ffn_norm: true,
1019                    })
1020                    .collect()
1021            }
1022            // `models/openai-moe.cpp`: attention with biases and a learned sink per head, a 128-token window on every
1023            // other block (pattern 2) under one YaRN rotation, and a biased mixture of experts whose top-k router
1024            // logits are softmaxed after selection, each expert a clamped SwiGLU.
1025            "gpt-oss" => {
1026                let window = k.usize("attention.sliding_window")?;
1027                let every = k.usize_or("attention.sliding_window_pattern", 2);
1028                let attn = Attention { sinks: true, ..attention(true, false, 0)? };
1029                let ffn = Ffn::Moe(Moe {
1030                    experts: k.usize("expert_count")?,
1031                    top_k: k.usize("expert_used_count")?,
1032                    expert_dff: k.usize("expert_feed_forward_length")?,
1033                    act: Act::SwigluOai { alpha: 1.702, limit: 7.0 },
1034                    gating: Gating::SoftmaxOfTopK,
1035                    norm_top_k: false,
1036                    weights_scale: 1.0,
1037                    shared_experts: 0,
1038                    shared_dff: 0,
1039                    shared_gate: false,
1040                    biases: g.has("blk.0.ffn_gate_inp.bias"),
1041                    groups: None,
1042                });
1043                (0..n_layers)
1044                    .map(|l| Layer {
1045                        mixer: Mixer::Attention(Attention {
1046                            window: (every == 0 || l % every < every - 1).then_some(Window::Sliding(window)),
1047                            ..attn.clone()
1048                        }),
1049                        ffn: ffn.clone(),
1050                        post_mixer_norm: false,
1051                        post_ffn_norm: false,
1052                    })
1053                    .collect()
1054            }
1055            // `models/deepseek2.cpp`: multi-head latent attention with interleaved rotation; the first
1056            // `leading_dense_block_count` blocks a dense SiLU FFN, the rest a routed mixture with shared experts —
1057            // softmax routing (V2) or sigmoid with a selection bias and grouped choice (V3), per
1058            // `expert_gating_func`.
1059            "deepseek2" => {
1060                let rope_dims = k.usize("rope.dimension_count")?;
1061                let key = k.usize_or("attention.key_length_mla", head_dim);
1062                let rope = Rope {
1063                    dims: rope_dims,
1064                    theta: k.f64_or("rope.freq_base", 10000.0),
1065                    neox: false,
1066                    scaling: rope_scaling(&k, g)?,
1067                    sections: None,
1068                };
1069                // llama.cpp pre-scales the scores by YaRN's magnitude (its log multiplier, stored as a tenth) squared.
1070                let mscale = match rope.scaling {
1071                    RopeScaling::Yarn { factor, magnitude, .. } => {
1072                        let log_mul = k.f64_or("rope.scaling.yarn_log_multiplier", 0.0) / 0.1;
1073                        magnitude * (1.0 + 0.1 * log_mul * factor.ln())
1074                    }
1075                    _ => 1.0,
1076                };
1077                let mla = Mla {
1078                    heads,
1079                    q_lora_rank: k.get("attention.q_lora_rank").and_then(MetaValue::as_u64).map(|v| v as usize),
1080                    kv_lora_rank: k.usize("attention.kv_lora_rank")?,
1081                    qk_nope: key - rope_dims,
1082                    qk_rope: rope_dims,
1083                    v_dim: k.usize_or("attention.value_length_mla", value_dim),
1084                    rope,
1085                    scale: mscale * mscale / (key as f64).sqrt(),
1086                    kv_b_split: g.has("blk.0.attn_k_b.weight"),
1087                };
1088                let moe = routed(1)?;
1089                let dense_lead = k.usize_or("leading_dense_block_count", 0);
1090                let dense_ffn = dense(Act::Silu)?;
1091                (0..n_layers)
1092                    .map(|l| Layer {
1093                        mixer: Mixer::Mla(mla.clone()),
1094                        ffn: if l < dense_lead { dense_ffn.clone() } else { moe.clone() },
1095                        post_mixer_norm: false,
1096                        post_ffn_norm: false,
1097                    })
1098                    .collect()
1099            }
1100            // `models/llama4.cpp`: three layers in chunks of 8192 positions, rotated (interleaved pairs, the checkpoint's
1101            // frequency factors) with a weightless QK norm unless the model has 128 experts, then one full layer with no
1102            // rotation whose queries take the attention temperature (floor 8192, scale 0.1) — llama.cpp's constants, which
1103            // no key restates. `attention.sliding_window` 0 turns the chunks off: every layer rotates but the last (the
1104            // no-rotation step is the layer count) and no temperature applies.
1105            // Every `interleave_moe_layer_step`-th block is a mixture whose top-k logits' sigmoids scale their experts'
1106            // inputs, beside a shared expert as wide as one; every other block a dense SiLU FFN.
1107            "llama4" => {
1108                let experts = k.usize("expert_count")?;
1109                let chunked = k.get("attention.sliding_window").and_then(MetaValue::as_u64) != Some(0);
1110                let step = k.usize("interleave_moe_layer_step")?;
1111                let expert_dff = k.usize("expert_feed_forward_length")?;
1112                let base = attention(false, false, 0)?;
1113                let rotated = Attention {
1114                    window: chunked.then_some(Window::Chunked(8192)),
1115                    qk_unit_norm: experts != 128,
1116                    qk_norm_whole: false,
1117                    ..base.clone()
1118                };
1119                let full = Attention {
1120                    rope: None,
1121                    temperature: chunked.then_some(Temperature { floor: 8192.0, scale: 0.1 }),
1122                    ..base.clone()
1123                };
1124                let no_rope_step = if chunked { 4 } else { n_layers };
1125                let moe = Ffn::Moe(Moe {
1126                    experts,
1127                    top_k: k.usize("expert_used_count")?,
1128                    expert_dff,
1129                    act: Act::Silu,
1130                    gating: Gating::SigmoidScaledInput,
1131                    norm_top_k: false,
1132                    weights_scale: 1.0,
1133                    shared_experts: 1,
1134                    shared_dff: expert_dff,
1135                    shared_gate: false,
1136                    biases: false,
1137                    groups: None,
1138                });
1139                let dense_ffn = dense(Act::Silu)?;
1140                (0..n_layers)
1141                    .map(|l| Layer {
1142                        mixer: Mixer::Attention(if (l + 1) % no_rope_step == 0 { full.clone() } else { rotated.clone() }),
1143                        ffn: if step > 0 && (l + 1) % step == 0 { moe.clone() } else { dense_ffn.clone() },
1144                        post_mixer_norm: false,
1145                        post_ffn_norm: false,
1146                    })
1147                    .collect()
1148            }
1149            other => return Err(SpecError::UnknownArchitecture(other.to_string())),
1150        };
1151        let ffn_norm =
1152            if matches!(arch.as_str(), "qwen35" | "qwen35moe" | "qwen3next" | "gpt-oss") { "post_attention_norm" } else { "ffn_norm" };
1153        let (hyper, ple, indexer) = if arch == "qwen4exp" { qwen4exp_parts(&k, &layers)? } else { (None, None, None) };
1154        let embedding_scale = matches!(arch.as_str(), "gemma" | "gemma2" | "gemma3").then(|| (d as f64).sqrt());
1155        // Gemma 2's graph caps its logits whatever the file says, at 30 where it says nothing.
1156        let final_logit_softcap = match k.get("final_logit_softcapping").and_then(decimal) {
1157            None if arch == "gemma2" => Some(30.0),
1158            cap => cap,
1159        };
1160        Ok(ModelSpec {
1161            arch,
1162            d,
1163            vocab,
1164            ctx_len: k.usize_or("context_length", 0),
1165            rms_eps: k.f64_or("attention.layer_norm_rms_epsilon", 1e-6),
1166            embedding_scale,
1167            tied_output: !g.has("output.weight"),
1168            final_logit_softcap,
1169            layers,
1170            mtp_blocks,
1171            ffn_norm,
1172            hyper,
1173            ple,
1174            indexer,
1175        })
1176    }
1177
1178    /// The indices of the attention layers.
1179    pub fn attention_layers(&self) -> Vec<usize> {
1180        self.layers
1181            .iter()
1182            .enumerate()
1183            .filter(|(_, l)| matches!(l.mixer, Mixer::Attention(_)))
1184            .map(|(i, _)| i)
1185            .collect()
1186    }
1187}
1188
1189/// Whether any block holds a tensor named `blk.<n>.<suffix>`.
1190fn has_any<S: SpecSource + ?Sized>(g: &S, suffix: &str) -> bool {
1191    g.tensor_names().iter().any(|t| t.starts_with("blk.") && t.ends_with(suffix))
1192}
1193
1194/// The rotary scaling the checkpoint declares.
1195fn rope_scaling<S: SpecSource + ?Sized>(k: &Keys<S>, g: &S) -> Result<RopeScaling, SpecError> {
1196    if g.has("rope_freqs.weight") {
1197        return Ok(RopeScaling::Factors);
1198    }
1199    let kind = k.get("rope.scaling.type").and_then(MetaValue::as_str).unwrap_or("none");
1200    Ok(match kind {
1201        "none" => RopeScaling::None,
1202        "linear" => RopeScaling::Linear {
1203            factor: k.f64("rope.scaling.factor")?,
1204        },
1205        "yarn" => {
1206            let factor = k.f64("rope.scaling.factor")?;
1207            // llama-context.cpp: `get_mscale(f, m) = 0.1·m·ln f + 1`; with a log multiplier the ratio of two of them
1208            // (DeepSeek V2 takes the multiplier for both when it is not 1), else `get_mscale(f, 1)`.
1209            let get_mscale = |m: f64| if factor <= 1.0 { 1.0 } else { 0.1 * m * factor.ln() + 1.0 };
1210            let log_mul = k.f64_or("rope.scaling.yarn_log_multiplier", 0.0);
1211            let ratio = if log_mul != 0.0 {
1212                let m = if k.g.arch() == "deepseek2" && log_mul != 1.0 { log_mul } else { 1.0 };
1213                get_mscale(m) / get_mscale(log_mul)
1214            } else {
1215                get_mscale(1.0)
1216            };
1217            RopeScaling::Yarn {
1218                factor,
1219                original_ctx: k.usize("rope.scaling.original_context_length")?,
1220                beta_fast: k.f64_or("rope.scaling.yarn_beta_fast", 32.0),
1221                beta_slow: k.f64_or("rope.scaling.yarn_beta_slow", 1.0),
1222                magnitude: ratio * k.f64_or("rope.scaling.attn_factor", 1.0),
1223            }
1224        }
1225        other => return Err(SpecError::MissingKey(format!("rope.scaling.type `{other}` is not described yet"))),
1226    })
1227}