Skip to main content

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}