metrale_model_weights/weights/gguf/
config.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Build a [`ModelConfig`] from the metadata of the `.gguf` in a model
4//! directory, so a directory with no `config.json` or `params.json` can be served.
5//!
6//! `config_from_gguf` is in metrale-config, which does not depend on this crate's
7//! GGUF parser. This module implements metrale-config's [`GgufMeta`] over
8//! [`GgufFile`] and reads the two tensor-directory facts the builder takes: the
9//! vocab dim of `token_embd.weight` and whether `output.weight` exists.
10//!
11//! Owner: model-weights (GGUF).
12//! Invariants: none beyond the types.
13
14use std::path::Path;
15
16use anyhow::{Context, Result};
17use metrale_config::{GgufConfigInputs, GgufMeta, ModelConfig, config_from_gguf};
18
19use super::container::GgufFile;
20use super::find_gguf;
21
22impl GgufMeta for GgufFile {
23    fn get_u64(&self, key: &str) -> Option<u64> {
24        GgufFile::get_u64(self, key)
25    }
26    fn get_f64(&self, key: &str) -> Option<f64> {
27        GgufFile::get_f64(self, key)
28    }
29    fn get_str(&self, key: &str) -> Option<&str> {
30        GgufFile::get_str(self, key)
31    }
32    fn get_arr_len(&self, key: &str) -> Option<usize> {
33        GgufFile::arr_len(self, key)
34    }
35    /// 2026-09-26: Integer array, every element widened to u64 whatever its
36    /// integer type (a bool reads as 0 or 1). `None` when the key is absent, the
37    /// value is not an array, or any element is negative or not an integer; no
38    /// element is dropped.
39    fn get_u64_arr(&self, key: &str) -> Option<Vec<u64>> {
40        let arr = GgufFile::get(self, key)?.as_array()?;
41        arr.iter()
42            .map(|v| {
43                v.as_u64()
44                    .or_else(|| v.as_i64().and_then(|i| u64::try_from(i).ok()))
45            })
46            .collect()
47    }
48    /// 2026-09-26: Float array, every element widened to f64 by
49    /// `MetaValue::as_f64`, which also converts integers and bools. `None` when
50    /// the key is absent, the value is not an array, or any element does not
51    /// convert.
52    fn get_f64_arr(&self, key: &str) -> Option<Vec<f64>> {
53        let arr = GgufFile::get(self, key)?.as_array()?;
54        arr.iter().map(|v| v.as_f64()).collect()
55    }
56}
57
58/// 2026-09-26: Build a [`ModelConfig`] from the `.gguf` that `find_gguf` picks in
59/// `model_dir`.
60///
61/// Passes `config_from_gguf` the trailing ggml dim of `token_embd.weight` (the
62/// vocab) and whether `output.weight` exists; `config_from_gguf` treats a missing
63/// `output.weight` as tied embeddings. Errors when there is no `.gguf`, when the
64/// file cannot be opened, mapped or parsed, or when the config cannot be built.
65pub fn config_from_gguf_dir(model_dir: &Path) -> Result<ModelConfig> {
66    let path = find_gguf(model_dir)
67        .with_context(|| format!("no .gguf file in {}", model_dir.display()))?;
68    let file =
69        std::fs::File::open(&path).with_context(|| format!("failed to open {}", path.display()))?;
70    // 2026-09-26: SAFETY: `GgufFile::parse` reads the map while `mmap` is alive.
71    // As in `sidecar::open_gguf`, nothing stops another process from modifying the
72    // file while it is mapped; this assumes none does.
73    let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
74    let gguf = GgufFile::parse(&mmap)
75        .with_context(|| format!("failed to parse GGUF metadata: {}", path.display()))?;
76
77    let token_embd_vocab = gguf
78        .tensor("token_embd.weight")
79        .and_then(|t| t.dims.last().copied());
80    let has_output_weight = gguf.tensor("output.weight").is_some();
81
82    let inputs = GgufConfigInputs {
83        meta: &gguf,
84        token_embd_vocab,
85        has_output_weight,
86    };
87    config_from_gguf(&inputs).context("failed to build ModelConfig from GGUF metadata")
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    /// 2026-09-26: The `GgufMeta` impl reaches the inherent getters (a call that
95    /// resolved to the trait method would recurse) and maps `get_arr_len` to
96    /// `arr_len`.
97    #[test]
98    fn gguf_meta_bridge_forwards() {
99        // 2026-09-26: A minimal GGUF v3 built by hand: no tensors, one UINT32 KV
100        // (type 4) and one STRING-array KV (type 9, element type 8).
101        let mut b: Vec<u8> = Vec::new();
102        let push_u32 = |b: &mut Vec<u8>, v: u32| b.extend_from_slice(&v.to_le_bytes());
103        let push_u64 = |b: &mut Vec<u8>, v: u64| b.extend_from_slice(&v.to_le_bytes());
104        let push_str = |b: &mut Vec<u8>, s: &str| {
105            b.extend_from_slice(&(s.len() as u64).to_le_bytes());
106            b.extend_from_slice(s.as_bytes());
107        };
108        push_u32(&mut b, 0x4655_4747);
109        push_u32(&mut b, 3);
110        push_u64(&mut b, 0);
111        push_u64(&mut b, 2);
112        push_str(&mut b, "qwen3.block_count");
113        push_u32(&mut b, 4);
114        push_u32(&mut b, 28);
115        push_str(&mut b, "tokenizer.ggml.tokens");
116        push_u32(&mut b, 9);
117        push_u32(&mut b, 8);
118        push_u64(&mut b, 3);
119        for s in ["a", "bb", "ccc"] {
120            push_str(&mut b, s);
121        }
122        // 2026-09-26: Pad to the default 32-byte alignment, so the empty
123        // tensor-data section starts inside the buffer, as `GgufFile::parse`
124        // requires.
125        while !b.len().is_multiple_of(32) {
126            b.push(0);
127        }
128        let gguf = GgufFile::parse(&b).unwrap();
129        let m: &dyn GgufMeta = &gguf;
130        assert_eq!(m.get_u64("qwen3.block_count"), Some(28));
131        assert_eq!(m.get_arr_len("tokenizer.ggml.tokens"), Some(3));
132        assert_eq!(m.get_str("nonexistent"), None);
133    }
134}