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" => {
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, ®istry, &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, ®istry, &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, ®istry, &HashSet::new()).unwrap();
267 assert!(result.valid, "{:?}", result.errors);
268 }
269
270 #[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, ®istry, &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, ®istry, &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 let available = HashSet::from([Capability::Locomotion]);
318 let result = validate_behavior_config(&config, ®istry, &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, ®istry, &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, ®istry, &HashSet::new()).unwrap();
392 assert!(!result.valid);
393 assert!(result.errors.iter().any(|e| e.contains("unknown_type")));
394 }
395
396 #[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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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, ®istry, &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 #[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, ®istry, &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}