use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SyndromeSeverity {
Info,
Warning,
Error,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SyndromeType {
DirectContradiction,
TemporalConflict,
SourceConflict,
MissingLink,
OrphanReference,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Syndrome {
pub id: String,
pub severity: SyndromeSeverity,
pub items: Vec<String>,
pub description: String,
pub syndrome_type: SyndromeType,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CorrectionOperation {
MarkSuperseded { item_id: String, reason: String },
MarkContradicted { item_id: String, by_item_id: String },
AddGraphEdge {
from: String,
to: String,
edge_type: String,
},
QuarantineItem { item_id: String, reason: String },
FlagForReview { item_id: String, reason: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Correction {
pub id: String,
pub syndrome_ids: Vec<String>,
pub operations: Vec<CorrectionOperation>,
pub confidence: f64,
pub cost: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Hyperedge {
pub id: String,
pub source: String,
pub targets: Vec<String>,
pub edge_type: String,
pub weight: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RefutationResult {
pub correction_id: String,
pub perturbations_tested: usize,
pub broken_by: Vec<String>,
pub is_robust: bool,
}
pub fn detect_syndromes(
results: &[(String, f64)],
contradictions: &[(String, String)],
) -> Vec<Syndrome> {
let mut syndromes = Vec::new();
let result_ids: std::collections::HashSet<&String> = results.iter().map(|(id, _)| id).collect();
for (idx, (a, b)) in contradictions.iter().enumerate() {
if result_ids.contains(a) || result_ids.contains(b) {
syndromes.push(Syndrome {
id: format!("syn-direct-{idx}"),
severity: SyndromeSeverity::Error,
items: vec![a.clone(), b.clone()],
description: format!("Direct contradiction between {a} and {b}"),
syndrome_type: SyndromeType::DirectContradiction,
});
}
}
let mut sorted: Vec<&(String, f64)> = results.iter().collect();
sorted.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap_or(std::cmp::Ordering::Equal));
for window in sorted.windows(2) {
let (id_a, sc_a) = window[0];
let (id_b, sc_b) = window[1];
if (sc_a - sc_b).abs() < 0.1 && id_a != id_b {
syndromes.push(Syndrome {
id: format!("syn-missing-{}-{}", id_a, id_b),
severity: SyndromeSeverity::Warning,
items: vec![id_a.clone(), id_b.clone()],
description: format!("Expected link between close-scoring items {id_a} and {id_b}"),
syndrome_type: SyndromeType::MissingLink,
});
}
}
for (id, _) in results {
if let Some(target) = id.strip_prefix("ref_") {
if !result_ids.contains(&target.to_string()) {
syndromes.push(Syndrome {
id: format!("syn-orphan-{id}"),
severity: SyndromeSeverity::Warning,
items: vec![id.clone()],
description: format!("Item {id} references {target} which is not present"),
syndrome_type: SyndromeType::OrphanReference,
});
}
}
}
for (idx, (id, score)) in results.iter().enumerate() {
if *score <= 0.0 {
syndromes.push(Syndrome {
id: format!("syn-temporal-{idx}"),
severity: SyndromeSeverity::Critical,
items: vec![id.clone()],
description: format!("Item {id} has non-positive score {score} (temporal suspect)"),
syndrome_type: SyndromeType::TemporalConflict,
});
}
}
{
let mut seen: HashMap<&String, Vec<f64>> = HashMap::new();
for (id, score) in results {
seen.entry(id).or_default().push(*score);
}
for (id, scores) in &seen {
if scores.len() > 1 && scores.iter().any(|s| s != &scores[0]) {
syndromes.push(Syndrome {
id: format!("syn-source-{id}"),
severity: SyndromeSeverity::Error,
items: vec![(**id).clone()],
description: format!("Item {id} appears with differing scores: {:?}", scores),
syndrome_type: SyndromeType::SourceConflict,
});
}
}
}
syndromes
}
pub fn compute_correction(syndromes: &[Syndrome], max_cost: f64) -> Vec<Correction> {
if syndromes.is_empty() {
return Vec::new();
}
let n = syndromes.len();
let mut corrections = Vec::new();
if n < 20 {
let total = 1usize << n;
let mut idx = 0usize;
for mask in 1..total {
let subset: Vec<&Syndrome> = (0..n)
.filter(|&i| mask & (1 << i) != 0)
.map(|i| &syndromes[i])
.collect();
let cost = subset.len() as f64 * 0.5;
if cost > max_cost {
continue;
}
let confidence = 1.0 / (subset.len() as f64);
let operations =
subset
.iter()
.flat_map(|s| s.items.iter())
.fold(Vec::new(), |mut acc, item| {
acc.push(CorrectionOperation::FlagForReview {
item_id: item.clone(),
reason: "syndrome resolution".to_string(),
});
acc
});
corrections.push(Correction {
id: format!("corr-{idx}"),
syndrome_ids: subset.iter().map(|s| s.id.clone()).collect(),
operations,
confidence,
cost,
});
idx += 1;
}
} else {
for (idx, s) in syndromes.iter().enumerate() {
let cost = 0.5_f64.min(max_cost);
if cost > max_cost {
continue;
}
corrections.push(Correction {
id: format!("corr-{idx}"),
syndrome_ids: vec![s.id.clone()],
operations: s
.items
.iter()
.map(|item| CorrectionOperation::FlagForReview {
item_id: item.clone(),
reason: "syndrome resolution".to_string(),
})
.collect(),
confidence: 1.0,
cost,
});
}
let agg_cost = (n as f64) * 0.25;
if agg_cost <= max_cost {
corrections.push(Correction {
id: format!("corr-agg-{n}"),
syndrome_ids: syndromes.iter().map(|s| s.id.clone()).collect(),
operations: syndromes
.iter()
.flat_map(|s| s.items.iter())
.map(|item| CorrectionOperation::FlagForReview {
item_id: item.clone(),
reason: "aggregated correction".to_string(),
})
.collect(),
confidence: 0.5,
cost: agg_cost,
});
}
}
corrections.sort_by(|a, b| {
a.cost
.partial_cmp(&b.cost)
.unwrap_or(std::cmp::Ordering::Equal)
});
corrections
}
pub fn promote_to_hyperedges(
edges: &[(String, String, String)],
min_targets: usize,
) -> Vec<Hyperedge> {
if edges.is_empty() || min_targets == 0 {
return Vec::new();
}
let mut by_source: HashMap<String, (Vec<String>, String)> = HashMap::new();
for (from, to, edge_type) in edges {
let entry = by_source
.entry(from.clone())
.or_insert_with(|| (Vec::new(), edge_type.clone()));
if !entry.0.contains(to) {
entry.0.push(to.clone());
}
}
let mut hyperedges: Vec<Hyperedge> = by_source
.into_iter()
.filter_map(|(source, (targets, edge_type))| {
if targets.len() >= min_targets {
let weight = targets.len() as f64;
Some(Hyperedge {
id: format!("hyper-{source}"),
source,
targets,
edge_type,
weight,
})
} else {
None
}
})
.collect();
hyperedges.sort_by(|a, b| a.source.cmp(&b.source));
hyperedges
}
pub fn refute_correction(
correction: &Correction,
items: &[String],
max_perturbations: usize,
) -> RefutationResult {
let tested = items.len().min(max_perturbations);
let mut broken_by = Vec::new();
for item in items.iter().take(tested) {
let remaining: Vec<&CorrectionOperation> = correction
.operations
.iter()
.filter(|op| match op {
CorrectionOperation::MarkSuperseded { item_id, .. } => item_id != item,
CorrectionOperation::MarkContradicted {
item_id,
by_item_id,
} => item_id != item && by_item_id != item,
CorrectionOperation::AddGraphEdge { from, to, .. } => from != item && to != item,
CorrectionOperation::QuarantineItem { item_id, .. } => item_id != item,
CorrectionOperation::FlagForReview { item_id, .. } => item_id != item,
})
.collect();
if remaining.is_empty() && !correction.operations.is_empty() {
broken_by.push(item.clone());
}
}
let is_robust = broken_by.is_empty();
RefutationResult {
correction_id: correction.id.clone(),
perturbations_tested: tested,
broken_by,
is_robust,
}
}
#[derive(Debug, Clone)]
pub struct ConflictNode {
pub item_id: String,
pub initial_confidence: f64,
pub neighbors: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ConflictGraph {
pub nodes: HashMap<String, ConflictNode>,
}
impl ConflictGraph {
pub fn from_syndromes(results: &[(String, f64)], syndromes: &[Syndrome]) -> Self {
let mut nodes: HashMap<String, ConflictNode> = HashMap::new();
for (item_id, score) in results {
nodes.insert(
item_id.clone(),
ConflictNode {
item_id: item_id.clone(),
initial_confidence: *score,
neighbors: Vec::new(),
},
);
}
for syndrome in syndromes {
let items = &syndrome.items;
for i in 0..items.len() {
for j in (i + 1)..items.len() {
if !nodes.contains_key(&items[i]) {
nodes.insert(
items[i].clone(),
ConflictNode {
item_id: items[i].clone(),
initial_confidence: 0.5,
neighbors: Vec::new(),
},
);
}
if !nodes.contains_key(&items[j]) {
nodes.insert(
items[j].clone(),
ConflictNode {
item_id: items[j].clone(),
initial_confidence: 0.5,
neighbors: Vec::new(),
},
);
}
if let Some(node) = nodes.get_mut(&items[i]) {
node.neighbors.push(items[j].clone());
}
if let Some(node) = nodes.get_mut(&items[j]) {
node.neighbors.push(items[i].clone());
}
}
}
}
Self { nodes }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessagePassingResult {
pub node_confidences: HashMap<String, f64>,
pub iterations: usize,
pub converged: bool,
pub elapsed_ms: u64,
}
pub fn pass_messages(
graph: &ConflictGraph,
max_iterations: usize,
convergence_threshold: f64,
) -> MessagePassingResult {
let start = std::time::Instant::now();
let mut current: HashMap<String, f64> = graph
.nodes
.iter()
.map(|(id, node)| (id.clone(), node.initial_confidence))
.collect();
let mut iterations = 0;
let mut converged = false;
for iter in 0..max_iterations {
iterations = iter + 1;
let mut max_delta: f64 = 0.0;
let mut next: HashMap<String, f64> = HashMap::new();
for (id, node) in &graph.nodes {
let current_conf = *current.get(id).unwrap_or(&node.initial_confidence);
let neighbor_msgs: Vec<f64> = node
.neighbors
.iter()
.map(|n| *current.get(n).unwrap_or(&0.5))
.collect();
if neighbor_msgs.is_empty() {
next.insert(id.clone(), node.initial_confidence);
} else {
let avg_neighbor: f64 =
neighbor_msgs.iter().sum::<f64>() / neighbor_msgs.len() as f64;
let new_conf = 0.7 * node.initial_confidence + 0.3 * avg_neighbor;
let new_conf = new_conf.clamp(0.0, 1.0);
let delta = (new_conf - current_conf).abs();
if delta > max_delta {
max_delta = delta;
}
next.insert(id.clone(), new_conf);
}
}
current = next;
if max_delta < convergence_threshold {
converged = true;
break;
}
}
MessagePassingResult {
node_confidences: current,
iterations,
converged,
elapsed_ms: start.elapsed().as_millis() as u64,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_results() -> Vec<(String, f64)> {
vec![
("item-a".to_string(), 0.95),
("item-b".to_string(), 0.90),
("item-c".to_string(), 0.50),
]
}
#[test]
fn detects_direct_contradiction() {
let results = sample_results();
let contradictions = vec![("item-a".to_string(), "item-b".to_string())];
let syndromes = detect_syndromes(&results, &contradictions);
let direct: Vec<&Syndrome> = syndromes
.iter()
.filter(|s| s.syndrome_type == SyndromeType::DirectContradiction)
.collect();
assert_eq!(direct.len(), 1);
assert_eq!(
direct[0].items,
vec!["item-a".to_string(), "item-b".to_string()]
);
assert_eq!(direct[0].severity, SyndromeSeverity::Error);
}
#[test]
fn detects_missing_link() {
let results = vec![("x".to_string(), 0.80), ("y".to_string(), 0.79)];
let syndromes = detect_syndromes(&results, &[]);
let missing: Vec<&Syndrome> = syndromes
.iter()
.filter(|s| s.syndrome_type == SyndromeType::MissingLink)
.collect();
assert!(
!missing.is_empty(),
"should detect a missing link between close-scoring items"
);
}
#[test]
fn detects_orphan_reference() {
let results = vec![("ref_missing".to_string(), 0.5)];
let syndromes = detect_syndromes(&results, &[]);
let orphan: Vec<&Syndrome> = syndromes
.iter()
.filter(|s| s.syndrome_type == SyndromeType::OrphanReference)
.collect();
assert_eq!(orphan.len(), 1);
assert_eq!(orphan[0].items, vec!["ref_missing".to_string()]);
}
#[test]
fn detects_temporal_conflict() {
let results = vec![("neg".to_string(), -0.2), ("zero".to_string(), 0.0)];
let syndromes = detect_syndromes(&results, &[]);
let temporal: Vec<&Syndrome> = syndromes
.iter()
.filter(|s| s.syndrome_type == SyndromeType::TemporalConflict)
.collect();
assert_eq!(temporal.len(), 2);
assert_eq!(temporal[0].severity, SyndromeSeverity::Critical);
}
#[test]
fn detects_source_conflict() {
let results = vec![("dup".to_string(), 0.5), ("dup".to_string(), 0.9)];
let syndromes = detect_syndromes(&results, &[]);
let source: Vec<&Syndrome> = syndromes
.iter()
.filter(|s| s.syndrome_type == SyndromeType::SourceConflict)
.collect();
assert_eq!(source.len(), 1);
assert_eq!(source[0].severity, SyndromeSeverity::Error);
}
#[test]
fn computes_correction_for_small_set() {
let syndromes = vec![
Syndrome {
id: "s1".to_string(),
severity: SyndromeSeverity::Error,
items: vec!["a".to_string()],
description: "d1".to_string(),
syndrome_type: SyndromeType::DirectContradiction,
},
Syndrome {
id: "s2".to_string(),
severity: SyndromeSeverity::Warning,
items: vec!["b".to_string()],
description: "d2".to_string(),
syndrome_type: SyndromeType::MissingLink,
},
];
let corrections = compute_correction(&syndromes, 10.0);
assert!(!corrections.is_empty());
for w in corrections.windows(2) {
assert!(w[0].cost <= w[1].cost);
}
}
#[test]
fn correction_respects_max_cost() {
let syndromes = vec![
Syndrome {
id: "s1".to_string(),
severity: SyndromeSeverity::Error,
items: vec!["a".to_string()],
description: "d".to_string(),
syndrome_type: SyndromeType::DirectContradiction,
};
10
];
let corrections = compute_correction(&syndromes, 2.0);
for c in &corrections {
assert!(c.cost <= 2.0, "cost {} exceeds max 2.0", c.cost);
}
}
#[test]
fn promotes_hyperedges_when_min_targets_met() {
let edges = vec![
("src".to_string(), "t1".to_string(), "related".to_string()),
("src".to_string(), "t2".to_string(), "related".to_string()),
("src".to_string(), "t3".to_string(), "related".to_string()),
("other".to_string(), "u1".to_string(), "related".to_string()),
];
let hyperedges = promote_to_hyperedges(&edges, 3);
assert_eq!(hyperedges.len(), 1);
assert_eq!(hyperedges[0].source, "src");
assert_eq!(hyperedges[0].targets.len(), 3);
assert!((hyperedges[0].weight - 3.0).abs() < f64::EPSILON);
}
#[test]
fn hyperedge_promotion_skips_below_threshold() {
let edges = vec![
("src".to_string(), "t1".to_string(), "r".to_string()),
("src".to_string(), "t2".to_string(), "r".to_string()),
];
let hyperedges = promote_to_hyperedges(&edges, 5);
assert!(hyperedges.is_empty());
}
#[test]
fn refutation_robust_correction_survives() {
let correction = Correction {
id: "c1".to_string(),
syndrome_ids: vec!["s1".to_string()],
operations: vec![
CorrectionOperation::FlagForReview {
item_id: "a".to_string(),
reason: "r".to_string(),
},
CorrectionOperation::FlagForReview {
item_id: "b".to_string(),
reason: "r".to_string(),
},
],
confidence: 0.9,
cost: 1.0,
};
let items = vec!["a".to_string(), "b".to_string()];
let result = refute_correction(&correction, &items, 10);
assert!(result.is_robust);
assert!(result.broken_by.is_empty());
assert_eq!(result.perturbations_tested, 2);
}
#[test]
fn refutation_fragile_correction_breaks() {
let correction = Correction {
id: "c1".to_string(),
syndrome_ids: vec!["s1".to_string()],
operations: vec![CorrectionOperation::FlagForReview {
item_id: "only".to_string(),
reason: "r".to_string(),
}],
confidence: 0.9,
cost: 1.0,
};
let items = vec!["only".to_string()];
let result = refute_correction(&correction, &items, 10);
assert!(!result.is_robust);
assert_eq!(result.broken_by, vec!["only".to_string()]);
}
#[test]
fn empty_inputs_produce_empty_outputs() {
assert!(detect_syndromes(&[], &[]).is_empty());
assert!(compute_correction(&[], 10.0).is_empty());
assert!(promote_to_hyperedges(&[], 1).is_empty());
let correction = Correction {
id: "c".to_string(),
syndrome_ids: vec![],
operations: vec![],
confidence: 1.0,
cost: 0.0,
};
let result = refute_correction(&correction, &[], 5);
assert_eq!(result.perturbations_tested, 0);
assert!(result.is_robust);
}
#[test]
fn large_syndrome_set_uses_greedy() {
let syndromes: Vec<Syndrome> = (0..30)
.map(|i| Syndrome {
id: format!("s{i}"),
severity: SyndromeSeverity::Warning,
items: vec![format!("item-{i}")],
description: "d".to_string(),
syndrome_type: SyndromeType::MissingLink,
})
.collect();
let corrections = compute_correction(&syndromes, 100.0);
assert_eq!(corrections.len(), 31);
for w in corrections.windows(2) {
assert!(w[0].cost <= w[1].cost);
}
}
#[test]
fn confidence_threshold_in_corrections() {
let syndromes = vec![
Syndrome {
id: "s1".to_string(),
severity: SyndromeSeverity::Error,
items: vec!["a".to_string(), "b".to_string()],
description: "d".to_string(),
syndrome_type: SyndromeType::DirectContradiction,
},
Syndrome {
id: "s2".to_string(),
severity: SyndromeSeverity::Warning,
items: vec!["c".to_string()],
description: "d".to_string(),
syndrome_type: SyndromeType::MissingLink,
},
];
let corrections = compute_correction(&syndromes, 10.0);
for c in &corrections {
assert!(
c.confidence > 0.0 && c.confidence <= 1.0,
"confidence {} out of range",
c.confidence
);
}
}
#[test]
fn message_passing_converges_with_no_syndromes() {
let results = sample_results();
let syndromes: Vec<Syndrome> = vec![];
let graph = ConflictGraph::from_syndromes(&results, &syndromes);
let mp = pass_messages(&graph, 100, 0.001);
assert!(mp.converged, "should converge with no contradictions");
for (id, node) in &graph.nodes {
let conf = mp.node_confidences.get(id).unwrap();
assert!(
(conf - node.initial_confidence).abs() < 0.001,
"node {id} should keep initial confidence"
);
}
}
#[test]
fn message_passing_pulls_contradicted_nodes_down() {
let results = vec![("clean".to_string(), 0.9), ("conflicted".to_string(), 0.9)];
let syndromes = vec![Syndrome {
id: "s1".to_string(),
severity: SyndromeSeverity::Error,
items: vec!["conflicted".to_string(), "clean".to_string()],
description: "contradiction".to_string(),
syndrome_type: SyndromeType::DirectContradiction,
}];
let graph = ConflictGraph::from_syndromes(&results, &syndromes);
let mp = pass_messages(&graph, 100, 0.001);
let clean_conf = mp.node_confidences.get("clean").unwrap();
let conflicted_conf = mp.node_confidences.get("conflicted").unwrap();
assert!(
*clean_conf <= 0.91,
"clean node should not increase beyond initial"
);
}
#[test]
fn message_passing_unequal_initial_beliefs_converge() {
let results = vec![("high".to_string(), 0.95), ("low".to_string(), 0.30)];
let syndromes = vec![Syndrome {
id: "s1".to_string(),
severity: SyndromeSeverity::Error,
items: vec!["high".to_string(), "low".to_string()],
description: "contradiction".to_string(),
syndrome_type: SyndromeType::DirectContradiction,
}];
let graph = ConflictGraph::from_syndromes(&results, &syndromes);
let mp = pass_messages(&graph, 100, 0.001);
let high_conf = mp.node_confidences.get("high").unwrap();
let low_conf = mp.node_confidences.get("low").unwrap();
assert!(*high_conf < 0.95, "high should decrease toward low");
assert!(*low_conf > 0.30, "low should increase toward high");
}
#[test]
fn message_passing_max_iterations_stops_without_convergence() {
let results = vec![("a".to_string(), 0.99), ("b".to_string(), 0.01)];
let syndromes = vec![Syndrome {
id: "s1".to_string(),
severity: SyndromeSeverity::Error,
items: vec!["a".to_string(), "b".to_string()],
description: "contradiction".to_string(),
syndrome_type: SyndromeType::DirectContradiction,
}];
let graph = ConflictGraph::from_syndromes(&results, &syndromes);
let mp = pass_messages(&graph, 1, 0.0001);
assert!(
!mp.converged,
"should not converge in 1 iteration with large initial delta"
);
assert_eq!(mp.iterations, 1);
}
#[test]
fn message_passing_empty_graph() {
let graph = ConflictGraph {
nodes: HashMap::new(),
};
let mp = pass_messages(&graph, 10, 0.001);
assert!(mp.converged, "empty graph should converge immediately");
assert_eq!(mp.iterations, 1);
assert!(mp.node_confidences.is_empty());
}
#[test]
fn conflict_graph_builds_from_results_only() {
let results = sample_results();
let graph = ConflictGraph::from_syndromes(&results, &[]);
assert_eq!(graph.nodes.len(), 3, "should have 3 nodes from results");
for node in graph.nodes.values() {
assert!(node.neighbors.is_empty(), "no neighbors without syndromes");
}
}
#[test]
fn conflict_graph_adds_nodes_from_syndromes() {
let results = vec![("a".to_string(), 0.9)];
let syndromes = vec![Syndrome {
id: "s1".to_string(),
severity: SyndromeSeverity::Error,
items: vec!["a".to_string(), "b".to_string()],
description: "contradiction".to_string(),
syndrome_type: SyndromeType::DirectContradiction,
}];
let graph = ConflictGraph::from_syndromes(&results, &syndromes);
assert_eq!(graph.nodes.len(), 2);
assert!(
graph.nodes.contains_key("b"),
"syndrome item should be added as node"
);
assert!(graph
.nodes
.get("a")
.unwrap()
.neighbors
.contains(&"b".to_string()));
assert!(graph
.nodes
.get("b")
.unwrap()
.neighbors
.contains(&"a".to_string()));
}
}