Skip to main content

eliciter_core/
projection.rs

1//! The `InterviewState` fold (spec §3 / §8): replay the append-only log into a
2//! live snapshot with **computed** hypothesis standing.
3//!
4//! Standing is never stored — it is a pure function of the events. The fold
5//! happens in two passes:
6//!  1. Collect raw claims, evidence, remediations, and the coverage schema.
7//!  2. Detect contradictions ([`crate::detect`]) and compute each claim's
8//!     discrete [`HypothesisStanding`].
9//!
10//! Standing rules (spec §3 thesis — falsification, never "true"):
11//!  - `disproven`  — retracted, superseded (as the *old* claim), or carrying a
12//!    surviving refutation that outranks its support.
13//!  - `challenged` — in a live (unresolved) contradiction, or contradicting a
14//!    governing `accept` stance (conformance pressure), and not disproven.
15//!  - `withstood`  — has supporting evidence and is neither challenged nor
16//!    disproven (survived falsification attempts).
17//!  - `open`       — none of the above (no evidence yet, no live tension).
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use serde::{Deserialize, Serialize};
22
23use crate::detect::{self, DetectionOutput};
24use crate::event::{Event, EvidenceEvent};
25use crate::model::{
26    Claim, Contradiction, HypothesisStanding, RemediationEvent, RemediationMove,
27    RequirementCandidate, RequirementMeta,
28};
29use crate::readiness::ReadinessReport;
30use crate::session::CoverageSchema;
31use crate::signal::Signal;
32
33/// A governing stance taken via the `accept` move (spec §4). Its target claim
34/// is elevated; contradicting claims are placed under conformance pressure.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct AcceptStance {
37    pub remediation_id: String,
38    pub target: String,
39    pub rationale: String,
40    /// Claim ids currently in tension with the governing target (computed).
41    pub nonconforming_claims: Vec<String>,
42}
43
44/// The session registry (spec §2): the known entity & predicate ids so callers
45/// reuse stable identity instead of re-declaring it.
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Registry {
48    pub entity_ids: Vec<String>,
49    pub predicate_tokens: Vec<String>,
50}
51
52/// The full computed projection of a session (spec §3 `InterviewState`).
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct InterviewState {
55    pub session_id: String,
56    pub coverage_schema: CoverageSchema,
57    /// Live hypotheses with their computed standing, in insertion order.
58    pub claims: Vec<ClaimWithStanding>,
59    pub contradictions: Vec<Contradiction>,
60    pub requirements: Vec<RequirementCandidate>,
61    pub accepted_stances: Vec<AcceptStance>,
62    pub registry: Registry,
63    pub signals: Vec<Signal>,
64    pub readiness: ReadinessReport,
65}
66
67/// A claim paired with its computed standing (spec §3).
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ClaimWithStanding {
70    #[serde(flatten)]
71    pub claim: Claim,
72    pub standing: HypothesisStanding,
73    /// Per-claim evidence event ids, for "show your work".
74    pub evidence: Vec<String>,
75}
76
77/// Intermediate raw fold of the log, before detection/standing are computed.
78#[derive(Debug, Default)]
79pub(crate) struct RawFold {
80    pub session_id: String,
81    pub coverage_schema: CoverageSchema,
82    /// Claims in insertion order.
83    pub claims: Vec<Claim>,
84    /// Requirement metadata keyed by claim id.
85    pub requirement_meta: BTreeMap<String, RequirementMeta>,
86    /// Evidence grouped by claim id, in append order.
87    pub evidence: BTreeMap<String, Vec<EvidenceEvent>>,
88    /// All remediation events in append order.
89    pub remediations: Vec<RemediationEvent>,
90}
91
92impl RawFold {
93    fn claim(&self, id: &str) -> Option<&Claim> {
94        self.claims.iter().find(|c| c.id == id)
95    }
96}
97
98/// Fold a replayed event stream into a [`RawFold`] (pass 1). Later events that
99/// mutate a claim (e.g. `qualify` adds a condition) are applied here.
100pub(crate) fn raw_fold(events: &[Event]) -> RawFold {
101    let mut fold = RawFold::default();
102    for event in events {
103        match event {
104            Event::SessionOpened {
105                session_id,
106                coverage_schema,
107            } => {
108                fold.session_id = session_id.clone();
109                fold.coverage_schema = coverage_schema.clone();
110            }
111            Event::ClaimAdded { claim, requirement } => {
112                fold.claims.push((**claim).clone());
113                if let Some(meta) = requirement {
114                    fold.requirement_meta.insert(claim.id.clone(), meta.clone());
115                }
116            }
117            Event::EvidenceAdded { evidence } => {
118                fold.evidence
119                    .entry(evidence.claim_id.clone())
120                    .or_default()
121                    .push(evidence.clone());
122            }
123            Event::Remediated { remediation } => {
124                apply_qualify(&mut fold, remediation);
125                fold.remediations.push(remediation.clone());
126            }
127        }
128    }
129    fold
130}
131
132/// `qualify` mutates the targeted claim's scope so a contradiction dissolves.
133fn apply_qualify(fold: &mut RawFold, remediation: &RemediationEvent) {
134    let RemediationMove::Qualify {
135        claim,
136        condition,
137        time_scope,
138    } = &remediation.r#move
139    else {
140        return;
141    };
142    let Some(c) = fold.claims.iter_mut().find(|c| &c.id == claim) else {
143        return;
144    };
145    if let Some(cond) = condition {
146        c.condition = Some(cond.clone());
147    }
148    if let Some(ts) = time_scope {
149        c.time_scope = Some(ts.clone());
150    }
151}
152
153/// Build the registry from the raw fold (spec §2): every entity id and
154/// predicate token ever seen, sorted & deduped for stable output.
155fn build_registry(fold: &RawFold) -> Registry {
156    let mut entities: BTreeSet<String> = BTreeSet::new();
157    let mut predicates: BTreeSet<String> = BTreeSet::new();
158    for c in &fold.claims {
159        entities.insert(c.subject.id.clone());
160        if let crate::model::Object::Entity(e) = &c.object {
161            entities.insert(e.id.clone());
162        }
163        predicates.insert(c.predicate.clone());
164    }
165    Registry {
166        entity_ids: entities.into_iter().collect(),
167        predicate_tokens: predicates.into_iter().collect(),
168    }
169}
170
171/// Set of claim ids that have been retracted or superseded (terminally
172/// disproven by an explicit remediation move).
173fn disproven_by_remediation(fold: &RawFold) -> BTreeSet<String> {
174    let mut disproven = BTreeSet::new();
175    for r in &fold.remediations {
176        match &r.r#move {
177            RemediationMove::Retract { claim } => {
178                disproven.insert(claim.clone());
179            }
180            RemediationMove::Supersede { old, .. } => {
181                disproven.insert(old.clone());
182            }
183            _ => {}
184        }
185    }
186    disproven
187}
188
189/// Compute the discrete standing of every claim (pass 2).
190///
191/// `contradictions` is the detector output over the *current* (post-qualify)
192/// claim set and reality evidence; `accept_targets` are claims governing under
193/// an accept stance; `nonconforming` maps a governing target to the set of
194/// claims contradicting it.
195fn compute_standings(
196    fold: &RawFold,
197    detection: &DetectionOutput,
198    disproven: &BTreeSet<String>,
199) -> BTreeMap<String, HypothesisStanding> {
200    // An `accept(target)` ELEVATES its target to governing (spec §4): the target
201    // must NOT be marked contradicted by the very intra-spec tension(s) the
202    // stance governs — the conformance burden falls only on the nonconforming
203    // claims. Collect governing targets so we can exclude them below.
204    let accept_targets: BTreeSet<&str> = detection
205        .accepted_stances
206        .iter()
207        .map(|s| s.target.as_str())
208        .collect();
209
210    // Claims implicated in a live contradiction are challenged (unless disproven
211    // or governing under an accept stance).
212    let mut contradicted: BTreeSet<String> = BTreeSet::new();
213    for c in &detection.contradictions {
214        for id in &c.claims {
215            if accept_targets.contains(id.as_str()) {
216                continue;
217            }
218            contradicted.insert(id.clone());
219        }
220    }
221    // Claims under accept-conformance pressure are challenged too.
222    for id in &detection.nonconforming_claims {
223        contradicted.insert(id.clone());
224    }
225
226    let mut standings = BTreeMap::new();
227    for claim in &fold.claims {
228        let standing = compute_one(fold, claim, disproven, &contradicted);
229        standings.insert(claim.id.clone(), standing);
230    }
231    standings
232}
233
234fn compute_one(
235    fold: &RawFold,
236    claim: &Claim,
237    disproven: &BTreeSet<String>,
238    contradicted: &BTreeSet<String>,
239) -> HypothesisStanding {
240    if disproven.contains(&claim.id) {
241        return HypothesisStanding::Disproven;
242    }
243
244    let evidence = fold
245        .evidence
246        .get(&claim.id)
247        .map(Vec::as_slice)
248        .unwrap_or(&[]);
249    let has_user_refutation = evidence
250        .iter()
251        .any(|e| e.source_type == crate::model::EvidenceSourceType::UserRefutation);
252
253    // A user refutation falsifies the hypothesis — unless the user later
254    // assented after refuting (last user verdict wins). User facts outrank
255    // app-research either way (spec §1 non-goal 6).
256    if has_user_refutation && !user_reasserted_after_refutation(evidence) {
257        return HypothesisStanding::Disproven;
258    }
259
260    let has_support = evidence.iter().any(|e| e.source_type.is_support());
261    // Last-research-wins (spec §4): a later app support supersedes an earlier
262    // app refutation, so the claim is only app-refuted if the *last* app verdict
263    // refutes it.
264    let app_refutes = detect::app_research_refutes(evidence);
265
266    // Live structural tension (intra-spec contradiction, spec-vs-reality, or
267    // accept-conformance pressure) challenges the claim.
268    if contradicted.contains(&claim.id) {
269        return HypothesisStanding::Challenged;
270    }
271
272    // An app-research refutation challenges the claim. User assent only
273    // overrides it for fact-like claims (context | risk); for intent claims
274    // (requirement | constraint | decision) the intent↔code divergence stands
275    // regardless of assent (E1). Shared with detection so they agree.
276    if app_refutes && !detect::app_refutation_suppressed_by_user(claim, evidence) {
277        return HypothesisStanding::Challenged;
278    }
279
280    if has_support {
281        return HypothesisStanding::Withstood;
282    }
283
284    HypothesisStanding::Open
285}
286
287/// True if the *last* user verdict in the evidence stream was assent (the user
288/// reasserted the claim after a prior refutation).
289fn user_reasserted_after_refutation(evidence: &[EvidenceEvent]) -> bool {
290    let last_user = evidence
291        .iter()
292        .rev()
293        .find(|e| e.source_type.is_user_sourced());
294    matches!(
295        last_user.map(|e| e.source_type),
296        Some(crate::model::EvidenceSourceType::UserAssent)
297    )
298}
299
300/// Derive requirement candidates from `category: requirement` claims and their
301/// attached metadata (spec §3). Quality checks are computed deterministically.
302fn derive_requirements(
303    fold: &RawFold,
304    standings: &BTreeMap<String, HypothesisStanding>,
305) -> Vec<RequirementCandidate> {
306    use crate::model::{Category, Object, Priority, RequirementQualityCheck};
307
308    let mut out = Vec::new();
309    for claim in &fold.claims {
310        if claim.category != Category::Requirement {
311            continue;
312        }
313        // Disproven requirements drop out of the derived set.
314        if standings.get(&claim.id) == Some(&HypothesisStanding::Disproven) {
315            continue;
316        }
317        let meta = fold
318            .requirement_meta
319            .get(&claim.id)
320            .cloned()
321            .unwrap_or_default();
322        let capability = meta.capability.unwrap_or_else(|| claim.predicate.clone());
323        let has_explicit_statement = meta.statement.is_some();
324        let statement = meta.statement.unwrap_or_else(|| {
325            let obj = match &claim.object {
326                Object::Entity(e) => e.id.clone(),
327                Object::Value { value } => value.clone(),
328            };
329            format!("{} {} {}", claim.subject.id, claim.predicate, obj)
330        });
331        let priority = meta.priority;
332        let owner = meta.owner;
333
334        let mut quality_checks = Vec::new();
335        let has_acceptance = !meta.acceptance_criteria.is_empty();
336        quality_checks.push(RequirementQualityCheck {
337            name: "has_acceptance".into(),
338            passed: has_acceptance,
339            detail: None,
340        });
341        quality_checks.push(RequirementQualityCheck {
342            name: "has_owner".into(),
343            passed: owner.is_some(),
344            detail: None,
345        });
346        // `testable`: a requirement is testable iff it has acceptance criteria.
347        quality_checks.push(RequirementQualityCheck {
348            name: "testable".into(),
349            passed: has_acceptance,
350            detail: None,
351        });
352        // `unambiguous`: the requirement names a concrete thing — either the
353        // caller supplied an explicit statement, or the claim's object carries a
354        // non-empty value / entity id. An empty object value with no statement is
355        // ambiguous (we cannot say *what* must hold).
356        let object_is_concrete = match &claim.object {
357            Object::Entity(e) => !e.id.trim().is_empty(),
358            Object::Value { value } => !value.trim().is_empty(),
359        };
360        quality_checks.push(RequirementQualityCheck {
361            name: "unambiguous".into(),
362            passed: has_explicit_statement || object_is_concrete,
363            detail: None,
364        });
365
366        out.push(RequirementCandidate {
367            id: format!("req:{}", claim.id),
368            statement,
369            subject: claim.subject.clone(),
370            capability,
371            condition: claim.condition.clone(),
372            acceptance_criteria: meta.acceptance_criteria,
373            priority: priority.or({
374                // Default a `must` for requirement claims if none given? No —
375                // priority is caller intent; leave None.
376                None::<Priority>
377            }),
378            owner,
379            rationale: meta.rationale,
380            source_claims: vec![claim.id.clone()],
381            quality_checks,
382        });
383    }
384    out
385}
386
387/// Build the complete [`InterviewState`] from a replayed event stream. This is
388/// the single deterministic entry point used by both `state.get` and `append`.
389pub fn project(events: &[Event]) -> InterviewState {
390    let fold = raw_fold(events);
391    let disproven = disproven_by_remediation(&fold);
392
393    // Detection needs to know which claims are already disproven so it doesn't
394    // flag tensions involving dead hypotheses.
395    let detection = detect::detect(&fold, &disproven);
396    let standings = compute_standings(&fold, &detection, &disproven);
397    let requirements = derive_requirements(&fold, &standings);
398    let registry = build_registry(&fold);
399
400    let claims: Vec<ClaimWithStanding> = fold
401        .claims
402        .iter()
403        .map(|c| ClaimWithStanding {
404            claim: c.clone(),
405            standing: standings
406                .get(&c.id)
407                .copied()
408                .unwrap_or(HypothesisStanding::Open),
409            evidence: fold
410                .evidence
411                .get(&c.id)
412                .map(|evs| evs.iter().map(|e| e.id.clone()).collect())
413                .unwrap_or_default(),
414        })
415        .collect();
416
417    let readiness = crate::readiness::assess(
418        &fold.coverage_schema,
419        &claims,
420        &detection.contradictions,
421        &detection.accepted_stances,
422        &requirements,
423    );
424
425    let signals = crate::signal::compute_signals(&claims, &detection, &readiness);
426
427    InterviewState {
428        session_id: fold.session_id.clone(),
429        coverage_schema: fold.coverage_schema.clone(),
430        claims,
431        contradictions: detection.contradictions,
432        requirements,
433        accepted_stances: detection.accepted_stances,
434        registry,
435        signals,
436        readiness,
437    }
438}
439
440// Re-export so `detect` can reach the raw fold helpers it needs.
441pub(crate) use RawFold as InternalRawFold;
442
443impl RawFold {
444    /// Public-within-crate accessor used by detection.
445    pub(crate) fn claim_by_id(&self, id: &str) -> Option<&Claim> {
446        self.claim(id)
447    }
448}