1use chrono::{DateTime, Utc};
17use std::collections::HashMap;
18
19use super::explain::{BoostKind, ScoreComponent, SearchExplanation};
20use super::schema::{Field, IndexedMemory, MemoryOrigin, tokenize};
21use crate::core::{MemoryId, MemoryKind};
22
23const K1: f32 = 1.2;
25const B: f32 = 0.75;
27
28#[derive(Debug, Clone, Copy)]
30struct Posting {
31 doc: usize,
32 field: Field,
33 tf: u32,
34}
35
36#[derive(Debug, Clone, Default)]
38pub struct Query {
39 pub text: String,
41 pub entities: Vec<String>,
43 pub kinds: Vec<MemoryKind>,
45 pub boost_only: Vec<String>,
64 pub limit: usize,
66}
67
68impl Query {
69 pub fn new(text: impl Into<String>) -> Self {
71 Self {
72 text: text.into(),
73 entities: Vec::new(),
74 kinds: Vec::new(),
75 boost_only: Vec::new(),
76 limit: 20,
77 }
78 }
79
80 pub fn with_entities<I, S>(mut self, entities: I) -> Self
82 where
83 I: IntoIterator<Item = S>,
84 S: Into<String>,
85 {
86 self.entities = entities
87 .into_iter()
88 .map(|e| crate::core::normalize_token(&e.into()))
89 .filter(|e| !e.is_empty())
90 .collect();
91 self
92 }
93
94 pub fn with_kinds(mut self, kinds: Vec<MemoryKind>) -> Self {
96 self.kinds = kinds;
97 self
98 }
99
100 pub fn with_boost_only<I, S>(mut self, terms: I) -> Self
102 where
103 I: IntoIterator<Item = S>,
104 S: Into<String>,
105 {
106 self.boost_only = terms.into_iter().map(Into::into).collect();
107 self
108 }
109
110 pub fn with_limit(mut self, limit: usize) -> Self {
112 self.limit = limit;
113 self
114 }
115}
116
117#[derive(Debug, Clone)]
119pub struct SearchHit {
120 pub id: MemoryId,
122 pub score: f32,
124 pub statement: String,
126 pub kind: MemoryKind,
128 pub origin: MemoryOrigin,
130 pub explanation: SearchExplanation,
132}
133
134#[derive(Debug, Default)]
136pub struct MemoryIndex {
137 docs: Vec<Option<IndexedMemory>>,
138 by_id: HashMap<MemoryId, usize>,
139 postings: HashMap<String, Vec<Posting>>,
140 doc_frequency: HashMap<String, u32>,
141 field_length_total: [u64; 7],
142 live_docs: usize,
143 revision: u64,
144}
145
146impl MemoryIndex {
147 pub fn new() -> Self {
149 Self::default()
150 }
151
152 pub fn build(documents: impl IntoIterator<Item = IndexedMemory>) -> Self {
154 let mut index = Self::new();
155 for doc in documents {
156 index.upsert(doc);
157 }
158 index
159 }
160
161 pub fn revision(&self) -> u64 {
163 self.revision
164 }
165
166 pub fn len(&self) -> usize {
168 self.live_docs
169 }
170
171 pub fn is_empty(&self) -> bool {
173 self.live_docs == 0
174 }
175
176 pub fn upsert(&mut self, doc: IndexedMemory) {
178 self.remove(&doc.id);
179
180 let slot = self.docs.len();
181 let mut seen_terms: HashMap<&str, ()> = HashMap::new();
182 for field in Field::ALL {
183 let tokens = &doc.fields[field.slot()];
184 self.field_length_total[field.slot()] += tokens.len() as u64;
185 let mut counts: HashMap<&str, u32> = HashMap::new();
186 for token in tokens {
187 *counts.entry(token.as_str()).or_insert(0) += 1;
188 }
189 for (term, tf) in counts {
190 self.postings
191 .entry(term.to_string())
192 .or_default()
193 .push(Posting {
194 doc: slot,
195 field,
196 tf,
197 });
198 seen_terms.insert(term, ());
199 }
200 }
201 for term in seen_terms.keys() {
202 *self.doc_frequency.entry((*term).to_string()).or_insert(0) += 1;
203 }
204
205 self.by_id.insert(doc.id.clone(), slot);
206 self.docs.push(Some(doc));
207 self.live_docs += 1;
208 self.revision += 1;
209 }
210
211 pub fn remove(&mut self, id: &MemoryId) -> bool {
213 let Some(slot) = self.by_id.remove(id) else {
214 return false;
215 };
216 let Some(doc) = self.docs[slot].take() else {
217 return false;
218 };
219
220 let mut seen: HashMap<&str, ()> = HashMap::new();
221 for field in Field::ALL {
222 let tokens = &doc.fields[field.slot()];
223 self.field_length_total[field.slot()] -= tokens.len() as u64;
224 for token in tokens {
225 seen.insert(token.as_str(), ());
226 }
227 }
228 for term in seen.keys() {
229 if let Some(df) = self.doc_frequency.get_mut(*term) {
230 *df = df.saturating_sub(1);
231 }
232 if let Some(postings) = self.postings.get_mut(*term) {
233 postings.retain(|p| p.doc != slot);
234 }
235 }
236
237 self.live_docs -= 1;
238 self.revision += 1;
239 true
240 }
241
242 pub fn clear(&mut self) {
244 let revision = self.revision + 1;
245 *self = Self::default();
246 self.revision = revision;
247 }
248
249 pub fn get(&self, id: &MemoryId) -> Option<&IndexedMemory> {
251 self.by_id
252 .get(id)
253 .and_then(|slot| self.docs[*slot].as_ref())
254 }
255
256 pub fn documents(&self) -> impl Iterator<Item = &IndexedMemory> {
258 self.docs.iter().filter_map(|d| d.as_ref())
259 }
260
261 fn average_field_length(&self, field: Field) -> f32 {
262 if self.live_docs == 0 {
263 return 1.0;
264 }
265 let total = self.field_length_total[field.slot()] as f32;
266 (total / self.live_docs as f32).max(1.0)
267 }
268
269 fn idf(&self, term: &str) -> f32 {
270 let df = f32::from(*self.doc_frequency.get(term).unwrap_or(&0) as u16);
271 let n = self.live_docs as f32;
272 (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
275 }
276
277 pub fn search(&self, query: &Query, now: DateTime<Utc>) -> Vec<SearchHit> {
279 let terms = tokenize(&query.text);
280 if terms.is_empty() && query.entities.is_empty() {
281 return Vec::new();
282 }
283
284 let (admitting, ranking): (Vec<&String>, Vec<&String>) = terms
288 .iter()
289 .partition(|term| !query.boost_only.iter().any(|b| b == *term));
290
291 let mut accumulated: HashMap<usize, Vec<ScoreComponent>> = HashMap::new();
292 for (term, admits) in admitting
293 .iter()
294 .map(|t| (*t, true))
295 .chain(ranking.iter().map(|t| (*t, false)))
296 {
297 let Some(postings) = self.postings.get(term) else {
298 continue;
299 };
300 let idf = self.idf(term);
301 for posting in postings {
302 if !admits && !accumulated.contains_key(&posting.doc) {
303 continue;
304 }
305 let Some(doc) = self.docs[posting.doc].as_ref() else {
306 continue;
307 };
308 if !doc.is_retrievable(now) {
309 continue;
310 }
311 if !query.kinds.is_empty() && !query.kinds.contains(&doc.kind) {
312 continue;
313 }
314 let avg = self.average_field_length(posting.field);
315 let len = doc.field_len(posting.field) as f32;
316 let tf = posting.tf as f32;
317 let norm = tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * len / avg));
318 let contribution = idf * norm * posting.field.weight();
319 accumulated
320 .entry(posting.doc)
321 .or_default()
322 .push(ScoreComponent {
323 term: term.clone(),
324 field: posting.field,
325 score: contribution,
326 });
327 }
328 }
329
330 if !query.entities.is_empty() {
334 for (slot, doc) in self.docs.iter().enumerate() {
335 let Some(doc) = doc else { continue };
336 if !doc.is_retrievable(now) {
337 continue;
338 }
339 if !query.kinds.is_empty() && !query.kinds.contains(&doc.kind) {
340 continue;
341 }
342 if query
343 .entities
344 .iter()
345 .any(|e| e == &doc.subject_form || doc.entity_forms.contains(e))
346 {
347 accumulated.entry(slot).or_default();
348 }
349 }
350 }
351
352 let mut hits: Vec<SearchHit> = accumulated
353 .into_iter()
354 .filter_map(|(slot, components)| {
355 let doc = self.docs[slot].as_ref()?;
356 let lexical: f32 = components.iter().map(|c| c.score).sum();
357 let mut explanation = SearchExplanation {
358 memory_id: doc.id.clone(),
359 components,
360 boosts: Vec::new(),
361 lexical_score: lexical,
362 final_score: lexical,
363 };
364 let score = apply_boosts(doc, query, now, lexical, &mut explanation);
365 explanation.final_score = score;
366 Some(SearchHit {
367 id: doc.id.clone(),
368 score,
369 statement: doc.statement.clone(),
370 kind: doc.kind,
371 origin: doc.origin,
372 explanation,
373 })
374 })
375 .collect();
376
377 hits.sort_by(|a, b| {
378 b.score
379 .partial_cmp(&a.score)
380 .unwrap_or(std::cmp::Ordering::Equal)
381 .then_with(|| a.id.as_str().cmp(b.id.as_str()))
382 });
383 hits.truncate(if query.limit == 0 { 20 } else { query.limit });
384 hits
385 }
386}
387
388const ENTITY_BASE: f32 = 2.0;
394
395const RECENCY_HALF_LIFE_DAYS: f32 = 14.0;
397
398fn apply_boosts(
409 doc: &IndexedMemory,
410 query: &Query,
411 now: DateTime<Utc>,
412 lexical: f32,
413 explanation: &mut SearchExplanation,
414) -> f32 {
415 let mut score = lexical;
416
417 let exact_entity = query
418 .entities
419 .iter()
420 .any(|e| e == &doc.subject_form || doc.entity_forms.contains(e));
421 if exact_entity {
422 score += ENTITY_BASE;
423 explanation
424 .boosts
425 .push((BoostKind::ExactEntity, ENTITY_BASE));
426 }
427
428 let mut apply = |kind: BoostKind, factor: f32, score: &mut f32| {
429 let before = *score;
430 *score *= factor;
431 explanation.boosts.push((kind, *score - before));
432 };
433
434 if doc.explicit {
435 apply(BoostKind::ExplicitSource, 1.1, &mut score);
436 }
437 if doc.origin == MemoryOrigin::SessionOverlay {
438 apply(BoostKind::SessionOverlay, 1.5, &mut score);
441 }
442
443 let confidence = doc.confidence.clamp(0.0, 1.0);
444 apply(BoostKind::Confidence, 0.7 + 0.6 * confidence, &mut score);
445 if confidence < 0.5 {
446 apply(BoostKind::LowConfidencePenalty, 0.7, &mut score);
447 }
448
449 if doc.kind.is_episodic() || doc.temporal_scope != crate::core::TemporalScope::Persistent {
451 let age_days = (now - doc.valid_from).num_days().max(0) as f32;
452 let factor = 0.5 + 0.5 * (-age_days / RECENCY_HALF_LIFE_DAYS).exp();
453 apply(BoostKind::Recency, factor, &mut score);
454 }
455
456 score.max(0.0)
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::bm25::schema::IndexedMemory;
463 use crate::core::{
464 CanonicalMemory, CanonicalPredicate, EntityRef, EvidenceCounters, Explicitness,
465 MemorySource, MemoryStatus, MemoryValue, PrivacyMetadata, RetrievalMetadata, SessionId,
466 TemporalMetadata, TemporalScope, TurnId, UserId,
467 };
468
469 fn record(
470 id: &str,
471 kind: MemoryKind,
472 subject: &str,
473 statement: &str,
474 tags: &[&str],
475 ) -> CanonicalMemory {
476 CanonicalMemory {
477 id: MemoryId::new(id),
478 owner: UserId::new("usr_1"),
479 kind,
480 predicate: CanonicalPredicate::new("venue_preference"),
481 status: MemoryStatus::Active,
482 confidence: 0.9,
483 subject: EntityRef::named(subject),
484 value: MemoryValue::Text(statement.into()),
485 statement: statement.into(),
486 evidence_summary: "stated".into(),
487 source: MemorySource::from_explicitness(
488 Explicitness::ExplicitStatement,
489 SessionId::new("ses_1"),
490 TurnId(1),
491 ),
492 temporal: TemporalMetadata::created_at(Utc::now()),
493 retrieval: RetrievalMetadata {
494 subject: crate::core::normalize_token(subject),
495 tags: tags.iter().map(|t| (*t).to_string()).collect(),
496 ..Default::default()
497 },
498 evidence: EvidenceCounters::first(),
499 privacy: PrivacyMetadata::default(),
500 temporal_scope: TemporalScope::Persistent,
501 supersedes: Vec::new(),
502 superseded_by: None,
503 qualifier: None,
504 }
505 }
506
507 fn corpus() -> MemoryIndex {
508 MemoryIndex::build([
509 IndexedMemory::from_canonical(&record(
510 "mem_quiet",
511 MemoryKind::RelationshipPreference,
512 "Rhea",
513 "Rhea prefers quiet restaurants.",
514 &["restaurant", "noise"],
515 )),
516 IndexedMemory::from_canonical(&record(
517 "mem_diet",
518 MemoryKind::Preference,
519 "user",
520 "The user is pescatarian.",
521 &["food", "diet"],
522 )),
523 IndexedMemory::from_canonical(&record(
524 "mem_music",
525 MemoryKind::Preference,
526 "user",
527 "The user enjoys live music venues with friends.",
528 &["music", "venue"],
529 )),
530 ])
531 }
532
533 #[test]
534 fn finds_the_relevant_record_and_ranks_it_first() {
535 let index = corpus();
536 let hits = index.search(&Query::new("quiet restaurant"), Utc::now());
537 assert_eq!(hits.first().map(|h| h.id.as_str()), Some("mem_quiet"));
538 }
539
540 #[test]
541 fn a_query_with_no_matching_terms_returns_nothing() {
542 let index = corpus();
543 assert!(
544 index
545 .search(&Query::new("quantum chromodynamics"), Utc::now())
546 .is_empty()
547 );
548 assert!(index.search(&Query::new(""), Utc::now()).is_empty());
549 }
550
551 #[test]
552 fn an_entity_only_query_still_finds_that_entitys_records() {
553 let index = corpus();
554 let hits = index.search(&Query::new("").with_entities(["Rhea"]), Utc::now());
555 assert_eq!(hits.len(), 1);
556 assert_eq!(hits[0].id.as_str(), "mem_quiet");
557 assert!(
558 hits[0]
559 .explanation
560 .boosts
561 .iter()
562 .any(|(kind, _)| *kind == BoostKind::ExactEntity)
563 );
564 }
565
566 #[test]
567 fn subject_matches_outrank_statement_matches() {
568 let mut index = MemoryIndex::new();
569 index.upsert(IndexedMemory::from_canonical(&record(
570 "mem_subject",
571 MemoryKind::Relationship,
572 "Kushal",
573 "A colleague.",
574 &[],
575 )));
576 index.upsert(IndexedMemory::from_canonical(&record(
577 "mem_mention",
578 MemoryKind::Episodic,
579 "user",
580 "The user had dinner and mentioned Kushal in passing.",
581 &[],
582 )));
583 let hits = index.search(&Query::new("kushal"), Utc::now());
584 assert_eq!(hits[0].id.as_str(), "mem_subject");
585 }
586
587 #[test]
588 fn session_overlay_facts_outrank_equivalent_canonical_ones() {
589 let canonical = IndexedMemory::from_canonical(&record(
590 "mem_old",
591 MemoryKind::Preference,
592 "user",
593 "The user is vegetarian.",
594 &["diet"],
595 ));
596 let overlay = IndexedMemory::from_canonical(&record(
597 "mem_new",
598 MemoryKind::Preference,
599 "user",
600 "The user is vegetarian now pescatarian.",
601 &["diet"],
602 ))
603 .as_session_overlay();
604 let index = MemoryIndex::build([canonical, overlay]);
605
606 let hits = index.search(&Query::new("vegetarian diet"), Utc::now());
607 assert_eq!(hits[0].id.as_str(), "mem_new");
608 }
609
610 #[test]
611 fn superseded_and_expired_records_never_surface() {
612 let mut superseded = IndexedMemory::from_canonical(&record(
613 "mem_old",
614 MemoryKind::Preference,
615 "user",
616 "The user is vegetarian.",
617 &["diet"],
618 ));
619 superseded.status = MemoryStatus::Superseded;
620
621 let mut expired = IndexedMemory::from_canonical(&record(
622 "mem_gone",
623 MemoryKind::Episodic,
624 "user",
625 "The user slept badly.",
626 &["sleep"],
627 ));
628 expired.expires_at = Some(Utc::now() - chrono::Duration::hours(1));
629
630 let index = MemoryIndex::build([superseded, expired]);
631 assert!(
632 index
633 .search(&Query::new("vegetarian"), Utc::now())
634 .is_empty()
635 );
636 assert!(index.search(&Query::new("sleep"), Utc::now()).is_empty());
637 }
638
639 #[test]
640 fn removing_a_record_removes_it_from_results_and_statistics() {
641 let mut index = corpus();
642 assert_eq!(index.len(), 3);
643 assert!(index.remove(&MemoryId::new("mem_quiet")));
644 assert_eq!(index.len(), 2);
645 assert!(
646 index
647 .search(&Query::new("quiet restaurant"), Utc::now())
648 .is_empty()
649 );
650 assert!(!index.remove(&MemoryId::new("mem_quiet")));
652 }
653
654 #[test]
655 fn upserting_replaces_rather_than_duplicates() {
656 let mut index = corpus();
657 let revised = IndexedMemory::from_canonical(&record(
658 "mem_diet",
659 MemoryKind::Preference,
660 "user",
661 "The user is pescatarian and avoids shellfish.",
662 &["food", "diet"],
663 ));
664 index.upsert(revised);
665 assert_eq!(index.len(), 3);
666 let hits = index.search(&Query::new("shellfish"), Utc::now());
667 assert_eq!(hits.len(), 1);
668 }
669
670 #[test]
671 fn recent_episodes_outrank_stale_ones() {
672 let now = Utc::now();
673 let mut fresh = IndexedMemory::from_canonical(&record(
674 "mem_fresh",
675 MemoryKind::Episodic,
676 "user",
677 "Dinner in Bandra was too noisy.",
678 &["restaurant"],
679 ));
680 fresh.temporal_scope = TemporalScope::RecentHistory;
681 fresh.valid_from = now - chrono::Duration::days(1);
682
683 let mut stale = IndexedMemory::from_canonical(&record(
684 "mem_stale",
685 MemoryKind::Episodic,
686 "user",
687 "Dinner in Bandra was too noisy.",
688 &["restaurant"],
689 ));
690 stale.temporal_scope = TemporalScope::RecentHistory;
691 stale.valid_from = now - chrono::Duration::days(60);
692
693 let index = MemoryIndex::build([fresh, stale]);
694 let hits = index.search(&Query::new("noisy dinner bandra"), now);
695 assert_eq!(hits[0].id.as_str(), "mem_fresh");
696 }
697
698 #[test]
699 fn kind_filters_restrict_the_search_scope() {
700 let index = corpus();
701 let hits = index.search(
702 &Query::new("user").with_kinds(vec![MemoryKind::RelationshipPreference]),
703 Utc::now(),
704 );
705 assert!(
706 hits.iter()
707 .all(|h| h.kind == MemoryKind::RelationshipPreference)
708 );
709 }
710
711 #[test]
712 fn an_explanation_adds_up_to_the_score_it_explains() {
713 let index = corpus();
714 for hit in index.search(
715 &Query::new("quiet restaurant").with_entities(["Rhea"]),
716 Utc::now(),
717 ) {
718 let summed: f32 = hit.explanation.lexical_score
719 + hit.explanation.boosts.iter().map(|(_, d)| d).sum::<f32>();
720 assert!(
721 (summed - hit.score).abs() < 1e-4,
722 "explanation for {} sums to {summed}, score is {}",
723 hit.id,
724 hit.score
725 );
726 }
727 }
728
729 #[test]
730 fn scores_are_never_negative_and_always_explained() {
731 let index = corpus();
732 for hit in index.search(&Query::new("restaurant diet music"), Utc::now()) {
733 assert!(hit.score >= 0.0);
734 assert!(!hit.explanation.components.is_empty());
735 assert_eq!(hit.explanation.final_score, hit.score);
736 }
737 }
738}