Skip to main content

semantic_memory_forge/
v13.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use stack_ids::{
4    ClaimId, ClaimStateId, ClaimVersionId, ContentDigest, ContradictionWitnessId,
5    RetractionRecordId, SemanticsProfileId, SupportSetId,
6};
7
8use crate::{DegradationKindV1, EvidenceAdmissibilityV1, ExactnessLevelV1, SemanticViewV1};
9
10pub const BILATTICE_TRUTH_V1_SCHEMA: &str = "bilattice_truth_v1";
11pub const SUPPORT_SET_V1_SCHEMA: &str = "support_set_v1";
12pub const CONTRADICTION_WITNESS_V1_SCHEMA: &str = "contradiction_witness_v1";
13pub const RETRACTION_RECORD_V1_SCHEMA: &str = "retraction_record_v1";
14pub const CLAIM_STATE_V13_SCHEMA: &str = "claim_state_v13";
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum BilatticeTruthV1 {
19    Unknown,
20    TrueOnly,
21    FalseOnly,
22    Both,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum SupportPolarityV1 {
28    Supports,
29    Refutes,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum SupportProvenanceKindV1 {
35    EvidenceRef,
36    ClaimVersion,
37    RelationVersion,
38    Episode,
39    Receipt,
40    External,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44pub struct SupportTokenV1 {
45    pub token_id: String,
46    pub kind: SupportProvenanceKindV1,
47    pub reference: String,
48    pub polarity: SupportPolarityV1,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
52#[serde(tag = "op", rename_all = "snake_case")]
53pub enum SupportExprV1 {
54    Token { token_id: String },
55    AnyOf { children: Vec<SupportExprV1> },
56    AllOf { children: Vec<SupportExprV1> },
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub struct SupportSetV1 {
61    pub schema_version: String,
62    pub support_set_id: SupportSetId,
63    pub claim_id: ClaimId,
64    pub semantics_profile_id: SemanticsProfileId,
65    pub support_tokens: Vec<SupportTokenV1>,
66    pub support_expr: SupportExprV1,
67    pub content_digest: ContentDigest,
68}
69
70impl SupportSetV1 {
71    /// Validates a support set and its support expression against the token set.
72    pub fn validate(&self) -> Result<(), String> {
73        ensure_schema(&self.schema_version, SUPPORT_SET_V1_SCHEMA)?;
74        ensure_non_empty_id(self.support_set_id.as_str(), "support_set_id")?;
75        ensure_non_empty_id(self.claim_id.as_str(), "claim_id")?;
76        ensure_non_empty_id(self.semantics_profile_id.as_str(), "semantics_profile_id")?;
77        ensure_non_empty_vec(&self.support_tokens, "support_tokens")?;
78        validate_support_expr(&self.support_expr, &self.support_tokens)?;
79        Ok(())
80    }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84pub struct QualityVectorV1 {
85    pub exactness: ExactnessLevelV1,
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub degradation: Vec<DegradationKindV1>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub freshness: Option<String>,
90    pub replay_limited: bool,
91    pub execution_contaminated: bool,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95pub struct ContradictionWitnessV1 {
96    pub schema_version: String,
97    pub contradiction_witness_id: ContradictionWitnessId,
98    pub claim_id: ClaimId,
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub conflicting_token_ids: Vec<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub summary: Option<String>,
103}
104
105impl ContradictionWitnessV1 {
106    /// Validates a contradiction witness before publication or transport.
107    pub fn validate(&self) -> Result<(), String> {
108        ensure_schema(&self.schema_version, CONTRADICTION_WITNESS_V1_SCHEMA)?;
109        ensure_non_empty_id(
110            self.contradiction_witness_id.as_str(),
111            "contradiction_witness_id",
112        )?;
113        ensure_non_empty_id(self.claim_id.as_str(), "claim_id")?;
114        ensure_non_empty_vec(&self.conflicting_token_ids, "conflicting_token_ids")
115    }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
119pub struct RetractionRecordV1 {
120    pub schema_version: String,
121    pub retraction_record_id: RetractionRecordId,
122    pub claim_id: ClaimId,
123    pub retracted_claim_version_id: ClaimVersionId,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub superseded_by_claim_version_id: Option<ClaimVersionId>,
126    pub effective_recorded_at: String,
127    pub reason: String,
128    pub cascade_required: bool,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub delta_summary: Option<String>,
131}
132
133impl RetractionRecordV1 {
134    /// Validates a retraction record before publication or transport.
135    pub fn validate(&self) -> Result<(), String> {
136        ensure_schema(&self.schema_version, RETRACTION_RECORD_V1_SCHEMA)?;
137        ensure_non_empty_id(self.retraction_record_id.as_str(), "retraction_record_id")?;
138        ensure_non_empty_id(self.claim_id.as_str(), "claim_id")?;
139        ensure_non_empty_id(
140            self.retracted_claim_version_id.as_str(),
141            "retracted_claim_version_id",
142        )?;
143        ensure_non_empty(&self.effective_recorded_at, "effective_recorded_at")?;
144        ensure_non_empty(&self.reason, "reason")?;
145        Ok(())
146    }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
150pub struct ClaimStateV13 {
151    pub schema_version: String,
152    pub claim_state_id: ClaimStateId,
153    pub claim_id: ClaimId,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub claim_version_id: Option<ClaimVersionId>,
156    pub semantics_profile_id: SemanticsProfileId,
157    pub view: SemanticViewV1,
158    pub bilattice_truth: BilatticeTruthV1,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub support_set_id: Option<SupportSetId>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub support_set_digest: Option<ContentDigest>,
163    pub quality_vector: QualityVectorV1,
164    pub evidence_admissibility: EvidenceAdmissibilityV1,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub contradiction_witness_id: Option<ContradictionWitnessId>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub valid_from: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub valid_to: Option<String>,
171    pub tx_from: String,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub tx_to: Option<String>,
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub proof_obligations_remaining: Vec<String>,
176    pub policy_action_allowed: bool,
177}
178
179impl ClaimStateV13 {
180    /// Validates a v13 claim-state artifact before publication or transport.
181    pub fn validate(&self) -> Result<(), String> {
182        ensure_schema(&self.schema_version, CLAIM_STATE_V13_SCHEMA)?;
183        ensure_non_empty_id(self.claim_state_id.as_str(), "claim_state_id")?;
184        ensure_non_empty_id(self.claim_id.as_str(), "claim_id")?;
185        ensure_non_empty_id(self.semantics_profile_id.as_str(), "semantics_profile_id")?;
186        ensure_non_empty(&self.tx_from, "tx_from")?;
187        if self.support_set_id.is_some() ^ self.support_set_digest.is_some() {
188            return Err(
189                "support_set_id and support_set_digest must either both be present or both be absent"
190                    .into(),
191            );
192        }
193        if self.policy_action_allowed
194            && matches!(
195                self.bilattice_truth,
196                BilatticeTruthV1::Unknown | BilatticeTruthV1::FalseOnly | BilatticeTruthV1::Both
197            )
198        {
199            return Err(
200                "policy_action_allowed cannot be true for unknown, false_only, or both truth states"
201                    .into(),
202            );
203        }
204        Ok(())
205    }
206}
207
208fn ensure_schema(actual: &str, expected: &str) -> Result<(), String> {
209    if actual == expected {
210        Ok(())
211    } else {
212        Err(format!(
213            "schema_version mismatch: expected {expected}, got {actual}"
214        ))
215    }
216}
217
218fn ensure_non_empty(value: &str, field: &str) -> Result<(), String> {
219    if value.trim().is_empty() {
220        Err(format!("{field} must not be empty"))
221    } else {
222        Ok(())
223    }
224}
225
226fn ensure_non_empty_id(value: &str, field: &str) -> Result<(), String> {
227    ensure_non_empty(value, field)
228}
229
230fn ensure_non_empty_vec<T>(value: &[T], field: &str) -> Result<(), String> {
231    if value.is_empty() {
232        Err(format!("{field} must not be empty"))
233    } else {
234        Ok(())
235    }
236}
237
238fn validate_support_expr(
239    expr: &SupportExprV1,
240    support_tokens: &[SupportTokenV1],
241) -> Result<(), String> {
242    match expr {
243        SupportExprV1::Token { token_id } => {
244            if support_tokens
245                .iter()
246                .any(|token| token.token_id == *token_id)
247            {
248                Ok(())
249            } else {
250                Err(format!(
251                    "support_expr references unknown token_id '{token_id}'"
252                ))
253            }
254        }
255        SupportExprV1::AnyOf { children } | SupportExprV1::AllOf { children } => {
256            ensure_non_empty_vec(children, "support_expr.children")?;
257            for child in children {
258                validate_support_expr(child, support_tokens)?;
259            }
260            Ok(())
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn support_set_requires_known_tokens() {
271        let support = SupportSetV1 {
272            schema_version: SUPPORT_SET_V1_SCHEMA.into(),
273            support_set_id: SupportSetId::new("support-1"),
274            claim_id: ClaimId::new("claim-1"),
275            semantics_profile_id: SemanticsProfileId::new("profile-1"),
276            support_tokens: vec![SupportTokenV1 {
277                token_id: "tok-1".into(),
278                kind: SupportProvenanceKindV1::EvidenceRef,
279                reference: "evidence:1".into(),
280                polarity: SupportPolarityV1::Supports,
281            }],
282            support_expr: SupportExprV1::Token {
283                token_id: "tok-1".into(),
284            },
285            content_digest: ContentDigest::compute(b"support-set"),
286        };
287
288        assert!(support.validate().is_ok());
289    }
290
291    #[test]
292    fn claim_state_v13_rejects_half_present_support_digest_pair() {
293        let claim_state = ClaimStateV13 {
294            schema_version: CLAIM_STATE_V13_SCHEMA.into(),
295            claim_state_id: ClaimStateId::new("claim-state-1"),
296            claim_id: ClaimId::new("claim-1"),
297            claim_version_id: Some(ClaimVersionId::new("claim-version-1")),
298            semantics_profile_id: SemanticsProfileId::new("profile-1"),
299            view: SemanticViewV1::Canonical,
300            bilattice_truth: BilatticeTruthV1::TrueOnly,
301            support_set_id: Some(SupportSetId::new("support-1")),
302            support_set_digest: None,
303            quality_vector: QualityVectorV1 {
304                exactness: ExactnessLevelV1::Conservative,
305                degradation: vec![DegradationKindV1::ExactnessDowngraded],
306                freshness: Some("current".into()),
307                replay_limited: false,
308                execution_contaminated: false,
309            },
310            evidence_admissibility: EvidenceAdmissibilityV1::Admissible,
311            contradiction_witness_id: None,
312            valid_from: None,
313            valid_to: None,
314            tx_from: "2026-03-14T12:05:00Z".into(),
315            tx_to: None,
316            proof_obligations_remaining: vec![],
317            policy_action_allowed: true,
318        };
319
320        assert!(claim_state.validate().is_err());
321    }
322}