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 removed: RwLock<HashSet<MemoryId>>,
245}
246
247impl LocalMemoryRetriever {
248 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 pub fn suppress_windows(&self, windows: HashSet<String>) {
273 *self.suppressed.write() = windows;
274 self.invalidate_cache();
275 }
276
277 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 pub fn semantic(&self) -> Option<&Arc<dyn SemanticFallback>> {
327 self.semantic.as_ref()
328 }
329
330 pub fn with_semantic_fallback(mut self, fallback: Arc<dyn SemanticFallback>) -> Self {
332 self.semantic = Some(fallback);
333 self
334 }
335
336 pub fn invalidate_cache(&self) {
338 self.cache.write().clear();
339 }
340
341 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[tokio::test]
1001 async fn a_matching_hint_promotes_its_record_above_a_rival() {
1002 let (retriever, canonical, _overlay) = retriever();
1003 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 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}