gemini_memory_rs/retrieval/
deterministic.rs

1//! Rule-based retrieval planning.
2//!
3//! This runs on partial transcripts, where a model call would be both too slow
4//! and too speculative, and it runs first on final transcripts so the model
5//! extractor has something to refine rather than invent. It is also the
6//! fallback when the out-of-band extractor is unavailable — degrading to
7//! keyword retrieval is far better than degrading to no memory.
8
9use 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/// A signal the rules recognised in the transcript.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RetrievalSignal {
19    /// A name or alias already present in memory.
20    KnownEntity(String),
21    /// Language about liking, preferring or avoiding.
22    PreferencePredicate,
23    /// A kinship or relationship term.
24    RelationshipReference,
25    /// A reference to a past event.
26    PriorEventReference,
27    /// A direct request to recall.
28    ExplicitRecall,
29    /// A request for a personalized suggestion.
30    PersonalRecommendation,
31    /// A comparison between options.
32    Comparison,
33    /// A named time window.
34    TemporalRecall(String),
35}
36
37impl RetrievalSignal {
38    /// Whether this signal alone justifies bypassing the speculation debounce.
39    pub fn is_strong(&self) -> bool {
40        matches!(
41            self,
42            Self::KnownEntity(_) | Self::ExplicitRecall | Self::RelationshipReference
43        )
44    }
45}
46
47/// Surface forms the engine already knows to be entities.
48///
49/// Built from the corpus, so "Rhea" is recognised because there are memories
50/// about Rhea — not because of a name list.
51#[derive(Debug, Clone, Default)]
52pub struct KnownEntities {
53    forms: HashMap<String, String>,
54}
55
56impl KnownEntities {
57    /// An empty table.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Learn every subject and alias present in an index.
63    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    /// Register a surface form for a canonical entity.
79    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    /// Resolve a surface form.
87    pub fn resolve(&self, surface: &str) -> Option<&str> {
88        self.forms
89            .get(&normalize_token(surface))
90            .map(String::as_str)
91    }
92
93    /// How many forms are known.
94    pub fn len(&self) -> usize {
95        self.forms.len()
96    }
97
98    /// Whether the table is empty.
99    pub fn is_empty(&self) -> bool {
100        self.forms.is_empty()
101    }
102
103    /// Find every known form occurring in `text`, longest first so "my wife"
104    /// wins over a bare "wife".
105    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
118/// Whether `needle` occurs in `haystack` on word boundaries.
119fn 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
131/// Recall phrases.
132///
133/// These tables are *hints*, and English-only on purpose. An earlier version
134/// tried to make them exhaustive — Hindi and Tamil phrase lists, romanized
135/// stop words, a kinship table — because the planner used them to decide
136/// whether to search at all, so a gap in the list meant a Hinglish question
137/// got no memory. That put the rule planner in the business of understanding
138/// language, which is not a business a phrase table can be in: there is no
139/// list length at which "mujhe yaad dilao" and "enakku theriyuma" and the next
140/// thousand ways to ask are all covered.
141///
142/// So the decision moved. Whether to search is answered by "are there content
143/// words", and what comes back is answered by BM25 and the score threshold —
144/// both of which are language-agnostic, because a term the corpus does not
145/// contain simply has no postings. What a matched phrase now buys is a
146/// slightly better-shaped plan: an intent label, a scope preference, a
147/// predicate guess. Missing one costs a little ranking quality on that turn.
148/// It no longer costs the memory.
149const 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
208/// English kinship terms, kept only so `RelationshipReference` can be labelled
209/// for scoping. Detection is not required for retrieval: a relationship in the
210/// corpus is found by its own aliases.
211const 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
226/// Words that carry no topical signal even though they survive tokenization.
227///
228/// Two groups. The first is ordinary function words. The second is the
229/// vocabulary of *asking* — "remember", "preference", "like" — which the plan
230/// already captures as an intent and a signal. Leaving those in the query text
231/// makes every preference record match every preference question, because
232/// "preference" is a word each of them contains by construction rather than
233/// because it is what the user is asking about.
234const NON_TOPICAL: &[&str] = &[
235    // Function words.
236    "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    // The vocabulary of asking, already captured as intent.
283    "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    // The corpus's own subject form.
299    //
300    // Every record about the user carries `user` in its subject field, which
301    // is weighted 3.0 — so as a query term it matches most of the corpus and
302    // discriminates within none of it. It reaches queries at all because the
303    // `recall_context` argument is written by the model, which naturally
304    // phrases a memory lookup in the third person ("the user's usual coffee
305    // order"). Left in, a question memory has no answer to still clears the
306    // score floor on the strength of the word "user" alone, and the model is
307    // handed five arbitrary facts to improvise from.
308    "user",
309];
310
311/// The topical terms of an utterance or query.
312///
313/// What survives after function words, the vocabulary of asking, and the
314/// corpus's own subject form are removed. Short tokens go too: at two
315/// characters a token is almost always a fragment of a contraction.
316///
317/// Shared by the planner and by the synchronous tool path so the two cannot
318/// drift into disagreeing about what a query is made of.
319pub 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/// The rule-based planner.
328#[derive(Debug, Default)]
329pub struct DeterministicPlanner {
330    known: KnownEntities,
331}
332
333impl DeterministicPlanner {
334    /// A planner that knows no entities yet.
335    pub fn new() -> Self {
336        Self::default()
337    }
338
339    /// A planner primed with the corpus's entities.
340    pub fn with_entities(known: KnownEntities) -> Self {
341        Self { known }
342    }
343
344    /// Replace the entity table, e.g. after an index refresh.
345    pub fn set_entities(&mut self, known: KnownEntities) {
346        self.known = known;
347    }
348
349    /// The entity table in use.
350    pub fn entities(&self) -> &KnownEntities {
351        &self.known
352    }
353
354    /// Everything the rules recognise in `text`.
355    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    /// Whether any recognised signal justifies bypassing the debounce.
394    pub fn has_strong_signal(&self, text: &str) -> bool {
395        self.signals(text).iter().any(RetrievalSignal::is_strong)
396    }
397
398    /// Build a plan from a transcript.
399    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        // Content words are the gate, not recognised phrases.
409        //
410        // The previous rule — "no recognised signal, no memory" — made the
411        // planner responsible for understanding language, which it cannot do.
412        // It answered "no memory needed" to any phrasing outside its word
413        // lists, which meant every language but English.
414        //
415        // Searching locally costs tens of microseconds. Searching and finding
416        // nothing is the same observable outcome as not searching, minus the
417        // failure mode. So the default is to search, and the score threshold in
418        // the assembler decides whether anything comes back. Deciding a query
419        // needs no memory at all is left to the model planner, which
420        // understands the sentence.
421        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        // No kinship table. A relationship the engine has never heard of has no
435        // memories to retrieve, so failing to spot it costs nothing; one it has
436        // heard of is in the corpus already, carrying the aliases the
437        // extraction model wrote in whatever language the user used.
438
439        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        // Three independent queries, fused later: entities with topics finds
448        // "what does Rhea like"; topics alone finds preference records that
449        // never name an entity; the entity alone finds everything about them.
450        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    // Ordered by how unambiguous each signal is about what the user wants.
497    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    // Ambient rather than None: the caller has already decided there is
517    // something to search for, so the intent only chooses scoping hints.
518    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        // A corpus-derived alias, not a kinship table: the fact "Rhea is the
616        // user's wife" lists both forms as entities, so both land here.
617        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        // The planner no longer rules on whether a question "needs memory" —
629        // it cannot know that without understanding the sentence. It strips
630        // function words and hands the residue to BM25, which scores a
631        // world-knowledge question against a personal corpus at nothing. The
632        // skip lives in the score threshold, where it can be right.
633        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        // Nothing to search *with* is the one case the planner can call, and
644        // it is a lexical fact rather than a semantic judgement.
645        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        // There is no kinship table, and none is needed. A brother the engine
680        // has never been told about has no memories to retrieve, so failing to
681        // classify him as an entity costs exactly nothing; the word still goes
682        // to the index as a topic, where it will match the moment a fact about
683        // him exists — in whatever language that fact was spoken.
684        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        // There is no Hindi phrase table here. "yaad dilao" is not recognised
712        // as a recall verb, and does not need to be — the content words go to
713        // the index, and the facts stored from Hinglish speech carry Hinglish
714        // search terms to meet them.
715        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        // "hai" survives the filter, and that is fine: a term no document
723        // contains has no postings, scores nothing, and costs nothing. Paying
724        // a stop-word list to remove it would buy exactly zero.
725        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        // "annoying" must not match the entity "ann".
774        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}