use serde::{Deserialize, Serialize};
use crate::detect::DetectionOutput;
use crate::model::HypothesisStanding;
use crate::projection::ClaimWithStanding;
use crate::readiness::ReadinessReport;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalKind {
Notify,
Clarify,
Remediate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signal {
pub kind: SignalKind,
pub code: String,
pub message: String,
#[serde(default)]
pub claims: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contradiction: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dimension: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_question: Option<String>,
}
pub(crate) fn compute_signals(
claims: &[ClaimWithStanding],
detection: &DetectionOutput,
readiness: &ReadinessReport,
) -> Vec<Signal> {
let mut signals = Vec::new();
for c in &detection.contradictions {
let code = match c.kind {
crate::model::ContradictionKind::IntraSpec => "intra_spec_contradiction",
crate::model::ContradictionKind::SpecVsReality => "spec_vs_reality_contradiction",
};
signals.push(Signal {
kind: SignalKind::Notify,
code: code.to_string(),
message: c.explanation.clone(),
claims: c.claims.clone(),
contradiction: Some(c.id.clone()),
dimension: None,
suggested_question: Some(c.suggested_question.clone()),
});
signals.push(Signal {
kind: SignalKind::Remediate,
code: format!("resolve_{code}"),
message: format!("Resolve contradiction '{}' with a remediation move.", c.id),
claims: c.claims.clone(),
contradiction: Some(c.id.clone()),
dimension: None,
suggested_question: Some(c.suggested_question.clone()),
});
}
for stance in &detection.accepted_stances {
for claim_id in &stance.nonconforming_claims {
signals.push(Signal {
kind: SignalKind::Remediate,
code: "accept_conformance".to_string(),
message: format!(
"Claim '{claim_id}' contradicts accepted stance '{}': {}. Bring it into conformance.",
stance.target, stance.rationale
),
claims: vec![claim_id.clone()],
contradiction: None,
dimension: None,
suggested_question: Some(format!(
"The governing decision is '{}'. How should '{claim_id}' change to conform?",
stance.target
)),
});
}
}
for gap in &readiness.gaps {
signals.push(Signal {
kind: SignalKind::Clarify,
code: "coverage_gap".to_string(),
message: format!(
"Coverage dimension '{}' has no withstood hypothesis.",
gap.dimension
),
claims: Vec::new(),
contradiction: None,
dimension: Some(gap.dimension.clone()),
suggested_question: Some(gap.suggested_question.clone()),
});
}
for cw in claims {
if cw.standing == HypothesisStanding::Open && cw.evidence.is_empty() {
signals.push(Signal {
kind: SignalKind::Clarify,
code: "open_hypothesis".to_string(),
message: format!(
"Hypothesis '{}' has no evidence yet; seek user assent or refutation.",
cw.claim.id
),
claims: vec![cw.claim.id.clone()],
contradiction: None,
dimension: cw.claim.dimensions.first().cloned(),
suggested_question: Some(format!(
"Can you confirm: {} {} ... ? (claim '{}')",
cw.claim.subject.id, cw.claim.predicate, cw.claim.id
)),
});
}
}
signals
}
pub fn next_question(signals: &[Signal]) -> Option<&Signal> {
signals
.iter()
.find(|s| s.kind == SignalKind::Remediate && s.suggested_question.is_some())
.or_else(|| {
signals
.iter()
.find(|s| s.code == "coverage_gap" && s.suggested_question.is_some())
})
.or_else(|| {
signals
.iter()
.find(|s| s.kind == SignalKind::Clarify && s.suggested_question.is_some())
})
}