mecha10-core 0.8.0

Mecha10 core runtime and foundational types
Documentation
//! The shared base config contract every node's resolved config composes with.
//!
//! # Problem
//!
//! Node configs are validated by two separate, independent mechanisms today: a built-in node's
//! config goes through a hardcoded match over its node type (`mecha10_schema_registry`'s
//! `node_type_schema`), a custom (project-authored) node's config instead falls back to a
//! file-lookup (`mecha10_core::schema_validation::validate`'s `resolve_custom_node_schema_file`).
//! Neither path has anywhere for fields that are common/reserved across *every* node's config to
//! live - the one that already exists, [`RelayTopicsField`](crate::relay_topics::RelayTopicsField),
//! only reaches a node's config by that node's author remembering to embed it themselves via
//! `#[serde(flatten)]`, with nothing enforcing that every node actually does.
//!
//! # Design
//!
//! [`NodeConfigBase`] is that single shared place: every node's config struct embeds it via
//! `#[serde(flatten)] pub base: NodeConfigBase` at the top level, the same pattern
//! [`RelayTopicsField`](crate::relay_topics::RelayTopicsField) already uses on its own - this
//! struct simply becomes the one place that field (and any other base/reserved field) is
//! declared, instead of every node's config re-declaring it individually.
//!
//! # What else lives here, and why (LAB-4384 follow-up)
//!
//! Besides `relay_topics`, this struct also now owns:
//! - `topics` ([`Topics`](crate::topic_utils::Topics)) - every single built-in node declares a
//!   `topics` field of this exact same shared type (per-role publish/subscribe wiring). That
//!   universality is exactly the test this module's own docs use for "belongs on
//!   `NodeConfigBase`", so it moved too, not just `relay_topics`.
//! - `model` ([`NodeModelConfig`](crate::model::NodeModelConfig)) - `object-detector`,
//!   `image-classifier`, and `rl` each declared their own near-identical "which model does this
//!   node use" struct; now a single shared type. `llm-command`'s analogous "which model"/"which
//!   backend" concept used to be two separate fields (`llm_model: Option<String>`,
//!   `provider: Option<Provider>`), but both are facets of the same "which model does this node
//!   use" concept - `llm-command` reads/writes `model.name`/`model.provider` via its own
//!   `llm_model()`/`provider()` accessors instead of declaring two more fields here.
//! - `status_rate_hz` - `camera`/`motor`/`teleop` each declared an identical "how often, in Hz,
//!   to publish node status updates" field with a per-node default (camera: 1.0, motor/teleop:
//!   10.0) - same shared-shape-with-per-node-default treatment as `model`.
//! - `controller` - `imu`/`motor` each declare a `{"type": "mock"|<hardware-name>, ...}`
//!   tagged-union field selecting a mock vs. real hardware backend. Unlike `model`/`topics`,
//!   there's no single Rust type to share here - every field beyond `type` is already a
//!   per-node, per-variant free-form shape (`imu`'s `mpu6050` has `i2c_bus`/`i2c_address`,
//!   `motor`'s `l298n` has different pins entirely), so this is carried as an opaque
//!   [`serde_json::Value`] rather than a shared enum. Each node deserializes it into its own
//!   strongly-typed enum via its own `controller()` accessor (mirroring the `model()` pattern) -
//!   this field only relocates *where* the JSON key lives, not how it's validated.
//!
//! All of these are `Option`/empty-default at this type's level - this struct never bakes in any
//! *particular* node's real default value (e.g. `object-detector`'s default model name differs
//! from `rl`'s), it only recognizes the field *exists* at the top level so
//! `split_base_and_custom` doesn't drop it. Each node resolves its own real default from an
//! absent/empty value itself - see each affected node's own `model()`/`status_rate_hz()`/
//! `controller()` accessor.
//!
//! Each node's own config-specific fields live nested under a `custom` key in the actual
//! resolved config JSON, not flattened alongside the base fields - so a base field and a
//! node-specific field can never collide on the same name.
//!
//! This struct's `schemars`-derived JSON Schema (`schemars::schema_for!(NodeConfigBase)`,
//! exported as `base.schema.json`) is the schema every node's full config schema composes with
//! (`LAB-4387`'s `compose_node_schema`). `LAB-4388` wired that composition into a built-in
//! node's own `<name>.schema.json` file, and `LAB-4389` deleted the hardcoded per-built-in-type
//! dispatch `mecha10_core::schema_validation::validate` used to consult first, leaving the
//! `<name>.schema.json` file lookup every node (built-in or custom) now shares as the only
//! resolution path. Migrating any existing node's config struct onto this type is `LAB-4386`.
//! This is a hard breaking change for existing on-disk node configs once that work lands -
//! there is deliberately no dual-read/legacy fallback for the pre-`custom`-key shape.

use crate::model::NodeModelConfig;
use crate::relay_topics::RelayTopicsField;
use crate::topic_utils::Topics;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};

/// Shared default for a `frame_skip: u32` field (process every frame) - used by any node config
/// that has one (e.g. `object-detector`, `rl`) so they don't each declare an identical private
/// `fn default_frame_skip() -> u32 { 1 }`. Not part of [`NodeConfigBase`] itself - each node
/// still declares its own `frame_skip` field; this is only the shared default-value helper.
pub fn default_frame_skip() -> u32 {
    1
}

/// Fields common to every node's configuration, reserved across all node types.
#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
#[schemars(description = "Fields common to every node's configuration, reserved across all node types.")]
pub struct NodeConfigBase {
    /// Which of this node's published topics should be relayed to the dashboard.
    #[serde(flatten)]
    pub relay: RelayTopicsField,

    /// Per-role publish/subscribe topic configuration - every built-in node declares one of
    /// these.
    #[serde(default)]
    pub topics: Topics,

    /// Which model this node should load, for the nodes that have this concept
    /// (`object-detector`, `image-classifier`, `rl`, `llm-command` - the latter via its
    /// `name`/`provider` fields only, see the module docs). `None` for every other node, and
    /// for these four when the key is simply absent - see the module docs for why this is never
    /// a node-specific default value.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<NodeModelConfig>,

    /// How often, in Hz, to publish node status updates, for the nodes that have this concept
    /// (`camera`, `motor`, `teleop`). `None` for every other node, and for these three when the
    /// key is simply absent - see the module docs for why this is never a node-specific default
    /// value.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status_rate_hz: Option<f32>,

    /// The mock-vs-hardware controller backend selector, for the nodes that have this concept
    /// (`imu`, `motor`). Opaque - see the module docs for why this can't be a shared Rust type
    /// the way `model`/`topics` are.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub controller: Option<serde_json::Value>,
}

/// Mirrors [`NodeConfigBase`]'s own fields exactly, as a flat set of plain (non-flatten) named
/// fields - used only as [`NodeConfigBase`]'s manual [`Deserialize`] target, see that impl's doc
/// comment for why.
#[derive(Deserialize)]
struct NodeConfigBaseShadow {
    #[serde(default)]
    relay_topics: Option<Vec<String>>,
    #[serde(default)]
    topics: Topics,
    #[serde(default)]
    model: Option<NodeModelConfig>,
    #[serde(default)]
    status_rate_hz: Option<f32>,
    #[serde(default)]
    controller: Option<serde_json::Value>,
}

impl<'de> Deserialize<'de> for NodeConfigBase {
    /// Deserializes via [`NodeConfigBaseShadow`] - a plain struct with no `#[serde(flatten)]`
    /// fields of its own - rather than a derived `#[serde(flatten)] relay: RelayTopicsField`
    /// field, which would be a second, nested level of flatten once this struct is itself
    /// flattened into a node's own `#[serde(deny_unknown_fields)]` config struct (the only way
    /// this type is ever used - see the struct doc comment). Nested flatten defeats
    /// `deny_unknown_fields`: the outer struct's generated code can no longer tell a genuine
    /// typo (e.g. `behaviour_name`) from a field this type legitimately owns, and silently
    /// accepts both. Every field this struct declares - `relay_topics`, `topics`, `model`,
    /// `status_rate_hz`, `controller` - is already a plain top-level JSON key by name, so the
    /// shadow struct can read them all as ordinary named fields with no flatten needed
    /// anywhere.
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let shadow = NodeConfigBaseShadow::deserialize(deserializer)?;
        Ok(NodeConfigBase {
            relay: RelayTopicsField {
                relay_topics: shadow.relay_topics,
            },
            topics: shadow.topics,
            model: shadow.model,
            status_rate_hz: shadow.status_rate_hz,
            controller: shadow.controller,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn flattens_relay_topics_at_the_top_level() {
        let base: NodeConfigBase = serde_json::from_str(r#"{ "relay_topics": ["/vision/classification"] }"#).unwrap();
        assert_eq!(
            base.relay.relay_topics,
            Some(vec!["/vision/classification".to_string()])
        );
    }

    #[test]
    fn omitted_relay_topics_is_none() {
        let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
        assert_eq!(base.relay.relay_topics, None);
    }

    #[test]
    fn round_trips_through_serialization() {
        let base = NodeConfigBase {
            relay: RelayTopicsField {
                relay_topics: Some(vec!["/a/b".to_string()]),
            },
            ..NodeConfigBase::default()
        };
        let value = serde_json::to_value(&base).unwrap();
        assert_eq!(
            value,
            serde_json::json!({
                "relay_topics": ["/a/b"],
                "topics": { "publishes": [], "subscribes": [] }
            })
        );
    }

    #[test]
    fn json_schema_exposes_relay_topics_field() {
        let schema = schemars::schema_for!(NodeConfigBase);
        let value = serde_json::to_value(&schema).unwrap();
        assert!(value["properties"].get("relay_topics").is_some());
    }

    #[test]
    fn json_schema_exposes_topics_model_status_rate_hz_and_controller_fields() {
        let schema = schemars::schema_for!(NodeConfigBase);
        let value = serde_json::to_value(&schema).unwrap();
        for field in ["topics", "model", "status_rate_hz", "controller"] {
            assert!(value["properties"].get(field).is_some(), "missing property: {field}");
        }
    }

    #[test]
    fn deserializes_topics_at_the_top_level() {
        let json = r#"{
            "topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] }
        }"#;
        let base: NodeConfigBase = serde_json::from_str(json).unwrap();
        assert_eq!(base.topics.published_topic("output", "/default"), "/a/b");
    }

    #[test]
    fn omitted_topics_defaults_to_empty() {
        let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
        assert!(base.topics.publishes.is_empty());
        assert!(base.topics.subscribes.is_empty());
    }

    #[test]
    fn deserializes_model_at_the_top_level() {
        let json = r#"{"model": {"name": "yolov8n", "path": "models/catalog/yolov8n/model.onnx"}}"#;
        let base: NodeConfigBase = serde_json::from_str(json).unwrap();
        let model = base.model.unwrap();
        assert_eq!(model.name, "yolov8n");
        assert_eq!(model.path, "models/catalog/yolov8n/model.onnx");
    }

    #[test]
    fn omitted_model_is_none() {
        let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
        assert_eq!(base.model, None);
    }

    #[test]
    fn deserializes_provider_nested_under_model() {
        let json = r#"{"model": {"name": "gpt-4o-mini", "provider": "openai"}}"#;
        let base: NodeConfigBase = serde_json::from_str(json).unwrap();
        let model = base.model.unwrap();
        assert_eq!(model.provider, Some(crate::model::Provider::Openai));
        assert_eq!(model.name, "gpt-4o-mini");
    }

    #[test]
    fn deserializes_status_rate_hz_at_the_top_level() {
        let json = r#"{"status_rate_hz": 5.0}"#;
        let base: NodeConfigBase = serde_json::from_str(json).unwrap();
        assert_eq!(base.status_rate_hz, Some(5.0));
    }

    #[test]
    fn omitted_status_rate_hz_is_none() {
        let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
        assert_eq!(base.status_rate_hz, None);
    }

    #[test]
    fn deserializes_controller_at_the_top_level_as_opaque_json() {
        let json = r#"{"controller": {"type": "mpu6050", "i2c_bus": "/dev/i2c-1"}}"#;
        let base: NodeConfigBase = serde_json::from_str(json).unwrap();
        assert_eq!(
            base.controller,
            Some(serde_json::json!({"type": "mpu6050", "i2c_bus": "/dev/i2c-1"}))
        );
    }

    #[test]
    fn omitted_controller_is_none() {
        let base: NodeConfigBase = serde_json::from_str("{}").unwrap();
        assert_eq!(base.controller, None);
    }

    /// Regression test for the exact way every node's own config struct actually embeds this
    /// type: `#[serde(flatten)] pub base: NodeConfigBase` on a struct that also has
    /// `#[serde(deny_unknown_fields)]` (e.g. `BehaviorExecutorConfig`). A derived
    /// `#[serde(flatten)] relay: RelayTopicsField` field here would be a second, nested level of
    /// flatten once `NodeConfigBase` itself gets flattened into that outer struct - and
    /// `deny_unknown_fields` silently stops rejecting *any* field, known or not, once flatten is
    /// nested two levels deep (this is what broke LAB-4401: `relay_topics` got rejected as
    /// "unknown" even though `NodeConfigBase` legitimately declares it, flattened one level too
    /// deep for the outer struct's generated deny-unknown-fields check to see it).
    #[test]
    fn deserializes_cleanly_when_flattened_into_a_deny_unknown_fields_struct() {
        #[derive(Debug, Deserialize)]
        #[serde(deny_unknown_fields)]
        struct OuterConfig {
            #[allow(dead_code)]
            name: String,
            #[serde(flatten)]
            base: NodeConfigBase,
        }

        let json = r#"{"name": "n", "relay_topics": ["/a/b"]}"#;
        let outer: OuterConfig =
            serde_json::from_str(json).expect("relay_topics must deserialize through the outer flatten");
        assert_eq!(outer.base.relay.relay_topics, Some(vec!["/a/b".to_string()]));

        let typo_json = r#"{"name": "n", "relay_topicz": ["/a/b"]}"#;
        let err = serde_json::from_str::<OuterConfig>(typo_json).unwrap_err();
        assert!(
            err.to_string().to_lowercase().contains("unknown field"),
            "a real typo must still be rejected: {err}"
        );
    }

    /// Same regression as `deserializes_cleanly_when_flattened_into_a_deny_unknown_fields_struct`,
    /// but for the fields added after `relay_topics` (`topics`, `model`, `status_rate_hz`,
    /// `controller`) - proving the shadow-struct approach still avoids nested flatten for every
    /// field this type now declares, not just the original one.
    #[test]
    fn deserializes_all_new_fields_cleanly_when_flattened_into_a_deny_unknown_fields_struct() {
        #[derive(Debug, Deserialize)]
        #[serde(deny_unknown_fields)]
        struct OuterConfig {
            #[allow(dead_code)]
            name: String,
            #[serde(flatten)]
            base: NodeConfigBase,
        }

        let json = r#"{
            "name": "n",
            "topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] },
            "model": { "name": "claude-3", "provider": "claude" },
            "status_rate_hz": 5.0,
            "controller": { "type": "mock" }
        }"#;
        let outer: OuterConfig =
            serde_json::from_str(json).expect("new base fields must deserialize through the outer flatten");

        assert_eq!(outer.base.topics.published_topic("output", "/default"), "/a/b");
        assert_eq!(outer.base.model.as_ref().unwrap().name, "claude-3");
        assert_eq!(outer.base.model.unwrap().provider, Some(crate::model::Provider::Claude));
        assert_eq!(outer.base.status_rate_hz, Some(5.0));
        assert_eq!(outer.base.controller, Some(serde_json::json!({"type": "mock"})));

        let typo_json = r#"{"name": "n", "modell": {"name": "yolov8n"}}"#;
        let err = serde_json::from_str::<OuterConfig>(typo_json).unwrap_err();
        assert!(
            err.to_string().to_lowercase().contains("unknown field"),
            "a real typo must still be rejected: {err}"
        );
    }
}