Skip to main content

semantic_memory/
state_epistemics.rs

1//! Deterministic state-resolution and answer-disposition contracts.
2//!
3//! This module interprets the existing authoritative facts and graph edges. It
4//! does not maintain a second memory store: dependency edges are encoded in
5//! the existing `graph_edges` table and resolution reads the existing search
6//! and state-view APIs.
7
8use crate::authority_contracts::{
9    AuthoritySnapshotId, RetrievalEpoch, RetrievalWitnessV1, StageOutcomeV1,
10};
11use crate::knowledge::StateView;
12use crate::types::{GraphEdgeType, SearchResult, SearchSource};
13use crate::{MemoryError, MemoryStore};
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, BTreeSet};
17
18pub const STATE_RESOLUTION_RECEIPT_V1: &str = "state_resolution_receipt_v1";
19pub const STATE_RESOLVED_RETRIEVAL_V1: &str = "state_resolved_retrieval_v1";
20
21/// The resolution request, kept separate from the compatible `StateView` wire enum.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum StateResolutionMode {
25    Current,
26    HistoricalAt(String),
27    Transition { from: String, to: String },
28    Trajectory { points: Vec<String> },
29}
30
31impl StateResolutionMode {
32    pub fn historical_at(as_of: impl Into<String>) -> Self {
33        Self::HistoricalAt(as_of.into())
34    }
35
36    pub fn transition(from: impl Into<String>, to: impl Into<String>) -> Self {
37        Self::Transition {
38            from: from.into(),
39            to: to.into(),
40        }
41    }
42
43    pub fn trajectory(points: Vec<String>) -> Self {
44        Self::Trajectory { points }
45    }
46
47    /// Map a resolution request to the existing authoritative fact view.
48    pub fn state_view(&self) -> StateView {
49        match self {
50            Self::Current => StateView::Current,
51            Self::HistoricalAt(as_of) => StateView::HistoricalAt(as_of.clone()),
52            Self::Transition { .. } | Self::Trajectory { .. } => StateView::IncludeSuperseded,
53        }
54    }
55}
56
57/// Explicit epistemic classification of a premise.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum PremiseStatus {
61    Supported,
62    Stale,
63    Contradicted,
64    Unsupported,
65    Ambiguous,
66}
67
68/// The only answer dispositions emitted by state resolution.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum AnswerDisposition {
72    Answer,
73    CorrectPremise,
74    DiscloseConflict,
75    Abstain,
76    RequestEvidence,
77}
78
79/// Compatibility name used by policy callers.
80pub type AnswerPolicy = AnswerDisposition;
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum DependencyState {
85    Valid,
86    Invalid,
87    Uncertain,
88    Pending,
89}
90
91/// Typed state-dependency relation stored through the existing graph API.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case", tag = "kind")]
94pub enum StateDependencyEdgeV1 {
95    Invalidates {
96        source_fact_id: String,
97        target_fact_id: String,
98    },
99    Weakens {
100        source_fact_id: String,
101        target_fact_id: String,
102    },
103    RequiresReevaluation {
104        source_fact_id: String,
105        target_fact_id: String,
106    },
107    ScopeChanges {
108        source_fact_id: String,
109        target_fact_id: String,
110        from_scope: String,
111        to_scope: String,
112    },
113    DerivedFromState {
114        source_fact_id: String,
115        target_fact_id: String,
116    },
117}
118
119impl StateDependencyEdgeV1 {
120    pub fn invalidates(source: impl Into<String>, target: impl Into<String>) -> Self {
121        Self::Invalidates {
122            source_fact_id: source.into(),
123            target_fact_id: target.into(),
124        }
125    }
126
127    pub fn weakens(source: impl Into<String>, target: impl Into<String>) -> Self {
128        Self::Weakens {
129            source_fact_id: source.into(),
130            target_fact_id: target.into(),
131        }
132    }
133
134    pub fn requires_reevaluation(source: impl Into<String>, target: impl Into<String>) -> Self {
135        Self::RequiresReevaluation {
136            source_fact_id: source.into(),
137            target_fact_id: target.into(),
138        }
139    }
140
141    pub fn scope_changes(
142        source: impl Into<String>,
143        target: impl Into<String>,
144        from_scope: impl Into<String>,
145        to_scope: impl Into<String>,
146    ) -> Self {
147        Self::ScopeChanges {
148            source_fact_id: source.into(),
149            target_fact_id: target.into(),
150            from_scope: from_scope.into(),
151            to_scope: to_scope.into(),
152        }
153    }
154
155    pub fn derived_from_state(source: impl Into<String>, target: impl Into<String>) -> Self {
156        Self::DerivedFromState {
157            source_fact_id: source.into(),
158            target_fact_id: target.into(),
159        }
160    }
161
162    pub fn source(&self) -> &str {
163        match self {
164            Self::Invalidates { source_fact_id, .. }
165            | Self::Weakens { source_fact_id, .. }
166            | Self::RequiresReevaluation { source_fact_id, .. }
167            | Self::ScopeChanges { source_fact_id, .. }
168            | Self::DerivedFromState { source_fact_id, .. } => source_fact_id,
169        }
170    }
171
172    pub fn target(&self) -> &str {
173        match self {
174            Self::Invalidates { target_fact_id, .. }
175            | Self::Weakens { target_fact_id, .. }
176            | Self::RequiresReevaluation { target_fact_id, .. }
177            | Self::ScopeChanges { target_fact_id, .. }
178            | Self::DerivedFromState { target_fact_id, .. } => target_fact_id,
179        }
180    }
181
182    pub fn relation(&self) -> &'static str {
183        match self {
184            Self::Invalidates { .. } => "invalidates",
185            Self::Weakens { .. } => "weakens",
186            Self::RequiresReevaluation { .. } => "requires_reevaluation",
187            Self::ScopeChanges { .. } => "scope_changes",
188            Self::DerivedFromState { .. } => "derived_from_state",
189        }
190    }
191
192    pub fn digest(&self) -> String {
193        blake3::hash(&serde_json::to_vec(self).expect("state edge is serializable"))
194            .to_hex()
195            .to_string()
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct DependencyResolutionV1 {
201    pub states: BTreeMap<String, DependencyState>,
202    pub premise_status: PremiseStatus,
203    pub invalid_lineage: bool,
204    pub unresolved_conflict: bool,
205    pub budget_exhausted: bool,
206    pub visited_nodes: Vec<String>,
207}
208
209impl DependencyResolutionV1 {
210    pub fn status(&self, id: &str) -> Option<DependencyState> {
211        self.states.get(id).copied()
212    }
213}
214
215fn set_state(states: &mut BTreeMap<String, DependencyState>, id: &str, next: DependencyState) {
216    let rank = |state: DependencyState| match state {
217        DependencyState::Valid => 0,
218        DependencyState::Pending => 1,
219        DependencyState::Uncertain => 2,
220        DependencyState::Invalid => 3,
221    };
222    let replace = states
223        .get(id)
224        .map_or(true, |current| rank(next) > rank(*current));
225    if replace {
226        states.insert(id.to_string(), next);
227    }
228}
229
230fn has_cycle(edges: &[StateDependencyEdgeV1]) -> bool {
231    let mut adjacency: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
232    for edge in edges {
233        adjacency
234            .entry(edge.source())
235            .or_default()
236            .push(edge.target());
237    }
238    fn visit<'a>(
239        node: &'a str,
240        adjacency: &BTreeMap<&'a str, Vec<&'a str>>,
241        visiting: &mut BTreeSet<&'a str>,
242        visited: &mut BTreeSet<&'a str>,
243    ) -> bool {
244        if visiting.contains(node) {
245            return true;
246        }
247        if visited.contains(node) {
248            return false;
249        }
250        visiting.insert(node);
251        if let Some(next) = adjacency.get(node) {
252            if next
253                .iter()
254                .any(|child| visit(child, adjacency, visiting, visited))
255            {
256                return true;
257            }
258        }
259        visiting.remove(node);
260        visited.insert(node);
261        false
262    }
263    let mut visiting = BTreeSet::new();
264    let mut visited = BTreeSet::new();
265    adjacency
266        .keys()
267        .any(|node| visit(node, &adjacency, &mut visiting, &mut visited))
268}
269
270/// Resolve the existing dependency graph with a deterministic node budget.
271pub fn resolve_dependency_states(
272    edges: &[StateDependencyEdgeV1],
273    roots: &[String],
274    budget: usize,
275) -> DependencyResolutionV1 {
276    let mut states = BTreeMap::new();
277    let mut visited = BTreeSet::new();
278    for root in roots {
279        if !root.trim().is_empty() {
280            states.insert(root.clone(), DependencyState::Valid);
281        }
282    }
283    let mut invalid_lineage = has_cycle(edges);
284    let mut target_invalidators: BTreeMap<&str, usize> = BTreeMap::new();
285    for edge in edges {
286        if edge.source().trim().is_empty() || edge.target().trim().is_empty() {
287            invalid_lineage = true;
288        }
289        if matches!(edge, StateDependencyEdgeV1::Invalidates { .. }) {
290            *target_invalidators.entry(edge.target()).or_default() += 1;
291        }
292    }
293    let unresolved_conflict = target_invalidators.values().any(|count| *count > 1);
294    invalid_lineage |= unresolved_conflict;
295
296    if budget == 0 && !states.is_empty() {
297        return DependencyResolutionV1 {
298            states: states
299                .into_keys()
300                .map(|id| (id, DependencyState::Pending))
301                .collect(),
302            premise_status: PremiseStatus::Unsupported,
303            invalid_lineage,
304            unresolved_conflict,
305            budget_exhausted: true,
306            visited_nodes: Vec::new(),
307        };
308    }
309
310    let mut changed = true;
311    while changed {
312        changed = false;
313        for edge in edges {
314            if visited.len() >= budget && budget > 0 {
315                break;
316            }
317            let source_state = states.get(edge.source()).copied();
318            let target_state = states.get(edge.target()).copied();
319            if source_state.is_some() || target_state.is_some() {
320                visited.insert(edge.source().to_string());
321                visited.insert(edge.target().to_string());
322            }
323            let before = states.clone();
324            match edge {
325                StateDependencyEdgeV1::Invalidates { .. } => {
326                    // An invalidation is authoritative for the target head even
327                    // when the invalidating successor is outside the requested
328                    // root set (the common stale-premise shape).
329                    if source_state.is_some() || target_state.is_some() {
330                        set_state(&mut states, edge.target(), DependencyState::Invalid);
331                    }
332                }
333                StateDependencyEdgeV1::Weakens { .. }
334                | StateDependencyEdgeV1::RequiresReevaluation { .. }
335                | StateDependencyEdgeV1::ScopeChanges { .. } => {
336                    if source_state.is_some() || target_state.is_some() {
337                        set_state(&mut states, edge.target(), DependencyState::Uncertain);
338                    }
339                }
340                StateDependencyEdgeV1::DerivedFromState { .. } => {
341                    if let Some(state) = target_state {
342                        set_state(&mut states, edge.source(), state);
343                    }
344                }
345            }
346            changed |= before != states;
347        }
348        if budget > 0 && visited.len() >= budget {
349            break;
350        }
351    }
352    let budget_exhausted = budget > 0 && (visited.len() >= budget && edges.len() > visited.len());
353    let aggregate = if invalid_lineage || unresolved_conflict {
354        PremiseStatus::Ambiguous
355    } else if states.values().any(|s| *s == DependencyState::Invalid) {
356        // An invalidation/supersession makes the addressed premise stale. A
357        // competing live head is represented separately as ambiguity/conflict.
358        PremiseStatus::Stale
359    } else if states.values().any(|s| *s == DependencyState::Uncertain) {
360        PremiseStatus::Ambiguous
361    } else if states.values().any(|s| *s == DependencyState::Pending) || budget_exhausted {
362        PremiseStatus::Unsupported
363    } else if states.is_empty() {
364        PremiseStatus::Unsupported
365    } else {
366        PremiseStatus::Supported
367    };
368    DependencyResolutionV1 {
369        states,
370        premise_status: aggregate,
371        invalid_lineage,
372        unresolved_conflict,
373        budget_exhausted,
374        visited_nodes: visited.into_iter().collect(),
375    }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
379pub struct AnswerPolicyDecision {
380    pub disposition: AnswerDisposition,
381    pub confident_negative: bool,
382}
383
384/// Map premise and evidence conditions to a safe explicit disposition.
385pub fn answer_policy_for(
386    premise_status: PremiseStatus,
387    evidence_sufficient: bool,
388    unresolved_conflict: bool,
389    invalid_lineage: bool,
390    budget_exhausted: bool,
391) -> AnswerPolicyDecision {
392    let disposition =
393        if invalid_lineage || unresolved_conflict || premise_status == PremiseStatus::Ambiguous {
394            AnswerDisposition::DiscloseConflict
395        } else if !evidence_sufficient
396            || premise_status == PremiseStatus::Unsupported
397            || budget_exhausted
398        {
399            AnswerDisposition::RequestEvidence
400        } else {
401            match premise_status {
402                PremiseStatus::Supported => AnswerDisposition::Answer,
403                PremiseStatus::Stale => AnswerDisposition::CorrectPremise,
404                PremiseStatus::Contradicted => AnswerDisposition::DiscloseConflict,
405                PremiseStatus::Unsupported | PremiseStatus::Ambiguous => {
406                    AnswerDisposition::RequestEvidence
407                }
408            }
409        };
410    AnswerPolicyDecision {
411        disposition,
412        // This kernel has no confident-negative disposition. Missing evidence,
413        // conflict, invalid lineage, and budget exhaustion therefore cannot be
414        // represented as a confident negative answer.
415        confident_negative: false,
416    }
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct ResolvedAssertionV1 {
421    pub schema_version: String,
422    pub memory_id: String,
423    pub content: String,
424    pub premise_status: PremiseStatus,
425    pub state_view: StateView,
426    pub source_kind: String,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct BeliefAlternativeV1 {
431    pub assertion: ResolvedAssertionV1,
432    pub reason: String,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct StateResolutionReceiptV1 {
437    pub schema_version: String,
438    pub receipt_id: String,
439    pub request_id: String,
440    pub state_view: StateView,
441    pub resolution_mode: StateResolutionMode,
442    pub premise_status: PremiseStatus,
443    pub answer_disposition: AnswerDisposition,
444    pub resolved_memory_ids: Vec<String>,
445    pub alternative_memory_ids: Vec<String>,
446    pub dependency_edge_digests: Vec<String>,
447    pub evidence_sufficient: bool,
448    pub unresolved_conflict: bool,
449    pub invalid_lineage: bool,
450    pub budget_exhausted: bool,
451    pub evaluated_at: String,
452    pub receipt_digest: String,
453    pub stage_outcomes: Vec<(String, StageOutcomeV1)>,
454    pub degradations: Vec<String>,
455}
456
457#[derive(Serialize)]
458struct ReceiptDigestInput<'a> {
459    schema_version: &'a str,
460    request_id: &'a str,
461    state_view: &'a StateView,
462    resolution_mode: &'a StateResolutionMode,
463    premise_status: PremiseStatus,
464    answer_disposition: AnswerDisposition,
465    resolved_memory_ids: &'a [String],
466    alternative_memory_ids: &'a [String],
467    dependency_edge_digests: &'a [String],
468    evidence_sufficient: bool,
469    unresolved_conflict: bool,
470    invalid_lineage: bool,
471    budget_exhausted: bool,
472}
473
474impl StateResolutionReceiptV1 {
475    fn digest_for(&self) -> Result<String, MemoryError> {
476        let input = ReceiptDigestInput {
477            schema_version: &self.schema_version,
478            request_id: &self.request_id,
479            state_view: &self.state_view,
480            resolution_mode: &self.resolution_mode,
481            premise_status: self.premise_status,
482            answer_disposition: self.answer_disposition,
483            resolved_memory_ids: &self.resolved_memory_ids,
484            alternative_memory_ids: &self.alternative_memory_ids,
485            dependency_edge_digests: &self.dependency_edge_digests,
486            evidence_sufficient: self.evidence_sufficient,
487            unresolved_conflict: self.unresolved_conflict,
488            invalid_lineage: self.invalid_lineage,
489            budget_exhausted: self.budget_exhausted,
490        };
491        Ok(blake3::hash(
492            &serde_json::to_vec(&input)
493                .map_err(|e| MemoryError::Other(format!("receipt serialization failed: {e}")))?,
494        )
495        .to_hex()
496        .to_string())
497    }
498
499    pub fn with_dependency_edge_digests(
500        mut self,
501        dependency_edge_digests: Vec<String>,
502    ) -> Result<Self, MemoryError> {
503        self.dependency_edge_digests = dependency_edge_digests;
504        self.receipt_digest = self.digest_for()?;
505        self.receipt_id = format!("state-resolution:{}", self.receipt_digest);
506        Ok(self)
507    }
508
509    #[allow(clippy::too_many_arguments)]
510    pub fn new_at(
511        request_id: impl Into<String>,
512        state_view: StateView,
513        resolution_mode: StateResolutionMode,
514        premise_status: PremiseStatus,
515        answer_disposition: AnswerDisposition,
516        resolved_memory_ids: Vec<String>,
517        alternative_memory_ids: Vec<String>,
518        evidence_sufficient: bool,
519        unresolved_conflict: bool,
520        invalid_lineage: bool,
521        budget_exhausted: bool,
522        evaluated_at: impl Into<String>,
523    ) -> Result<Self, MemoryError> {
524        let request_id = request_id.into();
525        let evaluated_at = evaluated_at.into();
526        if request_id.trim().is_empty() {
527            return Err(MemoryError::Other(
528                "state resolution request ID must not be empty".into(),
529            ));
530        }
531        DateTime::parse_from_rfc3339(&evaluated_at).map_err(|e| {
532            MemoryError::Other(format!(
533                "invalid state resolution timestamp '{evaluated_at}': {e}"
534            ))
535        })?;
536        validate_state_view(&state_view)?;
537        let schema_version = STATE_RESOLUTION_RECEIPT_V1.to_string();
538        let input = ReceiptDigestInput {
539            schema_version: &schema_version,
540            request_id: &request_id,
541            state_view: &state_view,
542            resolution_mode: &resolution_mode,
543            premise_status,
544            answer_disposition,
545            resolved_memory_ids: &resolved_memory_ids,
546            alternative_memory_ids: &alternative_memory_ids,
547            dependency_edge_digests: &[],
548            evidence_sufficient,
549            unresolved_conflict,
550            invalid_lineage,
551            budget_exhausted,
552        };
553        let receipt_digest = blake3::hash(
554            &serde_json::to_vec(&input)
555                .map_err(|e| MemoryError::Other(format!("receipt serialization failed: {e}")))?,
556        )
557        .to_hex()
558        .to_string();
559        let receipt_id = format!("state-resolution:{receipt_digest}");
560        Ok(Self {
561            schema_version,
562            receipt_id,
563            request_id,
564            state_view,
565            resolution_mode,
566            premise_status,
567            answer_disposition,
568            resolved_memory_ids,
569            alternative_memory_ids,
570            dependency_edge_digests: Vec::new(),
571            evidence_sufficient,
572            unresolved_conflict,
573            invalid_lineage,
574            budget_exhausted,
575            evaluated_at,
576            receipt_digest,
577            stage_outcomes: vec![("state_resolution".into(), StageOutcomeV1::Applied)],
578            degradations: Vec::new(),
579        })
580    }
581
582    pub fn new(
583        request_id: impl Into<String>,
584        state_view: StateView,
585        resolution_mode: StateResolutionMode,
586        premise_status: PremiseStatus,
587        answer_disposition: AnswerDisposition,
588        resolved_memory_ids: Vec<String>,
589        alternative_memory_ids: Vec<String>,
590        evidence_sufficient: bool,
591        unresolved_conflict: bool,
592        invalid_lineage: bool,
593        budget_exhausted: bool,
594    ) -> Result<Self, MemoryError> {
595        Self::new_at(
596            request_id,
597            state_view,
598            resolution_mode,
599            premise_status,
600            answer_disposition,
601            resolved_memory_ids,
602            alternative_memory_ids,
603            evidence_sufficient,
604            unresolved_conflict,
605            invalid_lineage,
606            budget_exhausted,
607            Utc::now().to_rfc3339(),
608        )
609    }
610}
611
612fn validate_state_view(view: &StateView) -> Result<(), MemoryError> {
613    if let StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) = view {
614        DateTime::parse_from_rfc3339(value).map_err(|e| {
615            MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
616        })?;
617    }
618    Ok(())
619}
620
621#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
622pub struct ResolvedMemoryAnswerV1 {
623    pub schema_version: String,
624    pub query: String,
625    pub answer: Option<String>,
626    pub premise_status: PremiseStatus,
627    pub answer_disposition: AnswerDisposition,
628    pub state_view: StateView,
629    pub assertions: Vec<ResolvedAssertionV1>,
630    pub alternatives: Vec<BeliefAlternativeV1>,
631    pub receipt: StateResolutionReceiptV1,
632    pub retrieval_witness: RetrievalWitnessV1,
633}
634
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636pub struct StateResolvedRetrievalResponseV1<T> {
637    pub schema_version: String,
638    pub state_view: StateView,
639    pub results: Vec<T>,
640    pub retrieval_witness: RetrievalWitnessV1,
641    pub resolution_receipt: StateResolutionReceiptV1,
642}
643
644fn edge_from_stored(edge: &crate::StoredGraphEdge) -> Option<StateDependencyEdgeV1> {
645    let graph_type = edge
646        .edge_type_parsed
647        .clone()
648        .or_else(|| serde_json::from_str(&edge.edge_type).ok())?;
649    let GraphEdgeType::Entity { relation } = graph_type else {
650        return None;
651    };
652    match relation.as_str() {
653        "invalidates" => Some(StateDependencyEdgeV1::invalidates(
654            &edge.source,
655            &edge.target,
656        )),
657        "weakens" => Some(StateDependencyEdgeV1::weakens(&edge.source, &edge.target)),
658        "requires_reevaluation" => Some(StateDependencyEdgeV1::requires_reevaluation(
659            &edge.source,
660            &edge.target,
661        )),
662        "scope_changes" => {
663            let metadata = edge
664                .metadata
665                .as_deref()
666                .and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok());
667            Some(StateDependencyEdgeV1::scope_changes(
668                &edge.source,
669                &edge.target,
670                metadata
671                    .as_ref()
672                    .and_then(|v| v.get("from_scope"))
673                    .and_then(|v| v.as_str())
674                    .unwrap_or("unknown"),
675                metadata
676                    .as_ref()
677                    .and_then(|v| v.get("to_scope"))
678                    .and_then(|v| v.as_str())
679                    .unwrap_or("unknown"),
680            ))
681        }
682        "derived_from_state" => Some(StateDependencyEdgeV1::derived_from_state(
683            &edge.source,
684            &edge.target,
685        )),
686        _ => None,
687    }
688}
689
690fn result_id(result: &SearchResult) -> String {
691    result.source.result_id()
692}
693
694fn result_status(result: &SearchResult, resolution: &DependencyResolutionV1) -> PremiseStatus {
695    let id = match &result.source {
696        SearchSource::Fact { fact_id, .. } => format!("fact:{fact_id}"),
697        _ => result_id(result),
698    };
699    match resolution.status(&id) {
700        Some(DependencyState::Invalid) => PremiseStatus::Stale,
701        Some(DependencyState::Uncertain) => PremiseStatus::Ambiguous,
702        Some(DependencyState::Pending) => PremiseStatus::Unsupported,
703        Some(DependencyState::Valid) => PremiseStatus::Supported,
704        None => PremiseStatus::Supported,
705    }
706}
707
708pub(crate) async fn witnessed_retrieval(
709    store: &MemoryStore,
710    request_id: String,
711    query: &str,
712    results: &[SearchResult],
713) -> Result<RetrievalWitnessV1, MemoryError> {
714    let query_digest = blake3::hash(query.as_bytes()).to_hex().to_string();
715    let result_ids = results.iter().map(result_id).collect::<Vec<_>>();
716    let result_digests = results
717        .iter()
718        .map(|result| blake3::hash(result.content.as_bytes()).to_hex().to_string())
719        .collect::<Vec<_>>();
720    let (epoch, heads) = store
721        .with_read_conn(move |conn| {
722            let epoch: i64 = conn.query_row(
723                "SELECT retrieval_epoch FROM authority_state WHERE id = 1",
724                [],
725                |row| row.get(0),
726            )?;
727            let heads: Vec<(String, String)> = conn
728                .prepare(
729                    "SELECT lineage_id, active_head_id FROM authority_lineages ORDER BY lineage_id",
730                )?
731                .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
732                .collect::<Result<Vec<_>, _>>()?;
733            let epoch = u64::try_from(epoch)
734                .map_err(|_| MemoryError::Other("negative authority epoch".into()))?;
735            Ok((epoch, heads))
736        })
737        .await?;
738    let snapshot_digest = blake3::hash(
739        &serde_json::to_vec(&(epoch, &heads))
740            .map_err(|e| MemoryError::Other(format!("snapshot serialization failed: {e}")))?,
741    )
742    .to_hex()
743    .to_string();
744    Ok(RetrievalWitnessV1 {
745        schema_version: "retrieval_witness_v1".into(),
746        request_id,
747        evaluated_at: Utc::now().to_rfc3339(),
748        authority_snapshot_id: AuthoritySnapshotId(format!("epoch:{epoch}:{snapshot_digest}")),
749        retrieval_epoch: RetrievalEpoch(epoch),
750        query_digest,
751        config_digest: blake3::hash(b"state-resolution-search-v1")
752            .to_hex()
753            .to_string(),
754        ordered_result_ids: result_ids,
755        ordered_result_digests: result_digests,
756        stage_outcomes: vec![
757            ("retrieval".into(), StageOutcomeV1::Applied),
758            ("state_resolution".into(), StageOutcomeV1::Applied),
759        ],
760        degradations: Vec::new(),
761        cached_witness_parent: None,
762    })
763}
764
765impl MemoryStore {
766    /// Resolve a query against the existing search/state/graph APIs and return a
767    /// state-labeled answer with a non-optional resolution receipt.
768    pub async fn resolve_memory(
769        &self,
770        query: &str,
771        top_k: Option<usize>,
772        namespaces: Option<&[&str]>,
773        mode: StateResolutionMode,
774        budget: usize,
775    ) -> Result<ResolvedMemoryAnswerV1, MemoryError> {
776        let state_view = mode.state_view();
777        let results = self
778            .search_with_view(query, top_k, namespaces, None, state_view.clone())
779            .await?;
780        let result_ids: Vec<String> = results.iter().map(result_id).collect();
781        let fact_ids: Vec<String> = results
782            .iter()
783            .filter_map(|result| match &result.source {
784                SearchSource::Fact { fact_id, .. } => Some(format!("fact:{fact_id}")),
785                _ => None,
786            })
787            .collect();
788        let stored_edges = if fact_ids.is_empty() {
789            Vec::new()
790        } else {
791            self.list_graph_edges_for_neighborhood(fact_ids.clone(), 2, budget.max(1))
792                .await?
793        };
794        let dependency_edges: Vec<StateDependencyEdgeV1> =
795            stored_edges.iter().filter_map(edge_from_stored).collect();
796        let mut resolution = resolve_dependency_states(&dependency_edges, &fact_ids, budget);
797        let budget_exhausted =
798            resolution.budget_exhausted || (budget > 0 && results.len() > budget);
799        if budget_exhausted {
800            resolution.budget_exhausted = true;
801            if resolution.premise_status == PremiseStatus::Supported {
802                resolution.premise_status = PremiseStatus::Unsupported;
803            }
804        }
805        let evidence_sufficient = !results.is_empty();
806        let decision = answer_policy_for(
807            resolution.premise_status,
808            evidence_sufficient,
809            resolution.unresolved_conflict,
810            resolution.invalid_lineage,
811            resolution.budget_exhausted,
812        );
813        let request_id = blake3::hash(query.as_bytes()).to_hex().to_string();
814        let retrieval_witness = witnessed_retrieval(self, request_id, query, &results).await?;
815        let assertions: Vec<ResolvedAssertionV1> = results
816            .iter()
817            .map(|result| ResolvedAssertionV1 {
818                schema_version: "resolved_assertion_v1".into(),
819                memory_id: result_id(result),
820                content: result.content.clone(),
821                premise_status: result_status(result, &resolution),
822                state_view: state_view.clone(),
823                source_kind: result.source.source_kind().into(),
824            })
825            .collect();
826        let resolved_memory_ids = assertions
827            .iter()
828            .map(|a| a.memory_id.clone())
829            .collect::<Vec<_>>();
830        let alternatives = assertions
831            .iter()
832            .skip(1)
833            .cloned()
834            .map(|assertion| BeliefAlternativeV1 {
835                assertion,
836                reason: "additional witnessed retrieval candidate".into(),
837            })
838            .collect::<Vec<_>>();
839        let alternative_memory_ids = alternatives
840            .iter()
841            .map(|a| a.assertion.memory_id.clone())
842            .collect::<Vec<_>>();
843        let mut receipt = StateResolutionReceiptV1::new(
844            blake3::hash(query.as_bytes()).to_hex().to_string(),
845            state_view.clone(),
846            mode,
847            resolution.premise_status,
848            decision.disposition,
849            resolved_memory_ids,
850            alternative_memory_ids,
851            evidence_sufficient,
852            resolution.unresolved_conflict,
853            resolution.invalid_lineage,
854            resolution.budget_exhausted,
855        )?;
856        receipt = receipt.with_dependency_edge_digests(
857            dependency_edges
858                .iter()
859                .map(StateDependencyEdgeV1::digest)
860                .collect(),
861        )?;
862        let answer = assertions
863            .first()
864            .map(|assertion| assertion.content.clone());
865        Ok(ResolvedMemoryAnswerV1 {
866            schema_version: STATE_RESOLVED_RETRIEVAL_V1.into(),
867            query: query.into(),
868            answer,
869            premise_status: resolution.premise_status,
870            answer_disposition: decision.disposition,
871            state_view,
872            assertions,
873            alternatives,
874            receipt,
875            retrieval_witness,
876        })
877    }
878
879    /// Persist typed state-dependency relationships through the existing graph store.
880    pub async fn add_state_dependency_edge(
881        &self,
882        edge: StateDependencyEdgeV1,
883        weight: f64,
884    ) -> Result<crate::StoredGraphEdge, MemoryError> {
885        let metadata = match &edge {
886            StateDependencyEdgeV1::ScopeChanges {
887                from_scope,
888                to_scope,
889                ..
890            } => Some(serde_json::json!({"from_scope": from_scope, "to_scope": to_scope})),
891            _ => None,
892        };
893        self.add_graph_edge(
894            edge.source(),
895            edge.target(),
896            GraphEdgeType::Entity {
897                relation: edge.relation().into(),
898            },
899            weight,
900            metadata,
901        )
902        .await
903    }
904}