Skip to main content

mecha10_core/
model.rs

1//! Model configuration utilities for AI nodes
2//!
3//! This module provides shared utilities for loading and managing ONNX model configurations.
4//! It's used by both the CLI (for model management) and AI nodes (for runtime inference).
5//!
6//! # Design
7//!
8//! Model configurations are stored in `models/catalog/<name>/config.json` and include:
9//! - Model metadata (name, task, repo, filename)
10//! - Preprocessing parameters (input_size, mean, std, channel_order)
11//! - Class labels configuration
12//!
13//! # Usage
14//!
15//! ```no_run
16//! use mecha10_core::model::{load_model_config, load_labels};
17//!
18//! // Load complete model configuration
19//! let config = load_model_config("mobilenet-v2")?;
20//!
21//! // Load class labels
22//! let labels = load_labels("mobilenet-v2")?;
23//! ```
24
25use anyhow::{Context, Result};
26use serde::{Deserialize, Serialize};
27use std::path::PathBuf;
28
29/// `models/catalog` - the versionless namespaced subtree the public model
30/// catalog writes to (`LAB-1802`).
31///
32/// This mirrors `mecha10_models::ModelService::catalog_model_dir` (see
33/// `packages/mecha10-models/src/services/model_service/mod.rs`), which is the
34/// actual write location `mecha10 model pull` resolves to. It's hardcoded
35/// here (rather than imported) for the same reason
36/// `object-detector`'s `CATALOG_MODELS_SUBDIR` is: this crate shouldn't
37/// depend on `mecha10-models`, which pulls in `reqwest`/download tooling that
38/// has no business being a dependency of `mecha10-core`, and
39/// `catalog_model_dir` is `pub(super)` (not exported) besides. See
40/// `catalog_paths_match_model_services_catalog_write_location` below for the
41/// regression test tying this string to that module's doc-committed layout.
42pub const CATALOG_MODELS_SUBDIR: &str = "models/catalog";
43
44/// Model configuration stored in models/catalog/<name>/config.json
45///
46/// This is the complete configuration for a model, including metadata,
47/// preprocessing parameters, and label information.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ModelConfig {
50    /// Model name
51    pub name: String,
52    /// Task type (e.g., "image-classification", "object-detection")
53    pub task: String,
54    /// HuggingFace repository
55    pub repo: String,
56    /// Filename within the repo
57    pub filename: String,
58    /// Input size [width, height]
59    pub input_size: [u32; 2],
60    /// Preprocessing configuration
61    pub preprocessing: PreprocessingConfig,
62    /// Number of output classes
63    pub num_classes: usize,
64    /// Path to labels file (relative to model directory)
65    pub labels_file: String,
66    /// Custom labels configuration (for fine-tuning)
67    #[serde(default)]
68    pub custom_labels: CustomLabelsConfig,
69}
70
71/// Preprocessing configuration
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct PreprocessingConfig {
74    /// Mean values for normalization [R, G, B]
75    pub mean: [f32; 3],
76    /// Standard deviation for normalization [R, G, B]
77    pub std: [f32; 3],
78    /// Channel order (RGB or BGR)
79    pub channel_order: String,
80}
81
82/// Custom labels for fine-tuned models
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct CustomLabelsConfig {
85    /// Whether custom labels are enabled
86    #[serde(default)]
87    pub enabled: bool,
88    /// Additional classes for fine-tuned models
89    #[serde(default)]
90    pub additional_classes: Vec<String>,
91}
92
93/// Load model configuration from disk
94///
95/// Loads the complete model configuration from `models/catalog/<name>/config.json`.
96///
97/// # Arguments
98///
99/// * `model_name` - Name of the model (e.g., "mobilenet-v2")
100///
101/// # Returns
102///
103/// The complete `ModelConfig` with all metadata and preprocessing parameters.
104///
105/// # Errors
106///
107/// Returns an error if:
108/// - The config file doesn't exist
109/// - The config file is not valid JSON
110/// - The config file is missing required fields
111///
112/// # Example
113///
114/// ```no_run
115/// use mecha10_core::model::load_model_config;
116///
117/// let config = load_model_config("mobilenet-v2")?;
118/// println!("Model: {} ({})", config.name, config.task);
119/// println!("Input size: {:?}", config.input_size);
120/// println!("Preprocessing: mean={:?}, std={:?}", config.preprocessing.mean, config.preprocessing.std);
121/// # Ok::<(), anyhow::Error>(())
122/// ```
123pub fn load_model_config(model_name: &str) -> Result<ModelConfig> {
124    let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
125        .join(model_name)
126        .join("config.json");
127
128    if !config_path.exists() {
129        anyhow::bail!(
130            "Model config not found for '{}' at {}. Run: mecha10 model pull {}",
131            model_name,
132            config_path.display(),
133            model_name
134        );
135    }
136
137    let content = std::fs::read_to_string(&config_path)
138        .context(format!("Failed to read model config: {}", config_path.display()))?;
139
140    let config: ModelConfig = serde_json::from_str(&content)
141        .context(format!("Failed to parse model config.json: {}", config_path.display()))?;
142
143    Ok(config)
144}
145
146/// Load class labels from model directory
147///
148/// Loads class labels from `models/catalog/<name>/labels.txt` with support for custom labels.
149/// If custom labels are enabled in the model config, they will be appended to the base labels.
150///
151/// # Arguments
152///
153/// * `model_name` - Name of the model (e.g., "mobilenet-v2")
154///
155/// # Returns
156///
157/// A vector of class label strings. The index in the vector corresponds to the class ID.
158///
159/// # Errors
160///
161/// Returns an error if:
162/// - The labels file doesn't exist
163/// - The labels file cannot be read
164/// - The model config cannot be loaded (when checking for custom labels)
165///
166/// # Example
167///
168/// ```no_run
169/// use mecha10_core::model::load_labels;
170///
171/// let labels = load_labels("mobilenet-v2")?;
172/// println!("Loaded {} class labels", labels.len());
173/// println!("First label: {}", labels[0]);
174/// # Ok::<(), anyhow::Error>(())
175/// ```
176pub fn load_labels(model_name: &str) -> Result<Vec<String>> {
177    // Load from models/catalog/<name>/labels.txt
178    let labels_path = PathBuf::from(CATALOG_MODELS_SUBDIR).join(model_name).join("labels.txt");
179
180    if !labels_path.exists() {
181        anyhow::bail!(
182            "Labels file not found for model '{}' at {}. Run: mecha10 model pull {}",
183            model_name,
184            labels_path.display(),
185            model_name
186        );
187    }
188
189    tracing::info!("📋 Loading labels from: {}", labels_path.display());
190
191    let content = std::fs::read_to_string(&labels_path)
192        .context(format!("Failed to read labels file: {}", labels_path.display()))?;
193
194    let mut labels: Vec<String> = content.lines().map(|s| s.trim().to_string()).collect();
195
196    // Load model config to check for custom labels
197    let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
198        .join(model_name)
199        .join("config.json");
200    if config_path.exists() {
201        if let Ok(config_content) = std::fs::read_to_string(&config_path) {
202            if let Ok(config) = serde_json::from_str::<ModelConfig>(&config_content) {
203                if config.custom_labels.enabled {
204                    // Append custom labels
205                    let num_custom = config.custom_labels.additional_classes.len();
206                    labels.extend(config.custom_labels.additional_classes);
207                    tracing::info!("✅ Added {} custom labels (total: {} labels)", num_custom, labels.len());
208                }
209            }
210        }
211    }
212
213    tracing::info!("✅ Loaded {} labels", labels.len());
214    Ok(labels)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_model_config_serialization() {
223        let config = ModelConfig {
224            name: "test-model".to_string(),
225            task: "image-classification".to_string(),
226            repo: "test/repo".to_string(),
227            filename: "model.onnx".to_string(),
228            input_size: [224, 224],
229            preprocessing: PreprocessingConfig {
230                mean: [0.485, 0.456, 0.406],
231                std: [0.229, 0.224, 0.225],
232                channel_order: "RGB".to_string(),
233            },
234            num_classes: 1000,
235            labels_file: "labels.txt".to_string(),
236            custom_labels: CustomLabelsConfig::default(),
237        };
238
239        let json = serde_json::to_string(&config).unwrap();
240        let deserialized: ModelConfig = serde_json::from_str(&json).unwrap();
241
242        assert_eq!(config.name, deserialized.name);
243        assert_eq!(config.input_size, deserialized.input_size);
244        assert_eq!(config.preprocessing.mean, deserialized.preprocessing.mean);
245    }
246
247    /// Regression test for `LAB-2073`: a fresh `mecha10 model pull
248    /// <name>`-style write must produce files this module's own path
249    /// construction can find. `mecha10_models::ModelService::catalog_model_dir`
250    /// (see `packages/mecha10-models/src/services/model_service/mod.rs`)
251    /// writes catalog pulls to `<models_dir>/catalog/<name>/` (`LAB-1802`);
252    /// this asserts `load_model_config`/`load_labels`'s path construction
253    /// matches that layout exactly rather than drifting back to the
254    /// pre-`LAB-1802` flat `models/<name>/` path. `mecha10-models` can't be
255    /// imported directly here (see `CATALOG_MODELS_SUBDIR`'s doc comment), so
256    /// this pins the literal `"catalog"` segment instead - mirroring
257    /// `object-detector`'s
258    /// `default_model_path_matches_model_services_catalog_write_location`.
259    #[test]
260    fn catalog_paths_match_model_services_catalog_write_location() {
261        assert_eq!(CATALOG_MODELS_SUBDIR, "models/catalog");
262
263        let model_name = "mobilenet-v2";
264        let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
265            .join(model_name)
266            .join("config.json");
267        let labels_path = PathBuf::from(CATALOG_MODELS_SUBDIR).join(model_name).join("labels.txt");
268
269        assert_eq!(config_path, PathBuf::from("models/catalog/mobilenet-v2/config.json"));
270        assert_eq!(labels_path, PathBuf::from("models/catalog/mobilenet-v2/labels.txt"));
271    }
272}