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}