mecha10-behavior-runtime 0.7.0

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! JSON configuration types for behavior composition
//!
//! This module provides types for defining behavior trees in JSON configuration files.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// A behavior tree definition loaded from a JSON configuration file.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BehaviorConfig {
    /// URL of the JSON schema this configuration conforms to.
    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
    #[schemars(example = &"https://mecha10.dev/schemas/behavior-composition-v1.json")]
    pub schema: Option<String>,

    /// Name of this behavior.
    #[schemars(example = &"patrol_mission")]
    pub name: String,

    /// Human-readable description of what this behavior does.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[schemars(example = &"Patrol behavior with safety layer")]
    pub description: Option<String>,

    /// The root node of the behavior tree.
    pub root: CompositionConfig,

    /// Named configuration values referenced by nodes in the behavior tree via `config_ref`.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[schemars(example = HashMap::from([
        ("safety".to_string(), serde_json::json!({"max_speed": 1.0})),
        ("detector".to_string(), serde_json::json!({"confidence": 0.7})),
        ("wander".to_string(), serde_json::json!({"speed": 0.5})),
    ]))]
    pub configs: HashMap<String, serde_json::Value>,
}

/// A node in a behavior tree: either a composite control-flow node or a leaf behavior node.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CompositionConfig {
    /// Runs its children in order, stopping early if one fails.
    Sequence {
        /// Child nodes to run in order.
        children: Vec<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Runs its children in order, stopping early if one succeeds.
    Selector {
        /// Child nodes to try in order.
        children: Vec<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Runs all of its children concurrently.
    Parallel {
        /// Child nodes to run concurrently.
        children: Vec<CompositionConfig>,

        /// Policy determining when this node succeeds based on its children's outcomes.
        #[serde(default = "default_parallel_policy")]
        policy: ParallelPolicyConfig,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Like `Parallel`, but every child is re-ticked every call regardless of its previous
    /// status - no "frozen once resolved" memory. Use this (not `Parallel`) for a condition that
    /// must be re-evaluated indefinitely, e.g. inside a `Subsumption` layer that needs to flip
    /// between claiming and ceding control many times over a session.
    Watch {
        /// Child nodes, all re-ticked every call.
        children: Vec<CompositionConfig>,

        /// Policy determining when this node succeeds/fails based on its children's outcomes.
        #[serde(default = "default_parallel_policy")]
        policy: ParallelPolicyConfig,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Priority-based preemption (subsumption architecture): tries each layer highest-priority
    /// first, the first one to return `Running`/`Success` "wins" and its status is returned for
    /// this tick (lower-priority layers aren't ticked at all that cycle, so their own internal
    /// progress - e.g. a `Sequence`'s current child index - is naturally preserved rather than
    /// reset, which is what gives a preempted lower layer "resume where it left off" semantics
    /// for free). A layer returns `Failure` to cede control to the next layer down, within the
    /// *same* tick - this matters: if every layer fails, `Subsumption` itself returns `Failure`,
    /// which the top-level executor treats as "whole tree finished, reset everything" - so a
    /// layer meant to cede control temporarily (not finish the whole tree) must never be the sole
    /// reason the overall tick ends in `Failure` while a lower layer still has work to do; in
    /// practice this means: as long as there's always at least one always-available fallback
    /// layer (e.g. the lowest-priority layer is something like `wander`/`patrol` that's
    /// essentially never `Failure`), a higher layer ceding just causes an immediate same-tick
    /// fallthrough to that layer instead, and `Subsumption` returns *that* layer's status
    /// (typically `Running`) - never reaching the "all layers failed" case at all.
    Subsumption {
        /// Priority layers, highest priority first (ties broken by declaration order).
        layers: Vec<SubsumptionLayerConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Negates its child's `Success`/`Failure` result; `Running` passes through unchanged.
    Inverter {
        /// The single child node to invert.
        child: Box<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Reports `Success` once its child resolves, regardless of the child's actual result;
    /// `Running` passes through unchanged. Useful for an optional/best-effort step whose
    /// failure shouldn't abort a surrounding `Sequence`.
    ForceSuccess {
        /// The single child node to force-succeed.
        child: Box<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Reports `Failure` once its child resolves, regardless of the child's actual result;
    /// `Running` passes through unchanged. The mirror of `ForceSuccess`.
    ForceFailure {
        /// The single child node to force-fail.
        child: Box<CompositionConfig>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Re-runs its child every time it succeeds, up to an optional fixed `count`; a `Failure`
    /// ends the loop immediately with `Failure`. Omit `count` to repeat indefinitely.
    Repeat {
        /// The single child node to repeat.
        child: Box<CompositionConfig>,

        /// Number of successful runs required before this node succeeds. Omit to repeat
        /// indefinitely (the node only ever stops via the child failing).
        #[serde(skip_serializing_if = "Option::is_none")]
        count: Option<u32>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Re-runs its child up to `max_attempts` times whenever it fails, succeeding as soon as one
    /// attempt succeeds; only fails once every attempt is exhausted.
    Retry {
        /// The single child node to retry.
        child: Box<CompositionConfig>,

        /// Maximum number of attempts (must be at least 1).
        #[schemars(example = &3)]
        max_attempts: u32,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// Bounds how long its child may stay `Running`: fails immediately once `timeout_secs` have
    /// elapsed since this node started ticking the child (without resolving).
    Timeout {
        /// The single child node to bound.
        child: Box<CompositionConfig>,

        /// Maximum time, in seconds, the child is allowed to stay `Running`.
        #[schemars(example = &30.0)]
        timeout_secs: f64,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },

    /// A leaf node that runs a single behavior node implementation.
    Node {
        /// Name of the behavior node type to run.
        #[schemars(example = &"wander")]
        node: String,

        /// Key into the top-level `configs` map to use as this node's configuration.
        #[serde(skip_serializing_if = "Option::is_none")]
        #[schemars(example = &"wander")]
        config_ref: Option<String>,

        /// Inline configuration for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        config: Option<serde_json::Value>,

        /// Optional name for this node.
        #[serde(skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },
}

fn default_parallel_policy() -> ParallelPolicyConfig {
    ParallelPolicyConfig::RequireAll
}

/// Determines when a `Parallel` composition node succeeds, based on its children's outcomes.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ParallelPolicyConfig {
    /// Succeeds only once all children succeed.
    RequireAll,

    /// Succeeds once any one child succeeds.
    RequireOne,

    /// Succeeds once at least the given number of children succeed.
    RequireN(usize),
}

impl From<ParallelPolicyConfig> for crate::composition::ParallelPolicy {
    fn from(config: ParallelPolicyConfig) -> Self {
        match config {
            ParallelPolicyConfig::RequireAll => crate::composition::ParallelPolicy::RequireAll,
            ParallelPolicyConfig::RequireOne => crate::composition::ParallelPolicy::RequireOne,
            ParallelPolicyConfig::RequireN(n) => crate::composition::ParallelPolicy::RequireN(n),
        }
    }
}

/// A single priority layer within a `Subsumption` composition node.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SubsumptionLayerConfig {
    /// Layer priority - higher wins. Layers are tried highest-first each tick.
    pub priority: u8,

    /// The composition node implementing this layer.
    pub node: CompositionConfig,

    /// Optional name for this layer, used for debugging/logging.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

/// Configuration referencing a behavior node type and its inline configuration.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct NodeConfig {
    /// Name of the behavior node type.
    #[schemars(example = &"wander")]
    pub node: String,

    /// Inline configuration for this node.
    #[serde(default)]
    pub config: serde_json::Value,
}

#[derive(Debug, Clone)]
pub struct NodeReference {
    pub node_type: String,

    pub config: serde_json::Value,

    pub name: Option<String>,
}

impl BehaviorConfig {
    /// Load a behavior configuration from a JSON string.
    ///
    /// # Example
    ///
    /// ```rust
    /// use mecha10_behavior_runtime::BehaviorConfig;
    ///
    /// let json = r#"{
    ///     "name": "test_behavior",
    ///     "root": {
    ///         "type": "sequence",
    ///         "children": []
    ///     }
    /// }"#;
    ///
    /// let config = BehaviorConfig::from_json(json).unwrap();
    /// assert_eq!(config.name, "test_behavior");
    /// ```
    pub fn from_json(json: &str) -> anyhow::Result<Self> {
        Ok(serde_json::from_str(json)?)
    }

    /// Load a behavior configuration from a JSON file.
    pub fn from_file(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
        let content = std::fs::read_to_string(path)?;
        Self::from_json(&content)
    }

    /// Convert this configuration to JSON.
    pub fn to_json(&self) -> anyhow::Result<String> {
        Ok(serde_json::to_string_pretty(self)?)
    }

    /// Generate a JSON schema for this configuration type.
    ///
    /// This can be used for editor IntelliSense and validation.
    pub fn generate_schema() -> anyhow::Result<String> {
        Ok(serde_json::to_string_pretty(&Self::schema_value())?)
    }

    /// The `schemars`-derived JSON Schema for this configuration type, as a
    /// `serde_json::Value` -- the same schema [`generate_schema`](Self::generate_schema)
    /// serializes to a pretty-printed string, `super::validator`'s runtime validator
    /// is built from, and `cargo run --example export_schemas`
    /// (`packages/cli/examples/export_schemas.rs`) renders to `schemas/behavior.schema.json`,
    /// the single standard-named `schemas/` source of truth for this type (LAB-4245 folded
    /// the old separate `cargo run --example generate_schema` →
    /// `schemas/behavior_composition_v1.json` path into this one).
    pub fn schema_value() -> serde_json::Value {
        let schema = schemars::schema_for!(BehaviorConfig);
        serde_json::to_value(&schema).expect("schemars schema always serializes to JSON")
    }

    /// Get a configuration value by reference key.
    pub fn get_config(&self, key: &str) -> Option<&serde_json::Value> {
        self.configs.get(key)
    }
}