atlas_core/config/
gguf.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Build a [`ModelConfig`] from GGUF file metadata.
4//!
5//! GGUF carries its model config inline as metadata key/values
6//! (`llama.block_count`, `qwen3.attention.head_count`, …) rather than a
7//! sibling `config.json`. This module reads those keys through the
8//! [`GgufMeta`] accessor (implemented by the GGUF parser in spark-runtime, so
9//! atlas-core keeps no GGUF dependency) and produces a validated
10//! [`ModelConfig`] for the llama / qwen2 / qwen3 / gemma decoder families.
11//!
12//! Strategy: synthesize an HF-config-shaped JSON object from the GGUF keys and
13//! deserialize it into `ModelConfig` (serde `#[serde(default)]` fills the many
14//! fields GGUF has no analog for), then set the architecture flags
15//! (`attn_gated`, `weight_prefix`, gemma `embed_scale` /
16//! `final_logit_softcapping`) explicitly, then run the shared
17//! [`super::finalize_config`]. No silent production defaults: every value GGUF
18//! omits is either derived by an explicit documented rule or is an error.
19
20use anyhow::{Context, Result, bail};
21use serde_json::{Map, Value, json};
22
23use super::{ModelConfig, finalize_config};
24
25/// Typed read access to GGUF metadata. Implemented by the spark-runtime GGUF
26/// parser over its parsed key/value table. All getters return `None` when the
27/// key is absent or holds a different value type — the builder decides whether
28/// absence is fatal or has a derivation rule.
29pub trait GgufMeta {
30    /// Any unsigned/signed integer metadata value, widened to u64.
31    fn get_u64(&self, key: &str) -> Option<u64>;
32    /// Any float metadata value (f32/f64), widened to f64.
33    fn get_f64(&self, key: &str) -> Option<f64>;
34    /// A string metadata value.
35    fn get_str(&self, key: &str) -> Option<&str>;
36    /// Length of an array metadata value (e.g. `tokenizer.ggml.tokens`).
37    fn get_arr_len(&self, key: &str) -> Option<usize>;
38}
39
40/// Inputs to [`config_from_gguf`]: the metadata accessor plus two facts the
41/// builder needs from the tensor section (not the metadata KV block).
42pub struct GgufConfigInputs<'a> {
43    pub meta: &'a dyn GgufMeta,
44    /// Rows of `token_embd.weight` — the authoritative vocab size when the
45    /// `{arch}.vocab_size` key is absent. `None` if the loader could not read
46    /// the tensor shape before building the config.
47    pub token_embd_vocab: Option<usize>,
48    /// Whether the file contains an `output.weight` tensor. Its presence means
49    /// an untied LM head; its absence means the LM head ties to the input
50    /// embeddings. GGUF has no explicit `tie_word_embeddings` key, so this is
51    /// the only reliable signal.
52    pub has_output_weight: bool,
53}
54
55/// Map a GGUF `general.architecture` string to an Atlas `model_type` (must be
56/// a supported loader string) and whether attention Q is gated.
57///
58/// Plain-decoder GGUFs (llama/qwen2) have no dedicated Atlas arch loader; the
59/// closest dense GQA path is the Mistral loader. qwen3 dense maps to `qwen3_5`
60/// with `num_experts == 0` (dense qwen3.5 loader). Returns an error for
61/// unmapped architectures rather than guessing.
62fn arch_to_model_type(arch: &str) -> Result<(&'static str, bool)> {
63    // (model_type, attn_gated)
64    Ok(match arch {
65        "llama" => ("mistral", false),
66        // qwen2 ships QKV biases; the Mistral GQA path is the closest dense
67        // loader. (Bias handling is a known caveat — see module notes.)
68        "qwen2" => ("mistral", false),
69        // qwen3 dense: q_norm/k_norm, ungated Q. num_experts==0 → dense loader.
70        "qwen3" => ("qwen3_5", false),
71        "qwen3moe" => ("qwen3_5_moe", false),
72        // gemma family: GeGLU, ungated Q, embedding scale + logit softcap.
73        "gemma" | "gemma2" | "gemma3" | "gemma4" => ("gemma4", false),
74        other => bail!(
75            "GGUF general.architecture '{other}' has no Atlas model_type mapping. \
76             Supported GGUF arches: llama, qwen2, qwen3, qwen3moe, gemma/gemma2/gemma3/gemma4."
77        ),
78    })
79}
80
81/// Build a validated [`ModelConfig`] from GGUF metadata.
82pub fn config_from_gguf(inputs: &GgufConfigInputs) -> Result<ModelConfig> {
83    let meta = inputs.meta;
84
85    let arch = meta
86        .get_str("general.architecture")
87        .context("GGUF metadata missing required key 'general.architecture'")?
88        .to_string();
89    let (model_type, attn_gated) = arch_to_model_type(&arch)?;
90
91    // Namespaced key helper: `{arch}.<suffix>`.
92    let k = |suffix: &str| format!("{arch}.{suffix}");
93    let req_u64 = |suffix: &str| -> Result<u64> {
94        meta.get_u64(&k(suffix))
95            .with_context(|| format!("GGUF metadata missing required key '{arch}.{suffix}'"))
96    };
97
98    // ── Core dimensions (required) ──
99    let hidden_size = req_u64("embedding_length")? as usize;
100    let num_hidden_layers = req_u64("block_count")? as usize;
101    let intermediate_size = req_u64("feed_forward_length")? as usize;
102    let num_attention_heads = req_u64("attention.head_count")? as usize;
103
104    // GQA: kv head count defaults to full MHA (== attention heads) when the key
105    // is absent, which is the ggml convention.
106    let num_key_value_heads = meta
107        .get_u64(&k("attention.head_count_kv"))
108        .map(|v| v as usize)
109        .unwrap_or(num_attention_heads);
110    if num_attention_heads > 0
111        && (num_key_value_heads == 0 || !num_attention_heads.is_multiple_of(num_key_value_heads))
112    {
113        bail!(
114            "GGUF metadata key '{}.attention.head_count_kv' ({num_key_value_heads}) must be a non-zero divisor of attention.head_count ({num_attention_heads})",
115            arch
116        );
117    }
118
119    // head_dim: explicit key_length if present, else hidden_size / head_count.
120    // Erroring on a non-divisible fallback avoids a silently-wrong head_dim.
121    let head_dim = match meta.get_u64(&k("attention.key_length")) {
122        Some(0) => bail!(
123            "GGUF metadata key '{}.attention.key_length' must be greater than zero",
124            arch
125        ),
126        Some(v) => v as usize,
127        None => {
128            if num_attention_heads == 0 || !hidden_size.is_multiple_of(num_attention_heads) {
129                bail!(
130                    "GGUF: cannot derive head_dim — '{arch}.attention.key_length' absent and \
131                     hidden_size ({hidden_size}) not divisible by head_count ({num_attention_heads})"
132                );
133            }
134            hidden_size / num_attention_heads
135        }
136    };
137
138    // vocab_size: explicit key → token_embd rows → tokenizer token list length.
139    let metadata_vocab = meta.get_u64(&k("vocab_size")).map(|v| v as usize);
140    if let (Some(metadata_vocab), Some(tensor_vocab)) = (metadata_vocab, inputs.token_embd_vocab)
141        && metadata_vocab != tensor_vocab
142    {
143        bail!(
144            "GGUF: '{arch}.vocab_size' ({metadata_vocab}) does not match token_embd.weight rows \
145             ({tensor_vocab})"
146        );
147    }
148    let vocab_size = metadata_vocab
149        .or(inputs.token_embd_vocab)
150        .or_else(|| meta.get_arr_len("tokenizer.ggml.tokens"))
151        .context(
152            "GGUF: could not determine vocab_size (no '{arch}.vocab_size', no token_embd rows, \
153             no 'tokenizer.ggml.tokens')",
154        )?;
155    if vocab_size == 0 {
156        bail!("GGUF: vocab_size must be non-zero");
157    }
158
159    // ── Normalization / RoPE / context (documented explicit defaults) ──
160    // rms_norm_eps: ggml default is 1e-5 when the key is absent (differs from
161    // Atlas's 1e-6 default — we set it explicitly rather than inherit).
162    let rms_norm_eps = meta
163        .get_f64(&k("attention.layer_norm_rms_epsilon"))
164        .unwrap_or(1e-5);
165    // rope_theta: ggml default 10000.0.
166    let rope_theta = meta.get_f64(&k("rope.freq_base")).unwrap_or(10_000.0);
167    // context_length is required for a usable KV cache upper bound.
168    let max_position_embeddings = req_u64("context_length")? as usize;
169
170    // Tokenizer special tokens (0 when unset is acceptable).
171    let bos_token_id = meta.get_u64("tokenizer.ggml.bos_token_id").unwrap_or(0);
172    let eos_token_id = meta.get_u64("tokenizer.ggml.eos_token_id").unwrap_or(0);
173
174    // Tied embeddings: no `output.weight` tensor ⇒ tied.
175    let tie_word_embeddings = !inputs.has_output_weight;
176
177    // ── MoE (only for MoE arches) ──
178    let num_experts = if arch == "qwen3moe" {
179        req_u64("expert_count")? as usize
180    } else {
181        meta.get_u64(&k("expert_count"))
182            .map(|v| v as usize)
183            .unwrap_or(0)
184    };
185    if arch == "qwen3moe" && num_experts == 0 {
186        bail!("GGUF metadata key '{arch}.expert_count' must be greater than zero");
187    }
188
189    let mut body: Map<String, Value> = json!({
190        "hidden_size": hidden_size,
191        "num_hidden_layers": num_hidden_layers,
192        "intermediate_size": intermediate_size,
193        "vocab_size": vocab_size,
194        "num_attention_heads": num_attention_heads,
195        "num_key_value_heads": num_key_value_heads,
196        "head_dim": head_dim,
197        "rms_norm_eps": rms_norm_eps,
198        "rope_theta": rope_theta,
199        "max_position_embeddings": max_position_embeddings,
200        "bos_token_id": bos_token_id,
201        "eos_token_id": eos_token_id,
202        "tie_word_embeddings": tie_word_embeddings,
203        "model_type": model_type,
204    })
205    .as_object()
206    .expect("json! object literal")
207    .clone();
208
209    if num_experts > 0 {
210        let experts_per_tok = req_u64("expert_used_count").with_context(|| {
211            format!("GGUF: MoE arch '{arch}' has expert_count>0 but no '{arch}.expert_used_count'")
212        })? as usize;
213        let moe_ffn = req_u64("expert_feed_forward_length").with_context(|| {
214            format!("GGUF: MoE arch '{arch}' missing '{arch}.expert_feed_forward_length'")
215        })? as usize;
216        if experts_per_tok == 0 || experts_per_tok > num_experts {
217            bail!(
218                "GGUF metadata key '{arch}.expert_used_count' must be in 1..={num_experts}, \
219                 found {experts_per_tok}"
220            );
221        }
222        if moe_ffn == 0 {
223            bail!(
224                "GGUF metadata key '{arch}.expert_feed_forward_length' must be greater than zero"
225            );
226        }
227        body.insert("num_experts".into(), json!(num_experts));
228        body.insert("num_experts_per_tok".into(), json!(experts_per_tok));
229        body.insert("moe_intermediate_size".into(), json!(moe_ffn));
230    }
231
232    // sliding_window (gemma hybrid attention); 0/absent ⇒ full attention.
233    if let Some(sw) = meta.get_u64(&k("attention.sliding_window")) {
234        body.insert("sliding_window".into(), json!(sw));
235    }
236
237    // ── Deserialize numeric fields, then set arch fields explicitly ──
238    let raw = Value::Object(body);
239    let json_str = serde_json::to_string(&raw).context("serialize synthesized GGUF config")?;
240    let mut config: ModelConfig =
241        serde_json::from_str(&json_str).context("deserialize synthesized GGUF config")?;
242
243    config.model_type = model_type.to_string();
244    config.attn_gated = attn_gated;
245    // The GGUF name map emits HF names under the `model.` prefix
246    // (`model.embed_tokens.weight`, `model.layers.N.*`, `model.norm.weight`).
247    // `layer_prefix()` yields `model.layers.N` for both "" and "model", but the
248    // embed/norm/lm_head lookups use the raw prefix — so it must be "model", not
249    // "" (else they resolve to `.embed_tokens.weight` and fail).
250    config.weight_prefix = "model".to_string();
251
252    // Gemma-specific post-parse fixups.
253    if model_type == "gemma4" {
254        config.embed_scale = (hidden_size as f32).sqrt();
255        // Logit softcap: honor the GGUF key if present (gemma2), else 0.0
256        // (disabled). gemma3+ dropped softcapping.
257        config.final_logit_softcapping = match meta.get_f64(&k("final_logit_softcapping")) {
258            Some(v) if v >= 0.0 && v <= f32::MAX as f64 => v as f32,
259            Some(v) => bail!(
260                "GGUF metadata key '{}.final_logit_softcapping' must be non-negative and representable as a finite f32 (got {v})",
261                arch
262            ),
263            None => 0.0,
264        };
265    }
266
267    // Reuse the shared quantization-config + validation pass.
268    finalize_config(&mut config, &raw)?;
269    Ok(config)
270}
271
272// ── Fields GGUF does NOT provide, and how they are set (explicit, no silent
273//    prod defaults) ──
274//   * partial_rotary_factor / rotary_dim: left at struct default 1.0 (full
275//     RoPE). `{arch}.rope.dimension_count` could refine this for partial-rotary
276//     models; deliberately NOT auto-applied here until a target arch needs it.
277//   * layer_types / hybrid fields: left empty (homogeneous decoder).
278//   * All SSM/MLA/DeepSeek/MiniMax/vision fields: 0 / empty — not applicable to
279//     the llama/qwen/gemma decoder families this builder targets.
280//   * ep_rank/ep_world_size/tp_*: set at runtime by the caller, not here.
281
282#[cfg(test)]
283mod tests;