Skip to main content

gemini_memory_rs/retrieval/
retriever.rs

1//! The retrieval pipeline: plan in, prepared snapshot out.
2//!
3//! Search order is L0 prepared-query cache → session overlay → canonical BM25 →
4//! optional semantic fallback. Each stage is cheaper than the next, and the
5//! expensive one is reached only when the cheap ones genuinely could not serve
6//! the query.
7
8use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use parking_lot::RwLock;
11use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13
14use super::assembler::ContextAssembler;
15use super::fusion::{FusedCandidate, reciprocal_rank_fusion};
16use super::plan::RetrievalPlan;
17use super::snapshot::PreparedMemorySnapshot;
18use crate::bm25::{MemoryIndex, Query, SearchHit};
19use crate::core::{MemoryError, MemoryId, RetrievalConfig, TurnId};
20use crate::retrieval::deterministic::topical_terms;
21
22/// The words of a query that carry no topic: what is left after the topical
23/// terms are taken out.
24///
25/// These are the words a memory lookup is *always* phrased with — "what", "the
26/// user's", "my" — plus the corpus's own subject form, which has a posting in
27/// almost every record. They are excellent at saying whose memory to prefer and
28/// useless at saying which memory is relevant, so the index is told to let them
29/// rank a record but never admit one. See [`Query::boost_only`].
30pub(crate) fn non_topical_terms(query: &str) -> Vec<String> {
31    let topical: HashSet<String> = topical_terms(query).into_iter().collect();
32    crate::bm25::tokenize(query)
33        .into_iter()
34        .filter(|term| !topical.contains(term))
35        .collect()
36}
37
38/// A request to prepare context for a turn.
39#[derive(Debug, Clone)]
40pub struct RetrievalRequest {
41    /// The plan to execute.
42    pub plan: RetrievalPlan,
43    /// Time to evaluate expiry and recency against.
44    pub now: DateTime<Utc>,
45}
46
47impl RetrievalRequest {
48    /// A request to run `plan` now.
49    pub fn new(plan: RetrievalPlan) -> Self {
50        Self {
51            plan,
52            now: Utc::now(),
53        }
54    }
55}
56
57/// A time budget for a retrieval.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct RetrievalBudget {
60    /// Milliseconds allowed for lexical search.
61    pub lexical_ms: u64,
62    /// Milliseconds allowed for semantic fallback; zero disables it.
63    pub semantic_ms: u64,
64}
65
66impl RetrievalBudget {
67    /// The budget for a synchronous fallback on the tool-call path.
68    ///
69    /// Semantic search gets a short deadline rather than none. It used to get
70    /// none, on the reasoning that a network round trip would turn a slow
71    /// answer into a late one — true of a remote backend, and the reason the
72    /// deadline is short. But it also meant a *local* backend was never asked:
73    /// measured against a perfect semantic oracle, this path answered exactly
74    /// as many questions as BM25 alone, because the oracle was never once
75    /// consulted. A deadline keeps the latency guarantee and stops throwing
76    /// away the one path that could answer.
77    pub fn interactive() -> Self {
78        Self::interactive_with(&RetrievalConfig::default())
79    }
80
81    /// The interactive budget under a specific configuration.
82    pub fn interactive_with(config: &RetrievalConfig) -> Self {
83        Self {
84            lexical_ms: config.immediate_lexical_timeout_ms,
85            semantic_ms: config.immediate_semantic_timeout_ms,
86        }
87    }
88
89    /// The budget for speculative preparation, where semantic fallback is worth
90    /// attempting because nothing is waiting on it.
91    pub fn speculative() -> Self {
92        Self::speculative_with(&RetrievalConfig::default())
93    }
94
95    /// The speculative budget under a specific configuration.
96    pub fn speculative_with(config: &RetrievalConfig) -> Self {
97        Self {
98            lexical_ms: config.immediate_lexical_timeout_ms * 4,
99            semantic_ms: config.semantic_fallback_timeout_ms,
100        }
101    }
102}
103
104/// Prepares and serves memory context.
105#[async_trait]
106pub trait MemoryRetriever: Send + Sync {
107    /// Run a plan and produce a snapshot.
108    async fn prepare(
109        &self,
110        request: RetrievalRequest,
111    ) -> Result<PreparedMemorySnapshot, MemoryError>;
112
113    /// Answer a query directly, for when speculation missed.
114    async fn retrieve_immediate(
115        &self,
116        query: &str,
117        turn_id: TurnId,
118        budget: RetrievalBudget,
119    ) -> Result<PreparedMemorySnapshot, MemoryError>;
120}
121
122/// An optional paraphrase-tolerant backend.
123///
124/// Reached only when lexical search finds too little — an indirect question, a
125/// pronoun-heavy reference, or a fact the user is describing rather than naming.
126#[async_trait]
127pub trait SemanticFallback: Send + Sync {
128    /// Return record ids in descending relevance.
129    async fn search(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>, MemoryError>;
130
131    /// Bring the backend in line with the active corpus.
132    ///
133    /// Called after the canonical index is recompiled — that is, after a
134    /// session seals and reconciliation has decided what is true. `active` is
135    /// the **entire desired state**: every active record paired with the text
136    /// it should be findable by, from
137    /// [`embedding_text`](crate::retrieval::embedding_text).
138    ///
139    /// Passing the whole set rather than a diff is deliberate. A diff has to be
140    /// right, and the cost of getting it wrong is a vector store that quietly
141    /// disagrees with the corpus — which is the failure this method exists to
142    /// prevent, reintroduced by the fix for it. A backend given the full set
143    /// can reconcile idempotently and embed only what it does not already hold.
144    ///
145    /// Default is a no-op, so an existing implementation keeps compiling and
146    /// keeps its current behaviour: an index that never learns about
147    /// corrections. That is the *safe* failure — `semantic_ranking` resolves
148    /// every returned id against the canonical index and drops what is no
149    /// longer retrievable, so a stale backend loses facts rather than serving
150    /// wrong ones — but it is still a failure, and it lands hardest on
151    /// corrected facts, which are the ones a user has shown they care about.
152    ///
153    /// # `revision` orders concurrent callers
154    ///
155    /// One engine hands the same backend to every session it opens, so two
156    /// sessions sealing at the same moment both call this — each with a corpus
157    /// snapshot taken before it started. Because `active` is a whole desired
158    /// state rather than a diff, the call that *finishes* last wins, and if
159    /// that is the one that *started* first it silently removes every record
160    /// the other added, vectors and all.
161    ///
162    /// Serialising the calls does not fix that: the loser's snapshot is stale
163    /// whenever it is applied, not only when it interleaves. So callers pass
164    /// the canonical index revision the snapshot was taken from — monotonic per
165    /// engine — and an implementation must ignore a revision it has already
166    /// passed. Pass `0` if there is nothing meaningful to order by; that
167    /// disables the check rather than breaking it.
168    async fn reconcile(
169        &self,
170        _active: &[(MemoryId, String)],
171        _revision: u64,
172    ) -> Result<(), MemoryError> {
173        Ok(())
174    }
175}
176
177/// Shared, swappable index state.
178///
179/// Held behind an `RwLock` rather than rebuilt per query: retrieval runs while
180/// the user is still speaking, so it must never wait on a rebuild.
181#[derive(Debug, Default)]
182pub struct IndexHandle {
183    index: RwLock<MemoryIndex>,
184    /// Monotonic across replacements.
185    ///
186    /// A rebuilt index derives its revision from how many documents were
187    /// inserted, so swapping one active fact for another can land on the same
188    /// number. Retrieval caches key on this value, so an equal revision after a
189    /// correction would serve a stale-but-fresh-looking snapshot.
190    generation: std::sync::atomic::AtomicU64,
191}
192
193impl IndexHandle {
194    /// An empty handle.
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Wrap an existing index.
200    pub fn with_index(index: MemoryIndex) -> Self {
201        Self {
202            index: RwLock::new(index),
203            generation: std::sync::atomic::AtomicU64::new(0),
204        }
205    }
206
207    /// Replace the index wholesale, e.g. after a corpus recompile.
208    pub fn replace(&self, index: MemoryIndex) {
209        *self.index.write() = index;
210        self.generation
211            .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
212    }
213
214    /// Read the index.
215    pub fn read(&self) -> parking_lot::RwLockReadGuard<'_, MemoryIndex> {
216        self.index.read()
217    }
218
219    /// Mutate the index in place.
220    pub fn write(&self) -> parking_lot::RwLockWriteGuard<'_, MemoryIndex> {
221        self.index.write()
222    }
223
224    /// A revision that advances on every replacement and every mutation.
225    pub fn revision(&self) -> u64 {
226        // Both terms matter: in-place upserts move the inner revision, whole
227        // replacements move the generation.
228        self.generation
229            .load(std::sync::atomic::Ordering::Acquire)
230            .wrapping_mul(1_000_003)
231            .wrapping_add(self.index.read().revision())
232    }
233}
234
235/// The default retriever: local, in-process, no network on the happy path.
236pub struct LocalMemoryRetriever {
237    canonical: Arc<IndexHandle>,
238    overlay: Arc<IndexHandle>,
239    assembler: ContextAssembler,
240    config: RetrievalConfig,
241    semantic: Option<Arc<dyn SemanticFallback>>,
242    cache: RwLock<HashMap<String, PreparedMemorySnapshot>>,
243    suppressed: RwLock<HashSet<String>>,
244    removed: RwLock<HashSet<MemoryId>>,
245}
246
247impl LocalMemoryRetriever {
248    /// Build a retriever over canonical and overlay indexes.
249    pub fn new(
250        canonical: Arc<IndexHandle>,
251        overlay: Arc<IndexHandle>,
252        config: RetrievalConfig,
253    ) -> Self {
254        Self {
255            canonical,
256            overlay,
257            assembler: ContextAssembler::new(config.clone()),
258            config,
259            semantic: None,
260            cache: RwLock::new(HashMap::new()),
261            suppressed: RwLock::new(HashSet::new()),
262            removed: RwLock::new(HashSet::new()),
263        }
264    }
265
266    /// Hide canonical records in the given `subject|predicate` windows.
267    ///
268    /// When the user states something outright mid-conversation, the durable
269    /// record it contradicts must stop being retrieved *now* — not after
270    /// reconciliation. Otherwise B answers a correction by repeating the thing
271    /// it was just corrected about.
272    pub fn suppress_windows(&self, windows: HashSet<String>) {
273        *self.suppressed.write() = windows;
274        self.invalidate_cache();
275    }
276
277    /// The windows currently hidden from canonical retrieval.
278    pub fn suppressed_windows(&self) -> HashSet<String> {
279        self.suppressed.read().clone()
280    }
281
282    pub(crate) fn suppress_records(&self, ids: HashSet<MemoryId>) {
283        *self.removed.write() = ids;
284        self.invalidate_cache();
285    }
286
287    pub(crate) fn record_visible(&self, id: &MemoryId, origin: crate::bm25::MemoryOrigin) -> bool {
288        if self.removed.read().contains(id) {
289            return false;
290        }
291        if origin == crate::bm25::MemoryOrigin::SessionOverlay {
292            return self.overlay.read().get(id).is_some();
293        }
294        self.canonical.read().get(id).is_none_or(|doc| {
295            !self
296                .suppressed
297                .read()
298                .contains(&format!("{}|{}", doc.subject_form, doc.predicate))
299        })
300    }
301
302    pub(crate) fn filter_snapshot(
303        &self,
304        mut snapshot: PreparedMemorySnapshot,
305    ) -> PreparedMemorySnapshot {
306        let facts: Vec<_> = snapshot
307            .facts
308            .iter()
309            .filter(|fact| self.record_visible(&fact.memory_id, fact.origin))
310            .cloned()
311            .collect();
312        snapshot.token_count = facts
313            .iter()
314            .map(super::snapshot::RetrievedMemory::token_cost)
315            .sum::<usize>()
316            .min(u16::MAX as usize) as u16;
317        snapshot.facts = facts.into();
318        snapshot
319    }
320
321    /// The semantic backend, if one was installed.
322    ///
323    /// Exposed so the engine can call [`SemanticFallback::reconcile`] after a
324    /// commit — the vector store has to be told what reconciliation decided, or
325    /// it keeps answering from the corpus as it was before the correction.
326    pub fn semantic(&self) -> Option<&Arc<dyn SemanticFallback>> {
327        self.semantic.as_ref()
328    }
329
330    /// Attach a semantic fallback backend.
331    pub fn with_semantic_fallback(mut self, fallback: Arc<dyn SemanticFallback>) -> Self {
332        self.semantic = Some(fallback);
333        self
334    }
335
336    /// Drop cached results — call after any index change.
337    pub fn invalidate_cache(&self) {
338        self.cache.write().clear();
339    }
340
341    /// How many cached snapshots are held.
342    pub fn cached_len(&self) -> usize {
343        self.cache.read().len()
344    }
345
346    fn run_lexical(&self, plan: &RetrievalPlan, now: DateTime<Utc>) -> Vec<Vec<SearchHit>> {
347        let entity_forms = plan.entity_forms();
348        // A plan's scopes are deliberately *not* applied as a filter.
349        //
350        // Kinds are assigned by the extraction model and scopes are proposed by
351        // the planning model; making retrieval depend on those two agreeing on
352        // a taxonomy loses recall for no benefit. A dietary fact the extractor
353        // filed as `Identity` is exactly what a plan scoped to `Preference` is
354        // looking for. Only an explicit caller scope — the `recall_context`
355        // argument — restricts kinds, because that is a stated intent rather
356        // than an inference.
357        let mut queries: Vec<Query> = plan
358            .lexical_queries
359            .iter()
360            .map(|text| {
361                Query::new(text)
362                    .with_entities(entity_forms.clone())
363                    .with_kinds(plan.kind_filter.clone())
364                    .with_boost_only(non_topical_terms(text))
365                    .with_limit(20)
366            })
367            .collect();
368        if queries.is_empty() && !entity_forms.is_empty() {
369            queries.push(
370                Query::new("")
371                    .with_entities(entity_forms.clone())
372                    .with_kinds(plan.kind_filter.clone())
373                    .with_limit(20),
374            );
375        }
376
377        let canonical = self.canonical.read();
378        let overlay = self.overlay.read();
379        let mut rankings = Vec::with_capacity(queries.len() * 2);
380        for query in &queries {
381            // The overlay is searched first and fused as its own ranking, so a
382            // fact learned seconds ago competes on rank rather than having to
383            // out-score months of accumulated evidence.
384            let overlay_hits = overlay.search(query, now);
385            if !overlay_hits.is_empty() {
386                rankings.push(overlay_hits);
387            }
388            let hits = canonical.search(query, now);
389            if !hits.is_empty() {
390                rankings.push(hits);
391            }
392        }
393        rankings
394    }
395
396    /// Ask the semantic backend, as a ranking that competes on merit.
397    ///
398    /// Two decisions here were reversed by measurement, and both were reversed
399    /// for the same reason: the semantic side is the *stronger* ranker, not a
400    /// weaker one worth consulting in emergencies.
401    ///
402    /// **It is no longer gated.** The gate asked the backend only when lexical
403    /// search had found little, which fired on 13 of 93 paraphrased questions.
404    /// All 13 were rescued — every single call was worth making — while the
405    /// other 80 were declined because BM25 had returned something confident.
406    /// Confident and wrong is the failure this was supposed to catch.
407    ///
408    /// **It is no longer appended below the lexical hits.** Embedding a
409    /// record's own frontmatter answers 66 of those 93 questions against BM25's
410    /// 42; ranking that beneath every lexical hit discards the better opinion
411    /// by construction. The ranking is fused instead, weighted 2:1 — the
412    /// configuration `semantic_fusion_probe` measured at 79/93 in the top five,
413    /// against 73 for semantics alone and 58 for BM25 alone.
414    ///
415    /// Returns the semantic ranking, empty if there is no backend, no budget,
416    /// no query, or the deadline passes.
417    async fn semantic_ranking(
418        &self,
419        plan: &RetrievalPlan,
420        budget: RetrievalBudget,
421        now: DateTime<Utc>,
422    ) -> Vec<SearchHit> {
423        let (Some(semantic), true) = (self.semantic.as_ref(), budget.semantic_ms > 0) else {
424            return Vec::new();
425        };
426        let Some(query) = plan.lexical_queries.first() else {
427            return Vec::new();
428        };
429
430        let deadline = std::time::Duration::from_millis(budget.semantic_ms);
431        let result = tokio::time::timeout(deadline, semantic.search(query, 10)).await;
432        let Ok(Ok(ids)) = result else {
433            // A failed or slow fallback is not an error: the lexical results
434            // stand, and the caller never learns the difference.
435            return Vec::new();
436        };
437
438        let canonical = self.canonical.read();
439        ids.iter()
440            .filter_map(|id| {
441                let doc = canonical.get(id)?;
442                if !doc.is_retrievable(now) {
443                    return None;
444                }
445                // The score is nominal: RRF ranks by position, and a dense
446                // similarity is not comparable with a BM25 score anyway.
447                let score = self.config.minimum_candidate_score * 2.0;
448                Some(SearchHit {
449                    id: id.clone(),
450                    score,
451                    statement: doc.statement.clone(),
452                    kind: doc.kind,
453                    origin: doc.origin,
454                    explanation: crate::bm25::SearchExplanation {
455                        memory_id: id.clone(),
456                        components: Vec::new(),
457                        boosts: Vec::new(),
458                        lexical_score: 0.0,
459                        final_score: score,
460                    },
461                })
462            })
463            .collect()
464    }
465
466    /// Answer a query directly, restricted to certain memory kinds.
467    ///
468    /// The prepared-snapshot shortcut is deliberately bypassed when a scope is
469    /// given: the snapshot was built speculatively and knows nothing about the
470    /// restriction the model asked for, so serving it would answer a different
471    /// question quickly.
472    pub async fn retrieve_scoped(
473        &self,
474        query: &str,
475        turn_id: TurnId,
476        budget: RetrievalBudget,
477        kinds: Vec<crate::core::MemoryKind>,
478        about: Option<String>,
479        attribute: Option<String>,
480    ) -> Result<PreparedMemorySnapshot, MemoryError> {
481        let now = Utc::now();
482        let blank = |value: Option<String>| value.filter(|v| !v.trim().is_empty());
483        let plan = RetrievalPlan {
484            requires_memory: true,
485            lexical_queries: vec![query.to_string()],
486            kind_filter: kinds,
487            subject_hint: blank(about),
488            predicate_hint: blank(attribute),
489            ..RetrievalPlan::skip(turn_id, 0, query)
490        }
491        .normalized();
492        Ok(self.execute(&plan, budget, now).await)
493    }
494
495    /// Boost records matching the caller's `about`/`attribute` hints.
496    ///
497    /// For each ranking already collected, this appends a copy containing only
498    /// the records that match. A matching record therefore earns its
499    /// `1/(60 + rank)` twice over and roughly doubles its fused weight, while a
500    /// record that does not match keeps everything it had. Nothing is removed.
501    ///
502    /// That "nothing is removed" is the entire design, and it is the third
503    /// place in this crate where the same lesson was learned by measurement.
504    /// `needs_semantic_fallback` gated the semantic backend and cost 80% of its
505    /// value; `satisfies` gated the prepared snapshot and discarded 65 of 93
506    /// correct ones; and a *hard* version of this filter answers 0 of 93 when
507    /// the hint is wrong, because the answer never enters the candidate set.
508    /// The soft version costs one question in the same case.
509    ///
510    /// A hint matching nothing produces empty rankings, which contribute
511    /// nothing and leave the unfiltered result exactly as it was. That is the
512    /// degradation path, and it is why a model guessing badly is survivable.
513    fn apply_hints(&self, plan: &RetrievalPlan, rankings: &mut Vec<Vec<SearchHit>>) {
514        let subject = plan
515            .subject_hint
516            .as_deref()
517            .map(crate::core::normalize_token)
518            .filter(|s| !s.is_empty());
519        let predicate = plan
520            .predicate_hint
521            .as_deref()
522            .map(crate::core::normalize_token)
523            .filter(|s| !s.is_empty());
524        if subject.is_none() && predicate.is_none() {
525            return;
526        }
527
528        let canonical = self.canonical.read();
529        let matches = |hit: &SearchHit| -> bool {
530            let Some(doc) = canonical.get(&hit.id) else {
531                // Overlay hits are not in the canonical index. A fact learned
532                // seconds ago is the one thing most likely to be what the model
533                // is asking about, so an unresolvable id is boosted rather than
534                // dropped — the failure mode of the alternative is silently
535                // demoting the freshest record in memory.
536                return true;
537            };
538            if let Some(subject) = &subject
539                && &doc.subject_form != subject
540            {
541                return false;
542            }
543            if let Some(predicate) = &predicate
544                && &crate::core::normalize_token(doc.predicate.as_str()) != predicate
545            {
546                return false;
547            }
548            true
549        };
550
551        let boosted: Vec<Vec<SearchHit>> = rankings
552            .iter()
553            .map(|ranking| ranking.iter().filter(|h| matches(h)).cloned().collect())
554            .filter(|ranking: &Vec<SearchHit>| !ranking.is_empty())
555            .collect();
556        rankings.extend(boosted);
557    }
558
559    /// Drop canonical candidates the session has superseded in conversation.
560    fn drop_suppressed(&self, candidates: &mut Vec<FusedCandidate>) {
561        candidates.retain(|candidate| self.record_visible(&candidate.hit.id, candidate.hit.origin));
562    }
563
564    async fn execute(
565        &self,
566        plan: &RetrievalPlan,
567        budget: RetrievalBudget,
568        now: DateTime<Utc>,
569    ) -> PreparedMemorySnapshot {
570        let mut rankings = self.run_lexical(plan, now);
571        let semantic = self.semantic_ranking(plan, budget, now).await;
572        if !semantic.is_empty() {
573            // Twice, which is how you weight a ranking in RRF: each appearance
574            // contributes its own 1/(60 + rank). Measured at 79/93 in the top
575            // five against 73 for semantics alone and 58 for lexical alone.
576            rankings.push(semantic.clone());
577            rankings.push(semantic);
578        }
579        self.apply_hints(plan, &mut rankings);
580        let mut candidates = reciprocal_rank_fusion(&rankings);
581        // Suppression runs after the fusion rather than before it: the semantic
582        // backend returns ids of its own choosing, and one of them may sit in a
583        // window the user corrected seconds ago. Filtering only the lexical
584        // side would let the superseded fact back in by the side door.
585        self.drop_suppressed(&mut candidates);
586
587        let canonical = self.canonical.read();
588        let overlay = self.overlay.read();
589        self.assembler.assemble(
590            plan,
591            &candidates,
592            &canonical,
593            Some(&overlay),
594            (canonical.revision(), overlay.revision()),
595            now,
596        )
597    }
598}
599
600#[async_trait]
601impl MemoryRetriever for LocalMemoryRetriever {
602    async fn prepare(
603        &self,
604        request: RetrievalRequest,
605    ) -> Result<PreparedMemorySnapshot, MemoryError> {
606        let plan = &request.plan;
607        if !plan.requires_memory {
608            return Ok(PreparedMemorySnapshot::empty(plan.turn_id));
609        }
610
611        let key = format!(
612            "{}|{}|{}",
613            plan.cache_key(),
614            self.canonical.revision(),
615            self.overlay.revision()
616        );
617        if let Some(cached) = self.cache.read().get(&key)
618            && cached.is_fresh(request.now)
619        {
620            let mut snapshot = cached.clone();
621            snapshot.source_turn_id = plan.turn_id;
622            snapshot.eligible_from_turn = TurnId(plan.turn_id.0 + 1);
623            return Ok(snapshot);
624        }
625
626        let snapshot = self
627            .execute(plan, RetrievalBudget::speculative(), request.now)
628            .await;
629        self.cache.write().insert(key, snapshot.clone());
630        Ok(snapshot)
631    }
632
633    async fn retrieve_immediate(
634        &self,
635        query: &str,
636        turn_id: TurnId,
637        budget: RetrievalBudget,
638    ) -> Result<PreparedMemorySnapshot, MemoryError> {
639        self.retrieve_scoped(query, turn_id, budget, Vec::new(), None, None)
640            .await
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use crate::bm25::IndexedMemory;
648    use crate::core::{
649        CanonicalMemory, CanonicalPredicate, EntityRef, EvidenceCounters, Explicitness, MemoryKind,
650        MemorySource, MemoryStatus, MemoryValue, PrivacyMetadata, RetrievalMetadata, SessionId,
651        TemporalMetadata, TemporalScope, UserId,
652    };
653    use crate::retrieval::deterministic::{DeterministicPlanner, KnownEntities};
654
655    fn record(id: &str, kind: MemoryKind, subject: &str, statement: &str) -> CanonicalMemory {
656        CanonicalMemory {
657            id: MemoryId::new(id),
658            owner: UserId::new("usr_1"),
659            kind,
660            predicate: CanonicalPredicate::new(format!("pred_{id}")),
661            status: MemoryStatus::Active,
662            confidence: 0.9,
663            subject: EntityRef::named(subject),
664            value: MemoryValue::Text(statement.into()),
665            statement: statement.into(),
666            evidence_summary: "stated".into(),
667            source: MemorySource::from_explicitness(
668                Explicitness::ExplicitStatement,
669                SessionId::new("ses_1"),
670                TurnId(1),
671            ),
672            temporal: TemporalMetadata::created_at(Utc::now()),
673            retrieval: RetrievalMetadata {
674                subject: crate::core::normalize_token(subject),
675                tags: vec!["restaurant".into(), "food".into()],
676                ..Default::default()
677            },
678            evidence: EvidenceCounters::first(),
679            privacy: PrivacyMetadata::default(),
680            temporal_scope: TemporalScope::Persistent,
681            supersedes: Vec::new(),
682            superseded_by: None,
683            qualifier: None,
684        }
685    }
686
687    fn retriever() -> (LocalMemoryRetriever, Arc<IndexHandle>, Arc<IndexHandle>) {
688        let canonical = Arc::new(IndexHandle::with_index(MemoryIndex::build([
689            IndexedMemory::from_canonical(&record(
690                "mem_quiet",
691                MemoryKind::RelationshipPreference,
692                "Rhea",
693                "Rhea prefers quiet restaurants.",
694            )),
695            IndexedMemory::from_canonical(&record(
696                "mem_diet",
697                MemoryKind::Preference,
698                "user",
699                "The user is pescatarian.",
700            )),
701        ])));
702        let overlay = Arc::new(IndexHandle::new());
703        let retriever = LocalMemoryRetriever::new(
704            canonical.clone(),
705            overlay.clone(),
706            RetrievalConfig::default(),
707        );
708        (retriever, canonical, overlay)
709    }
710
711    fn plan_for(text: &str) -> RetrievalPlan {
712        let mut known = KnownEntities::new();
713        known.insert("Rhea", "rhea");
714        known.insert("my wife", "rhea");
715        DeterministicPlanner::with_entities(known).plan(text, TurnId(3), 3, Utc::now())
716    }
717
718    #[tokio::test]
719    async fn prepares_relevant_context_for_a_recommendation_turn() {
720        let (retriever, _, _) = retriever();
721        let snapshot = retriever
722            .prepare(RetrievalRequest::new(plan_for(
723                "where should we eat with my wife tonight",
724            )))
725            .await
726            .unwrap();
727        assert!(!snapshot.is_empty());
728        assert!(
729            snapshot
730                .facts
731                .iter()
732                .any(|f| f.statement.contains("quiet restaurants"))
733        );
734    }
735
736    #[tokio::test]
737    async fn a_plan_with_nothing_to_search_with_returns_an_empty_snapshot() {
738        let (retriever, _, _) = retriever();
739        let snapshot = retriever
740            .prepare(RetrievalRequest::new(plan_for("what do you think")))
741            .await
742            .unwrap();
743        assert!(snapshot.is_empty());
744        assert_eq!(retriever.cached_len(), 0, "a skip plan is not cached");
745    }
746
747    #[tokio::test]
748    async fn a_world_knowledge_question_searches_and_finds_nothing() {
749        // The other half of the same contract, and the reason the planner does
750        // not need to recognise "what is the capital of France" as general
751        // knowledge: the query runs against a personal corpus that contains no
752        // matching term, so it scores nothing. Identical observable outcome to
753        // a skip, without needing to understand the sentence to get there.
754        let (retriever, _, _) = retriever();
755        let snapshot = retriever
756            .prepare(RetrievalRequest::new(plan_for(
757                "what is the capital of France",
758            )))
759            .await
760            .unwrap();
761        assert!(
762            snapshot.is_empty(),
763            "a question the corpus knows nothing about produced context: {:?}",
764            snapshot.facts
765        );
766    }
767
768    #[tokio::test]
769    async fn identical_plans_hit_the_cache_but_an_index_change_invalidates_it() {
770        let (retriever, canonical, _) = retriever();
771        let plan = plan_for("what does my wife like about restaurants");
772        retriever
773            .prepare(RetrievalRequest::new(plan.clone()))
774            .await
775            .unwrap();
776        assert_eq!(retriever.cached_len(), 1);
777
778        retriever
779            .prepare(RetrievalRequest::new(plan.clone()))
780            .await
781            .unwrap();
782        assert_eq!(retriever.cached_len(), 1, "same plan reuses the entry");
783
784        // A new revision keys differently, so stale context cannot be served.
785        canonical
786            .write()
787            .upsert(IndexedMemory::from_canonical(&record(
788                "mem_new",
789                MemoryKind::Preference,
790                "user",
791                "The user likes rooftop restaurants.",
792            )));
793        retriever
794            .prepare(RetrievalRequest::new(plan))
795            .await
796            .unwrap();
797        assert_eq!(retriever.cached_len(), 2);
798    }
799
800    #[tokio::test]
801    async fn overlay_facts_are_retrievable_immediately() {
802        let (retriever, _, overlay) = retriever();
803        overlay.write().upsert(
804            IndexedMemory::from_canonical(&record(
805                "mem_session",
806                MemoryKind::Episodic,
807                "user",
808                "The user is meeting Kushal for dinner tonight.",
809            ))
810            .as_session_overlay(),
811        );
812
813        let snapshot = retriever
814            .retrieve_immediate("dinner tonight", TurnId(4), RetrievalBudget::interactive())
815            .await
816            .unwrap();
817        assert!(
818            snapshot
819                .facts
820                .iter()
821                .any(|f| f.memory_id.as_str() == "mem_session")
822        );
823    }
824
825    #[tokio::test]
826    async fn an_immediate_query_with_no_match_degrades_to_not_found() {
827        let (retriever, _, _) = retriever();
828        let snapshot = retriever
829            .retrieve_immediate(
830                "what medication is prescribed",
831                TurnId(4),
832                RetrievalBudget::interactive(),
833            )
834            .await
835            .unwrap();
836        assert!(snapshot.is_empty());
837        assert_eq!(snapshot.to_tool_payload()["status"], "not_found");
838    }
839
840    struct StubSemantic {
841        ids: Vec<MemoryId>,
842    }
843
844    #[async_trait]
845    impl SemanticFallback for StubSemantic {
846        async fn search(&self, _query: &str, _limit: usize) -> Result<Vec<MemoryId>, MemoryError> {
847            Ok(self.ids.clone())
848        }
849    }
850
851    struct FailingSemantic;
852
853    #[async_trait]
854    impl SemanticFallback for FailingSemantic {
855        async fn search(&self, _query: &str, _limit: usize) -> Result<Vec<MemoryId>, MemoryError> {
856            Err(MemoryError::Retrieval("backend down".into()))
857        }
858    }
859
860    #[tokio::test]
861    async fn semantic_fallback_rescues_a_query_lexical_search_misses() {
862        let (retriever, canonical, overlay) = retriever();
863        drop(retriever);
864        let retriever = LocalMemoryRetriever::new(canonical, overlay, RetrievalConfig::default())
865            .with_semantic_fallback(Arc::new(StubSemantic {
866                ids: vec![MemoryId::new("mem_diet")],
867            }));
868
869        // No lexical overlap with any record, so only the fallback can find it.
870        let snapshot = retriever
871            .retrieve_immediate(
872                "seafood eating habits",
873                TurnId(4),
874                RetrievalBudget::speculative(),
875            )
876            .await
877            .unwrap();
878        assert!(
879            snapshot
880                .facts
881                .iter()
882                .any(|f| f.memory_id.as_str() == "mem_diet")
883        );
884    }
885
886    #[tokio::test]
887    async fn a_failing_semantic_backend_degrades_to_lexical_results() {
888        let (retriever, canonical, overlay) = retriever();
889        drop(retriever);
890        let retriever = LocalMemoryRetriever::new(canonical, overlay, RetrievalConfig::default())
891            .with_semantic_fallback(Arc::new(FailingSemantic));
892
893        let snapshot = retriever
894            .retrieve_immediate(
895                "quiet restaurants",
896                TurnId(4),
897                RetrievalBudget::speculative(),
898            )
899            .await
900            .unwrap();
901        assert!(!snapshot.is_empty(), "lexical results must still be served");
902    }
903
904    #[tokio::test]
905    async fn the_interactive_budget_bounds_the_semantic_call_rather_than_forbidding_it() {
906        let interactive = RetrievalBudget::interactive();
907        let speculative = RetrievalBudget::speculative();
908
909        // It used to be zero. Zero meant a *local* backend — an in-process
910        // vector scan costing well under a millisecond — was never asked, and
911        // measured against a perfect oracle this path answered exactly as many
912        // questions as BM25 alone.
913        assert!(
914            interactive.semantic_ms > 0,
915            "the tool path must at least ask; a backend that cannot answer in \
916             time will time out on its own"
917        );
918        // But it stays a fraction of the speculative budget: nothing is waiting
919        // on speculation, and the model is waiting on this.
920        assert!(
921            interactive.semantic_ms * 4 <= speculative.semantic_ms,
922            "the interactive deadline ({}ms) has drifted close to the \
923             speculative one ({}ms) — the point of the split is that only one \
924             of them has somebody waiting",
925            interactive.semantic_ms,
926            speculative.semantic_ms,
927        );
928    }
929
930    #[tokio::test]
931    async fn a_zero_interactive_deadline_restores_the_old_behaviour() {
932        let config = RetrievalConfig {
933            immediate_semantic_timeout_ms: 0,
934            ..RetrievalConfig::default()
935        };
936        assert_eq!(RetrievalBudget::interactive_with(&config).semantic_ms, 0);
937    }
938
939    /// The gate is gone, and this is what replaces the argument for it.
940    ///
941    /// It used to consult the backend only when lexical search came back thin,
942    /// which fired on 13 of 93 paraphrased questions — and rescued all 13. The
943    /// 80 it declined were declined because BM25 had returned something
944    /// confident, which is the failure mode, not the safe case.
945    /// The property the whole design rests on: a wrong hint must not be able to
946    /// remove the answer.
947    ///
948    /// Measured, a *hard* version of this filter answers 0 of 93 questions when
949    /// the hint is wrong — the answer is absent from the candidate set every
950    /// single time — while the soft version costs one. If this test ever fails,
951    /// the filter has become a gate and the feature is worse than not having it.
952    #[tokio::test]
953    async fn a_wrong_hint_reorders_the_results_without_removing_the_answer() {
954        let (retriever, canonical, _overlay) = retriever();
955        canonical
956            .write()
957            .upsert(IndexedMemory::from_canonical(&record(
958                "mem_coffee",
959                MemoryKind::Preference,
960                "user",
961                "The user drinks cortados.",
962            )));
963
964        let unhinted = retriever
965            .retrieve_scoped(
966                "cortados",
967                TurnId(1),
968                RetrievalBudget::interactive(),
969                Vec::new(),
970                None,
971                None,
972            )
973            .await
974            .expect("retrieval");
975        assert!(
976            !unhinted.facts.is_empty(),
977            "the baseline must find the record for this test to mean anything"
978        );
979
980        let misdirected = retriever
981            .retrieve_scoped(
982                "cortados",
983                TurnId(1),
984                RetrievalBudget::interactive(),
985                Vec::new(),
986                Some("somebody-else-entirely".into()),
987                Some("an-attribute-that-does-not-exist".into()),
988            )
989            .await
990            .expect("retrieval");
991        assert_eq!(
992            misdirected.facts.len(),
993            unhinted.facts.len(),
994            "a hint matching nothing must leave the unfiltered result standing, \
995             not empty it"
996        );
997    }
998
999    /// And a right hint has to actually do something, or the field is theatre.
1000    #[tokio::test]
1001    async fn a_matching_hint_promotes_its_record_above_a_rival() {
1002        let (retriever, canonical, _overlay) = retriever();
1003        // Two records the query matches equally well, distinguished only by
1004        // whose fact it is.
1005        canonical
1006            .write()
1007            .upsert(IndexedMemory::from_canonical(&record(
1008                "mem_rival",
1009                MemoryKind::Preference,
1010                "rhea",
1011                "Rhea drinks cortados.",
1012            )));
1013        canonical
1014            .write()
1015            .upsert(IndexedMemory::from_canonical(&record(
1016                "mem_target",
1017                MemoryKind::Preference,
1018                "user",
1019                "The user drinks cortados.",
1020            )));
1021
1022        let hinted = retriever
1023            .retrieve_scoped(
1024                "cortados",
1025                TurnId(1),
1026                RetrievalBudget::interactive(),
1027                Vec::new(),
1028                Some("user".into()),
1029                None,
1030            )
1031            .await
1032            .expect("retrieval");
1033        let first = hinted.facts.first().expect("a result");
1034        assert_eq!(
1035            first.memory_id.as_str(),
1036            "mem_target",
1037            "the record whose subject matches the hint should rank first; got {:?}",
1038            hinted
1039                .facts
1040                .iter()
1041                .map(|m| m.memory_id.as_str())
1042                .collect::<Vec<_>>()
1043        );
1044    }
1045
1046    #[tokio::test]
1047    async fn the_semantic_backend_is_consulted_even_when_lexical_search_is_confident() {
1048        let (retriever, canonical, overlay) = retriever();
1049        drop(retriever);
1050        let retriever = LocalMemoryRetriever::new(canonical, overlay, RetrievalConfig::default())
1051            .with_semantic_fallback(Arc::new(StubSemantic {
1052                ids: vec![MemoryId::new("mem_quiet")],
1053            }));
1054
1055        // "pescatarian" is a confident lexical hit on mem_diet — precisely the
1056        // case the old gate declined to escalate.
1057        let snapshot = retriever
1058            .retrieve_immediate("pescatarian", TurnId(4), RetrievalBudget::speculative())
1059            .await
1060            .unwrap();
1061        assert!(
1062            snapshot
1063                .facts
1064                .iter()
1065                .any(|f| f.memory_id.as_str() == "mem_quiet"),
1066            "a confident lexical hit suppressed the semantic ranking entirely; \
1067             got {:?}",
1068            snapshot
1069                .facts
1070                .iter()
1071                .map(|f| f.memory_id.as_str())
1072                .collect::<Vec<_>>()
1073        );
1074    }
1075}