Skip to main content

eliciter_core/
export.rs

1//! `spec.export` (spec §6): emit the aligned, plan-ready spec document.
2//!
3//! Groups `withstood`+ hypotheses by coverage dimension, lists requirement
4//! candidates with acceptance criteria + provenance, and includes a visible
5//! **accepted-tensions** section so nothing is waved through silently.
6
7use serde::{Deserialize, Serialize};
8
9use crate::model::{HypothesisStanding, RequirementCandidate};
10use crate::projection::InterviewState;
11
12/// A hypothesis as it appears in the exported spec (settled facts only).
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ExportedHypothesis {
15    pub claim_id: String,
16    pub statement: String,
17    pub standing: HypothesisStanding,
18    pub source_turn: String,
19}
20
21/// `withstood`+ hypotheses grouped under one coverage dimension.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct DimensionSection {
24    pub dimension: String,
25    pub hypotheses: Vec<ExportedHypothesis>,
26}
27
28/// An accepted tension (spec §6): a governing `accept` stance, surfaced so it
29/// is never silent.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct AcceptedTension {
32    pub target: String,
33    pub rationale: String,
34    pub nonconforming_claims: Vec<String>,
35}
36
37/// The exported plan-ready spec document (spec §6).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct SpecDocument {
40    pub session_id: String,
41    pub ready: bool,
42    pub dimensions: Vec<DimensionSection>,
43    pub requirements: Vec<RequirementCandidate>,
44    pub accepted_tensions: Vec<AcceptedTension>,
45    /// Carried verbatim so the caller sees exactly why it is/isn't plan-ready.
46    pub blockers: Vec<String>,
47}
48
49/// Build the exported document from a projection (deterministic).
50pub(crate) fn build(state: &InterviewState) -> SpecDocument {
51    let mut dimensions = Vec::new();
52    for dim in &state.coverage_schema.dimensions {
53        let mut hypotheses = Vec::new();
54        for cw in &state.claims {
55            if !cw.standing.is_withstood_or_better() {
56                continue;
57            }
58            if cw.claim.dimensions.iter().any(|t| t == &dim.tag) {
59                hypotheses.push(ExportedHypothesis {
60                    claim_id: cw.claim.id.clone(),
61                    statement: statement_for(&cw.claim),
62                    standing: cw.standing,
63                    source_turn: cw.claim.source.turn_id.clone(),
64                });
65            }
66        }
67        dimensions.push(DimensionSection {
68            dimension: dim.tag.clone(),
69            hypotheses,
70        });
71    }
72
73    let accepted_tensions = state
74        .accepted_stances
75        .iter()
76        .map(|s| AcceptedTension {
77            target: s.target.clone(),
78            rationale: s.rationale.clone(),
79            nonconforming_claims: s.nonconforming_claims.clone(),
80        })
81        .collect();
82
83    SpecDocument {
84        session_id: state.session_id.clone(),
85        ready: state.readiness.ready,
86        dimensions,
87        requirements: state.requirements.clone(),
88        accepted_tensions,
89        blockers: state.readiness.blockers.clone(),
90    }
91}
92
93fn statement_for(claim: &crate::model::Claim) -> String {
94    let obj = match &claim.object {
95        crate::model::Object::Entity(e) => e.id.clone(),
96        crate::model::Object::Value { value } => value.clone(),
97    };
98    let polarity = match claim.polarity {
99        crate::model::Polarity::Positive => "",
100        crate::model::Polarity::Negative => "NOT ",
101    };
102    let scope = claim
103        .condition
104        .as_ref()
105        .map(|c| format!(" [when {c}]"))
106        .unwrap_or_default();
107    format!(
108        "{}{} {} {}{scope}",
109        polarity, claim.subject.id, claim.predicate, obj
110    )
111}