Skip to main content

semantic_memory/
transition_contracts.rs

1//! Versioned contracts for deterministic, source-grounded memory transitions.
2
3use serde::{Deserialize, Serialize};
4
5pub const MEMORY_TRANSITION_CANDIDATE_V1: &str = "memory_transition_candidate_v1";
6pub const MEMORY_TRANSITION_VERIFICATION_V1: &str = "memory_transition_verification_v1";
7pub const MEMORY_TRANSITION_RECORD_V1: &str = "memory_transition_record_v1";
8
9/// Immutable raw evidence supplied to the transition compiler.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct SourceArtifactV1 {
12    pub artifact_id: String,
13    pub content: String,
14    pub content_digest: String,
15}
16
17impl SourceArtifactV1 {
18    pub fn new(artifact_id: impl Into<String>, content: impl Into<String>) -> Result<Self, String> {
19        let artifact_id = artifact_id.into();
20        let content = content.into();
21        if artifact_id.trim().is_empty() {
22            return Err("source artifact ID must not be empty".into());
23        }
24        let content_digest = blake3::hash(content.as_bytes()).to_hex().to_string();
25        Ok(Self {
26            artifact_id,
27            content,
28            content_digest,
29        })
30    }
31}
32
33/// Byte-exact reference into one immutable source artifact.
34#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
35pub struct SourceSpanRefV1 {
36    pub artifact_id: String,
37    pub start_byte: usize,
38    pub end_byte: usize,
39}
40
41impl SourceSpanRefV1 {
42    pub fn new(
43        artifact_id: impl Into<String>,
44        start_byte: usize,
45        end_byte: usize,
46    ) -> Result<Self, String> {
47        let artifact_id = artifact_id.into();
48        if artifact_id.trim().is_empty() {
49            return Err("source span artifact ID must not be empty".into());
50        }
51        if start_byte >= end_byte {
52            return Err("source span range must be non-empty and increasing".into());
53        }
54        Ok(Self {
55            artifact_id,
56            start_byte,
57            end_byte,
58        })
59    }
60}
61
62/// One proposed canonical assertion and its complete source grounding.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct AssertionDraftV1 {
65    pub assertion_id: String,
66    pub namespace: String,
67    pub content: String,
68    pub source_spans: Vec<SourceSpanRefV1>,
69    pub dependency_fact_ids: Vec<String>,
70}
71
72impl AssertionDraftV1 {
73    pub fn new(
74        assertion_id: impl Into<String>,
75        namespace: impl Into<String>,
76        content: impl Into<String>,
77        source_spans: Vec<SourceSpanRefV1>,
78        dependency_fact_ids: Vec<String>,
79    ) -> Result<Self, String> {
80        let value = Self {
81            assertion_id: assertion_id.into(),
82            namespace: namespace.into(),
83            content: content.into(),
84            source_spans,
85            dependency_fact_ids,
86        };
87        if value.assertion_id.trim().is_empty() {
88            return Err("assertion ID must not be empty".into());
89        }
90        if value.namespace.trim().is_empty() || value.content.is_empty() {
91            return Err("assertion namespace and content must not be empty".into());
92        }
93        if value.source_spans.is_empty() {
94            return Err("assertion must cite at least one exact source span".into());
95        }
96        if value
97            .dependency_fact_ids
98            .iter()
99            .any(|id| id.trim().is_empty())
100        {
101            return Err("dependency fact IDs must not be empty".into());
102        }
103        Ok(value)
104    }
105}
106
107/// Explicit replacement of one current authority head.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SupersessionDraftV1 {
110    pub target_fact_id: String,
111    pub replacement_assertion_id: String,
112}
113
114impl SupersessionDraftV1 {
115    pub fn new(
116        target_fact_id: impl Into<String>,
117        replacement_assertion_id: impl Into<String>,
118    ) -> Result<Self, String> {
119        let value = Self {
120            target_fact_id: target_fact_id.into(),
121            replacement_assertion_id: replacement_assertion_id.into(),
122        };
123        if value.target_fact_id.trim().is_empty()
124            || value.replacement_assertion_id.trim().is_empty()
125        {
126            return Err("supersession target and replacement IDs must not be empty".into());
127        }
128        Ok(value)
129    }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(tag = "kind", rename_all = "snake_case")]
134pub enum TransitionOperation {
135    Append {
136        assertion_id: String,
137    },
138    Supersede {
139        draft: SupersessionDraftV1,
140    },
141    Retract {
142        target_fact_id: String,
143        reason: String,
144        source_spans: Vec<SourceSpanRefV1>,
145    },
146}
147
148/// Candidate proposed by extraction or an operator before canonical admission.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct MemoryTransitionCandidateV1 {
151    pub schema_version: String,
152    pub candidate_id: String,
153    pub source_artifacts: Vec<SourceArtifactV1>,
154    pub required_source_spans: Vec<SourceSpanRefV1>,
155    pub assertions: Vec<AssertionDraftV1>,
156    pub operation: TransitionOperation,
157    /// Current assertions in affected namespaces that the proposal explicitly retains.
158    pub preserved_active_fact_ids: Vec<String>,
159}
160
161impl MemoryTransitionCandidateV1 {
162    #[allow(clippy::too_many_arguments)]
163    pub fn new(
164        candidate_id: impl Into<String>,
165        source_artifacts: Vec<SourceArtifactV1>,
166        required_source_spans: Vec<SourceSpanRefV1>,
167        assertions: Vec<AssertionDraftV1>,
168        operation: TransitionOperation,
169        preserved_active_fact_ids: Vec<String>,
170    ) -> Result<Self, String> {
171        let value = Self {
172            schema_version: MEMORY_TRANSITION_CANDIDATE_V1.into(),
173            candidate_id: candidate_id.into(),
174            source_artifacts,
175            required_source_spans,
176            assertions,
177            operation,
178            preserved_active_fact_ids,
179        };
180        value.validate()?;
181        Ok(value)
182    }
183
184    pub fn validate(&self) -> Result<(), String> {
185        if self.schema_version != MEMORY_TRANSITION_CANDIDATE_V1 {
186            return Err(format!(
187                "unsupported transition candidate schema '{}'",
188                self.schema_version
189            ));
190        }
191        if self.candidate_id.trim().is_empty() {
192            return Err("transition candidate ID must not be empty".into());
193        }
194        if self.source_artifacts.is_empty() {
195            return Err("transition candidate must retain at least one source artifact".into());
196        }
197        let artifacts: std::collections::BTreeMap<&str, &SourceArtifactV1> = self
198            .source_artifacts
199            .iter()
200            .map(|artifact| (artifact.artifact_id.as_str(), artifact))
201            .collect();
202        if artifacts.len() != self.source_artifacts.len() {
203            return Err("source artifact IDs must be unique".into());
204        }
205        for artifact in &self.source_artifacts {
206            if artifact.artifact_id.trim().is_empty() {
207                return Err("source artifact ID must not be empty".into());
208            }
209            let digest = blake3::hash(artifact.content.as_bytes())
210                .to_hex()
211                .to_string();
212            if digest != artifact.content_digest {
213                return Err(format!(
214                    "source artifact '{}' content digest mismatch",
215                    artifact.artifact_id
216                ));
217            }
218        }
219        if self.required_source_spans.is_empty() {
220            return Err("transition candidate must declare required source spans".into());
221        }
222        if self
223            .preserved_active_fact_ids
224            .iter()
225            .any(|id| id.trim().is_empty())
226        {
227            return Err("preserved active fact IDs must not be empty".into());
228        }
229        let mut all_spans: Vec<&SourceSpanRefV1> = self.required_source_spans.iter().collect();
230        all_spans.extend(
231            self.assertions
232                .iter()
233                .flat_map(|assertion| assertion.source_spans.iter()),
234        );
235        if let TransitionOperation::Retract { source_spans, .. } = &self.operation {
236            all_spans.extend(source_spans);
237        }
238        for span in all_spans {
239            let artifact = artifacts.get(span.artifact_id.as_str()).ok_or_else(|| {
240                format!(
241                    "source span references missing source artifact '{}'",
242                    span.artifact_id
243                )
244            })?;
245            if span.start_byte >= span.end_byte
246                || span.end_byte > artifact.content.len()
247                || !artifact.content.is_char_boundary(span.start_byte)
248                || !artifact.content.is_char_boundary(span.end_byte)
249            {
250                return Err(format!(
251                    "source span {}:{}..{} is outside UTF-8 boundaries",
252                    span.artifact_id, span.start_byte, span.end_byte
253                ));
254            }
255        }
256        let assertion_ids: std::collections::BTreeSet<&str> = self
257            .assertions
258            .iter()
259            .map(|assertion| assertion.assertion_id.as_str())
260            .collect();
261        if assertion_ids.len() != self.assertions.len() {
262            return Err("assertion IDs must be unique".into());
263        }
264        let assertion_id = match &self.operation {
265            TransitionOperation::Append { assertion_id } => Some(assertion_id.as_str()),
266            TransitionOperation::Supersede { draft } => {
267                Some(draft.replacement_assertion_id.as_str())
268            }
269            TransitionOperation::Retract {
270                target_fact_id,
271                reason,
272                source_spans,
273            } => {
274                if target_fact_id.trim().is_empty()
275                    || reason.trim().is_empty()
276                    || source_spans.is_empty()
277                {
278                    return Err(
279                        "retraction requires a target, reason, and exact source spans".into(),
280                    );
281                }
282                None
283            }
284        };
285        if let Some(assertion_id) = assertion_id {
286            let matching = self
287                .assertions
288                .iter()
289                .filter(|draft| draft.assertion_id == assertion_id)
290                .count();
291            if matching != 1 {
292                return Err("operation must reference exactly one assertion draft".into());
293            }
294        }
295        Ok(())
296    }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum TransitionDisposition {
302    Commit,
303    Quarantine,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307pub struct VerificationScore {
308    pub satisfied: u64,
309    pub total: u64,
310}
311
312impl VerificationScore {
313    pub fn is_complete(self) -> bool {
314        self.satisfied == self.total
315    }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct OmittedSourceSpanV1 {
320    pub span: SourceSpanRefV1,
321    pub exact_text: String,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct UnsupportedAssertionSpanV1 {
326    pub assertion_id: String,
327    pub start_byte: usize,
328    pub end_byte: usize,
329    pub exact_text: String,
330    pub reason: String,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct ActiveHeadSimulationV1 {
335    pub consistent: bool,
336    pub replaced_fact_id: Option<String>,
337    pub projected_active_fact_ids: Vec<String>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct DependencySimulationV1 {
342    pub requested_fact_ids: Vec<String>,
343    pub missing_or_inactive_fact_ids: Vec<String>,
344    pub preserved_edge_ids: Vec<String>,
345}
346
347/// Deterministic compiler result. It intentionally carries no wall-clock fields.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct MemoryTransitionVerificationV1 {
350    pub schema_version: String,
351    pub candidate_id: String,
352    pub candidate_digest: String,
353    pub source_digest: String,
354    pub coverage: VerificationScore,
355    pub preservation: VerificationScore,
356    pub faithfulness: VerificationScore,
357    pub omitted_spans: Vec<OmittedSourceSpanV1>,
358    pub unsupported_spans: Vec<UnsupportedAssertionSpanV1>,
359    pub omitted_active_fact_ids: Vec<String>,
360    pub diagnostics: Vec<String>,
361    pub active_head_simulation: ActiveHeadSimulationV1,
362    pub dependency_simulation: DependencySimulationV1,
363    pub disposition: TransitionDisposition,
364    pub verification_digest: String,
365}
366
367/// Immutable audit/quarantine row adjacent to the existing authority journal.
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct MemoryTransitionRecordV1 {
370    pub schema_version: String,
371    pub record_id: String,
372    pub caller_idempotency_key: String,
373    pub principal: String,
374    pub caller_id: String,
375    pub candidate_digest: String,
376    pub candidate: MemoryTransitionCandidateV1,
377    pub verification: MemoryTransitionVerificationV1,
378    pub disposition: TransitionDisposition,
379    pub authority_receipt_id: Option<String>,
380    pub created_at: String,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(tag = "disposition", rename_all = "snake_case")]
385#[allow(clippy::large_enum_variant)] // wire shape is already published; boxing would break it
386pub enum MemoryTransitionOutcomeV1 {
387    Committed {
388        record: MemoryTransitionRecordV1,
389        verification: MemoryTransitionVerificationV1,
390        authority_receipt: crate::AuthorityReceiptV1,
391    },
392    Quarantined {
393        record: MemoryTransitionRecordV1,
394    },
395}