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}
245
246impl LocalMemoryRetriever {
247    /// Build a retriever over canonical and overlay indexes.
248    pub fn new(
249        canonical: Arc<IndexHandle>,
250        overlay: Arc<IndexHandle>,
251        config: RetrievalConfig,
252    ) -> Self {
253        Self {
254            canonical,
255            overlay,
256            assembler: ContextAssembler::new(config.clone()),
257            config,
258            semantic: None,
259            cache: RwLock::new(HashMap::new()),
260            suppressed: RwLock::new(HashSet::new()),
261        }
262    }
263
264    /// Hide canonical records in the given `subject|predicate` windows.
265    ///
266    /// When the user states something outright mid-conversation, the durable
267    /// record it contradicts must stop being retrieved *now* — not after
268    /// reconciliation. Otherwise B answers a correction by repeating the thing
269    /// it was just corrected about.
270    pub fn suppress_windows(&self, windows: HashSet<String>) {
271        *self.suppressed.write() = windows;
272        self.invalidate_cache();
273    }
274
275    /// The windows currently hidden from canonical retrieval.
276    pub fn suppressed_windows(&self) -> HashSet<String> {
277        self.suppressed.read().clone()
278    }
279
280    /// The semantic backend, if one was installed.
281    ///
282    /// Exposed so the engine can call [`SemanticFallback::reconcile`] after a
283    /// commit — the vector store has to be told what reconciliation decided, or
284    /// it keeps answering from the corpus as it was before the correction.
285    pub fn semantic(&self) -> Option<&Arc<dyn SemanticFallback>> {
286        self.semantic.as_ref()
287    }
288
289    /// Attach a semantic fallback backend.
290    pub fn with_semantic_fallback(mut self, fallback: Arc<dyn SemanticFallback>) -> Self {
291        self.semantic = Some(fallback);
292        self
293    }
294
295    /// Drop cached results — call after any index change.
296    pub fn invalidate_cache(&self) {
297        self.cache.write().clear();
298    }
299
300    /// How many cached snapshots are held.
301    pub fn cached_len(&self) -> usize {
302        self.cache.read().len()
303    }
304
305    fn run_lexical(&self, plan: &RetrievalPlan, now: DateTime<Utc>) -> Vec<Vec<SearchHit>> {
306        let entity_forms = plan.entity_forms();
307        // A plan's scopes are deliberately *not* applied as a filter.
308        //
309        // Kinds are assigned by the extraction model and scopes are proposed by
310        // the planning model; making retrieval depend on those two agreeing on
311        // a taxonomy loses recall for no benefit. A dietary fact the extractor
312        // filed as `Identity` is exactly what a plan scoped to `Preference` is
313        // looking for. Only an explicit caller scope — the `recall_context`
314        // argument — restricts kinds, because that is a stated intent rather
315        // than an inference.
316        let mut queries: Vec<Query> = plan
317            .lexical_queries
318            .iter()
319            .map(|text| {
320                Query::new(text)
321                    .with_entities(entity_forms.clone())
322                    .with_kinds(plan.kind_filter.clone())
323                    .with_boost_only(non_topical_terms(text))
324                    .with_limit(20)
325            })
326            .collect();
327        if queries.is_empty() && !entity_forms.is_empty() {
328            queries.push(
329                Query::new("")
330                    .with_entities(entity_forms.clone())
331                    .with_kinds(plan.kind_filter.clone())
332                    .with_limit(20),
333            );
334        }
335
336        let canonical = self.canonical.read();
337        let overlay = self.overlay.read();
338        let mut rankings = Vec::with_capacity(queries.len() * 2);
339        for query in &queries {
340            // The overlay is searched first and fused as its own ranking, so a
341            // fact learned seconds ago competes on rank rather than having to
342            // out-score months of accumulated evidence.
343            let overlay_hits = overlay.search(query, now);
344            if !overlay_hits.is_empty() {
345                rankings.push(overlay_hits);
346            }
347            let hits = canonical.search(query, now);
348            if !hits.is_empty() {
349                rankings.push(hits);
350            }
351        }
352        rankings
353    }
354
355    /// Ask the semantic backend, as a ranking that competes on merit.
356    ///
357    /// Two decisions here were reversed by measurement, and both were reversed
358    /// for the same reason: the semantic side is the *stronger* ranker, not a
359    /// weaker one worth consulting in emergencies.
360    ///
361    /// **It is no longer gated.** The gate asked the backend only when lexical
362    /// search had found little, which fired on 13 of 93 paraphrased questions.
363    /// All 13 were rescued — every single call was worth making — while the
364    /// other 80 were declined because BM25 had returned something confident.
365    /// Confident and wrong is the failure this was supposed to catch.
366    ///
367    /// **It is no longer appended below the lexical hits.** Embedding a
368    /// record's own frontmatter answers 66 of those 93 questions against BM25's
369    /// 42; ranking that beneath every lexical hit discards the better opinion
370    /// by construction. The ranking is fused instead, weighted 2:1 — the
371    /// configuration `semantic_fusion_probe` measured at 79/93 in the top five,
372    /// against 73 for semantics alone and 58 for BM25 alone.
373    ///
374    /// Returns the semantic ranking, empty if there is no backend, no budget,
375    /// no query, or the deadline passes.
376    async fn semantic_ranking(
377        &self,
378        plan: &RetrievalPlan,
379        budget: RetrievalBudget,
380        now: DateTime<Utc>,
381    ) -> Vec<SearchHit> {
382        let (Some(semantic), true) = (self.semantic.as_ref(), budget.semantic_ms > 0) else {
383            return Vec::new();
384        };
385        let Some(query) = plan.lexical_queries.first() else {
386            return Vec::new();
387        };
388
389        let deadline = std::time::Duration::from_millis(budget.semantic_ms);
390        let result = tokio::time::timeout(deadline, semantic.search(query, 10)).await;
391        let Ok(Ok(ids)) = result else {
392            // A failed or slow fallback is not an error: the lexical results
393            // stand, and the caller never learns the difference.
394            return Vec::new();
395        };
396
397        let canonical = self.canonical.read();
398        ids.iter()
399            .filter_map(|id| {
400                let doc = canonical.get(id)?;
401                if !doc.is_retrievable(now) {
402                    return None;
403                }
404                // The score is nominal: RRF ranks by position, and a dense
405                // similarity is not comparable with a BM25 score anyway.
406                let score = self.config.minimum_candidate_score * 2.0;
407                Some(SearchHit {
408                    id: id.clone(),
409                    score,
410                    statement: doc.statement.clone(),
411                    kind: doc.kind,
412                    origin: doc.origin,
413                    explanation: crate::bm25::SearchExplanation {
414                        memory_id: id.clone(),
415                        components: Vec::new(),
416                        boosts: Vec::new(),
417                        lexical_score: 0.0,
418                        final_score: score,
419                    },
420                })
421            })
422            .collect()
423    }
424
425    /// Answer a query directly, restricted to certain memory kinds.
426    ///
427    /// The prepared-snapshot shortcut is deliberately bypassed when a scope is
428    /// given: the snapshot was built speculatively and knows nothing about the
429    /// restriction the model asked for, so serving it would answer a different
430    /// question quickly.
431    pub async fn retrieve_scoped(
432        &self,
433        query: &str,
434        turn_id: TurnId,
435        budget: RetrievalBudget,
436        kinds: Vec<crate::core::MemoryKind>,
437        about: Option<String>,
438        attribute: Option<String>,
439    ) -> Result<PreparedMemorySnapshot, MemoryError> {
440        let now = Utc::now();
441        let blank = |value: Option<String>| value.filter(|v| !v.trim().is_empty());
442        let plan = RetrievalPlan {
443            requires_memory: true,
444            lexical_queries: vec![query.to_string()],
445            kind_filter: kinds,
446            subject_hint: blank(about),
447            predicate_hint: blank(attribute),
448            ..RetrievalPlan::skip(turn_id, 0, query)
449        }
450        .normalized();
451        Ok(self.execute(&plan, budget, now).await)
452    }
453
454    /// Boost records matching the caller's `about`/`attribute` hints.
455    ///
456    /// For each ranking already collected, this appends a copy containing only
457    /// the records that match. A matching record therefore earns its
458    /// `1/(60 + rank)` twice over and roughly doubles its fused weight, while a
459    /// record that does not match keeps everything it had. Nothing is removed.
460    ///
461    /// That "nothing is removed" is the entire design, and it is the third
462    /// place in this crate where the same lesson was learned by measurement.
463    /// `needs_semantic_fallback` gated the semantic backend and cost 80% of its
464    /// value; `satisfies` gated the prepared snapshot and discarded 65 of 93
465    /// correct ones; and a *hard* version of this filter answers 0 of 93 when
466    /// the hint is wrong, because the answer never enters the candidate set.
467    /// The soft version costs one question in the same case.
468    ///
469    /// A hint matching nothing produces empty rankings, which contribute
470    /// nothing and leave the unfiltered result exactly as it was. That is the
471    /// degradation path, and it is why a model guessing badly is survivable.
472    fn apply_hints(&self, plan: &RetrievalPlan, rankings: &mut Vec<Vec<SearchHit>>) {
473        let subject = plan
474            .subject_hint
475            .as_deref()
476            .map(crate::core::normalize_token)
477            .filter(|s| !s.is_empty());
478        let predicate = plan
479            .predicate_hint
480            .as_deref()
481            .map(crate::core::normalize_token)
482            .filter(|s| !s.is_empty());
483        if subject.is_none() && predicate.is_none() {
484            return;
485        }
486
487        let canonical = self.canonical.read();
488        let matches = |hit: &SearchHit| -> bool {
489            let Some(doc) = canonical.get(&hit.id) else {
490                // Overlay hits are not in the canonical index. A fact learned
491                // seconds ago is the one thing most likely to be what the model
492                // is asking about, so an unresolvable id is boosted rather than
493                // dropped — the failure mode of the alternative is silently
494                // demoting the freshest record in memory.
495                return true;
496            };
497            if let Some(subject) = &subject
498                && &doc.subject_form != subject
499            {
500                return false;
501            }
502            if let Some(predicate) = &predicate
503                && &crate::core::normalize_token(doc.predicate.as_str()) != predicate
504            {
505                return false;
506            }
507            true
508        };
509
510        let boosted: Vec<Vec<SearchHit>> = rankings
511            .iter()
512            .map(|ranking| ranking.iter().filter(|h| matches(h)).cloned().collect())
513            .filter(|ranking: &Vec<SearchHit>| !ranking.is_empty())
514            .collect();
515        rankings.extend(boosted);
516    }
517
518    /// Drop canonical candidates the session has superseded in conversation.
519    fn drop_suppressed(&self, candidates: &mut Vec<FusedCandidate>) {
520        let suppressed = self.suppressed.read();
521        if suppressed.is_empty() {
522            return;
523        }
524        let canonical = self.canonical.read();
525        candidates.retain(|candidate| {
526            if candidate.hit.origin != crate::bm25::MemoryOrigin::Canonical {
527                return true;
528            }
529            match canonical.get(&candidate.hit.id) {
530                Some(doc) => {
531                    !suppressed.contains(&format!("{}|{}", doc.subject_form, doc.predicate))
532                }
533                None => true,
534            }
535        });
536    }
537
538    async fn execute(
539        &self,
540        plan: &RetrievalPlan,
541        budget: RetrievalBudget,
542        now: DateTime<Utc>,
543    ) -> PreparedMemorySnapshot {
544        let mut rankings = self.run_lexical(plan, now);
545        let semantic = self.semantic_ranking(plan, budget, now).await;
546        if !semantic.is_empty() {
547            // Twice, which is how you weight a ranking in RRF: each appearance
548            // contributes its own 1/(60 + rank). Measured at 79/93 in the top
549            // five against 73 for semantics alone and 58 for lexical alone.
550            rankings.push(semantic.clone());
551            rankings.push(semantic);
552        }
553        self.apply_hints(plan, &mut rankings);
554        let mut candidates = reciprocal_rank_fusion(&rankings);
555        // Suppression runs after the fusion rather than before it: the semantic
556        // backend returns ids of its own choosing, and one of them may sit in a
557        // window the user corrected seconds ago. Filtering only the lexical
558        // side would let the superseded fact back in by the side door.
559        self.drop_suppressed(&mut candidates);
560
561        let canonical = self.canonical.read();
562        let overlay = self.overlay.read();
563        self.assembler.assemble(
564            plan,
565            &candidates,
566            &canonical,
567            Some(&overlay),
568            (canonical.revision(), overlay.revision()),
569            now,
570        )
571    }
572}
573
574#[async_trait]
575impl MemoryRetriever for LocalMemoryRetriever {
576    async fn prepare(
577        &self,
578        request: RetrievalRequest,
579    ) -> Result<PreparedMemorySnapshot, MemoryError> {
580        let plan = &request.plan;
581        if !plan.requires_memory {
582            return Ok(PreparedMemorySnapshot::empty(plan.turn_id));
583        }
584
585        let key = format!(
586            "{}|{}|{}",
587            plan.cache_key(),
588            self.canonical.revision(),
589            self.overlay.revision()
590        );
591        if let Some(cached) = self.cache.read().get(&key)
592            && cached.is_fresh(request.now)
593        {
594            let mut snapshot = cached.clone();
595            snapshot.source_turn_id = plan.turn_id;
596            snapshot.eligible_from_turn = TurnId(plan.turn_id.0 + 1);
597            return Ok(snapshot);
598        }
599
600        let snapshot = self
601            .execute(plan, RetrievalBudget::speculative(), request.now)
602            .await;
603        self.cache.write().insert(key, snapshot.clone());
604        Ok(snapshot)
605    }
606
607    async fn retrieve_immediate(
608        &self,
609        query: &str,
610        turn_id: TurnId,
611        budget: RetrievalBudget,
612    ) -> Result<PreparedMemorySnapshot, MemoryError> {
613        self.retrieve_scoped(query, turn_id, budget, Vec::new(), None, None)
614            .await
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::bm25::IndexedMemory;
622    use crate::core::{
623        CanonicalMemory, CanonicalPredicate, EntityRef, EvidenceCounters, Explicitness, MemoryKind,
624        MemorySource, MemoryStatus, MemoryValue, PrivacyMetadata, RetrievalMetadata, SessionId,
625        TemporalMetadata, TemporalScope, UserId,
626    };
627    use crate::retrieval::deterministic::{DeterministicPlanner, KnownEntities};
628
629    fn record(id: &str, kind: MemoryKind, subject: &str, statement: &str) -> CanonicalMemory {
630        CanonicalMemory {
631            id: MemoryId::new(id),
632            owner: UserId::new("usr_1"),
633            kind,
634            predicate: CanonicalPredicate::new(format!("pred_{id}")),
635            status: MemoryStatus::Active,
636            confidence: 0.9,
637            subject: EntityRef::named(subject),
638            value: MemoryValue::Text(statement.into()),
639            statement: statement.into(),
640            evidence_summary: "stated".into(),
641            source: MemorySource::from_explicitness(
642                Explicitness::ExplicitStatement,
643                SessionId::new("ses_1"),
644                TurnId(1),
645            ),
646            temporal: TemporalMetadata::created_at(Utc::now()),
647            retrieval: RetrievalMetadata {
648                subject: crate::core::normalize_token(subject),
649                tags: vec!["restaurant".into(), "food".into()],
650                ..Default::default()
651            },
652            evidence: EvidenceCounters::first(),
653            privacy: PrivacyMetadata::default(),
654            temporal_scope: TemporalScope::Persistent,
655            supersedes: Vec::new(),
656            superseded_by: None,
657            qualifier: None,
658        }
659    }
660
661    fn retriever() -> (LocalMemoryRetriever, Arc<IndexHandle>, Arc<IndexHandle>) {
662        let canonical = Arc::new(IndexHandle::with_index(MemoryIndex::build([
663            IndexedMemory::from_canonical(&record(
664                "mem_quiet",
665                MemoryKind::RelationshipPreference,
666                "Rhea",
667                "Rhea prefers quiet restaurants.",
668            )),
669            IndexedMemory::from_canonical(&record(
670                "mem_diet",
671                MemoryKind::Preference,
672                "user",
673                "The user is pescatarian.",
674            )),
675        ])));
676        let overlay = Arc::new(IndexHandle::new());
677        let retriever = LocalMemoryRetriever::new(
678            canonical.clone(),
679            overlay.clone(),
680            RetrievalConfig::default(),
681        );
682        (retriever, canonical, overlay)
683    }
684
685    fn plan_for(text: &str) -> RetrievalPlan {
686        let mut known = KnownEntities::new();
687        known.insert("Rhea", "rhea");
688        known.insert("my wife", "rhea");
689        DeterministicPlanner::with_entities(known).plan(text, TurnId(3), 3, Utc::now())
690    }
691
692    #[tokio::test]
693    async fn prepares_relevant_context_for_a_recommendation_turn() {
694        let (retriever, _, _) = retriever();
695        let snapshot = retriever
696            .prepare(RetrievalRequest::new(plan_for(
697                "where should we eat with my wife tonight",
698            )))
699            .await
700            .unwrap();
701        assert!(!snapshot.is_empty());
702        assert!(
703            snapshot
704                .facts
705                .iter()
706                .any(|f| f.statement.contains("quiet restaurants"))
707        );
708    }
709
710    #[tokio::test]
711    async fn a_plan_with_nothing_to_search_with_returns_an_empty_snapshot() {
712        let (retriever, _, _) = retriever();
713        let snapshot = retriever
714            .prepare(RetrievalRequest::new(plan_for("what do you think")))
715            .await
716            .unwrap();
717        assert!(snapshot.is_empty());
718        assert_eq!(retriever.cached_len(), 0, "a skip plan is not cached");
719    }
720
721    #[tokio::test]
722    async fn a_world_knowledge_question_searches_and_finds_nothing() {
723        // The other half of the same contract, and the reason the planner does
724        // not need to recognise "what is the capital of France" as general
725        // knowledge: the query runs against a personal corpus that contains no
726        // matching term, so it scores nothing. Identical observable outcome to
727        // a skip, without needing to understand the sentence to get there.
728        let (retriever, _, _) = retriever();
729        let snapshot = retriever
730            .prepare(RetrievalRequest::new(plan_for(
731                "what is the capital of France",
732            )))
733            .await
734            .unwrap();
735        assert!(
736            snapshot.is_empty(),
737            "a question the corpus knows nothing about produced context: {:?}",
738            snapshot.facts
739        );
740    }
741
742    #[tokio::test]
743    async fn identical_plans_hit_the_cache_but_an_index_change_invalidates_it() {
744        let (retriever, canonical, _) = retriever();
745        let plan = plan_for("what does my wife like about restaurants");
746        retriever
747            .prepare(RetrievalRequest::new(plan.clone()))
748            .await
749            .unwrap();
750        assert_eq!(retriever.cached_len(), 1);
751
752        retriever
753            .prepare(RetrievalRequest::new(plan.clone()))
754            .await
755            .unwrap();
756        assert_eq!(retriever.cached_len(), 1, "same plan reuses the entry");
757
758        // A new revision keys differently, so stale context cannot be served.
759        canonical
760            .write()
761            .upsert(IndexedMemory::from_canonical(&record(
762                "mem_new",
763                MemoryKind::Preference,
764                "user",
765                "The user likes rooftop restaurants.",
766            )));
767        retriever
768            .prepare(RetrievalRequest::new(plan))
769            .await
770            .unwrap();
771        assert_eq!(retriever.cached_len(), 2);
772    }
773
774    #[tokio::test]
775    async fn overlay_facts_are_retrievable_immediately() {
776        let (retriever, _, overlay) = retriever();
777        overlay.write().upsert(
778            IndexedMemory::from_canonical(&record(
779                "mem_session",
780                MemoryKind::Episodic,
781                "user",
782                "The user is meeting Kushal for dinner tonight.",
783            ))
784            .as_session_overlay(),
785        );
786
787        let snapshot = retriever
788            .retrieve_immediate("dinner tonight", TurnId(4), RetrievalBudget::interactive())
789            .await
790            .unwrap();
791        assert!(
792            snapshot
793                .facts
794                .iter()
795                .any(|f| f.memory_id.as_str() == "mem_session")
796        );
797    }
798
799    #[tokio::test]
800    async fn an_immediate_query_with_no_match_degrades_to_not_found() {
801        let (retriever, _, _) = retriever();
802        let snapshot = retriever
803            .retrieve_immediate(
804                "what medication is prescribed",
805                TurnId(4),
806                RetrievalBudget::interactive(),
807            )
808            .await
809            .unwrap();
810        assert!(snapshot.is_empty());
811        assert_eq!(snapshot.to_tool_payload()["status"], "not_found");
812    }
813
814    struct StubSemantic {
815        ids: Vec<MemoryId>,
816    }
817
818    #[async_trait]
819    impl SemanticFallback for StubSemantic {
820        async fn search(&self, _query: &str, _limit: usize) -> Result<Vec<MemoryId>, MemoryError> {
821            Ok(self.ids.clone())
822        }
823    }
824
825    struct FailingSemantic;
826
827    #[async_trait]
828    impl SemanticFallback for FailingSemantic {
829        async fn search(&self, _query: &str, _limit: usize) -> Result<Vec<MemoryId>, MemoryError> {
830            Err(MemoryError::Retrieval("backend down".into()))
831        }
832    }
833
834    #[tokio::test]
835    async fn semantic_fallback_rescues_a_query_lexical_search_misses() {
836        let (retriever, canonical, overlay) = retriever();
837        drop(retriever);
838        let retriever = LocalMemoryRetriever::new(canonical, overlay, RetrievalConfig::default())
839            .with_semantic_fallback(Arc::new(StubSemantic {
840                ids: vec![MemoryId::new("mem_diet")],
841            }));
842
843        // No lexical overlap with any record, so only the fallback can find it.
844        let snapshot = retriever
845            .retrieve_immediate(
846                "seafood eating habits",
847                TurnId(4),
848                RetrievalBudget::speculative(),
849            )
850            .await
851            .unwrap();
852        assert!(
853            snapshot
854                .facts
855                .iter()
856                .any(|f| f.memory_id.as_str() == "mem_diet")
857        );
858    }
859
860    #[tokio::test]
861    async fn a_failing_semantic_backend_degrades_to_lexical_results() {
862        let (retriever, canonical, overlay) = retriever();
863        drop(retriever);
864        let retriever = LocalMemoryRetriever::new(canonical, overlay, RetrievalConfig::default())
865            .with_semantic_fallback(Arc::new(FailingSemantic));
866
867        let snapshot = retriever
868            .retrieve_immediate(
869                "quiet restaurants",
870                TurnId(4),
871                RetrievalBudget::speculative(),
872            )
873            .await
874            .unwrap();
875        assert!(!snapshot.is_empty(), "lexical results must still be served");
876    }
877
878    #[tokio::test]
879    async fn the_interactive_budget_bounds_the_semantic_call_rather_than_forbidding_it() {
880        let interactive = RetrievalBudget::interactive();
881        let speculative = RetrievalBudget::speculative();
882
883        // It used to be zero. Zero meant a *local* backend — an in-process
884        // vector scan costing well under a millisecond — was never asked, and
885        // measured against a perfect oracle this path answered exactly as many
886        // questions as BM25 alone.
887        assert!(
888            interactive.semantic_ms > 0,
889            "the tool path must at least ask; a backend that cannot answer in \
890             time will time out on its own"
891        );
892        // But it stays a fraction of the speculative budget: nothing is waiting
893        // on speculation, and the model is waiting on this.
894        assert!(
895            interactive.semantic_ms * 4 <= speculative.semantic_ms,
896            "the interactive deadline ({}ms) has drifted close to the \
897             speculative one ({}ms) — the point of the split is that only one \
898             of them has somebody waiting",
899            interactive.semantic_ms,
900            speculative.semantic_ms,
901        );
902    }
903
904    #[tokio::test]
905    async fn a_zero_interactive_deadline_restores_the_old_behaviour() {
906        let config = RetrievalConfig {
907            immediate_semantic_timeout_ms: 0,
908            ..RetrievalConfig::default()
909        };
910        assert_eq!(RetrievalBudget::interactive_with(&config).semantic_ms, 0);
911    }
912
913    /// The gate is gone, and this is what replaces the argument for it.
914    ///
915    /// It used to consult the backend only when lexical search came back thin,
916    /// which fired on 13 of 93 paraphrased questions — and rescued all 13. The
917    /// 80 it declined were declined because BM25 had returned something
918    /// confident, which is the failure mode, not the safe case.
919    /// The property the whole design rests on: a wrong hint must not be able to
920    /// remove the answer.
921    ///
922    /// Measured, a *hard* version of this filter answers 0 of 93 questions when
923    /// the hint is wrong — the answer is absent from the candidate set every
924    /// single time — while the soft version costs one. If this test ever fails,
925    /// the filter has become a gate and the feature is worse than not having it.
926    #[tokio::test]
927    async fn a_wrong_hint_reorders_the_results_without_removing_the_answer() {
928        let (retriever, canonical, _overlay) = retriever();
929        canonical
930            .write()
931            .upsert(IndexedMemory::from_canonical(&record(
932                "mem_coffee",
933                MemoryKind::Preference,
934                "user",
935                "The user drinks cortados.",
936            )));
937
938        let unhinted = retriever
939            .retrieve_scoped(
940                "cortados",
941                TurnId(1),
942                RetrievalBudget::interactive(),
943                Vec::new(),
944                None,
945                None,
946            )
947            .await
948            .expect("retrieval");
949        assert!(
950            !unhinted.facts.is_empty(),
951            "the baseline must find the record for this test to mean anything"
952        );
953
954        let misdirected = retriever
955            .retrieve_scoped(
956                "cortados",
957                TurnId(1),
958                RetrievalBudget::interactive(),
959                Vec::new(),
960                Some("somebody-else-entirely".into()),
961                Some("an-attribute-that-does-not-exist".into()),
962            )
963            .await
964            .expect("retrieval");
965        assert_eq!(
966            misdirected.facts.len(),
967            unhinted.facts.len(),
968            "a hint matching nothing must leave the unfiltered result standing, \
969             not empty it"
970        );
971    }
972
973    /// And a right hint has to actually do something, or the field is theatre.
974    #[tokio::test]
975    async fn a_matching_hint_promotes_its_record_above_a_rival() {
976        let (retriever, canonical, _overlay) = retriever();
977        // Two records the query matches equally well, distinguished only by
978        // whose fact it is.
979        canonical
980            .write()
981            .upsert(IndexedMemory::from_canonical(&record(
982                "mem_rival",
983                MemoryKind::Preference,
984                "rhea",
985                "Rhea drinks cortados.",
986            )));
987        canonical
988            .write()
989            .upsert(IndexedMemory::from_canonical(&record(
990                "mem_target",
991                MemoryKind::Preference,
992                "user",
993                "The user drinks cortados.",
994            )));
995
996        let hinted = retriever
997            .retrieve_scoped(
998                "cortados",
999                TurnId(1),
1000                RetrievalBudget::interactive(),
1001                Vec::new(),
1002                Some("user".into()),
1003                None,
1004            )
1005            .await
1006            .expect("retrieval");
1007        let first = hinted.facts.first().expect("a result");
1008        assert_eq!(
1009            first.memory_id.as_str(),
1010            "mem_target",
1011            "the record whose subject matches the hint should rank first; got {:?}",
1012            hinted
1013                .facts
1014                .iter()
1015                .map(|m| m.memory_id.as_str())
1016                .collect::<Vec<_>>()
1017        );
1018    }
1019
1020    #[tokio::test]
1021    async fn the_semantic_backend_is_consulted_even_when_lexical_search_is_confident() {
1022        let (retriever, canonical, overlay) = retriever();
1023        drop(retriever);
1024        let retriever = LocalMemoryRetriever::new(canonical, overlay, RetrievalConfig::default())
1025            .with_semantic_fallback(Arc::new(StubSemantic {
1026                ids: vec![MemoryId::new("mem_quiet")],
1027            }));
1028
1029        // "pescatarian" is a confident lexical hit on mem_diet — precisely the
1030        // case the old gate declined to escalate.
1031        let snapshot = retriever
1032            .retrieve_immediate("pescatarian", TurnId(4), RetrievalBudget::speculative())
1033            .await
1034            .unwrap();
1035        assert!(
1036            snapshot
1037                .facts
1038                .iter()
1039                .any(|f| f.memory_id.as_str() == "mem_quiet"),
1040            "a confident lexical hit suppressed the semantic ranking entirely; \
1041             got {:?}",
1042            snapshot
1043                .facts
1044                .iter()
1045                .map(|f| f.memory_id.as_str())
1046                .collect::<Vec<_>>()
1047        );
1048    }
1049}