use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentOp {
pub id: String,
#[serde(default)]
pub agent: String,
#[serde(default)]
pub read_set: Vec<String>,
#[serde(default)]
pub write_set: Vec<String>,
#[serde(default)]
pub tools_read: Vec<String>,
#[serde(default)]
pub tools_written: Vec<String>,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub read_at: u64,
#[serde(default)]
pub commit_at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConcurrencyAnomaly {
StaleGeneration,
PhantomTool,
CausalCascade,
ToolEffectReorder,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnomalyFinding {
pub anomaly: ConcurrencyAnomaly,
pub key: String,
pub ops: Vec<String>,
pub explanation: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsistencyLevel {
L0,
L1,
L2,
L3,
L4,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConcurrencyReport {
pub level: ConsistencyLevel,
pub serializable: bool,
pub anomalies: Vec<AnomalyFinding>,
}
fn windows_overlap(a: &AgentOp, b: &AgentOp) -> bool {
a.read_at <= b.commit_at && b.read_at <= a.commit_at
}
fn causal_ancestors(ops: &[AgentOp]) -> Vec<HashSet<String>> {
use std::collections::HashMap;
let index: HashMap<&str, usize> = ops
.iter()
.enumerate()
.map(|(i, o)| (o.id.as_str(), i))
.collect();
let n = ops.len();
let mut ancestors: Vec<HashSet<String>> = vec![HashSet::new(); n];
let mut changed = true;
while changed {
changed = false;
for i in 0..n {
for dep in &ops[i].depends_on {
if ancestors[i].insert(dep.clone()) {
changed = true;
}
if let Some(&di) = index.get(dep.as_str()) {
let dep_anc: Vec<String> = ancestors[di].iter().cloned().collect();
for a in dep_anc {
if ancestors[i].insert(a) {
changed = true;
}
}
}
}
}
}
ancestors
}
pub fn analyze(ops: &[AgentOp]) -> ConcurrencyReport {
let mut anomalies = Vec::new();
let ancestors = causal_ancestors(ops);
let idx_of = |id: &str| ops.iter().position(|o| o.id == id);
for (i, o) in ops.iter().enumerate() {
let reads: HashSet<&String> = o.read_set.iter().collect();
let writes_i: HashSet<&String> = o.write_set.iter().collect();
let tools_read: HashSet<&String> = o.tools_read.iter().collect();
for (j, w) in ops.iter().enumerate() {
if i == j {
continue;
}
for key in writes_i.intersection(&reads) {
if w.write_set.contains(*key)
&& o.read_at < w.commit_at
&& w.commit_at < o.commit_at
{
anomalies.push(AnomalyFinding {
anomaly: ConcurrencyAnomaly::StaleGeneration,
key: (*key).clone(),
ops: vec![o.id.clone(), w.id.clone()],
explanation: format!(
"op '{}' read '{}', generated, then committed a write based on a value \
op '{}' overwrote in between (lost update)",
o.id, key, w.id
),
});
}
}
for tool in tools_read.iter() {
if w.tools_written.contains(*tool)
&& o.read_at < w.commit_at
&& w.commit_at < o.commit_at
{
anomalies.push(AnomalyFinding {
anomaly: ConcurrencyAnomaly::PhantomTool,
key: (*tool).clone(),
ops: vec![o.id.clone(), w.id.clone()],
explanation: format!(
"op '{}' consulted tool '{}' during generate, but op '{}' changed the \
registry entry mid-window (phantom tool)",
o.id, tool, w.id
),
});
}
}
}
for dep in &o.depends_on {
if let Some(di) = idx_of(dep) {
if ops[di].commit_at > o.commit_at {
anomalies.push(AnomalyFinding {
anomaly: ConcurrencyAnomaly::CausalCascade,
key: dep.clone(),
ops: vec![o.id.clone(), dep.clone()],
explanation: format!(
"op '{}' causally depends on '{}' but committed before it \
(causality violated)",
o.id, dep
),
});
}
}
}
}
for i in 0..ops.len() {
for j in (i + 1)..ops.len() {
if ancestors[i].contains(&ops[j].id) || ancestors[j].contains(&ops[i].id) {
continue;
}
if !windows_overlap(&ops[i], &ops[j]) {
continue;
}
let wi: HashSet<&String> = ops[i].write_set.iter().collect();
for key in wi.intersection(&ops[j].write_set.iter().collect()) {
anomalies.push(AnomalyFinding {
anomaly: ConcurrencyAnomaly::ToolEffectReorder,
key: (*key).clone(),
ops: vec![ops[i].id.clone(), ops[j].id.clone()],
explanation: format!(
"ops '{}' and '{}' concurrently write '{}' with no causal ordering — \
their effects land in a nondeterministic order (reorder)",
ops[i].id, ops[j].id, key
),
});
}
}
}
let level = classify(&anomalies);
ConcurrencyReport {
serializable: matches!(level, ConsistencyLevel::L4),
level,
anomalies,
}
}
fn classify(anomalies: &[AnomalyFinding]) -> ConsistencyLevel {
let has = |a: ConcurrencyAnomaly| anomalies.iter().any(|f| f.anomaly == a);
if has(ConcurrencyAnomaly::CausalCascade) {
ConsistencyLevel::L0
} else if has(ConcurrencyAnomaly::StaleGeneration) {
ConsistencyLevel::L1
} else if has(ConcurrencyAnomaly::PhantomTool) {
ConsistencyLevel::L2
} else if has(ConcurrencyAnomaly::ToolEffectReorder) {
ConsistencyLevel::L3
} else {
ConsistencyLevel::L4
}
}
fn anomaly_level(a: ConcurrencyAnomaly) -> ConsistencyLevel {
match a {
ConcurrencyAnomaly::CausalCascade => ConsistencyLevel::L0,
ConcurrencyAnomaly::StaleGeneration => ConsistencyLevel::L1,
ConcurrencyAnomaly::PhantomTool => ConsistencyLevel::L2,
ConcurrencyAnomaly::ToolEffectReorder => ConsistencyLevel::L3,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Remediation {
RereadAndRegenerate { op: String, key: String },
PinToolRegistry { op: String, tool: String },
EnforceCausalOrder { dependent: String, cause: String },
SerializeWriters { ops: Vec<String>, key: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Disposition {
AutoRemediate,
RequireApproval,
Abort,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GatedRemediation {
pub anomaly: ConcurrencyAnomaly,
pub remediation: Remediation,
pub disposition: Disposition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConcurrencyGatePolicy {
pub abort_at_or_below: ConsistencyLevel,
pub require_approval_at_or_below: ConsistencyLevel,
}
impl Default for ConcurrencyGatePolicy {
fn default() -> Self {
Self {
abort_at_or_below: ConsistencyLevel::L0,
require_approval_at_or_below: ConsistencyLevel::L1,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConcurrencyGate {
pub safe: bool,
pub level: ConsistencyLevel,
pub abort: bool,
pub remediations: Vec<GatedRemediation>,
}
fn remediation_for(f: &AnomalyFinding) -> Remediation {
match f.anomaly {
ConcurrencyAnomaly::StaleGeneration => Remediation::RereadAndRegenerate {
op: f.ops.first().cloned().unwrap_or_default(),
key: f.key.clone(),
},
ConcurrencyAnomaly::PhantomTool => Remediation::PinToolRegistry {
op: f.ops.first().cloned().unwrap_or_default(),
tool: f.key.clone(),
},
ConcurrencyAnomaly::CausalCascade => Remediation::EnforceCausalOrder {
dependent: f.ops.first().cloned().unwrap_or_default(),
cause: f.ops.get(1).cloned().unwrap_or_default(),
},
ConcurrencyAnomaly::ToolEffectReorder => Remediation::SerializeWriters {
ops: f.ops.clone(),
key: f.key.clone(),
},
}
}
pub fn gate_concurrency(
report: &ConcurrencyReport,
policy: &ConcurrencyGatePolicy,
) -> ConcurrencyGate {
let mut remediations = Vec::new();
let mut abort = false;
for f in &report.anomalies {
let sev = anomaly_level(f.anomaly);
let disposition = if sev <= policy.abort_at_or_below {
abort = true;
Disposition::Abort
} else if sev <= policy.require_approval_at_or_below {
Disposition::RequireApproval
} else {
Disposition::AutoRemediate
};
remediations.push(GatedRemediation {
anomaly: f.anomaly,
remediation: remediation_for(f),
disposition,
});
}
ConcurrencyGate {
safe: report.anomalies.is_empty(),
level: report.level,
abort,
remediations,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn op(id: &str, read_at: u64, commit_at: u64) -> AgentOp {
AgentOp {
id: id.to_string(),
read_at,
commit_at,
..Default::default()
}
}
#[test]
fn clean_schedule_is_serializable_l4() {
let mut a = op("a", 0, 1);
a.read_set = vec!["x".into()];
a.write_set = vec!["x".into()];
let mut b = op("b", 2, 3);
b.read_set = vec!["y".into()];
b.write_set = vec!["y".into()];
let r = analyze(&[a, b]);
assert!(r.serializable);
assert_eq!(r.level, ConsistencyLevel::L4);
assert!(r.anomalies.is_empty());
}
#[test]
fn stale_generation_is_l1() {
let mut a = op("a", 0, 2);
a.read_set = vec!["k".into()];
a.write_set = vec!["k".into()];
let mut b = op("b", 1, 1);
b.write_set = vec!["k".into()];
let r = analyze(&[a, b]);
assert_eq!(r.level, ConsistencyLevel::L1);
assert!(r
.anomalies
.iter()
.any(|f| f.anomaly == ConcurrencyAnomaly::StaleGeneration));
}
#[test]
fn phantom_tool_is_l2() {
let mut a = op("a", 0, 5);
a.tools_read = vec!["search".into()];
let mut b = op("b", 1, 2);
b.tools_written = vec!["search".into()];
let r = analyze(&[a, b]);
assert_eq!(r.level, ConsistencyLevel::L2);
assert!(r
.anomalies
.iter()
.any(|f| f.anomaly == ConcurrencyAnomaly::PhantomTool));
}
#[test]
fn causal_cascade_is_l0_and_dominates() {
let mut c = op("c", 0, 5);
c.read_set = vec!["k".into()];
c.write_set = vec!["k".into()];
let mut d = op("d", 0, 1);
d.depends_on = vec!["c".into()];
let mut e = op("e", 1, 2); e.write_set = vec!["k".into()];
let r = analyze(&[c, d, e]);
assert_eq!(r.level, ConsistencyLevel::L0);
assert!(r
.anomalies
.iter()
.any(|f| f.anomaly == ConcurrencyAnomaly::CausalCascade));
}
#[test]
fn tool_effect_reorder_is_l3() {
let mut a = op("a", 0, 3);
a.write_set = vec!["k".into()];
let mut b = op("b", 1, 4);
b.write_set = vec!["k".into()];
let r = analyze(&[a, b]);
assert_eq!(r.level, ConsistencyLevel::L3);
assert!(r
.anomalies
.iter()
.any(|f| f.anomaly == ConcurrencyAnomaly::ToolEffectReorder));
}
#[test]
fn causal_order_suppresses_reorder() {
let mut a = op("a", 0, 3);
a.write_set = vec!["k".into()];
let mut b = op("b", 1, 4);
b.write_set = vec!["k".into()];
b.depends_on = vec!["a".into()];
let r = analyze(&[a, b]);
assert!(r
.anomalies
.iter()
.all(|f| f.anomaly != ConcurrencyAnomaly::ToolEffectReorder));
assert_eq!(r.level, ConsistencyLevel::L4);
}
#[test]
fn non_overlapping_writers_are_not_a_reorder() {
let mut a = op("a", 0, 1);
a.write_set = vec!["k".into()];
let mut b = op("b", 2, 3); b.write_set = vec!["k".into()];
let r = analyze(&[a, b]);
assert_eq!(r.level, ConsistencyLevel::L4);
}
#[test]
fn gate_clean_report_is_safe_no_remediation() {
let report = ConcurrencyReport {
level: ConsistencyLevel::L4,
serializable: true,
anomalies: vec![],
};
let g = gate_concurrency(&report, &ConcurrencyGatePolicy::default());
assert!(g.safe);
assert!(!g.abort);
assert!(g.remediations.is_empty());
}
#[test]
fn gate_stale_requires_approval_by_default() {
let mut a = op("a", 0, 2);
a.read_set = vec!["k".into()];
a.write_set = vec!["k".into()];
let mut b = op("b", 1, 1);
b.write_set = vec!["k".into()];
let report = analyze(&[a, b]);
let g = gate_concurrency(&report, &ConcurrencyGatePolicy::default());
assert!(!g.abort);
let r = &g.remediations[0];
assert_eq!(r.anomaly, ConcurrencyAnomaly::StaleGeneration);
assert_eq!(r.disposition, Disposition::RequireApproval);
assert!(matches!(
r.remediation,
Remediation::RereadAndRegenerate { .. }
));
}
#[test]
fn gate_causal_cascade_aborts() {
let mut c = op("c", 0, 5);
let mut d = op("d", 0, 1);
d.depends_on = vec!["c".into()];
c.write_set = vec!["k".into()];
let report = analyze(&[c, d]);
let g = gate_concurrency(&report, &ConcurrencyGatePolicy::default());
assert!(g.abort);
assert!(g
.remediations
.iter()
.any(|r| r.disposition == Disposition::Abort
&& matches!(r.remediation, Remediation::EnforceCausalOrder { .. })));
}
#[test]
fn gate_reorder_auto_remediates() {
let mut a = op("a", 0, 3);
a.write_set = vec!["k".into()];
let mut b = op("b", 1, 4);
b.write_set = vec!["k".into()];
let report = analyze(&[a, b]);
let g = gate_concurrency(&report, &ConcurrencyGatePolicy::default());
assert!(!g.abort);
let r = &g.remediations[0];
assert_eq!(r.disposition, Disposition::AutoRemediate);
assert!(matches!(r.remediation, Remediation::SerializeWriters { .. }));
}
#[test]
fn strict_policy_escalates_reorder_to_approval() {
let mut a = op("a", 0, 3);
a.write_set = vec!["k".into()];
let mut b = op("b", 1, 4);
b.write_set = vec!["k".into()];
let report = analyze(&[a, b]);
let policy = ConcurrencyGatePolicy {
abort_at_or_below: ConsistencyLevel::L0,
require_approval_at_or_below: ConsistencyLevel::L3,
};
let g = gate_concurrency(&report, &policy);
assert_eq!(g.remediations[0].disposition, Disposition::RequireApproval);
}
}