Skip to main content

scone_core/
recall.rs

1//! Hybrid retrieval (spec §7): BM25 + vectors fused by reciprocal-rank
2//! fusion, recency-weighted, and re-verified against SQLite truth before
3//! anything is returned (memory/bugs.md P-3).
4
5use std::collections::HashMap;
6
7use crate::Engine;
8use crate::auth::ScopedSpace;
9use crate::error::{Result, SconeError};
10
11const CANDIDATES_PER_GENERATOR: usize = 50;
12
13/// Interrogative and function words that pollute the BM25 leg of a
14/// natural-language question ("what did I say about X" must rank on X,
15/// not on "what"). The embedding leg keeps the full query — word order
16/// and function words carry meaning there.
17const QUERY_STOPWORDS: [&str; 33] = [
18    "a", "an", "the", "i", "me", "my", "we", "our", "you", "your", "it", "is", "was", "were",
19    "are", "be", "been", "do", "does", "did", "have", "has", "had", "what", "when", "where",
20    "which", "who", "how", "why", "say", "said", "about",
21];
22
23fn bm25_query(query: &str) -> String {
24    let kept: Vec<&str> = query
25        .split_whitespace()
26        .filter(|t| {
27            !QUERY_STOPWORDS.contains(
28                &t.to_lowercase()
29                    .trim_matches(|c: char| !c.is_alphanumeric()),
30            )
31        })
32        .collect();
33    if kept.is_empty() {
34        query.to_owned()
35    } else {
36        kept.join(" ")
37    }
38}
39const RRF_K: f32 = 60.0;
40const W_FUSED: f32 = 0.8;
41const W_RECENCY: f32 = 0.2;
42const RECENCY_HALF_LIFE_DAYS: f32 = 30.0;
43/// Weight of the cross-encoder score when a reranker is attached; the
44/// fused+recency score keeps the remainder (v1 blend, benchmarked in
45/// memory/benchmarks.md).
46const W_RERANK: f32 = 0.7;
47
48#[derive(Debug, Clone)]
49pub struct RecallOpts {
50    pub limit: usize,
51    pub budget_bytes: Option<usize>,
52    /// Evaluate fact validity at this instant (ISO-8601). None = now.
53    /// Time travel: a past `as_of` serves the then-valid closed facts.
54    pub as_of: Option<String>,
55    /// Widen each hit with its adjacent chunks. Roughly triples context
56    /// for a within-noise accuracy change on the retrieval floor
57    /// (measured 2026-08-27) — so it is off by default and enabled by
58    /// reader-facing surfaces (ask, MCP recall) where a downstream model
59    /// benefits from surrounding context.
60    pub expand_neighbors: bool,
61    /// Focus retrieval to episodes carrying ALL of these tags (facts
62    /// narrow through provenance). Empty = no tag filter.
63    pub tags: Vec<String>,
64}
65
66impl Default for RecallOpts {
67    fn default() -> Self {
68        Self {
69            limit: 10,
70            budget_bytes: None,
71            as_of: None,
72            expand_neighbors: false,
73            tags: Vec::new(),
74        }
75    }
76}
77
78#[derive(Debug, Clone)]
79pub struct FactItem {
80    pub fact_id: i64,
81    pub subject: String,
82    pub predicate: String,
83    pub object: String,
84    pub confidence: f32,
85    pub valid_from: String,
86    pub valid_until: Option<String>,
87    pub status: String,
88}
89
90#[derive(Debug, Clone)]
91pub struct RecallItem {
92    pub chunk_id: i64,
93    pub episode_id: i64,
94    pub text: String,
95    pub score: f32,
96    pub source: Option<String>,
97    pub created_at: String,
98}
99
100#[derive(Debug, Default)]
101pub struct ContextPack {
102    /// Semantic facts, first-class and budget-first (spec §7).
103    pub facts: Vec<FactItem>,
104    pub items: Vec<RecallItem>,
105    /// Generators that could not contribute, stated loudly (spec §10).
106    pub degraded: Vec<String>,
107    /// Bytes of chunk text returned in `items` (MISSION.md: token economy
108    /// is a product surface, not a benchmark-only number).
109    pub returned_bytes: usize,
110    /// Total episode bytes stored in the space at query time.
111    pub space_bytes: i64,
112}
113
114impl ContextPack {
115    /// Fraction of the stored corpus NOT sent: 0.99 = 99% saved.
116    pub fn context_reduction(&self) -> f64 {
117        if self.space_bytes <= 0 {
118            return 0.0;
119        }
120        1.0 - (self.returned_bytes as f64 / self.space_bytes as f64)
121    }
122}
123
124impl Engine {
125    pub fn recall(
126        &mut self,
127        space: &ScopedSpace,
128        query: &str,
129        opts: &RecallOpts,
130    ) -> Result<ContextPack> {
131        if query.trim().is_empty() {
132            return Err(SconeError::InvalidInput("query is empty".into()));
133        }
134        // Staged index writes become visible here (flush-on-recall).
135        self.flush_indexes()?;
136        let mut degraded = Vec::new();
137
138        // Fact generator: entity/predicate/object term match, validity
139        // evaluated at `as_of` (spec §5 I2/I3 make this a WHERE clause).
140        let facts = self.recall_facts(space, query, opts)?;
141
142        // Candidate generation: each generator may degrade, never abort.
143        let fts_hits = match self.fts.search(
144            space.id() as u64,
145            &bm25_query(query),
146            CANDIDATES_PER_GENERATOR,
147        ) {
148            Ok(hits) => hits,
149            Err(SconeError::InvalidInput(msg)) => {
150                degraded.push(format!("fts: {msg}"));
151                Vec::new()
152            }
153            Err(other) => return Err(other),
154        };
155        let vec_hits = {
156            let q = self.embedder.embed(&[query])?;
157            match q.first() {
158                Some(qv) => self.vectors.search(qv, CANDIDATES_PER_GENERATOR)?,
159                None => Vec::new(),
160            }
161        };
162
163        // Reciprocal-rank fusion across both ranked lists.
164        let mut fused: HashMap<u64, f32> = HashMap::new();
165        for hits in [&fts_hits, &vec_hits] {
166            for (rank, (chunk_id, _)) in hits.iter().enumerate() {
167                *fused.entry(*chunk_id).or_insert(0.0) += 1.0 / (RRF_K + rank as f32 + 1.0);
168            }
169        }
170        let max_fused = fused
171            .values()
172            .cloned()
173            .fold(0.0f32, f32::max)
174            .max(f32::MIN_POSITIVE);
175
176        // Truth re-verification: candidates materialize FROM SQLite or not
177        // at all; vector hits are space-filtered here too.
178        let mut items = Vec::new();
179        {
180            // Tag focus (AND semantics): the episode must carry every
181            // requested tag (normalized lowercase).
182            let normalized_tags: Vec<String> =
183                opts.tags.iter().map(|t| t.trim().to_lowercase()).collect();
184            let tag_filter = if normalized_tags.is_empty() {
185                String::new()
186            } else {
187                format!(
188                    " AND (SELECT count(DISTINCT t.name) FROM episode_tags et
189                           JOIN tags t ON t.id = et.tag_id
190                           WHERE et.episode_id = e.id AND t.name IN ({})) = {}",
191                    normalized_tags
192                        .iter()
193                        .map(|_| "?")
194                        .collect::<Vec<_>>()
195                        .join(","),
196                    normalized_tags.len()
197                )
198            };
199            let sql = format!(
200                "SELECT c.episode_id, c.start_byte, c.end_byte, e.content, e.source,
201                        e.created_at,
202                        (julianday('now') - julianday(e.created_at)) AS age_days
203                 FROM chunks c JOIN episodes e ON e.id = c.episode_id
204                 WHERE c.id = ?1 AND e.space_id = ?2{tag_filter}"
205            );
206            let mut stmt = self.conn.prepare(&sql)?;
207            for (chunk_id, fused_score) in &fused {
208                let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
209                    vec![Box::new(*chunk_id as i64), Box::new(space.id())];
210                for tag in &normalized_tags {
211                    params.push(Box::new(tag.clone()));
212                }
213                let row = stmt.query_row(
214                    rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())),
215                    |r| {
216                        Ok((
217                            r.get::<_, i64>(0)?,
218                            r.get::<_, i64>(1)?,
219                            r.get::<_, i64>(2)?,
220                            r.get::<_, String>(3)?,
221                            r.get::<_, Option<String>>(4)?,
222                            r.get::<_, String>(5)?,
223                            r.get::<_, f64>(6)?,
224                        ))
225                    },
226                );
227                let (episode_id, start, end, content, source, created_at, age_days) = match row {
228                    Ok(r) => r,
229                    Err(rusqlite::Error::QueryReturnedNoRows) => continue,
230                    Err(e) => return Err(SconeError::Db(e)),
231                };
232                let (start, end) = (start as usize, end as usize);
233                let text = content.get(start..end).unwrap_or_default().to_owned();
234                let recency = (-(age_days.max(0.0) as f32) / RECENCY_HALF_LIFE_DAYS).exp();
235                let score = W_FUSED * (fused_score / max_fused) + W_RECENCY * recency;
236                items.push(RecallItem {
237                    chunk_id: *chunk_id as i64,
238                    episode_id,
239                    text,
240                    score,
241                    source,
242                    created_at,
243                });
244            }
245        }
246        // Cross-encoder pass: rescore every surviving candidate against
247        // the query jointly. Rank fusion proposes; the reranker disposes.
248        if let Some(reranker) = &self.reranker
249            && !items.is_empty()
250        {
251            let docs: Vec<&str> = items.iter().map(|i| i.text.as_str()).collect();
252            match reranker.rerank(query, &docs) {
253                Ok(scores) => {
254                    let (lo, hi) = scores
255                        .iter()
256                        .fold((f32::MAX, f32::MIN), |(lo, hi), s| (lo.min(*s), hi.max(*s)));
257                    let span = (hi - lo).max(f32::MIN_POSITIVE);
258                    for (item, raw) in items.iter_mut().zip(&scores) {
259                        let normalized = (raw - lo) / span;
260                        item.score = W_RERANK * normalized + (1.0 - W_RERANK) * item.score;
261                    }
262                }
263                Err(e) => degraded.push(format!("reranker: {e}")),
264            }
265        }
266
267        items.sort_by(|a, b| b.score.total_cmp(&a.score));
268        items.truncate(opts.limit);
269
270        // Neighbor expansion: answers often live one chunk over. Widen each
271        // kept item to its adjacent chunks (contiguous byte spans, so this
272        // is one slice), skipping items swallowed by an earlier span.
273        let mut expanded: Vec<RecallItem> = Vec::with_capacity(items.len());
274        for mut item in items {
275            if !opts.expand_neighbors {
276                expanded.push(item);
277                continue;
278            }
279            let row = self.conn.query_row(
280                "SELECT e.content,
281                        (SELECT min(start_byte) FROM chunks n
282                         WHERE n.episode_id = c.episode_id AND n.pos BETWEEN c.pos - 1 AND c.pos + 1),
283                        (SELECT max(end_byte) FROM chunks n
284                         WHERE n.episode_id = c.episode_id AND n.pos BETWEEN c.pos - 1 AND c.pos + 1)
285                 FROM chunks c JOIN episodes e ON e.id = c.episode_id
286                 WHERE c.id = ?1",
287                [item.chunk_id],
288                |r| {
289                    Ok((
290                        r.get::<_, String>(0)?,
291                        r.get::<_, i64>(1)?,
292                        r.get::<_, i64>(2)?,
293                    ))
294                },
295            );
296            if let Ok((content, start, end)) = row {
297                let (start, end) = (start as usize, end as usize);
298                if let Some(wider) = content.get(start..end) {
299                    item.text = wider.to_owned();
300                }
301            }
302            let redundant = expanded
303                .iter()
304                .any(|kept| kept.episode_id == item.episode_id && kept.text.contains(&item.text));
305            if !redundant {
306                expanded.push(item);
307            }
308        }
309        let mut items = expanded;
310
311        // Budget rule (pinned): the top item always survives; the budget
312        // truncates strictly after it.
313        if let Some(budget) = opts.budget_bytes {
314            let mut used = 0usize;
315            let mut kept = Vec::new();
316            for item in items {
317                if !kept.is_empty() && used + item.text.len() > budget {
318                    break;
319                }
320                used += item.text.len();
321                kept.push(item);
322            }
323            items = kept;
324        }
325
326        // Budget: facts are dense and land first (spec §7); chunks share
327        // what remains under the pinned top-item rule.
328        if let Some(budget) = opts.budget_bytes {
329            let facts_bytes: usize = facts
330                .iter()
331                .map(|f| f.subject.len() + f.predicate.len() + f.object.len())
332                .sum();
333            let chunk_budget = budget.saturating_sub(facts_bytes);
334            let mut used = 0usize;
335            let mut kept = Vec::new();
336            for item in items {
337                if !kept.is_empty() && used + item.text.len() > chunk_budget {
338                    break;
339                }
340                used += item.text.len();
341                kept.push(item);
342            }
343            items = kept;
344        }
345
346        let returned_bytes = items.iter().map(|i| i.text.len()).sum();
347        let space_bytes = self.conn.query_row(
348            "SELECT coalesce(sum(length(content)), 0) FROM episodes WHERE space_id = ?1",
349            [space.id()],
350            |r| r.get(0),
351        )?;
352        Ok(ContextPack {
353            facts,
354            items,
355            degraded,
356            returned_bytes,
357            space_bytes,
358        })
359    }
360
361    fn recall_facts(
362        &mut self,
363        space: &ScopedSpace,
364        query: &str,
365        opts: &RecallOpts,
366    ) -> Result<Vec<FactItem>> {
367        let terms: Vec<String> = query
368            .to_lowercase()
369            .split_whitespace()
370            .filter(|t| t.len() > 2)
371            .map(|t| format!("%{t}%"))
372            .collect();
373        if terms.is_empty() {
374            return Ok(Vec::new());
375        }
376        let as_of = opts
377            .as_of
378            .clone()
379            .unwrap_or_else(|| "now-sentinel".to_owned());
380        let mut found: Vec<FactItem> = Vec::new();
381        {
382            let mut stmt = self.conn.prepare(
383                "SELECT f.id, en.canonical, f.predicate, f.object, f.confidence,
384                        f.valid_from, f.valid_until, f.status
385                 FROM facts f
386                 JOIN entities en ON en.id = f.subject_entity
387                 WHERE f.space_id = ?1
388                   AND f.valid_from <= ?2
389                   AND (f.valid_until IS NULL OR f.valid_until > ?2)
390                   AND (en.canonical LIKE ?3 OR f.predicate LIKE ?3 OR f.object LIKE ?3
391                        OR EXISTS (SELECT 1 FROM entity_aliases a
392                                   WHERE a.entity_id = f.subject_entity AND a.alias LIKE ?3))
393                 ORDER BY f.confidence DESC, f.access_count DESC
394                 LIMIT ?4",
395            )?;
396            let now: String =
397                self.conn
398                    .query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')", [], |r| {
399                        r.get(0)
400                    })?;
401            let effective = if as_of == "now-sentinel" { now } else { as_of };
402            for term in &terms {
403                let rows = stmt.query_map(
404                    rusqlite::params![space.id(), effective, term, opts.limit as i64],
405                    |r| {
406                        Ok(FactItem {
407                            fact_id: r.get(0)?,
408                            subject: r.get(1)?,
409                            predicate: r.get(2)?,
410                            object: r.get(3)?,
411                            confidence: r.get(4)?,
412                            valid_from: r.get(5)?,
413                            valid_until: r.get(6)?,
414                            status: r.get(7)?,
415                        })
416                    },
417                )?;
418                for row in rows {
419                    let row = row?;
420                    if !found.iter().any(|f| f.fact_id == row.fact_id) {
421                        found.push(row);
422                    }
423                }
424            }
425        }
426        found.truncate(opts.limit);
427        // Reinforcement: recalled present-time facts strengthen (spec §7).
428        // Historical (as_of) browsing does not rewrite the present.
429        if opts.as_of.is_none() {
430            for f in &found {
431                self.conn.execute(
432                    "UPDATE facts SET access_count = access_count + 1,
433                            last_accessed = strftime('%Y-%m-%dT%H:%M:%fZ','now')
434                     WHERE id = ?1",
435                    [f.fact_id],
436                )?;
437            }
438        }
439        Ok(found)
440    }
441}