core_api/memory/recall.rs
1//! What do I already know about this?
2//!
3//! The 0.6.12 implementation began by extracting *identifiers* from the topic
4//! — paths, `mod::name`, snake_case, backticked words — and returned nothing
5//! when it found none. On a memory store that rejected every natural-language
6//! topic, including a bare human name, while the note containing the word sat
7//! indexed and reachable by Cypher. There is no identifier gate here.
8
9use crate::digest::sanitize;
10use crate::GraphDb;
11use core_storage::fs::Fs;
12use core_storage::fulltext::{stem, value_tokens_stemmed_with_positions};
13use std::collections::{BTreeMap, HashSet};
14
15/// How many hits the digest will name.
16const MAX_HITS: usize = 6;
17
18/// Terms a topic contributes to the query, at most.
19///
20/// `repograph::recall` capped at the same 24 and this module shipped without
21/// one, which was harmless only while the caller was an MCP `recall` argument
22/// a person had typed. The `UserPromptSubmit` hook passes a whole prompt, and
23/// the query the index receives is every surviving term OR'd together — so
24/// without a cap a long prompt is a long query, once per turn, for a digest
25/// bounded to six lines. The first 24 in order of appearance: a prompt's
26/// subject is at its start far more often than at its end.
27pub const MAX_QUERY_TERMS: usize = 24;
28
29/// Glue words dropped from a topic before it becomes a query.
30///
31/// The index's query grammar ANDs space-separated terms within one group —
32/// there is no websearch-style stopword handling underneath it — so a natural
33/// question passed through unchanged (`"who is Matthew Sherlin?"`) requires
34/// the document to contain the word "who", which none does. Dropping these
35/// before the terms are OR'd together is what lets a question find the
36/// content word rather than demanding the whole sentence.
37///
38/// Copied from the code-graph `recall`'s list when this module moved out of
39/// `repograph` in 0.7; that module has since been deleted, so this is now the
40/// only copy.
41const STOPWORDS: [&str; 146] = [
42 "a",
43 "about",
44 "after",
45 "again",
46 "all",
47 "also",
48 "am",
49 "an",
50 "and",
51 "any",
52 "anything",
53 "are",
54 "as",
55 "at",
56 "back",
57 "be",
58 "because",
59 "been",
60 "before",
61 "being",
62 "below",
63 "between",
64 "both",
65 "but",
66 "by",
67 "can",
68 "cannot",
69 "could",
70 "did",
71 "do",
72 "does",
73 "doing",
74 "done",
75 "down",
76 "during",
77 "each",
78 "either",
79 "else",
80 "even",
81 "ever",
82 "every",
83 "few",
84 "for",
85 "from",
86 "further",
87 "had",
88 "has",
89 "have",
90 "having",
91 "he",
92 "her",
93 "here",
94 "hers",
95 "him",
96 "his",
97 "how",
98 "i",
99 "if",
100 "in",
101 "into",
102 "is",
103 "it",
104 "its",
105 "itself",
106 "just",
107 "know",
108 "let",
109 "like",
110 "may",
111 "maybe",
112 "me",
113 "might",
114 "more",
115 "most",
116 "much",
117 "must",
118 "my",
119 "need",
120 "no",
121 "nor",
122 "not",
123 "now",
124 "of",
125 "off",
126 "ok",
127 "okay",
128 "on",
129 "once",
130 "one",
131 "only",
132 "or",
133 "other",
134 "our",
135 "out",
136 "over",
137 "own",
138 "please",
139 "same",
140 "she",
141 "should",
142 "so",
143 "some",
144 "something",
145 "such",
146 "sure",
147 "tell",
148 "than",
149 "thanks",
150 "that",
151 "the",
152 "their",
153 "them",
154 "then",
155 "there",
156 "these",
157 "they",
158 "think",
159 "this",
160 "those",
161 "through",
162 "to",
163 "too",
164 "under",
165 "until",
166 "up",
167 "us",
168 "very",
169 "want",
170 "was",
171 "we",
172 "were",
173 "what",
174 "when",
175 "where",
176 "which",
177 "while",
178 "who",
179 "whom",
180 "why",
181 "will",
182 "with",
183 "would",
184 "yes",
185 "you",
186 "your",
187 "yours",
188];
189
190fn is_stopword(term: &str) -> bool {
191 STOPWORDS.binary_search(&term).is_ok()
192}
193
194/// `topic` split into its non-glue words: lowercase, split on every
195/// non-alphanumeric run — not just whitespace — so a compound identifier like
196/// `graph_db` still matches. The index tokenizes the same way at write time,
197/// so the document holds `graph` and `db` as two separate tokens, and an
198/// unquoted term that kept the underscore (`graphdb`) would match neither.
199/// Stopwords and repeats are dropped; order of first appearance is kept.
200///
201/// A repeat is a word that *stems* like an earlier one — "bugs" after "bug" —
202/// because coverage is counted on stems: kept as two terms, a node holding
203/// the one word would count twice toward its coverage.
204fn search_terms(topic: &str) -> Vec<String> {
205 let mut terms: Vec<String> = Vec::new();
206 let mut stems: HashSet<String> = HashSet::new();
207 for word in topic.split(|c: char| !c.is_alphanumeric()) {
208 if word.is_empty() {
209 continue;
210 }
211 let term = word.to_lowercase();
212 if is_stopword(&term) || !stems.insert(stem(&term)) {
213 continue;
214 }
215 terms.push(term);
216 if terms.len() == MAX_QUERY_TERMS {
217 break;
218 }
219 }
220 terms
221}
222
223/// The words of an all-stopword topic, for the fallback's one AND-group:
224/// split and lowercased as [`search_terms`] splits, so no `"`, `-` or `*` the
225/// index's parser reads as grammar survives, and without `or` and `and`,
226/// which it reads as keywords and so could never match as words anyway.
227/// Repeats dropped, capped at [`MAX_QUERY_TERMS`] like the ordinary query.
228fn fallback_words(topic: &str) -> Vec<String> {
229 let mut words: Vec<String> = Vec::new();
230 for word in topic.split(|c: char| !c.is_alphanumeric()) {
231 let word = word.to_lowercase();
232 if word.is_empty() || word == "or" || word == "and" || words.contains(&word) {
233 continue;
234 }
235 words.push(word);
236 if words.len() == MAX_QUERY_TERMS {
237 break;
238 }
239 }
240 words
241}
242
243/// The topic's search terms and the query the index is asked.
244///
245/// Space-separated terms are ANDed by the index's query grammar, so the
246/// topic as typed ("who is Matthew Sherlin?") would require the document
247/// to contain "who" and "is" too. [`search_terms`] drops the glue words, and
248/// the query ORs the rest, so a question or a name finds the content word
249/// that matches. Fall back to the topic's own words, ANDed, when nothing
250/// survives the filter, so an all-stopword topic still probes the index
251/// rather than silently skipping the search; see [`fallback_words`]. The
252/// terms are empty exactly when the fallback ran, and the query is empty
253/// only when the topic held no searchable word at all.
254fn topic_query(topic: &str) -> (Vec<String>, String) {
255 let terms = search_terms(topic);
256 let query = if terms.is_empty() {
257 fallback_words(topic).join(" ")
258 } else {
259 terms.join(" OR ")
260 };
261 (terms, query)
262}
263
264/// One node a topic matched.
265///
266/// `key`, `label` and `summary` are stored content, unsanitized: only
267/// [`recall_digest`] passes them through [`sanitize`]. A caller rendering
268/// them into an assistant's context owes `core_api::digest::sanitize`.
269#[derive(Debug, Clone, PartialEq, serde::Serialize)]
270pub struct RecallHit {
271 pub key: String,
272 /// The node's label, or empty if it vanished between the search and here.
273 pub label: String,
274 /// The first of `text`, `summary` or `name` the node carries, as
275 /// [`GraphDb::node_summary_line`] cuts it.
276 pub summary: Option<String>,
277 /// How many of the topic's terms this node's own text holds. `0` when
278 /// the topic was all stopwords and its words were searched as one
279 /// AND-group, where every hit holds all of them.
280 pub covered: usize,
281 /// The fused score. Rank-derived, so nearly flat across hits: order by
282 /// `covered` first, as this list already is.
283 pub score: f64,
284}
285
286/// A topic's hits, ranked: coverage descending, then score, then key.
287#[derive(Debug, Clone, PartialEq, serde::Serialize)]
288pub struct RecallRows {
289 /// False when the store declares no full-text index, so no topic can
290 /// match — a different answer from "this topic matched nothing".
291 pub indexed: bool,
292 /// The topic's search terms after stopwords and repeats are dropped, at
293 /// most [`MAX_QUERY_TERMS`] (24). `0` with hits present means the topic
294 /// was all stopwords and the fallback ran, where every hit's `covered`
295 /// is `0`.
296 pub terms: usize,
297 /// At most six, best first.
298 pub hits: Vec<RecallHit>,
299}
300
301/// Why `recall` had nothing to say — the two reasons are different advice.
302#[derive(Debug)]
303pub enum RecallOutcome {
304 /// The store declares no full-text index, so no topic can ever match.
305 NoIndex,
306 /// The store can search and this topic matched nothing.
307 NoMatch,
308 /// The rendered digest.
309 Hits(String),
310}
311
312/// Rank nodes against a free-text topic over every declared text field, and
313/// return the hits as data. [`recall_digest`] renders exactly these rows.
314pub fn recall_rows<F: Fs>(db: &GraphDb<F>, topic: &str) -> RecallRows {
315 let none = |indexed: bool| RecallRows {
316 indexed,
317 terms: 0,
318 hits: Vec::new(),
319 };
320 let mut fields: Vec<String> = db.fulltext_pairs().into_iter().map(|(_, f)| f).collect();
321 fields.sort();
322 fields.dedup();
323 // Checked before the topic itself: a store that cannot search at all
324 // owes the caller that answer regardless of what they typed, including
325 // an empty topic — "no text index" is the fix either way, "no match" is
326 // not.
327 if fields.is_empty() {
328 return none(false);
329 }
330 let topic = topic.trim();
331 if topic.is_empty() {
332 return none(true);
333 }
334
335 let (terms, query) = topic_query(topic);
336 if query.is_empty() {
337 return none(true);
338 }
339
340 let mut best: BTreeMap<String, f64> = BTreeMap::new();
341 for field in &fields {
342 for (key, score) in db.search_hybrid(field, &query, "embedding", &[], None, MAX_HITS) {
343 let slot = best.entry(key).or_insert(0.0);
344 if score > *slot {
345 *slot = score;
346 }
347 }
348 }
349
350 // Relevance floor. An OR query returns something the moment any one of
351 // its terms matches anywhere, and the fused score `search_hybrid` returns
352 // cannot be used to tell a real hit from an accident: RRF replaces every
353 // BM25 score with `1/(60 + rank)`, so the top hit of any non-empty query
354 // scores exactly 1/61 whether it satisfied one word of a five-word topic
355 // or all five.
356 //
357 // An absolute BM25 score cutoff (the approach `repograph::recall` used,
358 // `MIN_HIT_SCORE`) does not fix this. The operative fact is not that a
359 // young memory store is small — it is that BM25's idf term grows with the
360 // *corpus size* for any term that occurs in only one document, at any N:
361 // `repograph`'s own floor test, three documents and a term in one of
362 // them, scores idf≈0.981, about twenty times its own 0.05 floor. A
363 // coincidental single-document match clears an absolute floor whether the
364 // corpus has three documents or three million, so no fixed number closes
365 // this off.
366 //
367 // Nor is it enough to ask "does this word appear anywhere in the store",
368 // checked once for the whole corpus: a three-node store where one node
369 // holds only "apple", another only "banana", another only "cherry" would
370 // pass a corpus-wide check on the topic "apple banana cherry" — every
371 // word is present *somewhere* — while no single node answers more than a
372 // third of it. Coverage has to be asked of the same thing relevance is
373 // asked of: one candidate node, not the corpus, and a candidate survives
374 // only when at least half of the topic's own terms are in *its own*
375 // set. A one-word topic is 1 of 1 — full coverage, not a special case —
376 // so every topic is held to the same rule.
377 //
378 // The bar is *half* the topic's terms, not more than half. A strict
379 // majority reads well and refuses the shape this product is made of: an
380 // entity node holds a name and a note holds the fact, so a two-word topic
381 // naming a person and an action has one word in each, and the entity the
382 // question is about scored 1 of 2 and was dropped from the answer to it.
383 // Half still refuses a *minority* — one word of three, one of five — which
384 // is what the false-positive cases actually are.
385 //
386 // And the count survives the filter, because a digest that shows it does
387 // not have to be believed: `reid — reid (1/3 terms)` is a hit a reader can
388 // discount, where an unannotated line cannot be told from a full match.
389 // RRF flattens every field's top hit to 1/(60+1), so the fused score
390 // carries almost no ranking information; coverage carries it instead.
391 //
392 // Asked of the candidate's own text, not the index: `best` already holds
393 // every candidate this call will ever consider (at most
394 // `fields.len() * MAX_HITS`, ranked by `search_hybrid` above), so coverage
395 // for each is answered by tokenizing *that node's own* declared-field
396 // values with the index's own tokenizer
397 // (`value_tokens_stemmed_with_positions`, stemmed exactly as indexing
398 // stems, so "orchards" in the text matches a topic term "orchard") and
399 // checking membership directly — O(candidates × fields × terms) reading
400 // props already in hand, not O(corpus × fields × terms) re-querying the
401 // index per term. Before this, one `recall` call ran `fields.len()` full,
402 // untruncated (`k == 0`) posting-list scans per topic term — self-declaring
403 // full-text for a caller-supplied `entities[].label` (`remember`, fix
404 // round 2) grows `fields` without bound, so this was the one thing bounded
405 // to be able to say yes to that at all. `search_top` is never called here.
406 let mut covered: BTreeMap<String, usize> = BTreeMap::new();
407 if !terms.is_empty() {
408 let stemmed_terms: Vec<String> = terms.iter().map(|t| stem(t)).collect();
409 best.retain(|key, _| {
410 let mut candidate_tokens: HashSet<String> = HashSet::new();
411 for field in &fields {
412 if let Some(value) = db.get_prop(key, field) {
413 candidate_tokens.extend(
414 value_tokens_stemmed_with_positions(&value)
415 .into_iter()
416 .map(|(tok, _)| tok),
417 );
418 }
419 }
420 let present = stemmed_terms
421 .iter()
422 .filter(|t| candidate_tokens.contains(*t))
423 .count();
424 if present * 2 >= terms.len() {
425 covered.insert(key.clone(), present);
426 true
427 } else {
428 false
429 }
430 });
431 }
432
433 // Deterministic: coverage descending, then score descending, then key
434 // ascending. Coverage leads because it is the measurement that means
435 // something — see the floor above — and the key breaks the last tie the
436 // way every other tie in this engine breaks.
437 let mut ranked: Vec<(String, usize, f64)> = best
438 .into_iter()
439 .map(|(key, score)| {
440 let present = covered.get(&key).copied().unwrap_or(0);
441 (key, present, score)
442 })
443 .collect();
444 ranked.sort_by(|a, b| {
445 b.1.cmp(&a.1)
446 .then_with(|| b.2.total_cmp(&a.2))
447 .then_with(|| a.0.cmp(&b.0))
448 });
449 ranked.truncate(MAX_HITS);
450
451 RecallRows {
452 indexed: true,
453 terms: terms.len(),
454 hits: ranked
455 .into_iter()
456 .map(|(key, covered, score)| RecallHit {
457 label: db
458 .node_ref(&key)
459 .map(|n| n.label().to_string())
460 .unwrap_or_default(),
461 summary: db.node_summary_line(&key),
462 key,
463 covered,
464 score,
465 })
466 .collect(),
467 }
468}
469
470/// [`recall_rows`], rendered as the digest an assistant reads: one header,
471/// one line per hit, inside `max_bytes`.
472pub fn recall_digest<F: Fs>(
473 db: &GraphDb<F>,
474 topic: &str,
475 store_label: &str,
476 max_bytes: usize,
477) -> RecallOutcome {
478 let rows = recall_rows(db, topic);
479 if !rows.indexed {
480 return RecallOutcome::NoIndex;
481 }
482 if rows.hits.is_empty() {
483 return RecallOutcome::NoMatch;
484 }
485 let total = rows.terms;
486
487 // The label is a caller-supplied path, and it lands in the same context
488 // as the hits, so it is held to the same rule.
489 let label = sanitize(store_label);
490
491 // Pointers are rendered first so the header can count what actually
492 // printed. The header and the elision marker are charged up front, so
493 // `max_bytes` bounds the whole digest rather than only the pointers. The
494 // reservation uses `rows.hits.len()`, an upper bound on the count the header
495 // ends up printing. The same budgeting the 0.6 code-graph digest used.
496 let reserved = header(rows.hits.len(), &label).len() + ELISION.len();
497 let Some(mut budget) = max_bytes.checked_sub(reserved) else {
498 // A pathologically long store label: nothing useful fits.
499 return RecallOutcome::NoMatch;
500 };
501 let mut lines: Vec<String> = Vec::new();
502 let mut truncated = false;
503 for hit in &rows.hits {
504 // The all-stopword fallback searched the topic's words as one AND-group,
505 // which required every word in one document — 100% coverage, a
506 // stricter gate than the half rule, not a missing one — so there
507 // are no per-term counts to report and none are printed.
508 let cover = if total == 0 {
509 String::new()
510 } else {
511 format!(" ({}/{total} terms)", hit.covered)
512 };
513 // Keys and summaries are stored content, read back into an
514 // assistant's context: a newline or an escape sequence in one must
515 // not be able to split this line or forge a header.
516 let shown = sanitize(&hit.key);
517 let line = match &hit.summary {
518 Some(summary) => format!(" {shown} — {}{cover}\n", sanitize(summary)),
519 None => format!(" {shown}{cover}\n"),
520 };
521 if line.len() > budget {
522 truncated = true;
523 break;
524 }
525 budget -= line.len();
526 lines.push(line);
527 }
528 if lines.is_empty() {
529 // Not one pointer fits the budget: a header announcing hits it cannot
530 // show would be noise, so this answers as the 0.6 digest did.
531 return RecallOutcome::NoMatch;
532 }
533
534 let mut out = header(lines.len(), &label);
535 for line in &lines {
536 out.push_str(line);
537 }
538 if truncated {
539 out.push_str(ELISION);
540 }
541 RecallOutcome::Hits(out)
542}
543
544/// The line a digest ends with when the byte budget dropped hits from it.
545const ELISION: &str = " …\n";
546
547/// The digest's first line. `count` is the number of hits printed under it,
548/// never the number that matched.
549fn header(count: usize, label: &str) -> String {
550 format!("mushroomdb recall ({count} related nodes in {label}):\n")
551}
552
553#[cfg(test)]
554mod tests {
555 use super::{is_stopword, search_terms, topic_query, MAX_QUERY_TERMS, STOPWORDS};
556 use core_storage::fulltext::parse_query;
557
558 /// The all-stopword fallback is held to the same cap as the ordinary
559 /// query, and carries none of the grammar the index's parser reads: a
560 /// `"` phrase, a leading `-` negation, a trailing `*` prefix, or an
561 /// `OR`/`AND` keyword. It stays one AND-group of plain terms however the
562 /// prompt was written.
563 #[test]
564 fn the_all_stopword_fallback_is_capped_and_carries_no_grammar() {
565 let prompt: String = STOPWORDS
566 .iter()
567 .map(|w| format!("-{w}* \"{w} OR {w}\" AND {w}* ({w}) or "))
568 .collect();
569 let (terms, query) = topic_query(&prompt);
570 assert!(terms.is_empty(), "the prompt is all stopwords: {terms:?}");
571 let words: Vec<&str> = query.split(' ').collect();
572 assert!(
573 !query.is_empty() && words.len() <= MAX_QUERY_TERMS,
574 "{} words: {query:?}",
575 words.len()
576 );
577 assert!(
578 !query.contains(['"', '-', '*', '(', ')']),
579 "grammar characters reached the query: {query:?}"
580 );
581 let groups = parse_query(&query);
582 assert_eq!(groups.len(), 1, "one AND-group, no OR: {query:?}");
583 assert_eq!(groups[0].len(), words.len(), "no word read as a keyword");
584 assert!(
585 groups[0].iter().all(|t| !t.negated && !t.prefix),
586 "{query:?}"
587 );
588 }
589
590 /// A topic term and its plural are one term: they stem alike, so a node
591 /// holding the word would otherwise count twice toward its coverage.
592 #[test]
593 fn search_terms_drops_a_word_that_stems_like_an_earlier_one() {
594 assert_eq!(search_terms("bug bugs Bugs crash"), vec!["bug", "crash"]);
595 }
596
597 /// The cap is enforced where the terms are built, so it is pinned there:
598 /// an integration test that only checks the call returns cannot fail if
599 /// the `break` is removed.
600 #[test]
601 fn search_terms_stops_at_the_cap_and_keeps_the_earliest() {
602 let long: String = (0..200).map(|i| format!("tok{i} ")).collect();
603 let terms = search_terms(&format!("{long} matthew"));
604 assert_eq!(terms.len(), MAX_QUERY_TERMS);
605 assert_eq!(terms[0], "tok0");
606 assert!(!terms.contains(&"matthew".to_string()), "{terms:?}");
607 }
608
609 /// Binding: the list stays sorted and duplicate-free, because
610 /// [`is_stopword`] binary-searches it. An out-of-order insert would
611 /// silently stop matching that word — and every other word past it —
612 /// with nothing else in the suite noticing.
613 ///
614 /// Ported from `repograph::recall`'s `the_stopword_lists_are_sorted_and_unique`:
615 /// the list came across the split (see the doc comment on [`STOPWORDS`]),
616 /// but this binding test did not, leaving the copy's own sortedness
617 /// invariant unguarded.
618 #[test]
619 fn the_stopword_list_is_sorted_and_unique() {
620 for pair in STOPWORDS.windows(2) {
621 assert!(
622 pair[0] < pair[1],
623 "STOPWORDS must be sorted and duplicate-free: {:?} then {:?}",
624 pair[0],
625 pair[1]
626 );
627 }
628 // And every word in it is actually found by the lookup that
629 // searches it.
630 for word in STOPWORDS {
631 assert!(is_stopword(word), "STOPWORDS: {word:?} is not matched");
632 }
633 assert!(
634 !is_stopword("matthew"),
635 "a subject word must stay searchable"
636 );
637 assert!(!is_stopword("recall"));
638 }
639}