mecha10_core/node_config_base.rs
1//! The shared base config contract every node's resolved config composes with.
2//!
3//! # Problem
4//!
5//! Node configs are validated by two separate, independent mechanisms today: a built-in node's
6//! config goes through a hardcoded match over its node type (`mecha10_schema_registry`'s
7//! `node_type_schema`), a custom (project-authored) node's config instead falls back to a
8//! file-lookup (`mecha10_core::schema_validation::validate`'s `resolve_custom_node_schema_file`).
9//! Neither path has anywhere for fields that are common/reserved across *every* node's config to
10//! live - the one that already exists, [`RelayTopicsField`](crate::relay_topics::RelayTopicsField),
11//! only reaches a node's config by that node's author remembering to embed it themselves via
12//! `#[serde(flatten)]`, with nothing enforcing that every node actually does.
13//!
14//! # Design
15//!
16//! [`NodeConfigBase`] is that single shared place: every node's config struct embeds it via
17//! `#[serde(flatten)] pub base: NodeConfigBase` at the top level, the same pattern
18//! [`RelayTopicsField`](crate::relay_topics::RelayTopicsField) already uses on its own - this
19//! struct simply becomes the one place that field (and any other base/reserved field) is
20//! declared, instead of every node's config re-declaring it individually.
21//!
22//! # What else lives here, and why (LAB-4384 follow-up)
23//!
24//! Besides `relay_topics`, this struct also now owns:
25//! - `topics` ([`Topics`](crate::topic_utils::Topics)) - every single built-in node declares a
26//! `topics` field of this exact same shared type (per-role publish/subscribe wiring). That
27//! universality is exactly the test this module's own docs use for "belongs on
28//! `NodeConfigBase`", so it moved too, not just `relay_topics`.
29//! - `model` ([`NodeModelConfig`](crate::model::NodeModelConfig)) - `object-detector`,
30//! `image-classifier`, and `rl` each declared their own near-identical "which model does this
31//! node use" struct; now a single shared type. `llm-command`'s analogous "which model"/"which
32//! backend" concept used to be two separate fields (`llm_model: Option<String>`,
33//! `provider: Option<Provider>`), but both are facets of the same "which model does this node
34//! use" concept - `llm-command` reads/writes `model.name`/`model.provider` via its own
35//! `llm_model()`/`provider()` accessors instead of declaring two more fields here.
36//! - `task` - the task a node self-reports it implements (e.g. a trained imitation-learning
37//! policy's task name), surfaced over this node's `/system/health` heartbeat (`LAB-4825`) so
38//! the dashboard can show which tasks currently have a live node, without a separate
39//! request/response protocol. Unlike the fields above, this isn't a field moved off an
40//! existing node - no built-in node sets it yet; it's shared plumbing for a future
41//! task-running node.
42//! - `status_rate_hz` - `camera`/`motor`/`teleop` each declared an identical "how often, in Hz,
43//! to publish node status updates" field with a per-node default (camera: 1.0, motor/teleop:
44//! 10.0) - same shared-shape-with-per-node-default treatment as `model`.
45//! - `controller` - `imu`/`motor` each declare a `{"type": "mock"|<hardware-name>, ...}`
46//! tagged-union field selecting a mock vs. real hardware backend. Unlike `model`/`topics`,
47//! there's no single Rust type to share here - every field beyond `type` is already a
48//! per-node, per-variant free-form shape (`imu`'s `mpu6050` has `i2c_bus`/`i2c_address`,
49//! `motor`'s `l298n` has different pins entirely), so this is carried as an opaque
50//! [`serde_json::Value`] rather than a shared enum. Each node deserializes it into its own
51//! strongly-typed enum via its own `controller()` accessor (mirroring the `model()` pattern) -
52//! this field only relocates *where* the JSON key lives, not how it's validated.
53//! - `orientation` - physical-mounting correction, for the nodes that have this concept
54//! (`camera`, `servo`, `pan-tilt`). Same opaque-`serde_json::Value` treatment as `controller`
55//! and for the same reason: camera's shape (`flip_horizontal`/`flip_vertical`, an image
56//! transform) and servo/pan-tilt's shape (`invert`/`offset_rad`, an angle pre-transform
57//! applied before the existing angle->pulse-width calibration) are fundamentally different
58//! payloads with nothing in common beyond the field name.
59//!
60//! All of these are `Option`/empty-default at this type's level - this struct never bakes in any
61//! *particular* node's real default value (e.g. `object-detector`'s default model name differs
62//! from `rl`'s), it only recognizes the field *exists* at the top level so
63//! `split_base_and_custom` doesn't drop it. Each node resolves its own real default from an
64//! absent/empty value itself - see each affected node's own `model()`/`status_rate_hz()`/
65//! `controller()` accessor.
66//!
67//! Each node's own config-specific fields live nested under a `custom` key in the actual
68//! resolved config JSON, not flattened alongside the base fields - so a base field and a
69//! node-specific field can never collide on the same name.
70//!
71//! This struct's `schemars`-derived JSON Schema (`schemars::schema_for!(NodeConfigBase)`,
72//! exported as `base.schema.json`) is the schema every node's full config schema composes with
73//! (`LAB-4387`'s `compose_node_schema`). `LAB-4388` wired that composition into a built-in
74//! node's own `<name>.schema.json` file, and `LAB-4389` deleted the hardcoded per-built-in-type
75//! dispatch `mecha10_core::schema_validation::validate` used to consult first, leaving the
76//! `<name>.schema.json` file lookup every node (built-in or custom) now shares as the only
77//! resolution path. Migrating any existing node's config struct onto this type is `LAB-4386`.
78//! This is a hard breaking change for existing on-disk node configs once that work lands -
79//! there is deliberately no dual-read/legacy fallback for the pre-`custom`-key shape.
80
81use crate::model::NodeModelConfig;
82use crate::relay_topics::RelayTopicsField;
83use crate::topic_utils::Topics;
84use schemars::JsonSchema;
85use serde::{Deserialize, Deserializer, Serialize};
86
87/// Shared default for a `frame_skip: u32` field (process every frame) - used by any node config
88/// that has one (e.g. `object-detector`, `rl`) so they don't each declare an identical private
89/// `fn default_frame_skip() -> u32 { 1 }`. Not part of [`NodeConfigBase`] itself - each node
90/// still declares its own `frame_skip` field; this is only the shared default-value helper.
91pub fn default_frame_skip() -> u32 {
92 1
93}
94
95/// Fields common to every node's configuration, reserved across all node types.
96#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
97#[schemars(description = "Fields common to every node's configuration, reserved across all node types.")]
98pub struct NodeConfigBase {
99 /// Which of this node's published topics should be relayed to the dashboard.
100 #[serde(flatten)]
101 pub relay: RelayTopicsField,
102
103 /// Per-role publish/subscribe topic configuration - every built-in node declares one of
104 /// these.
105 #[serde(default)]
106 pub topics: Topics,
107
108 /// Which model this node should load, for the nodes that have this concept
109 /// (`object-detector`, `image-classifier`, `rl`, `llm-command` - the latter via its
110 /// `name`/`provider` fields only, see the module docs). `None` for every other node, and
111 /// for these four when the key is simply absent - see the module docs for why this is never
112 /// a node-specific default value.
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub model: Option<NodeModelConfig>,
115
116 /// The task this node implements, if any (e.g. a trained imitation-learning policy's task
117 /// name) - self-reported over this node's `/system/health` heartbeat so the dashboard can
118 /// show which tasks currently have a live node without a separate request/response
119 /// protocol. `None` for nodes that don't implement a specific task.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub task: Option<String>,
122
123 /// How often, in Hz, to publish node status updates, for the nodes that have this concept
124 /// (`camera`, `motor`, `teleop`). `None` for every other node, and for these three when the
125 /// key is simply absent - see the module docs for why this is never a node-specific default
126 /// value.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub status_rate_hz: Option<f32>,
129
130 /// The mock-vs-hardware controller backend selector, for the nodes that have this concept
131 /// (`imu`, `motor`). Opaque - see the module docs for why this can't be a shared Rust type
132 /// the way `model`/`topics` are.
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub controller: Option<serde_json::Value>,
135
136 /// Physical-mounting orientation correction, for the nodes that have this concept
137 /// (`camera`, `servo`, `pan-tilt`). Opaque - see the module docs for why this can't be a
138 /// shared Rust type the way `model`/`topics` are.
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub orientation: Option<serde_json::Value>,
141}
142
143/// Mirrors [`NodeConfigBase`]'s own fields exactly, as a flat set of plain (non-flatten) named
144/// fields - used only as [`NodeConfigBase`]'s manual [`Deserialize`] target, see that impl's doc
145/// comment for why.
146#[derive(Deserialize)]
147struct NodeConfigBaseShadow {
148 #[serde(default)]
149 relay_topics: Option<Vec<String>>,
150 #[serde(default)]
151 topics: Topics,
152 #[serde(default)]
153 model: Option<NodeModelConfig>,
154 #[serde(default)]
155 task: Option<String>,
156 #[serde(default)]
157 status_rate_hz: Option<f32>,
158 #[serde(default)]
159 controller: Option<serde_json::Value>,
160 #[serde(default)]
161 orientation: Option<serde_json::Value>,
162}
163
164impl<'de> Deserialize<'de> for NodeConfigBase {
165 /// Deserializes via [`NodeConfigBaseShadow`] - a plain struct with no `#[serde(flatten)]`
166 /// fields of its own - rather than a derived `#[serde(flatten)] relay: RelayTopicsField`
167 /// field, which would be a second, nested level of flatten once this struct is itself
168 /// flattened into a node's own `#[serde(deny_unknown_fields)]` config struct (the only way
169 /// this type is ever used - see the struct doc comment). Nested flatten defeats
170 /// `deny_unknown_fields`: the outer struct's generated code can no longer tell a genuine
171 /// typo (e.g. `behaviour_name`) from a field this type legitimately owns, and silently
172 /// accepts both. Every field this struct declares - `relay_topics`, `topics`, `model`,
173 /// `task`, `status_rate_hz`, `controller` - is already a plain top-level JSON key by name,
174 /// so the shadow struct can read them all as ordinary named fields with no flatten needed
175 /// anywhere.
176 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
177 where
178 D: Deserializer<'de>,
179 {
180 let shadow = NodeConfigBaseShadow::deserialize(deserializer)?;
181 Ok(NodeConfigBase {
182 relay: RelayTopicsField {
183 relay_topics: shadow.relay_topics,
184 },
185 topics: shadow.topics,
186 model: shadow.model,
187 task: shadow.task,
188 status_rate_hz: shadow.status_rate_hz,
189 controller: shadow.controller,
190 orientation: shadow.orientation,
191 })
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn flattens_relay_topics_at_the_top_level() {
201 let base: NodeConfigBase = serde_json::from_str(r#"{ "relay_topics": ["/vision/classification"] }"#).unwrap();
202 assert_eq!(
203 base.relay.relay_topics,
204 Some(vec!["/vision/classification".to_string()])
205 );
206 }
207
208 #[test]
209 fn omitted_relay_topics_is_none() {
210 let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
211 assert_eq!(base.relay.relay_topics, None);
212 }
213
214 #[test]
215 fn round_trips_through_serialization() {
216 let base = NodeConfigBase {
217 relay: RelayTopicsField {
218 relay_topics: Some(vec!["/a/b".to_string()]),
219 },
220 ..NodeConfigBase::default()
221 };
222 let value = serde_json::to_value(&base).unwrap();
223 assert_eq!(
224 value,
225 serde_json::json!({
226 "relay_topics": ["/a/b"],
227 "topics": { "publishes": [], "subscribes": [] }
228 })
229 );
230 }
231
232 #[test]
233 fn json_schema_exposes_relay_topics_field() {
234 let schema = schemars::schema_for!(NodeConfigBase);
235 let value = serde_json::to_value(&schema).unwrap();
236 assert!(value["properties"].get("relay_topics").is_some());
237 }
238
239 #[test]
240 fn json_schema_exposes_topics_model_status_rate_hz_and_controller_fields() {
241 let schema = schemars::schema_for!(NodeConfigBase);
242 let value = serde_json::to_value(&schema).unwrap();
243 for field in ["topics", "model", "status_rate_hz", "controller"] {
244 assert!(value["properties"].get(field).is_some(), "missing property: {field}");
245 }
246 }
247
248 #[test]
249 fn deserializes_topics_at_the_top_level() {
250 let json = r#"{
251 "topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] }
252 }"#;
253 let base: NodeConfigBase = serde_json::from_str(json).unwrap();
254 assert_eq!(base.topics.published_topic("output", "/default"), "/a/b");
255 }
256
257 #[test]
258 fn omitted_topics_defaults_to_empty() {
259 let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
260 assert!(base.topics.publishes.is_empty());
261 assert!(base.topics.subscribes.is_empty());
262 }
263
264 #[test]
265 fn deserializes_model_at_the_top_level() {
266 let json = r#"{"model": {"name": "yolov8n", "path": "models/catalog/yolov8n/model.onnx"}}"#;
267 let base: NodeConfigBase = serde_json::from_str(json).unwrap();
268 let model = base.model.unwrap();
269 assert_eq!(model.name, "yolov8n");
270 assert_eq!(model.path, "models/catalog/yolov8n/model.onnx");
271 }
272
273 #[test]
274 fn omitted_model_is_none() {
275 let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
276 assert_eq!(base.model, None);
277 }
278
279 #[test]
280 fn deserializes_provider_nested_under_model() {
281 let json = r#"{"model": {"name": "gpt-4o-mini", "provider": "openai"}}"#;
282 let base: NodeConfigBase = serde_json::from_str(json).unwrap();
283 let model = base.model.unwrap();
284 assert_eq!(model.provider, Some(crate::model::Provider::Openai));
285 assert_eq!(model.name, "gpt-4o-mini");
286 }
287
288 #[test]
289 fn deserializes_status_rate_hz_at_the_top_level() {
290 let json = r#"{"status_rate_hz": 5.0}"#;
291 let base: NodeConfigBase = serde_json::from_str(json).unwrap();
292 assert_eq!(base.status_rate_hz, Some(5.0));
293 }
294
295 #[test]
296 fn omitted_status_rate_hz_is_none() {
297 let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
298 assert_eq!(base.status_rate_hz, None);
299 }
300
301 #[test]
302 fn deserializes_controller_at_the_top_level_as_opaque_json() {
303 let json = r#"{"controller": {"type": "mpu6050", "i2c_bus": "/dev/i2c-1"}}"#;
304 let base: NodeConfigBase = serde_json::from_str(json).unwrap();
305 assert_eq!(
306 base.controller,
307 Some(serde_json::json!({"type": "mpu6050", "i2c_bus": "/dev/i2c-1"}))
308 );
309 }
310
311 #[test]
312 fn omitted_controller_is_none() {
313 let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
314 assert_eq!(base.controller, None);
315 }
316
317 /// Regression test for the exact way every node's own config struct actually embeds this
318 /// type: `#[serde(flatten)] pub base: NodeConfigBase` on a struct that also has
319 /// `#[serde(deny_unknown_fields)]` (e.g. `BehaviorExecutorConfig`). A derived
320 /// `#[serde(flatten)] relay: RelayTopicsField` field here would be a second, nested level of
321 /// flatten once `NodeConfigBase` itself gets flattened into that outer struct - and
322 /// `deny_unknown_fields` silently stops rejecting *any* field, known or not, once flatten is
323 /// nested two levels deep (this is what broke LAB-4401: `relay_topics` got rejected as
324 /// "unknown" even though `NodeConfigBase` legitimately declares it, flattened one level too
325 /// deep for the outer struct's generated deny-unknown-fields check to see it).
326 #[test]
327 fn deserializes_cleanly_when_flattened_into_a_deny_unknown_fields_struct() {
328 #[derive(Debug, Deserialize)]
329 #[serde(deny_unknown_fields)]
330 struct OuterConfig {
331 #[allow(dead_code)]
332 name: String,
333 #[serde(flatten)]
334 base: NodeConfigBase,
335 }
336
337 let json = r#"{"name": "n", "relay_topics": ["/a/b"]}"#;
338 let outer: OuterConfig =
339 serde_json::from_str(json).expect("relay_topics must deserialize through the outer flatten");
340 assert_eq!(outer.base.relay.relay_topics, Some(vec!["/a/b".to_string()]));
341
342 let typo_json = r#"{"name": "n", "relay_topicz": ["/a/b"]}"#;
343 let err = serde_json::from_str::<OuterConfig>(typo_json).unwrap_err();
344 assert!(
345 err.to_string().to_lowercase().contains("unknown field"),
346 "a real typo must still be rejected: {err}"
347 );
348 }
349
350 /// Same regression as `deserializes_cleanly_when_flattened_into_a_deny_unknown_fields_struct`,
351 /// but for the fields added after `relay_topics` (`topics`, `model`, `task`, `status_rate_hz`,
352 /// `controller`) - proving the shadow-struct approach still avoids nested flatten for every
353 /// field this type now declares, not just the original one.
354 #[test]
355 fn deserializes_all_new_fields_cleanly_when_flattened_into_a_deny_unknown_fields_struct() {
356 #[derive(Debug, Deserialize)]
357 #[serde(deny_unknown_fields)]
358 struct OuterConfig {
359 #[allow(dead_code)]
360 name: String,
361 #[serde(flatten)]
362 base: NodeConfigBase,
363 }
364
365 let json = r#"{
366 "name": "n",
367 "topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] },
368 "model": { "name": "claude-3", "provider": "claude" },
369 "task": "pick-and-place",
370 "status_rate_hz": 5.0,
371 "controller": { "type": "mock" }
372 }"#;
373 let outer: OuterConfig =
374 serde_json::from_str(json).expect("new base fields must deserialize through the outer flatten");
375
376 assert_eq!(outer.base.topics.published_topic("output", "/default"), "/a/b");
377 assert_eq!(outer.base.model.as_ref().unwrap().name, "claude-3");
378 assert_eq!(outer.base.model.unwrap().provider, Some(crate::model::Provider::Claude));
379 assert_eq!(outer.base.task, Some("pick-and-place".to_string()));
380 assert_eq!(outer.base.status_rate_hz, Some(5.0));
381 assert_eq!(outer.base.controller, Some(serde_json::json!({"type": "mock"})));
382
383 let typo_json = r#"{"name": "n", "modell": {"name": "yolov8n"}}"#;
384 let err = serde_json::from_str::<OuterConfig>(typo_json).unwrap_err();
385 assert!(
386 err.to_string().to_lowercase().contains("unknown field"),
387 "a real typo must still be rejected: {err}"
388 );
389 }
390}