1use 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 pub fn success() -> Self {
32 Self {
33 valid: true,
34 errors: Vec::new(),
35 warnings: Vec::new(),
36 }
37 }
38
39 pub fn with_errors(errors: Vec<String>) -> Self {
41 Self {
42 valid: false,
43 errors,
44 warnings: Vec::new(),
45 }
46 }
47
48 pub fn add_warning(&mut self, warning: String) {
50 self.warnings.push(warning);
51 }
52
53 pub fn add_error(&mut self, error: String) {
55 self.errors.push(error);
56 self.valid = false;
57 }
58}
59
60pub 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
78pub 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
112fn 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
130pub fn behavior_capabilities_satisfied(config: &Value, available_capabilities: &HashSet<Capability>) -> bool {
135 missing_required_capability_values(config, available_capabilities).is_empty()
136}
137
138fn 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
157fn 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 }
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, ®istry, &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, ®istry, &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, ®istry, &HashSet::new()).unwrap();
291 assert!(result.valid, "{:?}", result.errors);
292 }
293
294 #[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, ®istry, &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, ®istry, &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 let available = HashSet::from([Capability::Locomotion]);
342 let result = validate_behavior_config(&config, ®istry, &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, ®istry, &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, ®istry, &HashSet::new()).unwrap();
416 assert!(!result.valid);
417 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
418 }
419
420 #[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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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 #[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, ®istry, &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}