Skip to main content

ferrum_models/
definition.rs

1//! Model definition and configuration parsing
2
3use crate::{registry::Architecture, source::ResolvedModelSource};
4use ferrum_types::{
5    Activation, AttentionConfig, FerrumError, ModelInfo, ModelType, NormType, Result, RopeScaling,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use tracing::{debug, warn};
11
12/// Model definition from config.json
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ModelDefinition {
15    /// Architecture type
16    pub architecture: Architecture,
17    /// Hidden size (embedding dimension)
18    pub hidden_size: usize,
19    /// Intermediate size (FFN dimension)
20    pub intermediate_size: usize,
21    /// Vocabulary size
22    pub vocab_size: usize,
23    /// Number of hidden layers
24    pub num_hidden_layers: usize,
25    /// Number of attention heads
26    pub num_attention_heads: usize,
27    /// Number of key-value heads (for GQA)
28    pub num_key_value_heads: Option<usize>,
29    /// Maximum position embeddings
30    pub max_position_embeddings: usize,
31    /// RoPE theta (frequency base)
32    pub rope_theta: Option<f64>,
33    /// RoPE scaling config
34    pub rope_scaling: Option<RopeScaling>,
35    /// Normalization type
36    pub norm_type: NormType,
37    /// Normalization epsilon
38    pub norm_eps: f64,
39    /// Attention configuration
40    pub attention_config: AttentionConfig,
41    /// Activation function
42    pub activation: Activation,
43    /// Extra parameters
44    #[serde(flatten)]
45    pub extra_params: serde_json::Value,
46}
47
48impl Default for ModelDefinition {
49    fn default() -> Self {
50        Self {
51            architecture: Architecture::Llama,
52            hidden_size: 4096,
53            intermediate_size: 11008,
54            vocab_size: 32000,
55            num_hidden_layers: 32,
56            num_attention_heads: 32,
57            num_key_value_heads: None,
58            max_position_embeddings: 2048,
59            rope_theta: Some(10000.0),
60            rope_scaling: None,
61            norm_type: NormType::RMSNorm,
62            norm_eps: 1e-6,
63            attention_config: AttentionConfig {
64                attention_bias: false,
65                sliding_window: None,
66            },
67            activation: Activation::SiLU,
68            extra_params: serde_json::Value::Object(serde_json::Map::new()),
69        }
70    }
71}
72
73impl ModelDefinition {
74    /// Convert to ModelInfo
75    pub fn to_model_info(&self, model_id: impl Into<String>) -> ModelInfo {
76        use ferrum_types::{DataType, Device};
77
78        let model_id_str = model_id.into();
79
80        // Calculate approximate parameter count
81        let params = self.estimate_parameters();
82
83        ModelInfo {
84            model_id: ferrum_types::ModelId::new(model_id_str.clone()),
85            model_type: ModelType::Custom(format!("{:?}", self.architecture)),
86            num_parameters: params as u64,
87            hidden_size: self.hidden_size,
88            num_layers: self.num_hidden_layers,
89            num_heads: self.num_attention_heads,
90            num_kv_heads: self.num_key_value_heads.unwrap_or(self.num_attention_heads),
91            vocab_size: self.vocab_size,
92            max_sequence_length: self.max_position_embeddings,
93            dtype: DataType::FP16, // Default, can be overridden
94            device: Device::CPU,   // Default, will be set by backend
95            version: None,
96            license: None,
97            metadata: HashMap::new(),
98        }
99    }
100
101    /// Estimate parameter count
102    fn estimate_parameters(&self) -> usize {
103        // Rough estimation based on typical transformer architecture
104        let embedding_params = self.vocab_size * self.hidden_size;
105        let layer_params = self.num_hidden_layers
106            * (
107                // Attention: Q, K, V, O projections
108                4 * self.hidden_size * self.hidden_size +
109            // FFN: up, down, gate (if applicable)
110            3 * self.hidden_size * self.intermediate_size +
111            // Layer norms
112            2 * self.hidden_size
113            );
114        let lm_head_params = self.vocab_size * self.hidden_size;
115
116        embedding_params + layer_params + lm_head_params
117    }
118}
119
120/// Configuration manager for loading and parsing model configs
121#[derive(Debug, Default)]
122pub struct ConfigManager {
123    _cache: HashMap<String, ModelDefinition>,
124}
125
126impl ConfigManager {
127    pub fn new() -> Self {
128        Self {
129            _cache: HashMap::new(),
130        }
131    }
132
133    /// Load model definition from a resolved source
134    pub async fn load_from_source(
135        &mut self,
136        source: &ResolvedModelSource,
137    ) -> Result<ModelDefinition> {
138        self.load_from_path(&source.local_path).await
139    }
140
141    /// Load model definition from a directory path
142    pub async fn load_from_path(&mut self, path: &Path) -> Result<ModelDefinition> {
143        let config_path = path.join("config.json");
144
145        if !config_path.exists() {
146            return Err(FerrumError::model(format!(
147                "config.json not found in model directory: {:?}",
148                path
149            )));
150        }
151
152        debug!("Loading model config from: {:?}", config_path);
153
154        let content = tokio::fs::read_to_string(&config_path)
155            .await
156            .map_err(|e| FerrumError::io(format!("Failed to read config.json: {}", e)))?;
157
158        self.load_from_bytes(content.as_bytes())
159    }
160
161    /// Parse a model definition from bytes retained by a resolved product
162    /// source lease. This keeps startup decisions on the same immutable
163    /// `config.json` identity used by model-family registration.
164    pub fn load_from_bytes(&mut self, config_json: &[u8]) -> Result<ModelDefinition> {
165        let raw_config: serde_json::Value = serde_json::from_slice(config_json)
166            .map_err(|e| FerrumError::model(format!("Failed to parse config.json: {}", e)))?;
167
168        self.parse_config(&raw_config)
169    }
170
171    /// Crate-internal test seam over the private parser.
172    #[doc(hidden)]
173    pub(crate) fn parse_config_for_tests(
174        &mut self,
175        raw: &serde_json::Value,
176    ) -> Result<ModelDefinition> {
177        self.parse_config(raw)
178    }
179
180    /// Parse config from JSON value
181    fn parse_config(&mut self, raw: &serde_json::Value) -> Result<ModelDefinition> {
182        let obj = raw
183            .as_object()
184            .ok_or_else(|| FerrumError::model("config.json root is not an object"))?;
185
186        // Detect architecture
187        let architecture = self.detect_architecture(raw)?;
188
189        // Gemma 3 multimodal checkpoints (Gemma3ForConditionalGeneration)
190        // nest the language model under `text_config`. Flatten it over the
191        // root so field extraction and `extra_params` lookups (head_dim /
192        // sliding_window / rope_local_base_freq / query_pre_attn_scalar /
193        // rope_scaling) see the text-model values; vision_config is
194        // ignored — text-only support.
195        let text_merged: Option<serde_json::Map<String, serde_json::Value>> =
196            if architecture == Architecture::Gemma3 {
197                obj.get("text_config")
198                    .and_then(|v| v.as_object())
199                    .map(|tc| {
200                        let mut m = obj.clone();
201                        for (k, v) in tc {
202                            m.insert(k.clone(), v.clone());
203                        }
204                        m
205                    })
206            } else {
207                None
208            };
209        let obj = text_merged.as_ref().unwrap_or(obj);
210
211        // Parse common fields (CLIP stores these in text_config/vision_config)
212        let text_cfg = obj.get("text_config");
213        let hidden_size = obj
214            .get("hidden_size")
215            .and_then(|v| v.as_u64())
216            .or_else(|| {
217                text_cfg
218                    .and_then(|tc| tc.get("hidden_size"))
219                    .and_then(|v| v.as_u64())
220            })
221            .unwrap_or(4096) as usize;
222
223        let intermediate_size = obj
224            .get("intermediate_size")
225            .and_then(|v| v.as_u64())
226            .or_else(|| obj.get("ffn_dim").and_then(|v| v.as_u64()))
227            .unwrap_or(11008) as usize;
228
229        // CLIP models store vocab_size in text_config, not at top level
230        let vocab_size = obj
231            .get("vocab_size")
232            .and_then(|v| v.as_u64())
233            .or_else(|| {
234                text_cfg
235                    .and_then(|tc| tc.get("vocab_size"))
236                    .and_then(|v| v.as_u64())
237            })
238            .unwrap_or(0) as usize;
239
240        let num_hidden_layers = obj
241            .get("num_hidden_layers")
242            .and_then(|v| v.as_u64())
243            .or_else(|| obj.get("n_layer").and_then(|v| v.as_u64()))
244            .unwrap_or(32) as usize;
245
246        let num_attention_heads = obj
247            .get("num_attention_heads")
248            .and_then(|v| v.as_u64())
249            .or_else(|| obj.get("n_head").and_then(|v| v.as_u64()))
250            .unwrap_or(32) as usize;
251
252        let num_key_value_heads = obj
253            .get("num_key_value_heads")
254            .and_then(|v| v.as_u64())
255            .map(|v| v as usize);
256
257        let max_position_embeddings = obj
258            .get("max_position_embeddings")
259            .and_then(|v| v.as_u64())
260            .or_else(|| obj.get("n_positions").and_then(|v| v.as_u64()))
261            .unwrap_or(2048) as usize;
262
263        let rope_theta = obj
264            .get("rope_theta")
265            .and_then(|v| v.as_f64())
266            .or_else(|| obj.get("rotary_emb_base").and_then(|v| v.as_f64()))
267            .or_else(|| {
268                obj.get("rope_parameters")
269                    .and_then(|v| v.as_object())
270                    .and_then(|rope| rope.get("rope_theta"))
271                    .and_then(|v| v.as_f64())
272            });
273
274        // Parse RoPE scaling
275        let rope_scaling = obj
276            .get("rope_scaling")
277            .and_then(|v| serde_json::from_value(v.clone()).ok());
278
279        // Detect norm type
280        let norm_type = if obj.get("rms_norm_eps").is_some() {
281            NormType::RMSNorm
282        } else {
283            NormType::LayerNorm
284        };
285
286        let norm_eps = obj
287            .get("rms_norm_eps")
288            .or_else(|| obj.get("layer_norm_eps"))
289            .or_else(|| obj.get("layer_norm_epsilon"))
290            .and_then(|v| v.as_f64())
291            .unwrap_or(1e-6);
292
293        // Parse attention config
294        let attention_bias = obj
295            .get("attention_bias")
296            .and_then(|v| v.as_bool())
297            .unwrap_or(false);
298
299        let sliding_window = obj
300            .get("sliding_window")
301            .and_then(|v| v.as_u64())
302            .map(|v| v as usize);
303
304        // Detect activation. Gemma family uses `hidden_activation`
305        // ("gelu_pytorch_tanh") instead of `hidden_act`.
306        let activation = obj
307            .get("hidden_act")
308            .or_else(|| obj.get("hidden_activation"))
309            .and_then(|v| v.as_str())
310            .map(|s| match s {
311                "gelu" | "gelu_new" => Activation::GELU,
312                "gelu_pytorch_tanh" => Activation::GeluTanh,
313                "silu" => Activation::SiLU,
314                "relu" => Activation::ReLU,
315                "swish" => Activation::Swish,
316                _ => {
317                    warn!("Unknown activation function: {}, defaulting to SiLU", s);
318                    Activation::SiLU
319                }
320            })
321            .unwrap_or(Activation::SiLU);
322
323        Ok(ModelDefinition {
324            architecture,
325            hidden_size,
326            intermediate_size,
327            vocab_size,
328            num_hidden_layers,
329            num_attention_heads,
330            num_key_value_heads,
331            max_position_embeddings,
332            rope_theta,
333            rope_scaling,
334            norm_type,
335            norm_eps,
336            attention_config: AttentionConfig {
337                attention_bias,
338                sliding_window,
339            },
340            activation,
341            // Merged view for Gemma3 (text_config flattened) so that
342            // downstream extra_params lookups (head_dim, sliding_window,
343            // rope_local_base_freq, query_pre_attn_scalar, ...) resolve.
344            extra_params: serde_json::Value::Object(obj.clone()),
345        })
346    }
347
348    /// Detect architecture from config
349    fn detect_architecture(&self, config: &serde_json::Value) -> Result<Architecture> {
350        let obj = config
351            .as_object()
352            .ok_or_else(|| FerrumError::model("config.json root is not an object"))?;
353
354        // The concrete Hugging Face class is the product identity used by the
355        // vNext/legacy registries. Keep the transitional parser on that same
356        // authority when a broad `model_type` label is also present.
357        if let Some(architectures) = obj.get("architectures").and_then(|v| v.as_array()) {
358            if let Some(arch) = architectures.first().and_then(|v| v.as_str()) {
359                return Ok(Architecture::from_str(arch));
360            }
361        }
362
363        // Older local fixtures without a concrete class retain their legacy
364        // model_type fallback outside the registered product path.
365        if let Some(model_type) = obj.get("model_type").and_then(|v| v.as_str()) {
366            return Ok(Architecture::from_str(model_type));
367        }
368
369        warn!("Could not detect architecture, using default (Llama)");
370        Ok(Architecture::Llama)
371    }
372
373    /// Infer model type from definition
374    pub fn infer_model_type(&self, definition: &ModelDefinition) -> ModelType {
375        ModelType::Custom(format!("{:?}", definition.architecture))
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn concrete_architecture_identity_precedes_broad_model_type() {
385        let manager = ConfigManager::new();
386        let config = serde_json::json!({
387            "model_type": "qwen3",
388            "architectures": ["LlamaForCausalLM"]
389        });
390
391        assert_eq!(
392            manager.detect_architecture(&config).unwrap(),
393            Architecture::Llama
394        );
395    }
396}