avarok_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//! avarok-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    /// An integer array metadata value, every element widened to u64.
39    ///
40    /// DEFAULTS TO `None` so existing implementors keep compiling: a reader
41    /// that has not implemented it reports "absent", which every caller here
42    /// already handles. DeepSeek-V4.1 is the first arch that needs real array
43    /// VALUES rather than a length (`attention.compress_ratios`,
44    /// `engram.layer_ids`, `engram.{multipliers,primes,offsets}`), so a reader
45    /// that returns `None` here simply cannot build a V4.1 config.
46    fn get_u64_arr(&self, _key: &str) -> Option<Vec<u64>> {
47        None
48    }
49    /// A float array metadata value, every element widened to f64. Defaults to
50    /// `None` for the same reason as [`GgufMeta::get_u64_arr`].
51    /// DeepSeek-V4.1 needs it for `swiglu_clamp_exp` / `swiglu_clamp_shexp`.
52    fn get_f64_arr(&self, _key: &str) -> Option<Vec<f64>> {
53        None
54    }
55}
56
57/// Inputs to [`config_from_gguf`]: the metadata accessor plus two facts the
58/// builder needs from the tensor section (not the metadata KV block).
59pub struct GgufConfigInputs<'a> {
60    pub meta: &'a dyn GgufMeta,
61    /// Rows of `token_embd.weight` — the authoritative vocab size when the
62    /// `{arch}.vocab_size` key is absent. `None` if the loader could not read
63    /// the tensor shape before building the config.
64    pub token_embd_vocab: Option<usize>,
65    /// Whether the file contains an `output.weight` tensor. Its presence means
66    /// an untied LM head; its absence means the LM head ties to the input
67    /// embeddings. GGUF has no explicit `tie_word_embeddings` key, so this is
68    /// the only reliable signal.
69    pub has_output_weight: bool,
70}
71
72/// Map a GGUF `general.architecture` string to an Atlas `model_type` (must be
73/// a supported loader string) and whether attention Q is gated.
74///
75/// Plain-decoder GGUFs (llama/qwen2) have no dedicated Atlas arch loader; the
76/// closest dense GQA path is the Mistral loader. qwen3 dense maps to `qwen3_5`
77/// with `num_experts == 0` (dense qwen3.5 loader). Returns an error for
78/// unmapped architectures rather than guessing.
79fn arch_to_model_type(arch: &str) -> Result<(&'static str, bool)> {
80    // (model_type, attn_gated)
81    Ok(match arch {
82        "llama" => ("mistral", false),
83        // qwen2 ships QKV biases; the Mistral GQA path is the closest dense
84        // loader. (Bias handling is a known caveat — see module notes.)
85        "qwen2" => ("mistral", false),
86        // qwen3 dense: q_norm/k_norm, ungated Q. num_experts==0 → dense loader.
87        "qwen3" => ("qwen3_5", false),
88        "qwen3moe" => ("qwen3_5_moe", false),
89        // gemma family: GeGLU, ungated Q, embedding scale + logit softcap.
90        "gemma" | "gemma2" | "gemma3" | "gemma4" => ("gemma4", false),
91        // DeepSeek-V4.1 Flash. MLA + 384-expert MoE + mHC + shared compressed
92        // attention + engram. Its GGUF ships no dense `feed_forward_length`
93        // (every FFN is MoE or the shared expert), so the required-key handling
94        // below treats that key as optional for MoE arches.
95        "deepseek41" => ("deepseek_v41", false),
96        other => bail!(
97            "GGUF general.architecture '{other}' has no Atlas model_type mapping. \
98             Supported GGUF arches: llama, qwen2, qwen3, qwen3moe, gemma/gemma2/gemma3/gemma4, deepseek41."
99        ),
100    })
101}
102
103/// Build a validated [`ModelConfig`] from GGUF metadata.
104pub fn config_from_gguf(inputs: &GgufConfigInputs) -> Result<ModelConfig> {
105    let meta = inputs.meta;
106
107    let arch = meta
108        .get_str("general.architecture")
109        .context("GGUF metadata missing required key 'general.architecture'")?
110        .to_string();
111    let (model_type, attn_gated) = arch_to_model_type(&arch)?;
112
113    // Namespaced key helper: `{arch}.<suffix>`.
114    let k = |suffix: &str| format!("{arch}.{suffix}");
115    let req_u64 = |suffix: &str| -> Result<u64> {
116        meta.get_u64(&k(suffix))
117            .with_context(|| format!("GGUF metadata missing required key '{arch}.{suffix}'"))
118    };
119
120    // ── Core dimensions (required) ──
121    let hidden_size = req_u64("embedding_length")? as usize;
122    let num_hidden_layers = req_u64("block_count")? as usize;
123    // `feed_forward_length` is the DENSE FFN width. A pure-MoE arch may not ship
124    // it at all (DeepSeek-V4.1 has only `expert_feed_forward_length`), so it is
125    // required only when the file declares no experts. Resolving to 0 for a MoE
126    // file is correct: nothing dispatches a dense FFN there.
127    let has_experts = meta.get_u64(&k("expert_count")).unwrap_or(0) > 0;
128    let intermediate_size = match meta.get_u64(&k("feed_forward_length")) {
129        Some(v) => v as usize,
130        None if has_experts => 0,
131        None => bail!("GGUF metadata missing required key '{arch}.feed_forward_length'"),
132    };
133    let num_attention_heads = req_u64("attention.head_count")? as usize;
134
135    // GQA: kv head count defaults to full MHA (== attention heads) when the key
136    // is absent, which is the ggml convention.
137    let num_key_value_heads = meta
138        .get_u64(&k("attention.head_count_kv"))
139        .map(|v| v as usize)
140        .unwrap_or(num_attention_heads);
141    if num_attention_heads > 0
142        && (num_key_value_heads == 0 || !num_attention_heads.is_multiple_of(num_key_value_heads))
143    {
144        bail!(
145            "GGUF metadata key '{}.attention.head_count_kv' ({num_key_value_heads}) must be a non-zero divisor of attention.head_count ({num_attention_heads})",
146            arch
147        );
148    }
149
150    // head_dim: explicit key_length if present, else hidden_size / head_count.
151    // Erroring on a non-divisible fallback avoids a silently-wrong head_dim.
152    let head_dim = match meta.get_u64(&k("attention.key_length")) {
153        Some(0) => bail!(
154            "GGUF metadata key '{}.attention.key_length' must be greater than zero",
155            arch
156        ),
157        Some(v) => v as usize,
158        None => {
159            if num_attention_heads == 0 || !hidden_size.is_multiple_of(num_attention_heads) {
160                bail!(
161                    "GGUF: cannot derive head_dim — '{arch}.attention.key_length' absent and \
162                     hidden_size ({hidden_size}) not divisible by head_count ({num_attention_heads})"
163                );
164            }
165            hidden_size / num_attention_heads
166        }
167    };
168
169    // vocab_size: explicit key → token_embd rows → tokenizer token list length.
170    let metadata_vocab = meta.get_u64(&k("vocab_size")).map(|v| v as usize);
171    if let (Some(metadata_vocab), Some(tensor_vocab)) = (metadata_vocab, inputs.token_embd_vocab)
172        && metadata_vocab != tensor_vocab
173    {
174        bail!(
175            "GGUF: '{arch}.vocab_size' ({metadata_vocab}) does not match token_embd.weight rows \
176             ({tensor_vocab})"
177        );
178    }
179    let vocab_size = metadata_vocab
180        .or(inputs.token_embd_vocab)
181        .or_else(|| meta.get_arr_len("tokenizer.ggml.tokens"))
182        .context(
183            "GGUF: could not determine vocab_size (no '{arch}.vocab_size', no token_embd rows, \
184             no 'tokenizer.ggml.tokens')",
185        )?;
186    if vocab_size == 0 {
187        bail!("GGUF: vocab_size must be non-zero");
188    }
189
190    // ── Normalization / RoPE / context (documented explicit defaults) ──
191    // rms_norm_eps: ggml default is 1e-5 when the key is absent (differs from
192    // Atlas's 1e-6 default — we set it explicitly rather than inherit).
193    let rms_norm_eps = meta
194        .get_f64(&k("attention.layer_norm_rms_epsilon"))
195        .unwrap_or(1e-5);
196    // rope_theta: ggml default 10000.0.
197    let rope_theta = meta.get_f64(&k("rope.freq_base")).unwrap_or(10_000.0);
198    // context_length is required for a usable KV cache upper bound.
199    let max_position_embeddings = req_u64("context_length")? as usize;
200
201    // Tokenizer special tokens (0 when unset is acceptable).
202    let bos_token_id = meta.get_u64("tokenizer.ggml.bos_token_id").unwrap_or(0);
203    let eos_token_id = meta.get_u64("tokenizer.ggml.eos_token_id").unwrap_or(0);
204
205    // Tied embeddings: no `output.weight` tensor ⇒ tied.
206    let tie_word_embeddings = !inputs.has_output_weight;
207
208    // ── MoE (only for MoE arches) ──
209    let num_experts = if arch == "qwen3moe" {
210        req_u64("expert_count")? as usize
211    } else {
212        meta.get_u64(&k("expert_count"))
213            .map(|v| v as usize)
214            .unwrap_or(0)
215    };
216    if arch == "qwen3moe" && num_experts == 0 {
217        bail!("GGUF metadata key '{arch}.expert_count' must be greater than zero");
218    }
219
220    let mut body: Map<String, Value> = json!({
221        "hidden_size": hidden_size,
222        "num_hidden_layers": num_hidden_layers,
223        "intermediate_size": intermediate_size,
224        "vocab_size": vocab_size,
225        "num_attention_heads": num_attention_heads,
226        "num_key_value_heads": num_key_value_heads,
227        "head_dim": head_dim,
228        "rms_norm_eps": rms_norm_eps,
229        "rope_theta": rope_theta,
230        "max_position_embeddings": max_position_embeddings,
231        "bos_token_id": bos_token_id,
232        "eos_token_id": eos_token_id,
233        "tie_word_embeddings": tie_word_embeddings,
234        "model_type": model_type,
235    })
236    .as_object()
237    .expect("json! object literal")
238    .clone();
239
240    if num_experts > 0 {
241        let experts_per_tok = req_u64("expert_used_count").with_context(|| {
242            format!("GGUF: MoE arch '{arch}' has expert_count>0 but no '{arch}.expert_used_count'")
243        })? as usize;
244        let moe_ffn = req_u64("expert_feed_forward_length").with_context(|| {
245            format!("GGUF: MoE arch '{arch}' missing '{arch}.expert_feed_forward_length'")
246        })? as usize;
247        if experts_per_tok == 0 || experts_per_tok > num_experts {
248            bail!(
249                "GGUF metadata key '{arch}.expert_used_count' must be in 1..={num_experts}, \
250                 found {experts_per_tok}"
251            );
252        }
253        if moe_ffn == 0 {
254            bail!(
255                "GGUF metadata key '{arch}.expert_feed_forward_length' must be greater than zero"
256            );
257        }
258        body.insert("num_experts".into(), json!(num_experts));
259        body.insert("num_experts_per_tok".into(), json!(experts_per_tok));
260        body.insert("moe_intermediate_size".into(), json!(moe_ffn));
261    }
262
263    // sliding_window (gemma hybrid attention); 0/absent ⇒ full attention.
264    if let Some(sw) = meta.get_u64(&k("attention.sliding_window")) {
265        body.insert("sliding_window".into(), json!(sw));
266    }
267
268    // ── Deserialize numeric fields, then set arch fields explicitly ──
269    let raw = Value::Object(body);
270    let json_str = serde_json::to_string(&raw).context("serialize synthesized GGUF config")?;
271    let mut config: ModelConfig =
272        serde_json::from_str(&json_str).context("deserialize synthesized GGUF config")?;
273
274    config.model_type = model_type.to_string();
275    config.attn_gated = attn_gated;
276    // The GGUF name map emits HF names under the `model.` prefix
277    // (`model.embed_tokens.weight`, `model.layers.N.*`, `model.norm.weight`).
278    // `layer_prefix()` yields `model.layers.N` for both "" and "model", but the
279    // embed/norm/lm_head lookups use the raw prefix — so it must be "model", not
280    // "" (else they resolve to `.embed_tokens.weight` and fail).
281    config.weight_prefix = "model".to_string();
282
283    // Gemma-specific post-parse fixups.
284    if model_type == "gemma4" {
285        config.embed_scale = (hidden_size as f32).sqrt();
286        // Logit softcap: honor the GGUF key if present (gemma2), else 0.0
287        // (disabled). gemma3+ dropped softcapping.
288        config.final_logit_softcapping = match meta.get_f64(&k("final_logit_softcapping")) {
289            Some(v) if v >= 0.0 && v <= f32::MAX as f64 => v as f32,
290            Some(v) => bail!(
291                "GGUF metadata key '{}.final_logit_softcapping' must be non-negative and representable as a finite f32 (got {v})",
292                arch
293            ),
294            None => 0.0,
295        };
296    }
297
298    // ── DeepSeek-V4.1 post-parse fixups ──
299    //
300    // Everything below comes from the `deepseek41.*` metadata namespace. None
301    // of it is derivable from the generic decoder fields above, and a V4.1
302    // graph built without it is silently wrong rather than loudly broken, so
303    // every key here is REQUIRED and absence is an error.
304    if model_type == "deepseek_v41" {
305        let req = |suffix: &str| -> Result<u64> {
306            meta.get_u64(&k(suffix))
307                .with_context(|| format!("DeepSeek-V4.1 GGUF missing '{arch}.{suffix}'"))
308        };
309        let req_arr = |suffix: &str| -> Result<Vec<u64>> {
310            meta.get_u64_arr(&k(suffix))
311                .with_context(|| format!("DeepSeek-V4.1 GGUF missing array '{arch}.{suffix}'"))
312        };
313
314        // MLA geometry. `attention.key_length` already became head_dim above.
315        config.q_lora_rank = req("attention.q_lora_rank")? as usize;
316        config.o_lora_rank = req("attention.output_lora_rank")? as usize;
317        config.o_groups = req("attention.output_group_count")? as usize;
318        config.kv_lora_rank = req("attention.value_length")? as usize;
319        // Partial RoPE: only `rope.dimension_count` of head_dim is rotated.
320        config.rotary_dim = req("rope.dimension_count")? as usize;
321
322        // Sparse attention: the indexer, and the per-layer compression ladder.
323        config.index_n_heads = req("attention.indexer.head_count")? as usize;
324        config.index_head_dim = req("attention.indexer.key_length")? as usize;
325        config.index_topk = req("attention.indexer.top_k")? as usize;
326        config.compress_ratios = req_arr("attention.compress_ratios")?
327            .into_iter()
328            .map(|v| v as usize)
329            .collect();
330        // 🪤 NOT a per-block array: V4.1 ships 43 entries for 40 blocks (the
331        // three DSpark stages carry their own ratio). Do not assume
332        // `compress_ratios.len() == num_hidden_layers`.
333        if config.compress_ratios.len() < num_hidden_layers {
334            bail!(
335                "DeepSeek-V4.1: '{arch}.attention.compress_ratios' has {} entries, fewer than \
336                 block_count ({num_hidden_layers})",
337                config.compress_ratios.len()
338            );
339        }
340        config.compress_rope_theta = meta
341            .get_f64(&k("attention.compress_rope_freq_base"))
342            .context("DeepSeek-V4.1 GGUF missing 'attention.compress_rope_freq_base'")?
343            as f32;
344
345        // Hyper-connections (mHC).
346        config.hc_mult = req("hyper_connection.count")? as usize;
347        config.hc_sinkhorn_iters = req("hyper_connection.sinkhorn_iterations")? as usize;
348        config.hc_eps = meta
349            .get_f64(&k("hyper_connection.epsilon"))
350            .context("DeepSeek-V4.1 GGUF missing 'hyper_connection.epsilon'")?
351            as f32;
352
353        // MoE routing. `expert_gating_func` is ggml's enum; V4.1 uses
354        // sqrt-softplus. Refuse an unknown value rather than defaulting to
355        // softmax, which would route to the wrong experts and still generate
356        // fluent-looking text.
357        config.n_routed_experts = num_experts;
358        config.norm_topk_prob = req("expert_weights_norm")? != 0;
359        config.routed_scaling_factor = meta
360            .get_f64(&k("expert_weights_scale"))
361            .context("DeepSeek-V4.1 GGUF missing 'expert_weights_scale'")?;
362        config.scoring_func = match req("expert_gating_func")? {
363            4 => "sqrtsoftplus".to_string(),
364            other => bail!(
365                "DeepSeek-V4.1: unsupported '{arch}.expert_gating_func' ({other}); \
366                 only 4 (sqrt-softplus) is implemented"
367            ),
368        };
369        config.num_hash_layers = meta.get_u64(&k("hash_layer_count")).unwrap_or(0) as usize;
370
371        // SwiGLU clamp. Shipped per-layer; Atlas carries one scalar, so require
372        // the array to be uniform rather than silently taking element 0.
373        if let Some(clamps) = meta.get_f64_arr(&k("swiglu_clamp_exp")) {
374            let first = clamps.first().copied().unwrap_or(0.0);
375            if clamps.iter().any(|v| (v - first).abs() > f64::EPSILON) {
376                bail!(
377                    "DeepSeek-V4.1: '{arch}.swiglu_clamp_exp' is not uniform across layers; \
378                     ModelConfig carries a single `swiglu_limit`"
379                );
380            }
381            config.swiglu_limit = first as f32;
382        }
383
384        // Engram. Absent `num_embeddings` in the GGUF (unlike the HF config):
385        // the table geometry is carried by `offsets`, and row counts are read
386        // from the engram tensor shapes at load time.
387        config.engram_layer_ids = req_arr("engram.layer_ids")?
388            .into_iter()
389            .map(|v| v as usize)
390            .collect();
391        if !config.engram_layer_ids.is_empty() {
392            config.engram_max_ngram_size = req("engram.max_ngram_size")? as usize;
393            config.engram_n_heads = req("engram.head_count")? as usize;
394            config.engram_head_dim = req("engram.key_length")? as usize;
395            config.engram_pad_token_id = req("engram.pad_id")? as u32;
396            config.engram_multipliers = req_arr("engram.multipliers")?;
397            config.engram_primes = req_arr("engram.primes")?;
398            config.engram_offsets = req_arr("engram.offsets")?;
399            // `engram.token_map` is 129,280 entries and is read directly by the
400            // engram module at load time, not carried in ModelConfig.
401            if meta.get_arr_len(&k("engram.token_map")).unwrap_or(0) != vocab_size {
402                bail!(
403                    "DeepSeek-V4.1: '{arch}.engram.token_map' length does not match vocab_size \
404                     ({vocab_size})"
405                );
406            }
407        }
408    }
409
410    // Reuse the shared quantization-config + validation pass.
411    finalize_config(&mut config, &raw)?;
412    Ok(config)
413}
414
415// ── Fields GGUF does NOT provide, and how they are set (explicit, no silent
416//    prod defaults) ──
417//   * partial_rotary_factor / rotary_dim: left at struct default 1.0 (full
418//     RoPE). `{arch}.rope.dimension_count` could refine this for partial-rotary
419//     models; deliberately NOT auto-applied here until a target arch needs it.
420//   * layer_types / hybrid fields: left empty (homogeneous decoder).
421//   * All SSM/MLA/DeepSeek/MiniMax/vision fields: 0 / empty — not applicable to
422//     the llama/qwen/gemma decoder families this builder targets.
423//   * ep_rank/ep_world_size/tp_*: set at runtime by the caller, not here.
424
425#[cfg(test)]
426mod tests;