1use std::collections::HashSet;
2use std::sync::Arc;
3
4use anyhow::Result;
5use locus_core_rs::ContextQueryService;
6use locus_core_rs::domain::contracts::NodeStore;
7use locus_core_rs::domain::models::{AvecState, NodeQuery, SttpNode};
8
9use crate::application::memory_filters::{build_session_filter, node_matches_common_filters};
10use crate::application::memory_lexical::{
11 self, LEXICAL_SCAN_LIMIT, LexicalActivation, LexicalFields,
12};
13use crate::domain::memory::{
14 MemoryExplainRequest, MemoryExplainResult, MemoryExplainStage, RetrievalPath, clamp_limit,
15};
16
17pub struct MemoryExplainService {
18 store: Arc<dyn NodeStore>,
19 context_query: ContextQueryService,
20}
21
22impl MemoryExplainService {
23 pub fn new(store: Arc<dyn NodeStore>) -> Self {
25 Self {
26 context_query: ContextQueryService::new(store.clone()),
27 store,
28 }
29 }
30
31 pub async fn execute(&self, request: &MemoryExplainRequest) -> Result<MemoryExplainResult> {
36 let recall = &request.recall;
37 let limit = clamp_limit(recall.page.limit);
38 let expanded_limit = (limit.saturating_mul(5)).clamp(1, 200);
39
40 let current = recall.current_avec.unwrap_or_else(AvecState::zero);
41 let session_scope = recall
42 .scope
43 .session_ids
44 .as_deref()
45 .filter(|sessions| sessions.len() == 1)
46 .and_then(|sessions| sessions.first().map(String::as_str));
47 let session_filter = build_session_filter(&recall.scope);
48
49 let mut stages = Vec::new();
50 let mut path = if recall.query_embedding.is_some() {
51 RetrievalPath::Hybrid
52 } else {
53 RetrievalPath::ResonanceOnly
54 };
55 let mut fallback_triggered = false;
56 let mut fallback_reason = None;
57
58 let primary = if let Some(query_embedding) = recall.query_embedding.as_deref() {
59 self.context_query
60 .get_context_hybrid_scoped_filtered_async(
61 session_scope,
62 current.stability,
63 current.friction,
64 current.logic,
65 current.autonomy,
66 recall.scope.from_utc,
67 recall.scope.to_utc,
68 recall.scope.tiers.as_deref(),
69 Some(query_embedding),
70 recall.scoring.alpha,
71 recall.scoring.beta,
72 limit,
73 )
74 .await
75 } else {
76 self.context_query
77 .get_context_scoped_filtered_async(
78 session_scope,
79 current.stability,
80 current.friction,
81 current.logic,
82 current.autonomy,
83 recall.scope.from_utc,
84 recall.scope.to_utc,
85 recall.scope.tiers.as_deref(),
86 limit,
87 )
88 .await
89 };
90
91 stages.push(MemoryExplainStage {
92 stage: "primary_retrieval".to_string(),
93 count: primary.nodes.len(),
94 });
95
96 let filtered_primary = filter_nodes(primary.nodes, recall, session_filter.as_ref());
97 stages.push(MemoryExplainStage {
98 stage: "after_common_filter".to_string(),
99 count: filtered_primary.len(),
100 });
101
102 if let Some(query_text) = recall.query_text.as_deref() {
103 let primary_empty = filtered_primary.is_empty();
104 match memory_lexical::activation(
105 recall.scoring.fallback_policy,
106 query_text,
107 primary_empty,
108 ) {
109 LexicalActivation::Skip => {}
110 LexicalActivation::Legacy => {
111 fallback_triggered = true;
112 fallback_reason = Some(match recall.scoring.fallback_policy {
113 crate::domain::memory::FallbackPolicy::Never => "never".to_string(),
114 crate::domain::memory::FallbackPolicy::OnEmpty => {
115 "fallback_policy=on_empty and primary result set is empty".to_string()
116 }
117 crate::domain::memory::FallbackPolicy::Always => {
118 "fallback_policy=always".to_string()
119 }
120 });
121
122 let fallback = self
123 .context_query
124 .get_context_scoped_filtered_async(
125 session_scope,
126 current.stability,
127 current.friction,
128 current.logic,
129 current.autonomy,
130 recall.scope.from_utc,
131 recall.scope.to_utc,
132 recall.scope.tiers.as_deref(),
133 expanded_limit,
134 )
135 .await;
136
137 stages.push(MemoryExplainStage {
138 stage: "fallback_retrieval".to_string(),
139 count: fallback.nodes.len(),
140 });
141
142 let filtered_fallback =
143 filter_nodes(fallback.nodes, recall, session_filter.as_ref());
144 stages.push(MemoryExplainStage {
145 stage: "fallback_after_common_filter".to_string(),
146 count: filtered_fallback.len(),
147 });
148
149 let lexical =
150 memory_lexical::legacy_phrase_filter(filtered_fallback, query_text);
151 stages.push(MemoryExplainStage {
152 stage: "lexical_filter".to_string(),
153 count: lexical.len(),
154 });
155
156 path = RetrievalPath::LexicalFallback;
157 }
158 LexicalActivation::NaturalLanguage => {
159 let scanned = self
160 .store
161 .query_nodes_async(NodeQuery {
162 limit: LEXICAL_SCAN_LIMIT,
163 session_id: session_scope.map(str::to_string),
164 from_utc: recall.scope.from_utc,
165 to_utc: recall.scope.to_utc,
166 tiers: recall.scope.tiers.clone(),
167 })
168 .await?;
169 stages.push(MemoryExplainStage {
170 stage: "lexical_scan".to_string(),
171 count: scanned.len(),
172 });
173
174 let lexical = memory_lexical::select_lexical_matches(
175 filter_nodes(scanned, recall, session_filter.as_ref()),
176 &memory_lexical::parse_lexical_query(query_text),
177 recall.scoring.strictness,
178 LexicalFields::RECALL,
179 );
180 stages.push(MemoryExplainStage {
181 stage: "lexical_filter".to_string(),
182 count: lexical.len(),
183 });
184
185 let (_, applied) = memory_lexical::apply_natural_language(
186 filtered_primary,
187 lexical,
188 recall.query_embedding.is_some(),
189 );
190 if applied || primary_empty {
191 fallback_triggered = true;
192 fallback_reason = Some(if applied {
193 "natural language query matched content terms in scoped nodes"
194 .to_string()
195 } else {
196 "natural language query had no lexical match and primary result set is empty"
197 .to_string()
198 });
199 }
200 if recall.query_embedding.is_none() && (applied || primary_empty) {
201 path = RetrievalPath::LexicalFallback;
202 }
203 }
204 }
205 }
206
207 Ok(MemoryExplainResult {
208 retrieval_path: path,
209 fallback_triggered,
210 fallback_reason,
211 stages,
212 scoring: recall.scoring.clone(),
213 })
214 }
215}
216
217fn filter_nodes(
218 nodes: Vec<SttpNode>,
219 request: &crate::domain::memory::MemoryRecallRequest,
220 session_filter: Option<&HashSet<String>>,
221) -> Vec<SttpNode> {
222 nodes
223 .into_iter()
224 .filter(|node| {
225 node_matches_common_filters(node, &request.scope, &request.filter, session_filter)
226 })
227 .collect()
228}
229
230#[cfg(test)]
231mod tests {
232 use std::sync::Arc;
233
234 use chrono::Utc;
235 use locus_core_rs::domain::models::{AvecState, SttpNode};
236 use locus_core_rs::{InMemoryNodeStore, NodeStore};
237
238 use super::MemoryExplainService;
239 use crate::domain::memory::{
240 FallbackPolicy, MemoryExplainRequest, MemoryFilter, MemoryRecallRequest, MemoryScoring,
241 };
242
243 #[tokio::test]
244 async fn explain_marks_fallback_when_on_empty_and_no_primary_results() {
245 let store: Arc<dyn NodeStore> = Arc::new(InMemoryNodeStore::new());
246 let node = test_node("s-explain", "raw", "some unrelated payload");
247 store
248 .upsert_node_async(node)
249 .await
250 .expect("upsert should succeed");
251
252 let service = MemoryExplainService::new(store);
253 let request = MemoryExplainRequest {
254 recall: MemoryRecallRequest {
255 query_text: Some("nonexistent-token".to_string()),
256 filter: MemoryFilter {
257 has_embedding: Some(true),
258 ..Default::default()
259 },
260 scoring: MemoryScoring {
261 fallback_policy: FallbackPolicy::OnEmpty,
262 ..Default::default()
263 },
264 ..Default::default()
265 },
266 };
267
268 let result = service
269 .execute(&request)
270 .await
271 .expect("explain should succeed");
272
273 assert!(result.fallback_triggered);
274 assert_eq!(
275 result.retrieval_path,
276 crate::domain::memory::RetrievalPath::LexicalFallback
277 );
278 assert!(
279 result
280 .stages
281 .iter()
282 .any(|stage| stage.stage == "fallback_retrieval")
283 );
284 }
285
286 fn test_node(session_id: &str, tier: &str, raw: &str) -> SttpNode {
287 let now = Utc::now();
288 let user = AvecState {
289 stability: 0.6,
290 friction: 0.4,
291 logic: 0.8,
292 autonomy: 0.7,
293 };
294
295 SttpNode {
296 raw: raw.to_string(),
297 session_id: session_id.to_string(),
298 tier: tier.to_string(),
299 timestamp: now,
300 compression_depth: 1,
301 parent_node_id: None,
302 sync_key: format!(
303 "{session_id}:{tier}:{}",
304 now.timestamp_nanos_opt().unwrap_or_default()
305 ),
306 updated_at: now,
307 source_metadata: None,
308 context_summary: Some("summary".to_string()),
309 semantic_tags: None,
310 semantic_links: None,
311 embedding_dimensions: None,
312 embedding_model: None,
313 embedding: None,
314 embedded_at: None,
315 user_avec: user,
316 model_avec: user,
317 compression_avec: Some(user),
318 rho: 0.9,
319 kappa: 0.8,
320 psi: 2.5,
321 }
322 }
323}