1use anyhow::Result;
14use std::collections::{BTreeSet, HashSet};
15
16use super::index::{Edge, Index};
17use super::stamp::{user_metadata, Stamp};
18use super::types::*;
19use super::{Memory, MemoryError, RecencyContext};
20use crate::core::documents::{terms, DocumentResult, SearchOptions};
21
22const SNAPSHOT_ATTEMPTS: usize = 4;
24
25pub(super) fn evaluate(
27 stamp: Option<&Stamp>,
28 edges: &[Edge],
29 now: Millis,
30 known_at: Option<Millis>,
31) -> Option<FactStatus> {
32 if let (Some(known), Some(stamp)) = (known_at, stamp) {
33 if stamp.ingest > known {
34 return None;
35 }
36 }
37 let superseded = edges
38 .iter()
39 .any(|edge| edge.effective <= now && known_at.is_none_or(|known| edge.ingest <= known));
40 if superseded {
41 return Some(FactStatus::Superseded);
42 }
43 let Some(stamp) = stamp else {
44 return Some(FactStatus::Current);
45 };
46 if stamp.valid_to.is_some_and(|to| to <= now) {
47 return Some(FactStatus::Expired);
48 }
49 if stamp.valid_from.is_some_and(|from| from > now) {
50 return Some(FactStatus::NotYetValid);
51 }
52 Some(FactStatus::Current)
53}
54
55struct Ranked {
56 hit: DocumentResult,
57 stamp: Option<Stamp>,
58 status: FactStatus,
59 recency: f64,
60 historical: f64,
61 score: f64,
62}
63
64struct Run {
65 ranked: Vec<Ranked>,
66 now: Millis,
67 lexical_weight: f64,
68 semantic_weight: f64,
69 fusion: String,
70}
71
72impl RecallOptions {
73 fn search_options(&self, k: u32) -> SearchOptions {
74 SearchOptions {
75 k: Some(k),
76 candidate_limit: self.candidate_limit,
77 filter: self.filter.clone(),
78 embedding: self.embedding.clone(),
79 lexical_weight: self.lexical_weight,
80 semantic_weight: self.semantic_weight,
81 min_semantic_score: self.min_semantic_score,
82 fusion: self.fusion.clone(),
83 ..Default::default()
84 }
85 }
86}
87
88fn sorted_ids(edges: &[Edge]) -> Vec<String> {
89 let mut edges: Vec<&Edge> = edges.iter().collect();
90 edges.sort_by(|a, b| (a.effective, a.ingest, &a.by).cmp(&(b.effective, b.ingest, &b.by)));
91 edges.into_iter().map(|edge| edge.by.clone()).collect()
92}
93
94fn query_terms(query: &str) -> Vec<(String, Option<String>)> {
97 query
98 .split(|c: char| !c.is_alphanumeric())
99 .filter(|token| !token.is_empty())
100 .map(|token| {
101 let lower = token.to_lowercase();
102 let stem = terms(&lower, true).into_keys().next();
103 (lower, stem)
104 })
105 .collect()
106}
107
108fn matched_terms(query: &[(String, Option<String>)], text: &str) -> Vec<String> {
109 let stems = terms(text, true);
110 let raw = terms(text, false);
111 let mut matched: Vec<String> = Vec::new();
112 for (lower, stem) in query {
113 let hit =
114 raw.contains_key(lower) || stem.as_ref().is_some_and(|stem| stems.contains_key(stem));
115 if hit && !matched.contains(lower) {
116 matched.push(lower.clone());
117 }
118 }
119 matched
120}
121
122impl Memory {
123 pub fn recall(&self, query: &str, options: &RecallOptions) -> Result<Vec<Recalled>> {
129 self.with_revision(|| self.recall_once(query, options))
130 }
131
132 fn recall_once(&self, query: &str, options: &RecallOptions) -> Result<Vec<Recalled>> {
133 let k = options.k.unwrap_or(self.config.default_k);
134 self.core
135 .validate_document_search(query, &options.search_options(k))
136 .map_err(|error| MemoryError::Invalid(error.to_string()))?;
137 let history = options.history.unwrap_or(self.config.default_history);
138 self.ensure_fresh();
139 let Some(mut run) = self.run(query, options, history, k, false)? else {
140 return Ok(Vec::new());
141 };
142 let index = self.index.read();
143 let explained_terms = options.explain.then(|| query_terms(query));
144 let ranked = std::mem::take(&mut run.ranked);
145 Ok(ranked
146 .into_iter()
147 .enumerate()
148 .map(|(position, ranked)| {
149 let explanation = explained_terms.as_deref().map(|terms| {
150 self.explanation(&index, &run, terms, options, &ranked, Some(position + 1))
151 });
152 self.recalled(&index, ranked, explanation)
153 })
154 .collect())
155 }
156
157 pub fn explain(&self, query: &str, id: &str, options: &RecallOptions) -> Result<Explanation> {
164 self.with_revision(|| self.explain_once(query, id, options))
165 }
166
167 fn explain_once(&self, query: &str, id: &str, options: &RecallOptions) -> Result<Explanation> {
168 self.core
169 .validate_document_search(
170 query,
171 &options.search_options(options.k.unwrap_or(self.config.default_k)),
172 )
173 .map_err(|error| MemoryError::Invalid(error.to_string()))?;
174 self.ensure_fresh();
175 let (version, metadata, text) = self
176 .core
177 .document_snapshot(id)
178 .ok_or_else(|| MemoryError::NotFound(format!("unknown fact {id:?}")))?;
179 let k = options.k.unwrap_or(self.config.default_k).min(1000);
180 let limit = options
181 .candidate_limit
182 .unwrap_or_else(|| 100.max(k))
183 .clamp(1, 1000);
184 let run = self.run(query, options, HistoryMode::All, limit, true)?;
185 let now = options.as_of.unwrap_or_else(|| self.clock.now_millis());
186 let index = self.index.read();
187 if let Some(run) = &run {
188 if let Some(position) = run.ranked.iter().position(|r| r.hit.document_id == id) {
189 let ranked = &run.ranked[position];
190 return Ok(self.explanation(
191 &index,
192 run,
193 &query_terms(query),
194 options,
195 ranked,
196 Some(position + 1),
197 ));
198 }
199 }
200 let stamp = Stamp::from_parts(&version, &metadata);
201 let status = evaluate(stamp.as_ref(), index.edges(id), now, options.known_at)
202 .unwrap_or(FactStatus::Current);
203 Ok(Explanation {
204 retrieved: false,
205 rank: None,
206 lexical_score: None,
207 semantic_score: None,
208 retrieval_score: None,
209 lexical_weight: options.lexical_weight.unwrap_or(1.0),
210 semantic_weight: options.semantic_weight.unwrap_or(1.0),
211 fusion: options.fusion.clone().unwrap_or_else(|| "blend".into()),
212 matched_terms: text
213 .as_deref()
214 .map(|text| matched_terms(&query_terms(query), text))
215 .unwrap_or_default(),
216 recency_factor: 1.0,
217 historical_factor: 1.0,
218 final_score: None,
219 status,
220 as_of: now,
221 known_at: options.known_at,
222 valid_from: stamp.as_ref().and_then(|s| s.valid_from),
223 valid_to: stamp.as_ref().and_then(|s| s.valid_to),
224 within_validity: stamp.as_ref().is_none_or(|s| {
225 s.valid_from.is_none_or(|from| from <= now) && s.valid_to.is_none_or(|to| now < to)
226 }),
227 chain: self.chain(&index, id),
228 })
229 }
230
231 pub fn get(&self, id: &str) -> Result<Option<FactRecord>> {
233 let now = self.clock.now_millis();
234 self.with_snapshot(|index| Ok(self.record(index, id, now)))
235 }
236
237 pub fn history(&self, id: &str) -> Result<Vec<FactRecord>> {
240 let now = self.clock.now_millis();
241 self.with_snapshot(|index| {
242 if !self.core.has_document(id) {
243 return Err(MemoryError::NotFound(format!("unknown fact {id:?}")).into());
244 }
245 let mut records: Vec<FactRecord> = index
246 .component(id)
247 .iter()
248 .filter_map(|member| self.record(index, member, now))
249 .collect();
250 records.sort_by(|a, b| {
251 (a.effective_from, a.ingest_time, &a.id).cmp(&(
252 b.effective_from,
253 b.ingest_time,
254 &b.id,
255 ))
256 });
257 Ok(records)
258 })
259 }
260
261 fn record(&self, index: &Index, id: &str, now: Millis) -> Option<FactRecord> {
262 let (version, metadata, text) = self.core.document_snapshot(id)?;
263 let stamp = Stamp::from_parts(&version, &metadata);
264 let edges = index.edges(id);
265 let status = evaluate(stamp.as_ref(), edges, now, None).unwrap_or(FactStatus::Current);
266 let ingest_time = stamp.as_ref().map(|s| s.ingest);
267 let known = stamp.unwrap_or_default();
268 Some(FactRecord {
269 id: id.to_owned(),
270 text,
271 status,
272 ingest_time,
273 event_time: known.event,
274 valid_from: known.valid_from,
275 valid_to: known.valid_to,
276 effective_from: known.effective_from(),
277 subject: known.subject,
278 entities: known.entities,
279 supersedes: index_predecessors(index, id, &known.supersedes),
280 superseded_by: sorted_ids(edges),
281 metadata: user_metadata(metadata),
282 })
283 }
284
285 fn run(
295 &self,
296 query: &str,
297 options: &RecallOptions,
298 history: HistoryMode,
299 k: u32,
300 force_wide: bool,
301 ) -> Result<Option<Run>> {
302 let mut attempt = 1;
303 loop {
304 let (run, consistent) = self.run_once(query, options, history, k, force_wide)?;
305 if consistent {
306 return Ok(run);
307 }
308 if attempt == SNAPSHOT_ATTEMPTS {
309 return Err(MemoryError::Concurrent(
310 "memory changed during recall; retry the operation".into(),
311 )
312 .into());
313 }
314 attempt += 1;
315 self.ensure_fresh();
316 }
317 }
318
319 fn run_once(
321 &self,
322 query: &str,
323 options: &RecallOptions,
324 history: HistoryMode,
325 k: u32,
326 force_wide: bool,
327 ) -> Result<(Option<Run>, bool)> {
328 let revision = self.core.revision();
329 let now = options.as_of.unwrap_or_else(|| self.clock.now_millis());
330 use crate::core::query_limits::{QueryBudget, QueryLimits};
331 let mut budget = QueryBudget::new(QueryLimits::default());
332 let (indexed, constrained, restrict) = {
333 let index = self.index.read();
334 (
335 index.revision,
336 index.constrained(),
337 Self::restriction(&index, options, &mut budget)?,
338 )
339 };
340 let indexed = indexed == Some(revision);
341 let mut restrict = match restrict {
342 Some(ids) if ids.is_empty() => return Ok((None, indexed)),
343 other => other,
344 };
345 let needs_status = constrained || options.known_at.is_some();
346 if history == HistoryMode::Historical && !needs_status && !force_wide {
347 return Ok((None, indexed));
348 }
349 if needs_status && (history != HistoryMode::All || options.known_at.is_some()) {
350 let edges = self.index.read().capture_edges(&mut budget)?;
351 let scope = restrict
352 .take()
353 .map(|ids| ids.into_iter().collect::<BTreeSet<_>>());
354 let mut eligible = Vec::new();
355 let scanned = self.core.try_scan_documents(scope.as_ref(), |document| {
356 budget.work(1)?;
357 let stamp = Stamp::from_parts(document.version, document.metadata);
358 let status = evaluate(
359 stamp.as_ref(),
360 edges.get(document.id).map_or(&[], Vec::as_slice),
361 now,
362 options.known_at,
363 );
364 let keep = status.is_some_and(|status| match history {
365 HistoryMode::All => true,
366 HistoryMode::Current => status == FactStatus::Current,
367 HistoryMode::Historical => status != FactStatus::Current,
368 });
369 if keep {
370 budget.reserve(document.id.len().saturating_mul(4).saturating_add(256))?;
371 eligible.push(document.id.to_owned());
372 }
373 Ok(())
374 })?;
375 if scanned != revision {
376 return Ok((None, false));
377 }
378 if eligible.is_empty() {
379 return Ok((None, indexed && self.core.revision() == revision));
380 }
381 restrict = Some(eligible);
382 }
383 let reorder =
384 !self.recency.is_identity() || (needs_status && self.config.historical_weight != 1.0);
385 let wide = reorder || force_wide;
386 let fetch = if wide {
387 k.max(options.candidate_limit.unwrap_or_else(|| 100.max(k)))
388 .min(1000)
389 } else {
390 k
391 };
392 let search = SearchOptions {
393 document_ids: restrict,
394 ..options.search_options(fetch)
395 };
396 let hits = self
397 .core
398 .search_documents(query, &search)
399 .map_err(|error| MemoryError::Invalid(format!("{error}")))?;
400 let index = self.index.read();
401 let consistent =
404 indexed && index.revision == Some(revision) && self.core.revision() == revision;
405 let evaluated: Vec<_> = hits
406 .into_iter()
407 .filter_map(|hit| {
408 let stamp = Stamp::from_parts(&hit.version, &hit.metadata);
409 let status = evaluate(
410 stamp.as_ref(),
411 index.edges(&hit.document_id),
412 now,
413 options.known_at,
414 )?;
415 Some((hit, stamp, status))
416 })
417 .collect();
418 drop(index);
419 let mut seen: HashSet<String> = HashSet::with_capacity(evaluated.len());
420 let mut ranked = Vec::with_capacity(evaluated.len());
421 for (hit, stamp, status) in evaluated {
422 if !reorder && !force_wide && ranked.len() >= k as usize {
424 break;
425 }
426 if !seen.insert(hit.document_id.clone()) {
427 continue;
428 }
429 let keep = match history {
430 HistoryMode::All => true,
431 HistoryMode::Current => status == FactStatus::Current,
432 HistoryMode::Historical => status != FactStatus::Current,
433 };
434 if !keep {
435 continue;
436 }
437 let recency = if self.recency.is_identity() {
438 1.0
439 } else {
440 self.recency.factor(&RecencyContext {
441 now,
442 event_time: stamp.as_ref().and_then(|s| s.event),
443 valid_from: stamp.as_ref().and_then(|s| s.valid_from),
444 ingest_time: stamp.as_ref().map(|s| s.ingest),
445 status,
446 })
447 };
448 let historical = if status == FactStatus::Current {
449 1.0
450 } else {
451 self.config.historical_weight
452 };
453 let score = if reorder {
454 hit.score * recency * historical
455 } else {
456 hit.score
457 };
458 ranked.push(Ranked {
459 hit,
460 stamp,
461 status,
462 recency,
463 historical,
464 score,
465 });
466 }
467 if reorder {
468 ranked.sort_by(|a, b| b.score.total_cmp(&a.score));
469 }
470 if !force_wide {
471 ranked.truncate(k as usize);
472 }
473 let run = Run {
474 ranked,
475 now,
476 lexical_weight: options.lexical_weight.unwrap_or(1.0),
477 semantic_weight: options.semantic_weight.unwrap_or(1.0),
478 fusion: options.fusion.clone().unwrap_or_else(|| "blend".into()),
479 };
480 Ok((
481 Some(run),
482 consistent && self.core.memory_revision_matches(revision),
483 ))
484 }
485
486 fn restriction(
488 index: &Index,
489 options: &RecallOptions,
490 budget: &mut crate::core::query_limits::QueryBudget,
491 ) -> Result<Option<Vec<String>>> {
492 let mut allowed: Option<BTreeSet<&str>> = None;
493 if let Some(subject) = &options.subject {
494 let mut ids = BTreeSet::new();
495 for id in index.subject_id_refs(subject.trim()) {
496 budget.work(1)?;
497 if !ids.contains(id) {
498 budget.reserve(64)?;
499 ids.insert(id);
500 }
501 }
502 allowed = Some(ids);
503 }
504 if let Some(entities) = &options.entities {
505 let mut union = BTreeSet::new();
506 for entity in entities {
507 budget.work(1)?;
508 for id in index.entity_ids(entity.trim()) {
509 budget.work(1)?;
510 if !union.contains(id.as_str()) {
511 budget.reserve(64)?;
512 union.insert(id.as_str());
513 }
514 }
515 }
516 allowed = Some(match allowed {
517 Some(mut existing) => {
518 budget.work(existing.len())?;
519 existing.retain(|id| union.contains(id));
520 existing
521 }
522 None => union,
523 });
524 }
525 allowed
526 .map(|ids| {
527 let mut owned = Vec::new();
528 for id in ids {
529 budget.work(1)?;
530 budget.reserve(id.len().saturating_add(256))?;
531 owned.push(id.to_owned());
532 }
533 Ok(owned)
534 })
535 .transpose()
536 }
537
538 fn recalled(
539 &self,
540 index: &Index,
541 ranked: Ranked,
542 explanation: Option<Explanation>,
543 ) -> Recalled {
544 let Ranked {
545 hit,
546 stamp,
547 status,
548 score,
549 ..
550 } = ranked;
551 let ingest_time = stamp.as_ref().map(|s| s.ingest);
552 let known = stamp.unwrap_or_default();
553 Recalled {
554 superseded_by: sorted_ids(index.edges(&hit.document_id)),
555 id: hit.document_id,
556 text: hit.text,
557 score,
558 status,
559 ingest_time,
560 event_time: known.event,
561 valid_from: known.valid_from,
562 valid_to: known.valid_to,
563 subject: known.subject,
564 entities: known.entities,
565 supersedes: known.supersedes,
566 metadata: user_metadata(hit.metadata),
567 source_uri: hit.source_uri,
568 explanation,
569 }
570 }
571
572 fn explanation(
573 &self,
574 index: &Index,
575 run: &Run,
576 query: &[(String, Option<String>)],
577 options: &RecallOptions,
578 ranked: &Ranked,
579 rank: Option<usize>,
580 ) -> Explanation {
581 let stamp = ranked.stamp.as_ref();
582 let id = &ranked.hit.document_id;
583 Explanation {
584 retrieved: true,
585 rank,
586 lexical_score: Some(ranked.hit.lexical_score),
587 semantic_score: ranked.hit.semantic_score,
588 retrieval_score: Some(ranked.hit.score),
589 lexical_weight: run.lexical_weight,
590 semantic_weight: run.semantic_weight,
591 fusion: run.fusion.clone(),
592 matched_terms: ranked
593 .hit
594 .text
595 .as_deref()
596 .map(|text| matched_terms(query, text))
597 .unwrap_or_default(),
598 recency_factor: ranked.recency,
599 historical_factor: ranked.historical,
600 final_score: Some(ranked.score),
601 status: ranked.status,
602 as_of: run.now,
603 known_at: options.known_at,
604 valid_from: stamp.and_then(|s| s.valid_from),
605 valid_to: stamp.and_then(|s| s.valid_to),
606 within_validity: stamp.is_none_or(|s| {
607 s.valid_from.is_none_or(|from| from <= run.now)
608 && s.valid_to.is_none_or(|to| run.now < to)
609 }),
610 chain: self.chain(index, id),
611 }
612 }
613
614 fn chain(&self, index: &Index, id: &str) -> Vec<ChainLink> {
617 let mut links = Vec::new();
618 for (relation, members) in [
619 (ChainRelation::Predecessor, index.ancestors(id)),
620 (ChainRelation::Successor, index.descendants(id)),
621 ] {
622 for member in members {
623 let Some((version, metadata, _)) = self.core.document_snapshot(&member) else {
624 continue;
625 };
626 let stamp = Stamp::from_parts(&version, &metadata).unwrap_or_default();
627 links.push(ChainLink {
628 id: member,
629 relation,
630 effective_from: stamp.effective_from(),
631 ingest_time: stamp.ingest,
632 });
633 }
634 }
635 links.sort_by(|a, b| {
636 (a.effective_from, a.ingest_time, &a.id).cmp(&(b.effective_from, b.ingest_time, &b.id))
637 });
638 links
639 }
640}
641
642fn index_predecessors(index: &Index, id: &str, stamped: &[String]) -> Vec<String> {
644 let mut ids: BTreeSet<String> = stamped.iter().cloned().collect();
645 ids.extend(index.direct_predecessors(id).iter().cloned());
646 ids.into_iter().collect()
647}
648
649#[cfg(test)]
650mod scope_budget_tests {
651 use super::*;
652 use crate::core::query_limits::{QueryBudget, QueryLimitExceeded, QueryLimits};
653
654 #[test]
655 fn scoped_postings_are_bounded_before_capture_and_intersection() {
656 let mut index = Index::default();
657 for id in ["a", "b", "c"] {
658 index.insert(
659 id,
660 &Stamp {
661 subject: Some("subject".into()),
662 entities: vec!["entity".into()],
663 ..Stamp::default()
664 },
665 &|_| None,
666 );
667 }
668 let options = RecallOptions {
669 subject: Some("subject".into()),
670 ..RecallOptions::default()
671 };
672 for work in [5, 6, 7] {
673 let mut budget = QueryBudget::new(QueryLimits {
674 max_work: work,
675 ..QueryLimits::default()
676 });
677 let result = Memory::restriction(&index, &options, &mut budget);
678 assert_eq!(result.is_ok(), work >= 6);
679 }
680 let bytes = 3 * (64 + 256 + 1);
681 for limit in [bytes - 1, bytes, bytes + 1] {
682 let mut budget = QueryBudget::new(QueryLimits {
683 max_bytes: limit,
684 ..QueryLimits::default()
685 });
686 let result = Memory::restriction(&index, &options, &mut budget);
687 assert_eq!(result.is_ok(), limit >= bytes);
688 }
689 let options = RecallOptions {
690 entities: Some(vec!["entity".into()]),
691 ..options
692 };
693 let mut budget = QueryBudget::new(QueryLimits {
694 max_bytes: 63,
695 ..QueryLimits::default()
696 });
697 let error = Memory::restriction(&index, &options, &mut budget).unwrap_err();
698 assert_eq!(
699 error.downcast_ref::<QueryLimitExceeded>().unwrap().resource,
700 "bytes"
701 );
702 let mut budget = QueryBudget::new(QueryLimits::default());
703 assert_eq!(
704 Memory::restriction(&index, &options, &mut budget)
705 .unwrap()
706 .unwrap(),
707 vec!["a", "b", "c"]
708 );
709 }
710}