use std::collections::BTreeSet;
use crate::model::{
Claim, Contradiction, ContradictionKind, EvidenceSourceType, Object, Polarity, RemediationMove,
Severity,
};
use crate::projection::{AcceptStance, InternalRawFold};
#[derive(Debug, Default)]
pub(crate) struct DetectionOutput {
pub contradictions: Vec<Contradiction>,
pub accepted_stances: Vec<AcceptStance>,
pub nonconforming_claims: Vec<String>,
}
pub(crate) fn detect(fold: &InternalRawFold, disproven: &BTreeSet<String>) -> DetectionOutput {
let live: Vec<&Claim> = fold
.claims_iter()
.filter(|c| !disproven.contains(&c.id))
.collect();
let mut contradictions = Vec::new();
detect_intra_spec(&live, &mut contradictions);
detect_spec_vs_reality(fold, &live, &mut contradictions);
let (accepted_stances, nonconforming_claims) =
detect_accept_conformance(fold, &live, disproven);
DetectionOutput {
contradictions,
accepted_stances,
nonconforming_claims,
}
}
fn detect_intra_spec(live: &[&Claim], out: &mut Vec<Contradiction>) {
for i in 0..live.len() {
for j in (i + 1)..live.len() {
let a = live[i];
let b = live[j];
if a.subject.id != b.subject.id || a.predicate != b.predicate {
continue;
}
if scopes_disjoint(a, b) {
continue;
}
if let Some((sev, explanation)) = conflict(a, b) {
out.push(Contradiction {
id: contradiction_id(ContradictionKind::IntraSpec, &[&a.id, &b.id]),
kind: ContradictionKind::IntraSpec,
claims: vec![a.id.clone(), b.id.clone()],
severity: sev,
explanation,
suggested_question: format!(
"Claims '{}' and '{}' both describe {} {} but disagree — which holds?",
a.id, b.id, a.subject.id, a.predicate
),
});
}
}
}
}
fn scopes_disjoint(a: &Claim, b: &Claim) -> bool {
let cond_disjoint = match (&a.condition, &b.condition) {
(Some(x), Some(y)) => x != y,
_ => false,
};
let time_disjoint = match (&a.time_scope, &b.time_scope) {
(Some(x), Some(y)) => x != y,
_ => false,
};
cond_disjoint || time_disjoint
}
fn conflict(a: &Claim, b: &Claim) -> Option<(Severity, String)> {
let polarity_conflict = a.polarity != b.polarity && objects_equal(&a.object, &b.object);
let value_conflict = a.polarity == Polarity::Positive
&& b.polarity == Polarity::Positive
&& !objects_equal(&a.object, &b.object)
&& both_values(&a.object, &b.object);
if !polarity_conflict && !value_conflict {
return None;
}
let severity = severity_for(a, b);
let explanation = if polarity_conflict {
format!(
"Claim '{}' asserts and '{}' denies the same fact ({} {}).",
a.id, b.id, a.subject.id, a.predicate
)
} else {
format!(
"Claims '{}' and '{}' assign different values to {} {}.",
a.id, b.id, a.subject.id, a.predicate
)
};
Some((severity, explanation))
}
fn severity_for(a: &Claim, b: &Claim) -> Severity {
use crate::model::Category::{Constraint, Decision, Requirement};
let high = |c: &Claim| matches!(c.category, Constraint | Decision | Requirement);
if high(a) || high(b) {
Severity::High
} else {
Severity::Medium
}
}
fn objects_equal(a: &Object, b: &Object) -> bool {
a == b
}
fn both_values(a: &Object, b: &Object) -> bool {
matches!(a, Object::Value { .. }) && matches!(b, Object::Value { .. })
}
pub(crate) fn app_research_refutes(evidence: &[crate::event::EvidenceEvent]) -> bool {
let last_app = evidence.iter().rev().find(|e| {
matches!(
e.source_type,
EvidenceSourceType::AppResearchRefutation | EvidenceSourceType::AppResearchSupport
)
});
matches!(
last_app.map(|e| e.source_type),
Some(EvidenceSourceType::AppResearchRefutation)
)
}
pub(crate) fn app_refutation_suppressed_by_user(
claim: &Claim,
evidence: &[crate::event::EvidenceEvent],
) -> bool {
if claim.category.is_intent() {
return false;
}
evidence
.iter()
.any(|e| e.source_type == EvidenceSourceType::UserAssent)
}
fn detect_spec_vs_reality(fold: &InternalRawFold, live: &[&Claim], out: &mut Vec<Contradiction>) {
for claim in live {
let evs = fold.evidence_for(&claim.id);
let suppressed = app_refutation_suppressed_by_user(claim, evs);
if app_research_refutes(evs) && !suppressed {
out.push(Contradiction {
id: contradiction_id(ContradictionKind::SpecVsReality, &[&claim.id]),
kind: ContradictionKind::SpecVsReality,
claims: vec![claim.id.clone()],
severity: Severity::High,
explanation: format!(
"App research refutes claim '{}' ({} {}): the system does not match the spec.",
claim.id, claim.subject.id, claim.predicate
),
suggested_question: format!(
"Reality contradicts '{}'. Refine the spec, or change the app and re-evidence?",
claim.id
),
});
}
}
}
fn detect_accept_conformance(
fold: &InternalRawFold,
live: &[&Claim],
disproven: &BTreeSet<String>,
) -> (Vec<AcceptStance>, Vec<String>) {
let mut stances = Vec::new();
let mut all_nonconforming: BTreeSet<String> = BTreeSet::new();
for r in fold.remediations_iter() {
let RemediationMove::Accept { target, rationale } = &r.r#move else {
continue;
};
if disproven.contains(target) {
continue;
}
let Some(target_claim) = fold.claim_by_id(target) else {
continue;
};
let mut nonconforming = Vec::new();
for candidate in live {
if candidate.id == *target {
continue;
}
if candidate.subject.id != target_claim.subject.id
|| candidate.predicate != target_claim.predicate
{
continue;
}
if scopes_disjoint(candidate, target_claim) {
continue;
}
if conflict(candidate, target_claim).is_some() {
nonconforming.push(candidate.id.clone());
all_nonconforming.insert(candidate.id.clone());
}
}
stances.push(AcceptStance {
remediation_id: r.id.clone(),
target: target.clone(),
rationale: rationale.clone(),
nonconforming_claims: nonconforming,
});
}
(stances, all_nonconforming.into_iter().collect())
}
fn contradiction_id(kind: ContradictionKind, claim_ids: &[&str]) -> String {
let mut ids: Vec<&str> = claim_ids.to_vec();
ids.sort_unstable();
let prefix = match kind {
ContradictionKind::IntraSpec => "ctr:intra",
ContradictionKind::SpecVsReality => "ctr:reality",
};
format!("{prefix}:{}", ids.join("+"))
}
impl InternalRawFold {
fn claims_iter(&self) -> impl Iterator<Item = &Claim> {
self.claims.iter()
}
fn remediations_iter(&self) -> impl Iterator<Item = &crate::model::RemediationEvent> {
self.remediations.iter()
}
fn evidence_for(&self, claim_id: &str) -> &[crate::event::EvidenceEvent] {
self.evidence
.get(claim_id)
.map(Vec::as_slice)
.unwrap_or(&[])
}
}