Skip to main content

lean_ctx/core/session_summary/
recall.rs

1//! Recall past session summaries — semantic when embeddings are loaded, else a
2//! lexical token-overlap fallback (#292).
3
4use super::record::SummaryRecord;
5use super::store::SummaryStore;
6
7/// One recalled summary with its score and the recall mode that produced it.
8#[derive(Debug, Clone)]
9pub struct RecallHit {
10    pub record: SummaryRecord,
11    pub score: f32,
12    pub mode: &'static str,
13}
14
15/// Recall the `top_k` summaries most relevant to `query`.
16pub fn recall(project_root: &str, query: &str, top_k: usize) -> Vec<RecallHit> {
17    let store = SummaryStore::load_or_create(project_root);
18    if store.summaries.is_empty() || query.trim().is_empty() {
19        return Vec::new();
20    }
21    #[cfg(feature = "embeddings")]
22    {
23        if let Some(hits) = semantic(&store, query, top_k) {
24            return hits;
25        }
26    }
27    lexical(&store, query, top_k)
28}
29
30fn lexical(store: &SummaryStore, query: &str, top_k: usize) -> Vec<RecallHit> {
31    store
32        .search_lexical(query, top_k)
33        .into_iter()
34        .map(|(i, score)| RecallHit {
35            record: store.summaries[i].clone(),
36            score: score as f32,
37            mode: "lexical",
38        })
39        .collect()
40}
41
42/// Semantic recall. Returns `None` (→ lexical fallback) when embeddings are
43/// disabled or the model isn't already loaded — never blocks on a model load.
44#[cfg(feature = "embeddings")]
45fn semantic(store: &SummaryStore, query: &str, top_k: usize) -> Option<Vec<RecallHit>> {
46    let cfg = crate::core::config::Config::load();
47    let profile = crate::core::config::MemoryProfile::effective(&cfg);
48    if !profile.embeddings_enabled() {
49        return None;
50    }
51    // Non-blocking: only use semantic recall if the model is already warm.
52    let engine = crate::core::embeddings::try_shared_engine()?;
53    let q = engine.embed_query(query).ok()?;
54
55    let mut scored: Vec<RecallHit> = Vec::new();
56    for rec in &store.summaries {
57        if let Ok(emb) = engine.embed_query(&rec.searchable_text()) {
58            scored.push(RecallHit {
59                record: rec.clone(),
60                score: cosine(&q, &emb),
61                mode: "semantic",
62            });
63        }
64    }
65    if scored.is_empty() {
66        return None;
67    }
68    scored.sort_by(|a, b| {
69        b.score
70            .partial_cmp(&a.score)
71            .unwrap_or(std::cmp::Ordering::Equal)
72            .then_with(|| b.record.created_at.cmp(&a.record.created_at))
73    });
74    scored.truncate(top_k);
75    Some(scored)
76}
77
78#[cfg(feature = "embeddings")]
79fn cosine(a: &[f32], b: &[f32]) -> f32 {
80    if a.len() != b.len() {
81        return 0.0;
82    }
83    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
84    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
85    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
86    if na == 0.0 || nb == 0.0 {
87        0.0
88    } else {
89        dot / (na * nb)
90    }
91}