Skip to main content

car_verify/
lib.rs

1//! Formal verification for Agent IR.
2//!
3//! Given a state S and proposal P, you can:
4//! 1. **verify**: Prove P is satisfiable in S without executing
5//! 2. **simulate**: Compute expected final state S' without tools
6//! 3. **equivalent**: Show two proposals produce identical state
7//! 4. **optimize**: Reorder actions provably safely
8
9use 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/// Symbolic state for static analysis.
15#[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/// A single verification finding.
73#[derive(Debug, Clone)]
74pub struct VerifyIssue {
75    pub action_id: String,
76    pub severity: String, // "error", "warning", "info"
77    pub message: String,
78}
79
80/// Complete verification result.
81#[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)>, // (action1, action2, key)
88}
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
106// --- Action effects (symbolic) ---
107
108fn 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
124// --- Conflict detection ---
125
126fn 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
169// --- Tool-parameter schema validation ---
170
171/// Friendly JSON type name for error messages.
172fn 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
183/// Does `v` satisfy a single JSON Schema `type` keyword?
184fn value_matches_type(v: &Value, expected: &str) -> bool {
185    match expected {
186        "string" => v.is_string(),
187        "number" => v.is_number(),
188        // JSON Schema "integer": an integral number. Accept i64/u64,
189        // plus a float with no fractional part (e.g. `5.0`).
190        "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        // Unknown/unsupported type keyword: don't flag — we only
198        // enforce the keywords we understand.
199        _ => true,
200    }
201}
202
203/// Validate a tool_call's `parameters` against the tool's JSON-Schema
204/// `parameters` object. Intentionally a focused subset of JSON Schema
205/// — the two checks that catch the overwhelming majority of malformed
206/// model output: declared property `type`s and `required` presence.
207/// Returns human-readable violation messages; empty when the schema
208/// imposes no constraints (e.g. the default empty object `{}`).
209fn 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        // Non-object schema: nothing we can enforce.
213        return out;
214    };
215
216    // required: every named key must be present in params.
217    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    // property types: each supplied param whose key has a declared
228    // `type` must match it. `type` may be a string or an array of
229    // strings (JSON Schema union).
230    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                // No declared type (or non-string/array): accept.
242                _ => 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
265// --- Core verification ---
266
267/// Statically verify a proposal against an initial state.
268///
269/// `registered_tools` carries tool *names* only, so tool-existence is
270/// checked but `parameters` are not. To additionally validate each
271/// `tool_call`'s parameters against the tool's registered JSON Schema
272/// (type mismatches, missing required fields), use
273/// [`verify_with_schemas`].
274pub 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
283/// Like [`verify`], but validates each `tool_call`'s `parameters`
284/// against the registered [`ToolSchema`]'s `parameters` JSON Schema —
285/// catching type mismatches (`{"path": 42}` for a `string` param) and
286/// missing `required` fields before dispatch. Tool existence is
287/// checked against the schema map's keys. This is the path the runtime
288/// (`verify_proposal`) and daemon (`verify` JSON-RPC) use, where the
289/// full schemas registered via `register_tool_schema` are available.
290pub 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    // Resource bounds
313    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    // Loop detection
330    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    // Build DAG
361    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    // Walk in topological order
373    for level in &levels {
374        for &idx in level {
375            let action = &proposal.actions[idx];
376
377            // Check preconditions
378            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            // State dependencies
389            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            // Tool existence + parameter-schema validation
400            if action.action_type == ActionType::ToolCall {
401                if let Some(ref tool) = action.tool {
402                    // Existence: prefer the schema map's keys, fall
403                    // back to the name set. When neither is provided
404                    // (both None) existence isn't checked.
405                    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                    // Parameters: validate against the registered
418                    // schema when we have one. This is the check the
419                    // `register_tool_schema` contract promises —
420                    // type mismatches and missing required fields.
421                    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    // Conflicts
444    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
467/// Simulate a proposal's state effects without executing tools.
468pub 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
475/// Test if two proposals produce identical state transitions.
476pub 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
501/// Optimize a proposal: remove phantom dependencies to enable more parallelism.
502pub fn optimize(proposal: &ActionProposal) -> ActionProposal {
503    // Find which keys are actually written
504    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    // --- tool-parameter schema validation (car-releases#56) ---
611
612    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        // Back-compat: verify() with names checks existence only. A
683        // bad parameter type must NOT be flagged when no schema is
684        // supplied — that path has no schema to validate against.
685        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        // Default `{}` parameters schema -> existence only, no param
714        // checks (preserves behavior for tools registered without a
715        // detailed schema).
716        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}