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    let labels_path = PathBuf::from(CATALOG_MODELS_SUBDIR).join(model_name).join("labels.txt");
178
179    if !labels_path.exists() {
180        anyhow::bail!(
181            "Labels file not found for model '{}' at {}. Run: mecha10 model pull {}",
182            model_name,
183            labels_path.display(),
184            model_name
185        );
186    }
187
188    tracing::info!("📋 Loading labels from: {}", labels_path.display());
189
190    let content = std::fs::read_to_string(&labels_path)
191        .context(format!("Failed to read labels file: {}", labels_path.display()))?;
192
193    let mut labels: Vec<String> = content.lines().map(|s| s.trim().to_string()).collect();
194
195    let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
196        .join(model_name)
197        .join("config.json");
198    if config_path.exists() {
199        if let Ok(config_content) = std::fs::read_to_string(&config_path) {
200            if let Ok(config) = serde_json::from_str::<ModelConfig>(&config_content) {
201                if config.custom_labels.enabled {
202                    let num_custom = config.custom_labels.additional_classes.len();
203                    labels.extend(config.custom_labels.additional_classes);
204                    tracing::info!("✅ Added {} custom labels (total: {} labels)", num_custom, labels.len());
205                }
206            }
207        }
208    }
209
210    tracing::info!("✅ Loaded {} labels", labels.len());
211    Ok(labels)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_model_config_serialization() {
220        let config = ModelConfig {
221            name: "test-model".to_string(),
222            task: "image-classification".to_string(),
223            repo: "test/repo".to_string(),
224            filename: "model.onnx".to_string(),
225            input_size: [224, 224],
226            preprocessing: PreprocessingConfig {
227                mean: [0.485, 0.456, 0.406],
228                std: [0.229, 0.224, 0.225],
229                channel_order: "RGB".to_string(),
230            },
231            num_classes: 1000,
232            labels_file: "labels.txt".to_string(),
233            custom_labels: CustomLabelsConfig::default(),
234        };
235
236        let json = serde_json::to_string(&config).unwrap();
237        let deserialized: ModelConfig = serde_json::from_str(&json).unwrap();
238
239        assert_eq!(config.name, deserialized.name);
240        assert_eq!(config.input_size, deserialized.input_size);
241        assert_eq!(config.preprocessing.mean, deserialized.preprocessing.mean);
242    }
243
244    /// Regression test for `LAB-2073`: a fresh `mecha10 model pull
245    /// <name>`-style write must produce files this module's own path
246    /// construction can find. `mecha10_models::ModelService::catalog_model_dir`
247    /// (see `packages/mecha10-models/src/services/model_service/mod.rs`)
248    /// writes catalog pulls to `<models_dir>/catalog/<name>/` (`LAB-1802`);
249    /// this asserts `load_model_config`/`load_labels`'s path construction
250    /// matches that layout exactly rather than drifting back to the
251    /// pre-`LAB-1802` flat `models/<name>/` path. `mecha10-models` can't be
252    /// imported directly here (see `CATALOG_MODELS_SUBDIR`'s doc comment), so
253    /// this pins the literal `"catalog"` segment instead - mirroring
254    /// `object-detector`'s
255    /// `default_model_path_matches_model_services_catalog_write_location`.
256    #[test]
257    fn catalog_paths_match_model_services_catalog_write_location() {
258        assert_eq!(CATALOG_MODELS_SUBDIR, "models/catalog");
259
260        let model_name = "mobilenet-v2";
261        let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
262            .join(model_name)
263            .join("config.json");
264        let labels_path = PathBuf::from(CATALOG_MODELS_SUBDIR).join(model_name).join("labels.txt");
265
266        assert_eq!(config_path, PathBuf::from("models/catalog/mobilenet-v2/config.json"));
267        assert_eq!(labels_path, PathBuf::from("models/catalog/mobilenet-v2/labels.txt"));
268    }
269}