Skip to main content

zeph_memory/
recall_view.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! View-aware recall types for `MemCoT` graph retrieval (issues #3574 / #3575).
5//!
6//! [`RecallView`] selects the enrichment level applied to the raw graph recall results.
7//! [`RecalledFact`] is the unified fact type returned by [`crate::semantic::SemanticMemory::recall_graph_view`].
8
9use crate::graph::types::GraphFact;
10use crate::types::MessageId;
11
12pub use zeph_common::memory::RecallView;
13
14/// A graph fact returned by the view-aware recall path.
15///
16/// Wraps a base [`GraphFact`] and carries optional enrichment fields populated
17/// depending on the [`RecallView`] in use.
18///
19/// # Examples
20///
21/// ```
22/// use zeph_memory::{RecalledFact, RecallView};
23/// use zeph_memory::graph::types::GraphFact;
24/// use zeph_memory::graph::EdgeType;
25///
26/// let base = GraphFact {
27///     entity_name: "Rust".to_string(),
28///     relation: "uses".to_string(),
29///     target_name: "LLVM".to_string(),
30///     fact: "Rust uses LLVM for code generation".to_string(),
31///     entity_match_score: 0.9,
32///     hop_distance: 0,
33///     confidence: 0.95,
34///     valid_from: None,
35///     edge_type: EdgeType::Semantic,
36///     retrieval_count: 1,
37///     edge_id: None,
38/// };
39/// let recalled = RecalledFact::from_graph_fact(base);
40/// assert!(recalled.activation_score.is_none());
41/// assert!(recalled.provenance_message_id.is_none());
42/// assert!(recalled.neighbors.is_empty());
43/// ```
44#[derive(Debug, Clone)]
45pub struct RecalledFact {
46    /// The base graph fact (text, relation, confidence, `edge_type`, `hop_distance`).
47    pub fact: GraphFact,
48    /// Spreading-activation score. `Some` when the SA path was used; `None` for plain BFS.
49    ///
50    /// Used by the assembler to render `(activation: X)` suffix and preserve
51    /// current output bytes when `view = Head`.
52    pub activation_score: Option<f32>,
53    /// Source-message ID for the edge (Zoom-In only).
54    pub provenance_message_id: Option<MessageId>,
55    /// ≤200-char snippet from the source message, with `\n\r<>` scrubbed (Zoom-In only).
56    pub provenance_snippet: Option<String>,
57    /// 1-hop neighbor facts deduped against the head set (Zoom-Out only).
58    pub neighbors: Vec<GraphFact>,
59}
60
61impl RecalledFact {
62    /// Wrap a plain `GraphFact` with no enrichment fields set.
63    #[must_use]
64    pub fn from_graph_fact(fact: GraphFact) -> Self {
65        Self {
66            fact,
67            activation_score: None,
68            provenance_message_id: None,
69            provenance_snippet: None,
70            neighbors: Vec::new(),
71        }
72    }
73
74    /// Wrap a SYNAPSE spreading-activation result, preserving its activation score and
75    /// source-message provenance.
76    ///
77    /// `entity_name`/`target_name` are left empty because spreading activation does not
78    /// resolve entity names — callers format the fact via `edge.fact` instead.
79    #[must_use]
80    pub fn from_activated_fact(af: crate::graph::activation::ActivatedFact) -> Self {
81        let activation_score = af.activation_score;
82        let edge = af.edge;
83        let fact = GraphFact {
84            entity_name: String::new(),
85            relation: edge.canonical_relation.clone(),
86            target_name: String::new(),
87            fact: edge.fact.clone(),
88            entity_match_score: activation_score,
89            hop_distance: 0,
90            confidence: edge.confidence,
91            valid_from: if edge.valid_from.is_empty() {
92                None
93            } else {
94                Some(edge.valid_from.clone())
95            },
96            edge_type: edge.edge_type,
97            retrieval_count: edge.retrieval_count,
98            edge_id: Some(edge.id),
99        };
100        Self {
101            fact,
102            activation_score: Some(activation_score),
103            provenance_message_id: edge.source_message_id,
104            provenance_snippet: None,
105            neighbors: Vec::new(),
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::graph::types::{EdgeType, GraphFact};
114    use crate::types::MessageId;
115
116    fn make_fact() -> GraphFact {
117        GraphFact {
118            entity_name: "Rust".to_string(),
119            relation: "uses".to_string(),
120            target_name: "LLVM".to_string(),
121            fact: "Rust uses LLVM".to_string(),
122            entity_match_score: 0.9,
123            hop_distance: 0,
124            confidence: 0.95,
125            valid_from: None,
126            edge_type: EdgeType::Semantic,
127            retrieval_count: 0,
128            edge_id: None,
129        }
130    }
131
132    #[test]
133    fn from_graph_fact_no_enrichment() {
134        let rf = RecalledFact::from_graph_fact(make_fact());
135        assert!(rf.activation_score.is_none());
136        assert!(rf.provenance_message_id.is_none());
137        assert!(rf.provenance_snippet.is_none());
138        assert!(rf.neighbors.is_empty());
139    }
140
141    #[test]
142    fn recall_view_default_is_head() {
143        assert_eq!(RecallView::default(), RecallView::Head);
144    }
145
146    // ── Snapshot tests: Head / ZoomIn / ZoomOut output shape ─────────────────
147
148    fn head_fact() -> RecalledFact {
149        RecalledFact::from_graph_fact(GraphFact {
150            entity_name: "Rust".to_string(),
151            relation: "uses".to_string(),
152            target_name: "LLVM".to_string(),
153            fact: "Rust uses LLVM for code generation".to_string(),
154            entity_match_score: 0.9,
155            hop_distance: 0,
156            confidence: 0.95,
157            valid_from: Some("2026-01-01".to_string()),
158            edge_type: EdgeType::Semantic,
159            retrieval_count: 1,
160            edge_id: Some(10),
161        })
162    }
163
164    #[test]
165    fn snapshot_head_no_enrichment() {
166        let rf = head_fact();
167        insta::assert_debug_snapshot!("head_view", rf);
168    }
169
170    #[test]
171    fn snapshot_zoom_in_with_provenance() {
172        let mut rf = head_fact();
173        rf.provenance_message_id = Some(MessageId(42));
174        rf.provenance_snippet = Some("The Rust compiler uses LLVM as its backend".to_string());
175        insta::assert_debug_snapshot!("zoom_in_view", rf);
176    }
177
178    #[test]
179    fn snapshot_zoom_out_with_neighbors() {
180        let mut rf = head_fact();
181        rf.neighbors.push(GraphFact {
182            entity_name: "LLVM".to_string(),
183            relation: "supports".to_string(),
184            target_name: "WebAssembly".to_string(),
185            fact: "LLVM supports WebAssembly output".to_string(),
186            entity_match_score: 0.5,
187            hop_distance: 1,
188            confidence: 0.8,
189            valid_from: None,
190            edge_type: EdgeType::Semantic,
191            retrieval_count: 0,
192            edge_id: Some(11),
193        });
194        insta::assert_debug_snapshot!("zoom_out_view", rf);
195    }
196
197    #[test]
198    fn snapshot_sa_fact_with_activation_score() {
199        // Simulate an SA-path fact: activation_score is Some, entity names are empty.
200        let rf = RecalledFact {
201            fact: GraphFact {
202                entity_name: String::new(),
203                relation: "uses".to_string(),
204                target_name: String::new(),
205                fact: "Rust uses LLVM for compilation".to_string(),
206                entity_match_score: 0.82,
207                hop_distance: 0,
208                confidence: 0.9,
209                valid_from: Some("2026-01-01".to_string()),
210                edge_type: EdgeType::Semantic,
211                retrieval_count: 0,
212                edge_id: Some(55),
213            },
214            activation_score: Some(0.82),
215            provenance_message_id: None,
216            provenance_snippet: None,
217            neighbors: Vec::new(),
218        };
219        insta::assert_debug_snapshot!("sa_head_view", rf);
220    }
221}