Skip to main content

wm_memory/
episodic_keys.rs

1//! Deterministic index-time keys for the v6 episodic sidecar.
2//!
3//! These are typed, source-bounded features — not broad synonym expansion.
4
5use std::collections::HashMap;
6
7/// Category of a deterministic retrieval key.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum KeyCategory {
10    Person,
11    Date,
12    Location,
13    Organization,
14    Domain,
15    Preference,
16    Entity,
17    Quantity,
18    ProperNoun,
19}
20
21/// A typed key extracted from a record or query.
22#[derive(Debug, Clone, PartialEq)]
23pub struct EpisodicKey {
24    pub category: KeyCategory,
25    pub term: String,
26    pub surface: String,
27    pub start: usize,
28    pub end: usize,
29    pub confidence: f32,
30}
31
32impl EpisodicKey {
33    fn new(
34        category: KeyCategory,
35        term: impl Into<String>,
36        surface: impl Into<String>,
37        start: usize,
38        end: usize,
39        confidence: f32,
40    ) -> Self {
41        Self {
42            category,
43            term: term.into(),
44            surface: surface.into(),
45            start,
46            end,
47            confidence: confidence.clamp(0.0, 1.0),
48        }
49    }
50}
51
52/// Extract typed retrieval keys from source text.
53#[must_use]
54pub fn extract_episodic_keys(text: &str) -> Vec<EpisodicKey> {
55    let mut keys = Vec::new();
56    extract_person_keys(text, &mut keys);
57    extract_date_keys(text, &mut keys);
58    extract_place_org_keys(text, &mut keys);
59    extract_domain_keys(text, &mut keys);
60    extract_preference_keys(text, &mut keys);
61    extract_entity_keys(text, &mut keys);
62    extract_numeric_keys(text, &mut keys);
63    extract_selective_entities(text, &mut keys);
64    keys.sort_by(|left, right| {
65        left.start
66            .cmp(&right.start)
67            .then_with(|| left.term.cmp(&right.term))
68            .then_with(|| (left.category as u8).cmp(&(right.category as u8)))
69    });
70    keys.dedup_by(|left, right| left.category == right.category && left.term == right.term);
71    keys
72}
73
74/// Extra sidecar terms derived from typed keys.
75#[must_use]
76pub fn key_index_terms(text: &str) -> Vec<String> {
77    extract_episodic_keys(text)
78        .into_iter()
79        .map(|key| key.term)
80        .fold(Vec::new(), |mut terms, term| {
81            if !terms.contains(&term) {
82                terms.push(term);
83            }
84            terms
85        })
86}
87
88/// Adaptive alias proposals loaded from the dream cycle or a JSON file.
89///
90/// Each entry maps a surface form to a canonical key term, extending the
91/// hardcoded entity table at query and ingest time.
92#[derive(Debug, Clone, Default)]
93pub struct AdaptiveAliases {
94    /// surface form (lowercase) → canonical key term
95    aliases: HashMap<String, String>,
96}
97
98impl AdaptiveAliases {
99    /// Load aliases from a JSON file.
100    ///
101    /// Format: `{"entries": [{"surface": "valentine's day", "canonical": "date-02-14", "confidence": 0.9}]}`
102    pub fn from_file(path: &std::path::Path) -> std::io::Result<Self> {
103        let content = std::fs::read_to_string(path)?;
104        Self::from_json(&content)
105    }
106
107    /// Parse aliases from a JSON string.
108    pub fn from_json(json: &str) -> std::io::Result<Self> {
109        #[derive(serde::Deserialize)]
110        struct AliasEntry {
111            surface: String,
112            canonical: String,
113            #[allow(dead_code)]
114            confidence: Option<f32>,
115        }
116        #[derive(serde::Deserialize)]
117        struct AliasFile {
118            entries: Vec<AliasEntry>,
119        }
120
121        let parsed: AliasFile = serde_json::from_str(json)
122            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
123        let mut aliases = HashMap::new();
124        for entry in parsed.entries {
125            aliases.insert(entry.surface.to_ascii_lowercase(), entry.canonical);
126        }
127        Ok(Self { aliases })
128    }
129
130    /// Create from a simple HashMap.
131    #[must_use]
132    pub const fn from_map(map: HashMap<String, String>) -> Self {
133        Self { aliases: map }
134    }
135
136    /// Check if a surface form has an adaptive alias.
137    pub fn lookup(&self, surface: &str) -> Option<&str> {
138        self.aliases
139            .get(&surface.to_ascii_lowercase())
140            .map(String::as_str)
141    }
142
143    /// Number of aliases.
144    #[must_use]
145    pub fn len(&self) -> usize {
146        self.aliases.len()
147    }
148
149    /// Whether empty.
150    #[must_use]
151    pub fn is_empty(&self) -> bool {
152        self.aliases.is_empty()
153    }
154}
155
156/// Extract typed retrieval keys, optionally with adaptive aliases.
157#[must_use]
158pub fn extract_episodic_keys_with_aliases(
159    text: &str,
160    aliases: Option<&AdaptiveAliases>,
161) -> Vec<EpisodicKey> {
162    let mut keys = extract_episodic_keys(text);
163    if let Some(aliases) = aliases {
164        if !aliases.is_empty() {
165            extract_adaptive_entity_keys(text, aliases, &mut keys);
166        }
167    }
168    keys.sort_by(|left, right| {
169        left.start
170            .cmp(&right.start)
171            .then_with(|| left.term.cmp(&right.term))
172            .then_with(|| (left.category as u8).cmp(&(right.category as u8)))
173    });
174    keys.dedup_by(|left, right| left.category == right.category && left.term == right.term);
175    keys
176}
177
178/// Extra sidecar terms, optionally with adaptive aliases.
179#[must_use]
180pub fn key_index_terms_with_aliases(text: &str, aliases: Option<&AdaptiveAliases>) -> Vec<String> {
181    extract_episodic_keys_with_aliases(text, aliases)
182        .into_iter()
183        .map(|key| key.term)
184        .fold(Vec::new(), |mut terms, term| {
185            if !terms.contains(&term) {
186                terms.push(term);
187            }
188            terms
189        })
190}
191
192/// Extract only ProperNoun-category key terms (multi-word phrases + acronyms) for distinctive key scoring.
193#[must_use]
194pub fn entity_key_terms(text: &str) -> Vec<String> {
195    extract_episodic_keys(text)
196        .into_iter()
197        .filter(|k| k.category == KeyCategory::ProperNoun)
198        .map(|k| k.term)
199        .fold(Vec::new(), |mut terms, term| {
200            if !terms.contains(&term) {
201                terms.push(term);
202            }
203            terms
204        })
205}
206
207/// Extract entity keys from adaptive alias proposals.
208fn extract_adaptive_entity_keys(
209    text: &str,
210    aliases: &AdaptiveAliases,
211    keys: &mut Vec<EpisodicKey>,
212) {
213    let lower = text.to_ascii_lowercase();
214    for (surface, canonical) in &aliases.aliases {
215        for start in find_word_starts(&lower, surface) {
216            let end = start + surface.len();
217            keys.push(EpisodicKey::new(
218                KeyCategory::Entity,
219                canonical.clone(),
220                &text[start..end.min(text.len())],
221                start,
222                end.min(text.len()),
223                0.85,
224            ));
225        }
226    }
227}
228
229fn extract_person_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
230    const TITLES: &[(&str, &str, f32)] = &[
231        ("dr.", "doctor", 0.95),
232        ("doctor", "doctor", 0.9),
233        ("physician", "doctor", 0.85),
234        ("dermatologist", "doctor", 0.85),
235        ("professor", "professor", 0.9),
236        ("prof.", "professor", 0.9),
237    ];
238    let lower = text.to_ascii_lowercase();
239    for (surface, term, confidence) in TITLES {
240        for start in find_word_starts(&lower, surface) {
241            let end = start + surface.len();
242            keys.push(EpisodicKey::new(
243                KeyCategory::Person,
244                *term,
245                &text[start..end.min(text.len())],
246                start,
247                end.min(text.len()),
248                *confidence,
249            ));
250            if let Some((name, name_end)) = following_proper_name(text, end) {
251                keys.push(EpisodicKey::new(
252                    KeyCategory::Person,
253                    name.to_ascii_lowercase(),
254                    name,
255                    end,
256                    name_end,
257                    (*confidence - 0.05).max(0.7),
258                ));
259            }
260        }
261    }
262}
263
264fn extract_date_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
265    const MONTHS: &[(&str, &str)] = &[
266        ("january", "01"),
267        ("february", "02"),
268        ("march", "03"),
269        ("april", "04"),
270        ("may", "05"),
271        ("june", "06"),
272        ("july", "07"),
273        ("august", "08"),
274        ("september", "09"),
275        ("october", "10"),
276        ("november", "11"),
277        ("december", "12"),
278    ];
279    let lower = text.to_ascii_lowercase();
280    for (name, month) in MONTHS {
281        for start in find_word_starts(&lower, name) {
282            let mut end = start + name.len();
283            let mut term = format!("date-{month}");
284            if let Some((day, day_end)) = following_day(&lower, end) {
285                term = format!("date-{month}-{day:02}");
286                end = day_end;
287            }
288            keys.push(EpisodicKey::new(
289                KeyCategory::Date,
290                term,
291                &text[start..end.min(text.len())],
292                start,
293                end.min(text.len()),
294                0.9,
295            ));
296        }
297    }
298    for (start, end, year, month, day) in find_iso_dates(&lower) {
299        keys.push(EpisodicKey::new(
300            KeyCategory::Date,
301            format!("date-{month}-{day:02}"),
302            &text[start..end],
303            start,
304            end,
305            0.95,
306        ));
307        keys.push(EpisodicKey::new(
308            KeyCategory::Date,
309            format!("date-{year}"),
310            &text[start..end],
311            start,
312            end,
313            0.85,
314        ));
315    }
316}
317
318fn extract_place_org_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
319    const PLACES: &[(&str, &str, KeyCategory, f32)] = &[
320        (
321            "university of california, los angeles",
322            "ucla",
323            KeyCategory::Organization,
324            0.95,
325        ),
326        (
327            "university of california los angeles",
328            "ucla",
329            KeyCategory::Organization,
330            0.95,
331        ),
332        ("los angeles", "los-angeles", KeyCategory::Location, 0.85),
333        ("ucla", "university", KeyCategory::Organization, 0.9),
334        ("ikea", "ikea", KeyCategory::Organization, 0.95),
335        ("hawaii", "hawaii", KeyCategory::Location, 0.9),
336        ("japan", "japan", KeyCategory::Location, 0.9),
337        ("lake michigan", "michigan", KeyCategory::Location, 0.9),
338        ("serenity yoga", "yoga", KeyCategory::Organization, 0.9),
339        ("spotify", "spotify", KeyCategory::Organization, 0.95),
340    ];
341    let lower = text.to_ascii_lowercase();
342    for (surface, term, category, confidence) in PLACES {
343        for start in find_word_starts(&lower, surface) {
344            let end = start + surface.len();
345            keys.push(EpisodicKey::new(
346                *category,
347                *term,
348                &text[start..end.min(text.len())],
349                start,
350                end.min(text.len()),
351                *confidence,
352            ));
353        }
354    }
355    if let Some(start) = find_word_starts(&lower, "university of").into_iter().next() {
356        keys.push(EpisodicKey::new(
357            KeyCategory::Organization,
358            "university",
359            &text[start..(start + "university of".len()).min(text.len())],
360            start,
361            (start + "university of".len()).min(text.len()),
362            0.8,
363        ));
364    }
365}
366
367fn extract_domain_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
368    const DOMAINS: &[(&str, &[&str], f32)] = &[
369        (
370            "medicine",
371            &[
372                "doctor",
373                "physician",
374                "prescription",
375                "appointment",
376                "dermatolog",
377            ],
378            0.8,
379        ),
380        (
381            "education",
382            &[
383                "degree",
384                "bachelor",
385                "graduate",
386                "undergrad",
387                "university",
388                "college",
389                "computer science",
390            ],
391            0.8,
392        ),
393        (
394            "travel",
395            &["trip", "commute", "hawaii", "japan", "vacation"],
396            0.75,
397        ),
398        (
399            "pets",
400            &["dog", "cat", "retriever", "animal shelter", "puppy"],
401            0.85,
402        ),
403        ("finance", &["paid", "worth", "mbps", "internet plan"], 0.7),
404        ("food", &["bake", "recipe", "cook", "dinner"], 0.7),
405        (
406            "music",
407            &["spotify", "streaming", "concert", "playlist"],
408            0.85,
409        ),
410        (
411            "sports",
412            &["tennis", "bike", "bicycle", "yoga", "fishing"],
413            0.8,
414        ),
415    ];
416    let lower = text.to_ascii_lowercase();
417    for (domain, cues, confidence) in DOMAINS {
418        if let Some(cue) = cues.iter().copied().find(|cue| contains_word(&lower, cue)) {
419            let start = lower.find(cue).unwrap_or(0);
420            keys.push(EpisodicKey::new(
421                KeyCategory::Domain,
422                *domain,
423                cue,
424                start,
425                start + cue.len(),
426                *confidence,
427            ));
428        }
429    }
430}
431
432fn extract_preference_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
433    const MARKERS: &[&str] = &[
434        "my favorite",
435        "i prefer",
436        "i like",
437        "i enjoy",
438        "i've been using",
439        "have i been using",
440    ];
441    let lower = text.to_ascii_lowercase();
442    for marker in MARKERS {
443        for start in find_word_starts(&lower, marker) {
444            keys.push(EpisodicKey::new(
445                KeyCategory::Preference,
446                "preference",
447                &text[start..(start + marker.len()).min(text.len())],
448                start,
449                (start + marker.len()).min(text.len()),
450                0.85,
451            ));
452        }
453    }
454}
455
456fn extract_entity_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
457    const ENTITIES: &[(&str, &str, f32)] = &[
458        ("golden retriever", "dog", 0.95),
459        ("labrador", "dog", 0.9),
460        ("dog", "dog", 0.85),
461        ("cat", "cat", 0.85),
462        ("music streaming service", "spotify", 0.8),
463        ("streaming service", "spotify", 0.75),
464        ("community theater", "play", 0.8),
465        ("the glass menagerie", "play", 0.95),
466        ("play", "play", 0.7),
467        ("tennis racket", "tennis", 0.9),
468        ("daily commute", "commute", 0.9),
469        ("commute", "commute", 0.85),
470        ("computer science", "degree", 0.8),
471        ("bachelor's degree", "degree", 0.9),
472        ("bachelors degree", "degree", 0.9),
473        ("undergrad", "degree", 0.85),
474        ("undergraduate", "degree", 0.85),
475        ("cs", "degree", 0.7),
476        ("bookshelf", "bookshelf", 0.8),
477        ("internet plan", "internet-plan", 0.85),
478        ("yoga", "yoga", 0.85),
479        // Phase 3: aliases for known R@1 misses
480        ("valentine's day", "date-02-14", 0.9),
481        ("valentines day", "date-02-14", 0.9),
482        ("valentine day", "date-02-14", 0.85),
483        ("strut your mutt", "animal-shelter", 0.85),
484        ("animal shelter", "animal-shelter", 0.9),
485        ("animal welfare", "animal-shelter", 0.8),
486        ("audition", "play", 0.8),
487        ("down dog", "yoga", 0.85),
488        ("production", "play", 0.75),
489        ("serenity yoga", "yoga", 0.95),
490        ("vinyasa", "yoga", 0.8),
491        ("love is in the air", "fundraising", 0.85),
492        ("fundraising dinner", "fundraising", 0.9),
493        ("silent auction", "fundraising", 0.8),
494    ];
495    let lower = text.to_ascii_lowercase();
496    for (surface, term, confidence) in ENTITIES {
497        for start in find_word_starts(&lower, surface) {
498            let end = start + surface.len();
499            keys.push(EpisodicKey::new(
500                KeyCategory::Entity,
501                *term,
502                &text[start..end.min(text.len())],
503                start,
504                end.min(text.len()),
505                *confidence,
506            ));
507        }
508    }
509}
510
511/// Extract numeric values as Quantity keys.
512///
513/// Digit sequences (e.g. "4", "200", "14") become Quantity keys. This creates
514/// asymmetric index pathways for answer turns containing specific numbers.
515fn extract_numeric_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
516    let bytes = text.as_bytes();
517    let mut i = 0;
518    while i < bytes.len() {
519        if bytes[i].is_ascii_digit() {
520            let start = i;
521            while i < bytes.len() && bytes[i].is_ascii_digit() {
522                i += 1;
523            }
524            let end = i;
525            let num = &text[start..end];
526            keys.push(EpisodicKey::new(
527                KeyCategory::Quantity,
528                num.to_string(),
529                num,
530                start,
531                end,
532                0.8,
533            ));
534        } else {
535            i += 1;
536        }
537    }
538}
539
540/// Common words that may appear in capitalized phrases but are not proper nouns.
541const PHASE_STOPWORDS: &[&str] = &[
542    "The", "A", "An", "Of", "And", "Or", "In", "On", "At", "To", "For", "By", "With", "Is", "Are",
543    "Was", "Were", "Be", "Been", "Have", "Has", "Had", "Do", "Did", "My", "Your", "His", "Her",
544    "Our", "Their", "Its", "This", "That", "I", "We", "They", "He", "She", "It",
545];
546
547/// Extract only high-precision proper nouns: multi-word capitalized phrases
548/// (e.g. "Serenity Yoga", "Imagine Dragons") and all-caps acronyms (e.g. "IKEA").
549/// Single capitalized words are NOT extracted — they cause symmetric noise
550/// on competing turns (cat names, brand names, rice varieties, etc.).
551fn extract_selective_entities(text: &str, keys: &mut Vec<EpisodicKey>) {
552    let bytes = text.as_bytes();
553    let mut i = 0;
554
555    while i < bytes.len() {
556        if !bytes[i].is_ascii_alphanumeric() {
557            i += 1;
558            continue;
559        }
560
561        let word_start = i;
562        while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
563            i += 1;
564        }
565        let word_end = i;
566        let word = &text[word_start..word_end];
567
568        let is_capitalized = word.chars().next().is_some_and(|c| c.is_ascii_uppercase());
569        let is_all_caps = word.len() > 1 && word.chars().all(|c| c.is_ascii_uppercase());
570
571        if is_capitalized {
572            // Try to extend to a multi-word capitalized phrase
573            let mut phrase_end = word_end;
574            let mut phrase_words: Vec<&str> = vec![word];
575
576            loop {
577                let mut j = phrase_end;
578                while j < bytes.len() && bytes[j].is_ascii_whitespace() {
579                    j += 1;
580                }
581                if j < bytes.len() && bytes[j].is_ascii_uppercase() {
582                    let next_start = j;
583                    while j < bytes.len() && bytes[j].is_ascii_alphanumeric() {
584                        j += 1;
585                    }
586                    let next_word = &text[next_start..j];
587                    if PHASE_STOPWORDS.contains(&next_word) {
588                        break;
589                    }
590                    phrase_words.push(next_word);
591                    phrase_end = j;
592                } else {
593                    break;
594                }
595            }
596
597            if phrase_words.len() >= 2 {
598                // Multi-word capitalized phrase — high precision
599                let phrase: String = phrase_words.join(" ");
600                let term = phrase.to_ascii_lowercase().replace(' ', "-");
601                keys.push(EpisodicKey::new(
602                    KeyCategory::ProperNoun,
603                    term,
604                    &phrase,
605                    word_start,
606                    phrase_end,
607                    0.85,
608                ));
609                i = phrase_end;
610            } else if is_all_caps {
611                // All-caps acronym (e.g. IKEA, UCLA) — high precision
612                keys.push(EpisodicKey::new(
613                    KeyCategory::ProperNoun,
614                    word.to_ascii_lowercase(),
615                    word,
616                    word_start,
617                    word_end,
618                    0.85,
619                ));
620            }
621            // Single capitalized words that aren't all-caps are NOT extracted
622        }
623    }
624}
625
626fn find_word_starts(haystack: &str, needle: &str) -> Vec<usize> {
627    if needle.is_empty() {
628        return Vec::new();
629    }
630    let bytes = haystack.as_bytes();
631    let needle_bytes = needle.as_bytes();
632    let mut starts = Vec::new();
633    let mut offset = 0;
634    while offset + needle_bytes.len() <= bytes.len() {
635        if bytes[offset..].starts_with(needle_bytes)
636            && is_boundary_before(bytes, offset)
637            && is_boundary_after(bytes, offset + needle_bytes.len())
638        {
639            starts.push(offset);
640            offset += needle_bytes.len();
641        } else {
642            offset += 1;
643        }
644    }
645    starts
646}
647
648fn contains_word(haystack: &str, needle: &str) -> bool {
649    !find_word_starts(haystack, needle).is_empty()
650}
651
652const fn is_boundary_before(bytes: &[u8], offset: usize) -> bool {
653    offset == 0 || !bytes[offset - 1].is_ascii_alphanumeric()
654}
655
656const fn is_boundary_after(bytes: &[u8], offset: usize) -> bool {
657    offset >= bytes.len() || !bytes[offset].is_ascii_alphanumeric()
658}
659
660fn following_proper_name(text: &str, mut offset: usize) -> Option<(&str, usize)> {
661    while offset < text.len() && text.as_bytes()[offset].is_ascii_whitespace() {
662        offset += 1;
663    }
664    let start = offset;
665    while offset < text.len() && text.as_bytes()[offset].is_ascii_alphabetic() {
666        offset += 1;
667    }
668    if offset <= start {
669        return None;
670    }
671    let name = &text[start..offset];
672    if name
673        .chars()
674        .next()
675        .is_some_and(|ch| ch.is_ascii_uppercase())
676    {
677        Some((name, offset))
678    } else {
679        None
680    }
681}
682
683fn following_day(lower: &str, mut offset: usize) -> Option<(u8, usize)> {
684    while offset < lower.len() && lower.as_bytes()[offset].is_ascii_whitespace() {
685        offset += 1;
686    }
687    let start = offset;
688    while offset < lower.len() && lower.as_bytes()[offset].is_ascii_digit() {
689        offset += 1;
690    }
691    if offset == start {
692        return None;
693    }
694    let day = lower[start..offset].parse::<u8>().ok()?;
695    if !(1..=31).contains(&day) {
696        return None;
697    }
698    for suffix in ["st", "nd", "rd", "th"] {
699        if lower[offset..].starts_with(suffix) {
700            offset += suffix.len();
701            break;
702        }
703    }
704    Some((day, offset))
705}
706
707fn find_iso_dates(lower: &str) -> Vec<(usize, usize, i32, &str, u8)> {
708    let bytes = lower.as_bytes();
709    let mut dates = Vec::new();
710    let mut offset = 0;
711    while offset + 10 <= bytes.len() {
712        if bytes[offset].is_ascii_digit()
713            && bytes[offset + 1].is_ascii_digit()
714            && bytes[offset + 2].is_ascii_digit()
715            && bytes[offset + 3].is_ascii_digit()
716            && bytes[offset + 4] == b'-'
717            && bytes[offset + 5].is_ascii_digit()
718            && bytes[offset + 6].is_ascii_digit()
719            && bytes[offset + 7] == b'-'
720            && bytes[offset + 8].is_ascii_digit()
721            && bytes[offset + 9].is_ascii_digit()
722            && is_boundary_before(bytes, offset)
723            && is_boundary_after(bytes, offset + 10)
724        {
725            if let (Ok(year), Ok(month), Ok(day)) = (
726                lower[offset..offset + 4].parse::<i32>(),
727                lower[offset + 5..offset + 7].parse::<u8>(),
728                lower[offset + 8..offset + 10].parse::<u8>(),
729            ) {
730                if (1..=12).contains(&month) && (1..=31).contains(&day) {
731                    let month_term = match month {
732                        1 => "01",
733                        2 => "02",
734                        3 => "03",
735                        4 => "04",
736                        5 => "05",
737                        6 => "06",
738                        7 => "07",
739                        8 => "08",
740                        9 => "09",
741                        10 => "10",
742                        11 => "11",
743                        _ => "12",
744                    };
745                    dates.push((offset, offset + 10, year, month_term, day));
746                }
747            }
748            offset += 10;
749        } else {
750            offset += 1;
751        }
752    }
753    dates
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    fn terms(text: &str) -> Vec<String> {
761        key_index_terms(text)
762    }
763
764    #[test]
765    fn person_title_maps_doctor_to_name() {
766        let keys = extract_episodic_keys("I saw Dr. Patel yesterday");
767        assert!(
768            keys.iter()
769                .any(|key| key.category == KeyCategory::Person && key.term == "doctor")
770        );
771        assert!(
772            keys.iter()
773                .any(|key| key.category == KeyCategory::Person && key.term == "patel")
774        );
775        let doctor = keys
776            .iter()
777            .find(|key| key.term == "doctor")
778            .expect("doctor key");
779        assert!(doctor.start < doctor.end);
780        assert!(doctor.confidence > 0.9);
781    }
782
783    #[test]
784    fn date_normalizes_month_and_day() {
785        let keys = extract_episodic_keys("I volunteered on February 14th");
786        assert!(keys.iter().any(|key| key.term == "date-02-14"));
787        assert!(
788            extract_episodic_keys("due 2024-02-14")
789                .iter()
790                .any(|key| key.term == "date-02-14")
791        );
792    }
793
794    #[test]
795    fn organization_aliases_ucla() {
796        let keys = extract_episodic_keys(
797            "I completed my degree at the University of California, Los Angeles",
798        );
799        assert!(keys.iter().any(|key| key.term == "ucla"));
800        assert!(terms("UCLA").contains(&"university".to_string()));
801    }
802
803    #[test]
804    fn pet_breed_maps_to_dog() {
805        let keys = extract_episodic_keys("My Golden Retriever loves the park");
806        assert!(
807            keys.iter()
808                .any(|key| key.category == KeyCategory::Entity && key.term == "dog")
809        );
810        assert!(
811            keys.iter()
812                .any(|key| key.category == KeyCategory::Domain && key.term == "pets")
813        );
814    }
815
816    #[test]
817    fn preference_and_streaming_service_have_regression_cases() {
818        assert!(terms("Spotify is what I use").contains(&"spotify".to_string()));
819        assert!(
820            extract_episodic_keys(
821                "What is the name of the music streaming service have I been using lately?"
822            )
823            .iter()
824            .any(|key| key.term == "spotify" || key.term == "preference")
825        );
826    }
827
828    #[test]
829    fn empty_and_stopword_text_yields_no_keys() {
830        assert!(extract_episodic_keys("the and of").is_empty());
831    }
832
833    #[test]
834    fn numeric_keys_extracted_from_digits() {
835        let keys = extract_episodic_keys("It took 4 hours to finish");
836        assert!(
837            keys.iter()
838                .any(|k| k.category == KeyCategory::Quantity && k.term == "4")
839        );
840        let keys2 = extract_episodic_keys("I paid 200 dollars");
841        assert!(
842            keys2
843                .iter()
844                .any(|k| k.category == KeyCategory::Quantity && k.term == "200")
845        );
846    }
847
848    #[test]
849    fn selective_multi_word_phrase_extracted() {
850        let keys = extract_episodic_keys("I take classes at Serenity Yoga downtown");
851        assert!(
852            keys.iter()
853                .any(|k| k.category == KeyCategory::ProperNoun && k.term == "serenity-yoga")
854        );
855    }
856
857    #[test]
858    fn selective_all_caps_extracted() {
859        let keys = extract_episodic_keys("I bought it from IKEA last week");
860        assert!(
861            keys.iter()
862                .any(|k| k.category == KeyCategory::ProperNoun && k.term == "ikea")
863        );
864    }
865
866    #[test]
867    fn selective_single_capitalized_not_extracted() {
868        let keys = extract_episodic_keys("I bought it from Amazon last week");
869        assert!(
870            !keys
871                .iter()
872                .any(|k| k.category == KeyCategory::ProperNoun && k.term == "amazon")
873        );
874    }
875}