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