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
12/// Enrichment level for view-aware graph recall.
13///
14/// Controls whether and how graph facts are enriched after the base retrieval step
15/// (BFS or spreading activation). For `Head`, the function is byte-identical to
16/// the legacy `recall_graph` / `recall_graph_activated` paths.
17///
18/// TODO(F3): add a per-call override so callers can request a different view than
19/// the one configured in `MemCotConfig::recall_view`.
20///
21/// # Examples
22///
23/// ```
24/// use zeph_memory::RecallView;
25///
26/// assert_eq!(RecallView::default(), RecallView::Head);
27/// ```
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum RecallView {
30    /// Standard retrieval — no enrichment beyond what the base method provides.
31    ///
32    /// When `sa_params = None` AND `view = Head`, the output is byte-identical to
33    /// calling `recall_graph` directly.
34    #[default]
35    Head,
36    /// Retrieval + source-message provenance.
37    ///
38    /// Each returned fact is enriched with the `MessageId` and a ≤200-char snippet
39    /// from the message that originally created the edge.
40    ZoomIn,
41    /// Retrieval + 1-hop neighbor expansion.
42    ///
43    /// For each returned fact, up to `neighbor_cap` additional 1-hop neighbor facts
44    /// are appended. Neighbors are deduped against the head set using the canonical
45    /// `(source_name, relation, target_name, edge_type)` tuple.
46    ZoomOut,
47}
48
49/// A graph fact returned by the view-aware recall path.
50///
51/// Wraps a base [`GraphFact`] and carries optional enrichment fields populated
52/// depending on the [`RecallView`] in use.
53///
54/// # Examples
55///
56/// ```
57/// use zeph_memory::{RecalledFact, RecallView};
58/// use zeph_memory::graph::types::GraphFact;
59/// use zeph_memory::graph::EdgeType;
60///
61/// let base = GraphFact {
62///     entity_name: "Rust".to_string(),
63///     relation: "uses".to_string(),
64///     target_name: "LLVM".to_string(),
65///     fact: "Rust uses LLVM for code generation".to_string(),
66///     entity_match_score: 0.9,
67///     hop_distance: 0,
68///     confidence: 0.95,
69///     valid_from: None,
70///     edge_type: EdgeType::Semantic,
71///     retrieval_count: 1,
72///     edge_id: None,
73/// };
74/// let recalled = RecalledFact::from_graph_fact(base);
75/// assert!(recalled.activation_score.is_none());
76/// assert!(recalled.provenance_message_id.is_none());
77/// assert!(recalled.neighbors.is_empty());
78/// ```
79#[derive(Debug, Clone)]
80pub struct RecalledFact {
81    /// The base graph fact (text, relation, confidence, `edge_type`, `hop_distance`).
82    pub fact: GraphFact,
83    /// Spreading-activation score. `Some` when the SA path was used; `None` for plain BFS.
84    ///
85    /// Used by the assembler to render `(activation: X)` suffix and preserve
86    /// current output bytes when `view = Head`.
87    pub activation_score: Option<f32>,
88    /// Source-message ID for the edge (Zoom-In only).
89    pub provenance_message_id: Option<MessageId>,
90    /// ≤200-char snippet from the source message, with `\n\r<>` scrubbed (Zoom-In only).
91    pub provenance_snippet: Option<String>,
92    /// 1-hop neighbor facts deduped against the head set (Zoom-Out only).
93    pub neighbors: Vec<GraphFact>,
94}
95
96impl RecalledFact {
97    /// Wrap a plain `GraphFact` with no enrichment fields set.
98    #[must_use]
99    pub fn from_graph_fact(fact: GraphFact) -> Self {
100        Self {
101            fact,
102            activation_score: None,
103            provenance_message_id: None,
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}