1use car_ir::precondition::{self, StateView};
55use car_ir::{build_dag, Action, ActionProposal, ActionType, ToolSchema};
56use serde_json::Value;
57use std::collections::{HashMap, HashSet};
58
59pub mod admission;
60pub mod attempt;
61pub mod concurrency;
62pub mod cwm;
63pub mod dag;
64pub mod goal;
65pub mod infoflow;
66pub mod intent;
67pub mod montecarlo;
68pub mod plan_check;
69pub mod trace_policy;
70pub mod transaction;
71pub mod verifier;
72pub use admission::{
73 admit_state, AdmissionRefusal, CommitAuthority, OwnershipTable, SelfCommit, StateAdmission,
74 StateCandidate, StateSurface, SurfaceRule,
75};
76pub use attempt::{Attempt, AttemptAdvice, AttemptLedger, AttemptOutcome, Exclusion, FailureClass};
77pub use goal::{
78 anchor_directive, evaluate_goal, governor_check, run_goal_loop, GoalCondition, GoalGovernor,
79 GoalHalt, GoalInputs, GoalRun, GoalRunState, GoalSpec, GoalStatus, GoalVerdict,
80 IterationOutcome,
81};
82pub use intent::{
83 check_intent, gate_intent, intent_actions_from, IntentAction, IntentDisposition,
84 IntentGateDecision, IntentGatePolicy, IntentReport, IntentSpec, IntentViolation,
85 IntentViolationKind,
86};
87pub use montecarlo::{
88 simulate_monte_carlo, ActionOutcome, Distribution, KeyOutcome, MonteCarloConfig,
89 MonteCarloResult, ValueFrequency,
90};
91pub use plan_check::{
92 check_plan, PlanCheckReport, PlanCheckRequest, PlanDefect, PlanDefectKind, PlanStep,
93};
94pub use verifier::{
95 admit, required_classes, AdmissionDecision, AdmissionOutcome, EvidenceRequirement, UnmetReason,
96 UnmetRequirement, VerifierAuthority, VerifierCost, VerifierDescriptor, VerifierOutcome,
97 VerifierVerdict,
98};
99pub mod workflow_graph;
100pub use concurrency::{
101 analyze as analyze_concurrency, gate_concurrency, AgentOp, AnomalyFinding, ConcurrencyAnomaly,
102 ConcurrencyGate, ConcurrencyGatePolicy, ConcurrencyReport, ConsistencyLevel, Disposition,
103 GatedRemediation, Remediation,
104};
105pub use cwm::{
106 score, score_predictions, simulate_with_model, synthesize_cwm, CwmRequest, CwmResult,
107 EffectModel, Failure, GatedEffectModel, GatedPrediction, ScoreReport, Transition,
108};
109pub use infoflow::{
110 check_information_flow, gate_flow, Confidentiality, FlowAction, FlowGateDecision,
111 FlowGatePolicy, FlowPolicy, FlowReport, FlowViolation, FlowViolationKind, ToolLabels,
112 TrustLevel,
113};
114pub use transaction::{
115 check_transaction, check_transaction_with_predictions, ConflictKind, TransactionConflict,
116 TransactionReport,
117};
118pub use workflow_graph::{
119 check_temporal_policies, verify_workflow_graph, PolicyReport, PolicyViolation, TemporalPolicy,
120 WorkflowDefect, WorkflowDefectKind, WorkflowEdge, WorkflowGraph, WorkflowVerifyReport,
121};
122
123#[derive(Debug, Clone)]
125pub struct StaticState {
126 pub known: HashMap<String, Value>,
127 pub unknown_keys: HashSet<String>,
128}
129
130impl StaticState {
131 pub fn new() -> Self {
132 Self {
133 known: HashMap::new(),
134 unknown_keys: HashSet::new(),
135 }
136 }
137
138 pub fn from_map(map: HashMap<String, Value>) -> Self {
139 Self {
140 known: map,
141 unknown_keys: HashSet::new(),
142 }
143 }
144
145 pub fn get(&self, key: &str) -> Option<&Value> {
146 self.known.get(key)
147 }
148
149 pub fn exists(&self, key: &str) -> bool {
150 self.known.contains_key(key)
151 }
152
153 pub fn is_unknown(&self, key: &str) -> bool {
154 self.unknown_keys.contains(key)
155 }
156
157 pub fn set(&mut self, key: &str, value: Value) {
158 self.known.insert(key.to_string(), value);
159 self.unknown_keys.remove(key);
160 }
161}
162
163impl Default for StaticState {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169impl StateView for StaticState {
170 fn get_value(&self, key: &str) -> Option<Value> {
171 self.known.get(key).cloned()
172 }
173 fn key_exists(&self, key: &str) -> bool {
174 self.known.contains_key(key)
175 }
176 fn is_unknown(&self, key: &str) -> bool {
177 self.unknown_keys.contains(key)
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum EvidenceTier {
228 DecisionProcedure,
238 Heuristic,
248 Sampled,
256}
257
258impl EvidenceTier {
259 pub const fn as_str(&self) -> &'static str {
266 match self {
267 EvidenceTier::DecisionProcedure => "decision_procedure",
268 EvidenceTier::Heuristic => "heuristic",
269 EvidenceTier::Sampled => "sampled",
270 }
271 }
272}
273
274#[derive(Debug, Clone, serde::Serialize)]
276#[non_exhaustive]
277pub struct VerifyIssue {
278 pub action_id: String,
279 pub severity: String, pub message: String,
281 pub tier: EvidenceTier,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
306#[non_exhaustive]
307pub struct CheckRecord {
308 pub name: String,
310 pub ran: bool,
315 pub verifies: String,
317 pub cannot_verify: String,
320 pub findings: usize,
322 pub tier: EvidenceTier,
327}
328
329#[derive(Debug, Clone, serde::Serialize)]
338pub struct VerificationEvidence {
339 pub checks: Vec<CheckRecord>,
341 pub assumptions: Vec<String>,
344 pub untested_regions: Vec<String>,
347 pub residual_risks: Vec<String>,
351 pub confidence: f64,
357}
358
359#[derive(Debug, serde::Serialize)]
361pub struct VerifyResult {
362 pub valid: bool,
363 pub issues: Vec<VerifyIssue>,
364 pub simulated_state: HashMap<String, Value>,
365 pub execution_levels: Vec<Vec<String>>,
366 pub conflicts: Vec<(String, String, String)>, pub evidence: VerificationEvidence,
370}
371
372impl VerifyResult {
373 pub fn errors(&self) -> Vec<&VerifyIssue> {
374 self.issues
375 .iter()
376 .filter(|i| i.severity == "error")
377 .collect()
378 }
379
380 pub fn warnings(&self) -> Vec<&VerifyIssue> {
381 self.issues
382 .iter()
383 .filter(|i| i.severity == "warning")
384 .collect()
385 }
386
387 pub fn issues_with_tier(&self, tier: EvidenceTier) -> Vec<&VerifyIssue> {
395 self.issues.iter().filter(|i| i.tier == tier).collect()
396 }
397}
398
399pub(crate) fn apply_action_effects(action: &Action, state: &mut StaticState) {
402 if action.action_type == ActionType::StateWrite {
403 if let Some(key) = action.parameters.get("key").and_then(|v| v.as_str()) {
404 let value = action
405 .parameters
406 .get("value")
407 .cloned()
408 .unwrap_or(Value::Null);
409 state.set(key, value);
410 }
411 }
412 for (key, value) in &action.expected_effects {
413 state.set(key, value.clone());
414 }
415}
416
417fn detect_conflicts(actions: &[Action]) -> Vec<(String, String, String)> {
420 let mut writers: HashMap<String, Vec<String>> = HashMap::new();
421
422 for action in actions {
423 let mut keys_written = HashSet::new();
424 if action.action_type == ActionType::StateWrite {
425 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
426 keys_written.insert(k.to_string());
427 }
428 }
429 for key in action.expected_effects.keys() {
430 keys_written.insert(key.clone());
431 }
432 for key in keys_written {
433 writers.entry(key).or_default().push(action.id.clone());
434 }
435 }
436
437 let dep_map: HashMap<String, HashSet<String>> = actions
438 .iter()
439 .map(|a| (a.id.clone(), a.state_dependencies.iter().cloned().collect()))
440 .collect();
441
442 let mut conflicts = Vec::new();
443 for (key, action_ids) in &writers {
444 if action_ids.len() < 2 {
445 continue;
446 }
447 for i in 0..action_ids.len() {
448 for j in (i + 1)..action_ids.len() {
449 let a1 = &action_ids[i];
450 let a2 = &action_ids[j];
451 let deps_a2 = dep_map.get(a2).cloned().unwrap_or_default();
452 let deps_a1 = dep_map.get(a1).cloned().unwrap_or_default();
453 if !deps_a2.contains(key) && !deps_a1.contains(key) {
454 conflicts.push((a1.clone(), a2.clone(), key.clone()));
455 }
456 }
457 }
458 }
459 conflicts
460}
461
462fn json_type_name(v: &Value) -> &'static str {
466 match v {
467 Value::Null => "null",
468 Value::Bool(_) => "boolean",
469 Value::Number(_) => "number",
470 Value::String(_) => "string",
471 Value::Array(_) => "array",
472 Value::Object(_) => "object",
473 }
474}
475
476fn value_matches_type(v: &Value, expected: &str) -> bool {
478 match expected {
479 "string" => v.is_string(),
480 "number" => v.is_number(),
481 "integer" => {
484 v.is_i64() || v.is_u64() || v.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false)
485 }
486 "boolean" => v.is_boolean(),
487 "array" => v.is_array(),
488 "object" => v.is_object(),
489 "null" => v.is_null(),
490 _ => true,
493 }
494}
495
496fn validate_tool_params(params: &HashMap<String, Value>, schema: &Value) -> Vec<String> {
503 let mut out = Vec::new();
504 let Some(schema_obj) = schema.as_object() else {
505 return out;
507 };
508
509 if let Some(Value::Array(required)) = schema_obj.get("required") {
511 for req in required {
512 if let Some(name) = req.as_str() {
513 if !params.contains_key(name) {
514 out.push(format!("missing required parameter '{name}'"));
515 }
516 }
517 }
518 }
519
520 if let Some(Value::Object(properties)) = schema_obj.get("properties") {
524 for (key, val) in params {
525 let Some(prop_schema) = properties.get(key).and_then(|s| s.as_object()) else {
526 continue;
527 };
528 let ok = match prop_schema.get("type") {
529 Some(Value::String(t)) => value_matches_type(val, t),
530 Some(Value::Array(types)) => types
531 .iter()
532 .filter_map(|t| t.as_str())
533 .any(|t| value_matches_type(val, t)),
534 _ => true,
536 };
537 if !ok {
538 let expected = match prop_schema.get("type") {
539 Some(Value::String(t)) => t.clone(),
540 Some(Value::Array(types)) => types
541 .iter()
542 .filter_map(|t| t.as_str())
543 .collect::<Vec<_>>()
544 .join("|"),
545 _ => String::new(),
546 };
547 out.push(format!(
548 "parameter '{key}' has wrong type: expected {expected}, got {}",
549 json_type_name(val)
550 ));
551 }
552 }
553 }
554
555 out
556}
557
558pub fn verify(
568 proposal: &ActionProposal,
569 initial_state: Option<&HashMap<String, Value>>,
570 registered_tools: Option<&HashSet<String>>,
571 max_actions: usize,
572) -> VerifyResult {
573 verify_inner(proposal, initial_state, registered_tools, None, max_actions)
574}
575
576pub fn verify_with_schemas(
584 proposal: &ActionProposal,
585 initial_state: Option<&HashMap<String, Value>>,
586 tool_schemas: Option<&HashMap<String, ToolSchema>>,
587 max_actions: usize,
588) -> VerifyResult {
589 verify_inner(proposal, initial_state, None, tool_schemas, max_actions)
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
598enum EffectMode {
599 Optimistic,
608 ExecutionFaithful,
617}
618
619fn verify_inner(
620 proposal: &ActionProposal,
621 initial_state: Option<&HashMap<String, Value>>,
622 registered_tools: Option<&HashSet<String>>,
623 tool_schemas: Option<&HashMap<String, ToolSchema>>,
624 max_actions: usize,
625) -> VerifyResult {
626 verify_inner_with_effects(
627 proposal,
628 initial_state,
629 registered_tools,
630 tool_schemas,
631 max_actions,
632 EffectMode::Optimistic,
633 )
634}
635
636fn verify_inner_with_effects(
637 proposal: &ActionProposal,
638 initial_state: Option<&HashMap<String, Value>>,
639 registered_tools: Option<&HashSet<String>>,
640 tool_schemas: Option<&HashMap<String, ToolSchema>>,
641 max_actions: usize,
642 effect_mode: EffectMode,
643) -> VerifyResult {
644 let mut state = match initial_state {
645 Some(s) => StaticState::from_map(s.clone()),
646 None => StaticState::new(),
647 };
648 let mut issues = Vec::new();
649
650 let mut precondition_findings = 0usize;
654 let mut state_dependency_findings = 0usize;
655 let mut tool_existence_findings = 0usize;
656 let mut param_schema_findings = 0usize;
657 let mut has_tool_calls = false;
658 let mut saw_missing_tool = false;
663 let mut compensation_findings = 0usize;
667 let mut saw_compensation_ref = false;
671 let has_tool_registry = tool_schemas.is_some() || registered_tools.is_some();
675 let param_schema_ran = tool_schemas.is_some();
676
677 let issues_before_bounds = issues.len();
679 if proposal.actions.len() > max_actions {
680 issues.push(VerifyIssue {
681 action_id: proposal
682 .actions
683 .first()
684 .map(|a| a.id.clone())
685 .unwrap_or_default(),
686 severity: "warning".to_string(),
687 message: format!(
688 "excessive actions: {} (limit {})",
689 proposal.actions.len(),
690 max_actions
691 ),
692 tier: EvidenceTier::DecisionProcedure,
698 });
699 }
700
701 let resource_bound_findings = issues.len() - issues_before_bounds;
702
703 let issues_before_loop = issues.len();
705 let mut seen_calls: HashMap<String, u32> = HashMap::new();
706 for action in &proposal.actions {
707 if action.action_type == ActionType::ToolCall {
708 if let Some(ref tool) = action.tool {
709 let params = serde_json::to_string(&action.parameters).unwrap_or_default();
710 let key = format!("{}:{}", tool, params);
711 *seen_calls.entry(key).or_insert(0) += 1;
712 }
713 }
714 }
715 for (call_key, count) in &seen_calls {
716 let tool_name = call_key.split(':').next().unwrap_or("?");
717 if *count >= 3 {
718 issues.push(VerifyIssue {
719 action_id: "proposal".to_string(),
720 severity: "error".to_string(),
721 message: format!(
722 "repeated identical tool call: {} ({}x) — likely loop",
723 tool_name, count
724 ),
725 tier: EvidenceTier::Heuristic,
729 });
730 } else if *count == 2 {
731 issues.push(VerifyIssue {
732 action_id: "proposal".to_string(),
733 severity: "warning".to_string(),
734 message: format!("duplicate tool call: {} ({}x)", tool_name, count),
735 tier: EvidenceTier::Heuristic,
738 });
739 }
740 }
741
742 let loop_detection_findings = issues.len() - issues_before_loop;
743
744 let levels = build_dag(&proposal.actions);
746 let execution_levels: Vec<Vec<String>> = levels
747 .iter()
748 .map(|level| {
749 level
750 .iter()
751 .map(|&i| proposal.actions[i].id.clone())
752 .collect()
753 })
754 .collect();
755
756 for level in &levels {
758 for &idx in level {
759 let action = &proposal.actions[idx];
760
761 let mut blocked = false;
766
767 for pre in &action.preconditions {
769 if let Some(error) = precondition::check_precondition(pre, &state) {
770 precondition_findings += 1;
771 blocked = true;
772 issues.push(VerifyIssue {
773 action_id: action.id.clone(),
774 severity: "error".to_string(),
775 message: format!("precondition will fail: {}", error),
776 tier: EvidenceTier::DecisionProcedure,
784 });
785 }
786 }
787
788 for dep in &action.state_dependencies {
790 if !state.exists(dep) && !state.is_unknown(dep) {
791 state_dependency_findings += 1;
792 blocked = true;
793 issues.push(VerifyIssue {
794 action_id: action.id.clone(),
795 severity: "error".to_string(),
796 message: format!("state dependency '{}' not available at this point", dep),
797 tier: EvidenceTier::DecisionProcedure,
801 });
802 }
803 }
804
805 if action.action_type == ActionType::ToolCall {
807 has_tool_calls = true;
808 if let Some(ref tool) = action.tool {
809 let registered = match (tool_schemas, registered_tools) {
813 (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
814 (None, Some(names)) => Some(names.contains(tool.as_str())),
815 (None, None) => None,
816 };
817 if registered == Some(false) {
818 tool_existence_findings += 1;
819 issues.push(VerifyIssue {
820 action_id: action.id.clone(),
821 severity: "error".to_string(),
822 message: format!("tool '{}' is not registered", tool),
823 tier: EvidenceTier::DecisionProcedure,
825 });
826 }
827 if let Some(schema) = tool_schemas.and_then(|s| s.get(tool.as_str())) {
832 for msg in validate_tool_params(&action.parameters, &schema.parameters) {
833 param_schema_findings += 1;
834 issues.push(VerifyIssue {
835 action_id: action.id.clone(),
836 severity: "error".to_string(),
837 message: format!("tool '{tool}': {msg}"),
838 tier: EvidenceTier::DecisionProcedure,
846 });
847 }
848 }
849 } else {
850 saw_missing_tool = true;
851 tool_existence_findings += 1;
852 issues.push(VerifyIssue {
853 action_id: action.id.clone(),
854 severity: "error".to_string(),
855 message: "tool_call action has no tool specified".to_string(),
856 tier: EvidenceTier::DecisionProcedure,
858 });
859 }
860 }
861
862 match &action.compensation {
868 Some(car_ir::Compensation::Tool { tool, .. }) => {
869 let registered = match (tool_schemas, registered_tools) {
870 (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
871 (None, Some(names)) => Some(names.contains(tool.as_str())),
872 (None, None) => None,
873 };
874 if registered == Some(false) {
875 compensation_findings += 1;
876 issues.push(VerifyIssue {
877 action_id: action.id.clone(),
878 severity: "error".to_string(),
879 message: format!(
880 "compensation names tool '{tool}', which is not registered"
881 ),
882 tier: EvidenceTier::DecisionProcedure,
883 });
884 }
885 }
886 Some(car_ir::Compensation::ActionRef { action_id }) => {
887 saw_compensation_ref = true;
888 if !proposal.actions.iter().any(|a| &a.id == action_id) {
889 compensation_findings += 1;
890 issues.push(VerifyIssue {
891 action_id: action.id.clone(),
892 severity: "error".to_string(),
893 message: format!(
894 "compensation references action '{action_id}', which is not in this proposal"
895 ),
896 tier: EvidenceTier::DecisionProcedure,
897 });
898 }
899 }
900 None => {}
901 }
902
903 if action.missing_required_compensation() {
907 compensation_findings += 1;
908 issues.push(VerifyIssue {
909 action_id: action.id.clone(),
910 severity: "error".to_string(),
911 message: "action declares reversibility 'compensable' but no compensation"
912 .to_string(),
913 tier: EvidenceTier::DecisionProcedure,
914 });
915 }
916
917 if effect_mode == EffectMode::Optimistic || !blocked {
924 apply_action_effects(action, &mut state);
925 }
926 }
927 }
928
929 let conflicts = detect_conflicts(&proposal.actions);
931 for (a1, a2, key) in &conflicts {
932 issues.push(VerifyIssue {
933 action_id: a1.clone(),
934 severity: "warning".to_string(),
935 message: format!(
936 "write conflict on '{}' with action {} (no dependency declared)",
937 key, a2
938 ),
939 tier: EvidenceTier::DecisionProcedure,
944 });
945 }
946
947 let conflict_findings = conflicts.len();
948
949 let has_errors = issues.iter().any(|i| i.severity == "error");
950 let warning_count = issues.iter().filter(|i| i.severity == "warning").count();
951
952 let checks = vec![
954 CheckRecord {
955 name: "resource_bounds".into(),
956 ran: true,
957 verifies: format!("action count is within the limit ({max_actions})"),
958 cannot_verify: "per-action cost, wall-clock time, or memory at runtime".into(),
959 findings: resource_bound_findings,
960 tier: EvidenceTier::DecisionProcedure,
961 },
962 CheckRecord {
963 name: "loop_detection".into(),
964 ran: true,
965 verifies: "no identical tool call is repeated enough to look like a loop".into(),
966 cannot_verify: "semantically redundant calls with differing arguments".into(),
967 findings: loop_detection_findings,
968 tier: EvidenceTier::Heuristic,
972 },
973 CheckRecord {
974 name: "preconditions".into(),
975 ran: true,
976 verifies: "declared preconditions hold against the statically-known state".into(),
977 cannot_verify: "preconditions over keys whose values are only known at runtime".into(),
978 findings: precondition_findings,
979 tier: EvidenceTier::DecisionProcedure,
980 },
981 CheckRecord {
982 name: "state_dependencies".into(),
983 ran: true,
984 verifies: "each declared state dependency is produced before it is read".into(),
985 cannot_verify: "undeclared reads — state a tool consumes without listing it".into(),
986 findings: state_dependency_findings,
987 tier: EvidenceTier::DecisionProcedure,
988 },
989 CheckRecord {
990 name: "tool_existence".into(),
994 ran: has_tool_registry || saw_missing_tool,
995 verifies: if has_tool_registry {
996 "every tool_call names a registered tool".into()
997 } else if saw_missing_tool {
998 "tool_call structural well-formedness (a tool is named); registry not supplied so existence unchecked".into()
999 } else {
1000 "(skipped — no tool registry supplied)".into()
1001 },
1002 cannot_verify: "whether the registered tool behaves as its name/description implies"
1003 .into(),
1004 findings: tool_existence_findings,
1005 tier: EvidenceTier::DecisionProcedure,
1010 },
1011 CheckRecord {
1012 name: "param_schema".into(),
1013 ran: param_schema_ran,
1014 verifies: if param_schema_ran {
1015 "tool_call parameters match the registered JSON Schema (types + required)".into()
1016 } else {
1017 "(skipped — no tool schemas supplied; existence only)".into()
1018 },
1019 cannot_verify:
1020 "value-level constraints beyond type/required (ranges, formats, cross-field)".into(),
1021 findings: param_schema_findings,
1022 tier: EvidenceTier::DecisionProcedure,
1023 },
1024 CheckRecord {
1025 name: "compensation_resolution".into(),
1026 ran: has_tool_registry || saw_compensation_ref || compensation_findings > 0,
1030 verifies: "a declared compensation names a registered tool or an action in this \
1031 proposal, and a `compensable` action declares one at all"
1032 .into(),
1033 cannot_verify: "whether the named compensation actually undoes the effect — that it \
1034 is the right inverse, and that it will still work later"
1035 .into(),
1036 findings: compensation_findings,
1037 tier: EvidenceTier::DecisionProcedure,
1039 },
1040 CheckRecord {
1041 name: "write_conflicts".into(),
1042 ran: true,
1043 verifies: "concurrent writers to the same key declare an ordering dependency".into(),
1044 cannot_verify:
1045 "semantic conflicts — two actions whose effects are logically incompatible".into(),
1046 findings: conflict_findings,
1047 tier: EvidenceTier::DecisionProcedure,
1048 },
1049 ];
1050
1051 let mut untested_regions: Vec<String> = Vec::new();
1059 for action in &proposal.actions {
1060 if action.action_type == ActionType::ToolCall {
1061 if let Some(ref tool) = action.tool {
1062 untested_regions.push(format!(
1063 "runtime output of tool '{tool}' (action {})",
1064 action.id
1065 ));
1066 }
1067 for key in action.expected_effects.keys() {
1068 untested_regions.push(format!(
1069 "state key '{key}' (value set at runtime by action {})",
1070 action.id
1071 ));
1072 }
1073 }
1074 }
1075 untested_regions.sort();
1076 untested_regions.dedup();
1077
1078 let mut assumptions = vec![
1079 "supplied initial-state values are accurate".to_string(),
1080 "tool implementations honor their declared effects and side effects".to_string(),
1081 ];
1082 if !param_schema_ran && has_tool_calls {
1083 assumptions.push(
1084 "tool_call parameters are well-formed (no schemas supplied to check them)".to_string(),
1085 );
1086 }
1087
1088 let mut residual_risks = Vec::new();
1089 if !conflicts.is_empty() {
1090 residual_risks.push(format!(
1091 "{} undeclared write conflict(s) — last-writer-wins at runtime",
1092 conflicts.len()
1093 ));
1094 }
1095 if warning_count > 0 {
1096 residual_risks.push(format!(
1097 "{warning_count} warning(s) not blocking the verdict"
1098 ));
1099 }
1100 if !untested_regions.is_empty() {
1101 residual_risks.push(
1102 "outcomes depending on runtime tool output or runtime-set state are unverified"
1103 .to_string(),
1104 );
1105 }
1106
1107 let mut confidence: f64 = 1.0;
1111 if has_tool_calls && !has_tool_registry {
1112 confidence -= 0.15;
1113 }
1114 if has_tool_calls && !param_schema_ran {
1115 confidence -= 0.20;
1116 }
1117 confidence -= (untested_regions.len() as f64 * 0.02).min(0.25);
1118 confidence -= (warning_count as f64 * 0.05).min(0.20);
1119 let confidence = confidence.clamp(0.0, 1.0);
1120
1121 let evidence = VerificationEvidence {
1122 checks,
1123 assumptions,
1124 untested_regions,
1125 residual_risks,
1126 confidence,
1127 };
1128
1129 VerifyResult {
1130 valid: !has_errors,
1131 issues,
1132 simulated_state: state.known,
1133 execution_levels,
1134 conflicts,
1135 evidence,
1136 }
1137}
1138
1139pub fn simulate(
1158 proposal: &ActionProposal,
1159 initial_state: Option<&HashMap<String, Value>>,
1160) -> HashMap<String, Value> {
1161 verify_inner_with_effects(
1162 proposal,
1163 initial_state,
1164 None,
1165 None,
1166 usize::MAX,
1167 EffectMode::ExecutionFaithful,
1168 )
1169 .simulated_state
1170}
1171
1172pub fn equivalent(
1181 p1: &ActionProposal,
1182 p2: &ActionProposal,
1183 test_states: Option<&[HashMap<String, Value>]>,
1184) -> bool {
1185 let defaults = vec![
1186 HashMap::new(),
1187 [
1188 ("x".to_string(), Value::from(1)),
1189 ("y".to_string(), Value::from(2)),
1190 ]
1191 .into(),
1192 ];
1193 let states = test_states.unwrap_or(&defaults);
1194
1195 for state in states {
1196 let s1 = simulate(p1, Some(state));
1197 let s2 = simulate(p2, Some(state));
1198 if s1 != s2 {
1199 return false;
1200 }
1201 }
1202 true
1203}
1204
1205pub fn optimize(proposal: &ActionProposal) -> ActionProposal {
1207 let mut written_keys = HashSet::new();
1209 for action in &proposal.actions {
1210 if action.action_type == ActionType::StateWrite {
1211 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
1212 written_keys.insert(k.to_string());
1213 }
1214 }
1215 for key in action.expected_effects.keys() {
1216 written_keys.insert(key.clone());
1217 }
1218 }
1219
1220 let optimized_actions: Vec<Action> = proposal
1221 .actions
1222 .iter()
1223 .map(|action| {
1224 let pruned: Vec<String> = action
1225 .state_dependencies
1226 .iter()
1227 .filter(|d| written_keys.contains(d.as_str()))
1228 .cloned()
1229 .collect();
1230
1231 if pruned.len() != action.state_dependencies.len() {
1232 let mut new_action = action.clone();
1233 new_action.state_dependencies = pruned;
1234 new_action
1235 } else {
1236 action.clone()
1237 }
1238 })
1239 .collect();
1240
1241 ActionProposal {
1242 id: proposal.id.clone(),
1243 source: proposal.source.clone(),
1244 actions: optimized_actions,
1245 timestamp: proposal.timestamp,
1246 context: proposal.context.clone(),
1247 }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253 use car_ir::Precondition;
1254
1255 fn tool_call(id: &str, tool: &str) -> Action {
1256 {
1257 let mut a = Action::new(ActionType::ToolCall);
1258 a.id = id.to_string();
1259 a.tool = Some(tool.to_string());
1260 a
1261 }
1262 }
1263
1264 fn state_write(id: &str, key: &str, value: Value) -> Action {
1265 {
1266 let mut a = Action::new(ActionType::StateWrite);
1267 a.id = id.to_string();
1268 a.parameters = [
1269 ("key".to_string(), Value::from(key)),
1270 ("value".to_string(), value),
1271 ]
1272 .into();
1273 a
1274 }
1275 }
1276
1277 fn prop(actions: Vec<Action>) -> ActionProposal {
1278 ActionProposal {
1279 id: "test".to_string(),
1280 source: "test".to_string(),
1281 actions,
1282 timestamp: chrono::Utc::now(),
1283 context: HashMap::new(),
1284 }
1285 }
1286
1287 #[test]
1288 fn verify_valid_proposal() {
1289 let p = prop(vec![state_write("a1", "x", Value::from(1)), {
1290 let mut a = tool_call("a2", "search");
1291 a.state_dependencies = vec!["x".to_string()];
1292 a
1293 }]);
1294 let r = verify(&p, None, Some(&["search".to_string()].into()), 30);
1295 assert!(r.valid);
1296 }
1297
1298 fn echo_schema_parameters() -> Value {
1301 serde_json::json!({
1302 "type": "object",
1303 "properties": { "msg": { "type": "string" } },
1304 "required": ["msg"],
1305 })
1306 }
1307
1308 fn schema_map(parameters: Value) -> HashMap<String, ToolSchema> {
1309 [(
1310 "echo".to_string(),
1311 ToolSchema {
1312 name: "echo".to_string(),
1313 source: car_ir::ToolSourceKind::UserDefined,
1314 description: String::new(),
1315 parameters,
1316 returns: None,
1317 idempotent: true,
1318 cache_ttl_secs: None,
1319 rate_limit: None,
1320 },
1321 )]
1322 .into()
1323 }
1324
1325 fn echo_call(params: HashMap<String, Value>) -> ActionProposal {
1326 let mut a = tool_call("a1", "echo");
1327 a.parameters = params;
1328 prop(vec![a])
1329 }
1330
1331 #[test]
1332 fn schema_verify_accepts_well_typed_params() {
1333 let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
1334 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1335 assert!(r.valid, "{:?}", r.issues);
1336 }
1337
1338 #[test]
1339 fn schema_verify_rejects_type_mismatch() {
1340 let p = echo_call([("msg".to_string(), Value::from(42))].into());
1341 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1342 assert!(!r.valid);
1343 assert!(r
1344 .issues
1345 .iter()
1346 .any(|i| i.message.contains("wrong type") && i.message.contains("msg")));
1347 }
1348
1349 #[test]
1350 fn schema_verify_rejects_missing_required() {
1351 let p = echo_call(HashMap::new());
1352 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1353 assert!(!r.valid);
1354 assert!(
1355 r.issues
1356 .iter()
1357 .any(|i| i.message.contains("missing required parameter")
1358 && i.message.contains("msg"))
1359 );
1360 }
1361
1362 #[test]
1363 fn schema_verify_rejects_unknown_tool() {
1364 let mut a = tool_call("a1", "nope");
1365 a.parameters = [("msg".to_string(), Value::from("hi"))].into();
1366 let r = verify_with_schemas(
1367 &prop(vec![a]),
1368 None,
1369 Some(&schema_map(echo_schema_parameters())),
1370 30,
1371 );
1372 assert!(!r.valid);
1373 assert!(r
1374 .issues
1375 .iter()
1376 .any(|i| i.message.contains("not registered")));
1377 }
1378
1379 #[test]
1380 fn name_only_verify_still_skips_param_validation() {
1381 let p = echo_call([("msg".to_string(), Value::from(42))].into());
1385 let r = verify(&p, None, Some(&["echo".to_string()].into()), 30);
1386 assert!(
1387 r.valid,
1388 "name-only verify must not validate params: {:?}",
1389 r.issues
1390 );
1391 }
1392
1393 #[test]
1394 fn schema_verify_accepts_integer_and_union_types() {
1395 let parameters = serde_json::json!({
1396 "type": "object",
1397 "properties": {
1398 "n": { "type": "integer" },
1399 "maybe": { "type": ["string", "null"] },
1400 },
1401 "required": ["n"],
1402 });
1403 let p = echo_call(
1404 [
1405 ("n".to_string(), Value::from(7)),
1406 ("maybe".to_string(), Value::Null),
1407 ]
1408 .into(),
1409 );
1410 let r = verify_with_schemas(&p, None, Some(&schema_map(parameters)), 30);
1411 assert!(r.valid, "{:?}", r.issues);
1412 }
1413
1414 #[test]
1415 fn schema_verify_empty_schema_imposes_no_constraints() {
1416 let p = echo_call([("anything".to_string(), Value::from(42))].into());
1420 let r = verify_with_schemas(&p, None, Some(&schema_map(serde_json::json!({}))), 30);
1421 assert!(r.valid, "{:?}", r.issues);
1422 }
1423
1424 #[test]
1425 fn verify_catches_unsatisfied_precondition() {
1426 let mut a = tool_call("a1", "deploy");
1427 a.preconditions = vec![Precondition {
1428 key: "tests_passed".to_string(),
1429 operator: "eq".to_string(),
1430 value: Value::Bool(true),
1431 description: String::new(),
1432 }];
1433 let r = verify(&prop(vec![a]), None, None, 30);
1434 assert!(!r.valid);
1435 }
1436
1437 #[test]
1438 fn verify_precondition_satisfied_by_earlier_action() {
1439 let mut a2 = tool_call("a2", "deploy");
1440 a2.preconditions = vec![Precondition {
1441 key: "ready".to_string(),
1442 operator: "eq".to_string(),
1443 value: Value::Bool(true),
1444 description: String::new(),
1445 }];
1446 a2.state_dependencies = vec!["ready".to_string()];
1447
1448 let p = prop(vec![state_write("a1", "ready", Value::Bool(true)), a2]);
1449 let r = verify(&p, None, None, 30);
1450 assert!(r.valid);
1451 }
1452
1453 #[test]
1454 fn verify_missing_state_dependency() {
1455 let mut a = tool_call("a1", "x");
1456 a.state_dependencies = vec!["nonexistent".to_string()];
1457 let r = verify(&prop(vec![a]), None, None, 30);
1458 assert!(!r.valid);
1459 }
1460
1461 #[test]
1462 fn verify_tool_not_registered() {
1463 let a = tool_call("a1", "quantum");
1464 let r = verify(&prop(vec![a]), None, Some(&HashSet::new()), 30);
1465 assert!(!r.valid);
1466 }
1467
1468 #[test]
1469 fn compensation_naming_an_unregistered_tool_is_a_finding() {
1470 let mut a = tool_call("a1", "poll");
1475 a.reversibility = car_ir::Reversibility::Compensable;
1476 a.compensation = Some(car_ir::Compensation::Tool {
1477 tool: "db.delet".into(), parameters: Default::default(),
1479 });
1480 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
1481 assert!(!r.valid);
1482 assert!(r
1483 .issues
1484 .iter()
1485 .any(|i| i.message.contains("compensation names tool 'db.delet'")));
1486
1487 let mut a = tool_call("a1", "poll");
1489 a.reversibility = car_ir::Reversibility::Compensable;
1490 a.compensation = Some(car_ir::Compensation::Tool {
1491 tool: "undo".into(),
1492 parameters: Default::default(),
1493 });
1494 let reg = ["poll".to_string(), "undo".to_string()].into();
1495 assert!(verify(&prop(vec![a]), None, Some(®), 30).valid);
1496 }
1497
1498 #[test]
1499 fn compensation_action_ref_must_resolve_within_the_proposal() {
1500 let mut a = tool_call("a1", "deploy");
1502 a.reversibility = car_ir::Reversibility::Compensable;
1503 a.compensation = Some(car_ir::Compensation::ActionRef {
1504 action_id: "rollback-1".into(),
1505 });
1506 let r = verify(&prop(vec![a.clone()]), None, None, 30);
1507 assert!(!r.valid);
1508 assert!(r
1509 .issues
1510 .iter()
1511 .any(|i| i.message.contains("references action 'rollback-1'")));
1512
1513 let mut undo = tool_call("rollback-1", "rollback");
1515 undo.id = "rollback-1".into();
1516 let r = verify(&prop(vec![a, undo]), None, None, 30);
1517 assert!(
1518 !r.issues
1519 .iter()
1520 .any(|i| i.message.contains("references action")),
1521 "{:?}",
1522 r.issues
1523 );
1524 }
1525
1526 #[test]
1527 fn compensable_with_no_compensation_declared_is_a_finding() {
1528 let mut a = tool_call("a1", "poll");
1529 a.reversibility = car_ir::Reversibility::Compensable;
1530 a.compensation = None;
1531 let r = verify(&prop(vec![a]), None, None, 30);
1532 assert!(!r.valid);
1533 assert!(r.issues.iter().any(|i| i
1534 .message
1535 .contains("declares reversibility 'compensable' but no compensation")));
1536 assert!(r
1538 .issues_with_tier(EvidenceTier::DecisionProcedure)
1539 .iter()
1540 .any(|i| i.message.contains("no compensation")));
1541 let rec = r
1543 .evidence
1544 .checks
1545 .iter()
1546 .find(|c| c.name == "compensation_resolution")
1547 .expect("compensation_resolution check is recorded");
1548 assert!(rec.ran);
1549 assert_eq!(rec.findings, 1);
1550 }
1551
1552 #[test]
1553 fn verify_no_tool_specified() {
1554 let mut a = tool_call("a1", "x");
1555 a.tool = None;
1556 let r = verify(&prop(vec![a]), None, None, 30);
1557 assert!(!r.valid);
1558 }
1559
1560 #[test]
1561 fn detect_write_conflict() {
1562 let p = prop(vec![
1563 state_write("a1", "x", Value::from(1)),
1564 state_write("a2", "x", Value::from(2)),
1565 ]);
1566 let r = verify(&p, None, None, 30);
1567 assert!(!r.conflicts.is_empty());
1568 }
1569
1570 #[test]
1571 fn simulate_state_writes() {
1572 let p = prop(vec![
1573 state_write("a1", "x", Value::from(10)),
1574 state_write("a2", "y", Value::from(20)),
1575 ]);
1576 let s = simulate(&p, None);
1577 assert_eq!(s.get("x"), Some(&Value::from(10)));
1578 assert_eq!(s.get("y"), Some(&Value::from(20)));
1579 }
1580
1581 #[test]
1587 fn simulate_skips_effects_of_a_provably_blocked_action() {
1588 let mut deploy = tool_call("deploy", "deploy");
1589 deploy.preconditions = vec![Precondition {
1590 key: "tests_passed".to_string(),
1591 operator: "eq".to_string(),
1592 value: Value::Bool(true),
1593 description: String::new(),
1594 }];
1595 deploy
1596 .expected_effects
1597 .insert("deployed".to_string(), Value::Bool(true));
1598 let p = prop(vec![deploy]);
1599
1600 let failing: HashMap<String, Value> =
1601 [("tests_passed".to_string(), Value::Bool(false))].into();
1602 let s = simulate(&p, Some(&failing));
1603 assert_eq!(
1604 s.get("deployed"),
1605 None,
1606 "a deploy whose precondition provably fails must not appear deployed: {s:?}"
1607 );
1608
1609 let passing: HashMap<String, Value> =
1611 [("tests_passed".to_string(), Value::Bool(true))].into();
1612 let s = simulate(&p, Some(&passing));
1613 assert_eq!(s.get("deployed"), Some(&Value::Bool(true)));
1614 }
1615
1616 #[test]
1621 fn verify_stays_optimistic_so_it_reports_every_finding() {
1622 let mut deploy = tool_call("deploy", "deploy");
1623 deploy.preconditions = vec![Precondition {
1624 key: "tests_passed".to_string(),
1625 operator: "eq".to_string(),
1626 value: Value::Bool(true),
1627 description: String::new(),
1628 }];
1629 deploy
1630 .expected_effects
1631 .insert("deployed".to_string(), Value::Bool(true));
1632 let mut notify = tool_call("notify", "notify");
1633 notify.state_dependencies = vec!["deployed".to_string()];
1634 let p = prop(vec![deploy, notify]);
1635
1636 let failing: HashMap<String, Value> =
1637 [("tests_passed".to_string(), Value::Bool(false))].into();
1638 let r = verify(&p, Some(&failing), None, 30);
1639
1640 assert!(!r.valid);
1641 assert_eq!(
1645 r.errors().len(),
1646 1,
1647 "expected only the precondition finding, got {:?}",
1648 r.issues
1649 );
1650 assert!(r.issues[0].message.contains("precondition will fail"));
1651 }
1652
1653 #[test]
1656 fn simulate_cascade_follows_data_dependencies() {
1657 let mut build = tool_call("build", "build");
1658 build.preconditions = vec![Precondition {
1659 key: "ready".to_string(),
1660 operator: "eq".to_string(),
1661 value: Value::Bool(true),
1662 description: String::new(),
1663 }];
1664 build
1665 .expected_effects
1666 .insert("artifact".to_string(), Value::from("app.tar.gz"));
1667 let mut deploy = tool_call("deploy", "deploy");
1668 deploy.state_dependencies = vec!["artifact".to_string()];
1669 deploy
1670 .expected_effects
1671 .insert("deployed".to_string(), Value::Bool(true));
1672
1673 let s = simulate(&prop(vec![build, deploy]), None);
1674 assert_eq!(
1675 s.get("artifact"),
1676 None,
1677 "blocked build produced no artifact"
1678 );
1679 assert_eq!(
1680 s.get("deployed"),
1681 None,
1682 "deploy depends on the artifact that never appeared: {s:?}"
1683 );
1684 }
1685
1686 #[test]
1690 fn equivalent_distinguishes_a_gated_proposal_from_an_ungated_one() {
1691 let mut gated = tool_call("a", "deploy");
1692 gated.preconditions = vec![Precondition {
1693 key: "tests_passed".to_string(),
1694 operator: "eq".to_string(),
1695 value: Value::Bool(true),
1696 description: String::new(),
1697 }];
1698 gated
1699 .expected_effects
1700 .insert("deployed".to_string(), Value::Bool(true));
1701
1702 let mut ungated = tool_call("b", "deploy");
1703 ungated
1704 .expected_effects
1705 .insert("deployed".to_string(), Value::Bool(true));
1706
1707 let failing: Vec<HashMap<String, Value>> =
1708 vec![[("tests_passed".to_string(), Value::Bool(false))].into()];
1709 assert!(
1710 !equivalent(&prop(vec![gated]), &prop(vec![ungated]), Some(&failing)),
1711 "a gate that blocks one proposal and not the other is a real difference"
1712 );
1713 }
1714
1715 #[test]
1716 fn equivalent_proposals() {
1717 let p1 = prop(vec![
1718 state_write("a1", "x", Value::from(1)),
1719 state_write("a2", "y", Value::from(2)),
1720 ]);
1721 let p2 = prop(vec![
1722 state_write("b1", "y", Value::from(2)),
1723 state_write("b2", "x", Value::from(1)),
1724 ]);
1725 assert!(equivalent(&p1, &p2, None));
1726 }
1727
1728 #[test]
1729 fn non_equivalent_proposals() {
1730 let p1 = prop(vec![state_write("a1", "x", Value::from(1))]);
1731 let p2 = prop(vec![state_write("b1", "x", Value::from(99))]);
1732 assert!(!equivalent(&p1, &p2, None));
1733 }
1734
1735 #[test]
1736 fn optimize_removes_phantom_deps() {
1737 let mut a = tool_call("a1", "search");
1738 a.state_dependencies = vec!["phantom".to_string()];
1739 let p = prop(vec![a]);
1740 let optimized = optimize(&p);
1741 assert!(optimized.actions[0].state_dependencies.is_empty());
1742 }
1743
1744 #[test]
1745 fn optimize_preserves_real_deps() {
1746 let mut a2 = tool_call("a2", "x");
1747 a2.state_dependencies = vec!["x".to_string()];
1748 let p = prop(vec![state_write("a1", "x", Value::from(1)), a2]);
1749 let optimized = optimize(&p);
1750 assert_eq!(optimized.actions[1].state_dependencies, vec!["x"]);
1751 }
1752
1753 #[test]
1754 fn loop_detection_duplicates() {
1755 let p = prop(vec![tool_call("a1", "search"), tool_call("a2", "search")]);
1756 let r = verify(&p, None, None, 30);
1757 assert!(r.issues.iter().any(|i| i.message.contains("duplicate")));
1758 }
1759
1760 #[test]
1761 fn loop_detection_triple() {
1762 let p = prop(vec![
1763 tool_call("a1", "search"),
1764 tool_call("a2", "search"),
1765 tool_call("a3", "search"),
1766 ]);
1767 let r = verify(&p, None, None, 30);
1768 assert!(!r.valid);
1769 assert!(r.issues.iter().any(|i| i.message.contains("likely loop")));
1770 }
1771
1772 #[test]
1773 fn resource_bounds() {
1774 let actions: Vec<Action> = (0..35)
1775 .map(|i| tool_call(&format!("a{}", i), &format!("t{}", i)))
1776 .collect();
1777 let r = verify(&prop(actions), None, None, 30);
1778 assert!(r.issues.iter().any(|i| i.message.contains("excessive")));
1779 }
1780
1781 #[test]
1792 fn resource_bound_is_exclusive_at_the_limit() {
1793 let at_limit: Vec<Action> = (0..30)
1797 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1798 .collect();
1799 let r = verify(&prop(at_limit), None, None, 30);
1800 assert!(
1801 !r.issues.iter().any(|i| i.message.contains("excessive")),
1802 "exactly max_actions is within the bound"
1803 );
1804
1805 let over: Vec<Action> = (0..31)
1806 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1807 .collect();
1808 let r = verify(&prop(over), None, None, 30);
1809 assert!(
1810 r.issues.iter().any(|i| i.message.contains("excessive")),
1811 "one past max_actions is over it"
1812 );
1813 }
1814
1815 #[test]
1816 fn resource_bound_finding_count_is_recorded() {
1817 let over: Vec<Action> = (0..31)
1821 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1822 .collect();
1823 let r = verify(&prop(over), None, None, 30);
1824 let rec = r
1825 .evidence
1826 .checks
1827 .iter()
1828 .find(|c| c.name == "resource_bounds")
1829 .expect("resource_bounds is always recorded");
1830 assert_eq!(rec.findings, 1, "exactly one bound was exceeded");
1831
1832 let under = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
1833 let rec = under
1834 .evidence
1835 .checks
1836 .iter()
1837 .find(|c| c.name == "resource_bounds")
1838 .unwrap();
1839 assert_eq!(
1840 rec.findings, 0,
1841 "a proposal inside the bound has no finding"
1842 );
1843 }
1844
1845 #[test]
1846 fn precondition_findings_are_counted_not_just_reported() {
1847 let mut a = tool_call("a1", "t1");
1850 a.preconditions = vec![Precondition {
1851 key: "missing_key".to_string(),
1852 operator: "exists".to_string(),
1853 value: Value::Null,
1854 description: String::new(),
1855 }];
1856 let r = verify(&prop(vec![a]), Some(&HashMap::new()), None, 30);
1857 let rec = r
1858 .evidence
1859 .checks
1860 .iter()
1861 .find(|c| c.name == "preconditions")
1862 .expect("preconditions is recorded whenever an action declares one");
1863 assert_eq!(
1864 rec.findings,
1865 r.issues
1866 .iter()
1867 .filter(|i| i.message.contains("precondition"))
1868 .count(),
1869 "the recorded count must match the issues actually raised"
1870 );
1871 assert!(rec.findings > 0, "an unmet precondition is a finding");
1872 }
1873
1874 #[test]
1875 fn warning_count_drives_the_residual_risk_line() {
1876 let clean = verify(
1880 &prop(vec![state_write("a1", "x", Value::from(1))]),
1881 None,
1882 None,
1883 30,
1884 );
1885 assert!(
1886 !clean
1887 .evidence
1888 .residual_risks
1889 .iter()
1890 .any(|s| s.contains("warning(s)")),
1891 "no warnings means no warning risk line at all"
1892 );
1893
1894 let warned = verify(
1896 &prop(vec![
1897 state_write("a1", "k", Value::from(1)),
1898 state_write("a2", "k", Value::from(2)),
1899 ]),
1900 None,
1901 None,
1902 30,
1903 );
1904 let warnings = warned
1905 .issues
1906 .iter()
1907 .filter(|i| i.severity == "warning")
1908 .count();
1909 assert!(warnings > 0, "the fixture must actually produce a warning");
1910 assert!(
1911 warned
1912 .evidence
1913 .residual_risks
1914 .iter()
1915 .any(|s| s.contains(&format!("{warnings} warning(s)"))),
1916 "the risk line must carry the real warning count"
1917 );
1918 }
1919
1920 #[test]
1921 fn confidence_docks_exactly_once_per_skipped_check() {
1922 let untested_dock =
1931 |r: &VerifyResult| (r.evidence.untested_regions.len() as f64 * 0.02).min(0.25);
1932
1933 let r = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
1935 let expected = 1.0 - 0.15 - 0.20 - untested_dock(&r);
1936 assert!(
1937 (r.evidence.confidence - expected).abs() < 1e-9,
1938 "1.0 - 0.15 (no registry) - 0.20 (no schemas) - untested, want {expected}, got {}",
1939 r.evidence.confidence
1940 );
1941
1942 let r = verify(
1945 &prop(vec![tool_call("a1", "t1")]),
1946 None,
1947 Some(&["t1".to_string()].into()),
1948 30,
1949 );
1950 let expected = 1.0 - 0.20 - untested_dock(&r);
1951 assert!(
1952 (r.evidence.confidence - expected).abs() < 1e-9,
1953 "1.0 - 0.20 (no schemas) - untested, want {expected}, got {}",
1954 r.evidence.confidence
1955 );
1956
1957 let r = verify(
1960 &prop(vec![state_write("a1", "x", Value::from(1))]),
1961 None,
1962 None,
1963 30,
1964 );
1965 assert!(
1966 (r.evidence.confidence - 1.0).abs() < 1e-9,
1967 "pure state writes dock nothing, got {}",
1968 r.evidence.confidence
1969 );
1970 }
1971
1972 #[test]
1973 fn confidence_docks_scale_with_warnings_and_stay_clamped() {
1974 let warned = verify(
1977 &prop(vec![
1978 state_write("a1", "k", Value::from(1)),
1979 state_write("a2", "k", Value::from(2)),
1980 ]),
1981 None,
1982 None,
1983 30,
1984 );
1985 let warnings = warned
1986 .issues
1987 .iter()
1988 .filter(|i| i.severity == "warning")
1989 .count();
1990 let expected = 1.0 - (warnings as f64 * 0.05).min(0.20);
1991 assert!(
1992 (warned.evidence.confidence - expected).abs() < 1e-9,
1993 "{warnings} warning(s) dock 0.05 each, capped at 0.20; got {}",
1994 warned.evidence.confidence
1995 );
1996 assert!(
1997 (0.0..=1.0).contains(&warned.evidence.confidence),
1998 "confidence must stay inside its documented range"
1999 );
2000 }
2001
2002 #[test]
2003 fn the_schema_assumption_needs_both_conditions() {
2004 let r = verify(
2008 &prop(vec![state_write("a1", "x", Value::from(1))]),
2009 None,
2010 None,
2011 30,
2012 );
2013 assert!(
2014 !r.evidence
2015 .assumptions
2016 .iter()
2017 .any(|s| s.contains("tool_call parameters")),
2018 "a proposal with no tool calls assumes nothing about tool_call params"
2019 );
2020
2021 let r = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
2022 assert!(
2023 r.evidence
2024 .assumptions
2025 .iter()
2026 .any(|s| s.contains("tool_call parameters")),
2027 "unchecked tool_call params must be declared as an assumption"
2028 );
2029 }
2030
2031 #[test]
2032 fn loop_detection_finding_count_is_a_delta_not_a_total() {
2033 let mut actions: Vec<Action> = (0..31)
2038 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
2039 .collect();
2040 actions.push(tool_call("dup", "t0")); let r = verify(&prop(actions), None, None, 30);
2042 let bounds = r
2043 .evidence
2044 .checks
2045 .iter()
2046 .find(|c| c.name == "resource_bounds")
2047 .unwrap();
2048 let loops = r
2049 .evidence
2050 .checks
2051 .iter()
2052 .find(|c| c.name == "loop_detection")
2053 .expect("loop_detection is recorded");
2054 assert_eq!(bounds.findings, 1, "one bound exceeded");
2055 assert!(loops.findings > 0, "the duplicate must be found");
2056 assert!(
2057 loops.findings < r.issues.len(),
2058 "loop_detection reports its own findings ({}), not every issue raised ({})",
2059 loops.findings,
2060 r.issues.len()
2061 );
2062 }
2063
2064 #[test]
2065 fn param_schema_finding_count_is_recorded() {
2066 let p = echo_call([("msg".to_string(), Value::from(42))].into());
2069 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
2070 assert!(!r.valid);
2071 let rec = r
2072 .evidence
2073 .checks
2074 .iter()
2075 .find(|c| c.name == "param_schema")
2076 .expect("param_schema is recorded when schemas are supplied");
2077 assert_eq!(
2078 rec.findings,
2079 r.issues
2080 .iter()
2081 .filter(|i| i.message.contains("wrong type"))
2082 .count(),
2083 "the recorded count must match the type errors raised"
2084 );
2085 assert!(rec.findings > 0);
2086 }
2087
2088 #[test]
2089 fn compensation_finding_count_is_recorded() {
2090 let mut a = tool_call("a1", "poll");
2092 a.reversibility = car_ir::Reversibility::Compensable;
2093 a.compensation = Some(car_ir::Compensation::Tool {
2094 tool: "db.delet".into(), parameters: Default::default(),
2096 });
2097 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
2098 let rec = r
2099 .evidence
2100 .checks
2101 .iter()
2102 .find(|c| c.name == "compensation_resolution")
2103 .expect("compensation is recorded when an action declares one");
2104 assert!(
2105 rec.findings > 0,
2106 "an unrunnable rollback plan is a compensation finding"
2107 );
2108
2109 let mut ok = tool_call("a1", "poll");
2112 ok.reversibility = car_ir::Reversibility::Compensable;
2113 ok.compensation = Some(car_ir::Compensation::Tool {
2114 tool: "undo".into(),
2115 parameters: Default::default(),
2116 });
2117 let r = verify(
2118 &prop(vec![ok]),
2119 None,
2120 Some(&["poll".to_string(), "undo".to_string()].into()),
2121 30,
2122 );
2123 let rec = r
2124 .evidence
2125 .checks
2126 .iter()
2127 .find(|c| c.name == "compensation_resolution")
2128 .unwrap();
2129 assert_eq!(rec.findings, 0, "a registered undo is not a finding");
2130 }
2131
2132 #[test]
2133 fn action_ref_compensation_finding_count_is_recorded() {
2134 let mut a = tool_call("a1", "poll");
2138 a.reversibility = car_ir::Reversibility::Compensable;
2139 a.compensation = Some(car_ir::Compensation::ActionRef {
2140 action_id: "nonexistent".into(),
2141 });
2142 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
2143 let rec = r
2144 .evidence
2145 .checks
2146 .iter()
2147 .find(|c| c.name == "compensation_resolution")
2148 .expect("compensation_resolution is recorded");
2149 assert_eq!(
2150 rec.findings,
2151 r.issues
2152 .iter()
2153 .filter(|i| i.message.contains("not in this proposal"))
2154 .count(),
2155 "the count must match the dangling references raised"
2156 );
2157 assert!(rec.findings > 0, "a dangling action ref is a finding");
2158 }
2159
2160 #[test]
2161 fn untested_regions_drive_their_own_residual_risk() {
2162 let clean = verify(
2165 &prop(vec![state_write("a1", "x", Value::from(1))]),
2166 None,
2167 None,
2168 30,
2169 );
2170 assert!(clean.evidence.untested_regions.is_empty());
2171 assert!(
2172 !clean
2173 .evidence
2174 .residual_risks
2175 .iter()
2176 .any(|s| s.contains("runtime tool output")),
2177 "nothing untested means no runtime-output risk line"
2178 );
2179
2180 let dynamic = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
2181 assert!(
2182 !dynamic.evidence.untested_regions.is_empty(),
2183 "a tool call leaves runtime-decided regions"
2184 );
2185 assert!(
2186 dynamic
2187 .evidence
2188 .residual_risks
2189 .iter()
2190 .any(|s| s.contains("runtime tool output")),
2191 "untested regions must surface as a residual risk"
2192 );
2193 }
2194
2195 #[test]
2204 fn evidence_declares_all_check_scopes() {
2205 let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
2206 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
2207 for want in [
2209 "resource_bounds",
2210 "loop_detection",
2211 "preconditions",
2212 "state_dependencies",
2213 "tool_existence",
2214 "param_schema",
2215 "write_conflicts",
2216 ] {
2217 let rec = r
2218 .evidence
2219 .checks
2220 .iter()
2221 .find(|c| c.name == want)
2222 .unwrap_or_else(|| panic!("missing check record {want}"));
2223 assert!(!rec.verifies.is_empty());
2224 assert!(!rec.cannot_verify.is_empty());
2225 }
2226 let by = |n: &str| r.evidence.checks.iter().find(|c| c.name == n).unwrap();
2228 assert!(by("param_schema").ran);
2229 assert!(by("tool_existence").ran);
2230 }
2231
2232 #[test]
2233 fn evidence_marks_param_schema_skipped_without_schemas() {
2234 let p = prop(vec![tool_call("a1", "search")]);
2237 let r = verify(&p, None, None, 30);
2238 let param = r
2239 .evidence
2240 .checks
2241 .iter()
2242 .find(|c| c.name == "param_schema")
2243 .unwrap();
2244 assert!(!param.ran);
2245 assert!(
2246 r.evidence.confidence < 1.0,
2247 "skipped check should dock coverage"
2248 );
2249 assert!(r
2250 .evidence
2251 .assumptions
2252 .iter()
2253 .any(|a| a.contains("well-formed")));
2254 }
2255
2256 #[test]
2257 fn evidence_full_confidence_for_pure_state_writes() {
2258 let p = prop(vec![state_write("a1", "x", Value::from(1))]);
2260 let r = verify(&p, None, None, 30);
2261 assert!(r.valid);
2262 assert_eq!(r.evidence.confidence, 1.0);
2263 assert!(r.evidence.untested_regions.is_empty());
2264 }
2265
2266 #[test]
2267 fn evidence_conflicts_become_residual_risk() {
2268 let p = prop(vec![
2271 state_write("a1", "k", Value::from(1)),
2272 state_write("a2", "k", Value::from(2)),
2273 ]);
2274 let r = verify(&p, None, None, 30);
2275 assert!(r.valid, "conflicts are warnings, not errors");
2276 assert!(!r.conflicts.is_empty());
2277 assert!(r
2278 .evidence
2279 .residual_risks
2280 .iter()
2281 .any(|s| s.contains("write conflict")));
2282 let wc = r
2283 .evidence
2284 .checks
2285 .iter()
2286 .find(|c| c.name == "write_conflicts")
2287 .unwrap();
2288 assert_eq!(wc.findings, r.conflicts.len());
2289 }
2290
2291 #[test]
2292 fn evidence_untested_includes_runtime_set_effect_keys() {
2293 let mut a = tool_call("a1", "fetch");
2297 a.expected_effects = [("out".to_string(), Value::from("placeholder"))].into();
2298 let r = verify(
2299 &prop(vec![a]),
2300 None,
2301 Some(&["fetch".to_string()].into()),
2302 30,
2303 );
2304 assert!(r
2305 .evidence
2306 .untested_regions
2307 .iter()
2308 .any(|s| s.contains("state key 'out'")));
2309 assert!(r
2310 .evidence
2311 .untested_regions
2312 .iter()
2313 .any(|s| s.contains("runtime output of tool 'fetch'")));
2314 }
2315
2316 #[test]
2317 fn evidence_tool_existence_ran_consistent_with_findings() {
2318 let mut a = tool_call("a1", "x");
2322 a.tool = None;
2323 let r = verify(&prop(vec![a]), None, None, 30);
2324 assert!(!r.valid);
2325 let te = r
2326 .evidence
2327 .checks
2328 .iter()
2329 .find(|c| c.name == "tool_existence")
2330 .unwrap();
2331 assert!(te.findings >= 1);
2332 assert!(
2333 te.ran,
2334 "ran must be true whenever the check produced a finding"
2335 );
2336 }
2337
2338 #[test]
2345 fn loop_detection_findings_are_heuristic_and_the_rest_are_not() {
2346 let p = prop(vec![
2347 tool_call("a1", "poll"),
2348 tool_call("a2", "poll"),
2349 tool_call("a3", "poll"),
2350 tool_call("a4", "ghost"),
2351 ]);
2352 let r = verify(&p, None, Some(&["poll".to_string()].into()), 30);
2353
2354 let heuristic = r.issues_with_tier(EvidenceTier::Heuristic);
2355 assert_eq!(
2356 heuristic.len(),
2357 1,
2358 "only the repeated-call finding is heuristic: {:?}",
2359 r.issues
2360 );
2361 assert!(heuristic[0]
2362 .message
2363 .contains("repeated identical tool call"));
2364
2365 let decided = r.issues_with_tier(EvidenceTier::DecisionProcedure);
2367 assert!(decided
2368 .iter()
2369 .any(|i| i.message.contains("'ghost' is not registered")));
2370
2371 assert!(r.issues_with_tier(EvidenceTier::Sampled).is_empty());
2373 }
2374
2375 #[test]
2387 fn check_records_and_issues_agree_on_tier() {
2388 let p = prop(vec![
2389 tool_call("a1", "poll"),
2392 tool_call("a2", "poll"),
2393 {
2396 let mut a = tool_call("a3", "ghost");
2397 a.state_dependencies = vec!["missing".to_string()];
2398 a
2399 },
2400 state_write("a4", "x", Value::from(1)),
2403 state_write("a5", "x", Value::from(2)),
2404 ]);
2405 let r = verify(&p, None, Some(&["poll".to_string()].into()), 30);
2406
2407 for tier in [
2412 EvidenceTier::DecisionProcedure,
2413 EvidenceTier::Heuristic,
2414 EvidenceTier::Sampled,
2415 ] {
2416 let declared: usize = r
2417 .evidence
2418 .checks
2419 .iter()
2420 .filter(|c| c.tier == tier)
2421 .map(|c| c.findings)
2422 .sum();
2423 let actual = r.issues_with_tier(tier).len();
2424 assert_eq!(
2425 declared,
2426 actual,
2427 "checks at tier {} declare {declared} findings but {actual} issues carry it: {:?}",
2428 tier.as_str(),
2429 r.issues
2430 );
2431 }
2432
2433 let total: usize = r.evidence.checks.iter().map(|c| c.findings).sum();
2436 assert_eq!(total, r.issues.len(), "unaccounted issues: {:?}", r.issues);
2437
2438 assert_eq!(
2441 r.issues_with_tier(EvidenceTier::Heuristic).len(),
2442 1,
2443 "expected exactly the duplicate-call finding: {:?}",
2444 r.issues
2445 );
2446 assert!(
2447 r.issues_with_tier(EvidenceTier::DecisionProcedure).len() >= 3,
2448 "expected the unregistered tool, the missing dependency, and the \
2449 write conflict: {:?}",
2450 r.issues
2451 );
2452 }
2453
2454 #[test]
2457 fn tier_serializes_as_stable_snake_case() {
2458 let p = prop(vec![tool_call("a1", "ghost")]);
2459 let r = verify(&p, None, Some(&HashSet::new()), 30);
2460 let json = serde_json::to_value(&r.issues[0]).expect("issue serializes");
2461 assert_eq!(json["tier"], Value::from("decision_procedure"));
2462 assert_eq!(
2463 json["tier"],
2464 Value::from(r.issues[0].tier.as_str()),
2465 "as_str and the serde representation must not drift"
2466 );
2467 }
2468}