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" | "condition" | "cooldown" => {
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        "weighted_select" => {
202            if let Some(children) = obj.get("children").and_then(|v| v.as_array()) {
203                if children.is_empty() {
204                    result.add_warning(format!(
205                        "Composition node at '{}' has no children (will immediately return success/failure)",
206                        path
207                    ));
208                }
209
210                for (i, weighted_child) in children.iter().enumerate() {
211                    if let Some(node) = weighted_child.get("node") {
212                        let child_path = format!("{}.children[{}].node", path, i);
213                        validate_node_types(node, registry, result, &child_path);
214                    }
215                }
216            }
217        }
218        "subsumption" => {
219            if let Some(layers) = obj.get("layers").and_then(|v| v.as_array()) {
220                if layers.is_empty() {
221                    result.add_warning(format!(
222                        "Subsumption node at '{}' has no layers (will immediately return failure)",
223                        path
224                    ));
225                }
226
227                for (i, layer) in layers.iter().enumerate() {
228                    if let Some(node) = layer.get("node") {
229                        let child_path = format!("{}.layers[{}].node", path, i);
230                        validate_node_types(node, registry, result, &child_path);
231                    }
232                }
233            }
234        }
235        "include" => {
236            // No filesystem access here - this function validates a bare JSON `Value`, with no
237            // base directory to resolve `path` against (that context only exists in
238            // `BehaviorLoader::load_from_file`). The fragment file's own node types are still
239            // checked when the tree is actually loaded (an unknown node type inside it fails
240            // loudly there), just not by this static validator.
241        }
242        _ => {}
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::NodeRegistry;
250    use serde_json::json;
251
252    #[test]
253    fn test_validate_missing_name() {
254        let registry = NodeRegistry::new();
255        let config = json!({
256            "root": {
257                "type": "sequence",
258                "children": []
259            }
260        });
261
262        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
263        assert!(!result.valid);
264        assert!(result.errors.iter().any(|e| e.contains("name")));
265    }
266
267    #[test]
268    fn test_validate_missing_root() {
269        let registry = NodeRegistry::new();
270        let config = json!({
271            "name": "test"
272        });
273
274        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
275        assert!(!result.valid);
276        assert!(result.errors.iter().any(|e| e.contains("root")));
277    }
278
279    #[test]
280    fn test_validate_valid_config() {
281        let registry = NodeRegistry::new();
282        let config = json!({
283            "name": "test",
284            "root": {
285                "type": "sequence",
286                "children": []
287            }
288        });
289
290        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
291        assert!(result.valid, "{:?}", result.errors);
292    }
293
294    /// A behavior with no `requires_capabilities` at all (the common case) must be unaffected
295    /// by the capability check, regardless of what the project does or doesn't provide.
296    #[test]
297    fn test_validate_behavior_with_no_requires_capabilities_is_unaffected() {
298        let registry = NodeRegistry::new();
299        let config = json!({
300            "name": "test",
301            "root": {
302                "type": "sequence",
303                "children": []
304            }
305        });
306
307        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
308        assert!(result.valid, "{:?}", result.errors);
309    }
310
311    #[test]
312    fn test_validate_requires_capabilities_passes_when_satisfied() {
313        let registry = NodeRegistry::new();
314        let config = json!({
315            "name": "test",
316            "root": {
317                "type": "sequence",
318                "children": []
319            },
320            "requires_capabilities": ["locomotion"]
321        });
322
323        let available = HashSet::from([Capability::Locomotion]);
324        let result = validate_behavior_config(&config, &registry, &available).unwrap();
325        assert!(result.valid, "{:?}", result.errors);
326    }
327
328    #[test]
329    fn test_validate_requires_capabilities_fails_when_unsatisfied() {
330        let registry = NodeRegistry::new();
331        let config = json!({
332            "name": "test",
333            "root": {
334                "type": "sequence",
335                "children": []
336            },
337            "requires_capabilities": ["flight"]
338        });
339
340        // The project has no node providing "flight" -- e.g. just a locomotion node.
341        let available = HashSet::from([Capability::Locomotion]);
342        let result = validate_behavior_config(&config, &registry, &available).unwrap();
343        assert!(!result.valid);
344        assert!(
345            result.errors.iter().any(|e| e.contains("flight")),
346            "expected an error naming the missing 'flight' capability, got: {:?}",
347            result.errors
348        );
349    }
350
351    #[test]
352    fn test_validate_requires_capabilities_fails_naming_only_the_missing_one() {
353        let registry = NodeRegistry::new();
354        let config = json!({
355            "name": "test",
356            "root": {
357                "type": "sequence",
358                "children": []
359            },
360            "requires_capabilities": ["locomotion", "flight"]
361        });
362
363        let available = HashSet::from([Capability::Locomotion]);
364        let result = validate_behavior_config(&config, &registry, &available).unwrap();
365        assert!(!result.valid);
366        assert_eq!(result.errors.len(), 1, "{:?}", result.errors);
367        assert!(result.errors[0].contains("flight"));
368    }
369
370    #[test]
371    fn test_behavior_capabilities_satisfied_true_when_no_requires_capabilities() {
372        let config = json!({
373            "name": "test",
374            "root": { "type": "sequence", "children": [] }
375        });
376
377        assert!(behavior_capabilities_satisfied(&config, &HashSet::new()));
378    }
379
380    #[test]
381    fn test_behavior_capabilities_satisfied_true_when_subset_of_available() {
382        let config = json!({
383            "name": "test",
384            "root": { "type": "sequence", "children": [] },
385            "requires_capabilities": ["locomotion"]
386        });
387
388        let available = HashSet::from([Capability::Locomotion, Capability::Vision]);
389        assert!(behavior_capabilities_satisfied(&config, &available));
390    }
391
392    #[test]
393    fn test_behavior_capabilities_satisfied_false_when_missing_one() {
394        let config = json!({
395            "name": "test",
396            "root": { "type": "sequence", "children": [] },
397            "requires_capabilities": ["locomotion", "flight"]
398        });
399
400        let available = HashSet::from([Capability::Locomotion]);
401        assert!(!behavior_capabilities_satisfied(&config, &available));
402    }
403
404    #[test]
405    fn test_validate_unknown_node_type() {
406        let registry = NodeRegistry::new();
407        let config = json!({
408            "name": "test",
409            "root": {
410                "type": "node",
411                "node": "unknown_type"
412            }
413        });
414
415        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
416        assert!(!result.valid);
417        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
418    }
419
420    /// A leaf node shaped as `{"type": "<node_type>"}` never matches the real
421    /// `BehaviorConfig`/`CompositionConfig` struct (its `Node` variant tags as `"type": "node"`
422    /// with the actual identifier in a separate `node` field) and must be rejected.
423    #[test]
424    fn test_validate_rejects_legacy_leaf_shape_that_never_matched_the_real_struct() {
425        let registry = NodeRegistry::new();
426        let config = json!({
427            "name": "test",
428            "root": {
429                "type": "wander",
430                "config": {}
431            }
432        });
433
434        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
435        assert!(!result.valid);
436    }
437
438    #[test]
439    fn test_validate_valid_leaf_node_config() {
440        let mut registry = NodeRegistry::new();
441        crate::register_builtin_actions(&mut registry);
442
443        let config = json!({
444            "name": "test",
445            "root": {
446                "type": "node",
447                "node": "wander",
448                "config": { "speed": 0.5 }
449            }
450        });
451
452        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
453        assert!(result.valid, "{:?}", result.errors);
454    }
455
456    #[test]
457    fn test_validate_empty_children_warns() {
458        let registry = NodeRegistry::new();
459        let config = json!({
460            "name": "test",
461            "root": {
462                "type": "sequence",
463                "children": []
464            }
465        });
466
467        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
468        assert!(result.valid);
469        assert!(result.warnings.iter().any(|w| w.contains("no children")));
470    }
471
472    #[test]
473    fn test_validate_watch_recurses_into_children_like_parallel() {
474        let registry = NodeRegistry::new();
475        let config = json!({
476            "name": "test",
477            "root": {
478                "type": "watch",
479                "policy": "require_one",
480                "children": [
481                    { "type": "node", "node": "unknown_type" }
482                ]
483            }
484        });
485
486        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
487        assert!(!result.valid);
488        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
489    }
490
491    #[test]
492    fn test_validate_weighted_select_recurses_into_children() {
493        let registry = NodeRegistry::new();
494        let config = json!({
495            "name": "test",
496            "root": {
497                "type": "weighted_select",
498                "children": [
499                    { "weight": 1.0, "node": { "type": "node", "node": "unknown_type" } }
500                ]
501            }
502        });
503
504        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
505        assert!(!result.valid);
506        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
507    }
508
509    #[test]
510    fn test_validate_weighted_select_empty_children_warns() {
511        let registry = NodeRegistry::new();
512        let config = json!({
513            "name": "test",
514            "root": {
515                "type": "weighted_select",
516                "children": []
517            }
518        });
519
520        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
521        assert!(result.valid);
522        assert!(result.warnings.iter().any(|w| w.contains("no children")));
523    }
524
525    #[test]
526    fn test_validate_cooldown_recurses_into_child() {
527        let registry = NodeRegistry::new();
528        let config = json!({
529            "name": "test",
530            "root": {
531                "type": "cooldown",
532                "cooldown_secs": 5.0,
533                "child": { "type": "node", "node": "unknown_type" }
534            }
535        });
536
537        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
538        assert!(!result.valid);
539        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
540    }
541
542    #[test]
543    fn test_validate_subsumption_recurses_into_layer_nodes() {
544        let registry = NodeRegistry::new();
545        let config = json!({
546            "name": "test",
547            "root": {
548                "type": "subsumption",
549                "layers": [
550                    { "priority": 10, "node": { "type": "node", "node": "unknown_type" } },
551                    { "priority": 0, "node": { "type": "sequence", "children": [] } }
552                ]
553            }
554        });
555
556        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
557        assert!(!result.valid);
558        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
559    }
560
561    #[test]
562    fn test_validate_decorator_recurses_into_child() {
563        let registry = NodeRegistry::new();
564        for decorator_type in [
565            "inverter",
566            "force_success",
567            "force_failure",
568            "repeat",
569            "retry",
570            "timeout",
571            "condition",
572            "cooldown",
573        ] {
574            let config = json!({
575                "name": "test",
576                "root": {
577                    "type": decorator_type,
578                    "max_attempts": 1,
579                    "timeout_secs": 1.0,
580                    "cooldown_secs": 1.0,
581                    "condition": { "controller_mode_is": "locomotion" },
582                    "child": { "type": "node", "node": "unknown_type" }
583                }
584            });
585
586            let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
587            assert!(
588                result.errors.iter().any(|e| e.contains("unknown_type")),
589                "decorator '{}' should recurse into its child: {:?}",
590                decorator_type,
591                result.errors
592            );
593        }
594    }
595
596    #[test]
597    fn test_validate_condition_recurses_into_child() {
598        let registry = NodeRegistry::new();
599        let config = json!({
600            "name": "test",
601            "root": {
602                "type": "condition",
603                "condition": { "controller_mode_is": "locomotion" },
604                "child": { "type": "node", "node": "unknown_type" }
605            }
606        });
607
608        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
609        assert!(!result.valid);
610        assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
611    }
612
613    #[test]
614    fn test_validate_include_is_accepted_by_schema_without_recursing() {
615        let registry = NodeRegistry::new();
616        let config = json!({
617            "name": "test",
618            "root": {
619                "type": "include",
620                "path": "../_shared/fragment.json"
621            }
622        });
623
624        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
625        assert!(result.valid, "{:?}", result.errors);
626    }
627
628    #[test]
629    fn test_validate_subsumption_empty_layers_warns() {
630        let registry = NodeRegistry::new();
631        let config = json!({
632            "name": "test",
633            "root": {
634                "type": "subsumption",
635                "layers": []
636            }
637        });
638
639        let result = validate_behavior_config(&config, &registry, &HashSet::new()).unwrap();
640        assert!(result.valid);
641        assert!(result.warnings.iter().any(|w| w.contains("no layers")));
642    }
643
644    #[test]
645    fn validate_behavior_composition_json_rejects_non_object() {
646        assert!(validate_behavior_composition_json(&json!("not an object")).is_err());
647    }
648
649    #[test]
650    fn validate_behavior_composition_json_rejects_wrong_children_type() {
651        let config = json!({
652            "name": "test",
653            "root": {
654                "type": "sequence",
655                "children": "not an array"
656            }
657        });
658
659        assert!(validate_behavior_composition_json(&config).is_err());
660    }
661
662    #[test]
663    fn validate_behavior_composition_json_accepts_the_real_repo_seed_templates() {
664        let templates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
665            .join("tests/fixtures/seed_behaviors")
666            .canonicalize()
667            .unwrap();
668
669        let mut checked = 0;
670        for entry in std::fs::read_dir(&templates_dir).unwrap() {
671            let config_path = entry.unwrap().path().join("config.json");
672            if !config_path.is_file() {
673                continue;
674            }
675            let content = std::fs::read_to_string(&config_path).unwrap();
676            let value: Value = serde_json::from_str(&content).unwrap();
677            assert!(
678                validate_behavior_composition_json(&value).is_ok(),
679                "{} should validate against the real BehaviorConfig schema",
680                config_path.display()
681            );
682            checked += 1;
683        }
684        assert!(
685            checked > 0,
686            "expected at least one seed behavior config template to check"
687        );
688    }
689
690    /// LAB-4831: `find_target` is the one true seed behavior for the rl node's find_target
691    /// task - it must exist, must validate against the real `NodeRegistry` (not just the bare
692    /// JSON Schema), must still wire up the live target-class override (`set_capability_target`
693    /// configured for the `rl` control topic), and there must be no separate
694    /// `rl`/`find_target_dynamic`/`find_target_sentry` seed directory left behind for this
695    /// concept to accidentally fork back into multiple configs.
696    ///
697    /// Since the `move`/`wander` action nodes migrated to dispatching on the project's
698    /// `ControllerMode` (Locomotion vs Joints) rather than always publishing a Twist, the old
699    /// joints-only `find_target_sentry` seed behavior was folded into this one too: `find_target`
700    /// now uses the same subsumption tree `find_target_sentry` used to (an `instance_track`
701    /// interrupt layer over the generic `capability_task`/`set_capability_target` leaf nodes,
702    /// configured for the `rl` capability) and, critically, declares no `requires_capabilities`
703    /// at all - it works unmodified on every platform.
704    #[test]
705    fn find_target_seed_behavior_is_the_sole_rl_task_config_and_keeps_the_live_target_override() {
706        let seed_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
707            .join("tests/fixtures/seed_behaviors")
708            .canonicalize()
709            .unwrap();
710
711        assert!(
712            !seed_dir.join("rl").exists(),
713            "the old 'rl' seed behavior directory should have been folded into 'find_target'"
714        );
715        assert!(
716            !seed_dir.join("find_target_dynamic").exists(),
717            "find_target_dynamic should be folded into a single 'find_target' behavior, not kept alongside it"
718        );
719        assert!(
720            !seed_dir.join("find_target_sentry").exists(),
721            "find_target_sentry should be folded into a single, platform-agnostic 'find_target' \
722             behavior, not kept alongside it"
723        );
724
725        let config_path = seed_dir.join("find_target/config.json");
726        let content = std::fs::read_to_string(&config_path)
727            .unwrap_or_else(|e| panic!("{} should exist: {}", config_path.display(), e));
728        let value: Value = serde_json::from_str(&content).unwrap();
729
730        assert_eq!(value["name"], "find_target");
731        assert!(
732            value.get("requires_capabilities").is_none(),
733            "find_target must not declare requires_capabilities at all - it dispatches on \
734             ControllerMode at the node level and must run on every platform, not just none/empty"
735        );
736
737        let mut registry = crate::NodeRegistry::new();
738        crate::register_builtin_actions(&mut registry);
739        let result = validate_behavior_config(&value, &registry, &HashSet::new()).unwrap();
740        assert!(result.valid, "{:?}", result.errors);
741
742        assert_eq!(value["root"]["type"], "subsumption", "{:?}", value["root"]);
743        let layers = value["root"]["layers"].as_array().unwrap();
744        let main_layer = layers
745            .iter()
746            .find(|layer| layer["name"] == "find_target-main")
747            .unwrap_or_else(|| panic!("expected a 'find_target-main' layer, got: {:?}", layers));
748        let main_node = &main_layer["node"];
749
750        let serialized = content;
751        assert!(
752            serialized.contains("\"capability_task\""),
753            "find_target should still hand control to the rl node via the generic capability_task node"
754        );
755        assert!(
756            serialized.contains("\"set_capability_target\""),
757            "find_target should still relay live dashboard target-class overrides via \
758             set_capability_target, regardless of whether the rl node's own config.json selects \
759             onnx or act_torchscript"
760        );
761        let main_children: Vec<&str> = main_node["children"]
762            .as_array()
763            .unwrap_or_else(|| panic!("expected find_target-main's node to have children: {:?}", main_node))
764            .iter()
765            .map(|child| child["node"].as_str().unwrap())
766            .collect();
767        assert!(
768            main_children.contains(&"capability_task") && main_children.contains(&"set_capability_target"),
769            "capability_task/set_capability_target should be nested under the lower-priority \
770             'find_target-main' layer, got children: {:?}",
771            main_children
772        );
773    }
774}