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::capabilities` (the union of every
85/// enabled node's declared capabilities) for the project this behavior is being validated
86/// 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::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    for value in missing_required_capability_values(config, available_capabilities) {
122        result.add_error(format!(
123            "Behavior requires capability '{}', which is not provided by any enabled node in this project. \
124             Add a node that declares this capability, or remove it from 'requires_capabilities'.",
125            value.as_str().unwrap_or("<unknown>")
126        ));
127    }
128}
129
130/// Returns `true` if every capability `config` declares via `requires_capabilities` is present
131/// in `available_capabilities` -- the same subset check [`validate_required_capabilities`] uses
132/// to produce validation errors, exposed for runtime filtering (e.g. excluding a behavior from
133/// `/behavior/available` when the robot doesn't provide a capability it needs).
134pub fn behavior_capabilities_satisfied(config: &Value, available_capabilities: &HashSet<Capability>) -> bool {
135    missing_required_capability_values(config, available_capabilities).is_empty()
136}
137
138/// Returns every raw `requires_capabilities` entry in `config` that is not present in
139/// `available_capabilities`. An unparseable/unknown capability value is already reported by
140/// schema validation elsewhere, so it's treated as satisfied here rather than double-reported.
141fn missing_required_capability_values(config: &Value, available_capabilities: &HashSet<Capability>) -> Vec<Value> {
142    let Some(required) = config.get("requires_capabilities").and_then(|v| v.as_array()) else {
143        return Vec::new();
144    };
145
146    required
147        .iter()
148        .filter(|value| {
149            serde_json::from_value::<Capability>((*value).clone())
150                .map(|capability| !available_capabilities.contains(&capability))
151                .unwrap_or(false)
152        })
153        .cloned()
154        .collect()
155}
156
157/// Walk the composition tree checking that every leaf node's `node` identifier is
158/// registered in `registry` -- the one rule JSON Schema can't express. Skips anything schema
159/// validation already reports (non-object nodes, missing/invalid `type`/`children`).
160fn validate_node_types(node: &Value, registry: &NodeRegistry, result: &mut ValidationResult, path: &str) {
161    let Some(obj) = node.as_object() else {
162        return;
163    };
164
165    let Some(node_type) = obj.get("type").and_then(|v| v.as_str()) else {
166        return;
167    };
168
169    match node_type {
170        "node" => {
171            if let Some(node_name) = obj.get("node").and_then(|v| v.as_str()) {
172                if !registry.has_node(node_name) {
173                    result.add_error(format!(
174                        "Node at '{}' has unknown type '{}'. Register this node type or check for typos.",
175                        path, node_name
176                    ));
177                }
178            }
179        }
180        "sequence" | "selector" | "parallel" | "watch" => {
181            if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
182                if children.is_empty() {
183                    result.add_warning(format!(
184                        "Composition node at '{}' has no children (will immediately return success/failure)",
185                        path
186                    ));
187                }
188
189                for (i, child) in children.iter().enumerate() {
190                    let child_path = format!("{}.children[{}]", path, i);
191                    validate_node_types(child, registry, result, &child_path);
192                }
193            }
194        }
195        "inverter" | "force_success" | "force_failure" | "repeat" | "retry" | "timeout" => {
196            if let Some(child) = obj.get("child") {
197                let child_path = format!("{}.child", path);
198                validate_node_types(child, registry, result, &child_path);
199            }
200        }
201        "subsumption" => {
202            if let Some(layers) = obj.get("layers").and_then(|v| v.as_array()) {
203                if layers.is_empty() {
204                    result.add_warning(format!(
205                        "Subsumption node at '{}' has no layers (will immediately return failure)",
206                        path
207                    ));
208                }
209
210                for (i, layer) in layers.iter().enumerate() {
211                    if let Some(node) = layer.get("node") {
212                        let child_path = format!("{}.layers[{}].node", path, i);
213                        validate_node_types(node, registry, result, &child_path);
214                    }
215                }
216            }
217        }
218        _ => {}
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::NodeRegistry;
226    use serde_json::json;
227
228    #[test]
229    fn test_validate_missing_name() {
230        let registry = NodeRegistry::new();
231        let config = json!({
232            "root": {
233                "type": "sequence",
234                "children": []
235            }
236        });
237
238        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
239        assert!(!result.valid);
240        assert!(result.errors.iter().any(|e| e.contains("name")));
241    }
242
243    #[test]
244    fn test_validate_missing_root() {
245        let registry = NodeRegistry::new();
246        let config = json!({
247            "name": "test"
248        });
249
250        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
251        assert!(!result.valid);
252        assert!(result.errors.iter().any(|e| e.contains("root")));
253    }
254
255    #[test]
256    fn test_validate_valid_config() {
257        let registry = NodeRegistry::new();
258        let config = json!({
259            "name": "test",
260            "root": {
261                "type": "sequence",
262                "children": []
263            }
264        });
265
266        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
267        assert!(result.valid, "{:?}", result.errors);
268    }
269
270    /// A behavior with no `requires_capabilities` at all (the common case) must be unaffected
271    /// by the capability check, regardless of what the project does or doesn't provide.
272    #[test]
273    fn test_validate_behavior_with_no_requires_capabilities_is_unaffected() {
274        let registry = NodeRegistry::new();
275        let config = json!({
276            "name": "test",
277            "root": {
278                "type": "sequence",
279                "children": []
280            }
281        });
282
283        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
284        assert!(result.valid, "{:?}", result.errors);
285    }
286
287    #[test]
288    fn test_validate_requires_capabilities_passes_when_satisfied() {
289        let registry = NodeRegistry::new();
290        let config = json!({
291            "name": "test",
292            "root": {
293                "type": "sequence",
294                "children": []
295            },
296            "requires_capabilities": ["locomotion"]
297        });
298
299        let available = HashSet::from([Capability::Locomotion]);
300        let result = validate_behavior_config(&config, &registry, &available).unwrap();
301        assert!(result.valid, "{:?}", result.errors);
302    }
303
304    #[test]
305    fn test_validate_requires_capabilities_fails_when_unsatisfied() {
306        let registry = NodeRegistry::new();
307        let config = json!({
308            "name": "test",
309            "root": {
310                "type": "sequence",
311                "children": []
312            },
313            "requires_capabilities": ["flight"]
314        });
315
316        // The project has no node providing "flight" -- e.g. just a locomotion node.
317        let available = HashSet::from([Capability::Locomotion]);
318        let result = validate_behavior_config(&config, &registry, &available).unwrap();
319        assert!(!result.valid);
320        assert!(
321            result.errors.iter().any(|e| e.contains("flight")),
322            "expected an error naming the missing 'flight' capability, got: {:?}",
323            result.errors
324        );
325    }
326
327    #[test]
328    fn test_validate_requires_capabilities_fails_naming_only_the_missing_one() {
329        let registry = NodeRegistry::new();
330        let config = json!({
331            "name": "test",
332            "root": {
333                "type": "sequence",
334                "children": []
335            },
336            "requires_capabilities": ["locomotion", "flight"]
337        });
338
339        let available = HashSet::from([Capability::Locomotion]);
340        let result = validate_behavior_config(&config, &registry, &available).unwrap();
341        assert!(!result.valid);
342        assert_eq!(result.errors.len(), 1, "{:?}", result.errors);
343        assert!(result.errors[0].contains("flight"));
344    }
345
346    #[test]
347    fn test_behavior_capabilities_satisfied_true_when_no_requires_capabilities() {
348        let config = json!({
349            "name": "test",
350            "root": { "type": "sequence", "children": [] }
351        });
352
353        assert!(behavior_capabilities_satisfied(&config, &HashSet::new()));
354    }
355
356    #[test]
357    fn test_behavior_capabilities_satisfied_true_when_subset_of_available() {
358        let config = json!({
359            "name": "test",
360            "root": { "type": "sequence", "children": [] },
361            "requires_capabilities": ["locomotion"]
362        });
363
364        let available = HashSet::from([Capability::Locomotion, Capability::Vision]);
365        assert!(behavior_capabilities_satisfied(&config, &available));
366    }
367
368    #[test]
369    fn test_behavior_capabilities_satisfied_false_when_missing_one() {
370        let config = json!({
371            "name": "test",
372            "root": { "type": "sequence", "children": [] },
373            "requires_capabilities": ["locomotion", "flight"]
374        });
375
376        let available = HashSet::from([Capability::Locomotion]);
377        assert!(!behavior_capabilities_satisfied(&config, &available));
378    }
379
380    #[test]
381    fn test_validate_unknown_node_type() {
382        let registry = NodeRegistry::new();
383        let config = json!({
384            "name": "test",
385            "root": {
386                "type": "node",
387                "node": "unknown_type"
388            }
389        });
390
391        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
392        assert!(!result.valid);
393        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
394    }
395
396    /// A leaf node shaped as `{"type": "<node_type>"}` never matches the real
397    /// `BehaviorConfig`/`CompositionConfig` struct (its `Node` variant tags as `"type": "node"`
398    /// with the actual identifier in a separate `node` field) and must be rejected.
399    #[test]
400    fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
401        let registry = NodeRegistry::new();
402        let config = json!({
403            "name": "test",
404            "root": {
405                "type": "wander",
406                "config": {}
407            }
408        });
409
410        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
411        assert!(!result.valid);
412    }
413
414    #[test]
415    fn test_validate_valid_leaf_node_config() {
416        let mut registry = NodeRegistry::new();
417        crate::register_builtin_actions(&mut registry);
418
419        let config = json!({
420            "name": "test",
421            "root": {
422                "type": "node",
423                "node": "wander",
424                "config": { "speed": 0.5 }
425            }
426        });
427
428        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
429        assert!(result.valid, "{:?}", result.errors);
430    }
431
432    #[test]
433    fn test_validate_empty_children_warns() {
434        let registry = NodeRegistry::new();
435        let config = json!({
436            "name": "test",
437            "root": {
438                "type": "sequence",
439                "children": []
440            }
441        });
442
443        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
444        assert!(result.valid);
445        assert!(result.warnings.iter().any(|w| w.contains("no children")));
446    }
447
448    #[test]
449    fn test_validate_watch_recurses_into_children_like_parallel() {
450        let registry = NodeRegistry::new();
451        let config = json!({
452            "name": "test",
453            "root": {
454                "type": "watch",
455                "policy": "require_one",
456                "children": [
457                    { "type": "node", "node": "unknown_type" }
458                ]
459            }
460        });
461
462        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
463        assert!(!result.valid);
464        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
465    }
466
467    #[test]
468    fn test_validate_subsumption_recurses_into_layer_nodes() {
469        let registry = NodeRegistry::new();
470        let config = json!({
471            "name": "test",
472            "root": {
473                "type": "subsumption",
474                "layers": [
475                    { "priority": 10, "node": { "type": "node", "node": "unknown_type" } },
476                    { "priority": 0, "node": { "type": "sequence", "children": [] } }
477                ]
478            }
479        });
480
481        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
482        assert!(!result.valid);
483        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
484    }
485
486    #[test]
487    fn test_validate_decorator_recurses_into_child() {
488        let registry = NodeRegistry::new();
489        for decorator_type in [
490            "inverter",
491            "force_success",
492            "force_failure",
493            "repeat",
494            "retry",
495            "timeout",
496        ] {
497            let config = json!({
498                "name": "test",
499                "root": {
500                    "type": decorator_type,
501                    "max_attempts": 1,
502                    "timeout_secs": 1.0,
503                    "child": { "type": "node", "node": "unknown_type" }
504                }
505            });
506
507            let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
508            assert!(
509                result.errors.iter().any(|e| e.contains("unknown_type")),
510                "decorator '{}' should recurse into its child: {:?}",
511                decorator_type,
512                result.errors
513            );
514        }
515    }
516
517    #[test]
518    fn test_validate_subsumption_empty_layers_warns() {
519        let registry = NodeRegistry::new();
520        let config = json!({
521            "name": "test",
522            "root": {
523                "type": "subsumption",
524                "layers": []
525            }
526        });
527
528        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
529        assert!(result.valid);
530        assert!(result.warnings.iter().any(|w| w.contains("no layers")));
531    }
532
533    #[test]
534    fn validate_behavior_composition_json_rejects_non_object() {
535        assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
536    }
537
538    #[test]
539    fn validate_behavior_composition_json_rejects_wrong_children_type() {
540        let config = json!({
541            "name": "test",
542            "root": {
543                "type": "sequence",
544                "children": "not an array"
545            }
546        });
547
548        assert!(validate_behavior_composition_json(&config).is_err());
549    }
550
551    #[test]
552    fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
553        let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
554            .join("tests/fixtures/seed_behaviors")
555            .canonicalize()
556            .unwrap();
557
558        let mut checked = 0;
559        for entry in std::fs::read_dir(&templates_dir).unwrap() {
560            let config_path = entry.unwrap().path().join("config.json");
561            if !config_path.is_file() {
562                continue;
563            }
564            let content = std::fs::read_to_string(&config_path).unwrap();
565            let value: Value = serde_json::from_str(&content).unwrap();
566            assert!(
567                validate_behavior_composition_json(&value).is_ok(),
568                "{} should validate against the real BehaviorConfig schema",
569                config_path.display()
570            );
571            checked += 1;
572        }
573        assert!(
574            checked > 0,
575            "expected at least one seed behavior config template to check"
576        );
577    }
578
579    /// LAB-4831: `find_target` is the one true seed behavior for the rl node's find_target
580    /// task - it must exist, must validate against the real `NodeRegistry` (not just the bare
581    /// JSON Schema), must still wire up the live target-class override (`set_rl_target`), and
582    /// there must be no separate `rl`/`find_target_dynamic` seed directory left behind for this
583    /// concept to accidentally fork back into two configs.
584    #[test]
585    fn find_target_seed_behavior_is_the_sole_rl_task_config_and_keeps_the_live_target_override() {
586        let seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
587            .join("tests/fixtures/seed_behaviors")
588            .canonicalize()
589            .unwrap();
590
591        assert!(
592            !seed_dir.join("rl").exists(),
593            "the old 'rl' seed behavior directory should have been folded into 'find_target'"
594        );
595        assert!(
596            !seed_dir.join("find_target_dynamic").exists(),
597            "find_target_dynamic should be folded into a single 'find_target' behavior, not kept alongside it"
598        );
599
600        let config_path = seed_dir.join("find_target/config.json");
601        let content = std::fs::read_to_string(&config_path)
602            .unwrap_or_else(|e| panic!("{} should exist: {}", config_path.display(), e));
603        let value: Value = serde_json::from_str(&content).unwrap();
604
605        assert_eq!(value["name"], "find_target");
606
607        let mut registry = crate::NodeRegistry::new();
608        crate::register_builtin_actions(&mut registry);
609        let result = validate_behavior_config(&value, &registry, &HashSet::new()).unwrap();
610        assert!(result.valid, "{:?}", result.errors);
611
612        let serialized = content;
613        assert!(
614            serialized.contains("\"rl_task\""),
615            "find_target should still hand control to the rl node via the rl_task node"
616        );
617        assert!(
618            serialized.contains("\"set_rl_target\""),
619            "find_target should still relay live dashboard target-class overrides via set_rl_target, \
620             regardless of whether the rl node's own config.json selects onnx or act_torchscript"
621        );
622    }
623}