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//! # Ok::<(), anyhow::Error>(())
24//! ```
25
26use anyhow::{Context, Result};
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use std::path::PathBuf;
30
31pub const CATALOG_MODELS_SUBDIR: &str = "models/catalog";
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ModelConfig {
35 pub name: String,
36 pub task: String,
37 pub repo: String,
38 pub filename: String,
39 pub input_size: [u32; 2],
40 pub preprocessing: PreprocessingConfig,
41 pub num_classes: usize,
42 pub labels_file: String,
43 #[serde(default)]
44 pub custom_labels: CustomLabelsConfig,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PreprocessingConfig {
49 pub mean: [f32; 3],
50 pub std: [f32; 3],
51 pub channel_order: String,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct CustomLabelsConfig {
56 #[serde(default)]
57 pub enabled: bool,
58 #[serde(default)]
59 pub additional_classes: Vec<String>,
60}
61
62/// Where a node should resolve a model it references from.
63///
64/// Shared by every built-in node that points at an on-disk model this way (`object-detector`,
65/// `image-classifier`, `rl` - see [`NodeModelConfig`]).
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum ModelSource {
69 /// The public catalog of example models.
70 #[default]
71 Catalog,
72 /// The private per-project model registry.
73 Registry,
74}
75
76/// A node's reference to the on-disk model it should load - shared/reserved across every
77/// built-in node that has this concept (`object-detector`, `image-classifier`, `rl`), moved
78/// onto [`crate::node_config_base::NodeConfigBase`] (LAB-4384 follow-up) the same way
79/// `relay_topics` already was, rather than each of those nodes re-declaring its own
80/// near-identical `ModelConfig` struct.
81///
82/// This is deliberately a *different* type from [`ModelConfig`] above: that one is the model
83/// catalog's own on-disk metadata (`models/catalog/<name>/config.json` - task, repo, filename,
84/// preprocessing, labels); this one is a *node's pointer* to a model (which one, where its file
85/// lives, where to resolve it from), a completely different concept that happens to share the
86/// word "model".
87///
88/// `name`/`path` default to the empty string rather than this type baking in any one node's
89/// real default value (object-detector defaults to `"yolov8n"`, rl to `"find-target"`,
90/// image-classifier to `"mobilenet-v2"` - genuinely different per node). Each node resolves its
91/// own real default for an empty value itself (see each node's own `model()` accessor on its
92/// `Config` type) - this mirrors exactly how `#[serde(default)]` on this struct's own fields
93/// already behaved before this type was shared (calling `Default::default()`, not a per-node
94/// default function, when the field - or the whole `model` key - is merely absent).
95///
96/// `confidence_threshold` only has meaning for `image-classifier` today (object-detector/rl
97/// ignore it) - it lives here rather than as a `image-classifier`-only extra field so this
98/// struct stays the single shared shape for the "which model does this node use" concept across
99/// all three nodes rather than splintering into near-identical siblings again.
100///
101/// `provider` only has meaning for `llm-command` (object-detector/image-classifier/rl ignore
102/// it) - it used to be a separate field on `NodeConfigBase` alongside `model`, but "which
103/// backend resolves this model" is part of the same "which model does this node use" concept,
104/// so it consolidated into this one struct instead of two separate `Option` fields on the base.
105#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
106#[serde(deny_unknown_fields)]
107pub struct NodeModelConfig {
108 /// Name of the model to load. Empty means "not set" - the owning node resolves its own
109 /// default in that case.
110 #[serde(default)]
111 pub name: String,
112
113 /// Which LLM backend to query (`llm-command`-specific; ignored by other nodes using this
114 /// type). `None` means "not set" - the owning node resolves its own default in that case.
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub provider: Option<Provider>,
117
118 /// Filesystem path to the model file. Empty means "not set" - the owning node resolves its
119 /// own default in that case.
120 #[serde(default)]
121 pub path: String,
122
123 /// Where to resolve the model from.
124 #[serde(default)]
125 pub source: ModelSource,
126
127 /// Specific model version to use (registry-sourced models only); latest if omitted.
128 #[serde(default)]
129 pub version: Option<i32>,
130
131 /// Minimum confidence score a classification result must meet to be reported
132 /// (`image-classifier`-specific; ignored by other nodes using this type).
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub confidence_threshold: Option<f32>,
135}
136
137/// Which LLM backend a node should query - shared/reserved the same way [`NodeModelConfig`] is,
138/// for `llm-command`'s analogous "which model does this node use" concern (LAB-4384 follow-up).
139/// `llm-command` is the only node with this concept today, so unlike [`NodeModelConfig`] there's
140/// no cross-node shape to reconcile - this just lives in `mecha10-core` (rather than
141/// `mecha10-nodes-llm-command`) so [`crate::node_config_base::NodeConfigBase`] can reference it.
142#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
143#[serde(rename_all = "lowercase")]
144pub enum Provider {
145 /// OpenAI's hosted API.
146 Openai,
147 /// Anthropic's hosted API.
148 Claude,
149 /// A local Ollama server.
150 #[default]
151 Local,
152}
153
154crate::enum_display_via_str!(Provider {
155 Openai => "openai",
156 Claude => "claude",
157 Local => "local",
158});
159
160/// Load model configuration from disk
161///
162/// Loads the complete model configuration from `models/catalog/<name>/config.json`.
163///
164/// # Arguments
165///
166/// * `model_name` - Name of the model (e.g., "mobilenet-v2")
167///
168/// # Returns
169///
170/// The complete `ModelConfig` with all metadata and preprocessing parameters.
171///
172/// # Errors
173///
174/// Returns an error if:
175/// - The config file doesn't exist
176/// - The config file is not valid JSON
177/// - The config file is missing required fields
178///
179/// # Example
180///
181/// ```no_run
182/// use mecha10_core::model::load_model_config;
183///
184/// let config = load_model_config("mobilenet-v2")?;
185/// println!("Model: {} ({})", config.name, config.task);
186/// println!("Input size: {:?}", config.input_size);
187/// println!("Preprocessing: mean={:?}, std={:?}", config.preprocessing.mean, config.preprocessing.std);
188/// # Ok::<(), anyhow::Error>(())
189/// ```
190pub fn load_model_config(model_name: &str) -> Result<ModelConfig> {
191 let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
192 .join(model_name)
193 .join("config.json");
194
195 if !config_path.exists() {
196 anyhow::bail!(
197 "Model config not found for '{}' at {}. Run: mecha10 model pull {}",
198 model_name,
199 config_path.display(),
200 model_name
201 );
202 }
203
204 let content = std::fs::read_to_string(&config_path)
205 .context(format!("Failed to read model config: {}", config_path.display()))?;
206
207 let config: ModelConfig = serde_json::from_str(&content)
208 .context(format!("Failed to parse model config.json: {}", config_path.display()))?;
209
210 Ok(config)
211}
212
213/// Load class labels from model directory
214///
215/// Loads class labels from `models/catalog/<name>/labels.txt` with support for custom labels.
216/// If custom labels are enabled in the model config, they will be appended to the base labels.
217///
218/// # Arguments
219///
220/// * `model_name` - Name of the model (e.g., "mobilenet-v2")
221///
222/// # Returns
223///
224/// A vector of class label strings. The index in the vector corresponds to the class ID.
225///
226/// # Errors
227///
228/// Returns an error if:
229/// - The labels file doesn't exist
230/// - The labels file cannot be read
231/// - The model config cannot be loaded (when checking for custom labels)
232///
233/// # Example
234///
235/// ```no_run
236/// use mecha10_core::model::load_labels;
237///
238/// let labels = load_labels("mobilenet-v2")?;
239/// println!("Loaded {} class labels", labels.len());
240/// println!("First label: {}", labels[0]);
241/// # Ok::<(), anyhow::Error>(())
242/// ```
243pub fn load_labels(model_name: &str) -> Result<Vec<String>> {
244 let labels_path = PathBuf::from(CATALOG_MODELS_SUBDIR).join(model_name).join("labels.txt");
245
246 if !labels_path.exists() {
247 anyhow::bail!(
248 "Labels file not found for model '{}' at {}. Run: mecha10 model pull {}",
249 model_name,
250 labels_path.display(),
251 model_name
252 );
253 }
254
255 tracing::info!("📋 Loading labels from: {}", labels_path.display());
256
257 let content = std::fs::read_to_string(&labels_path)
258 .context(format!("Failed to read labels file: {}", labels_path.display()))?;
259
260 let mut labels: Vec<String> = content.lines().map(|s| s.trim().to_string()).collect();
261
262 let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
263 .join(model_name)
264 .join("config.json");
265 if config_path.exists() {
266 if let Ok(config_content) = std::fs::read_to_string(&config_path) {
267 if let Ok(config) = serde_json::from_str::<ModelConfig>(&config_content) {
268 if config.custom_labels.enabled {
269 let num_custom = config.custom_labels.additional_classes.len();
270 labels.extend(config.custom_labels.additional_classes);
271 tracing::info!("✅ Added {} custom labels (total: {} labels)", num_custom, labels.len());
272 }
273 }
274 }
275 }
276
277 tracing::info!("✅ Loaded {} labels", labels.len());
278 Ok(labels)
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_model_config_serialization() {
287 let config = ModelConfig {
288 name: "test-model".to_string(),
289 task: "image-classification".to_string(),
290 repo: "test/repo".to_string(),
291 filename: "model.onnx".to_string(),
292 input_size: [224, 224],
293 preprocessing: PreprocessingConfig {
294 mean: [0.485, 0.456, 0.406],
295 std: [0.229, 0.224, 0.225],
296 channel_order: "RGB".to_string(),
297 },
298 num_classes: 1000,
299 labels_file: "labels.txt".to_string(),
300 custom_labels: CustomLabelsConfig::default(),
301 };
302
303 let json = serde_json::to_string(&config).unwrap();
304 let deserialized: ModelConfig = serde_json::from_str(&json).unwrap();
305
306 assert_eq!(config.name, deserialized.name);
307 assert_eq!(config.input_size, deserialized.input_size);
308 assert_eq!(config.preprocessing.mean, deserialized.preprocessing.mean);
309 }
310
311 /// Regression test for `LAB-2073`: a fresh `mecha10 model pull
312 /// <name>`-style write must produce files this module's own path
313 /// construction can find. `mecha10_models::ModelService::catalog_model_dir`
314 /// (see `packages/mecha10-models/src/services/model_service/mod.rs`)
315 /// writes catalog pulls to `<models_dir>/catalog/<name>/` (`LAB-1802`);
316 /// this asserts `load_model_config`/`load_labels`'s path construction
317 /// matches that layout exactly rather than drifting back to the
318 /// pre-`LAB-1802` flat `models/<name>/` path. `mecha10-models` can't be
319 /// imported directly here (see `CATALOG_MODELS_SUBDIR`'s doc comment), so
320 /// this pins the literal `"catalog"` segment instead - mirroring
321 /// `object-detector`'s
322 /// `default_model_path_matches_model_services_catalog_write_location`.
323 #[test]
324 fn catalog_paths_match_model_services_catalog_write_location() {
325 assert_eq!(CATALOG_MODELS_SUBDIR, "models/catalog");
326
327 let model_name = "mobilenet-v2";
328 let config_path = PathBuf::from(CATALOG_MODELS_SUBDIR)
329 .join(model_name)
330 .join("config.json");
331 let labels_path = PathBuf::from(CATALOG_MODELS_SUBDIR).join(model_name).join("labels.txt");
332
333 assert_eq!(config_path, PathBuf::from("models/catalog/mobilenet-v2/config.json"));
334 assert_eq!(labels_path, PathBuf::from("models/catalog/mobilenet-v2/labels.txt"));
335 }
336}