1use std::cmp::Ordering;
2use std::io;
3
4use super::{
5 DurableMemoryStatus, LexicalIndex, MemoryScope, MemoryStore, TemporalGranularity, parse_rfc3339,
6};
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct MemoryRecallCandidate {
10 pub id: String,
11 pub title: String,
12 pub score: f64,
13 pub scope: MemoryScope,
14 pub project_key: Option<String>,
15 pub status: DurableMemoryStatus,
16 pub updated_at: String,
17 pub summary: String,
18 pub granularity: Option<TemporalGranularity>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct MemoryRecallOptions {
23 pub shortlist_limit: usize,
24 pub include_global_fallback: bool,
25 pub max_candidates_per_scope: usize,
26}
27
28impl Default for MemoryRecallOptions {
29 fn default() -> Self {
30 Self {
31 shortlist_limit: 3,
32 include_global_fallback: true,
33 max_candidates_per_scope: 20,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum MemoryRecallStrategy {
40 Lexical,
41}
42
43impl MemoryRecallStrategy {
44 #[must_use]
45 pub const fn as_str(self) -> &'static str {
46 "lexical"
47 }
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct MemoryRecallSelection {
52 pub candidates: Vec<MemoryRecallCandidate>,
53 pub strategy: MemoryRecallStrategy,
54}
55
56pub async fn shortlist_relevant_memories(
57 store: &MemoryStore,
58 project_key: Option<&str>,
59 query: &str,
60 options: &MemoryRecallOptions,
61) -> io::Result<Vec<MemoryRecallCandidate>> {
62 let limit = options.shortlist_limit.max(1);
63 let mut candidates =
64 lexical_shortlist_relevant_memories(store, project_key, query, options).await?;
65 candidates.truncate(limit);
66 Ok(candidates)
67}
68
69pub async fn select_relevant_memories(
70 store: &MemoryStore,
71 project_key: Option<&str>,
72 query: &str,
73 options: &MemoryRecallOptions,
74) -> io::Result<MemoryRecallSelection> {
75 Ok(MemoryRecallSelection {
76 candidates: shortlist_relevant_memories(store, project_key, query, options).await?,
77 strategy: MemoryRecallStrategy::Lexical,
78 })
79}
80
81#[must_use]
91pub fn recall_candidates_from_lexical_index(
92 index: &LexicalIndex,
93 query: &str,
94 limit: usize,
95) -> Vec<MemoryRecallCandidate> {
96 recall_candidates_from_lexical_index_with_status_policy(index, query, limit, false)
97}
98
99pub(crate) fn recall_candidates_from_lexical_index_with_status_policy(
100 index: &LexicalIndex,
101 query: &str,
102 limit: usize,
103 include_all_statuses: bool,
104) -> Vec<MemoryRecallCandidate> {
105 let query = query.trim();
106 if limit == 0 || query.is_empty() {
107 return Vec::new();
108 }
109
110 let query_tokens = super::lexical_bm25::tokenize(query);
111 if query_tokens.is_empty() {
112 return Vec::new();
113 }
114
115 let corpus = if include_all_statuses {
116 super::lexical_bm25::Bm25Corpus::build_including_all_statuses(&index.items)
117 } else {
118 super::lexical_bm25::Bm25Corpus::build(&index.items)
119 };
120 let mut candidates = index
121 .items
122 .iter()
123 .enumerate()
124 .filter_map(|(index, item)| {
125 corpus
126 .score(index, &query_tokens)
127 .map(|score| (item, score))
128 })
129 .map(|(item, score)| MemoryRecallCandidate {
130 id: item.id.clone(),
131 title: item.title.clone(),
132 score,
133 scope: item.scope,
134 project_key: item.project_key.clone(),
135 status: item.status,
136 updated_at: item.updated_at.clone(),
137 summary: item.summary.clone(),
138 granularity: item.granularity,
139 })
140 .collect::<Vec<_>>();
141
142 sort_recall_candidates(&mut candidates);
143 candidates.truncate(limit);
144 candidates
145}
146
147async fn lexical_shortlist_relevant_memories(
148 store: &MemoryStore,
149 project_key: Option<&str>,
150 query: &str,
151 options: &MemoryRecallOptions,
152) -> io::Result<Vec<MemoryRecallCandidate>> {
153 let query = query.trim();
154 if query.is_empty() {
155 return Ok(Vec::new());
156 }
157
158 let limit = options.shortlist_limit.max(1);
159 let per_scope_limit = options.max_candidates_per_scope.max(limit);
160
161 if let Some(project_key) = project_key.map(str::trim).filter(|value| !value.is_empty()) {
162 let project_hits = shortlist_scope(
163 store,
164 MemoryScope::Project,
165 Some(project_key),
166 query,
167 per_scope_limit,
168 )
169 .await?;
170 if !project_hits.is_empty() {
171 return Ok(project_hits);
172 }
173 }
174
175 if options.include_global_fallback {
176 return shortlist_scope(store, MemoryScope::Global, None, query, per_scope_limit).await;
177 }
178
179 Ok(Vec::new())
180}
181
182async fn shortlist_scope(
183 store: &MemoryStore,
184 scope: MemoryScope,
185 project_key: Option<&str>,
186 query: &str,
187 limit: usize,
188) -> io::Result<Vec<MemoryRecallCandidate>> {
189 let Some(index) = store.read_lexical_index(scope, project_key).await? else {
190 return Ok(Vec::new());
191 };
192 Ok(recall_candidates_from_lexical_index(&index, query, limit))
193}
194
195fn sort_recall_candidates(candidates: &mut [MemoryRecallCandidate]) {
196 candidates.sort_by(|left, right| {
197 right
198 .score
199 .partial_cmp(&left.score)
200 .unwrap_or(Ordering::Equal)
201 .then_with(|| {
202 TemporalGranularity::cache_stability_rank(left.granularity).cmp(
203 &TemporalGranularity::cache_stability_rank(right.granularity),
204 )
205 })
206 .then_with(|| {
207 let left_dt = parse_rfc3339(&left.updated_at)
208 .unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC);
209 let right_dt = parse_rfc3339(&right.updated_at)
210 .unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC);
211 right_dt.cmp(&left_dt)
212 })
213 .then_with(|| left.title.cmp(&right.title))
214 });
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::memory_store::{DurableMemoryType, LexicalIndexItem};
221 use tempfile::tempdir;
222
223 fn candidate(
224 id: &str,
225 score: f64,
226 granularity: Option<TemporalGranularity>,
227 ) -> MemoryRecallCandidate {
228 MemoryRecallCandidate {
229 id: id.to_string(),
230 title: id.to_string(),
231 score,
232 scope: MemoryScope::Project,
233 project_key: Some("proj-1".to_string()),
234 status: DurableMemoryStatus::Active,
235 updated_at: "2026-04-09T00:00:00Z".to_string(),
236 summary: "summary".to_string(),
237 granularity,
238 }
239 }
240
241 fn lexical_item(id: &str, status: DurableMemoryStatus, title: &str) -> LexicalIndexItem {
242 LexicalIndexItem {
243 id: id.to_string(),
244 title: title.to_string(),
245 scope: MemoryScope::Project,
246 project_key: Some("proj-1".to_string()),
247 r#type: DurableMemoryType::Project,
248 status,
249 tags: Vec::new(),
250 keywords: Vec::new(),
251 entities: Vec::new(),
252 updated_at: "2026-04-09T00:00:00Z".to_string(),
253 created_at: "2026-04-09T00:00:00Z".to_string(),
254 summary: title.to_string(),
255 granularity: None,
256 }
257 }
258
259 #[test]
260 fn equal_score_candidates_sort_coarse_granularity_first_for_cache_stability() {
261 let mut candidates = vec![
262 candidate("day", 5.0, Some(TemporalGranularity::Day)),
263 candidate("year", 5.0, Some(TemporalGranularity::Year)),
264 candidate("none", 5.0, None),
265 candidate("month", 5.0, Some(TemporalGranularity::Month)),
266 ];
267 sort_recall_candidates(&mut candidates);
268 let order: Vec<&str> = candidates
269 .iter()
270 .map(|candidate| candidate.id.as_str())
271 .collect();
272 assert_eq!(order, vec!["none", "year", "month", "day"]);
273 }
274
275 #[test]
276 fn higher_score_still_wins_over_cache_stable_granularity() {
277 let mut candidates = vec![
278 candidate("year-low", 1.0, Some(TemporalGranularity::Year)),
279 candidate("day-high", 9.0, Some(TemporalGranularity::Day)),
280 ];
281 sort_recall_candidates(&mut candidates);
282 assert_eq!(candidates[0].id, "day-high");
283 }
284
285 #[test]
286 fn lexical_index_recall_is_cjk_status_aware_limited_and_deterministic() {
287 let index = LexicalIndex {
288 generated_at: "2026-04-09T00:00:00Z".to_string(),
289 items: vec![
290 lexical_item("stale", DurableMemoryStatus::Stale, "多租户隔离设计"),
291 lexical_item("active", DurableMemoryStatus::Active, "多租户隔离设计"),
292 lexical_item("archived", DurableMemoryStatus::Archived, "多租户隔离设计"),
293 lexical_item("unrelated", DurableMemoryStatus::Active, "缓存前缀稳定性"),
294 ],
295 };
296
297 let limited = recall_candidates_from_lexical_index(&index, "多租户", 1);
298 assert_eq!(limited.len(), 1);
299 assert_eq!(limited[0].id, "active");
300 assert_eq!(limited[0].status, DurableMemoryStatus::Active);
301
302 let all = recall_candidates_from_lexical_index(&index, "多租户", 10);
303 assert_eq!(
304 all.iter()
305 .map(|candidate| candidate.id.as_str())
306 .collect::<Vec<_>>(),
307 vec!["active", "stale"]
308 );
309 assert_eq!(
310 all,
311 recall_candidates_from_lexical_index(&index, "多租户", 10)
312 );
313 assert!(recall_candidates_from_lexical_index(&index, "多租户", 0).is_empty());
314 }
315
316 #[tokio::test]
317 async fn store_shortlist_matches_pure_lexical_index_recall() {
318 let dir = tempdir().unwrap();
319 let store = MemoryStore::new(dir.path());
320 store
321 .write_memory(
322 MemoryScope::Project,
323 Some("proj-1"),
324 DurableMemoryType::Project,
325 "Checkpoint recovery decision",
326 "Restore the database snapshot before reopening traffic.",
327 &[],
328 Some("session-1"),
329 "main-model",
330 false,
331 None,
332 )
333 .await
334 .unwrap();
335
336 let options = MemoryRecallOptions {
337 shortlist_limit: 2,
338 include_global_fallback: false,
339 max_candidates_per_scope: 4,
340 };
341 let from_store =
342 shortlist_relevant_memories(&store, Some("proj-1"), "checkpoint", &options)
343 .await
344 .unwrap();
345 let index = store
346 .read_lexical_index(MemoryScope::Project, Some("proj-1"))
347 .await
348 .unwrap()
349 .unwrap();
350 let from_index =
351 recall_candidates_from_lexical_index(&index, "checkpoint", options.shortlist_limit);
352
353 assert_eq!(from_store, from_index);
354 }
355
356 #[tokio::test]
357 async fn project_scope_shortlist_excludes_global_when_project_hits_exist() {
358 let dir = tempdir().unwrap();
359 let store = MemoryStore::new(dir.path());
360 store
361 .write_memory(
362 MemoryScope::Project,
363 Some("proj-1"),
364 DurableMemoryType::Project,
365 "Release freeze decision",
366 "Project-specific release freeze note.",
367 &["release".to_string()],
368 Some("session-1"),
369 "main-model",
370 false,
371 None,
372 )
373 .await
374 .unwrap();
375 store
376 .write_memory(
377 MemoryScope::Global,
378 None,
379 DurableMemoryType::Reference,
380 "Global release guidance",
381 "Global note that should not be used when project hits exist.",
382 &["release".to_string()],
383 Some("session-1"),
384 "main-model",
385 false,
386 None,
387 )
388 .await
389 .unwrap();
390
391 let candidates = shortlist_relevant_memories(
392 &store,
393 Some("proj-1"),
394 "release freeze",
395 &MemoryRecallOptions::default(),
396 )
397 .await
398 .unwrap();
399 assert!(!candidates.is_empty());
400 assert!(
401 candidates
402 .iter()
403 .all(|candidate| candidate.scope == MemoryScope::Project)
404 );
405 }
406
407 #[tokio::test]
408 async fn global_fallback_triggers_only_when_project_hits_are_absent() {
409 let dir = tempdir().unwrap();
410 let store = MemoryStore::new(dir.path());
411 store
412 .write_memory(
413 MemoryScope::Global,
414 None,
415 DurableMemoryType::Reference,
416 "Global release guidance",
417 "Fallback note for release work.",
418 &["release".to_string()],
419 Some("session-1"),
420 "main-model",
421 false,
422 None,
423 )
424 .await
425 .unwrap();
426
427 let candidates = shortlist_relevant_memories(
428 &store,
429 Some("proj-missing"),
430 "release guidance",
431 &MemoryRecallOptions::default(),
432 )
433 .await
434 .unwrap();
435 assert!(!candidates.is_empty());
436 assert!(
437 candidates
438 .iter()
439 .all(|candidate| candidate.scope == MemoryScope::Global)
440 );
441 }
442}