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/// How many live nodes under one label [`unindexed_memory_fields`] reads, at
471/// most, looking for one that carries a field. The check runs on every
472/// `recall` tool call and every session brief, so it is bounded whatever the
473/// store holds: eight default pairs, this many property reads each.
474pub const UNINDEXED_SCAN_LIMIT: usize = 1_000;
475
476/// The memory defaults' full-text fields this store has not declared and has
477/// something to find in: each `(label, field)` of
478/// [`memory_defaults`](crate::memory_schema::memory_defaults) that
479/// `fulltext_pairs()` lacks while a live node under the label carries the
480/// field. In the defaults' order.
481///
482/// `remember` declares `Note.text`, and the name of each `entities[].label`,
483/// as it writes. So a store that never took the defaults stops being
484/// "a store with no text index" at its first note, while the entities already
485/// in it stay unsearchable by name. This is what `recall` and the session
486/// brief name from then on, with the one command that indexes them.
487///
488/// Empty on a store that took the defaults. Empty too on a store whose schema
489/// is its owner's and holds no node under a memory label: the defaults would
490/// index nothing there, so it is never told to apply them. A field no node
491/// carries is not reported, whatever the label holds.
492///
493/// Bounded, because it runs on every `recall` tool call and every session
494/// brief: for a pair whose default is undeclared it reads the first
495/// [`UNINDEXED_SCAN_LIMIT`] live nodes under the label, in id order, and stops
496/// at the first that carries the field. A field carried only by a node past
497/// that bound is not reported — the notice is advice, and it stays silent
498/// rather than read the whole label to give it.
499pub fn unindexed_memory_fields<F: Fs>(db: &GraphDb<F>) -> Vec<(String, String)> {
500 let declared = db.fulltext_pairs();
501 crate::memory_schema::memory_defaults()
502 .fulltext
503 .into_iter()
504 .filter(|pair| !declared.contains(pair))
505 .filter(|(label, field)| db.first_nodes_carry_prop(label, field, UNINDEXED_SCAN_LIMIT))
506 .collect()
507}
508
509/// `Person.name, Entity.name` — [`unindexed_memory_fields`] as a notice
510/// prints them.
511pub fn unindexed_fields_list(fields: &[(String, String)]) -> String {
512 fields
513 .iter()
514 .map(|(label, field)| format!("{label}.{field}"))
515 .collect::<Vec<_>>()
516 .join(", ")
517}
518
519/// [`recall_rows`], rendered as the digest an assistant reads: one header,
520/// one line per hit, inside `max_bytes`.
521pub fn recall_digest<F: Fs>(
522 db: &GraphDb<F>,
523 topic: &str,
524 store_label: &str,
525 max_bytes: usize,
526) -> RecallOutcome {
527 let rows = recall_rows(db, topic);
528 if !rows.indexed {
529 return RecallOutcome::NoIndex;
530 }
531 if rows.hits.is_empty() {
532 return RecallOutcome::NoMatch;
533 }
534 let total = rows.terms;
535
536 // The label is a caller-supplied path, and it lands in the same context
537 // as the hits, so it is held to the same rule.
538 let label = sanitize(store_label);
539
540 // Pointers are rendered first so the header can count what actually
541 // printed. The header and the elision marker are charged up front, so
542 // `max_bytes` bounds the whole digest rather than only the pointers. The
543 // reservation uses `rows.hits.len()`, an upper bound on the count the header
544 // ends up printing. The same budgeting the 0.6 code-graph digest used.
545 let reserved = header(rows.hits.len(), &label).len() + ELISION.len();
546 let Some(mut budget) = max_bytes.checked_sub(reserved) else {
547 // A pathologically long store label: nothing useful fits.
548 return RecallOutcome::NoMatch;
549 };
550 let mut lines: Vec<String> = Vec::new();
551 let mut truncated = false;
552 for hit in &rows.hits {
553 // The all-stopword fallback searched the topic's words as one AND-group,
554 // which required every word in one document — 100% coverage, a
555 // stricter gate than the half rule, not a missing one — so there
556 // are no per-term counts to report and none are printed.
557 let cover = if total == 0 {
558 String::new()
559 } else {
560 format!(" ({}/{total} terms)", hit.covered)
561 };
562 // Keys and summaries are stored content, read back into an
563 // assistant's context: a newline or an escape sequence in one must
564 // not be able to split this line or forge a header.
565 let shown = sanitize(&hit.key);
566 let line = match &hit.summary {
567 Some(summary) => format!(" {shown} — {}{cover}\n", sanitize(summary)),
568 None => format!(" {shown}{cover}\n"),
569 };
570 if line.len() > budget {
571 truncated = true;
572 break;
573 }
574 budget -= line.len();
575 lines.push(line);
576 }
577 if lines.is_empty() {
578 // Not one pointer fits the budget: a header announcing hits it cannot
579 // show would be noise, so this answers as the 0.6 digest did.
580 return RecallOutcome::NoMatch;
581 }
582
583 let mut out = header(lines.len(), &label);
584 for line in &lines {
585 out.push_str(line);
586 }
587 if truncated {
588 out.push_str(ELISION);
589 }
590 RecallOutcome::Hits(out)
591}
592
593/// The line a digest ends with when the byte budget dropped hits from it.
594const ELISION: &str = " …\n";
595
596/// The digest's first line. `count` is the number of hits printed under it,
597/// never the number that matched.
598fn header(count: usize, label: &str) -> String {
599 format!("mushroomdb recall ({count} related nodes in {label}):\n")
600}
601
602#[cfg(test)]
603mod tests {
604 use super::{is_stopword, search_terms, topic_query, MAX_QUERY_TERMS, STOPWORDS};
605 use core_storage::fulltext::parse_query;
606
607 /// The all-stopword fallback is held to the same cap as the ordinary
608 /// query, and carries none of the grammar the index's parser reads: a
609 /// `"` phrase, a leading `-` negation, a trailing `*` prefix, or an
610 /// `OR`/`AND` keyword. It stays one AND-group of plain terms however the
611 /// prompt was written.
612 #[test]
613 fn the_all_stopword_fallback_is_capped_and_carries_no_grammar() {
614 let prompt: String = STOPWORDS
615 .iter()
616 .map(|w| format!("-{w}* \"{w} OR {w}\" AND {w}* ({w}) or "))
617 .collect();
618 let (terms, query) = topic_query(&prompt);
619 assert!(terms.is_empty(), "the prompt is all stopwords: {terms:?}");
620 let words: Vec<&str> = query.split(' ').collect();
621 assert!(
622 !query.is_empty() && words.len() <= MAX_QUERY_TERMS,
623 "{} words: {query:?}",
624 words.len()
625 );
626 assert!(
627 !query.contains(['"', '-', '*', '(', ')']),
628 "grammar characters reached the query: {query:?}"
629 );
630 let groups = parse_query(&query);
631 assert_eq!(groups.len(), 1, "one AND-group, no OR: {query:?}");
632 assert_eq!(groups[0].len(), words.len(), "no word read as a keyword");
633 assert!(
634 groups[0].iter().all(|t| !t.negated && !t.prefix),
635 "{query:?}"
636 );
637 }
638
639 /// A topic term and its plural are one term: they stem alike, so a node
640 /// holding the word would otherwise count twice toward its coverage.
641 #[test]
642 fn search_terms_drops_a_word_that_stems_like_an_earlier_one() {
643 assert_eq!(search_terms("bug bugs Bugs crash"), vec!["bug", "crash"]);
644 }
645
646 /// The cap is enforced where the terms are built, so it is pinned there:
647 /// an integration test that only checks the call returns cannot fail if
648 /// the `break` is removed.
649 #[test]
650 fn search_terms_stops_at_the_cap_and_keeps_the_earliest() {
651 let long: String = (0..200).map(|i| format!("tok{i} ")).collect();
652 let terms = search_terms(&format!("{long} matthew"));
653 assert_eq!(terms.len(), MAX_QUERY_TERMS);
654 assert_eq!(terms[0], "tok0");
655 assert!(!terms.contains(&"matthew".to_string()), "{terms:?}");
656 }
657
658 /// Binding: the list stays sorted and duplicate-free, because
659 /// [`is_stopword`] binary-searches it. An out-of-order insert would
660 /// silently stop matching that word — and every other word past it —
661 /// with nothing else in the suite noticing.
662 ///
663 /// Ported from `repograph::recall`'s `the_stopword_lists_are_sorted_and_unique`:
664 /// the list came across the split (see the doc comment on [`STOPWORDS`]),
665 /// but this binding test did not, leaving the copy's own sortedness
666 /// invariant unguarded.
667 #[test]
668 fn the_stopword_list_is_sorted_and_unique() {
669 for pair in STOPWORDS.windows(2) {
670 assert!(
671 pair[0] < pair[1],
672 "STOPWORDS must be sorted and duplicate-free: {:?} then {:?}",
673 pair[0],
674 pair[1]
675 );
676 }
677 // And every word in it is actually found by the lookup that
678 // searches it.
679 for word in STOPWORDS {
680 assert!(is_stopword(word), "STOPWORDS: {word:?} is not matched");
681 }
682 assert!(
683 !is_stopword("matthew"),
684 "a subject word must stay searchable"
685 );
686 assert!(!is_stopword("recall"));
687 }
688}