1use 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
22pub(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#[derive(Debug, Clone)]
40pub struct RetrievalRequest {
41 pub plan: RetrievalPlan,
43 pub now: DateTime<Utc>,
45}
46
47impl RetrievalRequest {
48 pub fn new(plan: RetrievalPlan) -> Self {
50 Self {
51 plan,
52 now: Utc::now(),
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct RetrievalBudget {
60 pub lexical_ms: u64,
62 pub semantic_ms: u64,
64}
65
66impl RetrievalBudget {
67 pub fn interactive() -> Self {
78 Self::interactive_with(&RetrievalConfig::default())
79 }
80
81 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 pub fn speculative() -> Self {
92 Self::speculative_with(&RetrievalConfig::default())
93 }
94
95 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#[async_trait]
106pub trait MemoryRetriever: Send + Sync {
107 async fn prepare(
109 &self,
110 request: RetrievalRequest,
111 ) -> Result<PreparedMemorySnapshot, MemoryError>;
112
113 async fn retrieve_immediate(
115 &self,
116 query: &str,
117 turn_id: TurnId,
118 budget: RetrievalBudget,
119 ) -> Result<PreparedMemorySnapshot, MemoryError>;
120}
121
122#[async_trait]
127pub trait SemanticFallback: Send + Sync {
128 async fn search(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>, MemoryError>;
130
131 async fn reconcile(
169 &self,
170 _active: &[(MemoryId, String)],
171 _revision: u64,
172 ) -> Result<(), MemoryError> {
173 Ok(())
174 }
175}
176
177#[derive(Debug, Default)]
182pub struct IndexHandle {
183 index: RwLock<MemoryIndex>,
184 generation: std::sync::atomic::AtomicU64,
191}
192
193impl IndexHandle {
194 pub fn new() -> Self {
196 Self::default()
197 }
198
199 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 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 pub fn read(&self) -> parking_lot::RwLockReadGuard<'_, MemoryIndex> {
216 self.index.read()
217 }
218
219 pub fn write(&self) -> parking_lot::RwLockWriteGuard<'_, MemoryIndex> {
221 self.index.write()
222 }
223
224 pub fn revision(&self) -> u64 {
226 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
235pub 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 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 pub fn suppress_windows(&self, windows: HashSet<String>) {
271 *self.suppressed.write() = windows;
272 self.invalidate_cache();
273 }
274
275 pub fn suppressed_windows(&self) -> HashSet<String> {
277 self.suppressed.read().clone()
278 }
279
280 pub fn semantic(&self) -> Option<&Arc<dyn SemanticFallback>> {
286 self.semantic.as_ref()
287 }
288
289 pub fn with_semantic_fallback(mut self, fallback: Arc<dyn SemanticFallback>) -> Self {
291 self.semantic = Some(fallback);
292 self
293 }
294
295 pub fn invalidate_cache(&self) {
297 self.cache.write().clear();
298 }
299
300 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[tokio::test]
975 async fn a_matching_hint_promotes_its_record_above_a_rival() {
976 let (retriever, canonical, _overlay) = retriever();
977 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 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}