Skip to main content

kopitiam_runtime/
config.rs

1//! Architecture hyperparameters for a Qwen-family (LLaMA-shaped) decoder-only
2//! transformer, resolved from [`kopitiam_loader::ModelMetadata`].
3//!
4//! `ModelMetadata` deliberately leaves every field `Option` — it has to
5//! represent both a fully-specified GGUF export and a bare SafeTensors dump
6//! with no architecture metadata at all (see that type's docs). This module
7//! is where "this field is missing" becomes either a documented fallback
8//! (RoPE theta, RoPE dimension count, normalization epsilon: every Qwen/
9//! LLaMA checkpoint agrees on these defaults closely enough that guessing
10//! is safer than refusing to load) or a hard error (layer count, head
11//! counts, hidden size, vocab size: guessing any of these wrong does not
12//! fail to load, it loads a model that computes silent nonsense, which is
13//! strictly worse than refusing).
14
15use crate::rope::RopeKind;
16use kopitiam_core::{Error, Result};
17use kopitiam_loader::ModelMetadata;
18
19/// Architectures this crate's forward pass genuinely executes.
20///
21/// # Why a whitelist and not "try it and see"
22///
23/// This crate implements exactly ONE forward pass: pre-norm residual blocks,
24/// RMSNorm, rotary position embeddings, grouped-query attention, SwiGLU MLP.
25/// Point it at a Gemma, Phi or MoE GGUF and the tensor names may well resolve,
26/// a model may well build, and generation may well start — producing confident
27/// nonsense with no hint the weights were never runnable.
28///
29/// That is not hypothetical. `bd-b9x` was a *subtler version of the same
30/// failure*: a `llama`-architecture file running with `qwen2`'s RoPE convention
31/// produced fluent, plausible, wrong text for weeks. Silent wrongness is worse
32/// than a crash, and an architecture the runtime has never executed is the
33/// easiest way to get it.
34///
35/// # Why this list is short
36///
37/// It names what has actually been *run*, not what looks structurally similar:
38///
39/// * `llama` — verified end to end on real weights (SmolLM2-360M and -1.7B),
40///   with output diffed against `llama.cpp` (`docs/REFERENCE-ORACLE.md`).
41/// * `qwen2` — the family this crate was built and unit-tested against.
42///
43/// Mistral, and most "llama-shaped" derivatives, ship `general.architecture =
44/// "llama"` and so are already covered. Anything else should be added only
45/// after a real model of that architecture has been run and compared — adding a
46/// name because the shape looks right is precisely the reasoning that produced
47/// `bd-b9x`.
48const SUPPORTED_ARCHITECTURES: &[&str] = &["llama", "qwen2"];
49
50/// Whether this crate can actually execute `architecture`.
51///
52/// `None` (metadata absent, e.g. a bare SafeTensors dump) is treated as
53/// **unsupported**: with no architecture there is no way to know the RoPE
54/// convention, and guessing is the bug this gate exists to prevent.
55#[must_use]
56pub fn is_supported_architecture(architecture: Option<&str>) -> bool {
57    architecture.is_some_and(|a| SUPPORTED_ARCHITECTURES.contains(&a))
58}
59
60/// Maps a GGUF `general.architecture` string to the RoPE pairing its files
61/// were written for, mirroring `llama.cpp`'s `llama_model_rope_type`
62/// (`src/llama-model.cpp`).
63///
64/// The split is not intuitive and cost this project a long debugging session
65/// (`bd-b9x`): **`llama` is interleaved, `qwen2` is split-half**, even though
66/// HuggingFace's `LlamaAttention` uses split-half in Python — GGUF conversion
67/// permutes Q/K so ggml's interleaved rotation reproduces it. See
68/// [`crate::rope`]'s module docs for the full story.
69///
70/// Unknown architectures fall back to [`RopeKind::SplitHalf`], which is this
71/// crate's historical behaviour and correct for the Qwen family it was first
72/// built against. That default is a guess, and a wrong guess here is silent —
73/// so anything newly supported should be added here deliberately, checked
74/// against `llama_model_rope_type` rather than assumed.
75#[must_use]
76pub fn rope_kind_for_architecture(architecture: Option<&str>) -> RopeKind {
77    match architecture.unwrap_or_default() {
78        // ggml's NORM group ("pairs of consecutive head values").
79        "llama" | "llama4" | "deci" | "baichuan" | "starcoder" | "internlm2" | "minicpm"
80        | "xverse" | "command-r" | "cohere2" | "olmo" | "arctic" | "deepseek" | "deepseek2" => {
81            RopeKind::Interleaved
82        }
83        // ggml's NEOX group.
84        "qwen2" | "qwen2moe" | "qwen3" | "qwen3moe" | "phi2" | "phi3" | "gemma" | "gemma2"
85        | "gemma3" | "stablelm" | "olmo2" | "gptneox" => RopeKind::SplitHalf,
86        _ => RopeKind::SplitHalf,
87    }
88}
89
90/// `format` tag used on every [`Error::MalformedModel`] this module raises,
91/// so a config-resolution failure is distinguishable from a GGUF/SafeTensors
92/// parse failure raised by `kopitiam-loader` itself (which always tags its
93/// errors `"gguf"` or `"safetensors"`).
94const FORMAT: &str = "qwen-config";
95
96/// The resolved shape of a Qwen-family transformer: everything the forward
97/// pass needs to know about the model it is about to run, with no further
98/// `Option`s or metadata lookups once construction succeeds.
99///
100/// # Why "Qwen-family" and not "generic transformer config"
101///
102/// This struct encodes one specific architecture family: pre-norm residual
103/// blocks, RMSNorm, rotary position embeddings, grouped-query attention, and
104/// a SwiGLU MLP — the LLaMA-derived shape that Qwen, LLaMA itself, Mistral,
105/// and most current open decoder-only models share. It is not an attempt at
106/// a universal config covering encoder-decoder models, ALiBi, or MoE
107/// routing; those are different forward passes, not different values of the
108/// same fields, and would be a new config type (and a new [`crate::Model`]
109/// impl) rather than more `Option`s bolted onto this one.
110#[derive(Debug, Clone, PartialEq)]
111pub struct QwenConfig {
112    /// Number of transformer blocks.
113    pub n_layers: usize,
114    /// Number of query attention heads.
115    pub n_heads: usize,
116    /// Number of key/value attention heads. Equal to `n_heads` for ordinary
117    /// multi-head attention; smaller under grouped-query attention (Qwen2's
118    /// usual configuration), where each KV head is shared by
119    /// `n_heads / n_kv_heads` query heads. See [`crate::attention`].
120    pub n_kv_heads: usize,
121    /// The model's hidden/embedding width (`d_model`).
122    pub hidden_size: usize,
123    /// Width of one attention head: `hidden_size / n_heads`. Not an
124    /// independent metadata field — GGUF does not record it separately —
125    /// but derived here once so every consumer agrees on it.
126    pub head_dim: usize,
127    /// Width of the SwiGLU MLP's gate/up projections.
128    pub ffn_hidden_size: usize,
129    /// Vocabulary size — the row count of the token embedding table.
130    pub vocab_size: usize,
131    /// The context window this model's KV cache should be sized for.
132    pub max_context: usize,
133    /// RoPE base frequency (`theta`). Defaults to `10000.0`, the value
134    /// every LLaMA/Qwen checkpoint has shipped with to date, when the
135    /// metadata omits `rope.freq_base`.
136    pub rope_theta: f32,
137    /// Number of leading dimensions of each head RoPE actually rotates.
138    /// Defaults to `head_dim` (full rotary) when the metadata omits
139    /// `rope.dimension_count` — which is what every current Qwen2/LLaMA
140    /// GGUF export does, since full rotary is their only configuration; the
141    /// field exists in the format for architectures (e.g. GPT-NeoX-style
142    /// partial rotary) that rotate only a prefix of each head.
143    pub rope_dimension_count: usize,
144    /// Which RoPE pairing this architecture's GGUF files were written for.
145    ///
146    /// Derived from `general.architecture`, mirroring `llama.cpp`'s
147    /// `llama_model_rope_type` — see [`rope_kind_for_architecture`] and
148    /// [`crate::rope::RopeKind`]. Guessing this wrong does not fail; it
149    /// silently degrades output more and more as the context grows, which is
150    /// what `bd-b9x` turned out to be.
151    pub rope_kind: RopeKind,
152    /// RMSNorm epsilon. Defaults to `1e-6`, the value every current Qwen2
153    /// checkpoint uses, when the metadata omits both
154    /// `attention.layer_norm_rms_epsilon` and `attention.layer_norm_epsilon`.
155    pub norm_eps: f32,
156}
157
158impl QwenConfig {
159    /// Resolves a [`QwenConfig`] from a loaded model's metadata.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`Error::MalformedModel`] if any field with no safe default
164    /// (layer count, either head count, hidden size, feed-forward size, or
165    /// vocab size) is absent, or if `n_heads` does not evenly divide
166    /// `hidden_size` (which would make `head_dim` — and therefore every
167    /// weight matrix shape this crate assumes — ill-defined), or if
168    /// `n_kv_heads` does not evenly divide `n_heads` (which would make the
169    /// grouped-query head-repeat in [`crate::attention`] ill-defined).
170    pub fn from_metadata(metadata: &ModelMetadata) -> Result<Self> {
171        let n_layers = require(metadata.n_layers, "block_count")?;
172        let n_heads = require(metadata.n_heads, "attention.head_count")? as usize;
173        // GGUF's own convention (see kopitiam_loader::ModelMetadata::n_kv_heads
174        // docs): an absent head_count_kv means "equal to n_heads", i.e.
175        // ordinary multi-head attention rather than GQA. That fallback is a
176        // modeling decision the loader deliberately leaves to its consumer;
177        // this is where it is made.
178        let n_kv_heads = metadata.n_kv_heads.map(|v| v as usize).unwrap_or(n_heads);
179        let hidden_size = require(metadata.embedding_length, "embedding_length")? as usize;
180        let ffn_hidden_size = require(metadata.feed_forward_length, "feed_forward_length")?;
181        let vocab_size = require(metadata.vocab_size, "vocab_size (tokenizer.ggml.tokens length)")?;
182        let max_context = metadata.context_length.map(|v| v as usize).unwrap_or(4096);
183
184        if n_heads == 0 || !hidden_size.is_multiple_of(n_heads) {
185            return Err(malformed(format!(
186                "attention.head_count ({n_heads}) must evenly divide embedding_length ({hidden_size})"
187            )));
188        }
189        let head_dim = hidden_size / n_heads;
190
191        if n_kv_heads == 0 || !n_heads.is_multiple_of(n_kv_heads) {
192            return Err(malformed(format!(
193                "attention.head_count_kv ({n_kv_heads}) must evenly divide attention.head_count ({n_heads})"
194            )));
195        }
196
197        // Refuse an architecture this crate has never executed, BEFORE building
198        // anything. See `SUPPORTED_ARCHITECTURES`: a model that loads and then
199        // generates nonsense is strictly worse than one that refuses to load,
200        // and `kopitiam-ai`'s adapter already turns this `Err` into a clear
201        // user-facing fallback note.
202        let architecture = metadata.architecture.as_deref();
203        if !is_supported_architecture(architecture) {
204            return Err(malformed(format!(
205                "unsupported general.architecture {:?}: this runtime implements the dense \
206                 pre-norm RoPE/GQA/SwiGLU forward pass only, and executes {SUPPORTED_ARCHITECTURES:?}. \
207                 Running other architectures on it produces confident nonsense rather than an \
208                 error, so it is refused here instead",
209                architecture.unwrap_or("<absent>")
210            )));
211        }
212        let rope_kind = rope_kind_for_architecture(architecture);
213        let rope_theta = metadata.rope_theta.unwrap_or(10_000.0);
214        let rope_dimension_count = metadata
215            .rope_dimension_count
216            .map(|v| v as usize)
217            .unwrap_or(head_dim);
218        if rope_dimension_count == 0 || rope_dimension_count > head_dim {
219            return Err(malformed(format!(
220                "rope.dimension_count ({rope_dimension_count}) must be in 1..=head_dim ({head_dim})"
221            )));
222        }
223        // RoPE pairs dimension i with i + rope_dimension_count/2 (see
224        // crate::rope), so an odd rotary width has no valid pairing.
225        if !rope_dimension_count.is_multiple_of(2) {
226            return Err(malformed(format!(
227                "rope.dimension_count ({rope_dimension_count}) must be even: RoPE pairs dimension i with i + count/2"
228            )));
229        }
230
231        let norm_eps = metadata.norm_epsilon.unwrap_or(1e-6);
232
233        Ok(Self {
234            n_layers: n_layers as usize,
235            n_heads,
236            n_kv_heads,
237            hidden_size,
238            head_dim,
239            ffn_hidden_size: ffn_hidden_size as usize,
240            vocab_size: vocab_size as usize,
241            max_context,
242            rope_theta,
243            rope_dimension_count,
244            rope_kind,
245            norm_eps,
246        })
247    }
248
249    /// Query heads sharing each key/value head: `n_heads / n_kv_heads`.
250    /// `1` for ordinary multi-head attention, `> 1` under GQA.
251    pub fn gqa_group_size(&self) -> usize {
252        self.n_heads / self.n_kv_heads
253    }
254}
255
256fn require(field: Option<u64>, key: &str) -> Result<u64> {
257    field.ok_or_else(|| malformed(format!("missing required metadata field \"{key}\"")))
258}
259
260fn malformed(reason: String) -> Error {
261    Error::MalformedModel { format: FORMAT, reason }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn base_metadata() -> ModelMetadata {
269        ModelMetadata {
270            // Every fixture needs a supported architecture now: an absent one
271            // means the RoPE convention is unknowable, which `from_metadata`
272            // refuses rather than guesses. See `SUPPORTED_ARCHITECTURES`.
273            architecture: Some("llama".to_string()),
274            n_layers: Some(2),
275            n_heads: Some(4),
276            n_kv_heads: Some(2),
277            embedding_length: Some(16),
278            feed_forward_length: Some(32),
279            context_length: Some(512),
280            vocab_size: Some(100),
281            ..Default::default()
282        }
283    }
284
285    #[test]
286    fn resolves_a_complete_config_with_explicit_values() {
287        let mut metadata = base_metadata();
288        metadata.rope_theta = Some(1_000_000.0);
289        metadata.rope_dimension_count = Some(4);
290        metadata.norm_epsilon = Some(1e-5);
291
292        let config = QwenConfig::from_metadata(&metadata).unwrap();
293        assert_eq!(config.n_layers, 2);
294        assert_eq!(config.n_heads, 4);
295        assert_eq!(config.n_kv_heads, 2);
296        assert_eq!(config.hidden_size, 16);
297        assert_eq!(config.head_dim, 4);
298        assert_eq!(config.ffn_hidden_size, 32);
299        assert_eq!(config.vocab_size, 100);
300        assert_eq!(config.max_context, 512);
301        assert_eq!(config.rope_theta, 1_000_000.0);
302        assert_eq!(config.rope_dimension_count, 4);
303        assert_eq!(config.norm_eps, 1e-5);
304        assert_eq!(config.gqa_group_size(), 2);
305    }
306
307    #[test]
308    fn missing_n_kv_heads_falls_back_to_ordinary_multi_head_attention() {
309        let mut metadata = base_metadata();
310        metadata.n_kv_heads = None;
311        let config = QwenConfig::from_metadata(&metadata).unwrap();
312        assert_eq!(config.n_kv_heads, config.n_heads);
313        assert_eq!(config.gqa_group_size(), 1);
314    }
315
316    #[test]
317    fn missing_rope_and_norm_fields_use_documented_defaults() {
318        let metadata = base_metadata();
319        let config = QwenConfig::from_metadata(&metadata).unwrap();
320        assert_eq!(config.rope_theta, 10_000.0);
321        assert_eq!(config.rope_dimension_count, config.head_dim);
322        assert_eq!(config.norm_eps, 1e-6);
323    }
324
325    #[test]
326    fn missing_required_field_is_rejected() {
327        let mut metadata = base_metadata();
328        metadata.n_layers = None;
329        assert!(matches!(
330            QwenConfig::from_metadata(&metadata),
331            Err(Error::MalformedModel { .. })
332        ));
333    }
334
335    #[test]
336    fn head_count_that_does_not_divide_hidden_size_is_rejected() {
337        let mut metadata = base_metadata();
338        metadata.n_heads = Some(3); // 16 is not divisible by 3
339        assert!(matches!(
340            QwenConfig::from_metadata(&metadata),
341            Err(Error::MalformedModel { .. })
342        ));
343    }
344
345    #[test]
346    fn kv_head_count_that_does_not_divide_head_count_is_rejected() {
347        let mut metadata = base_metadata();
348        metadata.n_kv_heads = Some(3); // 4 heads is not divisible by 3
349        assert!(matches!(
350            QwenConfig::from_metadata(&metadata),
351            Err(Error::MalformedModel { .. })
352        ));
353    }
354
355    #[test]
356    fn odd_rope_dimension_count_is_rejected() {
357        let mut metadata = base_metadata();
358        metadata.rope_dimension_count = Some(3);
359        assert!(matches!(
360            QwenConfig::from_metadata(&metadata),
361            Err(Error::MalformedModel { .. })
362        ));
363    }
364}
365
366#[cfg(test)]
367mod rope_kind_tests {
368    use super::*;
369
370    /// The mapping that `bd-b9x` turned on. `llama` really is interleaved and
371    /// `qwen2` really is split-half, however counter-intuitive that is next to
372    /// HuggingFace's Python (see `crate::rope`'s module docs). Checked against
373    /// `llama.cpp`'s `llama_model_rope_type`, not against intuition.
374    #[test]
375    fn llama_is_interleaved_and_qwen2_is_split_half() {
376        assert_eq!(rope_kind_for_architecture(Some("llama")), RopeKind::Interleaved);
377        assert_eq!(rope_kind_for_architecture(Some("qwen2")), RopeKind::SplitHalf);
378    }
379
380    /// SmolLM2 — the model this bug was found on, and KOPITIAM's default —
381    /// reports `general.architecture = "llama"`, so it must get interleaved.
382    #[test]
383    fn smollm2_reports_llama_and_therefore_gets_interleaved() {
384        assert_eq!(rope_kind_for_architecture(Some("llama")), RopeKind::Interleaved);
385    }
386
387    /// An unknown architecture falls back to split-half. That is a *guess*, and
388    /// a silently-wrong one if the model is really a NORM architecture — which
389    /// is exactly how this bug survived. Pinned so the fallback is a decision
390    /// someone made, not an accident someone inherited.
391    #[test]
392    fn an_unknown_architecture_falls_back_to_split_half() {
393        assert_eq!(rope_kind_for_architecture(None), RopeKind::SplitHalf);
394        assert_eq!(rope_kind_for_architecture(Some("not-a-real-arch")), RopeKind::SplitHalf);
395    }
396}
397
398#[cfg(test)]
399mod architecture_gate_tests {
400    use super::*;
401
402    fn metadata_with(arch: Option<&str>) -> ModelMetadata {
403        ModelMetadata {
404            architecture: arch.map(str::to_string),
405            n_layers: Some(2),
406            n_heads: Some(4),
407            n_kv_heads: Some(2),
408            embedding_length: Some(16),
409            feed_forward_length: Some(32),
410            context_length: Some(512),
411            vocab_size: Some(100),
412            ..Default::default()
413        }
414    }
415
416    /// The two architectures this crate has actually run must keep loading —
417    /// the bead's own "what would make this wrong" is a whitelist so tight it
418    /// locks out models that work today.
419    #[test]
420    fn the_architectures_we_actually_execute_still_load() {
421        for arch in ["llama", "qwen2"] {
422            assert!(
423                QwenConfig::from_metadata(&metadata_with(Some(arch))).is_ok(),
424                "{arch} must still load"
425            );
426        }
427    }
428
429    /// An architecture with a different forward pass must be REFUSED, not run.
430    /// Gemma is the live example: it was the question that opened this bead,
431    /// and running it on this crate's forward pass would emit fluent nonsense.
432    #[test]
433    fn an_architecture_with_a_different_forward_pass_is_refused() {
434        for arch in ["gemma", "gemma3", "phi3", "qwen2moe", "mamba"] {
435            let err = QwenConfig::from_metadata(&metadata_with(Some(arch)))
436                .expect_err("{arch} must be refused");
437            assert!(matches!(err, Error::MalformedModel { .. }), "{arch}: {err:?}");
438        }
439    }
440
441    /// Absent metadata is unsupported, not "probably fine". Without an
442    /// architecture the RoPE convention is unknowable, and guessing it wrong is
443    /// exactly bd-b9x — fluent, confident, wrong.
444    #[test]
445    fn an_absent_architecture_is_refused_rather_than_guessed() {
446        assert!(!is_supported_architecture(None));
447        assert!(QwenConfig::from_metadata(&metadata_with(None)).is_err());
448    }
449
450    /// The refusal has to say what is wrong and what IS supported, or the user
451    /// just sees "malformed" and goes hunting in the wrong place.
452    #[test]
453    fn the_refusal_names_the_architecture_and_the_supported_set() {
454        let err = QwenConfig::from_metadata(&metadata_with(Some("gemma"))).unwrap_err();
455        let text = format!("{err}");
456        assert!(text.contains("gemma"), "should name the offender: {text}");
457        assert!(text.contains("llama") && text.contains("qwen2"), "should name what works: {text}");
458    }
459}