Skip to main content

eliciter_core/
model.rs

1//! Typed data model for the elicitation kernel (spec §3).
2//!
3//! Everything here is plain data with `serde` derives. The model is the
4//! vocabulary of the append-only event log; standing and contradictions are
5//! *computed* (see [`crate::projection`] / [`crate::detect`]), never stored.
6
7use serde::{Deserialize, Serialize};
8
9/// A caller-assigned, stable identity for an entity in the interview graph
10/// (spec §2). The kernel does **no** semantics over these — detectors match
11/// `id` by exact equality only. `label` is human-facing and never compared.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct EntityRef {
14    /// Caller-assigned stable id, e.g. `entity:auth-service`.
15    pub id: String,
16    /// Optional human-readable label; informational, never matched.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub label: Option<String>,
19}
20
21impl EntityRef {
22    /// Construct a bare entity reference with no label.
23    pub fn new(id: impl Into<String>) -> Self {
24        Self {
25            id: id.into(),
26            label: None,
27        }
28    }
29}
30
31/// The object side of a claim: either another entity or a literal value
32/// (spec §3, `object: EntityRef | Value`).
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum Object {
36    /// Reference to another entity by stable id.
37    Entity(EntityRef),
38    /// A literal scalar value (string-canonicalized by the caller).
39    Value { value: String },
40}
41
42/// Provenance pointer — every claim/evidence/remediation points back to a
43/// transcript turn (spec §3, `EvidenceRef { turn_id }`).
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct EvidenceRef {
46    /// Caller-supplied transcript turn id.
47    pub turn_id: String,
48}
49
50impl EvidenceRef {
51    /// Construct from a turn id.
52    pub fn new(turn_id: impl Into<String>) -> Self {
53        Self {
54            turn_id: turn_id.into(),
55        }
56    }
57}
58
59/// Sign of a claim — whether it asserts or denies the predicate (spec §3).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum Polarity {
63    Positive,
64    Negative,
65}
66
67/// Coarse classification of a claim (spec §3).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum Category {
71    Requirement,
72    Constraint,
73    Risk,
74    Context,
75    Decision,
76}
77
78impl Category {
79    /// True for **intent** categories (`requirement | constraint | decision`):
80    /// claims that express what the operator *wants the system to be*, not facts
81    /// about the world. For these, an `app_research_refutation` (the code does
82    /// not conform) IS the finding — the operator asserting the requirement does
83    /// not make the code meet it, so user assent must NOT suppress the
84    /// spec-vs-reality signal.
85    ///
86    /// `context | risk` are fact-like: there the user-outranks-app rule holds
87    /// (spec §1 non-goal 6) and a user verdict overrides app research.
88    pub fn is_intent(self) -> bool {
89        matches!(
90            self,
91            Category::Requirement | Category::Constraint | Category::Decision
92        )
93    }
94}
95
96/// Epistemic modality of a claim (spec §3) — orthogonal to [`Category`].
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum Modality {
100    Fact,
101    Goal,
102    Constraint,
103    Assumption,
104    Decision,
105    Risk,
106    Preference,
107}
108
109/// A caller-supplied coverage tag (spec §3, `dimensions: [DimensionTag]`).
110/// Compared by exact string equality against the session coverage schema.
111pub type DimensionTag = String;
112
113/// An optional scope window for a claim (spec §3, `time_scope?`). Used by the
114/// `qualify` remediation move to let two otherwise-contradicting claims coexist
115/// under different conditions.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct TimeScope {
118    /// Free-form caller-supplied scope token (e.g. `v2`, `peak-load`).
119    pub condition: String,
120}
121
122/// A **hypothesis** (spec §3). Never an asserted fact: standing is computed by
123/// folding the evidence/remediation log, never stored on the claim.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Claim {
126    pub id: String,
127    pub session_id: String,
128    pub subject: EntityRef,
129    /// Predicate token from a caller-controlled vocabulary (e.g. `supports`).
130    pub predicate: String,
131    pub object: Object,
132    pub polarity: Polarity,
133    pub category: Category,
134    pub modality: Modality,
135    #[serde(default)]
136    pub dimensions: Vec<DimensionTag>,
137    pub source: EvidenceRef,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub time_scope: Option<TimeScope>,
140    /// Optional qualifying condition added by the `qualify` move. Distinct from
141    /// `time_scope`: a condition is a generic guard ("under SSO"), a time_scope
142    /// is temporal. Either dissolves an intra-spec contradiction.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub condition: Option<String>,
145}
146
147/// The computed discrete standing of a hypothesis (spec §3). **Never stored.**
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum HypothesisStanding {
151    /// No corroborating or refuting evidence yet.
152    Open,
153    /// Live refutation/contradiction against it that has not been remediated.
154    Challenged,
155    /// Corroborated and not currently challenged — survived falsification.
156    Withstood,
157    /// Refuted/superseded/retracted — falsified.
158    Disproven,
159}
160
161impl HypothesisStanding {
162    /// Rank for the readiness "withstood or better" comparison (spec §6.1).
163    /// Higher is better; `withstood` is the bar.
164    pub fn rank(self) -> u8 {
165        match self {
166            HypothesisStanding::Disproven => 0,
167            HypothesisStanding::Challenged => 1,
168            HypothesisStanding::Open => 2,
169            HypothesisStanding::Withstood => 3,
170        }
171    }
172
173    /// True iff this standing satisfies the coverage bar (`withstood`+).
174    pub fn is_withstood_or_better(self) -> bool {
175        self.rank() >= HypothesisStanding::Withstood.rank()
176    }
177}
178
179/// Where a piece of evidence came from (spec §3). User-sourced evidence always
180/// outranks app-research evidence (spec §1, non-goal 6).
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum EvidenceSourceType {
184    /// The human confirmed the hypothesis.
185    UserAssent,
186    /// The human refuted the hypothesis.
187    UserRefutation,
188    /// Independent corroboration (another claim/turn agrees).
189    Corroboration,
190    /// Caller researched the app and found it supports the hypothesis.
191    AppResearchSupport,
192    /// Caller researched the app and found it refutes the hypothesis.
193    AppResearchRefutation,
194}
195
196impl EvidenceSourceType {
197    /// True if this evidence supports (corroborates) the hypothesis.
198    pub fn is_support(self) -> bool {
199        matches!(
200            self,
201            EvidenceSourceType::UserAssent
202                | EvidenceSourceType::Corroboration
203                | EvidenceSourceType::AppResearchSupport
204        )
205    }
206
207    /// True if this evidence refutes the hypothesis.
208    pub fn is_refutation(self) -> bool {
209        matches!(
210            self,
211            EvidenceSourceType::UserRefutation | EvidenceSourceType::AppResearchRefutation
212        )
213    }
214
215    /// True if this evidence came directly from the human operator. User facts
216    /// always outrank external findings (spec §1).
217    pub fn is_user_sourced(self) -> bool {
218        matches!(
219            self,
220            EvidenceSourceType::UserAssent | EvidenceSourceType::UserRefutation
221        )
222    }
223}
224
225/// Severity of a detected contradiction (spec §3).
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum Severity {
229    Low,
230    Medium,
231    High,
232}
233
234/// The kind of structurally-detected contradiction (spec §3).
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum ContradictionKind {
238    /// Claim-vs-claim within the spec.
239    IntraSpec,
240    /// Claim vs an `app_research_refutation` evidence event (reality).
241    SpecVsReality,
242}
243
244/// A computed contradiction — itself a first-class, non-stored projection
245/// (spec §3 / §4 `notify`).
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct Contradiction {
248    pub id: String,
249    pub kind: ContradictionKind,
250    pub claims: Vec<String>,
251    pub severity: Severity,
252    pub explanation: String,
253    pub suggested_question: String,
254}
255
256/// A remediation move (spec §4). A transition *driver*, not terminal state.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(tag = "move", rename_all = "snake_case")]
259pub enum RemediationMove {
260    /// `new` wins; `old` → `disproven`.
261    Supersede { old: String, new: String },
262    /// Withdraw a hypothesis.
263    Retract { claim: String },
264    /// Add scope so two claims hold under different conditions.
265    Qualify {
266        claim: String,
267        #[serde(default, skip_serializing_if = "Option::is_none")]
268        condition: Option<String>,
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        time_scope: Option<TimeScope>,
271    },
272    /// The "refine the system" path — caller will submit `app_research_support`;
273    /// the contradiction clears on rerun. Records the intent + which
274    /// contradiction is being reconciled.
275    ReconcileByEvidence { contradiction: String },
276    /// Take a stance: elevate `target` to *governing*. Every claim contradicting
277    /// it becomes `challenged` with a conformance remediate signal. Readiness
278    /// stays blocked until the graph conforms (spec §4 / §6.3).
279    Accept { target: String, rationale: String },
280}
281
282/// A remediation event in the log (spec §3, `RemediationEvent`).
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct RemediationEvent {
285    pub id: String,
286    pub session_id: String,
287    #[serde(flatten)]
288    pub r#move: RemediationMove,
289    pub source: EvidenceRef,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub detail: Option<String>,
292}
293
294/// A quality check on a derived requirement (spec §3 `RequirementQualityCheck`).
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct RequirementQualityCheck {
297    /// Check name, e.g. `has_acceptance`, `has_owner`, `testable`.
298    pub name: String,
299    pub passed: bool,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub detail: Option<String>,
302}
303
304/// Priority of a requirement (spec §3, MoSCoW subset).
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "snake_case")]
307pub enum Priority {
308    Must,
309    Should,
310    Could,
311}
312
313/// A derived requirement candidate (spec §3). Derived from claims, not stored
314/// raw — but the caller may enrich a claim's requirement metadata via the
315/// `add_claim` payload (acceptance criteria, owner, priority).
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct RequirementCandidate {
318    pub id: String,
319    pub statement: String,
320    pub subject: EntityRef,
321    pub capability: String,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub condition: Option<String>,
324    #[serde(default)]
325    pub acceptance_criteria: Vec<String>,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub priority: Option<Priority>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub owner: Option<String>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub rationale: Option<String>,
332    #[serde(default)]
333    pub source_claims: Vec<String>,
334    #[serde(default)]
335    pub quality_checks: Vec<RequirementQualityCheck>,
336}
337
338/// Optional requirement enrichment a caller attaches when adding a claim of
339/// `category: requirement`. Kept on the claim's add event so requirement
340/// candidates can be derived deterministically.
341#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
342pub struct RequirementMeta {
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub statement: Option<String>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub capability: Option<String>,
347    #[serde(default)]
348    pub acceptance_criteria: Vec<String>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub priority: Option<Priority>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub owner: Option<String>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub rationale: Option<String>,
355}