Skip to main content

locus_sdk/application/
memory_explain.rs

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    /// Create an explanation service for retrieval-stage introspection.
24    pub fn new(store: Arc<dyn NodeStore>) -> Self {
25        Self {
26            context_query: ContextQueryService::new(store.clone()),
27            store,
28        }
29    }
30
31    /// Explain retrieval behavior for a recall request.
32    ///
33    /// Returns per-stage counts, retrieval path, and fallback diagnostics
34    /// without mutating stored nodes.
35    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}