1use car_ir::precondition::{self, StateView};
10use car_ir::{build_dag, Action, ActionProposal, ActionType, ToolSchema};
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13
14#[derive(Debug, Clone)]
16pub struct StaticState {
17 pub known: HashMap<String, Value>,
18 pub unknown_keys: HashSet<String>,
19}
20
21impl StaticState {
22 pub fn new() -> Self {
23 Self {
24 known: HashMap::new(),
25 unknown_keys: HashSet::new(),
26 }
27 }
28
29 pub fn from_map(map: HashMap<String, Value>) -> Self {
30 Self {
31 known: map,
32 unknown_keys: HashSet::new(),
33 }
34 }
35
36 pub fn get(&self, key: &str) -> Option<&Value> {
37 self.known.get(key)
38 }
39
40 pub fn exists(&self, key: &str) -> bool {
41 self.known.contains_key(key)
42 }
43
44 pub fn is_unknown(&self, key: &str) -> bool {
45 self.unknown_keys.contains(key)
46 }
47
48 pub fn set(&mut self, key: &str, value: Value) {
49 self.known.insert(key.to_string(), value);
50 self.unknown_keys.remove(key);
51 }
52}
53
54impl Default for StaticState {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl StateView for StaticState {
61 fn get_value(&self, key: &str) -> Option<Value> {
62 self.known.get(key).cloned()
63 }
64 fn key_exists(&self, key: &str) -> bool {
65 self.known.contains_key(key)
66 }
67 fn is_unknown(&self, key: &str) -> bool {
68 self.unknown_keys.contains(key)
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct VerifyIssue {
75 pub action_id: String,
76 pub severity: String, pub message: String,
78}
79
80#[derive(Debug)]
82pub struct VerifyResult {
83 pub valid: bool,
84 pub issues: Vec<VerifyIssue>,
85 pub simulated_state: HashMap<String, Value>,
86 pub execution_levels: Vec<Vec<String>>,
87 pub conflicts: Vec<(String, String, String)>, }
89
90impl VerifyResult {
91 pub fn errors(&self) -> Vec<&VerifyIssue> {
92 self.issues
93 .iter()
94 .filter(|i| i.severity == "error")
95 .collect()
96 }
97
98 pub fn warnings(&self) -> Vec<&VerifyIssue> {
99 self.issues
100 .iter()
101 .filter(|i| i.severity == "warning")
102 .collect()
103 }
104}
105
106fn apply_action_effects(action: &Action, state: &mut StaticState) {
109 if action.action_type == ActionType::StateWrite {
110 if let Some(key) = action.parameters.get("key").and_then(|v| v.as_str()) {
111 let value = action
112 .parameters
113 .get("value")
114 .cloned()
115 .unwrap_or(Value::Null);
116 state.set(key, value);
117 }
118 }
119 for (key, value) in &action.expected_effects {
120 state.set(key, value.clone());
121 }
122}
123
124fn detect_conflicts(actions: &[Action]) -> Vec<(String, String, String)> {
127 let mut writers: HashMap<String, Vec<String>> = HashMap::new();
128
129 for action in actions {
130 let mut keys_written = HashSet::new();
131 if action.action_type == ActionType::StateWrite {
132 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
133 keys_written.insert(k.to_string());
134 }
135 }
136 for key in action.expected_effects.keys() {
137 keys_written.insert(key.clone());
138 }
139 for key in keys_written {
140 writers.entry(key).or_default().push(action.id.clone());
141 }
142 }
143
144 let dep_map: HashMap<String, HashSet<String>> = actions
145 .iter()
146 .map(|a| (a.id.clone(), a.state_dependencies.iter().cloned().collect()))
147 .collect();
148
149 let mut conflicts = Vec::new();
150 for (key, action_ids) in &writers {
151 if action_ids.len() < 2 {
152 continue;
153 }
154 for i in 0..action_ids.len() {
155 for j in (i + 1)..action_ids.len() {
156 let a1 = &action_ids[i];
157 let a2 = &action_ids[j];
158 let deps_a2 = dep_map.get(a2).cloned().unwrap_or_default();
159 let deps_a1 = dep_map.get(a1).cloned().unwrap_or_default();
160 if !deps_a2.contains(key) && !deps_a1.contains(key) {
161 conflicts.push((a1.clone(), a2.clone(), key.clone()));
162 }
163 }
164 }
165 }
166 conflicts
167}
168
169fn json_type_name(v: &Value) -> &'static str {
173 match v {
174 Value::Null => "null",
175 Value::Bool(_) => "boolean",
176 Value::Number(_) => "number",
177 Value::String(_) => "string",
178 Value::Array(_) => "array",
179 Value::Object(_) => "object",
180 }
181}
182
183fn value_matches_type(v: &Value, expected: &str) -> bool {
185 match expected {
186 "string" => v.is_string(),
187 "number" => v.is_number(),
188 "integer" => {
191 v.is_i64() || v.is_u64() || v.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false)
192 }
193 "boolean" => v.is_boolean(),
194 "array" => v.is_array(),
195 "object" => v.is_object(),
196 "null" => v.is_null(),
197 _ => true,
200 }
201}
202
203fn validate_tool_params(params: &HashMap<String, Value>, schema: &Value) -> Vec<String> {
210 let mut out = Vec::new();
211 let Some(schema_obj) = schema.as_object() else {
212 return out;
214 };
215
216 if let Some(Value::Array(required)) = schema_obj.get("required") {
218 for req in required {
219 if let Some(name) = req.as_str() {
220 if !params.contains_key(name) {
221 out.push(format!("missing required parameter '{name}'"));
222 }
223 }
224 }
225 }
226
227 if let Some(Value::Object(properties)) = schema_obj.get("properties") {
231 for (key, val) in params {
232 let Some(prop_schema) = properties.get(key).and_then(|s| s.as_object()) else {
233 continue;
234 };
235 let ok = match prop_schema.get("type") {
236 Some(Value::String(t)) => value_matches_type(val, t),
237 Some(Value::Array(types)) => types
238 .iter()
239 .filter_map(|t| t.as_str())
240 .any(|t| value_matches_type(val, t)),
241 _ => true,
243 };
244 if !ok {
245 let expected = match prop_schema.get("type") {
246 Some(Value::String(t)) => t.clone(),
247 Some(Value::Array(types)) => types
248 .iter()
249 .filter_map(|t| t.as_str())
250 .collect::<Vec<_>>()
251 .join("|"),
252 _ => String::new(),
253 };
254 out.push(format!(
255 "parameter '{key}' has wrong type: expected {expected}, got {}",
256 json_type_name(val)
257 ));
258 }
259 }
260 }
261
262 out
263}
264
265pub fn verify(
275 proposal: &ActionProposal,
276 initial_state: Option<&HashMap<String, Value>>,
277 registered_tools: Option<&HashSet<String>>,
278 max_actions: usize,
279) -> VerifyResult {
280 verify_inner(proposal, initial_state, registered_tools, None, max_actions)
281}
282
283pub fn verify_with_schemas(
291 proposal: &ActionProposal,
292 initial_state: Option<&HashMap<String, Value>>,
293 tool_schemas: Option<&HashMap<String, ToolSchema>>,
294 max_actions: usize,
295) -> VerifyResult {
296 verify_inner(proposal, initial_state, None, tool_schemas, max_actions)
297}
298
299fn verify_inner(
300 proposal: &ActionProposal,
301 initial_state: Option<&HashMap<String, Value>>,
302 registered_tools: Option<&HashSet<String>>,
303 tool_schemas: Option<&HashMap<String, ToolSchema>>,
304 max_actions: usize,
305) -> VerifyResult {
306 let mut state = match initial_state {
307 Some(s) => StaticState::from_map(s.clone()),
308 None => StaticState::new(),
309 };
310 let mut issues = Vec::new();
311
312 if proposal.actions.len() > max_actions {
314 issues.push(VerifyIssue {
315 action_id: proposal
316 .actions
317 .first()
318 .map(|a| a.id.clone())
319 .unwrap_or_default(),
320 severity: "warning".to_string(),
321 message: format!(
322 "excessive actions: {} (limit {})",
323 proposal.actions.len(),
324 max_actions
325 ),
326 });
327 }
328
329 let mut seen_calls: HashMap<String, u32> = HashMap::new();
331 for action in &proposal.actions {
332 if action.action_type == ActionType::ToolCall {
333 if let Some(ref tool) = action.tool {
334 let params = serde_json::to_string(&action.parameters).unwrap_or_default();
335 let key = format!("{}:{}", tool, params);
336 *seen_calls.entry(key).or_insert(0) += 1;
337 }
338 }
339 }
340 for (call_key, count) in &seen_calls {
341 let tool_name = call_key.split(':').next().unwrap_or("?");
342 if *count >= 3 {
343 issues.push(VerifyIssue {
344 action_id: "proposal".to_string(),
345 severity: "error".to_string(),
346 message: format!(
347 "repeated identical tool call: {} ({}x) — likely loop",
348 tool_name, count
349 ),
350 });
351 } else if *count == 2 {
352 issues.push(VerifyIssue {
353 action_id: "proposal".to_string(),
354 severity: "warning".to_string(),
355 message: format!("duplicate tool call: {} ({}x)", tool_name, count),
356 });
357 }
358 }
359
360 let levels = build_dag(&proposal.actions);
362 let execution_levels: Vec<Vec<String>> = levels
363 .iter()
364 .map(|level| {
365 level
366 .iter()
367 .map(|&i| proposal.actions[i].id.clone())
368 .collect()
369 })
370 .collect();
371
372 for level in &levels {
374 for &idx in level {
375 let action = &proposal.actions[idx];
376
377 for pre in &action.preconditions {
379 if let Some(error) = precondition::check_precondition(pre, &state) {
380 issues.push(VerifyIssue {
381 action_id: action.id.clone(),
382 severity: "error".to_string(),
383 message: format!("precondition will fail: {}", error),
384 });
385 }
386 }
387
388 for dep in &action.state_dependencies {
390 if !state.exists(dep) && !state.is_unknown(dep) {
391 issues.push(VerifyIssue {
392 action_id: action.id.clone(),
393 severity: "error".to_string(),
394 message: format!("state dependency '{}' not available at this point", dep),
395 });
396 }
397 }
398
399 if action.action_type == ActionType::ToolCall {
401 if let Some(ref tool) = action.tool {
402 let registered = match (tool_schemas, registered_tools) {
406 (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
407 (None, Some(names)) => Some(names.contains(tool.as_str())),
408 (None, None) => None,
409 };
410 if registered == Some(false) {
411 issues.push(VerifyIssue {
412 action_id: action.id.clone(),
413 severity: "error".to_string(),
414 message: format!("tool '{}' is not registered", tool),
415 });
416 }
417 if let Some(schema) = tool_schemas.and_then(|s| s.get(tool.as_str())) {
422 for msg in validate_tool_params(&action.parameters, &schema.parameters) {
423 issues.push(VerifyIssue {
424 action_id: action.id.clone(),
425 severity: "error".to_string(),
426 message: format!("tool '{tool}': {msg}"),
427 });
428 }
429 }
430 } else {
431 issues.push(VerifyIssue {
432 action_id: action.id.clone(),
433 severity: "error".to_string(),
434 message: "tool_call action has no tool specified".to_string(),
435 });
436 }
437 }
438
439 apply_action_effects(action, &mut state);
440 }
441 }
442
443 let conflicts = detect_conflicts(&proposal.actions);
445 for (a1, a2, key) in &conflicts {
446 issues.push(VerifyIssue {
447 action_id: a1.clone(),
448 severity: "warning".to_string(),
449 message: format!(
450 "write conflict on '{}' with action {} (no dependency declared)",
451 key, a2
452 ),
453 });
454 }
455
456 let has_errors = issues.iter().any(|i| i.severity == "error");
457
458 VerifyResult {
459 valid: !has_errors,
460 issues,
461 simulated_state: state.known,
462 execution_levels,
463 conflicts,
464 }
465}
466
467pub fn simulate(
469 proposal: &ActionProposal,
470 initial_state: Option<&HashMap<String, Value>>,
471) -> HashMap<String, Value> {
472 verify(proposal, initial_state, None, usize::MAX).simulated_state
473}
474
475pub fn equivalent(
477 p1: &ActionProposal,
478 p2: &ActionProposal,
479 test_states: Option<&[HashMap<String, Value>]>,
480) -> bool {
481 let defaults = vec![
482 HashMap::new(),
483 [
484 ("x".to_string(), Value::from(1)),
485 ("y".to_string(), Value::from(2)),
486 ]
487 .into(),
488 ];
489 let states = test_states.unwrap_or(&defaults);
490
491 for state in states {
492 let s1 = simulate(p1, Some(state));
493 let s2 = simulate(p2, Some(state));
494 if s1 != s2 {
495 return false;
496 }
497 }
498 true
499}
500
501pub fn optimize(proposal: &ActionProposal) -> ActionProposal {
503 let mut written_keys = HashSet::new();
505 for action in &proposal.actions {
506 if action.action_type == ActionType::StateWrite {
507 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
508 written_keys.insert(k.to_string());
509 }
510 }
511 for key in action.expected_effects.keys() {
512 written_keys.insert(key.clone());
513 }
514 }
515
516 let optimized_actions: Vec<Action> = proposal
517 .actions
518 .iter()
519 .map(|action| {
520 let pruned: Vec<String> = action
521 .state_dependencies
522 .iter()
523 .filter(|d| written_keys.contains(d.as_str()))
524 .cloned()
525 .collect();
526
527 if pruned.len() != action.state_dependencies.len() {
528 let mut new_action = action.clone();
529 new_action.state_dependencies = pruned;
530 new_action
531 } else {
532 action.clone()
533 }
534 })
535 .collect();
536
537 ActionProposal {
538 id: proposal.id.clone(),
539 source: proposal.source.clone(),
540 actions: optimized_actions,
541 timestamp: proposal.timestamp,
542 context: proposal.context.clone(),
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use car_ir::{FailureBehavior, Precondition};
550
551 fn tool_call(id: &str, tool: &str) -> Action {
552 Action {
553 id: id.to_string(),
554 action_type: ActionType::ToolCall,
555 tool: Some(tool.to_string()),
556 parameters: HashMap::new(),
557 preconditions: vec![],
558 expected_effects: HashMap::new(),
559 state_dependencies: vec![],
560 idempotent: false,
561 max_retries: 3,
562 failure_behavior: FailureBehavior::Abort,
563 timeout_ms: None,
564 metadata: HashMap::new(),
565 }
566 }
567
568 fn state_write(id: &str, key: &str, value: Value) -> Action {
569 Action {
570 id: id.to_string(),
571 action_type: ActionType::StateWrite,
572 tool: None,
573 parameters: [
574 ("key".to_string(), Value::from(key)),
575 ("value".to_string(), value),
576 ]
577 .into(),
578 preconditions: vec![],
579 expected_effects: HashMap::new(),
580 state_dependencies: vec![],
581 idempotent: false,
582 max_retries: 3,
583 failure_behavior: FailureBehavior::Abort,
584 timeout_ms: None,
585 metadata: HashMap::new(),
586 }
587 }
588
589 fn prop(actions: Vec<Action>) -> ActionProposal {
590 ActionProposal {
591 id: "test".to_string(),
592 source: "test".to_string(),
593 actions,
594 timestamp: chrono::Utc::now(),
595 context: HashMap::new(),
596 }
597 }
598
599 #[test]
600 fn verify_valid_proposal() {
601 let p = prop(vec![state_write("a1", "x", Value::from(1)), {
602 let mut a = tool_call("a2", "search");
603 a.state_dependencies = vec!["x".to_string()];
604 a
605 }]);
606 let r = verify(&p, None, Some(&["search".to_string()].into()), 30);
607 assert!(r.valid);
608 }
609
610 fn echo_schema_parameters() -> Value {
613 serde_json::json!({
614 "type": "object",
615 "properties": { "msg": { "type": "string" } },
616 "required": ["msg"],
617 })
618 }
619
620 fn schema_map(parameters: Value) -> HashMap<String, ToolSchema> {
621 [(
622 "echo".to_string(),
623 ToolSchema {
624 name: "echo".to_string(),
625 description: String::new(),
626 parameters,
627 returns: None,
628 idempotent: true,
629 cache_ttl_secs: None,
630 rate_limit: None,
631 },
632 )]
633 .into()
634 }
635
636 fn echo_call(params: HashMap<String, Value>) -> ActionProposal {
637 let mut a = tool_call("a1", "echo");
638 a.parameters = params;
639 prop(vec![a])
640 }
641
642 #[test]
643 fn schema_verify_accepts_well_typed_params() {
644 let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
645 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
646 assert!(r.valid, "{:?}", r.issues);
647 }
648
649 #[test]
650 fn schema_verify_rejects_type_mismatch() {
651 let p = echo_call([("msg".to_string(), Value::from(42))].into());
652 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
653 assert!(!r.valid);
654 assert!(r
655 .issues
656 .iter()
657 .any(|i| i.message.contains("wrong type") && i.message.contains("msg")));
658 }
659
660 #[test]
661 fn schema_verify_rejects_missing_required() {
662 let p = echo_call(HashMap::new());
663 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
664 assert!(!r.valid);
665 assert!(r
666 .issues
667 .iter()
668 .any(|i| i.message.contains("missing required parameter") && i.message.contains("msg")));
669 }
670
671 #[test]
672 fn schema_verify_rejects_unknown_tool() {
673 let mut a = tool_call("a1", "nope");
674 a.parameters = [("msg".to_string(), Value::from("hi"))].into();
675 let r = verify_with_schemas(&prop(vec![a]), None, Some(&schema_map(echo_schema_parameters())), 30);
676 assert!(!r.valid);
677 assert!(r.issues.iter().any(|i| i.message.contains("not registered")));
678 }
679
680 #[test]
681 fn name_only_verify_still_skips_param_validation() {
682 let p = echo_call([("msg".to_string(), Value::from(42))].into());
686 let r = verify(&p, None, Some(&["echo".to_string()].into()), 30);
687 assert!(r.valid, "name-only verify must not validate params: {:?}", r.issues);
688 }
689
690 #[test]
691 fn schema_verify_accepts_integer_and_union_types() {
692 let parameters = serde_json::json!({
693 "type": "object",
694 "properties": {
695 "n": { "type": "integer" },
696 "maybe": { "type": ["string", "null"] },
697 },
698 "required": ["n"],
699 });
700 let p = echo_call(
701 [
702 ("n".to_string(), Value::from(7)),
703 ("maybe".to_string(), Value::Null),
704 ]
705 .into(),
706 );
707 let r = verify_with_schemas(&p, None, Some(&schema_map(parameters)), 30);
708 assert!(r.valid, "{:?}", r.issues);
709 }
710
711 #[test]
712 fn schema_verify_empty_schema_imposes_no_constraints() {
713 let p = echo_call([("anything".to_string(), Value::from(42))].into());
717 let r = verify_with_schemas(&p, None, Some(&schema_map(serde_json::json!({}))), 30);
718 assert!(r.valid, "{:?}", r.issues);
719 }
720
721 #[test]
722 fn verify_catches_unsatisfied_precondition() {
723 let mut a = tool_call("a1", "deploy");
724 a.preconditions = vec![Precondition {
725 key: "tests_passed".to_string(),
726 operator: "eq".to_string(),
727 value: Value::Bool(true),
728 description: String::new(),
729 }];
730 let r = verify(&prop(vec![a]), None, None, 30);
731 assert!(!r.valid);
732 }
733
734 #[test]
735 fn verify_precondition_satisfied_by_earlier_action() {
736 let mut a2 = tool_call("a2", "deploy");
737 a2.preconditions = vec![Precondition {
738 key: "ready".to_string(),
739 operator: "eq".to_string(),
740 value: Value::Bool(true),
741 description: String::new(),
742 }];
743 a2.state_dependencies = vec!["ready".to_string()];
744
745 let p = prop(vec![state_write("a1", "ready", Value::Bool(true)), a2]);
746 let r = verify(&p, None, None, 30);
747 assert!(r.valid);
748 }
749
750 #[test]
751 fn verify_missing_state_dependency() {
752 let mut a = tool_call("a1", "x");
753 a.state_dependencies = vec!["nonexistent".to_string()];
754 let r = verify(&prop(vec![a]), None, None, 30);
755 assert!(!r.valid);
756 }
757
758 #[test]
759 fn verify_tool_not_registered() {
760 let a = tool_call("a1", "quantum");
761 let r = verify(&prop(vec![a]), None, Some(&HashSet::new()), 30);
762 assert!(!r.valid);
763 }
764
765 #[test]
766 fn verify_no_tool_specified() {
767 let mut a = tool_call("a1", "x");
768 a.tool = None;
769 let r = verify(&prop(vec![a]), None, None, 30);
770 assert!(!r.valid);
771 }
772
773 #[test]
774 fn detect_write_conflict() {
775 let p = prop(vec![
776 state_write("a1", "x", Value::from(1)),
777 state_write("a2", "x", Value::from(2)),
778 ]);
779 let r = verify(&p, None, None, 30);
780 assert!(!r.conflicts.is_empty());
781 }
782
783 #[test]
784 fn simulate_state_writes() {
785 let p = prop(vec![
786 state_write("a1", "x", Value::from(10)),
787 state_write("a2", "y", Value::from(20)),
788 ]);
789 let s = simulate(&p, None);
790 assert_eq!(s.get("x"), Some(&Value::from(10)));
791 assert_eq!(s.get("y"), Some(&Value::from(20)));
792 }
793
794 #[test]
795 fn equivalent_proposals() {
796 let p1 = prop(vec![
797 state_write("a1", "x", Value::from(1)),
798 state_write("a2", "y", Value::from(2)),
799 ]);
800 let p2 = prop(vec![
801 state_write("b1", "y", Value::from(2)),
802 state_write("b2", "x", Value::from(1)),
803 ]);
804 assert!(equivalent(&p1, &p2, None));
805 }
806
807 #[test]
808 fn non_equivalent_proposals() {
809 let p1 = prop(vec![state_write("a1", "x", Value::from(1))]);
810 let p2 = prop(vec![state_write("b1", "x", Value::from(99))]);
811 assert!(!equivalent(&p1, &p2, None));
812 }
813
814 #[test]
815 fn optimize_removes_phantom_deps() {
816 let mut a = tool_call("a1", "search");
817 a.state_dependencies = vec!["phantom".to_string()];
818 let p = prop(vec![a]);
819 let optimized = optimize(&p);
820 assert!(optimized.actions[0].state_dependencies.is_empty());
821 }
822
823 #[test]
824 fn optimize_preserves_real_deps() {
825 let mut a2 = tool_call("a2", "x");
826 a2.state_dependencies = vec!["x".to_string()];
827 let p = prop(vec![state_write("a1", "x", Value::from(1)), a2]);
828 let optimized = optimize(&p);
829 assert_eq!(optimized.actions[1].state_dependencies, vec!["x"]);
830 }
831
832 #[test]
833 fn loop_detection_duplicates() {
834 let p = prop(vec![tool_call("a1", "search"), tool_call("a2", "search")]);
835 let r = verify(&p, None, None, 30);
836 assert!(r.issues.iter().any(|i| i.message.contains("duplicate")));
837 }
838
839 #[test]
840 fn loop_detection_triple() {
841 let p = prop(vec![
842 tool_call("a1", "search"),
843 tool_call("a2", "search"),
844 tool_call("a3", "search"),
845 ]);
846 let r = verify(&p, None, None, 30);
847 assert!(!r.valid);
848 assert!(r.issues.iter().any(|i| i.message.contains("likely loop")));
849 }
850
851 #[test]
852 fn resource_bounds() {
853 let actions: Vec<Action> = (0..35)
854 .map(|i| tool_call(&format!("a{}", i), &format!("t{}", i)))
855 .collect();
856 let r = verify(&prop(actions), None, None, 30);
857 assert!(r.issues.iter().any(|i| i.message.contains("excessive")));
858 }
859}