Skip to main content

mecha10_behavior_runtime/config/
validator.rs

1//! Validation for behavior tree configurations
2//!
3//! Ensures that behavior tree JSON is well-formed, matches the `schemars`-derived JSON Schema
4
5use super::types::BehaviorConfig;
6use crate::registry::NodeRegistry;
7use anyhow::{anyhow, Result};
8use mecha10_core::node::Capability;
9use serde_json::Value;
10use std::collections::HashSet;
11use std::sync::LazyLock;
12
13static BEHAVIOR_CONFIG_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
14    let root_schema = schemars::schema_for!(BehaviorConfig);
15    serde_json::to_value(root_schema).expect("schemars schema always serializes to JSON")
16});
17
18static BEHAVIOR_CONFIG_VALIDATOR: LazyLock<jsonschema::Validator> = LazyLock::new(|| {
19    jsonschema::validator_for(&BEHAVIOR_CONFIG_SCHEMA).expect("BehaviorConfig schema is a valid JSON Schema")
20});
21
22#[derive(Debug, Clone)]
23pub struct ValidationResult {
24    pub valid: bool,
25    pub errors: Vec<String>,
26    pub warnings: Vec<String>,
27}
28
29impl ValidationResult {
30    /// Create a successful validation result
31    pub fn success() -> Self {
32        Self {
33            valid: true,
34            errors: Vec::new(),
35            warnings: Vec::new(),
36        }
37    }
38
39    /// Create a failed validation result with errors
40    pub fn with_errors(errors: Vec<String>) -> Self {
41        Self {
42            valid: false,
43            errors,
44            warnings: Vec::new(),
45        }
46    }
47
48    /// Add a warning to the result
49    pub fn add_warning(&mut self, warning: String) {
50        self.warnings.push(warning);
51    }
52
53    /// Add an error to the result
54    pub fn add_error(&mut self, error: String) {
55        self.errors.push(error);
56        self.valid = false;
57    }
58}
59
60/// Validate a behavior composition JSON value against the `schemars`-derived
61/// [`BehaviorConfig`] JSON Schema -- structural validation only, no [`NodeRegistry`] required.
62/// Returns an error describing every schema violation found.
63pub fn validate_behavior_composition_json(config: &Value) -> Result<()> {
64    if !BEHAVIOR_CONFIG_VALIDATOR.is_valid(config) {
65        let messages: Vec<String> = BEHAVIOR_CONFIG_VALIDATOR
66            .iter_errors(config)
67            .map(|e| format!("  - {}: {} ({})", e.instance_path(), e, e.instance()))
68            .collect();
69        return Err(anyhow!(
70            "Behavior config schema validation failed:\n{}",
71            messages.join("\n")
72        ));
73    }
74
75    Ok(())
76}
77
78/// Validate a behavior tree configuration: checks it against the real `BehaviorConfig` JSON
79/// Schema (see [`validate_behavior_composition_json`]), that every leaf node's `node` type
80/// exists in `registry`, and that every capability it declares via `requires_capabilities` is
81/// present in `available_capabilities` -- rules a JSON Schema can't express on its own since
82/// they cross-reference the live node registry / the project's other nodes.
83///
84/// `available_capabilities` is expected to be `ProjectConfig::aggregated_capabilities()` (the
85/// union of every enabled node's declared capabilities) for the project this behavior is being
86/// validated against.
87pub fn validate_behavior_config(
88    config: &Value,
89    registry: &NodeRegistry,
90    available_capabilities: &HashSet<Capability>,
91) -> Result<ValidationResult> {
92    let mut result = ValidationResult::success();
93
94    if let Err(schema_err) = validate_behavior_composition_json(config) {
95        result.add_error(schema_err.to_string());
96    }
97
98    if let Some(root) = config.get("root") {
99        validate_node_types(root, registry, &mut result, "root");
100    }
101
102    validate_required_capabilities(config, available_capabilities, &mut result);
103
104    if config.get("$schema").is_none() {
105        result
106            .add_warning("No '$schema' field found. Consider adding schema reference for IDE validation.".to_string());
107    }
108
109    Ok(result)
110}
111
112/// Check that every capability this behavior declares via `requires_capabilities` is present in
113/// `available_capabilities` -- a rule the JSON Schema alone can't express since it cross-
114/// references another config (`ProjectConfig::aggregated_capabilities()`). An unsatisfied
115/// requirement is a hard validation error naming the missing capability, not a warning.
116fn validate_required_capabilities(
117    config: &Value,
118    available_capabilities: &HashSet<Capability>,
119    result: &mut ValidationResult,
120) {
121    let Some(required) = config.get("requires_capabilities").and_then(|v| v.as_array()) else {
122        return;
123    };
124
125    for value in required {
126        // An unparseable/unknown capability value is already reported by schema validation
127        // above; don't double-report it here.
128        let Ok(capability) = serde_json::from_value::<Capability>(value.clone()) else {
129            continue;
130        };
131
132        if !available_capabilities.contains(&capability) {
133            result.add_error(format!(
134                "Behavior requires capability '{}', which is not provided by any enabled node in this project. \
135                 Add a node that declares this capability, or remove it from 'requires_capabilities'.",
136                value.as_str().unwrap_or("<unknown>")
137            ));
138        }
139    }
140}
141
142/// Walk the composition tree checking that every leaf node's `node` identifier is
143/// registered in `registry` -- the one rule JSON Schema can't express. Skips anything schema
144/// validation already reports (non-object nodes, missing/invalid `type`/`children`).
145fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
146    let Some(obj) = node.as_object() else {
147        return;
148    };
149
150    let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
151        return;
152    };
153
154    match node_type {
155        "node" => {
156            if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
157                if !registry.has_node(node_name) {
158                    result.add_error(format!(
159                        "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
160                        path, node_name
161                    ));
162                }
163            }
164        }
165        "sequence" | "selector" | "parallel" | "watch" => {
166            if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
167                if children.is_empty() {
168                    result.add_warning(format!(
169                        "Composition node at '{}' has no children (will immediately return success/failure)",
170                        path
171                    ));
172                }
173
174                for (i, child) in children.iter().enumerate() {
175                    let child_path = format!("{}.children[{}]", path, i);
176                    validate_node_types(child, registry, result, &child_path);
177                }
178            }
179        }
180        "inverter" | "force_success" | "force_failure" | "repeat" | "retry" | "timeout" => {
181            if let Some(child) = obj.get("child") {
182                let child_path = format!("{}.child", path);
183                validate_node_types(child, registry, result, &child_path);
184            }
185        }
186        "subsumption" => {
187            if let Some(layers) = obj.get("layers").and_then(|v| v.as_array()) {
188                if layers.is_empty() {
189                    result.add_warning(format!(
190                        "Subsumption node at '{}' has no layers (will immediately return failure)",
191                        path
192                    ));
193                }
194
195                for (i, layer) in layers.iter().enumerate() {
196                    if let Some(node) = layer.get("node") {
197                        let child_path = format!("{}.layers[{}].node", path, i);
198                        validate_node_types(node, registry, result, &child_path);
199                    }
200                }
201            }
202        }
203        _ => {}
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::NodeRegistry;
211    use serde_json::json;
212
213    #[test]
214    fn test_validate_missing_name() {
215        let registry = NodeRegistry::new();
216        let config = json!({
217            "root": {
218                "type": "sequence",
219                "children": []
220            }
221        });
222
223        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
224        assert!(!result.valid);
225        assert!(result.errors.iter().any(|e| e.contains("name")));
226    }
227
228    #[test]
229    fn test_validate_missing_root() {
230        let registry = NodeRegistry::new();
231        let config = json!({
232            "name": "test"
233        });
234
235        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
236        assert!(!result.valid);
237        assert!(result.errors.iter().any(|e| e.contains("root")));
238    }
239
240    #[test]
241    fn test_validate_valid_config() {
242        let registry = NodeRegistry::new();
243        let config = json!({
244            "name": "test",
245            "root": {
246                "type": "sequence",
247                "children": []
248            }
249        });
250
251        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
252        assert!(result.valid, "{:?}", result.errors);
253    }
254
255    /// A behavior with no `requires_capabilities` at all (the common case) must be unaffected
256    /// by the capability check, regardless of what the project does or doesn't provide.
257    #[test]
258    fn test_validate_behavior_with_no_requires_capabilities_is_unaffected() {
259        let registry = NodeRegistry::new();
260        let config = json!({
261            "name": "test",
262            "root": {
263                "type": "sequence",
264                "children": []
265            }
266        });
267
268        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
269        assert!(result.valid, "{:?}", result.errors);
270    }
271
272    #[test]
273    fn test_validate_requires_capabilities_passes_when_satisfied() {
274        let registry = NodeRegistry::new();
275        let config = json!({
276            "name": "test",
277            "root": {
278                "type": "sequence",
279                "children": []
280            },
281            "requires_capabilities": ["locomotion"]
282        });
283
284        let available = HashSet::from([Capability::Locomotion]);
285        let result = validate_behavior_config(&config, &registry, &available).unwrap();
286        assert!(result.valid, "{:?}", result.errors);
287    }
288
289    #[test]
290    fn test_validate_requires_capabilities_fails_when_unsatisfied() {
291        let registry = NodeRegistry::new();
292        let config = json!({
293            "name": "test",
294            "root": {
295                "type": "sequence",
296                "children": []
297            },
298            "requires_capabilities": ["flight"]
299        });
300
301        // The project has no node providing "flight" -- e.g. just a locomotion node.
302        let available = HashSet::from([Capability::Locomotion]);
303        let result = validate_behavior_config(&config, &registry, &available).unwrap();
304        assert!(!result.valid);
305        assert!(
306            result.errors.iter().any(|e| e.contains("flight")),
307            "expected an error naming the missing 'flight' capability, got: {:?}",
308            result.errors
309        );
310    }
311
312    #[test]
313    fn test_validate_requires_capabilities_fails_naming_only_the_missing_one() {
314        let registry = NodeRegistry::new();
315        let config = json!({
316            "name": "test",
317            "root": {
318                "type": "sequence",
319                "children": []
320            },
321            "requires_capabilities": ["locomotion", "flight"]
322        });
323
324        let available = HashSet::from([Capability::Locomotion]);
325        let result = validate_behavior_config(&config, &registry, &available).unwrap();
326        assert!(!result.valid);
327        assert_eq!(result.errors.len(), 1, "{:?}", result.errors);
328        assert!(result.errors[0].contains("flight"));
329    }
330
331    #[test]
332    fn test_validate_unknown_node_type() {
333        let registry = NodeRegistry::new();
334        let config = json!({
335            "name": "test",
336            "root": {
337                "type": "node",
338                "node": "unknown_type"
339            }
340        });
341
342        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
343        assert!(!result.valid);
344        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
345    }
346
347    /// A leaf node shaped as `{"type": "<node_type>"}` never matches the real
348    /// `BehaviorConfig`/`CompositionConfig` struct (its `Node` variant tags as `"type": "node"`
349    /// with the actual identifier in a separate `node` field) and must be rejected.
350    #[test]
351    fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
352        let registry = NodeRegistry::new();
353        let config = json!({
354            "name": "test",
355            "root": {
356                "type": "wander",
357                "config": {}
358            }
359        });
360
361        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
362        assert!(!result.valid);
363    }
364
365    #[test]
366    fn test_validate_valid_leaf_node_config() {
367        let mut registry = NodeRegistry::new();
368        crate::register_builtin_actions(&mut registry);
369
370        let config = json!({
371            "name": "test",
372            "root": {
373                "type": "node",
374                "node": "wander",
375                "config": { "speed": 0.5 }
376            }
377        });
378
379        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
380        assert!(result.valid, "{:?}", result.errors);
381    }
382
383    #[test]
384    fn test_validate_empty_children_warns() {
385        let registry = NodeRegistry::new();
386        let config = json!({
387            "name": "test",
388            "root": {
389                "type": "sequence",
390                "children": []
391            }
392        });
393
394        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
395        assert!(result.valid);
396        assert!(result.warnings.iter().any(|w| w.contains("no children")));
397    }
398
399    #[test]
400    fn test_validate_watch_recurses_into_children_like_parallel() {
401        let registry = NodeRegistry::new();
402        let config = json!({
403            "name": "test",
404            "root": {
405                "type": "watch",
406                "policy": "require_one",
407                "children": [
408                    { "type": "node", "node": "unknown_type" }
409                ]
410            }
411        });
412
413        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
414        assert!(!result.valid);
415        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
416    }
417
418    #[test]
419    fn test_validate_subsumption_recurses_into_layer_nodes() {
420        let registry = NodeRegistry::new();
421        let config = json!({
422            "name": "test",
423            "root": {
424                "type": "subsumption",
425                "layers": [
426                    { "priority": 10, "node": { "type": "node", "node": "unknown_type" } },
427                    { "priority": 0, "node": { "type": "sequence", "children": [] } }
428                ]
429            }
430        });
431
432        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
433        assert!(!result.valid);
434        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
435    }
436
437    #[test]
438    fn test_validate_decorator_recurses_into_child() {
439        let registry = NodeRegistry::new();
440        for decorator_type in [
441            "inverter",
442            "force_success",
443            "force_failure",
444            "repeat",
445            "retry",
446            "timeout",
447        ] {
448            let config = json!({
449                "name": "test",
450                "root": {
451                    "type": decorator_type,
452                    "max_attempts": 1,
453                    "timeout_secs": 1.0,
454                    "child": { "type": "node", "node": "unknown_type" }
455                }
456            });
457
458            let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
459            assert!(
460                result.errors.iter().any(|e| e.contains("unknown_type")),
461                "decorator '{}' should recurse into its child: {:?}",
462                decorator_type,
463                result.errors
464            );
465        }
466    }
467
468    #[test]
469    fn test_validate_subsumption_empty_layers_warns() {
470        let registry = NodeRegistry::new();
471        let config = json!({
472            "name": "test",
473            "root": {
474                "type": "subsumption",
475                "layers": []
476            }
477        });
478
479        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
480        assert!(result.valid);
481        assert!(result.warnings.iter().any(|w| w.contains("no layers")));
482    }
483
484    #[test]
485    fn validate_behavior_composition_json_rejects_non_object() {
486        assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
487    }
488
489    #[test]
490    fn validate_behavior_composition_json_rejects_wrong_children_type() {
491        let config = json!({
492            "name": "test",
493            "root": {
494                "type": "sequence",
495                "children": "not an array"
496            }
497        });
498
499        assert!(validate_behavior_composition_json(&config).is_err());
500    }
501
502    #[test]
503    fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
504        let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
505            .join("tests/fixtures/seed_behaviors")
506            .canonicalize()
507            .unwrap();
508
509        let mut checked = 0;
510        for entry in std::fs::read_dir(&templates_dir).unwrap() {
511            let config_path = entry.unwrap().path().join("config.json");
512            if !config_path.is_file() {
513                continue;
514            }
515            let content = std::fs::read_to_string(&config_path).unwrap();
516            let value: Value = serde_json::from_str(&content).unwrap();
517            assert!(
518                validate_behavior_composition_json(&value).is_ok(),
519                "{} should validate against the real BehaviorConfig schema",
520                config_path.display()
521            );
522            checked += 1;
523        }
524        assert!(
525            checked > 0,
526            "expected at least one seed behavior config template to check"
527        );
528    }
529
530    /// LAB-4831: `find_target` is the one true seed behavior for the rl node's find_target
531    /// task - it must exist, must validate against the real `NodeRegistry` (not just the bare
532    /// JSON Schema), must still wire up the live target-class override (`set_rl_target`), and
533    /// there must be no separate `rl`/`find_target_dynamic` seed directory left behind for this
534    /// concept to accidentally fork back into two configs.
535    #[test]
536    fn find_target_seed_behavior_is_the_sole_rl_task_config_and_keeps_the_live_target_override() {
537        let seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
538            .join("tests/fixtures/seed_behaviors")
539            .canonicalize()
540            .unwrap();
541
542        assert!(
543            !seed_dir.join("rl").exists(),
544            "the old 'rl' seed behavior directory should have been folded into 'find_target'"
545        );
546        assert!(
547            !seed_dir.join("find_target_dynamic").exists(),
548            "find_target_dynamic should be folded into a single 'find_target' behavior, not kept alongside it"
549        );
550
551        let config_path = seed_dir.join("find_target/config.json");
552        let content = std::fs::read_to_string(&config_path)
553            .unwrap_or_else(|e| panic!("{} should exist: {}", config_path.display(), e));
554        let value: Value = serde_json::from_str(&content).unwrap();
555
556        assert_eq!(value["name"], "find_target");
557
558        let mut registry = crate::NodeRegistry::new();
559        crate::register_builtin_actions(&mut registry);
560        let result = validate_behavior_config(&value, &registry, &HashSet::new()).unwrap();
561        assert!(result.valid, "{:?}", result.errors);
562
563        let serialized = content;
564        assert!(
565            serialized.contains("\"rl_task\""),
566            "find_target should still hand control to the rl node via the rl_task node"
567        );
568        assert!(
569            serialized.contains("\"set_rl_target\""),
570            "find_target should still relay live dashboard target-class overrides via set_rl_target, \
571             regardless of whether the rl node's own config.json selects onnx or act_torchscript"
572        );
573    }
574}