metrale_config/
gguf.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Build a [`ModelConfig`] from GGUF metadata key/values.
4//!
5//! The keys become an HF-config-shaped JSON object that serde reads into `ModelConfig`. The
6//! fields the JSON cannot carry (`model_type`, `attn_gated`, `weight_prefix`, the gemma and
7//! DeepSeek-V4.1 fields) are then set directly, and `finalize_config` validates the result.
8//! `arch_to_model_type` lists the accepted `general.architecture` values. The metadata is read
9//! through [`GgufMeta`], which metrale-model-weights implements for its `GgufFile`.
10//!
11//! Owner: config.
12//! Invariants: none beyond the types.
13
14use anyhow::{Context, Result, bail};
15use serde_json::{Map, Value, json};
16
17use super::{ModelConfig, finalize_config};
18
19/// 2026-09-26: Typed read access to GGUF metadata. Integer getters widen to u64 and float
20/// getters to f64. A getter returns `None` when the key is absent or holds another value
21/// type; the builder decides whether absence is an error or has a fallback.
22pub trait GgufMeta {
23    fn get_u64(&self, key: &str) -> Option<u64>;
24    fn get_f64(&self, key: &str) -> Option<f64>;
25    fn get_str(&self, key: &str) -> Option<&str>;
26    fn get_arr_len(&self, key: &str) -> Option<usize>;
27    /// 2026-09-26: Default `None`. A reader without it cannot build a DeepSeek-V4.1 config,
28    /// which requires `attention.compress_ratios` and the `engram.*` integer arrays.
29    fn get_u64_arr(&self, _key: &str) -> Option<Vec<u64>> {
30        None
31    }
32    /// 2026-09-26: Default `None`. DeepSeek-V4.1 reads `swiglu_clamp_exp` through it when the
33    /// key is present.
34    fn get_f64_arr(&self, _key: &str) -> Option<Vec<f64>> {
35        None
36    }
37}
38
39/// 2026-09-26: Inputs to [`config_from_gguf`]: the metadata reader plus two facts from the
40/// tensor section.
41pub struct GgufConfigInputs<'a> {
42    pub meta: &'a dyn GgufMeta,
43    /// 2026-09-26: Rows of `token_embd.weight`: the vocab size when `{arch}.vocab_size` is
44    /// absent, and checked against it when present. `None` if the loader could not read
45    /// the tensor shape.
46    pub token_embd_vocab: Option<usize>,
47    /// 2026-09-26: An `output.weight` tensor means an untied LM head; without one the head
48    /// ties to the input embeddings.
49    pub has_output_weight: bool,
50}
51
52/// 2026-09-26: Map `general.architecture` to a `model_type` and whether attention Q is gated.
53/// An unmapped architecture is an error.
54fn arch_to_model_type(arch: &str) -> Result<(&'static str, bool)> {
55    Ok(match arch {
56        "llama" => ("mistral", false),
57        "qwen2" => ("mistral", false),
58        "qwen3" => ("qwen3_5", false),
59        "qwen3moe" => ("qwen3_5_moe", false),
60        "gemma" | "gemma2" | "gemma3" | "gemma4" => ("gemma4", false),
61        "deepseek41" => ("deepseek_v41", false),
62        other => bail!(
63            "GGUF general.architecture '{other}' has no Metrale Engine model_type mapping. \
64             Supported GGUF arches: llama, qwen2, qwen3, qwen3moe, gemma/gemma2/gemma3/gemma4, deepseek41."
65        ),
66    })
67}
68
69/// 2026-09-26: Build a [`ModelConfig`] from GGUF metadata and validate it with
70/// `finalize_config`.
71pub fn config_from_gguf(inputs: &GgufConfigInputs) -> Result<ModelConfig> {
72    let meta = inputs.meta;
73
74    let arch = meta
75        .get_str("general.architecture")
76        .context("GGUF metadata missing required key 'general.architecture'")?
77        .to_string();
78    let (model_type, attn_gated) = arch_to_model_type(&arch)?;
79
80    let k = |suffix: &str| format!("{arch}.{suffix}");
81    let req_u64 = |suffix: &str| -> Result<u64> {
82        meta.get_u64(&k(suffix))
83            .with_context(|| format!("GGUF metadata missing required key '{arch}.{suffix}'"))
84    };
85
86    let hidden_size = req_u64("embedding_length")? as usize;
87    let num_hidden_layers = req_u64("block_count")? as usize;
88    // 2026-09-26: `feed_forward_length` is the dense FFN width. It is required only when
89    // the file declares no experts; a MoE file without it gets 0.
90    let has_experts = meta.get_u64(&k("expert_count")).unwrap_or(0) > 0;
91    let intermediate_size = match meta.get_u64(&k("feed_forward_length")) {
92        Some(v) => v as usize,
93        None if has_experts => 0,
94        None => bail!("GGUF metadata missing required key '{arch}.feed_forward_length'"),
95    };
96    let num_attention_heads = req_u64("attention.head_count")? as usize;
97
98    // 2026-09-26: An absent `attention.head_count_kv` means one KV head per attention head.
99    let num_key_value_heads = meta
100        .get_u64(&k("attention.head_count_kv"))
101        .map(|v| v as usize)
102        .unwrap_or(num_attention_heads);
103    if num_attention_heads > 0
104        && (num_key_value_heads == 0 || !num_attention_heads.is_multiple_of(num_key_value_heads))
105    {
106        bail!(
107            "GGUF metadata key '{}.attention.head_count_kv' ({num_key_value_heads}) must be a non-zero divisor of attention.head_count ({num_attention_heads})",
108            arch
109        );
110    }
111
112    let head_dim = match meta.get_u64(&k("attention.key_length")) {
113        Some(0) => bail!(
114            "GGUF metadata key '{}.attention.key_length' must be greater than zero",
115            arch
116        ),
117        Some(v) => v as usize,
118        None => {
119            if num_attention_heads == 0 || !hidden_size.is_multiple_of(num_attention_heads) {
120                bail!(
121                    "GGUF: cannot derive head_dim — '{arch}.attention.key_length' absent and \
122                     hidden_size ({hidden_size}) not divisible by head_count ({num_attention_heads})"
123                );
124            }
125            hidden_size / num_attention_heads
126        }
127    };
128
129    // 2026-09-26: vocab_size is `{arch}.vocab_size`, else the `token_embd.weight` rows, else
130    // the `tokenizer.ggml.tokens` length. The first two must agree when both exist.
131    let metadata_vocab = meta.get_u64(&k("vocab_size")).map(|v| v as usize);
132    if let (Some(metadata_vocab), Some(tensor_vocab)) = (metadata_vocab, inputs.token_embd_vocab)
133        && metadata_vocab != tensor_vocab
134    {
135        bail!(
136            "GGUF: '{arch}.vocab_size' ({metadata_vocab}) does not match token_embd.weight rows \
137             ({tensor_vocab})"
138        );
139    }
140    let vocab_size = metadata_vocab
141        .or(inputs.token_embd_vocab)
142        .or_else(|| meta.get_arr_len("tokenizer.ggml.tokens"))
143        .context(
144            "GGUF: could not determine vocab_size (no '{arch}.vocab_size', no token_embd rows, \
145             no 'tokenizer.ggml.tokens')",
146        )?;
147    if vocab_size == 0 {
148        bail!("GGUF: vocab_size must be non-zero");
149    }
150
151    // 2026-09-26: An absent `attention.layer_norm_rms_epsilon` gives 1e-5, set here instead
152    // of the serde default `default_rms_eps()` (1e-6).
153    let rms_norm_eps = meta
154        .get_f64(&k("attention.layer_norm_rms_epsilon"))
155        .unwrap_or(1e-5);
156    let rope_theta = meta.get_f64(&k("rope.freq_base")).unwrap_or(10_000.0);
157    let max_position_embeddings = req_u64("context_length")? as usize;
158
159    let bos_token_id = meta.get_u64("tokenizer.ggml.bos_token_id").unwrap_or(0);
160    let eos_token_id = meta.get_u64("tokenizer.ggml.eos_token_id").unwrap_or(0);
161
162    let tie_word_embeddings = !inputs.has_output_weight;
163
164    let num_experts = if arch == "qwen3moe" {
165        req_u64("expert_count")? as usize
166    } else {
167        meta.get_u64(&k("expert_count"))
168            .map(|v| v as usize)
169            .unwrap_or(0)
170    };
171    if arch == "qwen3moe" && num_experts == 0 {
172        bail!("GGUF metadata key '{arch}.expert_count' must be greater than zero");
173    }
174
175    let mut body: Map<String, Value> = json!({
176        "hidden_size": hidden_size,
177        "num_hidden_layers": num_hidden_layers,
178        "intermediate_size": intermediate_size,
179        "vocab_size": vocab_size,
180        "num_attention_heads": num_attention_heads,
181        "num_key_value_heads": num_key_value_heads,
182        "head_dim": head_dim,
183        "rms_norm_eps": rms_norm_eps,
184        "rope_theta": rope_theta,
185        "max_position_embeddings": max_position_embeddings,
186        "bos_token_id": bos_token_id,
187        "eos_token_id": eos_token_id,
188        "tie_word_embeddings": tie_word_embeddings,
189        "model_type": model_type,
190    })
191    .as_object()
192    .expect("json! object literal")
193    .clone();
194
195    if num_experts > 0 {
196        let experts_per_tok = req_u64("expert_used_count").with_context(|| {
197            format!("GGUF: MoE arch '{arch}' has expert_count>0 but no '{arch}.expert_used_count'")
198        })? as usize;
199        let moe_ffn = req_u64("expert_feed_forward_length").with_context(|| {
200            format!("GGUF: MoE arch '{arch}' missing '{arch}.expert_feed_forward_length'")
201        })? as usize;
202        if experts_per_tok == 0 || experts_per_tok > num_experts {
203            bail!(
204                "GGUF metadata key '{arch}.expert_used_count' must be in 1..={num_experts}, \
205                 found {experts_per_tok}"
206            );
207        }
208        if moe_ffn == 0 {
209            bail!(
210                "GGUF metadata key '{arch}.expert_feed_forward_length' must be greater than zero"
211            );
212        }
213        body.insert("num_experts".into(), json!(num_experts));
214        body.insert("num_experts_per_tok".into(), json!(experts_per_tok));
215        body.insert("moe_intermediate_size".into(), json!(moe_ffn));
216    }
217
218    if let Some(sw) = meta.get_u64(&k("attention.sliding_window")) {
219        body.insert("sliding_window".into(), json!(sw));
220    }
221
222    let raw = Value::Object(body);
223    let json_str = serde_json::to_string(&raw).context("serialize synthesized GGUF config")?;
224    let mut config: ModelConfig =
225        serde_json::from_str(&json_str).context("deserialize synthesized GGUF config")?;
226
227    config.model_type = model_type.to_string();
228    config.attn_gated = attn_gated;
229    // 2026-09-26: The GGUF name map emits HF names under `model.` (`model.embed_tokens.weight`,
230    // `model.layers.N.*`). `layer_prefix()` gives `model.layers.N` for both "" and "model",
231    // but the embedding and norm loaders format `weight_prefix` as is, so it must be "model".
232    config.weight_prefix = "model".to_string();
233
234    if model_type == "gemma4" {
235        config.embed_scale = (hidden_size as f32).sqrt();
236        config.final_logit_softcapping = match meta.get_f64(&k("final_logit_softcapping")) {
237            Some(v) if v >= 0.0 && v <= f32::MAX as f64 => v as f32,
238            Some(v) => bail!(
239                "GGUF metadata key '{}.final_logit_softcapping' must be non-negative and representable as a finite f32 (got {v})",
240                arch
241            ),
242            None => 0.0,
243        };
244    }
245
246    // 2026-09-26: DeepSeek-V4.1 fields, from the `deepseek41.*` keys. An absent key is an
247    // error, except `hash_layer_count` (0) and `swiglu_clamp_exp` (the clamp stays unset);
248    // the `engram.*` keys past `engram.layer_ids` are read only when that list is non-empty.
249    if model_type == "deepseek_v41" {
250        let req = |suffix: &str| -> Result<u64> {
251            meta.get_u64(&k(suffix))
252                .with_context(|| format!("DeepSeek-V4.1 GGUF missing '{arch}.{suffix}'"))
253        };
254        let req_arr = |suffix: &str| -> Result<Vec<u64>> {
255            meta.get_u64_arr(&k(suffix))
256                .with_context(|| format!("DeepSeek-V4.1 GGUF missing array '{arch}.{suffix}'"))
257        };
258
259        config.q_lora_rank = req("attention.q_lora_rank")? as usize;
260        config.o_lora_rank = req("attention.output_lora_rank")? as usize;
261        config.o_groups = req("attention.output_group_count")? as usize;
262        config.kv_lora_rank = req("attention.value_length")? as usize;
263        config.rotary_dim = req("rope.dimension_count")? as usize;
264
265        config.index_n_heads = req("attention.indexer.head_count")? as usize;
266        config.index_head_dim = req("attention.indexer.key_length")? as usize;
267        config.index_topk = req("attention.indexer.top_k")? as usize;
268        config.compress_ratios = req_arr("attention.compress_ratios")?
269            .into_iter()
270            .map(|v| v as usize)
271            .collect();
272        // 2026-09-26: `compress_ratios` may have more entries than `block_count`; only fewer
273        // is an error.
274        if config.compress_ratios.len() < num_hidden_layers {
275            bail!(
276                "DeepSeek-V4.1: '{arch}.attention.compress_ratios' has {} entries, fewer than \
277                 block_count ({num_hidden_layers})",
278                config.compress_ratios.len()
279            );
280        }
281        config.compress_rope_theta = meta
282            .get_f64(&k("attention.compress_rope_freq_base"))
283            .context("DeepSeek-V4.1 GGUF missing 'attention.compress_rope_freq_base'")?
284            as f32;
285
286        config.hc_mult = req("hyper_connection.count")? as usize;
287        config.hc_sinkhorn_iters = req("hyper_connection.sinkhorn_iterations")? as usize;
288        config.hc_eps = meta
289            .get_f64(&k("hyper_connection.epsilon"))
290            .context("DeepSeek-V4.1 GGUF missing 'hyper_connection.epsilon'")?
291            as f32;
292
293        config.n_routed_experts = num_experts;
294        config.norm_topk_prob = req("expert_weights_norm")? != 0;
295        config.routed_scaling_factor = meta
296            .get_f64(&k("expert_weights_scale"))
297            .context("DeepSeek-V4.1 GGUF missing 'expert_weights_scale'")?;
298        config.scoring_func = match req("expert_gating_func")? {
299            4 => "sqrtsoftplus".to_string(),
300            other => bail!(
301                "DeepSeek-V4.1: unsupported '{arch}.expert_gating_func' ({other}); \
302                 only 4 (sqrt-softplus) is implemented"
303            ),
304        };
305        config.num_hash_layers = meta.get_u64(&k("hash_layer_count")).unwrap_or(0) as usize;
306
307        // 2026-09-26: `swiglu_clamp_exp` is per layer and `ModelConfig` carries one
308        // `swiglu_limit`, so a non-uniform array is an error rather than element 0.
309        if let Some(clamps) = meta.get_f64_arr(&k("swiglu_clamp_exp")) {
310            let first = clamps.first().copied().unwrap_or(0.0);
311            if clamps.iter().any(|v| (v - first).abs() > f64::EPSILON) {
312                bail!(
313                    "DeepSeek-V4.1: '{arch}.swiglu_clamp_exp' is not uniform across layers; \
314                     ModelConfig carries a single `swiglu_limit`"
315                );
316            }
317            config.swiglu_limit = first as f32;
318        }
319
320        config.engram_layer_ids = req_arr("engram.layer_ids")?
321            .into_iter()
322            .map(|v| v as usize)
323            .collect();
324        if !config.engram_layer_ids.is_empty() {
325            config.engram_max_ngram_size = req("engram.max_ngram_size")? as usize;
326            config.engram_n_heads = req("engram.head_count")? as usize;
327            config.engram_head_dim = req("engram.key_length")? as usize;
328            config.engram_pad_token_id = req("engram.pad_id")? as u32;
329            config.engram_multipliers = req_arr("engram.multipliers")?;
330            config.engram_primes = req_arr("engram.primes")?;
331            config.engram_offsets = req_arr("engram.offsets")?;
332            if meta.get_arr_len(&k("engram.token_map")).unwrap_or(0) != vocab_size {
333                bail!(
334                    "DeepSeek-V4.1: '{arch}.engram.token_map' length does not match vocab_size \
335                     ({vocab_size})"
336                );
337            }
338        }
339    }
340
341    finalize_config(&mut config, &raw)?;
342    super::resolve_attn_position_encoding(&mut config)?;
343    Ok(config)
344}
345
346#[cfg(test)]
347mod tests;