Skip to main content

jiandu_memory/memory_store/
recall.rs

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/// Rank recall candidates from a caller-provided lexical index.
82///
83/// This function is deterministic and performs no filesystem or network I/O. It
84/// applies Jiandu's fixed BM25/CJK scoring, status eligibility, and deterministic
85/// ordering, while remaining policy-neutral about cross-index composition: it
86/// ranks only the items in `index`. The caller remains responsible for scope
87/// precedence, fallback behavior, index unions, and any primary-first ID
88/// deduplication. `limit` is an exact upper bound; a zero limit, a blank query,
89/// or a query without scorable matches returns an empty vector.
90#[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}