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}