1use car_ir::precondition::{self, StateView};
10use car_ir::{build_dag, Action, ActionProposal, ActionType};
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
169pub fn verify(
173 proposal: &ActionProposal,
174 initial_state: Option<&HashMap<String, Value>>,
175 registered_tools: Option<&HashSet<String>>,
176 max_actions: usize,
177) -> VerifyResult {
178 let mut state = match initial_state {
179 Some(s) => StaticState::from_map(s.clone()),
180 None => StaticState::new(),
181 };
182 let mut issues = Vec::new();
183
184 if proposal.actions.len() > max_actions {
186 issues.push(VerifyIssue {
187 action_id: proposal
188 .actions
189 .first()
190 .map(|a| a.id.clone())
191 .unwrap_or_default(),
192 severity: "warning".to_string(),
193 message: format!(
194 "excessive actions: {} (limit {})",
195 proposal.actions.len(),
196 max_actions
197 ),
198 });
199 }
200
201 let mut seen_calls: HashMap<String, u32> = HashMap::new();
203 for action in &proposal.actions {
204 if action.action_type == ActionType::ToolCall {
205 if let Some(ref tool) = action.tool {
206 let params = serde_json::to_string(&action.parameters).unwrap_or_default();
207 let key = format!("{}:{}", tool, params);
208 *seen_calls.entry(key).or_insert(0) += 1;
209 }
210 }
211 }
212 for (call_key, count) in &seen_calls {
213 let tool_name = call_key.split(':').next().unwrap_or("?");
214 if *count >= 3 {
215 issues.push(VerifyIssue {
216 action_id: "proposal".to_string(),
217 severity: "error".to_string(),
218 message: format!(
219 "repeated identical tool call: {} ({}x) — likely loop",
220 tool_name, count
221 ),
222 });
223 } else if *count == 2 {
224 issues.push(VerifyIssue {
225 action_id: "proposal".to_string(),
226 severity: "warning".to_string(),
227 message: format!("duplicate tool call: {} ({}x)", tool_name, count),
228 });
229 }
230 }
231
232 let levels = build_dag(&proposal.actions);
234 let execution_levels: Vec<Vec<String>> = levels
235 .iter()
236 .map(|level| {
237 level
238 .iter()
239 .map(|&i| proposal.actions[i].id.clone())
240 .collect()
241 })
242 .collect();
243
244 for level in &levels {
246 for &idx in level {
247 let action = &proposal.actions[idx];
248
249 for pre in &action.preconditions {
251 if let Some(error) = precondition::check_precondition(pre, &state) {
252 issues.push(VerifyIssue {
253 action_id: action.id.clone(),
254 severity: "error".to_string(),
255 message: format!("precondition will fail: {}", error),
256 });
257 }
258 }
259
260 for dep in &action.state_dependencies {
262 if !state.exists(dep) && !state.is_unknown(dep) {
263 issues.push(VerifyIssue {
264 action_id: action.id.clone(),
265 severity: "error".to_string(),
266 message: format!("state dependency '{}' not available at this point", dep),
267 });
268 }
269 }
270
271 if action.action_type == ActionType::ToolCall {
273 if let Some(ref tool) = action.tool {
274 if let Some(tools) = registered_tools {
275 if !tools.contains(tool.as_str()) {
276 issues.push(VerifyIssue {
277 action_id: action.id.clone(),
278 severity: "error".to_string(),
279 message: format!("tool '{}' is not registered", tool),
280 });
281 }
282 }
283 } else {
284 issues.push(VerifyIssue {
285 action_id: action.id.clone(),
286 severity: "error".to_string(),
287 message: "tool_call action has no tool specified".to_string(),
288 });
289 }
290 }
291
292 apply_action_effects(action, &mut state);
293 }
294 }
295
296 let conflicts = detect_conflicts(&proposal.actions);
298 for (a1, a2, key) in &conflicts {
299 issues.push(VerifyIssue {
300 action_id: a1.clone(),
301 severity: "warning".to_string(),
302 message: format!(
303 "write conflict on '{}' with action {} (no dependency declared)",
304 key, a2
305 ),
306 });
307 }
308
309 let has_errors = issues.iter().any(|i| i.severity == "error");
310
311 VerifyResult {
312 valid: !has_errors,
313 issues,
314 simulated_state: state.known,
315 execution_levels,
316 conflicts,
317 }
318}
319
320pub fn simulate(
322 proposal: &ActionProposal,
323 initial_state: Option<&HashMap<String, Value>>,
324) -> HashMap<String, Value> {
325 verify(proposal, initial_state, None, usize::MAX).simulated_state
326}
327
328pub fn equivalent(
330 p1: &ActionProposal,
331 p2: &ActionProposal,
332 test_states: Option<&[HashMap<String, Value>]>,
333) -> bool {
334 let defaults = vec![
335 HashMap::new(),
336 [
337 ("x".to_string(), Value::from(1)),
338 ("y".to_string(), Value::from(2)),
339 ]
340 .into(),
341 ];
342 let states = test_states.unwrap_or(&defaults);
343
344 for state in states {
345 let s1 = simulate(p1, Some(state));
346 let s2 = simulate(p2, Some(state));
347 if s1 != s2 {
348 return false;
349 }
350 }
351 true
352}
353
354pub fn optimize(proposal: &ActionProposal) -> ActionProposal {
356 let mut written_keys = HashSet::new();
358 for action in &proposal.actions {
359 if action.action_type == ActionType::StateWrite {
360 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
361 written_keys.insert(k.to_string());
362 }
363 }
364 for key in action.expected_effects.keys() {
365 written_keys.insert(key.clone());
366 }
367 }
368
369 let optimized_actions: Vec<Action> = proposal
370 .actions
371 .iter()
372 .map(|action| {
373 let pruned: Vec<String> = action
374 .state_dependencies
375 .iter()
376 .filter(|d| written_keys.contains(d.as_str()))
377 .cloned()
378 .collect();
379
380 if pruned.len() != action.state_dependencies.len() {
381 let mut new_action = action.clone();
382 new_action.state_dependencies = pruned;
383 new_action
384 } else {
385 action.clone()
386 }
387 })
388 .collect();
389
390 ActionProposal {
391 id: proposal.id.clone(),
392 source: proposal.source.clone(),
393 actions: optimized_actions,
394 timestamp: proposal.timestamp,
395 context: proposal.context.clone(),
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use car_ir::{FailureBehavior, Precondition};
403
404 fn tool_call(id: &str, tool: &str) -> Action {
405 Action {
406 id: id.to_string(),
407 action_type: ActionType::ToolCall,
408 tool: Some(tool.to_string()),
409 parameters: HashMap::new(),
410 preconditions: vec![],
411 expected_effects: HashMap::new(),
412 state_dependencies: vec![],
413 idempotent: false,
414 max_retries: 3,
415 failure_behavior: FailureBehavior::Abort,
416 timeout_ms: None,
417 metadata: HashMap::new(),
418 }
419 }
420
421 fn state_write(id: &str, key: &str, value: Value) -> Action {
422 Action {
423 id: id.to_string(),
424 action_type: ActionType::StateWrite,
425 tool: None,
426 parameters: [
427 ("key".to_string(), Value::from(key)),
428 ("value".to_string(), value),
429 ]
430 .into(),
431 preconditions: vec![],
432 expected_effects: HashMap::new(),
433 state_dependencies: vec![],
434 idempotent: false,
435 max_retries: 3,
436 failure_behavior: FailureBehavior::Abort,
437 timeout_ms: None,
438 metadata: HashMap::new(),
439 }
440 }
441
442 fn prop(actions: Vec<Action>) -> ActionProposal {
443 ActionProposal {
444 id: "test".to_string(),
445 source: "test".to_string(),
446 actions,
447 timestamp: chrono::Utc::now(),
448 context: HashMap::new(),
449 }
450 }
451
452 #[test]
453 fn verify_valid_proposal() {
454 let p = prop(vec![state_write("a1", "x", Value::from(1)), {
455 let mut a = tool_call("a2", "search");
456 a.state_dependencies = vec!["x".to_string()];
457 a
458 }]);
459 let r = verify(&p, None, Some(&["search".to_string()].into()), 30);
460 assert!(r.valid);
461 }
462
463 #[test]
464 fn verify_catches_unsatisfied_precondition() {
465 let mut a = tool_call("a1", "deploy");
466 a.preconditions = vec![Precondition {
467 key: "tests_passed".to_string(),
468 operator: "eq".to_string(),
469 value: Value::Bool(true),
470 description: String::new(),
471 }];
472 let r = verify(&prop(vec![a]), None, None, 30);
473 assert!(!r.valid);
474 }
475
476 #[test]
477 fn verify_precondition_satisfied_by_earlier_action() {
478 let mut a2 = tool_call("a2", "deploy");
479 a2.preconditions = vec![Precondition {
480 key: "ready".to_string(),
481 operator: "eq".to_string(),
482 value: Value::Bool(true),
483 description: String::new(),
484 }];
485 a2.state_dependencies = vec!["ready".to_string()];
486
487 let p = prop(vec![state_write("a1", "ready", Value::Bool(true)), a2]);
488 let r = verify(&p, None, None, 30);
489 assert!(r.valid);
490 }
491
492 #[test]
493 fn verify_missing_state_dependency() {
494 let mut a = tool_call("a1", "x");
495 a.state_dependencies = vec!["nonexistent".to_string()];
496 let r = verify(&prop(vec![a]), None, None, 30);
497 assert!(!r.valid);
498 }
499
500 #[test]
501 fn verify_tool_not_registered() {
502 let a = tool_call("a1", "quantum");
503 let r = verify(&prop(vec![a]), None, Some(&HashSet::new()), 30);
504 assert!(!r.valid);
505 }
506
507 #[test]
508 fn verify_no_tool_specified() {
509 let mut a = tool_call("a1", "x");
510 a.tool = None;
511 let r = verify(&prop(vec![a]), None, None, 30);
512 assert!(!r.valid);
513 }
514
515 #[test]
516 fn detect_write_conflict() {
517 let p = prop(vec![
518 state_write("a1", "x", Value::from(1)),
519 state_write("a2", "x", Value::from(2)),
520 ]);
521 let r = verify(&p, None, None, 30);
522 assert!(!r.conflicts.is_empty());
523 }
524
525 #[test]
526 fn simulate_state_writes() {
527 let p = prop(vec![
528 state_write("a1", "x", Value::from(10)),
529 state_write("a2", "y", Value::from(20)),
530 ]);
531 let s = simulate(&p, None);
532 assert_eq!(s.get("x"), Some(&Value::from(10)));
533 assert_eq!(s.get("y"), Some(&Value::from(20)));
534 }
535
536 #[test]
537 fn equivalent_proposals() {
538 let p1 = prop(vec![
539 state_write("a1", "x", Value::from(1)),
540 state_write("a2", "y", Value::from(2)),
541 ]);
542 let p2 = prop(vec![
543 state_write("b1", "y", Value::from(2)),
544 state_write("b2", "x", Value::from(1)),
545 ]);
546 assert!(equivalent(&p1, &p2, None));
547 }
548
549 #[test]
550 fn non_equivalent_proposals() {
551 let p1 = prop(vec![state_write("a1", "x", Value::from(1))]);
552 let p2 = prop(vec![state_write("b1", "x", Value::from(99))]);
553 assert!(!equivalent(&p1, &p2, None));
554 }
555
556 #[test]
557 fn optimize_removes_phantom_deps() {
558 let mut a = tool_call("a1", "search");
559 a.state_dependencies = vec!["phantom".to_string()];
560 let p = prop(vec![a]);
561 let optimized = optimize(&p);
562 assert!(optimized.actions[0].state_dependencies.is_empty());
563 }
564
565 #[test]
566 fn optimize_preserves_real_deps() {
567 let mut a2 = tool_call("a2", "x");
568 a2.state_dependencies = vec!["x".to_string()];
569 let p = prop(vec![state_write("a1", "x", Value::from(1)), a2]);
570 let optimized = optimize(&p);
571 assert_eq!(optimized.actions[1].state_dependencies, vec!["x"]);
572 }
573
574 #[test]
575 fn loop_detection_duplicates() {
576 let p = prop(vec![tool_call("a1", "search"), tool_call("a2", "search")]);
577 let r = verify(&p, None, None, 30);
578 assert!(r.issues.iter().any(|i| i.message.contains("duplicate")));
579 }
580
581 #[test]
582 fn loop_detection_triple() {
583 let p = prop(vec![
584 tool_call("a1", "search"),
585 tool_call("a2", "search"),
586 tool_call("a3", "search"),
587 ]);
588 let r = verify(&p, None, None, 30);
589 assert!(!r.valid);
590 assert!(r.issues.iter().any(|i| i.message.contains("likely loop")));
591 }
592
593 #[test]
594 fn resource_bounds() {
595 let actions: Vec<Action> = (0..35)
596 .map(|i| tool_call(&format!("a{}", i), &format!("t{}", i)))
597 .collect();
598 let r = verify(&prop(actions), None, None, 30);
599 assert!(r.issues.iter().any(|i| i.message.contains("excessive")));
600 }
601}