Skip to main content

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}