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/// [`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}