use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ContradictionSignal {
NumericDisagreement,
ValueDisagreement,
NegationPolarity,
Antonym,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedPair {
pub a: String,
pub b: String,
pub score: f64,
pub signals: Vec<ContradictionSignal>,
pub reason: String,
}
#[derive(Debug, Clone, Copy)]
pub struct DetectorConfig {
pub min_overlap: f64,
pub min_subject_overlap: f64,
}
impl Default for DetectorConfig {
fn default() -> Self {
Self {
min_overlap: 0.6,
min_subject_overlap: 0.6,
}
}
}
const STOPWORDS: &[&str] = &[
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "of", "to", "in", "on",
"by", "with", "and", "or", "for", "it", "its", "this", "that", "these", "those", "as", "at",
"from", "into", "via", "per", "but", "not", "no", "than", "then", "so", "such",
];
fn is_stopword(w: &str) -> bool {
STOPWORDS.contains(&w)
}
fn tokens(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(|t| t.to_lowercase())
.collect()
}
fn is_number_token(t: &str) -> bool {
t.parse::<f64>().is_ok()
}
fn content_words(s: &str) -> HashSet<String> {
tokens(s)
.into_iter()
.filter(|t| t.len() >= 2 && !is_stopword(t) && !is_number_token(t))
.collect()
}
fn numbers(s: &str) -> HashSet<String> {
s.split(|c: char| !(c.is_ascii_digit() || c == '.'))
.filter(|t| !t.is_empty() && t.parse::<f64>().is_ok())
.map(|t| t.to_string())
.collect()
}
fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
if a.is_empty() && b.is_empty() {
return 0.0;
}
let inter = a.intersection(b).count() as f64;
let union = a.union(b).count() as f64;
if union == 0.0 {
0.0
} else {
inter / union
}
}
const NEGATIONS: &[&str] = &["not", "no", "never", "cannot", "without", "n't", "isn", "aren", "doesn"];
fn has_negation(s: &str) -> bool {
let toks = tokens(s);
toks.iter().any(|t| NEGATIONS.contains(&t.as_str()))
|| s.to_lowercase().contains("n't")
}
const ANTONYMS: &[(&str, &str)] = &[
("enabled", "disabled"),
("true", "false"),
("on", "off"),
("supported", "unsupported"),
("active", "deprecated"),
("present", "absent"),
("increase", "decrease"),
("allowed", "denied"),
("valid", "invalid"),
];
fn antonym_hit(a: &HashSet<String>, b: &HashSet<String>) -> bool {
ANTONYMS.iter().any(|(x, y)| {
(a.contains(*x) && b.contains(*y)) || (a.contains(*y) && b.contains(*x))
})
}
fn subject_value(original: &str) -> Option<(HashSet<String>, String)> {
let lower = original.to_lowercase();
let (idx, kw_len) = [" is ", " are ", " was ", " were "]
.iter()
.find_map(|kw| lower.find(kw).map(|i| (i, kw.len())))?;
let subject = &original[..idx];
let after = &original[idx + kw_len..];
let value = after
.split(|c: char| !c.is_alphanumeric())
.find(|t| !t.is_empty())?;
let entity_like = value.chars().next().map(|c| c.is_uppercase()).unwrap_or(false);
if !entity_like {
return None;
}
Some((content_words(subject), value.to_lowercase()))
}
fn clamp01(x: f64) -> f64 {
x.clamp(0.0, 1.0)
}
fn evaluate_pair(
a_id: &str,
a_text: &str,
b_id: &str,
b_text: &str,
cfg: &DetectorConfig,
) -> Option<DetectedPair> {
let a_words = content_words(a_text);
let b_words = content_words(b_text);
let overlap = jaccard(&a_words, &b_words);
let mut signals = Vec::new();
let mut reasons = Vec::new();
if overlap >= cfg.min_overlap {
let (na, nb) = (numbers(a_text), numbers(b_text));
if !na.is_empty() && !nb.is_empty() && na.is_disjoint(&nb) {
signals.push(ContradictionSignal::NumericDisagreement);
reasons.push(format!(
"same wording (overlap {:.2}) but different numbers {:?} vs {:?}",
overlap, na, nb
));
}
}
if let (Some((sa, va)), Some((sb, vb))) = (subject_value(a_text), subject_value(b_text)) {
if va != vb && jaccard(&sa, &sb) >= cfg.min_subject_overlap {
signals.push(ContradictionSignal::ValueDisagreement);
reasons.push(format!("same subject asserts different values '{va}' vs '{vb}'"));
}
}
if overlap >= cfg.min_overlap {
if has_negation(a_text) != has_negation(b_text) {
signals.push(ContradictionSignal::NegationPolarity);
reasons.push(format!("same wording (overlap {overlap:.2}) but opposite negation"));
}
if antonym_hit(&a_words, &b_words) {
signals.push(ContradictionSignal::Antonym);
reasons.push("antonym pair across otherwise-similar items".to_string());
}
}
if signals.is_empty() {
return None;
}
let score = clamp01(0.4 + 0.2 * signals.len() as f64 + 0.3 * overlap);
Some(DetectedPair {
a: a_id.to_string(),
b: b_id.to_string(),
score,
signals,
reason: reasons.join("; "),
})
}
pub fn detect_contradictions(items: &[(String, String)], cfg: &DetectorConfig) -> Vec<DetectedPair> {
let mut out = Vec::new();
for i in 0..items.len() {
for j in (i + 1)..items.len() {
if let Some(pair) =
evaluate_pair(&items[i].0, &items[i].1, &items[j].0, &items[j].1, cfg)
{
out.push(pair);
}
}
}
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProposedEdge {
pub source: String,
pub target: String,
pub edge_type: String,
pub score: f64,
pub reason: String,
}
pub fn propose_contradiction_edges(
items: &[(String, String)],
cfg: &DetectorConfig,
) -> Vec<ProposedEdge> {
detect_contradictions(items, cfg)
.into_iter()
.map(|p| ProposedEdge {
source: p.a,
target: p.b,
edge_type: "contradicts".to_string(),
score: p.score,
reason: p.reason,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn items(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs.iter().map(|(i, c)| (i.to_string(), c.to_string())).collect()
}
#[test]
fn detects_numeric_disagreement() {
let it = items(&[
("a", "The MCP server exposes 33 tools."),
("b", "The MCP server exposes 12 tools."),
]);
let found = detect_contradictions(&it, &DetectorConfig::default());
assert_eq!(found.len(), 1);
assert!(found[0].signals.contains(&ContradictionSignal::NumericDisagreement));
}
#[test]
fn detects_value_disagreement() {
let it = items(&[
("a", "The default embedder is Candle (in-process, CPU)."),
("b", "The default embedder is Ollama."),
]);
let found = detect_contradictions(&it, &DetectorConfig::default());
assert_eq!(found.len(), 1);
assert!(found[0].signals.contains(&ContradictionSignal::ValueDisagreement));
}
#[test]
fn detects_negation_polarity() {
let it = items(&[
("a", "The warm server endpoint is reachable on startup."),
("b", "The warm server endpoint is not reachable on startup."),
]);
let found = detect_contradictions(&it, &DetectorConfig::default());
assert_eq!(found.len(), 1, "negation flip should be flagged");
assert!(found[0].signals.contains(&ContradictionSignal::NegationPolarity));
}
#[test]
fn detects_antonym() {
let it = items(&[
("a", "The autocapture feature is enabled by default."),
("b", "The autocapture feature is disabled by default."),
]);
let found = detect_contradictions(&it, &DetectorConfig::default());
assert!(found.iter().any(|p| p.signals.contains(&ContradictionSignal::Antonym)));
}
#[test]
fn does_not_flag_unrelated_items() {
let it = items(&[
("a", "The warm server listens on port 1739 by default."),
("b", "The warm HTTP server is co-hosted by the MCP server."),
]);
let found = detect_contradictions(&it, &DetectorConfig::default());
assert!(found.is_empty(), "different claims, no shared number/value → no flag");
}
#[test]
fn does_not_flag_compatible_same_entity() {
let it = items(&[
("a", "The default embedder is Candle (in-process, CPU)."),
("c", "Candle downloads nomic-embed-text on first use."),
]);
let found = detect_contradictions(&it, &DetectorConfig::default());
assert!(found.is_empty(), "compatible statements about Candle must not be flagged");
}
#[test]
fn proposes_contradicts_edges() {
let it = items(&[
("a", "The MCP server exposes 33 tools."),
("b", "The MCP server exposes 12 tools."),
("c", "The server is built with the rmcp Rust SDK."),
]);
let edges = propose_contradiction_edges(&it, &DetectorConfig::default());
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].edge_type, "contradicts");
assert!(!edges[0].reason.is_empty());
let pair = (edges[0].source.as_str(), edges[0].target.as_str());
assert!(pair == ("a", "b") || pair == ("b", "a"));
}
#[test]
fn value_disagreement_ignores_lowercase_predicates() {
let it = items(&[
("a", "The server is fast."),
("b", "The server is built with rmcp."),
]);
let found = detect_contradictions(&it, &DetectorConfig::default());
assert!(found.is_empty(), "lowercase predicate values must not compete");
}
}