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 eval_boundary;
65pub mod goal;
66pub mod infoflow;
67pub mod intent;
68pub mod montecarlo;
69pub mod plan_check;
70pub mod trace_policy;
71pub mod transaction;
72pub mod verifier;
73pub use admission::{
74 admit_state, AdmissionRefusal, CommitAuthority, OwnershipTable, SelfCommit, StateAdmission,
75 StateCandidate, StateSurface, SurfaceRule,
76};
77pub use attempt::{Attempt, AttemptAdvice, AttemptLedger, AttemptOutcome, Exclusion, FailureClass};
78pub use goal::{
79 anchor_directive, evaluate_goal, governor_check, run_goal_loop, GoalCondition, GoalGovernor,
80 GoalHalt, GoalInputs, GoalRun, GoalRunState, GoalSpec, GoalStatus, GoalVerdict,
81 IterationOutcome,
82};
83pub use intent::{
84 check_intent, gate_intent, intent_actions_from, IntentAction, IntentDisposition,
85 IntentGateDecision, IntentGatePolicy, IntentReport, IntentSpec, IntentViolation,
86 IntentViolationKind,
87};
88pub use montecarlo::{
89 simulate_monte_carlo, ActionOutcome, Distribution, KeyOutcome, MonteCarloConfig,
90 MonteCarloResult, ValueFrequency,
91};
92pub use plan_check::{
93 check_plan, PlanCheckReport, PlanCheckRequest, PlanDefect, PlanDefectKind, PlanStep,
94};
95pub use verifier::{
96 admit, required_classes, AdmissionDecision, AdmissionOutcome, EvidenceRequirement, UnmetReason,
97 UnmetRequirement, VerifierAuthority, VerifierCost, VerifierDescriptor, VerifierOutcome,
98 VerifierVerdict,
99};
100pub mod workflow_graph;
101pub use concurrency::{
102 analyze as analyze_concurrency, gate_concurrency, AgentOp, AnomalyFinding, ConcurrencyAnomaly,
103 ConcurrencyGate, ConcurrencyGatePolicy, ConcurrencyReport, ConsistencyLevel, Disposition,
104 GatedRemediation, Remediation,
105};
106pub use cwm::{
107 score, score_predictions, simulate_with_model, synthesize_cwm, CwmRequest, CwmResult,
108 EffectModel, Failure, GatedEffectModel, GatedPrediction, ScoreReport, Transition,
109};
110pub use eval_boundary::{
111 check_eval_optimize_boundary, BoundaryReport, BoundaryRole, BoundaryRoles, CAP_EVALUATOR,
112 CAP_OPTIMIZER, CAP_REDACTOR,
113};
114pub use infoflow::{
115 check_information_flow, gate_flow, Confidentiality, FlowAction, FlowGateDecision,
116 FlowGatePolicy, FlowPolicy, FlowReport, FlowViolation, FlowViolationKind, ToolLabels,
117 TrustLevel,
118};
119pub use transaction::{
120 check_transaction, check_transaction_ordered_by, check_transaction_with_predictions,
121 ConflictKind, TransactionConflict, TransactionReport,
122};
123pub use workflow_graph::{
124 check_temporal_policies, verify_workflow_graph, PolicyReport, PolicyViolation, TemporalPolicy,
125 WorkflowDefect, WorkflowDefectKind, WorkflowEdge, WorkflowGraph, WorkflowVerifyReport,
126};
127
128#[derive(Debug, Clone)]
130pub struct StaticState {
131 pub known: HashMap<String, Value>,
132 pub unknown_keys: HashSet<String>,
133}
134
135impl StaticState {
136 pub fn new() -> Self {
137 Self {
138 known: HashMap::new(),
139 unknown_keys: HashSet::new(),
140 }
141 }
142
143 pub fn from_map(map: HashMap<String, Value>) -> Self {
144 Self {
145 known: map,
146 unknown_keys: HashSet::new(),
147 }
148 }
149
150 pub fn get(&self, key: &str) -> Option<&Value> {
151 self.known.get(key)
152 }
153
154 pub fn exists(&self, key: &str) -> bool {
155 self.known.contains_key(key)
156 }
157
158 pub fn is_unknown(&self, key: &str) -> bool {
159 self.unknown_keys.contains(key)
160 }
161
162 pub fn set(&mut self, key: &str, value: Value) {
163 self.known.insert(key.to_string(), value);
164 self.unknown_keys.remove(key);
165 }
166}
167
168impl Default for StaticState {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174impl StateView for StaticState {
175 fn get_value(&self, key: &str) -> Option<Value> {
176 self.known.get(key).cloned()
177 }
178 fn key_exists(&self, key: &str) -> bool {
179 self.known.contains_key(key)
180 }
181 fn is_unknown(&self, key: &str) -> bool {
182 self.unknown_keys.contains(key)
183 }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum EvidenceTier {
233 DecisionProcedure,
243 Heuristic,
253 Sampled,
261}
262
263impl EvidenceTier {
264 pub const fn as_str(&self) -> &'static str {
271 match self {
272 EvidenceTier::DecisionProcedure => "decision_procedure",
273 EvidenceTier::Heuristic => "heuristic",
274 EvidenceTier::Sampled => "sampled",
275 }
276 }
277}
278
279#[derive(Debug, Clone, serde::Serialize)]
281#[non_exhaustive]
282pub struct VerifyIssue {
283 pub action_id: String,
284 pub severity: String, pub message: String,
286 pub tier: EvidenceTier,
300}
301
302impl VerifyIssue {
303 pub fn new(
307 action_id: impl Into<String>,
308 severity: impl Into<String>,
309 message: impl Into<String>,
310 tier: EvidenceTier,
311 ) -> Self {
312 Self {
313 action_id: action_id.into(),
314 severity: severity.into(),
315 message: message.into(),
316 tier,
317 }
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
330#[non_exhaustive]
331pub struct CheckRecord {
332 pub name: String,
334 pub ran: bool,
339 pub verifies: String,
341 pub cannot_verify: String,
344 pub findings: usize,
346 pub tier: EvidenceTier,
351}
352
353#[derive(Debug, Clone, serde::Serialize)]
362pub struct VerificationEvidence {
363 pub checks: Vec<CheckRecord>,
365 pub assumptions: Vec<String>,
368 pub untested_regions: Vec<String>,
371 pub residual_risks: Vec<String>,
375 pub confidence: f64,
381}
382
383#[derive(Debug, serde::Serialize)]
385pub struct VerifyResult {
386 pub valid: bool,
387 pub issues: Vec<VerifyIssue>,
388 pub simulated_state: HashMap<String, Value>,
389 pub execution_levels: Vec<Vec<String>>,
390 pub conflicts: Vec<(String, String, String)>, pub evidence: VerificationEvidence,
394}
395
396impl VerifyResult {
397 pub fn errors(&self) -> Vec<&VerifyIssue> {
398 self.issues
399 .iter()
400 .filter(|i| i.severity == "error")
401 .collect()
402 }
403
404 pub fn warnings(&self) -> Vec<&VerifyIssue> {
405 self.issues
406 .iter()
407 .filter(|i| i.severity == "warning")
408 .collect()
409 }
410
411 pub fn issues_with_tier(&self, tier: EvidenceTier) -> Vec<&VerifyIssue> {
419 self.issues.iter().filter(|i| i.tier == tier).collect()
420 }
421}
422
423pub(crate) fn apply_action_effects(action: &Action, state: &mut StaticState) {
426 if action.action_type == ActionType::StateWrite {
427 if let Some(key) = action.parameters.get("key").and_then(|v| v.as_str()) {
428 let value = action
429 .parameters
430 .get("value")
431 .cloned()
432 .unwrap_or(Value::Null);
433 state.set(key, value);
434 }
435 }
436 for (key, value) in &action.expected_effects {
437 state.set(key, value.clone());
438 }
439}
440
441fn detect_conflicts(actions: &[Action]) -> Vec<(String, String, String)> {
444 let mut writers: HashMap<String, Vec<String>> = HashMap::new();
445
446 for action in actions {
447 let mut keys_written = HashSet::new();
448 if action.action_type == ActionType::StateWrite {
449 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
450 keys_written.insert(k.to_string());
451 }
452 }
453 for key in action.expected_effects.keys() {
454 keys_written.insert(key.clone());
455 }
456 for key in keys_written {
457 writers.entry(key).or_default().push(action.id.clone());
458 }
459 }
460
461 let dep_map: HashMap<String, HashSet<String>> = actions
462 .iter()
463 .map(|a| (a.id.clone(), a.state_dependencies.iter().cloned().collect()))
464 .collect();
465
466 let mut conflicts = Vec::new();
467 for (key, action_ids) in &writers {
468 if action_ids.len() < 2 {
469 continue;
470 }
471 for i in 0..action_ids.len() {
472 for j in (i + 1)..action_ids.len() {
473 let a1 = &action_ids[i];
474 let a2 = &action_ids[j];
475 let deps_a2 = dep_map.get(a2).cloned().unwrap_or_default();
476 let deps_a1 = dep_map.get(a1).cloned().unwrap_or_default();
477 if !deps_a2.contains(key) && !deps_a1.contains(key) {
478 conflicts.push((a1.clone(), a2.clone(), key.clone()));
479 }
480 }
481 }
482 }
483 conflicts
484}
485
486fn json_type_name(v: &Value) -> &'static str {
490 match v {
491 Value::Null => "null",
492 Value::Bool(_) => "boolean",
493 Value::Number(_) => "number",
494 Value::String(_) => "string",
495 Value::Array(_) => "array",
496 Value::Object(_) => "object",
497 }
498}
499
500fn value_matches_type(v: &Value, expected: &str) -> bool {
502 match expected {
503 "string" => v.is_string(),
504 "number" => v.is_number(),
505 "integer" => {
508 v.is_i64() || v.is_u64() || v.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false)
509 }
510 "boolean" => v.is_boolean(),
511 "array" => v.is_array(),
512 "object" => v.is_object(),
513 "null" => v.is_null(),
514 _ => true,
517 }
518}
519
520fn validate_tool_params(params: &HashMap<String, Value>, schema: &Value) -> Vec<String> {
527 let mut out = Vec::new();
528 let Some(schema_obj) = schema.as_object() else {
529 return out;
531 };
532
533 if let Some(Value::Array(required)) = schema_obj.get("required") {
535 for req in required {
536 if let Some(name) = req.as_str() {
537 if !params.contains_key(name) {
538 out.push(format!("missing required parameter '{name}'"));
539 }
540 }
541 }
542 }
543
544 if let Some(Value::Object(properties)) = schema_obj.get("properties") {
548 for (key, val) in params {
549 let Some(prop_schema) = properties.get(key).and_then(|s| s.as_object()) else {
550 continue;
551 };
552 let ok = match prop_schema.get("type") {
553 Some(Value::String(t)) => value_matches_type(val, t),
554 Some(Value::Array(types)) => types
555 .iter()
556 .filter_map(|t| t.as_str())
557 .any(|t| value_matches_type(val, t)),
558 _ => true,
560 };
561 if !ok {
562 let expected = match prop_schema.get("type") {
563 Some(Value::String(t)) => t.clone(),
564 Some(Value::Array(types)) => types
565 .iter()
566 .filter_map(|t| t.as_str())
567 .collect::<Vec<_>>()
568 .join("|"),
569 _ => String::new(),
570 };
571 out.push(format!(
572 "parameter '{key}' has wrong type: expected {expected}, got {}",
573 json_type_name(val)
574 ));
575 }
576 }
577 }
578
579 out
580}
581
582pub fn verify(
592 proposal: &ActionProposal,
593 initial_state: Option<&HashMap<String, Value>>,
594 registered_tools: Option<&HashSet<String>>,
595 max_actions: usize,
596) -> VerifyResult {
597 verify_inner(proposal, initial_state, registered_tools, None, max_actions)
598}
599
600pub fn verify_with_schemas(
608 proposal: &ActionProposal,
609 initial_state: Option<&HashMap<String, Value>>,
610 tool_schemas: Option<&HashMap<String, ToolSchema>>,
611 max_actions: usize,
612) -> VerifyResult {
613 verify_inner(proposal, initial_state, None, tool_schemas, max_actions)
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622enum EffectMode {
623 Optimistic,
632 ExecutionFaithful,
641}
642
643fn verify_inner(
644 proposal: &ActionProposal,
645 initial_state: Option<&HashMap<String, Value>>,
646 registered_tools: Option<&HashSet<String>>,
647 tool_schemas: Option<&HashMap<String, ToolSchema>>,
648 max_actions: usize,
649) -> VerifyResult {
650 verify_inner_with_effects(
651 proposal,
652 initial_state,
653 registered_tools,
654 tool_schemas,
655 max_actions,
656 EffectMode::Optimistic,
657 )
658}
659
660fn verify_inner_with_effects(
661 proposal: &ActionProposal,
662 initial_state: Option<&HashMap<String, Value>>,
663 registered_tools: Option<&HashSet<String>>,
664 tool_schemas: Option<&HashMap<String, ToolSchema>>,
665 max_actions: usize,
666 effect_mode: EffectMode,
667) -> VerifyResult {
668 let mut state = match initial_state {
669 Some(s) => StaticState::from_map(s.clone()),
670 None => StaticState::new(),
671 };
672 let mut issues = Vec::new();
673
674 let mut precondition_findings = 0usize;
678 let mut state_dependency_findings = 0usize;
679 let mut tool_existence_findings = 0usize;
680 let mut param_schema_findings = 0usize;
681 let mut has_tool_calls = false;
682 let mut saw_missing_tool = false;
687 let mut compensation_findings = 0usize;
691 let mut saw_compensation_ref = false;
695 let has_tool_registry = tool_schemas.is_some() || registered_tools.is_some();
699 let param_schema_ran = tool_schemas.is_some();
700
701 let issues_before_bounds = issues.len();
703 if proposal.actions.len() > max_actions {
704 issues.push(VerifyIssue {
705 action_id: proposal
706 .actions
707 .first()
708 .map(|a| a.id.clone())
709 .unwrap_or_default(),
710 severity: "warning".to_string(),
711 message: format!(
712 "excessive actions: {} (limit {})",
713 proposal.actions.len(),
714 max_actions
715 ),
716 tier: EvidenceTier::DecisionProcedure,
722 });
723 }
724
725 let resource_bound_findings = issues.len() - issues_before_bounds;
726
727 let issues_before_loop = issues.len();
729 let mut seen_calls: HashMap<String, u32> = HashMap::new();
730 for action in &proposal.actions {
731 if action.action_type == ActionType::ToolCall {
732 if let Some(ref tool) = action.tool {
733 let params = serde_json::to_string(&action.parameters).unwrap_or_default();
734 let key = format!("{}:{}", tool, params);
735 *seen_calls.entry(key).or_insert(0) += 1;
736 }
737 }
738 }
739 for (call_key, count) in &seen_calls {
740 let tool_name = call_key.split(':').next().unwrap_or("?");
741 if *count >= 3 {
742 issues.push(VerifyIssue {
743 action_id: "proposal".to_string(),
744 severity: "error".to_string(),
745 message: format!(
746 "repeated identical tool call: {} ({}x) — likely loop",
747 tool_name, count
748 ),
749 tier: EvidenceTier::Heuristic,
753 });
754 } else if *count == 2 {
755 issues.push(VerifyIssue {
756 action_id: "proposal".to_string(),
757 severity: "warning".to_string(),
758 message: format!("duplicate tool call: {} ({}x)", tool_name, count),
759 tier: EvidenceTier::Heuristic,
762 });
763 }
764 }
765
766 let loop_detection_findings = issues.len() - issues_before_loop;
767
768 let levels = build_dag(&proposal.actions);
770 let execution_levels: Vec<Vec<String>> = levels
771 .iter()
772 .map(|level| {
773 level
774 .iter()
775 .map(|&i| proposal.actions[i].id.clone())
776 .collect()
777 })
778 .collect();
779
780 for level in &levels {
782 for &idx in level {
783 let action = &proposal.actions[idx];
784
785 let mut blocked = false;
790
791 for pre in &action.preconditions {
793 if let Some(error) = precondition::check_precondition(pre, &state) {
794 precondition_findings += 1;
795 blocked = true;
796 issues.push(VerifyIssue {
797 action_id: action.id.clone(),
798 severity: "error".to_string(),
799 message: format!("precondition will fail: {}", error),
800 tier: EvidenceTier::DecisionProcedure,
808 });
809 }
810 }
811
812 for dep in &action.state_dependencies {
814 if !state.exists(dep) && !state.is_unknown(dep) {
815 state_dependency_findings += 1;
816 blocked = true;
817 issues.push(VerifyIssue {
818 action_id: action.id.clone(),
819 severity: "error".to_string(),
820 message: format!("state dependency '{}' not available at this point", dep),
821 tier: EvidenceTier::DecisionProcedure,
825 });
826 }
827 }
828
829 if action.action_type == ActionType::ToolCall {
831 has_tool_calls = true;
832 if let Some(ref tool) = action.tool {
833 let registered = match (tool_schemas, registered_tools) {
837 (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
838 (None, Some(names)) => Some(names.contains(tool.as_str())),
839 (None, None) => None,
840 };
841 if registered == Some(false) {
842 tool_existence_findings += 1;
843 issues.push(VerifyIssue {
844 action_id: action.id.clone(),
845 severity: "error".to_string(),
846 message: format!("tool '{}' is not registered", tool),
847 tier: EvidenceTier::DecisionProcedure,
849 });
850 }
851 if let Some(schema) = tool_schemas.and_then(|s| s.get(tool.as_str())) {
856 for msg in validate_tool_params(&action.parameters, &schema.parameters) {
857 param_schema_findings += 1;
858 issues.push(VerifyIssue {
859 action_id: action.id.clone(),
860 severity: "error".to_string(),
861 message: format!("tool '{tool}': {msg}"),
862 tier: EvidenceTier::DecisionProcedure,
870 });
871 }
872 }
873 } else {
874 saw_missing_tool = true;
875 tool_existence_findings += 1;
876 issues.push(VerifyIssue {
877 action_id: action.id.clone(),
878 severity: "error".to_string(),
879 message: "tool_call action has no tool specified".to_string(),
880 tier: EvidenceTier::DecisionProcedure,
882 });
883 }
884 }
885
886 match &action.compensation {
892 Some(car_ir::Compensation::Tool { tool, .. }) => {
893 let registered = match (tool_schemas, registered_tools) {
894 (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
895 (None, Some(names)) => Some(names.contains(tool.as_str())),
896 (None, None) => None,
897 };
898 if registered == Some(false) {
899 compensation_findings += 1;
900 issues.push(VerifyIssue {
901 action_id: action.id.clone(),
902 severity: "error".to_string(),
903 message: format!(
904 "compensation names tool '{tool}', which is not registered"
905 ),
906 tier: EvidenceTier::DecisionProcedure,
907 });
908 }
909 }
910 Some(car_ir::Compensation::ActionRef { action_id }) => {
911 saw_compensation_ref = true;
912 if !proposal.actions.iter().any(|a| &a.id == action_id) {
913 compensation_findings += 1;
914 issues.push(VerifyIssue {
915 action_id: action.id.clone(),
916 severity: "error".to_string(),
917 message: format!(
918 "compensation references action '{action_id}', which is not in this proposal"
919 ),
920 tier: EvidenceTier::DecisionProcedure,
921 });
922 }
923 }
924 None => {}
925 }
926
927 if action.missing_required_compensation() {
931 compensation_findings += 1;
932 issues.push(VerifyIssue {
933 action_id: action.id.clone(),
934 severity: "error".to_string(),
935 message: "action declares reversibility 'compensable' but no compensation"
936 .to_string(),
937 tier: EvidenceTier::DecisionProcedure,
938 });
939 }
940
941 if effect_mode == EffectMode::Optimistic || !blocked {
948 apply_action_effects(action, &mut state);
949 }
950 }
951 }
952
953 let conflicts = detect_conflicts(&proposal.actions);
955 for (a1, a2, key) in &conflicts {
956 issues.push(VerifyIssue {
957 action_id: a1.clone(),
958 severity: "warning".to_string(),
959 message: format!(
960 "write conflict on '{}' with action {} (no dependency declared)",
961 key, a2
962 ),
963 tier: EvidenceTier::DecisionProcedure,
968 });
969 }
970
971 let conflict_findings = conflicts.len();
972
973 let has_errors = issues.iter().any(|i| i.severity == "error");
974 let warning_count = issues.iter().filter(|i| i.severity == "warning").count();
975
976 let checks = vec![
978 CheckRecord {
979 name: "resource_bounds".into(),
980 ran: true,
981 verifies: format!("action count is within the limit ({max_actions})"),
982 cannot_verify: "per-action cost, wall-clock time, or memory at runtime".into(),
983 findings: resource_bound_findings,
984 tier: EvidenceTier::DecisionProcedure,
985 },
986 CheckRecord {
987 name: "loop_detection".into(),
988 ran: true,
989 verifies: "no identical tool call is repeated enough to look like a loop".into(),
990 cannot_verify: "semantically redundant calls with differing arguments".into(),
991 findings: loop_detection_findings,
992 tier: EvidenceTier::Heuristic,
996 },
997 CheckRecord {
998 name: "preconditions".into(),
999 ran: true,
1000 verifies: "declared preconditions hold against the statically-known state".into(),
1001 cannot_verify: "preconditions over keys whose values are only known at runtime".into(),
1002 findings: precondition_findings,
1003 tier: EvidenceTier::DecisionProcedure,
1004 },
1005 CheckRecord {
1006 name: "state_dependencies".into(),
1007 ran: true,
1008 verifies: "each declared state dependency is produced before it is read".into(),
1009 cannot_verify: "undeclared reads — state a tool consumes without listing it".into(),
1010 findings: state_dependency_findings,
1011 tier: EvidenceTier::DecisionProcedure,
1012 },
1013 CheckRecord {
1014 name: "tool_existence".into(),
1018 ran: has_tool_registry || saw_missing_tool,
1019 verifies: if has_tool_registry {
1020 "every tool_call names a registered tool".into()
1021 } else if saw_missing_tool {
1022 "tool_call structural well-formedness (a tool is named); registry not supplied so existence unchecked".into()
1023 } else {
1024 "(skipped — no tool registry supplied)".into()
1025 },
1026 cannot_verify: "whether the registered tool behaves as its name/description implies"
1027 .into(),
1028 findings: tool_existence_findings,
1029 tier: EvidenceTier::DecisionProcedure,
1034 },
1035 CheckRecord {
1036 name: "param_schema".into(),
1037 ran: param_schema_ran,
1038 verifies: if param_schema_ran {
1039 "tool_call parameters match the registered JSON Schema (types + required)".into()
1040 } else {
1041 "(skipped — no tool schemas supplied; existence only)".into()
1042 },
1043 cannot_verify:
1044 "value-level constraints beyond type/required (ranges, formats, cross-field)".into(),
1045 findings: param_schema_findings,
1046 tier: EvidenceTier::DecisionProcedure,
1047 },
1048 CheckRecord {
1049 name: "compensation_resolution".into(),
1050 ran: has_tool_registry || saw_compensation_ref || compensation_findings > 0,
1054 verifies: "a declared compensation names a registered tool or an action in this \
1055 proposal, and a `compensable` action declares one at all"
1056 .into(),
1057 cannot_verify: "whether the named compensation actually undoes the effect — that it \
1058 is the right inverse, and that it will still work later"
1059 .into(),
1060 findings: compensation_findings,
1061 tier: EvidenceTier::DecisionProcedure,
1063 },
1064 CheckRecord {
1065 name: "write_conflicts".into(),
1066 ran: true,
1067 verifies: "concurrent writers to the same key declare an ordering dependency".into(),
1068 cannot_verify:
1069 "semantic conflicts — two actions whose effects are logically incompatible".into(),
1070 findings: conflict_findings,
1071 tier: EvidenceTier::DecisionProcedure,
1072 },
1073 ];
1074
1075 let mut untested_regions: Vec<String> = Vec::new();
1083 for action in &proposal.actions {
1084 if action.action_type == ActionType::ToolCall {
1085 if let Some(ref tool) = action.tool {
1086 untested_regions.push(format!(
1087 "runtime output of tool '{tool}' (action {})",
1088 action.id
1089 ));
1090 }
1091 for key in action.expected_effects.keys() {
1092 untested_regions.push(format!(
1093 "state key '{key}' (value set at runtime by action {})",
1094 action.id
1095 ));
1096 }
1097 }
1098 }
1099 untested_regions.sort();
1100 untested_regions.dedup();
1101
1102 let mut assumptions = vec![
1103 "supplied initial-state values are accurate".to_string(),
1104 "tool implementations honor their declared effects and side effects".to_string(),
1105 ];
1106 if !param_schema_ran && has_tool_calls {
1107 assumptions.push(
1108 "tool_call parameters are well-formed (no schemas supplied to check them)".to_string(),
1109 );
1110 }
1111
1112 let mut residual_risks = Vec::new();
1113 if !conflicts.is_empty() {
1114 residual_risks.push(format!(
1115 "{} undeclared write conflict(s) — last-writer-wins at runtime",
1116 conflicts.len()
1117 ));
1118 }
1119 if warning_count > 0 {
1120 residual_risks.push(format!(
1121 "{warning_count} warning(s) not blocking the verdict"
1122 ));
1123 }
1124 if !untested_regions.is_empty() {
1125 residual_risks.push(
1126 "outcomes depending on runtime tool output or runtime-set state are unverified"
1127 .to_string(),
1128 );
1129 }
1130
1131 let mut confidence: f64 = 1.0;
1135 if has_tool_calls && !has_tool_registry {
1136 confidence -= 0.15;
1137 }
1138 if has_tool_calls && !param_schema_ran {
1139 confidence -= 0.20;
1140 }
1141 confidence -= (untested_regions.len() as f64 * 0.02).min(0.25);
1142 confidence -= (warning_count as f64 * 0.05).min(0.20);
1143 let confidence = confidence.clamp(0.0, 1.0);
1144
1145 let evidence = VerificationEvidence {
1146 checks,
1147 assumptions,
1148 untested_regions,
1149 residual_risks,
1150 confidence,
1151 };
1152
1153 VerifyResult {
1154 valid: !has_errors,
1155 issues,
1156 simulated_state: state.known,
1157 execution_levels,
1158 conflicts,
1159 evidence,
1160 }
1161}
1162
1163pub fn simulate(
1182 proposal: &ActionProposal,
1183 initial_state: Option<&HashMap<String, Value>>,
1184) -> HashMap<String, Value> {
1185 verify_inner_with_effects(
1186 proposal,
1187 initial_state,
1188 None,
1189 None,
1190 usize::MAX,
1191 EffectMode::ExecutionFaithful,
1192 )
1193 .simulated_state
1194}
1195
1196pub fn equivalent(
1205 p1: &ActionProposal,
1206 p2: &ActionProposal,
1207 test_states: Option<&[HashMap<String, Value>]>,
1208) -> bool {
1209 let defaults = vec![
1210 HashMap::new(),
1211 [
1212 ("x".to_string(), Value::from(1)),
1213 ("y".to_string(), Value::from(2)),
1214 ]
1215 .into(),
1216 ];
1217 let states = test_states.unwrap_or(&defaults);
1218
1219 for state in states {
1220 let s1 = simulate(p1, Some(state));
1221 let s2 = simulate(p2, Some(state));
1222 if s1 != s2 {
1223 return false;
1224 }
1225 }
1226 true
1227}
1228
1229pub fn optimize(proposal: &ActionProposal) -> ActionProposal {
1231 let mut written_keys = HashSet::new();
1233 for action in &proposal.actions {
1234 if action.action_type == ActionType::StateWrite {
1235 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
1236 written_keys.insert(k.to_string());
1237 }
1238 }
1239 for key in action.expected_effects.keys() {
1240 written_keys.insert(key.clone());
1241 }
1242 }
1243
1244 let optimized_actions: Vec<Action> = proposal
1245 .actions
1246 .iter()
1247 .map(|action| {
1248 let pruned: Vec<String> = action
1249 .state_dependencies
1250 .iter()
1251 .filter(|d| written_keys.contains(d.as_str()))
1252 .cloned()
1253 .collect();
1254
1255 if pruned.len() != action.state_dependencies.len() {
1256 let mut new_action = action.clone();
1257 new_action.state_dependencies = pruned;
1258 new_action
1259 } else {
1260 action.clone()
1261 }
1262 })
1263 .collect();
1264
1265 ActionProposal {
1266 id: proposal.id.clone(),
1267 source: proposal.source.clone(),
1268 actions: optimized_actions,
1269 timestamp: proposal.timestamp,
1270 context: proposal.context.clone(),
1271 }
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276 use super::*;
1277 use car_ir::Precondition;
1278
1279 fn tool_call(id: &str, tool: &str) -> Action {
1280 {
1281 let mut a = Action::new(ActionType::ToolCall);
1282 a.id = id.to_string();
1283 a.tool = Some(tool.to_string());
1284 a
1285 }
1286 }
1287
1288 fn state_write(id: &str, key: &str, value: Value) -> Action {
1289 {
1290 let mut a = Action::new(ActionType::StateWrite);
1291 a.id = id.to_string();
1292 a.parameters = [
1293 ("key".to_string(), Value::from(key)),
1294 ("value".to_string(), value),
1295 ]
1296 .into();
1297 a
1298 }
1299 }
1300
1301 fn prop(actions: Vec<Action>) -> ActionProposal {
1302 ActionProposal {
1303 id: "test".to_string(),
1304 source: "test".to_string(),
1305 actions,
1306 timestamp: chrono::Utc::now(),
1307 context: HashMap::new(),
1308 }
1309 }
1310
1311 #[test]
1312 fn verify_valid_proposal() {
1313 let p = prop(vec![state_write("a1", "x", Value::from(1)), {
1314 let mut a = tool_call("a2", "search");
1315 a.state_dependencies = vec!["x".to_string()];
1316 a
1317 }]);
1318 let r = verify(&p, None, Some(&["search".to_string()].into()), 30);
1319 assert!(r.valid);
1320 }
1321
1322 fn echo_schema_parameters() -> Value {
1325 serde_json::json!({
1326 "type": "object",
1327 "properties": { "msg": { "type": "string" } },
1328 "required": ["msg"],
1329 })
1330 }
1331
1332 fn schema_map(parameters: Value) -> HashMap<String, ToolSchema> {
1333 [(
1334 "echo".to_string(),
1335 ToolSchema {
1336 name: "echo".to_string(),
1337 source: car_ir::ToolSourceKind::UserDefined,
1338 description: String::new(),
1339 parameters,
1340 returns: None,
1341 idempotent: true,
1342 cache_ttl_secs: None,
1343 rate_limit: None,
1344 },
1345 )]
1346 .into()
1347 }
1348
1349 fn echo_call(params: HashMap<String, Value>) -> ActionProposal {
1350 let mut a = tool_call("a1", "echo");
1351 a.parameters = params;
1352 prop(vec![a])
1353 }
1354
1355 #[test]
1356 fn schema_verify_accepts_well_typed_params() {
1357 let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
1358 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1359 assert!(r.valid, "{:?}", r.issues);
1360 }
1361
1362 #[test]
1363 fn schema_verify_rejects_type_mismatch() {
1364 let p = echo_call([("msg".to_string(), Value::from(42))].into());
1365 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1366 assert!(!r.valid);
1367 assert!(r
1368 .issues
1369 .iter()
1370 .any(|i| i.message.contains("wrong type") && i.message.contains("msg")));
1371 }
1372
1373 #[test]
1374 fn schema_verify_rejects_missing_required() {
1375 let p = echo_call(HashMap::new());
1376 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1377 assert!(!r.valid);
1378 assert!(
1379 r.issues
1380 .iter()
1381 .any(|i| i.message.contains("missing required parameter")
1382 && i.message.contains("msg"))
1383 );
1384 }
1385
1386 #[test]
1387 fn schema_verify_rejects_unknown_tool() {
1388 let mut a = tool_call("a1", "nope");
1389 a.parameters = [("msg".to_string(), Value::from("hi"))].into();
1390 let r = verify_with_schemas(
1391 &prop(vec![a]),
1392 None,
1393 Some(&schema_map(echo_schema_parameters())),
1394 30,
1395 );
1396 assert!(!r.valid);
1397 assert!(r
1398 .issues
1399 .iter()
1400 .any(|i| i.message.contains("not registered")));
1401 }
1402
1403 #[test]
1404 fn name_only_verify_still_skips_param_validation() {
1405 let p = echo_call([("msg".to_string(), Value::from(42))].into());
1409 let r = verify(&p, None, Some(&["echo".to_string()].into()), 30);
1410 assert!(
1411 r.valid,
1412 "name-only verify must not validate params: {:?}",
1413 r.issues
1414 );
1415 }
1416
1417 #[test]
1418 fn schema_verify_accepts_integer_and_union_types() {
1419 let parameters = serde_json::json!({
1420 "type": "object",
1421 "properties": {
1422 "n": { "type": "integer" },
1423 "maybe": { "type": ["string", "null"] },
1424 },
1425 "required": ["n"],
1426 });
1427 let p = echo_call(
1428 [
1429 ("n".to_string(), Value::from(7)),
1430 ("maybe".to_string(), Value::Null),
1431 ]
1432 .into(),
1433 );
1434 let r = verify_with_schemas(&p, None, Some(&schema_map(parameters)), 30);
1435 assert!(r.valid, "{:?}", r.issues);
1436 }
1437
1438 #[test]
1439 fn schema_verify_empty_schema_imposes_no_constraints() {
1440 let p = echo_call([("anything".to_string(), Value::from(42))].into());
1444 let r = verify_with_schemas(&p, None, Some(&schema_map(serde_json::json!({}))), 30);
1445 assert!(r.valid, "{:?}", r.issues);
1446 }
1447
1448 #[test]
1449 fn verify_catches_unsatisfied_precondition() {
1450 let mut a = tool_call("a1", "deploy");
1451 a.preconditions = vec![Precondition {
1452 key: "tests_passed".to_string(),
1453 operator: "eq".to_string(),
1454 value: Value::Bool(true),
1455 description: String::new(),
1456 }];
1457 let r = verify(&prop(vec![a]), None, None, 30);
1458 assert!(!r.valid);
1459 }
1460
1461 #[test]
1462 fn verify_precondition_satisfied_by_earlier_action() {
1463 let mut a2 = tool_call("a2", "deploy");
1464 a2.preconditions = vec![Precondition {
1465 key: "ready".to_string(),
1466 operator: "eq".to_string(),
1467 value: Value::Bool(true),
1468 description: String::new(),
1469 }];
1470 a2.state_dependencies = vec!["ready".to_string()];
1471
1472 let p = prop(vec![state_write("a1", "ready", Value::Bool(true)), a2]);
1473 let r = verify(&p, None, None, 30);
1474 assert!(r.valid);
1475 }
1476
1477 #[test]
1478 fn verify_missing_state_dependency() {
1479 let mut a = tool_call("a1", "x");
1480 a.state_dependencies = vec!["nonexistent".to_string()];
1481 let r = verify(&prop(vec![a]), None, None, 30);
1482 assert!(!r.valid);
1483 }
1484
1485 #[test]
1489 fn verify_mutual_state_dependency_is_invalid() {
1490 let mut a1 = tool_call("a1", "step");
1491 a1.state_dependencies = vec!["k2".to_string()];
1492 a1.expected_effects = [("k1".to_string(), Value::from(1))].into();
1493 let mut a2 = tool_call("a2", "step");
1494 a2.state_dependencies = vec!["k1".to_string()];
1495 a2.expected_effects = [("k2".to_string(), Value::from(2))].into();
1496
1497 let r = verify(&prop(vec![a1, a2]), None, None, 30);
1498
1499 assert!(!r.valid);
1500 assert!(
1501 r.issues.iter().any(|i| i.severity == "error"
1502 && i.message == "state dependency 'k2' not available at this point"),
1503 "issues: {:?}",
1504 r.issues
1505 );
1506 }
1507
1508 #[test]
1509 fn verify_tool_not_registered() {
1510 let a = tool_call("a1", "quantum");
1511 let r = verify(&prop(vec![a]), None, Some(&HashSet::new()), 30);
1512 assert!(!r.valid);
1513 }
1514
1515 #[test]
1516 fn compensation_naming_an_unregistered_tool_is_a_finding() {
1517 let mut a = tool_call("a1", "poll");
1522 a.reversibility = car_ir::Reversibility::Compensable;
1523 a.compensation = Some(car_ir::Compensation::Tool {
1524 tool: "db.delet".into(), parameters: Default::default(),
1526 });
1527 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
1528 assert!(!r.valid);
1529 assert!(r
1530 .issues
1531 .iter()
1532 .any(|i| i.message.contains("compensation names tool 'db.delet'")));
1533
1534 let mut a = tool_call("a1", "poll");
1536 a.reversibility = car_ir::Reversibility::Compensable;
1537 a.compensation = Some(car_ir::Compensation::Tool {
1538 tool: "undo".into(),
1539 parameters: Default::default(),
1540 });
1541 let reg = ["poll".to_string(), "undo".to_string()].into();
1542 assert!(verify(&prop(vec![a]), None, Some(®), 30).valid);
1543 }
1544
1545 #[test]
1546 fn compensation_action_ref_must_resolve_within_the_proposal() {
1547 let mut a = tool_call("a1", "deploy");
1549 a.reversibility = car_ir::Reversibility::Compensable;
1550 a.compensation = Some(car_ir::Compensation::ActionRef {
1551 action_id: "rollback-1".into(),
1552 });
1553 let r = verify(&prop(vec![a.clone()]), None, None, 30);
1554 assert!(!r.valid);
1555 assert!(r
1556 .issues
1557 .iter()
1558 .any(|i| i.message.contains("references action 'rollback-1'")));
1559
1560 let mut undo = tool_call("rollback-1", "rollback");
1562 undo.id = "rollback-1".into();
1563 let r = verify(&prop(vec![a, undo]), None, None, 30);
1564 assert!(
1565 !r.issues
1566 .iter()
1567 .any(|i| i.message.contains("references action")),
1568 "{:?}",
1569 r.issues
1570 );
1571 }
1572
1573 #[test]
1574 fn compensable_with_no_compensation_declared_is_a_finding() {
1575 let mut a = tool_call("a1", "poll");
1576 a.reversibility = car_ir::Reversibility::Compensable;
1577 a.compensation = None;
1578 let r = verify(&prop(vec![a]), None, None, 30);
1579 assert!(!r.valid);
1580 assert!(r.issues.iter().any(|i| i
1581 .message
1582 .contains("declares reversibility 'compensable' but no compensation")));
1583 assert!(r
1585 .issues_with_tier(EvidenceTier::DecisionProcedure)
1586 .iter()
1587 .any(|i| i.message.contains("no compensation")));
1588 let rec = r
1590 .evidence
1591 .checks
1592 .iter()
1593 .find(|c| c.name == "compensation_resolution")
1594 .expect("compensation_resolution check is recorded");
1595 assert!(rec.ran);
1596 assert_eq!(rec.findings, 1);
1597 }
1598
1599 #[test]
1600 fn verify_no_tool_specified() {
1601 let mut a = tool_call("a1", "x");
1602 a.tool = None;
1603 let r = verify(&prop(vec![a]), None, None, 30);
1604 assert!(!r.valid);
1605 }
1606
1607 #[test]
1608 fn detect_write_conflict() {
1609 let p = prop(vec![
1610 state_write("a1", "x", Value::from(1)),
1611 state_write("a2", "x", Value::from(2)),
1612 ]);
1613 let r = verify(&p, None, None, 30);
1614 assert!(!r.conflicts.is_empty());
1615 }
1616
1617 #[test]
1618 fn simulate_state_writes() {
1619 let p = prop(vec![
1620 state_write("a1", "x", Value::from(10)),
1621 state_write("a2", "y", Value::from(20)),
1622 ]);
1623 let s = simulate(&p, None);
1624 assert_eq!(s.get("x"), Some(&Value::from(10)));
1625 assert_eq!(s.get("y"), Some(&Value::from(20)));
1626 }
1627
1628 #[test]
1634 fn simulate_skips_effects_of_a_provably_blocked_action() {
1635 let mut deploy = tool_call("deploy", "deploy");
1636 deploy.preconditions = vec![Precondition {
1637 key: "tests_passed".to_string(),
1638 operator: "eq".to_string(),
1639 value: Value::Bool(true),
1640 description: String::new(),
1641 }];
1642 deploy
1643 .expected_effects
1644 .insert("deployed".to_string(), Value::Bool(true));
1645 let p = prop(vec![deploy]);
1646
1647 let failing: HashMap<String, Value> =
1648 [("tests_passed".to_string(), Value::Bool(false))].into();
1649 let s = simulate(&p, Some(&failing));
1650 assert_eq!(
1651 s.get("deployed"),
1652 None,
1653 "a deploy whose precondition provably fails must not appear deployed: {s:?}"
1654 );
1655
1656 let passing: HashMap<String, Value> =
1658 [("tests_passed".to_string(), Value::Bool(true))].into();
1659 let s = simulate(&p, Some(&passing));
1660 assert_eq!(s.get("deployed"), Some(&Value::Bool(true)));
1661 }
1662
1663 #[test]
1668 fn verify_stays_optimistic_so_it_reports_every_finding() {
1669 let mut deploy = tool_call("deploy", "deploy");
1670 deploy.preconditions = vec![Precondition {
1671 key: "tests_passed".to_string(),
1672 operator: "eq".to_string(),
1673 value: Value::Bool(true),
1674 description: String::new(),
1675 }];
1676 deploy
1677 .expected_effects
1678 .insert("deployed".to_string(), Value::Bool(true));
1679 let mut notify = tool_call("notify", "notify");
1680 notify.state_dependencies = vec!["deployed".to_string()];
1681 let p = prop(vec![deploy, notify]);
1682
1683 let failing: HashMap<String, Value> =
1684 [("tests_passed".to_string(), Value::Bool(false))].into();
1685 let r = verify(&p, Some(&failing), None, 30);
1686
1687 assert!(!r.valid);
1688 assert_eq!(
1692 r.errors().len(),
1693 1,
1694 "expected only the precondition finding, got {:?}",
1695 r.issues
1696 );
1697 assert!(r.issues[0].message.contains("precondition will fail"));
1698 }
1699
1700 #[test]
1703 fn simulate_cascade_follows_data_dependencies() {
1704 let mut build = tool_call("build", "build");
1705 build.preconditions = vec![Precondition {
1706 key: "ready".to_string(),
1707 operator: "eq".to_string(),
1708 value: Value::Bool(true),
1709 description: String::new(),
1710 }];
1711 build
1712 .expected_effects
1713 .insert("artifact".to_string(), Value::from("app.tar.gz"));
1714 let mut deploy = tool_call("deploy", "deploy");
1715 deploy.state_dependencies = vec!["artifact".to_string()];
1716 deploy
1717 .expected_effects
1718 .insert("deployed".to_string(), Value::Bool(true));
1719
1720 let s = simulate(&prop(vec![build, deploy]), None);
1721 assert_eq!(
1722 s.get("artifact"),
1723 None,
1724 "blocked build produced no artifact"
1725 );
1726 assert_eq!(
1727 s.get("deployed"),
1728 None,
1729 "deploy depends on the artifact that never appeared: {s:?}"
1730 );
1731 }
1732
1733 #[test]
1737 fn equivalent_distinguishes_a_gated_proposal_from_an_ungated_one() {
1738 let mut gated = tool_call("a", "deploy");
1739 gated.preconditions = vec![Precondition {
1740 key: "tests_passed".to_string(),
1741 operator: "eq".to_string(),
1742 value: Value::Bool(true),
1743 description: String::new(),
1744 }];
1745 gated
1746 .expected_effects
1747 .insert("deployed".to_string(), Value::Bool(true));
1748
1749 let mut ungated = tool_call("b", "deploy");
1750 ungated
1751 .expected_effects
1752 .insert("deployed".to_string(), Value::Bool(true));
1753
1754 let failing: Vec<HashMap<String, Value>> =
1755 vec![[("tests_passed".to_string(), Value::Bool(false))].into()];
1756 assert!(
1757 !equivalent(&prop(vec![gated]), &prop(vec![ungated]), Some(&failing)),
1758 "a gate that blocks one proposal and not the other is a real difference"
1759 );
1760 }
1761
1762 #[test]
1763 fn equivalent_proposals() {
1764 let p1 = prop(vec![
1765 state_write("a1", "x", Value::from(1)),
1766 state_write("a2", "y", Value::from(2)),
1767 ]);
1768 let p2 = prop(vec![
1769 state_write("b1", "y", Value::from(2)),
1770 state_write("b2", "x", Value::from(1)),
1771 ]);
1772 assert!(equivalent(&p1, &p2, None));
1773 }
1774
1775 #[test]
1776 fn non_equivalent_proposals() {
1777 let p1 = prop(vec![state_write("a1", "x", Value::from(1))]);
1778 let p2 = prop(vec![state_write("b1", "x", Value::from(99))]);
1779 assert!(!equivalent(&p1, &p2, None));
1780 }
1781
1782 #[test]
1783 fn optimize_removes_phantom_deps() {
1784 let mut a = tool_call("a1", "search");
1785 a.state_dependencies = vec!["phantom".to_string()];
1786 let p = prop(vec![a]);
1787 let optimized = optimize(&p);
1788 assert!(optimized.actions[0].state_dependencies.is_empty());
1789 }
1790
1791 #[test]
1792 fn optimize_preserves_real_deps() {
1793 let mut a2 = tool_call("a2", "x");
1794 a2.state_dependencies = vec!["x".to_string()];
1795 let p = prop(vec![state_write("a1", "x", Value::from(1)), a2]);
1796 let optimized = optimize(&p);
1797 assert_eq!(optimized.actions[1].state_dependencies, vec!["x"]);
1798 }
1799
1800 #[test]
1801 fn loop_detection_duplicates() {
1802 let p = prop(vec![tool_call("a1", "search"), tool_call("a2", "search")]);
1803 let r = verify(&p, None, None, 30);
1804 assert!(r.issues.iter().any(|i| i.message.contains("duplicate")));
1805 }
1806
1807 #[test]
1808 fn loop_detection_triple() {
1809 let p = prop(vec![
1810 tool_call("a1", "search"),
1811 tool_call("a2", "search"),
1812 tool_call("a3", "search"),
1813 ]);
1814 let r = verify(&p, None, None, 30);
1815 assert!(!r.valid);
1816 assert!(r.issues.iter().any(|i| i.message.contains("likely loop")));
1817 }
1818
1819 #[test]
1820 fn resource_bounds() {
1821 let actions: Vec<Action> = (0..35)
1822 .map(|i| tool_call(&format!("a{}", i), &format!("t{}", i)))
1823 .collect();
1824 let r = verify(&prop(actions), None, None, 30);
1825 assert!(r.issues.iter().any(|i| i.message.contains("excessive")));
1826 }
1827
1828 #[test]
1839 fn resource_bound_is_exclusive_at_the_limit() {
1840 let at_limit: Vec<Action> = (0..30)
1844 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1845 .collect();
1846 let r = verify(&prop(at_limit), None, None, 30);
1847 assert!(
1848 !r.issues.iter().any(|i| i.message.contains("excessive")),
1849 "exactly max_actions is within the bound"
1850 );
1851
1852 let over: Vec<Action> = (0..31)
1853 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1854 .collect();
1855 let r = verify(&prop(over), None, None, 30);
1856 assert!(
1857 r.issues.iter().any(|i| i.message.contains("excessive")),
1858 "one past max_actions is over it"
1859 );
1860 }
1861
1862 #[test]
1863 fn resource_bound_finding_count_is_recorded() {
1864 let over: Vec<Action> = (0..31)
1868 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1869 .collect();
1870 let r = verify(&prop(over), None, None, 30);
1871 let rec = r
1872 .evidence
1873 .checks
1874 .iter()
1875 .find(|c| c.name == "resource_bounds")
1876 .expect("resource_bounds is always recorded");
1877 assert_eq!(rec.findings, 1, "exactly one bound was exceeded");
1878
1879 let under = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
1880 let rec = under
1881 .evidence
1882 .checks
1883 .iter()
1884 .find(|c| c.name == "resource_bounds")
1885 .unwrap();
1886 assert_eq!(
1887 rec.findings, 0,
1888 "a proposal inside the bound has no finding"
1889 );
1890 }
1891
1892 #[test]
1893 fn precondition_findings_are_counted_not_just_reported() {
1894 let mut a = tool_call("a1", "t1");
1897 a.preconditions = vec![Precondition {
1898 key: "missing_key".to_string(),
1899 operator: "exists".to_string(),
1900 value: Value::Null,
1901 description: String::new(),
1902 }];
1903 let r = verify(&prop(vec![a]), Some(&HashMap::new()), None, 30);
1904 let rec = r
1905 .evidence
1906 .checks
1907 .iter()
1908 .find(|c| c.name == "preconditions")
1909 .expect("preconditions is recorded whenever an action declares one");
1910 assert_eq!(
1911 rec.findings,
1912 r.issues
1913 .iter()
1914 .filter(|i| i.message.contains("precondition"))
1915 .count(),
1916 "the recorded count must match the issues actually raised"
1917 );
1918 assert!(rec.findings > 0, "an unmet precondition is a finding");
1919 }
1920
1921 #[test]
1922 fn warning_count_drives_the_residual_risk_line() {
1923 let clean = verify(
1927 &prop(vec![state_write("a1", "x", Value::from(1))]),
1928 None,
1929 None,
1930 30,
1931 );
1932 assert!(
1933 !clean
1934 .evidence
1935 .residual_risks
1936 .iter()
1937 .any(|s| s.contains("warning(s)")),
1938 "no warnings means no warning risk line at all"
1939 );
1940
1941 let warned = verify(
1943 &prop(vec![
1944 state_write("a1", "k", Value::from(1)),
1945 state_write("a2", "k", Value::from(2)),
1946 ]),
1947 None,
1948 None,
1949 30,
1950 );
1951 let warnings = warned
1952 .issues
1953 .iter()
1954 .filter(|i| i.severity == "warning")
1955 .count();
1956 assert!(warnings > 0, "the fixture must actually produce a warning");
1957 assert!(
1958 warned
1959 .evidence
1960 .residual_risks
1961 .iter()
1962 .any(|s| s.contains(&format!("{warnings} warning(s)"))),
1963 "the risk line must carry the real warning count"
1964 );
1965 }
1966
1967 #[test]
1968 fn confidence_docks_exactly_once_per_skipped_check() {
1969 let untested_dock =
1978 |r: &VerifyResult| (r.evidence.untested_regions.len() as f64 * 0.02).min(0.25);
1979
1980 let r = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
1982 let expected = 1.0 - 0.15 - 0.20 - untested_dock(&r);
1983 assert!(
1984 (r.evidence.confidence - expected).abs() < 1e-9,
1985 "1.0 - 0.15 (no registry) - 0.20 (no schemas) - untested, want {expected}, got {}",
1986 r.evidence.confidence
1987 );
1988
1989 let r = verify(
1992 &prop(vec![tool_call("a1", "t1")]),
1993 None,
1994 Some(&["t1".to_string()].into()),
1995 30,
1996 );
1997 let expected = 1.0 - 0.20 - untested_dock(&r);
1998 assert!(
1999 (r.evidence.confidence - expected).abs() < 1e-9,
2000 "1.0 - 0.20 (no schemas) - untested, want {expected}, got {}",
2001 r.evidence.confidence
2002 );
2003
2004 let r = verify(
2007 &prop(vec![state_write("a1", "x", Value::from(1))]),
2008 None,
2009 None,
2010 30,
2011 );
2012 assert!(
2013 (r.evidence.confidence - 1.0).abs() < 1e-9,
2014 "pure state writes dock nothing, got {}",
2015 r.evidence.confidence
2016 );
2017 }
2018
2019 #[test]
2020 fn confidence_docks_scale_with_warnings_and_stay_clamped() {
2021 let warned = verify(
2024 &prop(vec![
2025 state_write("a1", "k", Value::from(1)),
2026 state_write("a2", "k", Value::from(2)),
2027 ]),
2028 None,
2029 None,
2030 30,
2031 );
2032 let warnings = warned
2033 .issues
2034 .iter()
2035 .filter(|i| i.severity == "warning")
2036 .count();
2037 let expected = 1.0 - (warnings as f64 * 0.05).min(0.20);
2038 assert!(
2039 (warned.evidence.confidence - expected).abs() < 1e-9,
2040 "{warnings} warning(s) dock 0.05 each, capped at 0.20; got {}",
2041 warned.evidence.confidence
2042 );
2043 assert!(
2044 (0.0..=1.0).contains(&warned.evidence.confidence),
2045 "confidence must stay inside its documented range"
2046 );
2047 }
2048
2049 #[test]
2050 fn the_schema_assumption_needs_both_conditions() {
2051 let r = verify(
2055 &prop(vec![state_write("a1", "x", Value::from(1))]),
2056 None,
2057 None,
2058 30,
2059 );
2060 assert!(
2061 !r.evidence
2062 .assumptions
2063 .iter()
2064 .any(|s| s.contains("tool_call parameters")),
2065 "a proposal with no tool calls assumes nothing about tool_call params"
2066 );
2067
2068 let r = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
2069 assert!(
2070 r.evidence
2071 .assumptions
2072 .iter()
2073 .any(|s| s.contains("tool_call parameters")),
2074 "unchecked tool_call params must be declared as an assumption"
2075 );
2076 }
2077
2078 #[test]
2079 fn loop_detection_finding_count_is_a_delta_not_a_total() {
2080 let mut actions: Vec<Action> = (0..31)
2085 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
2086 .collect();
2087 actions.push(tool_call("dup", "t0")); let r = verify(&prop(actions), None, None, 30);
2089 let bounds = r
2090 .evidence
2091 .checks
2092 .iter()
2093 .find(|c| c.name == "resource_bounds")
2094 .unwrap();
2095 let loops = r
2096 .evidence
2097 .checks
2098 .iter()
2099 .find(|c| c.name == "loop_detection")
2100 .expect("loop_detection is recorded");
2101 assert_eq!(bounds.findings, 1, "one bound exceeded");
2102 assert!(loops.findings > 0, "the duplicate must be found");
2103 assert!(
2104 loops.findings < r.issues.len(),
2105 "loop_detection reports its own findings ({}), not every issue raised ({})",
2106 loops.findings,
2107 r.issues.len()
2108 );
2109 }
2110
2111 #[test]
2112 fn param_schema_finding_count_is_recorded() {
2113 let p = echo_call([("msg".to_string(), Value::from(42))].into());
2116 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
2117 assert!(!r.valid);
2118 let rec = r
2119 .evidence
2120 .checks
2121 .iter()
2122 .find(|c| c.name == "param_schema")
2123 .expect("param_schema is recorded when schemas are supplied");
2124 assert_eq!(
2125 rec.findings,
2126 r.issues
2127 .iter()
2128 .filter(|i| i.message.contains("wrong type"))
2129 .count(),
2130 "the recorded count must match the type errors raised"
2131 );
2132 assert!(rec.findings > 0);
2133 }
2134
2135 #[test]
2136 fn compensation_finding_count_is_recorded() {
2137 let mut a = tool_call("a1", "poll");
2139 a.reversibility = car_ir::Reversibility::Compensable;
2140 a.compensation = Some(car_ir::Compensation::Tool {
2141 tool: "db.delet".into(), parameters: Default::default(),
2143 });
2144 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
2145 let rec = r
2146 .evidence
2147 .checks
2148 .iter()
2149 .find(|c| c.name == "compensation_resolution")
2150 .expect("compensation is recorded when an action declares one");
2151 assert!(
2152 rec.findings > 0,
2153 "an unrunnable rollback plan is a compensation finding"
2154 );
2155
2156 let mut ok = tool_call("a1", "poll");
2159 ok.reversibility = car_ir::Reversibility::Compensable;
2160 ok.compensation = Some(car_ir::Compensation::Tool {
2161 tool: "undo".into(),
2162 parameters: Default::default(),
2163 });
2164 let r = verify(
2165 &prop(vec![ok]),
2166 None,
2167 Some(&["poll".to_string(), "undo".to_string()].into()),
2168 30,
2169 );
2170 let rec = r
2171 .evidence
2172 .checks
2173 .iter()
2174 .find(|c| c.name == "compensation_resolution")
2175 .unwrap();
2176 assert_eq!(rec.findings, 0, "a registered undo is not a finding");
2177 }
2178
2179 #[test]
2180 fn action_ref_compensation_finding_count_is_recorded() {
2181 let mut a = tool_call("a1", "poll");
2185 a.reversibility = car_ir::Reversibility::Compensable;
2186 a.compensation = Some(car_ir::Compensation::ActionRef {
2187 action_id: "nonexistent".into(),
2188 });
2189 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
2190 let rec = r
2191 .evidence
2192 .checks
2193 .iter()
2194 .find(|c| c.name == "compensation_resolution")
2195 .expect("compensation_resolution is recorded");
2196 assert_eq!(
2197 rec.findings,
2198 r.issues
2199 .iter()
2200 .filter(|i| i.message.contains("not in this proposal"))
2201 .count(),
2202 "the count must match the dangling references raised"
2203 );
2204 assert!(rec.findings > 0, "a dangling action ref is a finding");
2205 }
2206
2207 #[test]
2208 fn untested_regions_drive_their_own_residual_risk() {
2209 let clean = verify(
2212 &prop(vec![state_write("a1", "x", Value::from(1))]),
2213 None,
2214 None,
2215 30,
2216 );
2217 assert!(clean.evidence.untested_regions.is_empty());
2218 assert!(
2219 !clean
2220 .evidence
2221 .residual_risks
2222 .iter()
2223 .any(|s| s.contains("runtime tool output")),
2224 "nothing untested means no runtime-output risk line"
2225 );
2226
2227 let dynamic = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
2228 assert!(
2229 !dynamic.evidence.untested_regions.is_empty(),
2230 "a tool call leaves runtime-decided regions"
2231 );
2232 assert!(
2233 dynamic
2234 .evidence
2235 .residual_risks
2236 .iter()
2237 .any(|s| s.contains("runtime tool output")),
2238 "untested regions must surface as a residual risk"
2239 );
2240 }
2241
2242 #[test]
2251 fn evidence_declares_all_check_scopes() {
2252 let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
2253 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
2254 for want in [
2256 "resource_bounds",
2257 "loop_detection",
2258 "preconditions",
2259 "state_dependencies",
2260 "tool_existence",
2261 "param_schema",
2262 "write_conflicts",
2263 ] {
2264 let rec = r
2265 .evidence
2266 .checks
2267 .iter()
2268 .find(|c| c.name == want)
2269 .unwrap_or_else(|| panic!("missing check record {want}"));
2270 assert!(!rec.verifies.is_empty());
2271 assert!(!rec.cannot_verify.is_empty());
2272 }
2273 let by = |n: &str| r.evidence.checks.iter().find(|c| c.name == n).unwrap();
2275 assert!(by("param_schema").ran);
2276 assert!(by("tool_existence").ran);
2277 }
2278
2279 #[test]
2280 fn evidence_marks_param_schema_skipped_without_schemas() {
2281 let p = prop(vec![tool_call("a1", "search")]);
2284 let r = verify(&p, None, None, 30);
2285 let param = r
2286 .evidence
2287 .checks
2288 .iter()
2289 .find(|c| c.name == "param_schema")
2290 .unwrap();
2291 assert!(!param.ran);
2292 assert!(
2293 r.evidence.confidence < 1.0,
2294 "skipped check should dock coverage"
2295 );
2296 assert!(r
2297 .evidence
2298 .assumptions
2299 .iter()
2300 .any(|a| a.contains("well-formed")));
2301 }
2302
2303 #[test]
2304 fn evidence_full_confidence_for_pure_state_writes() {
2305 let p = prop(vec![state_write("a1", "x", Value::from(1))]);
2307 let r = verify(&p, None, None, 30);
2308 assert!(r.valid);
2309 assert_eq!(r.evidence.confidence, 1.0);
2310 assert!(r.evidence.untested_regions.is_empty());
2311 }
2312
2313 #[test]
2314 fn evidence_conflicts_become_residual_risk() {
2315 let p = prop(vec![
2318 state_write("a1", "k", Value::from(1)),
2319 state_write("a2", "k", Value::from(2)),
2320 ]);
2321 let r = verify(&p, None, None, 30);
2322 assert!(r.valid, "conflicts are warnings, not errors");
2323 assert!(!r.conflicts.is_empty());
2324 assert!(r
2325 .evidence
2326 .residual_risks
2327 .iter()
2328 .any(|s| s.contains("write conflict")));
2329 let wc = r
2330 .evidence
2331 .checks
2332 .iter()
2333 .find(|c| c.name == "write_conflicts")
2334 .unwrap();
2335 assert_eq!(wc.findings, r.conflicts.len());
2336 }
2337
2338 #[test]
2339 fn evidence_untested_includes_runtime_set_effect_keys() {
2340 let mut a = tool_call("a1", "fetch");
2344 a.expected_effects = [("out".to_string(), Value::from("placeholder"))].into();
2345 let r = verify(
2346 &prop(vec![a]),
2347 None,
2348 Some(&["fetch".to_string()].into()),
2349 30,
2350 );
2351 assert!(r
2352 .evidence
2353 .untested_regions
2354 .iter()
2355 .any(|s| s.contains("state key 'out'")));
2356 assert!(r
2357 .evidence
2358 .untested_regions
2359 .iter()
2360 .any(|s| s.contains("runtime output of tool 'fetch'")));
2361 }
2362
2363 #[test]
2364 fn evidence_tool_existence_ran_consistent_with_findings() {
2365 let mut a = tool_call("a1", "x");
2369 a.tool = None;
2370 let r = verify(&prop(vec![a]), None, None, 30);
2371 assert!(!r.valid);
2372 let te = r
2373 .evidence
2374 .checks
2375 .iter()
2376 .find(|c| c.name == "tool_existence")
2377 .unwrap();
2378 assert!(te.findings >= 1);
2379 assert!(
2380 te.ran,
2381 "ran must be true whenever the check produced a finding"
2382 );
2383 }
2384
2385 #[test]
2392 fn loop_detection_findings_are_heuristic_and_the_rest_are_not() {
2393 let p = prop(vec![
2394 tool_call("a1", "poll"),
2395 tool_call("a2", "poll"),
2396 tool_call("a3", "poll"),
2397 tool_call("a4", "ghost"),
2398 ]);
2399 let r = verify(&p, None, Some(&["poll".to_string()].into()), 30);
2400
2401 let heuristic = r.issues_with_tier(EvidenceTier::Heuristic);
2402 assert_eq!(
2403 heuristic.len(),
2404 1,
2405 "only the repeated-call finding is heuristic: {:?}",
2406 r.issues
2407 );
2408 assert!(heuristic[0]
2409 .message
2410 .contains("repeated identical tool call"));
2411
2412 let decided = r.issues_with_tier(EvidenceTier::DecisionProcedure);
2414 assert!(decided
2415 .iter()
2416 .any(|i| i.message.contains("'ghost' is not registered")));
2417
2418 assert!(r.issues_with_tier(EvidenceTier::Sampled).is_empty());
2420 }
2421
2422 #[test]
2434 fn check_records_and_issues_agree_on_tier() {
2435 let p = prop(vec![
2436 tool_call("a1", "poll"),
2439 tool_call("a2", "poll"),
2440 {
2443 let mut a = tool_call("a3", "ghost");
2444 a.state_dependencies = vec!["missing".to_string()];
2445 a
2446 },
2447 state_write("a4", "x", Value::from(1)),
2450 state_write("a5", "x", Value::from(2)),
2451 ]);
2452 let r = verify(&p, None, Some(&["poll".to_string()].into()), 30);
2453
2454 for tier in [
2459 EvidenceTier::DecisionProcedure,
2460 EvidenceTier::Heuristic,
2461 EvidenceTier::Sampled,
2462 ] {
2463 let declared: usize = r
2464 .evidence
2465 .checks
2466 .iter()
2467 .filter(|c| c.tier == tier)
2468 .map(|c| c.findings)
2469 .sum();
2470 let actual = r.issues_with_tier(tier).len();
2471 assert_eq!(
2472 declared,
2473 actual,
2474 "checks at tier {} declare {declared} findings but {actual} issues carry it: {:?}",
2475 tier.as_str(),
2476 r.issues
2477 );
2478 }
2479
2480 let total: usize = r.evidence.checks.iter().map(|c| c.findings).sum();
2483 assert_eq!(total, r.issues.len(), "unaccounted issues: {:?}", r.issues);
2484
2485 assert_eq!(
2488 r.issues_with_tier(EvidenceTier::Heuristic).len(),
2489 1,
2490 "expected exactly the duplicate-call finding: {:?}",
2491 r.issues
2492 );
2493 assert!(
2494 r.issues_with_tier(EvidenceTier::DecisionProcedure).len() >= 3,
2495 "expected the unregistered tool, the missing dependency, and the \
2496 write conflict: {:?}",
2497 r.issues
2498 );
2499 }
2500
2501 #[test]
2504 fn tier_serializes_as_stable_snake_case() {
2505 let p = prop(vec![tool_call("a1", "ghost")]);
2506 let r = verify(&p, None, Some(&HashSet::new()), 30);
2507 let json = serde_json::to_value(&r.issues[0]).expect("issue serializes");
2508 assert_eq!(json["tier"], Value::from("decision_procedure"));
2509 assert_eq!(
2510 json["tier"],
2511 Value::from(r.issues[0].tier.as_str()),
2512 "as_str and the serde representation must not drift"
2513 );
2514 }
2515}