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    // Ranking and rendering both work on the served view: a summary that is
18    // not released now can neither match a query nor be returned.
19    let store = super::served(SummaryStore::load_or_create(project_root));
20    if store.summaries.is_empty() || query.trim().is_empty() {
21        return Vec::new();
22    }
23    #[cfg(feature = "embeddings")]
24    {
25        if let Some(hits) = semantic(&store, query, top_k) {
26            return hits;
27        }
28    }
29    lexical(&store, query, top_k)
30}
31
32fn lexical(store: &SummaryStore, query: &str, top_k: usize) -> Vec<RecallHit> {
33    store
34        .search_lexical(query, top_k)
35        .into_iter()
36        .map(|(i, score)| RecallHit {
37            record: store.summaries[i].clone(),
38            score: score as f32,
39            mode: "lexical",
40        })
41        .collect()
42}
43
44/// Semantic recall. Returns `None` (→ lexical fallback) when embeddings are
45/// disabled or the model isn't already loaded — never blocks on a model load.
46#[cfg(feature = "embeddings")]
47fn semantic(store: &SummaryStore, query: &str, top_k: usize) -> Option<Vec<RecallHit>> {
48    let cfg = crate::core::config::Config::load();
49    let profile = crate::core::config::MemoryProfile::effective(&cfg);
50    if !profile.embeddings_enabled() {
51        return None;
52    }
53    // Non-blocking: only use semantic recall if the model is already warm.
54    let engine = crate::core::embeddings::try_shared_engine()?;
55    let q = engine.embed_query(query).ok()?;
56
57    let mut scored: Vec<RecallHit> = Vec::new();
58    for rec in &store.summaries {
59        if let Ok(emb) = engine.embed_query(&rec.searchable_text()) {
60            scored.push(RecallHit {
61                record: rec.clone(),
62                score: cosine(&q, &emb),
63                mode: "semantic",
64            });
65        }
66    }
67    if scored.is_empty() {
68        return None;
69    }
70    scored.sort_by(|a, b| {
71        b.score
72            .partial_cmp(&a.score)
73            .unwrap_or(std::cmp::Ordering::Equal)
74            .then_with(|| b.record.created_at.cmp(&a.record.created_at))
75    });
76    scored.truncate(top_k);
77    Some(scored)
78}
79
80#[cfg(feature = "embeddings")]
81fn cosine(a: &[f32], b: &[f32]) -> f32 {
82    if a.len() != b.len() {
83        return 0.0;
84    }
85    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
86    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
87    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
88    if na == 0.0 || nb == 0.0 {
89        0.0
90    } else {
91        dot / (na * nb)
92    }
93}