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_with_predictions, ConflictKind, TransactionConflict,
121 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
302#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
311#[non_exhaustive]
312pub struct CheckRecord {
313 pub name: String,
315 pub ran: bool,
320 pub verifies: String,
322 pub cannot_verify: String,
325 pub findings: usize,
327 pub tier: EvidenceTier,
332}
333
334#[derive(Debug, Clone, serde::Serialize)]
343pub struct VerificationEvidence {
344 pub checks: Vec<CheckRecord>,
346 pub assumptions: Vec<String>,
349 pub untested_regions: Vec<String>,
352 pub residual_risks: Vec<String>,
356 pub confidence: f64,
362}
363
364#[derive(Debug, serde::Serialize)]
366pub struct VerifyResult {
367 pub valid: bool,
368 pub issues: Vec<VerifyIssue>,
369 pub simulated_state: HashMap<String, Value>,
370 pub execution_levels: Vec<Vec<String>>,
371 pub conflicts: Vec<(String, String, String)>, pub evidence: VerificationEvidence,
375}
376
377impl VerifyResult {
378 pub fn errors(&self) -> Vec<&VerifyIssue> {
379 self.issues
380 .iter()
381 .filter(|i| i.severity == "error")
382 .collect()
383 }
384
385 pub fn warnings(&self) -> Vec<&VerifyIssue> {
386 self.issues
387 .iter()
388 .filter(|i| i.severity == "warning")
389 .collect()
390 }
391
392 pub fn issues_with_tier(&self, tier: EvidenceTier) -> Vec<&VerifyIssue> {
400 self.issues.iter().filter(|i| i.tier == tier).collect()
401 }
402}
403
404pub(crate) fn apply_action_effects(action: &Action, state: &mut StaticState) {
407 if action.action_type == ActionType::StateWrite {
408 if let Some(key) = action.parameters.get("key").and_then(|v| v.as_str()) {
409 let value = action
410 .parameters
411 .get("value")
412 .cloned()
413 .unwrap_or(Value::Null);
414 state.set(key, value);
415 }
416 }
417 for (key, value) in &action.expected_effects {
418 state.set(key, value.clone());
419 }
420}
421
422fn detect_conflicts(actions: &[Action]) -> Vec<(String, String, String)> {
425 let mut writers: HashMap<String, Vec<String>> = HashMap::new();
426
427 for action in actions {
428 let mut keys_written = HashSet::new();
429 if action.action_type == ActionType::StateWrite {
430 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
431 keys_written.insert(k.to_string());
432 }
433 }
434 for key in action.expected_effects.keys() {
435 keys_written.insert(key.clone());
436 }
437 for key in keys_written {
438 writers.entry(key).or_default().push(action.id.clone());
439 }
440 }
441
442 let dep_map: HashMap<String, HashSet<String>> = actions
443 .iter()
444 .map(|a| (a.id.clone(), a.state_dependencies.iter().cloned().collect()))
445 .collect();
446
447 let mut conflicts = Vec::new();
448 for (key, action_ids) in &writers {
449 if action_ids.len() < 2 {
450 continue;
451 }
452 for i in 0..action_ids.len() {
453 for j in (i + 1)..action_ids.len() {
454 let a1 = &action_ids[i];
455 let a2 = &action_ids[j];
456 let deps_a2 = dep_map.get(a2).cloned().unwrap_or_default();
457 let deps_a1 = dep_map.get(a1).cloned().unwrap_or_default();
458 if !deps_a2.contains(key) && !deps_a1.contains(key) {
459 conflicts.push((a1.clone(), a2.clone(), key.clone()));
460 }
461 }
462 }
463 }
464 conflicts
465}
466
467fn json_type_name(v: &Value) -> &'static str {
471 match v {
472 Value::Null => "null",
473 Value::Bool(_) => "boolean",
474 Value::Number(_) => "number",
475 Value::String(_) => "string",
476 Value::Array(_) => "array",
477 Value::Object(_) => "object",
478 }
479}
480
481fn value_matches_type(v: &Value, expected: &str) -> bool {
483 match expected {
484 "string" => v.is_string(),
485 "number" => v.is_number(),
486 "integer" => {
489 v.is_i64() || v.is_u64() || v.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false)
490 }
491 "boolean" => v.is_boolean(),
492 "array" => v.is_array(),
493 "object" => v.is_object(),
494 "null" => v.is_null(),
495 _ => true,
498 }
499}
500
501fn validate_tool_params(params: &HashMap<String, Value>, schema: &Value) -> Vec<String> {
508 let mut out = Vec::new();
509 let Some(schema_obj) = schema.as_object() else {
510 return out;
512 };
513
514 if let Some(Value::Array(required)) = schema_obj.get("required") {
516 for req in required {
517 if let Some(name) = req.as_str() {
518 if !params.contains_key(name) {
519 out.push(format!("missing required parameter '{name}'"));
520 }
521 }
522 }
523 }
524
525 if let Some(Value::Object(properties)) = schema_obj.get("properties") {
529 for (key, val) in params {
530 let Some(prop_schema) = properties.get(key).and_then(|s| s.as_object()) else {
531 continue;
532 };
533 let ok = match prop_schema.get("type") {
534 Some(Value::String(t)) => value_matches_type(val, t),
535 Some(Value::Array(types)) => types
536 .iter()
537 .filter_map(|t| t.as_str())
538 .any(|t| value_matches_type(val, t)),
539 _ => true,
541 };
542 if !ok {
543 let expected = match prop_schema.get("type") {
544 Some(Value::String(t)) => t.clone(),
545 Some(Value::Array(types)) => types
546 .iter()
547 .filter_map(|t| t.as_str())
548 .collect::<Vec<_>>()
549 .join("|"),
550 _ => String::new(),
551 };
552 out.push(format!(
553 "parameter '{key}' has wrong type: expected {expected}, got {}",
554 json_type_name(val)
555 ));
556 }
557 }
558 }
559
560 out
561}
562
563pub fn verify(
573 proposal: &ActionProposal,
574 initial_state: Option<&HashMap<String, Value>>,
575 registered_tools: Option<&HashSet<String>>,
576 max_actions: usize,
577) -> VerifyResult {
578 verify_inner(proposal, initial_state, registered_tools, None, max_actions)
579}
580
581pub fn verify_with_schemas(
589 proposal: &ActionProposal,
590 initial_state: Option<&HashMap<String, Value>>,
591 tool_schemas: Option<&HashMap<String, ToolSchema>>,
592 max_actions: usize,
593) -> VerifyResult {
594 verify_inner(proposal, initial_state, None, tool_schemas, max_actions)
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
603enum EffectMode {
604 Optimistic,
613 ExecutionFaithful,
622}
623
624fn verify_inner(
625 proposal: &ActionProposal,
626 initial_state: Option<&HashMap<String, Value>>,
627 registered_tools: Option<&HashSet<String>>,
628 tool_schemas: Option<&HashMap<String, ToolSchema>>,
629 max_actions: usize,
630) -> VerifyResult {
631 verify_inner_with_effects(
632 proposal,
633 initial_state,
634 registered_tools,
635 tool_schemas,
636 max_actions,
637 EffectMode::Optimistic,
638 )
639}
640
641fn verify_inner_with_effects(
642 proposal: &ActionProposal,
643 initial_state: Option<&HashMap<String, Value>>,
644 registered_tools: Option<&HashSet<String>>,
645 tool_schemas: Option<&HashMap<String, ToolSchema>>,
646 max_actions: usize,
647 effect_mode: EffectMode,
648) -> VerifyResult {
649 let mut state = match initial_state {
650 Some(s) => StaticState::from_map(s.clone()),
651 None => StaticState::new(),
652 };
653 let mut issues = Vec::new();
654
655 let mut precondition_findings = 0usize;
659 let mut state_dependency_findings = 0usize;
660 let mut tool_existence_findings = 0usize;
661 let mut param_schema_findings = 0usize;
662 let mut has_tool_calls = false;
663 let mut saw_missing_tool = false;
668 let mut compensation_findings = 0usize;
672 let mut saw_compensation_ref = false;
676 let has_tool_registry = tool_schemas.is_some() || registered_tools.is_some();
680 let param_schema_ran = tool_schemas.is_some();
681
682 let issues_before_bounds = issues.len();
684 if proposal.actions.len() > max_actions {
685 issues.push(VerifyIssue {
686 action_id: proposal
687 .actions
688 .first()
689 .map(|a| a.id.clone())
690 .unwrap_or_default(),
691 severity: "warning".to_string(),
692 message: format!(
693 "excessive actions: {} (limit {})",
694 proposal.actions.len(),
695 max_actions
696 ),
697 tier: EvidenceTier::DecisionProcedure,
703 });
704 }
705
706 let resource_bound_findings = issues.len() - issues_before_bounds;
707
708 let issues_before_loop = issues.len();
710 let mut seen_calls: HashMap<String, u32> = HashMap::new();
711 for action in &proposal.actions {
712 if action.action_type == ActionType::ToolCall {
713 if let Some(ref tool) = action.tool {
714 let params = serde_json::to_string(&action.parameters).unwrap_or_default();
715 let key = format!("{}:{}", tool, params);
716 *seen_calls.entry(key).or_insert(0) += 1;
717 }
718 }
719 }
720 for (call_key, count) in &seen_calls {
721 let tool_name = call_key.split(':').next().unwrap_or("?");
722 if *count >= 3 {
723 issues.push(VerifyIssue {
724 action_id: "proposal".to_string(),
725 severity: "error".to_string(),
726 message: format!(
727 "repeated identical tool call: {} ({}x) — likely loop",
728 tool_name, count
729 ),
730 tier: EvidenceTier::Heuristic,
734 });
735 } else if *count == 2 {
736 issues.push(VerifyIssue {
737 action_id: "proposal".to_string(),
738 severity: "warning".to_string(),
739 message: format!("duplicate tool call: {} ({}x)", tool_name, count),
740 tier: EvidenceTier::Heuristic,
743 });
744 }
745 }
746
747 let loop_detection_findings = issues.len() - issues_before_loop;
748
749 let levels = build_dag(&proposal.actions);
751 let execution_levels: Vec<Vec<String>> = levels
752 .iter()
753 .map(|level| {
754 level
755 .iter()
756 .map(|&i| proposal.actions[i].id.clone())
757 .collect()
758 })
759 .collect();
760
761 for level in &levels {
763 for &idx in level {
764 let action = &proposal.actions[idx];
765
766 let mut blocked = false;
771
772 for pre in &action.preconditions {
774 if let Some(error) = precondition::check_precondition(pre, &state) {
775 precondition_findings += 1;
776 blocked = true;
777 issues.push(VerifyIssue {
778 action_id: action.id.clone(),
779 severity: "error".to_string(),
780 message: format!("precondition will fail: {}", error),
781 tier: EvidenceTier::DecisionProcedure,
789 });
790 }
791 }
792
793 for dep in &action.state_dependencies {
795 if !state.exists(dep) && !state.is_unknown(dep) {
796 state_dependency_findings += 1;
797 blocked = true;
798 issues.push(VerifyIssue {
799 action_id: action.id.clone(),
800 severity: "error".to_string(),
801 message: format!("state dependency '{}' not available at this point", dep),
802 tier: EvidenceTier::DecisionProcedure,
806 });
807 }
808 }
809
810 if action.action_type == ActionType::ToolCall {
812 has_tool_calls = true;
813 if let Some(ref tool) = action.tool {
814 let registered = match (tool_schemas, registered_tools) {
818 (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
819 (None, Some(names)) => Some(names.contains(tool.as_str())),
820 (None, None) => None,
821 };
822 if registered == Some(false) {
823 tool_existence_findings += 1;
824 issues.push(VerifyIssue {
825 action_id: action.id.clone(),
826 severity: "error".to_string(),
827 message: format!("tool '{}' is not registered", tool),
828 tier: EvidenceTier::DecisionProcedure,
830 });
831 }
832 if let Some(schema) = tool_schemas.and_then(|s| s.get(tool.as_str())) {
837 for msg in validate_tool_params(&action.parameters, &schema.parameters) {
838 param_schema_findings += 1;
839 issues.push(VerifyIssue {
840 action_id: action.id.clone(),
841 severity: "error".to_string(),
842 message: format!("tool '{tool}': {msg}"),
843 tier: EvidenceTier::DecisionProcedure,
851 });
852 }
853 }
854 } else {
855 saw_missing_tool = true;
856 tool_existence_findings += 1;
857 issues.push(VerifyIssue {
858 action_id: action.id.clone(),
859 severity: "error".to_string(),
860 message: "tool_call action has no tool specified".to_string(),
861 tier: EvidenceTier::DecisionProcedure,
863 });
864 }
865 }
866
867 match &action.compensation {
873 Some(car_ir::Compensation::Tool { tool, .. }) => {
874 let registered = match (tool_schemas, registered_tools) {
875 (Some(schemas), _) => Some(schemas.contains_key(tool.as_str())),
876 (None, Some(names)) => Some(names.contains(tool.as_str())),
877 (None, None) => None,
878 };
879 if registered == Some(false) {
880 compensation_findings += 1;
881 issues.push(VerifyIssue {
882 action_id: action.id.clone(),
883 severity: "error".to_string(),
884 message: format!(
885 "compensation names tool '{tool}', which is not registered"
886 ),
887 tier: EvidenceTier::DecisionProcedure,
888 });
889 }
890 }
891 Some(car_ir::Compensation::ActionRef { action_id }) => {
892 saw_compensation_ref = true;
893 if !proposal.actions.iter().any(|a| &a.id == action_id) {
894 compensation_findings += 1;
895 issues.push(VerifyIssue {
896 action_id: action.id.clone(),
897 severity: "error".to_string(),
898 message: format!(
899 "compensation references action '{action_id}', which is not in this proposal"
900 ),
901 tier: EvidenceTier::DecisionProcedure,
902 });
903 }
904 }
905 None => {}
906 }
907
908 if action.missing_required_compensation() {
912 compensation_findings += 1;
913 issues.push(VerifyIssue {
914 action_id: action.id.clone(),
915 severity: "error".to_string(),
916 message: "action declares reversibility 'compensable' but no compensation"
917 .to_string(),
918 tier: EvidenceTier::DecisionProcedure,
919 });
920 }
921
922 if effect_mode == EffectMode::Optimistic || !blocked {
929 apply_action_effects(action, &mut state);
930 }
931 }
932 }
933
934 let conflicts = detect_conflicts(&proposal.actions);
936 for (a1, a2, key) in &conflicts {
937 issues.push(VerifyIssue {
938 action_id: a1.clone(),
939 severity: "warning".to_string(),
940 message: format!(
941 "write conflict on '{}' with action {} (no dependency declared)",
942 key, a2
943 ),
944 tier: EvidenceTier::DecisionProcedure,
949 });
950 }
951
952 let conflict_findings = conflicts.len();
953
954 let has_errors = issues.iter().any(|i| i.severity == "error");
955 let warning_count = issues.iter().filter(|i| i.severity == "warning").count();
956
957 let checks = vec![
959 CheckRecord {
960 name: "resource_bounds".into(),
961 ran: true,
962 verifies: format!("action count is within the limit ({max_actions})"),
963 cannot_verify: "per-action cost, wall-clock time, or memory at runtime".into(),
964 findings: resource_bound_findings,
965 tier: EvidenceTier::DecisionProcedure,
966 },
967 CheckRecord {
968 name: "loop_detection".into(),
969 ran: true,
970 verifies: "no identical tool call is repeated enough to look like a loop".into(),
971 cannot_verify: "semantically redundant calls with differing arguments".into(),
972 findings: loop_detection_findings,
973 tier: EvidenceTier::Heuristic,
977 },
978 CheckRecord {
979 name: "preconditions".into(),
980 ran: true,
981 verifies: "declared preconditions hold against the statically-known state".into(),
982 cannot_verify: "preconditions over keys whose values are only known at runtime".into(),
983 findings: precondition_findings,
984 tier: EvidenceTier::DecisionProcedure,
985 },
986 CheckRecord {
987 name: "state_dependencies".into(),
988 ran: true,
989 verifies: "each declared state dependency is produced before it is read".into(),
990 cannot_verify: "undeclared reads — state a tool consumes without listing it".into(),
991 findings: state_dependency_findings,
992 tier: EvidenceTier::DecisionProcedure,
993 },
994 CheckRecord {
995 name: "tool_existence".into(),
999 ran: has_tool_registry || saw_missing_tool,
1000 verifies: if has_tool_registry {
1001 "every tool_call names a registered tool".into()
1002 } else if saw_missing_tool {
1003 "tool_call structural well-formedness (a tool is named); registry not supplied so existence unchecked".into()
1004 } else {
1005 "(skipped — no tool registry supplied)".into()
1006 },
1007 cannot_verify: "whether the registered tool behaves as its name/description implies"
1008 .into(),
1009 findings: tool_existence_findings,
1010 tier: EvidenceTier::DecisionProcedure,
1015 },
1016 CheckRecord {
1017 name: "param_schema".into(),
1018 ran: param_schema_ran,
1019 verifies: if param_schema_ran {
1020 "tool_call parameters match the registered JSON Schema (types + required)".into()
1021 } else {
1022 "(skipped — no tool schemas supplied; existence only)".into()
1023 },
1024 cannot_verify:
1025 "value-level constraints beyond type/required (ranges, formats, cross-field)".into(),
1026 findings: param_schema_findings,
1027 tier: EvidenceTier::DecisionProcedure,
1028 },
1029 CheckRecord {
1030 name: "compensation_resolution".into(),
1031 ran: has_tool_registry || saw_compensation_ref || compensation_findings > 0,
1035 verifies: "a declared compensation names a registered tool or an action in this \
1036 proposal, and a `compensable` action declares one at all"
1037 .into(),
1038 cannot_verify: "whether the named compensation actually undoes the effect — that it \
1039 is the right inverse, and that it will still work later"
1040 .into(),
1041 findings: compensation_findings,
1042 tier: EvidenceTier::DecisionProcedure,
1044 },
1045 CheckRecord {
1046 name: "write_conflicts".into(),
1047 ran: true,
1048 verifies: "concurrent writers to the same key declare an ordering dependency".into(),
1049 cannot_verify:
1050 "semantic conflicts — two actions whose effects are logically incompatible".into(),
1051 findings: conflict_findings,
1052 tier: EvidenceTier::DecisionProcedure,
1053 },
1054 ];
1055
1056 let mut untested_regions: Vec<String> = Vec::new();
1064 for action in &proposal.actions {
1065 if action.action_type == ActionType::ToolCall {
1066 if let Some(ref tool) = action.tool {
1067 untested_regions.push(format!(
1068 "runtime output of tool '{tool}' (action {})",
1069 action.id
1070 ));
1071 }
1072 for key in action.expected_effects.keys() {
1073 untested_regions.push(format!(
1074 "state key '{key}' (value set at runtime by action {})",
1075 action.id
1076 ));
1077 }
1078 }
1079 }
1080 untested_regions.sort();
1081 untested_regions.dedup();
1082
1083 let mut assumptions = vec![
1084 "supplied initial-state values are accurate".to_string(),
1085 "tool implementations honor their declared effects and side effects".to_string(),
1086 ];
1087 if !param_schema_ran && has_tool_calls {
1088 assumptions.push(
1089 "tool_call parameters are well-formed (no schemas supplied to check them)".to_string(),
1090 );
1091 }
1092
1093 let mut residual_risks = Vec::new();
1094 if !conflicts.is_empty() {
1095 residual_risks.push(format!(
1096 "{} undeclared write conflict(s) — last-writer-wins at runtime",
1097 conflicts.len()
1098 ));
1099 }
1100 if warning_count > 0 {
1101 residual_risks.push(format!(
1102 "{warning_count} warning(s) not blocking the verdict"
1103 ));
1104 }
1105 if !untested_regions.is_empty() {
1106 residual_risks.push(
1107 "outcomes depending on runtime tool output or runtime-set state are unverified"
1108 .to_string(),
1109 );
1110 }
1111
1112 let mut confidence: f64 = 1.0;
1116 if has_tool_calls && !has_tool_registry {
1117 confidence -= 0.15;
1118 }
1119 if has_tool_calls && !param_schema_ran {
1120 confidence -= 0.20;
1121 }
1122 confidence -= (untested_regions.len() as f64 * 0.02).min(0.25);
1123 confidence -= (warning_count as f64 * 0.05).min(0.20);
1124 let confidence = confidence.clamp(0.0, 1.0);
1125
1126 let evidence = VerificationEvidence {
1127 checks,
1128 assumptions,
1129 untested_regions,
1130 residual_risks,
1131 confidence,
1132 };
1133
1134 VerifyResult {
1135 valid: !has_errors,
1136 issues,
1137 simulated_state: state.known,
1138 execution_levels,
1139 conflicts,
1140 evidence,
1141 }
1142}
1143
1144pub fn simulate(
1163 proposal: &ActionProposal,
1164 initial_state: Option<&HashMap<String, Value>>,
1165) -> HashMap<String, Value> {
1166 verify_inner_with_effects(
1167 proposal,
1168 initial_state,
1169 None,
1170 None,
1171 usize::MAX,
1172 EffectMode::ExecutionFaithful,
1173 )
1174 .simulated_state
1175}
1176
1177pub fn equivalent(
1186 p1: &ActionProposal,
1187 p2: &ActionProposal,
1188 test_states: Option<&[HashMap<String, Value>]>,
1189) -> bool {
1190 let defaults = vec![
1191 HashMap::new(),
1192 [
1193 ("x".to_string(), Value::from(1)),
1194 ("y".to_string(), Value::from(2)),
1195 ]
1196 .into(),
1197 ];
1198 let states = test_states.unwrap_or(&defaults);
1199
1200 for state in states {
1201 let s1 = simulate(p1, Some(state));
1202 let s2 = simulate(p2, Some(state));
1203 if s1 != s2 {
1204 return false;
1205 }
1206 }
1207 true
1208}
1209
1210pub fn optimize(proposal: &ActionProposal) -> ActionProposal {
1212 let mut written_keys = HashSet::new();
1214 for action in &proposal.actions {
1215 if action.action_type == ActionType::StateWrite {
1216 if let Some(k) = action.parameters.get("key").and_then(|v| v.as_str()) {
1217 written_keys.insert(k.to_string());
1218 }
1219 }
1220 for key in action.expected_effects.keys() {
1221 written_keys.insert(key.clone());
1222 }
1223 }
1224
1225 let optimized_actions: Vec<Action> = proposal
1226 .actions
1227 .iter()
1228 .map(|action| {
1229 let pruned: Vec<String> = action
1230 .state_dependencies
1231 .iter()
1232 .filter(|d| written_keys.contains(d.as_str()))
1233 .cloned()
1234 .collect();
1235
1236 if pruned.len() != action.state_dependencies.len() {
1237 let mut new_action = action.clone();
1238 new_action.state_dependencies = pruned;
1239 new_action
1240 } else {
1241 action.clone()
1242 }
1243 })
1244 .collect();
1245
1246 ActionProposal {
1247 id: proposal.id.clone(),
1248 source: proposal.source.clone(),
1249 actions: optimized_actions,
1250 timestamp: proposal.timestamp,
1251 context: proposal.context.clone(),
1252 }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258 use car_ir::Precondition;
1259
1260 fn tool_call(id: &str, tool: &str) -> Action {
1261 {
1262 let mut a = Action::new(ActionType::ToolCall);
1263 a.id = id.to_string();
1264 a.tool = Some(tool.to_string());
1265 a
1266 }
1267 }
1268
1269 fn state_write(id: &str, key: &str, value: Value) -> Action {
1270 {
1271 let mut a = Action::new(ActionType::StateWrite);
1272 a.id = id.to_string();
1273 a.parameters = [
1274 ("key".to_string(), Value::from(key)),
1275 ("value".to_string(), value),
1276 ]
1277 .into();
1278 a
1279 }
1280 }
1281
1282 fn prop(actions: Vec<Action>) -> ActionProposal {
1283 ActionProposal {
1284 id: "test".to_string(),
1285 source: "test".to_string(),
1286 actions,
1287 timestamp: chrono::Utc::now(),
1288 context: HashMap::new(),
1289 }
1290 }
1291
1292 #[test]
1293 fn verify_valid_proposal() {
1294 let p = prop(vec![state_write("a1", "x", Value::from(1)), {
1295 let mut a = tool_call("a2", "search");
1296 a.state_dependencies = vec!["x".to_string()];
1297 a
1298 }]);
1299 let r = verify(&p, None, Some(&["search".to_string()].into()), 30);
1300 assert!(r.valid);
1301 }
1302
1303 fn echo_schema_parameters() -> Value {
1306 serde_json::json!({
1307 "type": "object",
1308 "properties": { "msg": { "type": "string" } },
1309 "required": ["msg"],
1310 })
1311 }
1312
1313 fn schema_map(parameters: Value) -> HashMap<String, ToolSchema> {
1314 [(
1315 "echo".to_string(),
1316 ToolSchema {
1317 name: "echo".to_string(),
1318 source: car_ir::ToolSourceKind::UserDefined,
1319 description: String::new(),
1320 parameters,
1321 returns: None,
1322 idempotent: true,
1323 cache_ttl_secs: None,
1324 rate_limit: None,
1325 },
1326 )]
1327 .into()
1328 }
1329
1330 fn echo_call(params: HashMap<String, Value>) -> ActionProposal {
1331 let mut a = tool_call("a1", "echo");
1332 a.parameters = params;
1333 prop(vec![a])
1334 }
1335
1336 #[test]
1337 fn schema_verify_accepts_well_typed_params() {
1338 let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
1339 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1340 assert!(r.valid, "{:?}", r.issues);
1341 }
1342
1343 #[test]
1344 fn schema_verify_rejects_type_mismatch() {
1345 let p = echo_call([("msg".to_string(), Value::from(42))].into());
1346 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1347 assert!(!r.valid);
1348 assert!(r
1349 .issues
1350 .iter()
1351 .any(|i| i.message.contains("wrong type") && i.message.contains("msg")));
1352 }
1353
1354 #[test]
1355 fn schema_verify_rejects_missing_required() {
1356 let p = echo_call(HashMap::new());
1357 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
1358 assert!(!r.valid);
1359 assert!(
1360 r.issues
1361 .iter()
1362 .any(|i| i.message.contains("missing required parameter")
1363 && i.message.contains("msg"))
1364 );
1365 }
1366
1367 #[test]
1368 fn schema_verify_rejects_unknown_tool() {
1369 let mut a = tool_call("a1", "nope");
1370 a.parameters = [("msg".to_string(), Value::from("hi"))].into();
1371 let r = verify_with_schemas(
1372 &prop(vec![a]),
1373 None,
1374 Some(&schema_map(echo_schema_parameters())),
1375 30,
1376 );
1377 assert!(!r.valid);
1378 assert!(r
1379 .issues
1380 .iter()
1381 .any(|i| i.message.contains("not registered")));
1382 }
1383
1384 #[test]
1385 fn name_only_verify_still_skips_param_validation() {
1386 let p = echo_call([("msg".to_string(), Value::from(42))].into());
1390 let r = verify(&p, None, Some(&["echo".to_string()].into()), 30);
1391 assert!(
1392 r.valid,
1393 "name-only verify must not validate params: {:?}",
1394 r.issues
1395 );
1396 }
1397
1398 #[test]
1399 fn schema_verify_accepts_integer_and_union_types() {
1400 let parameters = serde_json::json!({
1401 "type": "object",
1402 "properties": {
1403 "n": { "type": "integer" },
1404 "maybe": { "type": ["string", "null"] },
1405 },
1406 "required": ["n"],
1407 });
1408 let p = echo_call(
1409 [
1410 ("n".to_string(), Value::from(7)),
1411 ("maybe".to_string(), Value::Null),
1412 ]
1413 .into(),
1414 );
1415 let r = verify_with_schemas(&p, None, Some(&schema_map(parameters)), 30);
1416 assert!(r.valid, "{:?}", r.issues);
1417 }
1418
1419 #[test]
1420 fn schema_verify_empty_schema_imposes_no_constraints() {
1421 let p = echo_call([("anything".to_string(), Value::from(42))].into());
1425 let r = verify_with_schemas(&p, None, Some(&schema_map(serde_json::json!({}))), 30);
1426 assert!(r.valid, "{:?}", r.issues);
1427 }
1428
1429 #[test]
1430 fn verify_catches_unsatisfied_precondition() {
1431 let mut a = tool_call("a1", "deploy");
1432 a.preconditions = vec![Precondition {
1433 key: "tests_passed".to_string(),
1434 operator: "eq".to_string(),
1435 value: Value::Bool(true),
1436 description: String::new(),
1437 }];
1438 let r = verify(&prop(vec![a]), None, None, 30);
1439 assert!(!r.valid);
1440 }
1441
1442 #[test]
1443 fn verify_precondition_satisfied_by_earlier_action() {
1444 let mut a2 = tool_call("a2", "deploy");
1445 a2.preconditions = vec![Precondition {
1446 key: "ready".to_string(),
1447 operator: "eq".to_string(),
1448 value: Value::Bool(true),
1449 description: String::new(),
1450 }];
1451 a2.state_dependencies = vec!["ready".to_string()];
1452
1453 let p = prop(vec![state_write("a1", "ready", Value::Bool(true)), a2]);
1454 let r = verify(&p, None, None, 30);
1455 assert!(r.valid);
1456 }
1457
1458 #[test]
1459 fn verify_missing_state_dependency() {
1460 let mut a = tool_call("a1", "x");
1461 a.state_dependencies = vec!["nonexistent".to_string()];
1462 let r = verify(&prop(vec![a]), None, None, 30);
1463 assert!(!r.valid);
1464 }
1465
1466 #[test]
1467 fn verify_tool_not_registered() {
1468 let a = tool_call("a1", "quantum");
1469 let r = verify(&prop(vec![a]), None, Some(&HashSet::new()), 30);
1470 assert!(!r.valid);
1471 }
1472
1473 #[test]
1474 fn compensation_naming_an_unregistered_tool_is_a_finding() {
1475 let mut a = tool_call("a1", "poll");
1480 a.reversibility = car_ir::Reversibility::Compensable;
1481 a.compensation = Some(car_ir::Compensation::Tool {
1482 tool: "db.delet".into(), parameters: Default::default(),
1484 });
1485 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
1486 assert!(!r.valid);
1487 assert!(r
1488 .issues
1489 .iter()
1490 .any(|i| i.message.contains("compensation names tool 'db.delet'")));
1491
1492 let mut a = tool_call("a1", "poll");
1494 a.reversibility = car_ir::Reversibility::Compensable;
1495 a.compensation = Some(car_ir::Compensation::Tool {
1496 tool: "undo".into(),
1497 parameters: Default::default(),
1498 });
1499 let reg = ["poll".to_string(), "undo".to_string()].into();
1500 assert!(verify(&prop(vec![a]), None, Some(®), 30).valid);
1501 }
1502
1503 #[test]
1504 fn compensation_action_ref_must_resolve_within_the_proposal() {
1505 let mut a = tool_call("a1", "deploy");
1507 a.reversibility = car_ir::Reversibility::Compensable;
1508 a.compensation = Some(car_ir::Compensation::ActionRef {
1509 action_id: "rollback-1".into(),
1510 });
1511 let r = verify(&prop(vec![a.clone()]), None, None, 30);
1512 assert!(!r.valid);
1513 assert!(r
1514 .issues
1515 .iter()
1516 .any(|i| i.message.contains("references action 'rollback-1'")));
1517
1518 let mut undo = tool_call("rollback-1", "rollback");
1520 undo.id = "rollback-1".into();
1521 let r = verify(&prop(vec![a, undo]), None, None, 30);
1522 assert!(
1523 !r.issues
1524 .iter()
1525 .any(|i| i.message.contains("references action")),
1526 "{:?}",
1527 r.issues
1528 );
1529 }
1530
1531 #[test]
1532 fn compensable_with_no_compensation_declared_is_a_finding() {
1533 let mut a = tool_call("a1", "poll");
1534 a.reversibility = car_ir::Reversibility::Compensable;
1535 a.compensation = None;
1536 let r = verify(&prop(vec![a]), None, None, 30);
1537 assert!(!r.valid);
1538 assert!(r.issues.iter().any(|i| i
1539 .message
1540 .contains("declares reversibility 'compensable' but no compensation")));
1541 assert!(r
1543 .issues_with_tier(EvidenceTier::DecisionProcedure)
1544 .iter()
1545 .any(|i| i.message.contains("no compensation")));
1546 let rec = r
1548 .evidence
1549 .checks
1550 .iter()
1551 .find(|c| c.name == "compensation_resolution")
1552 .expect("compensation_resolution check is recorded");
1553 assert!(rec.ran);
1554 assert_eq!(rec.findings, 1);
1555 }
1556
1557 #[test]
1558 fn verify_no_tool_specified() {
1559 let mut a = tool_call("a1", "x");
1560 a.tool = None;
1561 let r = verify(&prop(vec![a]), None, None, 30);
1562 assert!(!r.valid);
1563 }
1564
1565 #[test]
1566 fn detect_write_conflict() {
1567 let p = prop(vec![
1568 state_write("a1", "x", Value::from(1)),
1569 state_write("a2", "x", Value::from(2)),
1570 ]);
1571 let r = verify(&p, None, None, 30);
1572 assert!(!r.conflicts.is_empty());
1573 }
1574
1575 #[test]
1576 fn simulate_state_writes() {
1577 let p = prop(vec![
1578 state_write("a1", "x", Value::from(10)),
1579 state_write("a2", "y", Value::from(20)),
1580 ]);
1581 let s = simulate(&p, None);
1582 assert_eq!(s.get("x"), Some(&Value::from(10)));
1583 assert_eq!(s.get("y"), Some(&Value::from(20)));
1584 }
1585
1586 #[test]
1592 fn simulate_skips_effects_of_a_provably_blocked_action() {
1593 let mut deploy = tool_call("deploy", "deploy");
1594 deploy.preconditions = vec![Precondition {
1595 key: "tests_passed".to_string(),
1596 operator: "eq".to_string(),
1597 value: Value::Bool(true),
1598 description: String::new(),
1599 }];
1600 deploy
1601 .expected_effects
1602 .insert("deployed".to_string(), Value::Bool(true));
1603 let p = prop(vec![deploy]);
1604
1605 let failing: HashMap<String, Value> =
1606 [("tests_passed".to_string(), Value::Bool(false))].into();
1607 let s = simulate(&p, Some(&failing));
1608 assert_eq!(
1609 s.get("deployed"),
1610 None,
1611 "a deploy whose precondition provably fails must not appear deployed: {s:?}"
1612 );
1613
1614 let passing: HashMap<String, Value> =
1616 [("tests_passed".to_string(), Value::Bool(true))].into();
1617 let s = simulate(&p, Some(&passing));
1618 assert_eq!(s.get("deployed"), Some(&Value::Bool(true)));
1619 }
1620
1621 #[test]
1626 fn verify_stays_optimistic_so_it_reports_every_finding() {
1627 let mut deploy = tool_call("deploy", "deploy");
1628 deploy.preconditions = vec![Precondition {
1629 key: "tests_passed".to_string(),
1630 operator: "eq".to_string(),
1631 value: Value::Bool(true),
1632 description: String::new(),
1633 }];
1634 deploy
1635 .expected_effects
1636 .insert("deployed".to_string(), Value::Bool(true));
1637 let mut notify = tool_call("notify", "notify");
1638 notify.state_dependencies = vec!["deployed".to_string()];
1639 let p = prop(vec![deploy, notify]);
1640
1641 let failing: HashMap<String, Value> =
1642 [("tests_passed".to_string(), Value::Bool(false))].into();
1643 let r = verify(&p, Some(&failing), None, 30);
1644
1645 assert!(!r.valid);
1646 assert_eq!(
1650 r.errors().len(),
1651 1,
1652 "expected only the precondition finding, got {:?}",
1653 r.issues
1654 );
1655 assert!(r.issues[0].message.contains("precondition will fail"));
1656 }
1657
1658 #[test]
1661 fn simulate_cascade_follows_data_dependencies() {
1662 let mut build = tool_call("build", "build");
1663 build.preconditions = vec![Precondition {
1664 key: "ready".to_string(),
1665 operator: "eq".to_string(),
1666 value: Value::Bool(true),
1667 description: String::new(),
1668 }];
1669 build
1670 .expected_effects
1671 .insert("artifact".to_string(), Value::from("app.tar.gz"));
1672 let mut deploy = tool_call("deploy", "deploy");
1673 deploy.state_dependencies = vec!["artifact".to_string()];
1674 deploy
1675 .expected_effects
1676 .insert("deployed".to_string(), Value::Bool(true));
1677
1678 let s = simulate(&prop(vec![build, deploy]), None);
1679 assert_eq!(
1680 s.get("artifact"),
1681 None,
1682 "blocked build produced no artifact"
1683 );
1684 assert_eq!(
1685 s.get("deployed"),
1686 None,
1687 "deploy depends on the artifact that never appeared: {s:?}"
1688 );
1689 }
1690
1691 #[test]
1695 fn equivalent_distinguishes_a_gated_proposal_from_an_ungated_one() {
1696 let mut gated = tool_call("a", "deploy");
1697 gated.preconditions = vec![Precondition {
1698 key: "tests_passed".to_string(),
1699 operator: "eq".to_string(),
1700 value: Value::Bool(true),
1701 description: String::new(),
1702 }];
1703 gated
1704 .expected_effects
1705 .insert("deployed".to_string(), Value::Bool(true));
1706
1707 let mut ungated = tool_call("b", "deploy");
1708 ungated
1709 .expected_effects
1710 .insert("deployed".to_string(), Value::Bool(true));
1711
1712 let failing: Vec<HashMap<String, Value>> =
1713 vec![[("tests_passed".to_string(), Value::Bool(false))].into()];
1714 assert!(
1715 !equivalent(&prop(vec![gated]), &prop(vec![ungated]), Some(&failing)),
1716 "a gate that blocks one proposal and not the other is a real difference"
1717 );
1718 }
1719
1720 #[test]
1721 fn equivalent_proposals() {
1722 let p1 = prop(vec![
1723 state_write("a1", "x", Value::from(1)),
1724 state_write("a2", "y", Value::from(2)),
1725 ]);
1726 let p2 = prop(vec![
1727 state_write("b1", "y", Value::from(2)),
1728 state_write("b2", "x", Value::from(1)),
1729 ]);
1730 assert!(equivalent(&p1, &p2, None));
1731 }
1732
1733 #[test]
1734 fn non_equivalent_proposals() {
1735 let p1 = prop(vec![state_write("a1", "x", Value::from(1))]);
1736 let p2 = prop(vec![state_write("b1", "x", Value::from(99))]);
1737 assert!(!equivalent(&p1, &p2, None));
1738 }
1739
1740 #[test]
1741 fn optimize_removes_phantom_deps() {
1742 let mut a = tool_call("a1", "search");
1743 a.state_dependencies = vec!["phantom".to_string()];
1744 let p = prop(vec![a]);
1745 let optimized = optimize(&p);
1746 assert!(optimized.actions[0].state_dependencies.is_empty());
1747 }
1748
1749 #[test]
1750 fn optimize_preserves_real_deps() {
1751 let mut a2 = tool_call("a2", "x");
1752 a2.state_dependencies = vec!["x".to_string()];
1753 let p = prop(vec![state_write("a1", "x", Value::from(1)), a2]);
1754 let optimized = optimize(&p);
1755 assert_eq!(optimized.actions[1].state_dependencies, vec!["x"]);
1756 }
1757
1758 #[test]
1759 fn loop_detection_duplicates() {
1760 let p = prop(vec![tool_call("a1", "search"), tool_call("a2", "search")]);
1761 let r = verify(&p, None, None, 30);
1762 assert!(r.issues.iter().any(|i| i.message.contains("duplicate")));
1763 }
1764
1765 #[test]
1766 fn loop_detection_triple() {
1767 let p = prop(vec![
1768 tool_call("a1", "search"),
1769 tool_call("a2", "search"),
1770 tool_call("a3", "search"),
1771 ]);
1772 let r = verify(&p, None, None, 30);
1773 assert!(!r.valid);
1774 assert!(r.issues.iter().any(|i| i.message.contains("likely loop")));
1775 }
1776
1777 #[test]
1778 fn resource_bounds() {
1779 let actions: Vec<Action> = (0..35)
1780 .map(|i| tool_call(&format!("a{}", i), &format!("t{}", i)))
1781 .collect();
1782 let r = verify(&prop(actions), None, None, 30);
1783 assert!(r.issues.iter().any(|i| i.message.contains("excessive")));
1784 }
1785
1786 #[test]
1797 fn resource_bound_is_exclusive_at_the_limit() {
1798 let at_limit: Vec<Action> = (0..30)
1802 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1803 .collect();
1804 let r = verify(&prop(at_limit), None, None, 30);
1805 assert!(
1806 !r.issues.iter().any(|i| i.message.contains("excessive")),
1807 "exactly max_actions is within the bound"
1808 );
1809
1810 let over: Vec<Action> = (0..31)
1811 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1812 .collect();
1813 let r = verify(&prop(over), None, None, 30);
1814 assert!(
1815 r.issues.iter().any(|i| i.message.contains("excessive")),
1816 "one past max_actions is over it"
1817 );
1818 }
1819
1820 #[test]
1821 fn resource_bound_finding_count_is_recorded() {
1822 let over: Vec<Action> = (0..31)
1826 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
1827 .collect();
1828 let r = verify(&prop(over), None, None, 30);
1829 let rec = r
1830 .evidence
1831 .checks
1832 .iter()
1833 .find(|c| c.name == "resource_bounds")
1834 .expect("resource_bounds is always recorded");
1835 assert_eq!(rec.findings, 1, "exactly one bound was exceeded");
1836
1837 let under = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
1838 let rec = under
1839 .evidence
1840 .checks
1841 .iter()
1842 .find(|c| c.name == "resource_bounds")
1843 .unwrap();
1844 assert_eq!(
1845 rec.findings, 0,
1846 "a proposal inside the bound has no finding"
1847 );
1848 }
1849
1850 #[test]
1851 fn precondition_findings_are_counted_not_just_reported() {
1852 let mut a = tool_call("a1", "t1");
1855 a.preconditions = vec![Precondition {
1856 key: "missing_key".to_string(),
1857 operator: "exists".to_string(),
1858 value: Value::Null,
1859 description: String::new(),
1860 }];
1861 let r = verify(&prop(vec![a]), Some(&HashMap::new()), None, 30);
1862 let rec = r
1863 .evidence
1864 .checks
1865 .iter()
1866 .find(|c| c.name == "preconditions")
1867 .expect("preconditions is recorded whenever an action declares one");
1868 assert_eq!(
1869 rec.findings,
1870 r.issues
1871 .iter()
1872 .filter(|i| i.message.contains("precondition"))
1873 .count(),
1874 "the recorded count must match the issues actually raised"
1875 );
1876 assert!(rec.findings > 0, "an unmet precondition is a finding");
1877 }
1878
1879 #[test]
1880 fn warning_count_drives_the_residual_risk_line() {
1881 let clean = verify(
1885 &prop(vec![state_write("a1", "x", Value::from(1))]),
1886 None,
1887 None,
1888 30,
1889 );
1890 assert!(
1891 !clean
1892 .evidence
1893 .residual_risks
1894 .iter()
1895 .any(|s| s.contains("warning(s)")),
1896 "no warnings means no warning risk line at all"
1897 );
1898
1899 let warned = verify(
1901 &prop(vec![
1902 state_write("a1", "k", Value::from(1)),
1903 state_write("a2", "k", Value::from(2)),
1904 ]),
1905 None,
1906 None,
1907 30,
1908 );
1909 let warnings = warned
1910 .issues
1911 .iter()
1912 .filter(|i| i.severity == "warning")
1913 .count();
1914 assert!(warnings > 0, "the fixture must actually produce a warning");
1915 assert!(
1916 warned
1917 .evidence
1918 .residual_risks
1919 .iter()
1920 .any(|s| s.contains(&format!("{warnings} warning(s)"))),
1921 "the risk line must carry the real warning count"
1922 );
1923 }
1924
1925 #[test]
1926 fn confidence_docks_exactly_once_per_skipped_check() {
1927 let untested_dock =
1936 |r: &VerifyResult| (r.evidence.untested_regions.len() as f64 * 0.02).min(0.25);
1937
1938 let r = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
1940 let expected = 1.0 - 0.15 - 0.20 - untested_dock(&r);
1941 assert!(
1942 (r.evidence.confidence - expected).abs() < 1e-9,
1943 "1.0 - 0.15 (no registry) - 0.20 (no schemas) - untested, want {expected}, got {}",
1944 r.evidence.confidence
1945 );
1946
1947 let r = verify(
1950 &prop(vec![tool_call("a1", "t1")]),
1951 None,
1952 Some(&["t1".to_string()].into()),
1953 30,
1954 );
1955 let expected = 1.0 - 0.20 - untested_dock(&r);
1956 assert!(
1957 (r.evidence.confidence - expected).abs() < 1e-9,
1958 "1.0 - 0.20 (no schemas) - untested, want {expected}, got {}",
1959 r.evidence.confidence
1960 );
1961
1962 let r = verify(
1965 &prop(vec![state_write("a1", "x", Value::from(1))]),
1966 None,
1967 None,
1968 30,
1969 );
1970 assert!(
1971 (r.evidence.confidence - 1.0).abs() < 1e-9,
1972 "pure state writes dock nothing, got {}",
1973 r.evidence.confidence
1974 );
1975 }
1976
1977 #[test]
1978 fn confidence_docks_scale_with_warnings_and_stay_clamped() {
1979 let warned = verify(
1982 &prop(vec![
1983 state_write("a1", "k", Value::from(1)),
1984 state_write("a2", "k", Value::from(2)),
1985 ]),
1986 None,
1987 None,
1988 30,
1989 );
1990 let warnings = warned
1991 .issues
1992 .iter()
1993 .filter(|i| i.severity == "warning")
1994 .count();
1995 let expected = 1.0 - (warnings as f64 * 0.05).min(0.20);
1996 assert!(
1997 (warned.evidence.confidence - expected).abs() < 1e-9,
1998 "{warnings} warning(s) dock 0.05 each, capped at 0.20; got {}",
1999 warned.evidence.confidence
2000 );
2001 assert!(
2002 (0.0..=1.0).contains(&warned.evidence.confidence),
2003 "confidence must stay inside its documented range"
2004 );
2005 }
2006
2007 #[test]
2008 fn the_schema_assumption_needs_both_conditions() {
2009 let r = verify(
2013 &prop(vec![state_write("a1", "x", Value::from(1))]),
2014 None,
2015 None,
2016 30,
2017 );
2018 assert!(
2019 !r.evidence
2020 .assumptions
2021 .iter()
2022 .any(|s| s.contains("tool_call parameters")),
2023 "a proposal with no tool calls assumes nothing about tool_call params"
2024 );
2025
2026 let r = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
2027 assert!(
2028 r.evidence
2029 .assumptions
2030 .iter()
2031 .any(|s| s.contains("tool_call parameters")),
2032 "unchecked tool_call params must be declared as an assumption"
2033 );
2034 }
2035
2036 #[test]
2037 fn loop_detection_finding_count_is_a_delta_not_a_total() {
2038 let mut actions: Vec<Action> = (0..31)
2043 .map(|i| tool_call(&format!("a{i}"), &format!("t{i}")))
2044 .collect();
2045 actions.push(tool_call("dup", "t0")); let r = verify(&prop(actions), None, None, 30);
2047 let bounds = r
2048 .evidence
2049 .checks
2050 .iter()
2051 .find(|c| c.name == "resource_bounds")
2052 .unwrap();
2053 let loops = r
2054 .evidence
2055 .checks
2056 .iter()
2057 .find(|c| c.name == "loop_detection")
2058 .expect("loop_detection is recorded");
2059 assert_eq!(bounds.findings, 1, "one bound exceeded");
2060 assert!(loops.findings > 0, "the duplicate must be found");
2061 assert!(
2062 loops.findings < r.issues.len(),
2063 "loop_detection reports its own findings ({}), not every issue raised ({})",
2064 loops.findings,
2065 r.issues.len()
2066 );
2067 }
2068
2069 #[test]
2070 fn param_schema_finding_count_is_recorded() {
2071 let p = echo_call([("msg".to_string(), Value::from(42))].into());
2074 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
2075 assert!(!r.valid);
2076 let rec = r
2077 .evidence
2078 .checks
2079 .iter()
2080 .find(|c| c.name == "param_schema")
2081 .expect("param_schema is recorded when schemas are supplied");
2082 assert_eq!(
2083 rec.findings,
2084 r.issues
2085 .iter()
2086 .filter(|i| i.message.contains("wrong type"))
2087 .count(),
2088 "the recorded count must match the type errors raised"
2089 );
2090 assert!(rec.findings > 0);
2091 }
2092
2093 #[test]
2094 fn compensation_finding_count_is_recorded() {
2095 let mut a = tool_call("a1", "poll");
2097 a.reversibility = car_ir::Reversibility::Compensable;
2098 a.compensation = Some(car_ir::Compensation::Tool {
2099 tool: "db.delet".into(), parameters: Default::default(),
2101 });
2102 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
2103 let rec = r
2104 .evidence
2105 .checks
2106 .iter()
2107 .find(|c| c.name == "compensation_resolution")
2108 .expect("compensation is recorded when an action declares one");
2109 assert!(
2110 rec.findings > 0,
2111 "an unrunnable rollback plan is a compensation finding"
2112 );
2113
2114 let mut ok = tool_call("a1", "poll");
2117 ok.reversibility = car_ir::Reversibility::Compensable;
2118 ok.compensation = Some(car_ir::Compensation::Tool {
2119 tool: "undo".into(),
2120 parameters: Default::default(),
2121 });
2122 let r = verify(
2123 &prop(vec![ok]),
2124 None,
2125 Some(&["poll".to_string(), "undo".to_string()].into()),
2126 30,
2127 );
2128 let rec = r
2129 .evidence
2130 .checks
2131 .iter()
2132 .find(|c| c.name == "compensation_resolution")
2133 .unwrap();
2134 assert_eq!(rec.findings, 0, "a registered undo is not a finding");
2135 }
2136
2137 #[test]
2138 fn action_ref_compensation_finding_count_is_recorded() {
2139 let mut a = tool_call("a1", "poll");
2143 a.reversibility = car_ir::Reversibility::Compensable;
2144 a.compensation = Some(car_ir::Compensation::ActionRef {
2145 action_id: "nonexistent".into(),
2146 });
2147 let r = verify(&prop(vec![a]), None, Some(&["poll".to_string()].into()), 30);
2148 let rec = r
2149 .evidence
2150 .checks
2151 .iter()
2152 .find(|c| c.name == "compensation_resolution")
2153 .expect("compensation_resolution is recorded");
2154 assert_eq!(
2155 rec.findings,
2156 r.issues
2157 .iter()
2158 .filter(|i| i.message.contains("not in this proposal"))
2159 .count(),
2160 "the count must match the dangling references raised"
2161 );
2162 assert!(rec.findings > 0, "a dangling action ref is a finding");
2163 }
2164
2165 #[test]
2166 fn untested_regions_drive_their_own_residual_risk() {
2167 let clean = verify(
2170 &prop(vec![state_write("a1", "x", Value::from(1))]),
2171 None,
2172 None,
2173 30,
2174 );
2175 assert!(clean.evidence.untested_regions.is_empty());
2176 assert!(
2177 !clean
2178 .evidence
2179 .residual_risks
2180 .iter()
2181 .any(|s| s.contains("runtime tool output")),
2182 "nothing untested means no runtime-output risk line"
2183 );
2184
2185 let dynamic = verify(&prop(vec![tool_call("a1", "t1")]), None, None, 30);
2186 assert!(
2187 !dynamic.evidence.untested_regions.is_empty(),
2188 "a tool call leaves runtime-decided regions"
2189 );
2190 assert!(
2191 dynamic
2192 .evidence
2193 .residual_risks
2194 .iter()
2195 .any(|s| s.contains("runtime tool output")),
2196 "untested regions must surface as a residual risk"
2197 );
2198 }
2199
2200 #[test]
2209 fn evidence_declares_all_check_scopes() {
2210 let p = echo_call([("msg".to_string(), Value::from("hi"))].into());
2211 let r = verify_with_schemas(&p, None, Some(&schema_map(echo_schema_parameters())), 30);
2212 for want in [
2214 "resource_bounds",
2215 "loop_detection",
2216 "preconditions",
2217 "state_dependencies",
2218 "tool_existence",
2219 "param_schema",
2220 "write_conflicts",
2221 ] {
2222 let rec = r
2223 .evidence
2224 .checks
2225 .iter()
2226 .find(|c| c.name == want)
2227 .unwrap_or_else(|| panic!("missing check record {want}"));
2228 assert!(!rec.verifies.is_empty());
2229 assert!(!rec.cannot_verify.is_empty());
2230 }
2231 let by = |n: &str| r.evidence.checks.iter().find(|c| c.name == n).unwrap();
2233 assert!(by("param_schema").ran);
2234 assert!(by("tool_existence").ran);
2235 }
2236
2237 #[test]
2238 fn evidence_marks_param_schema_skipped_without_schemas() {
2239 let p = prop(vec![tool_call("a1", "search")]);
2242 let r = verify(&p, None, None, 30);
2243 let param = r
2244 .evidence
2245 .checks
2246 .iter()
2247 .find(|c| c.name == "param_schema")
2248 .unwrap();
2249 assert!(!param.ran);
2250 assert!(
2251 r.evidence.confidence < 1.0,
2252 "skipped check should dock coverage"
2253 );
2254 assert!(r
2255 .evidence
2256 .assumptions
2257 .iter()
2258 .any(|a| a.contains("well-formed")));
2259 }
2260
2261 #[test]
2262 fn evidence_full_confidence_for_pure_state_writes() {
2263 let p = prop(vec![state_write("a1", "x", Value::from(1))]);
2265 let r = verify(&p, None, None, 30);
2266 assert!(r.valid);
2267 assert_eq!(r.evidence.confidence, 1.0);
2268 assert!(r.evidence.untested_regions.is_empty());
2269 }
2270
2271 #[test]
2272 fn evidence_conflicts_become_residual_risk() {
2273 let p = prop(vec![
2276 state_write("a1", "k", Value::from(1)),
2277 state_write("a2", "k", Value::from(2)),
2278 ]);
2279 let r = verify(&p, None, None, 30);
2280 assert!(r.valid, "conflicts are warnings, not errors");
2281 assert!(!r.conflicts.is_empty());
2282 assert!(r
2283 .evidence
2284 .residual_risks
2285 .iter()
2286 .any(|s| s.contains("write conflict")));
2287 let wc = r
2288 .evidence
2289 .checks
2290 .iter()
2291 .find(|c| c.name == "write_conflicts")
2292 .unwrap();
2293 assert_eq!(wc.findings, r.conflicts.len());
2294 }
2295
2296 #[test]
2297 fn evidence_untested_includes_runtime_set_effect_keys() {
2298 let mut a = tool_call("a1", "fetch");
2302 a.expected_effects = [("out".to_string(), Value::from("placeholder"))].into();
2303 let r = verify(
2304 &prop(vec![a]),
2305 None,
2306 Some(&["fetch".to_string()].into()),
2307 30,
2308 );
2309 assert!(r
2310 .evidence
2311 .untested_regions
2312 .iter()
2313 .any(|s| s.contains("state key 'out'")));
2314 assert!(r
2315 .evidence
2316 .untested_regions
2317 .iter()
2318 .any(|s| s.contains("runtime output of tool 'fetch'")));
2319 }
2320
2321 #[test]
2322 fn evidence_tool_existence_ran_consistent_with_findings() {
2323 let mut a = tool_call("a1", "x");
2327 a.tool = None;
2328 let r = verify(&prop(vec![a]), None, None, 30);
2329 assert!(!r.valid);
2330 let te = r
2331 .evidence
2332 .checks
2333 .iter()
2334 .find(|c| c.name == "tool_existence")
2335 .unwrap();
2336 assert!(te.findings >= 1);
2337 assert!(
2338 te.ran,
2339 "ran must be true whenever the check produced a finding"
2340 );
2341 }
2342
2343 #[test]
2350 fn loop_detection_findings_are_heuristic_and_the_rest_are_not() {
2351 let p = prop(vec![
2352 tool_call("a1", "poll"),
2353 tool_call("a2", "poll"),
2354 tool_call("a3", "poll"),
2355 tool_call("a4", "ghost"),
2356 ]);
2357 let r = verify(&p, None, Some(&["poll".to_string()].into()), 30);
2358
2359 let heuristic = r.issues_with_tier(EvidenceTier::Heuristic);
2360 assert_eq!(
2361 heuristic.len(),
2362 1,
2363 "only the repeated-call finding is heuristic: {:?}",
2364 r.issues
2365 );
2366 assert!(heuristic[0]
2367 .message
2368 .contains("repeated identical tool call"));
2369
2370 let decided = r.issues_with_tier(EvidenceTier::DecisionProcedure);
2372 assert!(decided
2373 .iter()
2374 .any(|i| i.message.contains("'ghost' is not registered")));
2375
2376 assert!(r.issues_with_tier(EvidenceTier::Sampled).is_empty());
2378 }
2379
2380 #[test]
2392 fn check_records_and_issues_agree_on_tier() {
2393 let p = prop(vec![
2394 tool_call("a1", "poll"),
2397 tool_call("a2", "poll"),
2398 {
2401 let mut a = tool_call("a3", "ghost");
2402 a.state_dependencies = vec!["missing".to_string()];
2403 a
2404 },
2405 state_write("a4", "x", Value::from(1)),
2408 state_write("a5", "x", Value::from(2)),
2409 ]);
2410 let r = verify(&p, None, Some(&["poll".to_string()].into()), 30);
2411
2412 for tier in [
2417 EvidenceTier::DecisionProcedure,
2418 EvidenceTier::Heuristic,
2419 EvidenceTier::Sampled,
2420 ] {
2421 let declared: usize = r
2422 .evidence
2423 .checks
2424 .iter()
2425 .filter(|c| c.tier == tier)
2426 .map(|c| c.findings)
2427 .sum();
2428 let actual = r.issues_with_tier(tier).len();
2429 assert_eq!(
2430 declared,
2431 actual,
2432 "checks at tier {} declare {declared} findings but {actual} issues carry it: {:?}",
2433 tier.as_str(),
2434 r.issues
2435 );
2436 }
2437
2438 let total: usize = r.evidence.checks.iter().map(|c| c.findings).sum();
2441 assert_eq!(total, r.issues.len(), "unaccounted issues: {:?}", r.issues);
2442
2443 assert_eq!(
2446 r.issues_with_tier(EvidenceTier::Heuristic).len(),
2447 1,
2448 "expected exactly the duplicate-call finding: {:?}",
2449 r.issues
2450 );
2451 assert!(
2452 r.issues_with_tier(EvidenceTier::DecisionProcedure).len() >= 3,
2453 "expected the unregistered tool, the missing dependency, and the \
2454 write conflict: {:?}",
2455 r.issues
2456 );
2457 }
2458
2459 #[test]
2462 fn tier_serializes_as_stable_snake_case() {
2463 let p = prop(vec![tool_call("a1", "ghost")]);
2464 let r = verify(&p, None, Some(&HashSet::new()), 30);
2465 let json = serde_json::to_value(&r.issues[0]).expect("issue serializes");
2466 assert_eq!(json["tier"], Value::from("decision_procedure"));
2467 assert_eq!(
2468 json["tier"],
2469 Value::from(r.issues[0].tier.as_str()),
2470 "as_str and the serde representation must not drift"
2471 );
2472 }
2473}