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}