use crate::decoder::{detect_syndromes, Syndrome, SyndromeType};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalItem {
pub id: String,
#[serde(default)]
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalEdge {
pub source: String,
pub target: String,
pub edge_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContradictionCase {
pub id: String,
#[serde(default)]
pub query: String,
pub items: Vec<EvalItem>,
#[serde(default)]
pub candidate_contradictions: Vec<(String, String)>,
#[serde(default)]
pub graph_edges: Vec<EvalEdge>,
pub expected_conflicts: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ContradictionMetrics {
pub true_positives: usize,
pub false_positives: usize,
pub false_negatives: usize,
pub precision: f64,
pub recall: f64,
pub f1: f64,
}
impl ContradictionMetrics {
pub fn from_counts(tp: usize, fp: usize, fn_: usize) -> Self {
let precision = if tp + fp == 0 {
if fn_ == 0 { 1.0 } else { 0.0 }
} else {
tp as f64 / (tp + fp) as f64
};
let recall = if tp + fn_ == 0 {
1.0
} else {
tp as f64 / (tp + fn_) as f64
};
let f1 = if precision + recall == 0.0 {
0.0
} else {
2.0 * precision * recall / (precision + recall)
};
Self {
true_positives: tp,
false_positives: fp,
false_negatives: fn_,
precision,
recall,
f1,
}
}
}
fn norm_pair(a: &str, b: &str) -> (String, String) {
if a <= b {
(a.to_string(), b.to_string())
} else {
(b.to_string(), a.to_string())
}
}
pub fn prf(
detected: &HashSet<(String, String)>,
expected: &HashSet<(String, String)>,
) -> ContradictionMetrics {
let tp = detected.intersection(expected).count();
let fp = detected.len() - tp;
let fn_ = expected.len() - tp;
ContradictionMetrics::from_counts(tp, fp, fn_)
}
fn is_conflict(t: SyndromeType) -> bool {
matches!(
t,
SyndromeType::DirectContradiction
| SyndromeType::TemporalConflict
| SyndromeType::SourceConflict
)
}
pub fn detected_conflict_pairs(syndromes: &[Syndrome]) -> HashSet<(String, String)> {
let mut pairs = HashSet::new();
for s in syndromes {
if !is_conflict(s.syndrome_type) {
continue;
}
for i in 0..s.items.len() {
for j in (i + 1)..s.items.len() {
pairs.insert(norm_pair(&s.items[i], &s.items[j]));
}
}
}
pairs
}
pub const CONTRADICTION_EDGE_TYPES: [&str; 2] = ["contradicts", "contradicted_by"];
pub fn is_contradiction_edge(edge_type: &str) -> bool {
CONTRADICTION_EDGE_TYPES.contains(&edge_type)
}
pub fn derive_candidates(scope: &HashSet<String>, edges: &[EvalEdge]) -> Vec<(String, String)> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for e in edges {
if !is_contradiction_edge(&e.edge_type) {
continue;
}
if !scope.contains(&e.source) || !scope.contains(&e.target) {
continue;
}
if seen.insert(norm_pair(&e.source, &e.target)) {
out.push((e.source.clone(), e.target.clone()));
}
}
out
}
pub fn run_case(case: &ContradictionCase) -> ContradictionMetrics {
let results: Vec<(String, f64)> = case.items.iter().map(|i| (i.id.clone(), 1.0)).collect();
let syndromes = detect_syndromes(&results, &case.candidate_contradictions);
let detected = detected_conflict_pairs(&syndromes);
let expected: HashSet<(String, String)> = case
.expected_conflicts
.iter()
.map(|(a, b)| norm_pair(a, b))
.collect();
prf(&detected, &expected)
}
pub fn run_case_from_edges(case: &ContradictionCase) -> ContradictionMetrics {
let scope: HashSet<String> = case.items.iter().map(|i| i.id.clone()).collect();
let candidates = derive_candidates(&scope, &case.graph_edges);
let results: Vec<(String, f64)> = case.items.iter().map(|i| (i.id.clone(), 1.0)).collect();
let syndromes = detect_syndromes(&results, &candidates);
let detected = detected_conflict_pairs(&syndromes);
let expected: HashSet<(String, String)> = case
.expected_conflicts
.iter()
.map(|(a, b)| norm_pair(a, b))
.collect();
prf(&detected, &expected)
}
pub fn run_case_from_content(
case: &ContradictionCase,
cfg: &crate::contradiction_detect::DetectorConfig,
) -> ContradictionMetrics {
let items: Vec<(String, String)> = case
.items
.iter()
.map(|i| (i.id.clone(), i.content.clone()))
.collect();
let detected: HashSet<(String, String)> = crate::contradiction_detect::detect_contradictions(&items, cfg)
.iter()
.map(|p| norm_pair(&p.a, &p.b))
.collect();
let expected: HashSet<(String, String)> = case
.expected_conflicts
.iter()
.map(|(a, b)| norm_pair(a, b))
.collect();
prf(&detected, &expected)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContradictionReport {
pub per_case: Vec<(String, ContradictionMetrics)>,
pub micro: ContradictionMetrics,
pub macro_f1: f64,
}
pub fn run_suite(cases: &[ContradictionCase]) -> ContradictionReport {
let mut per_case = Vec::with_capacity(cases.len());
let (mut tp, mut fp, mut fn_) = (0usize, 0usize, 0usize);
let mut f1_sum = 0.0;
for case in cases {
let m = run_case(case);
tp += m.true_positives;
fp += m.false_positives;
fn_ += m.false_negatives;
f1_sum += m.f1;
per_case.push((case.id.clone(), m));
}
let macro_f1 = if cases.is_empty() {
0.0
} else {
f1_sum / cases.len() as f64
};
ContradictionReport {
micro: ContradictionMetrics::from_counts(tp, fp, fn_),
macro_f1,
per_case,
}
}
pub fn builtin_cases() -> Vec<ContradictionCase> {
let item = |id: &str, c: &str| EvalItem {
id: id.to_string(),
content: c.to_string(),
};
let edge = |s: &str, t: &str, et: &str| EvalEdge {
source: s.to_string(),
target: t.to_string(),
edge_type: et.to_string(),
};
vec![
ContradictionCase {
id: "clean-no-conflict".to_string(),
query: "what port does the warm server use".to_string(),
items: vec![
item("fact:a1", "The warm server listens on port 1739 by default."),
item("fact:a2", "The warm HTTP server is co-hosted by the MCP server."),
],
candidate_contradictions: vec![],
graph_edges: vec![edge("fact:a1", "fact:a2", "relates_to")],
expected_conflicts: vec![],
},
ContradictionCase {
id: "single-direct-conflict".to_string(),
query: "how many tools does the server expose".to_string(),
items: vec![
item("fact:b1", "The MCP server exposes 33 tools."),
item("fact:b2", "The MCP server exposes 12 tools."),
item("fact:b3", "The server is built with the rmcp Rust SDK."),
],
candidate_contradictions: vec![("fact:b1".to_string(), "fact:b2".to_string())],
graph_edges: vec![
edge("fact:b1", "fact:b2", "contradicts"),
edge("fact:b1", "fact:b3", "relates_to"), ],
expected_conflicts: vec![("fact:b1".to_string(), "fact:b2".to_string())],
},
ContradictionCase {
id: "noisy-one-real-conflict".to_string(),
query: "which embedder is the default".to_string(),
items: vec![
item("fact:c1", "The default embedder is Candle (in-process, CPU)."),
item("fact:c2", "The default embedder is Ollama."),
item("fact:c3", "Candle downloads nomic-embed-text on first use."),
],
candidate_contradictions: vec![
("fact:c1".to_string(), "fact:c2".to_string()),
("fact:c1".to_string(), "fact:c3".to_string()),
],
graph_edges: vec![
edge("fact:c1", "fact:c2", "contradicts"),
edge("fact:c1", "fact:c3", "relates_to"),
],
expected_conflicts: vec![("fact:c1".to_string(), "fact:c2".to_string())],
},
]
}
pub fn cases_to_jsonl(cases: &[ContradictionCase]) -> String {
cases
.iter()
.map(|c| serde_json::to_string(c).unwrap_or_default())
.collect::<Vec<_>>()
.join("\n")
}
pub fn cases_from_jsonl(jsonl: &str) -> Result<Vec<ContradictionCase>, serde_json::Error> {
jsonl
.lines()
.filter(|l| !l.trim().is_empty())
.map(serde_json::from_str)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn set(pairs: &[(&str, &str)]) -> HashSet<(String, String)> {
pairs.iter().map(|(a, b)| norm_pair(a, b)).collect()
}
#[test]
fn prf_perfect_match() {
let d = set(&[("a", "b"), ("c", "d")]);
let e = set(&[("b", "a"), ("d", "c")]); let m = prf(&d, &e);
assert_eq!(m.true_positives, 2);
assert_eq!(m.false_positives, 0);
assert_eq!(m.false_negatives, 0);
assert!((m.f1 - 1.0).abs() < 1e-9);
}
#[test]
fn prf_partial() {
let d = set(&[("a", "b"), ("x", "y")]); let e = set(&[("a", "b"), ("c", "d")]); let m = prf(&d, &e);
assert_eq!(m.true_positives, 1);
assert_eq!(m.false_positives, 1);
assert_eq!(m.false_negatives, 1);
assert!((m.precision - 0.5).abs() < 1e-9);
assert!((m.recall - 0.5).abs() < 1e-9);
assert!((m.f1 - 0.5).abs() < 1e-9);
}
#[test]
fn prf_empty_is_perfect() {
let m = prf(&HashSet::new(), &HashSet::new());
assert!((m.f1 - 1.0).abs() < 1e-9);
assert!((m.precision - 1.0).abs() < 1e-9);
assert!((m.recall - 1.0).abs() < 1e-9);
}
#[test]
fn clean_case_scores_perfect() {
let cases = builtin_cases();
let clean = cases.iter().find(|c| c.id == "clean-no-conflict").unwrap();
let m = run_case(clean);
assert_eq!(m.false_positives, 0);
assert!((m.f1 - 1.0).abs() < 1e-9);
}
#[test]
fn single_conflict_detected() {
let cases = builtin_cases();
let c = cases.iter().find(|c| c.id == "single-direct-conflict").unwrap();
let m = run_case(c);
assert_eq!(m.true_positives, 1);
assert_eq!(m.false_negatives, 0);
}
#[test]
fn suite_aggregates() {
let report = run_suite(&builtin_cases());
assert_eq!(report.per_case.len(), 3);
assert_eq!(report.micro.false_negatives, 0);
assert!(report.macro_f1 > 0.0);
}
#[test]
fn jsonl_roundtrip() {
let cases = builtin_cases();
let jsonl = cases_to_jsonl(&cases);
let back = cases_from_jsonl(&jsonl).unwrap();
assert_eq!(back.len(), cases.len());
assert_eq!(back[1].id, cases[1].id);
assert_eq!(back[1].expected_conflicts, cases[1].expected_conflicts);
}
#[test]
fn derive_candidates_filters_type_and_scope() {
let scope: HashSet<String> =
["x", "y", "z"].iter().map(|s| s.to_string()).collect();
let mk = |s: &str, t: &str, et: &str| EvalEdge {
source: s.to_string(),
target: t.to_string(),
edge_type: et.to_string(),
};
let edges = vec![
mk("x", "y", "contradicts"), mk("x", "z", "relates_to"), mk("x", "w", "contradicts"), mk("y", "x", "contradicted_by"), ];
let cands = derive_candidates(&scope, &edges);
assert_eq!(cands.len(), 1, "only the in-scope contradiction, de-duped");
assert_eq!(norm_pair(&cands[0].0, &cands[0].1), norm_pair("x", "y"));
}
#[test]
fn edge_path_scores_single_conflict() {
let cases = builtin_cases();
let c = cases.iter().find(|c| c.id == "single-direct-conflict").unwrap();
let m = run_case_from_edges(c);
assert_eq!(m.true_positives, 1);
assert_eq!(m.false_positives, 0, "the relates_to distractor must not count");
assert_eq!(m.false_negatives, 0);
}
#[test]
fn content_detector_scores_suite() {
use crate::contradiction_detect::DetectorConfig;
let cfg = DetectorConfig::default();
let cases = builtin_cases();
let (mut tp, mut fp, mut fn_) = (0usize, 0usize, 0usize);
for c in &cases {
let m = run_case_from_content(c, &cfg);
tp += m.true_positives;
fp += m.false_positives;
fn_ += m.false_negatives;
}
assert_eq!(tp, 2, "both real conflicts detected from content");
assert_eq!(fp, 0, "no benign pair flagged");
assert_eq!(fn_, 0, "nothing missed");
}
#[test]
fn edge_path_is_cleaner_than_candidate_path_on_noisy_case() {
let cases = builtin_cases();
let c = cases.iter().find(|c| c.id == "noisy-one-real-conflict").unwrap();
let edge_m = run_case_from_edges(c);
assert_eq!(edge_m.false_positives, 0, "edge predicate drops the benign c1/c3");
assert!((edge_m.f1 - 1.0).abs() < 1e-9);
}
}