use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contradiction {
pub claim_a: String,
pub response_a: usize,
pub claim_b: String,
pub response_b: usize,
pub conflict: String,
pub confidence: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsistencyReport {
pub n_responses: usize,
pub total_claims: usize,
pub consistent_claims: Vec<ConsistentClaim>,
pub contradictions: Vec<Contradiction>,
pub unique_claims: Vec<UniqueClaim>,
pub consistency_score: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsistentClaim {
pub text: String,
pub agreement_count: usize,
pub agreement_ratio: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniqueClaim {
pub text: String,
pub response_idx: usize,
}
pub fn check_consistency(responses: &[&str]) -> ConsistencyReport {
if responses.len() < 2 {
return ConsistencyReport {
n_responses: responses.len(),
total_claims: 0,
consistent_claims: vec![],
contradictions: vec![],
unique_claims: vec![],
consistency_score: 1.0,
};
}
let all_claims: Vec<Vec<ClaimEntity>> = responses.iter().map(|r| extract_entities(r)).collect();
let total_claims: usize = all_claims.iter().map(|c| c.len()).sum();
let mut consistent_claims = Vec::new();
let mut seen_entities: Vec<(String, String, Vec<usize>)> = Vec::new();
for (resp_idx, claims) in all_claims.iter().enumerate() {
for claim in claims {
let mut found = false;
for existing in seen_entities.iter_mut() {
if fuzzy_match_subject(&existing.0, &claim.subject) {
if !existing.2.contains(&resp_idx) {
existing.2.push(resp_idx);
}
found = true;
break;
}
}
if !found {
seen_entities.push((claim.subject.clone(), claim.value.clone(), vec![resp_idx]));
}
}
}
let n = responses.len();
for (subject, value, indices) in &seen_entities {
if indices.len() > 1 {
consistent_claims.push(ConsistentClaim {
text: format!("{}: {}", subject, value),
agreement_count: indices.len(),
agreement_ratio: indices.len() as f64 / n as f64,
});
}
}
let mut unique_claims = Vec::new();
for (subject, value, indices) in &seen_entities {
if indices.len() == 1 {
unique_claims.push(UniqueClaim {
text: format!("{}: {}", subject, value),
response_idx: indices[0],
});
}
}
let mut contradictions = Vec::new();
for (resp_a, claims_a) in all_claims.iter().enumerate() {
for (resp_b, claims_b) in all_claims.iter().enumerate() {
if resp_b <= resp_a {
continue;
}
for claim_a in claims_a {
for claim_b in claims_b {
if let Some(contradiction) =
detect_contradiction(claim_a, claim_b, resp_a, resp_b)
{
let already_found = contradictions
.iter()
.any(|c: &Contradiction| c.conflict == contradiction.conflict);
if !already_found {
contradictions.push(contradiction);
}
}
}
}
}
}
let consistency_score = compute_consistency_score(
&consistent_claims,
&contradictions,
&unique_claims,
total_claims,
n,
);
ConsistencyReport {
n_responses: n,
total_claims,
consistent_claims,
contradictions,
unique_claims,
consistency_score,
}
}
#[derive(Debug, Clone)]
struct ClaimEntity {
subject: String,
value: String,
full_text: String,
}
fn extract_entities(text: &str) -> Vec<ClaimEntity> {
let sentences: Vec<&str> = text
.split(['.', '!', '?'])
.filter(|s| s.trim().len() > 10)
.collect();
let mut entities = Vec::new();
for sentence in &sentences {
let s = sentence.trim();
let words: Vec<&str> = s.split_whitespace().collect();
if words.len() < 3 {
continue;
}
for (i, word) in words.iter().enumerate() {
if word.chars().any(|c| c.is_ascii_digit()) && word.len() <= 10 {
let subject = if i >= 2 {
words[..i].join(" ")
} else {
words[0].to_string()
};
entities.push(ClaimEntity {
subject: normalize_subject(&subject),
value: word.to_string(),
full_text: s.to_string(),
});
}
}
for (i, word) in words.iter().enumerate() {
if (*word == "in" || *word == "at" || *word == "from") && i + 1 < words.len() {
let place = words[i + 1..].join(" ");
if place.chars().next().is_some_and(|c| c.is_uppercase()) {
let subject = words[..i].join(" ");
entities.push(ClaimEntity {
subject: normalize_subject(&subject),
value: normalize_value(&place),
full_text: s.to_string(),
});
}
}
}
}
entities
}
fn normalize_subject(s: &str) -> String {
let s = s.trim().to_lowercase();
let s = s.strip_prefix("the ").unwrap_or(&s);
let s = s.strip_prefix("a ").unwrap_or(s);
let s = s.strip_prefix("an ").unwrap_or(s);
s.to_string()
}
fn normalize_value(s: &str) -> String {
s.trim()
.trim_end_matches(|c: char| c.is_ascii_punctuation())
.to_string()
}
fn fuzzy_match_subject(a: &str, b: &str) -> bool {
if a == b {
return true;
}
if a.contains(b) || b.contains(a) {
return true;
}
let a_words: Vec<&str> = a.split_whitespace().filter(|w| w.len() > 3).collect();
let b_words: Vec<&str> = b.split_whitespace().filter(|w| w.len() > 3).collect();
let shared = a_words.iter().filter(|w| b_words.contains(w)).count();
shared > 0 && shared >= a_words.len().min(b_words.len()) / 2
}
fn detect_contradiction(
a: &ClaimEntity,
b: &ClaimEntity,
resp_a: usize,
resp_b: usize,
) -> Option<Contradiction> {
if !fuzzy_match_subject(&a.subject, &b.subject) {
return None;
}
let a_val = normalize_value(&a.value);
let b_val = normalize_value(&b.value);
if a_val == b_val {
return None; }
let a_lower = a_val.to_lowercase();
let b_lower = b_val.to_lowercase();
if a_lower.contains(&b_lower) || b_lower.contains(&a_lower) {
return None;
}
let a_words: Vec<&str> = a_val.split_whitespace().collect();
let b_words: Vec<&str> = b_val.split_whitespace().collect();
let shared = a_words
.iter()
.filter(|w| {
b_words
.iter()
.any(|bw| bw.to_lowercase() == w.to_lowercase())
})
.count();
if shared > 0 && shared >= a_words.len().min(b_words.len()) {
return None;
}
let a_is_numeric = a_val.chars().any(|c| c.is_ascii_digit());
let b_is_numeric = b_val.chars().any(|c| c.is_ascii_digit());
if a_is_numeric != b_is_numeric {
return None;
}
let confidence = if a_is_numeric && b_is_numeric {
0.9 } else if a_val.chars().next().is_some_and(|c| c.is_uppercase())
&& b_val.chars().next().is_some_and(|c| c.is_uppercase())
{
0.8 } else {
0.5 };
Some(Contradiction {
claim_a: a.full_text.clone(),
response_a: resp_a,
claim_b: b.full_text.clone(),
response_b: resp_b,
conflict: format!("\"{}\" vs \"{}\" (subject: {})", a_val, b_val, a.subject),
confidence,
})
}
fn compute_consistency_score(
consistent: &[ConsistentClaim],
contradictions: &[Contradiction],
unique: &[UniqueClaim],
total_claims: usize,
n_responses: usize,
) -> f64 {
if total_claims == 0 || n_responses < 2 {
return 1.0;
}
let avg_agreement: f64 = if consistent.is_empty() {
0.5
} else {
consistent.iter().map(|c| c.agreement_ratio).sum::<f64>() / consistent.len() as f64
};
let contradiction_penalty = (contradictions.len() as f64 * 0.15).min(0.5);
let unique_penalty = (unique.len() as f64 * 0.05).min(0.3);
(avg_agreement - contradiction_penalty - unique_penalty).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn consistent_responses() {
let report = check_consistency(&[
"Einstein was born in 1879 in Ulm, Germany.",
"Einstein was born in 1879 in Ulm, Germany.",
"Einstein was born in 1879 in Ulm, Germany.",
]);
for c in &report.contradictions {
eprintln!(
"CONTRADICTION: {} (resp {}) vs {} (resp {}) — {}",
c.claim_a, c.response_a, c.claim_b, c.response_b, c.conflict
);
}
assert!(report.consistency_score > 0.5);
assert!(
report.contradictions.is_empty(),
"Found {} contradictions in identical responses",
report.contradictions.len()
);
}
#[test]
fn contradicting_responses() {
let report = check_consistency(&[
"Einstein was born in 1879 in Ulm, Germany.",
"Einstein was born in 1879 in Munich, Germany.",
"Einstein was born in 1879 in Ulm, Germany.",
]);
assert!(
!report.contradictions.is_empty(),
"Expected contradictions for Ulm vs Munich"
);
assert!(report.consistency_score < 0.9);
}
#[test]
fn numeric_contradiction() {
let report = check_consistency(&[
"The population is 126,000 people.",
"The population is 250,000 people.",
]);
assert!(
!report.contradictions.is_empty() || !report.unique_claims.is_empty(),
"Should detect disagreement in numbers"
);
}
#[test]
fn single_response() {
let report = check_consistency(&["Einstein was born in 1879."]);
assert_eq!(report.consistency_score, 1.0);
assert_eq!(report.n_responses, 1);
}
#[test]
fn empty_responses() {
let report = check_consistency(&[]);
assert_eq!(report.consistency_score, 1.0);
}
#[test]
fn consistency_score_bounded() {
let report = check_consistency(&[
"Einstein was born in 1879 in Ulm. He had 3 children.",
"Einstein was born in 1880 in Munich. He had 5 children.",
"Einstein was born in 1879 in Berlin. He had 3 kids.",
]);
assert!(report.consistency_score >= 0.0);
assert!(report.consistency_score <= 1.0);
}
#[test]
fn unique_claims_detected() {
let report = check_consistency(&[
"Einstein was born in 1879. He invented the laser.",
"Einstein was born in 1879. He played violin.",
]);
assert!(report.total_claims > 0);
}
}