Skip to main content

combs_formats/
metadata.rs

1//! Model metadata parsed from HuggingFace `config.json` (+ `generation_config.json`).
2
3use crate::{FormatError, Result};
4
5/// Architecture + hyperparameter description of a model, format-agnostic.
6///
7/// Field names follow the HuggingFace Llama config convention; other
8/// architecture families remap their config onto this struct in their adapter.
9#[derive(Debug, Clone)]
10pub struct ModelMetadata {
11    /// Architecture identifier, e.g. `"llama"`, `"smollm2"` (from
12    /// `config.json::model_type`). The model registry keys on this string.
13    pub architecture: String,
14    /// Hidden size (model dimension).
15    pub hidden_size: usize,
16    /// MLP intermediate size.
17    pub intermediate_size: usize,
18    /// Number of transformer layers.
19    pub num_hidden_layers: usize,
20    /// Number of attention query heads.
21    pub num_attention_heads: usize,
22    /// Number of key/value heads (GQA). Equal to `num_attention_heads` for MHA.
23    pub num_key_value_heads: usize,
24    /// Vocabulary size.
25    pub vocab_size: usize,
26    /// Maximum positional embeddings the model was built for.
27    pub max_position_embeddings: usize,
28    /// RMSNorm epsilon.
29    pub rms_norm_eps: f64,
30    /// RoPE base frequency (theta).
31    pub rope_theta: f64,
32    /// Whether lm_head is tied to the embedding matrix.
33    pub tie_word_embeddings: bool,
34    /// Per-head dimension, derived: `hidden_size / num_attention_heads`.
35    pub head_dim: usize,
36    /// Whether attention projections carry biases.
37    pub attention_bias: bool,
38    /// Beginning-of-sequence token id, if defined.
39    pub bos_token_id: Option<u32>,
40    /// End-of-sequence token ids (merged from config + generation_config).
41    pub eos_token_ids: Vec<u32>,
42    /// Vision-tower hyperparameters for multimodal models (Idefics3/SmolVLM
43    /// today); `None` for text-only models.
44    pub vision: Option<VisionConfig>,
45}
46
47/// Vision-encoder hyperparameters parsed from `config.json::vision_config`
48/// (plus top-level `scale_factor` / `image_token_id`).
49#[derive(Debug, Clone)]
50pub struct VisionConfig {
51    /// Square input image size (pixels).
52    pub image_size: usize,
53    /// Patch size (pixels) of the patch embedding.
54    pub patch_size: usize,
55    /// Vision hidden size.
56    pub hidden_size: usize,
57    /// Vision MLP intermediate size.
58    pub intermediate_size: usize,
59    /// Vision transformer layers.
60    pub num_hidden_layers: usize,
61    /// Vision attention heads (MHA — kv heads == q heads).
62    pub num_attention_heads: usize,
63    /// LayerNorm epsilon (SigLIP: 1e-6).
64    pub layer_norm_eps: f64,
65    /// Pixel-shuffle scale factor of the connector (scale² patches are
66    /// merged into one visual token).
67    pub scale_factor: usize,
68    /// Token id whose span in the prompt is replaced by visual embeddings.
69    pub image_token_id: u32,
70}
71
72impl VisionConfig {
73    /// Visual tokens per image:
74    /// `(image_size / patch_size)² / scale_factor²`.
75    pub fn image_seq_len(&self) -> usize {
76        let per_side = self.image_size / self.patch_size;
77        (per_side * per_side) / (self.scale_factor * self.scale_factor)
78    }
79
80    /// Vision per-head dimension.
81    pub fn head_dim(&self) -> usize {
82        self.hidden_size / self.num_attention_heads
83    }
84
85    /// Parses the vision section of a multimodal `config.json` (returns
86    /// `None` when no `vision_config` object is present).
87    fn from_hf_config(config: &serde_json::Value) -> Result<Option<Self>> {
88        let Some(v) = config.get("vision_config").filter(|v| v.is_object()) else {
89            return Ok(None);
90        };
91        let get = |key: &str| v.get(key).and_then(|x| x.as_u64()).map(|x| x as usize);
92        let image_token_id = config
93            .get("image_token_id")
94            .and_then(|x| x.as_u64())
95            .ok_or_else(|| FormatError::MissingField("image_token_id".to_string()))?
96            as u32;
97        Ok(Some(VisionConfig {
98            image_size: get("image_size").unwrap_or(512),
99            patch_size: get("patch_size")
100                .ok_or_else(|| FormatError::MissingField("vision_config.patch_size".to_string()))?,
101            hidden_size: get("hidden_size")
102                .ok_or_else(|| FormatError::MissingField("vision_config.hidden_size".to_string()))?,
103            intermediate_size: get("intermediate_size")
104                .ok_or_else(|| FormatError::MissingField("vision_config.intermediate_size".to_string()))?,
105            num_hidden_layers: get("num_hidden_layers")
106                .ok_or_else(|| FormatError::MissingField("vision_config.num_hidden_layers".to_string()))?,
107            num_attention_heads: get("num_attention_heads")
108                .ok_or_else(|| FormatError::MissingField("vision_config.num_attention_heads".to_string()))?,
109            layer_norm_eps: v
110                .get("layer_norm_eps")
111                .and_then(|x| x.as_f64())
112                .unwrap_or(1e-12),
113            scale_factor: config
114                .get("scale_factor")
115                .and_then(|x| x.as_u64())
116                .unwrap_or(2) as usize,
117            image_token_id,
118        }))
119    }
120}
121
122fn get_u64(v: &serde_json::Value, key: &str) -> Result<u64> {
123    v.get(key)
124        .and_then(|x| x.as_u64())
125        .ok_or_else(|| FormatError::MissingField(key.to_string()))
126}
127
128fn get_f64(v: &serde_json::Value, key: &str, default: f64) -> f64 {
129    v.get(key).and_then(|x| x.as_f64()).unwrap_or(default)
130}
131
132/// Extracts token ids from a config value that may be a single id or an array.
133fn token_ids(v: Option<&serde_json::Value>) -> Vec<u32> {
134    match v {
135        Some(serde_json::Value::Array(arr)) => arr
136            .iter()
137            .filter_map(|x| x.as_u64().map(|n| n as u32))
138            .collect(),
139        Some(x) => x.as_u64().map(|n| vec![n as u32]).unwrap_or_default(),
140        None => Vec::new(),
141    }
142}
143
144impl ModelMetadata {
145    /// Parses metadata from a HuggingFace `config.json` value, optionally
146    /// merged with a `generation_config.json` value (which can override/add
147    /// bos/eos ids).
148    pub fn from_hf_config(
149        config: &serde_json::Value,
150        generation_config: Option<&serde_json::Value>,
151    ) -> Result<Self> {
152        // Multimodal configs (Idefics3/SmolVLM) nest the text hyperparameters
153        // under `text_config`; the architecture id stays at the root.
154        let text = config.get("text_config").unwrap_or(config);
155        let hidden_size = get_u64(text, "hidden_size")? as usize;
156        let num_attention_heads = get_u64(text, "num_attention_heads")? as usize;
157        let num_key_value_heads = text
158            .get("num_key_value_heads")
159            .and_then(|x| x.as_u64())
160            .map(|x| x as usize)
161            .unwrap_or(num_attention_heads);
162
163        let mut eos_token_ids = token_ids(text.get("eos_token_id"));
164        if let Some(gc) = generation_config {
165            for id in token_ids(gc.get("eos_token_id")) {
166                if !eos_token_ids.contains(&id) {
167                    eos_token_ids.push(id);
168                }
169            }
170        }
171
172        let bos_token_id = generation_config
173            .and_then(|gc| gc.get("bos_token_id"))
174            .and_then(|x| x.as_u64())
175            .map(|x| x as u32)
176            .or_else(|| {
177                text.get("bos_token_id")
178                    .and_then(|x| x.as_u64())
179                    .map(|x| x as u32)
180            });
181
182        let architecture = config
183            .get("model_type")
184            .and_then(|x| x.as_str())
185            .ok_or_else(|| FormatError::MissingField("model_type".to_string()))?
186            .to_string();
187
188        if hidden_size % num_attention_heads != 0 {
189            return Err(FormatError::MissingField(format!(
190                "hidden_size ({hidden_size}) not divisible by num_attention_heads ({num_attention_heads})"
191            )));
192        }
193
194        Ok(ModelMetadata {
195            architecture,
196            hidden_size,
197            intermediate_size: get_u64(text, "intermediate_size")? as usize,
198            num_hidden_layers: get_u64(text, "num_hidden_layers")? as usize,
199            num_attention_heads,
200            num_key_value_heads,
201            vocab_size: get_u64(text, "vocab_size")? as usize,
202            max_position_embeddings: text
203                .get("max_position_embeddings")
204                .and_then(|x| x.as_u64())
205                .unwrap_or(2048) as usize,
206            rms_norm_eps: get_f64(text, "rms_norm_eps", 1e-5),
207            rope_theta: get_f64(text, "rope_theta", 10000.0),
208            tie_word_embeddings: config
209                .get("tie_word_embeddings")
210                .or_else(|| text.get("tie_word_embeddings"))
211                .and_then(|x| x.as_bool())
212                .unwrap_or(false),
213            head_dim: hidden_size / num_attention_heads,
214            attention_bias: text
215                .get("attention_bias")
216                .and_then(|x| x.as_bool())
217                .unwrap_or(false),
218            bos_token_id,
219            eos_token_ids,
220            vision: VisionConfig::from_hf_config(config)?,
221        })
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn parses_smollm2_style_config() {
231        let config = serde_json::json!({
232            "model_type": "llama",
233            "hidden_size": 576,
234            "intermediate_size": 1536,
235            "num_hidden_layers": 30,
236            "num_attention_heads": 9,
237            "num_key_value_heads": 3,
238            "vocab_size": 49152,
239            "max_position_embeddings": 8192,
240            "rms_norm_eps": 1e-5,
241            "rope_theta": 100000,
242            "tie_word_embeddings": true,
243            "eos_token_id": 0,
244            "bos_token_id": 0
245        });
246        let meta = ModelMetadata::from_hf_config(&config, None).unwrap();
247        assert_eq!(meta.architecture, "llama");
248        assert_eq!(meta.head_dim, 64);
249        assert_eq!(meta.num_key_value_heads, 3);
250        assert_eq!(meta.rope_theta, 100000.0);
251        assert!(meta.tie_word_embeddings);
252        assert_eq!(meta.eos_token_ids, vec![0]);
253    }
254
255    #[test]
256    fn merges_generation_config_eos_array() {
257        let config = serde_json::json!({
258            "model_type": "llama", "hidden_size": 8, "intermediate_size": 16,
259            "num_hidden_layers": 1, "num_attention_heads": 2, "vocab_size": 10,
260            "eos_token_id": 1
261        });
262        let gen = serde_json::json!({ "eos_token_id": [1, 2] });
263        let meta = ModelMetadata::from_hf_config(&config, Some(&gen)).unwrap();
264        assert_eq!(meta.eos_token_ids, vec![1, 2]);
265        // GQA default: kv heads == q heads.
266        assert_eq!(meta.num_key_value_heads, 2);
267    }
268
269    #[test]
270    fn parses_nested_idefics3_config() {
271        let config = serde_json::json!({
272            "model_type": "idefics3",
273            "image_token_id": 49190,
274            "scale_factor": 4,
275            "tie_word_embeddings": false,
276            "text_config": {
277                "hidden_size": 576,
278                "intermediate_size": 1536,
279                "num_hidden_layers": 30,
280                "num_attention_heads": 9,
281                "num_key_value_heads": 3,
282                "vocab_size": 49280,
283                "max_position_embeddings": 8192,
284                "rms_norm_eps": 1e-5,
285                "rope_theta": 100000,
286                "eos_token_id": 2
287            },
288            "vision_config": {
289                "hidden_size": 768,
290                "intermediate_size": 3072,
291                "num_hidden_layers": 12,
292                "num_attention_heads": 12,
293                "image_size": 512,
294                "patch_size": 16,
295                "layer_norm_eps": 1e-6
296            }
297        });
298        let meta = ModelMetadata::from_hf_config(&config, None).unwrap();
299        assert_eq!(meta.architecture, "idefics3");
300        assert_eq!(meta.hidden_size, 576);
301        assert_eq!(meta.eos_token_ids, vec![2]);
302        let v = meta.vision.expect("vision config parsed");
303        assert_eq!(v.hidden_size, 768);
304        assert_eq!(v.image_token_id, 49190);
305        assert_eq!(v.scale_factor, 4);
306        assert_eq!(v.head_dim(), 64);
307        // (512/16)² / 4² = 1024/16 = 64 visual tokens per image.
308        assert_eq!(v.image_seq_len(), 64);
309    }
310}