1use chrono::{DateTime, Duration, Utc};
10use std::collections::HashMap;
11
12use super::plan::{RetrievalEntity, RetrievalIntent, RetrievalPlan, TemporalConstraint};
13use crate::bm25::{MemoryIndex, tokenize};
14use crate::core::{CanonicalPredicate, MemoryKind, PlanId, TurnId, normalize_token, stable_hash};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RetrievalSignal {
19 KnownEntity(String),
21 PreferencePredicate,
23 RelationshipReference,
25 PriorEventReference,
27 ExplicitRecall,
29 PersonalRecommendation,
31 Comparison,
33 TemporalRecall(String),
35}
36
37impl RetrievalSignal {
38 pub fn is_strong(&self) -> bool {
40 matches!(
41 self,
42 Self::KnownEntity(_) | Self::ExplicitRecall | Self::RelationshipReference
43 )
44 }
45}
46
47#[derive(Debug, Clone, Default)]
52pub struct KnownEntities {
53 forms: HashMap<String, String>,
54}
55
56impl KnownEntities {
57 pub fn new() -> Self {
59 Self::default()
60 }
61
62 pub fn from_index(index: &MemoryIndex) -> Self {
64 let mut table = Self::new();
65 for doc in index.documents() {
66 let canonical = doc.subject_form.clone();
67 if canonical.is_empty() || canonical == "user" {
68 continue;
69 }
70 for form in &doc.entity_forms {
71 table.insert(form, &canonical);
72 }
73 table.insert(&canonical, &canonical);
74 }
75 table
76 }
77
78 pub fn insert(&mut self, surface: &str, canonical: &str) {
80 let key = normalize_token(surface);
81 if !key.is_empty() {
82 self.forms.insert(key, canonical.to_string());
83 }
84 }
85
86 pub fn resolve(&self, surface: &str) -> Option<&str> {
88 self.forms
89 .get(&normalize_token(surface))
90 .map(String::as_str)
91 }
92
93 pub fn len(&self) -> usize {
95 self.forms.len()
96 }
97
98 pub fn is_empty(&self) -> bool {
100 self.forms.is_empty()
101 }
102
103 fn matches_in(&self, text: &str) -> Vec<(String, String)> {
106 let haystack = normalize_token(text);
107 let mut hits: Vec<(String, String)> = self
108 .forms
109 .iter()
110 .filter(|(form, _)| contains_word_sequence(&haystack, form))
111 .map(|(form, canonical)| (form.clone(), canonical.clone()))
112 .collect();
113 hits.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
114 hits
115 }
116}
117
118fn contains_word_sequence(haystack: &str, needle: &str) -> bool {
120 if needle.is_empty() {
121 return false;
122 }
123 let hay: Vec<&str> = haystack.split_whitespace().collect();
124 let ned: Vec<&str> = needle.split_whitespace().collect();
125 if ned.is_empty() || ned.len() > hay.len() {
126 return false;
127 }
128 hay.windows(ned.len()).any(|w| w == ned.as_slice())
129}
130
131const RECALL_PHRASES: &[&str] = &[
150 "do you remember",
151 "what do you remember",
152 "what do you know about",
153 "remind me",
154 "did i tell you",
155 "i told you",
156 "you said",
157 "have i mentioned",
158];
159
160const RECOMMENDATION_PHRASES: &[&str] = &[
161 "should i",
162 "should we",
163 "recommend",
164 "suggest",
165 "where should",
166 "what should",
167 "any ideas",
168 "help me pick",
169 "book a",
170 "find me",
171];
172
173const PRIOR_EVENT_PHRASES: &[&str] = &[
174 "last time",
175 "the other day",
176 "earlier",
177 "again",
178 "before",
179 "previously",
180 "last week",
181 "last night",
182 "yesterday",
183];
184
185const PREFERENCE_WORDS: &[&str] = &[
186 "like",
187 "likes",
188 "liked",
189 "love",
190 "loves",
191 "hate",
192 "hates",
193 "prefer",
194 "prefers",
195 "preferred",
196 "favourite",
197 "favorite",
198 "allergic",
199 "avoid",
200 "avoids",
201 "usual",
202 "always",
203 "never",
204];
205
206const COMPARISON_PHRASES: &[&str] = &["better than", "instead of", "rather than", "compared to"];
207
208const KINSHIP_TERMS: &[&str] = &[
212 "my wife",
213 "my husband",
214 "my partner",
215 "my mother",
216 "my father",
217 "my son",
218 "my daughter",
219 "my sister",
220 "my brother",
221 "my friend",
222 "my colleague",
223 "my boss",
224];
225
226const NON_TOPICAL: &[&str] = &[
235 "i",
237 "me",
238 "my",
239 "we",
240 "us",
241 "our",
242 "you",
243 "your",
244 "he",
245 "she",
246 "they",
247 "them",
248 "what",
249 "when",
250 "where",
251 "who",
252 "how",
253 "why",
254 "should",
255 "would",
256 "could",
257 "can",
258 "get",
259 "got",
260 "go",
261 "going",
262 "want",
263 "need",
264 "think",
265 "some",
266 "any",
267 "this",
268 "there",
269 "just",
270 "about",
271 "did",
272 "does",
273 "do",
274 "goes",
275 "s",
276 "t",
277 "m",
278 "re",
279 "ve",
280 "ll",
281 "d",
282 "remember",
284 "memory",
285 "recall",
286 "remind",
287 "know",
288 "tell",
289 "told",
290 "say",
291 "says",
292 "said",
293 "preference",
294 "like",
295 "love",
296 "hate",
297 "prefer",
298 "user",
309];
310
311pub fn topical_terms(text: &str) -> Vec<String> {
320 tokenize(text)
321 .into_iter()
322 .filter(|t| !NON_TOPICAL.contains(&t.as_str()))
323 .filter(|t| t.len() > 2)
324 .collect()
325}
326
327#[derive(Debug, Default)]
329pub struct DeterministicPlanner {
330 known: KnownEntities,
331}
332
333impl DeterministicPlanner {
334 pub fn new() -> Self {
336 Self::default()
337 }
338
339 pub fn with_entities(known: KnownEntities) -> Self {
341 Self { known }
342 }
343
344 pub fn set_entities(&mut self, known: KnownEntities) {
346 self.known = known;
347 }
348
349 pub fn entities(&self) -> &KnownEntities {
351 &self.known
352 }
353
354 pub fn signals(&self, text: &str) -> Vec<RetrievalSignal> {
356 let lowered = text.to_lowercase();
357 let normalized = normalize_token(text);
358 let mut signals = Vec::new();
359
360 for (surface, canonical) in self.known.matches_in(text) {
361 let _ = surface;
362 signals.push(RetrievalSignal::KnownEntity(canonical));
363 }
364 signals.dedup();
365
366 if RECALL_PHRASES.iter().any(|p| lowered.contains(p)) {
367 signals.push(RetrievalSignal::ExplicitRecall);
368 }
369 if RECOMMENDATION_PHRASES.iter().any(|p| lowered.contains(p)) {
370 signals.push(RetrievalSignal::PersonalRecommendation);
371 }
372 if PRIOR_EVENT_PHRASES.iter().any(|p| lowered.contains(p)) {
373 signals.push(RetrievalSignal::PriorEventReference);
374 }
375 if KINSHIP_TERMS.iter().any(|k| normalized.contains(k)) {
376 signals.push(RetrievalSignal::RelationshipReference);
377 }
378 if COMPARISON_PHRASES.iter().any(|p| lowered.contains(p)) {
379 signals.push(RetrievalSignal::Comparison);
380 }
381 if tokenize(text)
382 .iter()
383 .any(|t| PREFERENCE_WORDS.contains(&t.as_str()))
384 {
385 signals.push(RetrievalSignal::PreferencePredicate);
386 }
387 if let Some(label) = detect_temporal_label(&normalized) {
388 signals.push(RetrievalSignal::TemporalRecall(label));
389 }
390 signals
391 }
392
393 pub fn has_strong_signal(&self, text: &str) -> bool {
395 self.signals(text).iter().any(RetrievalSignal::is_strong)
396 }
397
398 pub fn plan(
400 &self,
401 text: &str,
402 turn_id: TurnId,
403 generation: u64,
404 now: DateTime<Utc>,
405 ) -> RetrievalPlan {
406 let signals = self.signals(text);
407
408 let topics: Vec<String> = topical_terms(text);
422
423 let mut entities: Vec<RetrievalEntity> = Vec::new();
424 for (surface, canonical) in self.known.matches_in(text) {
425 if entities
426 .iter()
427 .any(|e| e.canonical.as_deref() == Some(&canonical))
428 {
429 continue;
430 }
431 entities.push(RetrievalEntity::resolved(surface, canonical));
432 }
433
434 if topics.is_empty() && entities.is_empty() {
440 return RetrievalPlan::skip(turn_id, generation, text);
441 }
442
443 let intent = infer_intent(&signals);
444 let predicates = infer_predicates(&signals);
445 let scopes = infer_scopes(&signals, intent);
446
447 let mut lexical_queries = Vec::new();
451 let entity_terms: Vec<String> = entities.iter().map(|e| e.surface.clone()).collect();
452 if !entity_terms.is_empty() && !topics.is_empty() {
453 lexical_queries.push(format!("{} {}", entity_terms.join(" "), topics.join(" ")));
454 }
455 if !topics.is_empty() {
456 lexical_queries.push(topics.join(" "));
457 }
458 if !entity_terms.is_empty() {
459 lexical_queries.push(entity_terms.join(" "));
460 }
461
462 let temporal = signals.iter().find_map(|s| match s {
463 RetrievalSignal::TemporalRecall(label) => Some(temporal_window(label, now)),
464 _ => None,
465 });
466
467 let confidence = if signals.iter().any(RetrievalSignal::is_strong) {
468 0.85
469 } else {
470 0.6
471 };
472
473 RetrievalPlan {
474 plan_id: PlanId::generate(),
475 turn_id,
476 generation,
477 requires_memory: intent.requires_memory(),
478 confidence,
479 intent,
480 entities,
481 topics,
482 predicates,
483 lexical_queries,
484 scopes,
485 kind_filter: Vec::new(),
486 subject_hint: None,
487 predicate_hint: None,
488 temporal,
489 source_transcript_hash: stable_hash(text),
490 }
491 .normalized()
492 }
493}
494
495fn infer_intent(signals: &[RetrievalSignal]) -> RetrievalIntent {
496 if signals.contains(&RetrievalSignal::ExplicitRecall) {
498 return RetrievalIntent::ExplicitRecall;
499 }
500 if signals.contains(&RetrievalSignal::PersonalRecommendation) {
501 return RetrievalIntent::PersonalRecommendation;
502 }
503 if signals.contains(&RetrievalSignal::Comparison) {
504 return RetrievalIntent::Comparison;
505 }
506 if signals.contains(&RetrievalSignal::PriorEventReference) {
507 return RetrievalIntent::PriorEventReference;
508 }
509 if signals.contains(&RetrievalSignal::RelationshipReference)
510 || signals
511 .iter()
512 .any(|s| matches!(s, RetrievalSignal::KnownEntity(_)))
513 {
514 return RetrievalIntent::RelationshipReference;
515 }
516 RetrievalIntent::Ambient
519}
520
521fn infer_predicates(signals: &[RetrievalSignal]) -> Vec<CanonicalPredicate> {
522 let mut predicates = Vec::new();
523 if signals.contains(&RetrievalSignal::PreferencePredicate) {
524 predicates.push(CanonicalPredicate::new("preference"));
525 }
526 if signals.contains(&RetrievalSignal::RelationshipReference) {
527 predicates.push(CanonicalPredicate::new("relationship"));
528 }
529 predicates
530}
531
532fn infer_scopes(signals: &[RetrievalSignal], intent: RetrievalIntent) -> Vec<MemoryKind> {
533 let mut scopes = Vec::new();
534 match intent {
535 RetrievalIntent::ExplicitRecall => {
536 scopes.extend([
537 MemoryKind::Identity,
538 MemoryKind::Preference,
539 MemoryKind::Relationship,
540 MemoryKind::Routine,
541 ]);
542 }
543 RetrievalIntent::PersonalRecommendation | RetrievalIntent::Comparison => {
544 scopes.extend([
545 MemoryKind::Preference,
546 MemoryKind::RelationshipPreference,
547 MemoryKind::LocationPreference,
548 ]);
549 }
550 RetrievalIntent::PriorEventReference => {
551 scopes.extend([MemoryKind::Episodic, MemoryKind::Commitment]);
552 }
553 RetrievalIntent::RelationshipReference => {
554 scopes.extend([
555 MemoryKind::Relationship,
556 MemoryKind::RelationshipPreference,
557 MemoryKind::Episodic,
558 ]);
559 }
560 RetrievalIntent::Ambient => scopes.push(MemoryKind::Preference),
561 RetrievalIntent::None => {}
562 }
563 if signals
564 .iter()
565 .any(|s| matches!(s, RetrievalSignal::TemporalRecall(_)))
566 && !scopes.contains(&MemoryKind::Episodic)
567 {
568 scopes.push(MemoryKind::Episodic);
569 }
570 scopes
571}
572
573fn detect_temporal_label(normalized: &str) -> Option<String> {
574 const LABELS: &[&str] = &[
575 "yesterday",
576 "last night",
577 "this morning",
578 "last week",
579 "this week",
580 "tonight",
581 "tomorrow",
582 "the other day",
583 ];
584 LABELS
585 .iter()
586 .find(|l| normalized.contains(*l))
587 .map(|l| (*l).to_string())
588}
589
590fn temporal_window(label: &str, now: DateTime<Utc>) -> TemporalConstraint {
591 let (after, before) = match label {
592 "yesterday" | "last night" => (Some(now - Duration::days(2)), Some(now)),
593 "this morning" | "tonight" => {
594 (Some(now - Duration::days(1)), Some(now + Duration::days(1)))
595 }
596 "last week" => (Some(now - Duration::days(14)), Some(now)),
597 "this week" => (Some(now - Duration::days(7)), Some(now + Duration::days(7))),
598 "tomorrow" => (Some(now), Some(now + Duration::days(2))),
599 _ => (Some(now - Duration::days(14)), Some(now)),
600 };
601 TemporalConstraint {
602 after,
603 before,
604 label: Some(label.to_string()),
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 fn planner() -> DeterministicPlanner {
613 let mut known = KnownEntities::new();
614 known.insert("Rhea", "rhea");
615 known.insert("wife", "rhea");
618 known.insert("Kushal", "kushal");
619 DeterministicPlanner::with_entities(known)
620 }
621
622 fn plan_for(text: &str) -> RetrievalPlan {
623 planner().plan(text, TurnId(1), 1, Utc::now())
624 }
625
626 #[test]
627 fn a_generic_factual_question_carries_only_its_content_words() {
628 let plan = plan_for("what is the capital of France");
634 assert_eq!(
635 plan.topics,
636 vec!["capital".to_string(), "france".to_string()]
637 );
638 assert!(plan.entities.is_empty());
639 }
640
641 #[test]
642 fn an_utterance_with_no_content_words_skips_memory_entirely() {
643 let plan = plan_for("what do you think");
646 assert!(!plan.requires_memory);
647 assert!(plan.lexical_queries.is_empty());
648 }
649
650 #[test]
651 fn an_explicit_recall_request_is_recognised() {
652 let plan = plan_for("what do you remember about my dietary preferences");
653 assert!(plan.requires_memory);
654 assert_eq!(plan.intent, RetrievalIntent::ExplicitRecall);
655 assert!(plan.scopes.contains(&MemoryKind::Preference));
656 }
657
658 #[test]
659 fn a_personal_recommendation_pulls_preference_scopes() {
660 let plan = plan_for("where should we eat dinner tonight");
661 assert_eq!(plan.intent, RetrievalIntent::PersonalRecommendation);
662 assert!(plan.scopes.contains(&MemoryKind::Preference));
663 assert!(plan.topics.contains(&"dinner".to_string()));
664 }
665
666 #[test]
667 fn known_entities_are_resolved_from_their_aliases() {
668 let plan = plan_for("book a table for my wife");
669 let resolved: Vec<_> = plan
670 .entities
671 .iter()
672 .filter_map(|e| e.canonical.as_deref())
673 .collect();
674 assert!(resolved.contains(&"rhea"), "got {:?}", plan.entities);
675 }
676
677 #[test]
678 fn a_relationship_the_corpus_never_heard_of_is_just_a_topic() {
679 let plan = plan_for("what does my brother like to drink");
685 assert!(plan.entities.is_empty());
686 assert!(plan.topics.contains(&"brother".to_string()));
687 }
688
689 #[test]
690 fn prior_event_references_scope_to_episodes_with_a_time_window() {
691 let plan = plan_for("what happened at dinner last week");
692 assert!(plan.scopes.contains(&MemoryKind::Episodic));
693 let temporal = plan.temporal.expect("a time window");
694 assert_eq!(temporal.label.as_deref(), Some("last week"));
695 assert!(temporal.after.is_some() && temporal.before.is_some());
696 }
697
698 #[test]
699 fn several_independent_queries_are_produced_for_fusion() {
700 let plan = plan_for("what restaurants does Rhea like");
701 assert!(plan.lexical_queries.len() >= 2);
702 assert!(
703 plan.lexical_queries
704 .iter()
705 .any(|q| q.contains("rhea") || q.to_lowercase().contains("rhea"))
706 );
707 }
708
709 #[test]
710 fn a_hinglish_question_reaches_the_index_without_a_hindi_word_list() {
711 let plan = plan_for("Mujhe yaad dilao, mera khaana ka preference kya hai?");
716 assert!(plan.requires_memory);
717 assert!(
718 plan.topics.contains(&"khaana".to_string()),
719 "the content word was dropped: {:?}",
720 plan.topics
721 );
722 assert!(!plan.lexical_queries.is_empty());
726 }
727
728 #[test]
729 fn a_hinglish_possessive_still_resolves_a_corpus_entity() {
730 let plan = plan_for("Meri wife ko kaunsa restaurant pasand hai?");
731 assert!(plan.requires_memory);
732 assert!(
733 plan.entities
734 .iter()
735 .any(|e| e.canonical.as_deref() == Some("rhea")),
736 "an entity the corpus knows was not resolved: {:?}",
737 plan.entities
738 );
739 }
740
741 #[test]
742 fn a_tanglish_question_reaches_the_index() {
743 let plan = plan_for("Enakku enna coffee pidikkum theriyuma?");
744 assert!(plan.requires_memory);
745 assert!(plan.topics.contains(&"coffee".to_string()));
746 }
747
748 #[test]
749 fn strong_signals_bypass_the_speculation_debounce() {
750 let planner = planner();
751 assert!(planner.has_strong_signal("tell me about Rhea"));
752 assert!(planner.has_strong_signal("do you remember what I said"));
753 assert!(!planner.has_strong_signal("it is quite warm today"));
754 }
755
756 #[test]
757 fn entities_are_learned_from_the_corpus_not_a_name_list() {
758 let index = MemoryIndex::new();
759 assert!(KnownEntities::from_index(&index).is_empty());
760
761 let mut known = KnownEntities::new();
762 known.insert("Rhea", "rhea");
763 assert_eq!(known.resolve("rhea"), Some("rhea"));
764 assert_eq!(known.resolve("RHEA"), Some("rhea"));
765 assert_eq!(known.resolve("Someone Else"), None);
766 }
767
768 #[test]
769 fn entity_matching_respects_word_boundaries() {
770 let mut known = KnownEntities::new();
771 known.insert("ann", "ann");
772 let planner = DeterministicPlanner::with_entities(known);
773 assert!(
775 !planner
776 .signals("that was annoying")
777 .iter()
778 .any(|s| matches!(s, RetrievalSignal::KnownEntity(_)))
779 );
780 assert!(
781 planner
782 .signals("ann called")
783 .iter()
784 .any(|s| matches!(s, RetrievalSignal::KnownEntity(_)))
785 );
786 }
787}