use crate::dag::{ordered, transitive_ancestors};
use car_ir::{Action, ActionProposal, StateAssumption};
use serde::Serialize;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictKind {
WriteWrite,
ReadWrite,
StaleAssumption,
}
fn values_equal(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(fx), Some(fy)) => fx == fy,
_ => x == y,
},
_ => a == b,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TransactionConflict {
pub kind: ConflictKind,
pub key: String,
pub actions: Vec<String>,
pub explanation: String,
pub resolution: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransactionReport {
pub consistent: bool,
pub conflicts: Vec<TransactionConflict>,
}
impl TransactionReport {
pub fn conflicts_of(&self, kind: ConflictKind) -> impl Iterator<Item = &TransactionConflict> {
self.conflicts.iter().filter(move |c| c.kind == kind)
}
}
pub fn check_transaction(
proposal: &ActionProposal,
current_versions: &HashMap<String, u64>,
current_state: Option<&HashMap<String, Value>>,
) -> TransactionReport {
let mut conflicts = Vec::new();
let actions = &proposal.actions;
let writes: Vec<HashSet<String>> = actions
.iter()
.map(|a| a.effective_write_set().into_iter().collect())
.collect();
let reads: Vec<HashSet<String>> = actions
.iter()
.map(|a| a.effective_read_set().into_iter().collect())
.collect();
let ancestors = transitive_ancestors(actions);
for i in 0..actions.len() {
for j in (i + 1)..actions.len() {
if ordered(i, j, &ancestors) {
continue; }
for key in writes[i].intersection(&writes[j]) {
conflicts.push(TransactionConflict {
kind: ConflictKind::WriteWrite,
key: key.clone(),
actions: vec![actions[i].id.clone(), actions[j].id.clone()],
explanation: format!(
"actions '{}' and '{}' both write '{}' with no ordering dependency — last-writer-wins is nondeterministic",
actions[i].id, actions[j].id, key
),
resolution: "declare an ordering (add the writer's key to the other action's state_dependencies), split into distinct keys, or define a semantic merge for this key".to_string(),
});
}
for key in reads[i].intersection(&writes[j]) {
conflicts.push(TransactionConflict {
kind: ConflictKind::ReadWrite,
key: key.clone(),
actions: vec![actions[i].id.clone(), actions[j].id.clone()],
explanation: format!(
"action '{}' reads '{}' while '{}' writes it, unordered — the read may observe the pre- or post-write value",
actions[i].id, key, actions[j].id
),
resolution: format!(
"sequence the reader after the writer (add '{}' to '{}'.state_dependencies) if the fresh value is intended, or before it if the prior value is",
key, actions[i].id
),
});
}
for key in reads[j].intersection(&writes[i]) {
conflicts.push(TransactionConflict {
kind: ConflictKind::ReadWrite,
key: key.clone(),
actions: vec![actions[j].id.clone(), actions[i].id.clone()],
explanation: format!(
"action '{}' reads '{}' while '{}' writes it, unordered — the read may observe the pre- or post-write value",
actions[j].id, key, actions[i].id
),
resolution: format!(
"sequence the reader after the writer (add '{}' to '{}'.state_dependencies) if the fresh value is intended, or before it if the prior value is",
key, actions[j].id
),
});
}
}
}
for action in actions {
for assumption in &action.assumptions {
if let Some(conflict) =
check_assumption(action, assumption, current_versions, current_state)
{
conflicts.push(conflict);
}
}
}
TransactionReport {
consistent: conflicts.is_empty(),
conflicts,
}
}
pub fn check_transaction_with_predictions(
proposal: &ActionProposal,
current_versions: &HashMap<String, u64>,
current_state: Option<&HashMap<String, Value>>,
predicted_writes: &HashMap<String, Vec<String>>,
) -> TransactionReport {
let mut augmented = proposal.clone();
for action in &mut augmented.actions {
if let Some(extra) = predicted_writes.get(&action.id) {
for key in extra {
if !action.write_set.contains(key) {
action.write_set.push(key.clone());
}
}
}
}
check_transaction(&augmented, current_versions, current_state)
}
fn check_assumption(
action: &Action,
assumption: &StateAssumption,
current_versions: &HashMap<String, u64>,
current_state: Option<&HashMap<String, Value>>,
) -> Option<TransactionConflict> {
if let Some(read_version) = assumption.read_version {
let current = current_versions.get(&assumption.key).copied();
if current != Some(read_version) {
return Some(TransactionConflict {
kind: ConflictKind::StaleAssumption,
key: assumption.key.clone(),
actions: vec![action.id.clone()],
explanation: format!(
"action '{}' planned against '{}' at version {} but the shared state is now at {} — the plan is based on a stale read",
action.id,
assumption.key,
read_version,
current.map(|v| v.to_string()).unwrap_or_else(|| "absent".to_string())
),
resolution: "re-verify the action against current state and reconcile its belief (re-plan from the new version) before executing".to_string(),
});
}
}
if let (Some(expected), Some(state)) = (&assumption.expected_value, current_state) {
let current = state.get(&assumption.key);
if !current.map(|c| values_equal(c, expected)).unwrap_or(false) {
return Some(TransactionConflict {
kind: ConflictKind::StaleAssumption,
key: assumption.key.clone(),
actions: vec![action.id.clone()],
explanation: format!(
"action '{}' assumes '{}' == {} but the shared state holds {} — belief divergence",
action.id,
assumption.key,
expected,
current.map(|v| v.to_string()).unwrap_or_else(|| "absent".to_string())
),
resolution: "reconcile the assumption against the current value (semantic merge or re-plan); do not execute on the stale belief".to_string(),
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{ActionType, FailureBehavior};
fn act(id: &str, ty: ActionType) -> Action {
Action {
id: id.to_string(),
action_type: ty,
tool: None,
parameters: HashMap::new(),
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
invocation_mode: Default::default(),
idempotent: false,
max_retries: 3,
failure_behavior: FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
fn prop(actions: Vec<Action>) -> ActionProposal {
ActionProposal {
id: "p".to_string(),
source: "test".to_string(),
actions,
timestamp: chrono::Utc::now(),
context: HashMap::new(),
}
}
#[test]
fn detects_write_write_race() {
let mut a = act("a", ActionType::ToolCall);
a.write_set = vec!["k".to_string()];
let mut b = act("b", ActionType::ToolCall);
b.write_set = vec!["k".to_string()];
let r = check_transaction(&prop(vec![a, b]), &HashMap::new(), None);
assert!(!r.consistent);
assert_eq!(r.conflicts_of(ConflictKind::WriteWrite).count(), 1);
}
#[test]
fn predicted_writes_surface_undeclared_conflict() {
let a = act("a", ActionType::ToolCall);
let b = act("b", ActionType::ToolCall);
let p = prop(vec![a, b]);
assert!(check_transaction(&p, &HashMap::new(), None).consistent);
let predicted: HashMap<String, Vec<String>> = [
("a".to_string(), vec!["k".to_string()]),
("b".to_string(), vec!["k".to_string()]),
]
.into();
let r = check_transaction_with_predictions(&p, &HashMap::new(), None, &predicted);
assert!(!r.consistent);
assert_eq!(r.conflicts_of(ConflictKind::WriteWrite).count(), 1);
}
#[test]
fn no_predictions_matches_plain_check() {
let mut a = act("a", ActionType::ToolCall);
a.write_set = vec!["k".to_string()];
let b = act("b", ActionType::ToolCall);
let p = prop(vec![a, b]);
let empty: HashMap<String, Vec<String>> = HashMap::new();
assert_eq!(
check_transaction_with_predictions(&p, &HashMap::new(), None, &empty).consistent,
check_transaction(&p, &HashMap::new(), None).consistent
);
}
#[test]
fn declared_ordering_suppresses_write_write() {
let mut a = act("a", ActionType::ToolCall);
a.expected_effects = [
("x".to_string(), Value::from(1)),
("k".to_string(), Value::from(1)),
]
.into();
let mut b = act("b", ActionType::ToolCall);
b.state_dependencies = vec!["x".to_string()];
b.expected_effects = [("k".to_string(), Value::from(2))].into();
let r = check_transaction(&prop(vec![a, b]), &HashMap::new(), None);
assert!(r.consistent, "{:?}", r.conflicts);
}
#[test]
fn write_set_ordering_is_not_a_dag_signal_so_race_stands() {
let mut a = act("a", ActionType::ToolCall);
a.write_set = vec!["k".to_string()];
let mut b = act("b", ActionType::ToolCall);
b.write_set = vec!["k".to_string()];
b.state_dependencies = vec!["k".to_string()];
let r = check_transaction(&prop(vec![a, b]), &HashMap::new(), None);
assert_eq!(r.conflicts_of(ConflictKind::WriteWrite).count(), 1);
}
#[test]
fn transitive_ordering_suppresses_distant_write_write() {
let mut a = act("a", ActionType::ToolCall);
a.expected_effects = [
("x".to_string(), Value::from(1)),
("k".to_string(), Value::from(1)),
]
.into();
let mut c = act("c", ActionType::ToolCall);
c.state_dependencies = vec!["x".to_string()];
c.expected_effects = [("y".to_string(), Value::from(1))].into();
let mut b = act("b", ActionType::ToolCall);
b.state_dependencies = vec!["y".to_string()];
b.expected_effects = [("k".to_string(), Value::from(2))].into();
let r = check_transaction(&prop(vec![a, c, b]), &HashMap::new(), None);
assert!(
r.consistent,
"transitively ordered, not a race: {:?}",
r.conflicts
);
}
#[test]
fn detects_read_write_hazard() {
let mut reader = act("reader", ActionType::ToolCall);
reader.read_set = vec!["k".to_string()];
let mut writer = act("writer", ActionType::ToolCall);
writer.write_set = vec!["k".to_string()];
let r = check_transaction(&prop(vec![reader, writer]), &HashMap::new(), None);
assert_eq!(r.conflicts_of(ConflictKind::ReadWrite).count(), 1);
}
#[test]
fn detects_stale_version_assumption() {
let mut a = act("a", ActionType::ToolCall);
a.assumptions = vec![StateAssumption {
key: "config".to_string(),
expected_value: None,
read_version: Some(3),
}];
let versions = [("config".to_string(), 4u64)].into();
let r = check_transaction(&prop(vec![a]), &versions, None);
assert!(!r.consistent);
assert_eq!(r.conflicts_of(ConflictKind::StaleAssumption).count(), 1);
}
#[test]
fn matching_version_assumption_is_consistent() {
let mut a = act("a", ActionType::ToolCall);
a.assumptions = vec![StateAssumption {
key: "config".to_string(),
expected_value: None,
read_version: Some(4),
}];
let versions = [("config".to_string(), 4u64)].into();
let r = check_transaction(&prop(vec![a]), &versions, None);
assert!(r.consistent);
}
#[test]
fn detects_stale_value_assumption() {
let mut a = act("a", ActionType::ToolCall);
a.assumptions = vec![StateAssumption {
key: "mode".to_string(),
expected_value: Some(Value::from("draft")),
read_version: None,
}];
let state = [("mode".to_string(), Value::from("published"))].into();
let r = check_transaction(&prop(vec![a]), &HashMap::new(), Some(&state));
assert_eq!(r.conflicts_of(ConflictKind::StaleAssumption).count(), 1);
}
#[test]
fn numeric_assumption_int_float_equal() {
let mut a = act("a", ActionType::ToolCall);
a.assumptions = vec![StateAssumption {
key: "n".to_string(),
expected_value: Some(Value::from(1)),
read_version: None,
}];
let state = [("n".to_string(), serde_json::json!(1.0))].into();
let r = check_transaction(&prop(vec![a]), &HashMap::new(), Some(&state));
assert!(r.consistent, "1 == 1.0: {:?}", r.conflicts);
}
#[test]
fn union_write_set_does_not_shadow_expected_effects() {
let mut x = act("x", ActionType::ToolCall);
x.write_set = vec!["a".to_string()];
x.expected_effects = [("b".to_string(), Value::from(1))].into();
let mut y = act("y", ActionType::ToolCall);
y.write_set = vec!["b".to_string()];
let r = check_transaction(&prop(vec![x, y]), &HashMap::new(), None);
assert_eq!(r.conflicts_of(ConflictKind::WriteWrite).count(), 1);
}
#[test]
fn derived_write_set_from_expected_effects() {
let mut a = act("a", ActionType::ToolCall);
a.expected_effects = [("k".to_string(), Value::from(1))].into();
let mut b = act("b", ActionType::ToolCall);
b.expected_effects = [("k".to_string(), Value::from(2))].into();
let r = check_transaction(&prop(vec![a, b]), &HashMap::new(), None);
assert_eq!(r.conflicts_of(ConflictKind::WriteWrite).count(), 1);
}
}