use crate::digest::sanitize;
use crate::GraphDb;
use core_storage::fs::Fs;
use core_storage::fulltext::{stem, value_tokens_stemmed_with_positions};
use std::collections::{BTreeMap, HashSet};
const MAX_HITS: usize = 6;
pub const MAX_QUERY_TERMS: usize = 24;
const STOPWORDS: [&str; 146] = [
"a",
"about",
"after",
"again",
"all",
"also",
"am",
"an",
"and",
"any",
"anything",
"are",
"as",
"at",
"back",
"be",
"because",
"been",
"before",
"being",
"below",
"between",
"both",
"but",
"by",
"can",
"cannot",
"could",
"did",
"do",
"does",
"doing",
"done",
"down",
"during",
"each",
"either",
"else",
"even",
"ever",
"every",
"few",
"for",
"from",
"further",
"had",
"has",
"have",
"having",
"he",
"her",
"here",
"hers",
"him",
"his",
"how",
"i",
"if",
"in",
"into",
"is",
"it",
"its",
"itself",
"just",
"know",
"let",
"like",
"may",
"maybe",
"me",
"might",
"more",
"most",
"much",
"must",
"my",
"need",
"no",
"nor",
"not",
"now",
"of",
"off",
"ok",
"okay",
"on",
"once",
"one",
"only",
"or",
"other",
"our",
"out",
"over",
"own",
"please",
"same",
"she",
"should",
"so",
"some",
"something",
"such",
"sure",
"tell",
"than",
"thanks",
"that",
"the",
"their",
"them",
"then",
"there",
"these",
"they",
"think",
"this",
"those",
"through",
"to",
"too",
"under",
"until",
"up",
"us",
"very",
"want",
"was",
"we",
"were",
"what",
"when",
"where",
"which",
"while",
"who",
"whom",
"why",
"will",
"with",
"would",
"yes",
"you",
"your",
"yours",
];
fn is_stopword(term: &str) -> bool {
STOPWORDS.binary_search(&term).is_ok()
}
fn search_terms(topic: &str) -> Vec<String> {
let mut terms: Vec<String> = Vec::new();
let mut stems: HashSet<String> = HashSet::new();
for word in topic.split(|c: char| !c.is_alphanumeric()) {
if word.is_empty() {
continue;
}
let term = word.to_lowercase();
if is_stopword(&term) || !stems.insert(stem(&term)) {
continue;
}
terms.push(term);
if terms.len() == MAX_QUERY_TERMS {
break;
}
}
terms
}
fn fallback_words(topic: &str) -> Vec<String> {
let mut words: Vec<String> = Vec::new();
for word in topic.split(|c: char| !c.is_alphanumeric()) {
let word = word.to_lowercase();
if word.is_empty() || word == "or" || word == "and" || words.contains(&word) {
continue;
}
words.push(word);
if words.len() == MAX_QUERY_TERMS {
break;
}
}
words
}
fn topic_query(topic: &str) -> (Vec<String>, String) {
let terms = search_terms(topic);
let query = if terms.is_empty() {
fallback_words(topic).join(" ")
} else {
terms.join(" OR ")
};
(terms, query)
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct RecallHit {
pub key: String,
pub label: String,
pub summary: Option<String>,
pub covered: usize,
pub score: f64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct RecallRows {
pub indexed: bool,
pub terms: usize,
pub hits: Vec<RecallHit>,
}
#[derive(Debug)]
pub enum RecallOutcome {
NoIndex,
NoMatch,
Hits(String),
}
pub fn recall_rows<F: Fs>(db: &GraphDb<F>, topic: &str) -> RecallRows {
let none = |indexed: bool| RecallRows {
indexed,
terms: 0,
hits: Vec::new(),
};
let mut fields: Vec<String> = db.fulltext_pairs().into_iter().map(|(_, f)| f).collect();
fields.sort();
fields.dedup();
if fields.is_empty() {
return none(false);
}
let topic = topic.trim();
if topic.is_empty() {
return none(true);
}
let (terms, query) = topic_query(topic);
if query.is_empty() {
return none(true);
}
let mut best: BTreeMap<String, f64> = BTreeMap::new();
for field in &fields {
for (key, score) in db.search_hybrid(field, &query, "embedding", &[], None, MAX_HITS) {
let slot = best.entry(key).or_insert(0.0);
if score > *slot {
*slot = score;
}
}
}
let mut covered: BTreeMap<String, usize> = BTreeMap::new();
if !terms.is_empty() {
let stemmed_terms: Vec<String> = terms.iter().map(|t| stem(t)).collect();
best.retain(|key, _| {
let mut candidate_tokens: HashSet<String> = HashSet::new();
for field in &fields {
if let Some(value) = db.get_prop(key, field) {
candidate_tokens.extend(
value_tokens_stemmed_with_positions(&value)
.into_iter()
.map(|(tok, _)| tok),
);
}
}
let present = stemmed_terms
.iter()
.filter(|t| candidate_tokens.contains(*t))
.count();
if present * 2 >= terms.len() {
covered.insert(key.clone(), present);
true
} else {
false
}
});
}
let mut ranked: Vec<(String, usize, f64)> = best
.into_iter()
.map(|(key, score)| {
let present = covered.get(&key).copied().unwrap_or(0);
(key, present, score)
})
.collect();
ranked.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.2.total_cmp(&a.2))
.then_with(|| a.0.cmp(&b.0))
});
ranked.truncate(MAX_HITS);
RecallRows {
indexed: true,
terms: terms.len(),
hits: ranked
.into_iter()
.map(|(key, covered, score)| RecallHit {
label: db
.node_ref(&key)
.map(|n| n.label().to_string())
.unwrap_or_default(),
summary: db.node_summary_line(&key),
key,
covered,
score,
})
.collect(),
}
}
pub const UNINDEXED_SCAN_LIMIT: usize = 1_000;
pub fn unindexed_memory_fields<F: Fs>(db: &GraphDb<F>) -> Vec<(String, String)> {
let declared = db.fulltext_pairs();
crate::memory_schema::memory_defaults()
.fulltext
.into_iter()
.filter(|pair| !declared.contains(pair))
.filter(|(label, field)| db.first_nodes_carry_prop(label, field, UNINDEXED_SCAN_LIMIT))
.collect()
}
pub fn unindexed_fields_list(fields: &[(String, String)]) -> String {
fields
.iter()
.map(|(label, field)| format!("{label}.{field}"))
.collect::<Vec<_>>()
.join(", ")
}
pub fn recall_digest<F: Fs>(
db: &GraphDb<F>,
topic: &str,
store_label: &str,
max_bytes: usize,
) -> RecallOutcome {
let rows = recall_rows(db, topic);
if !rows.indexed {
return RecallOutcome::NoIndex;
}
if rows.hits.is_empty() {
return RecallOutcome::NoMatch;
}
let total = rows.terms;
let label = sanitize(store_label);
let reserved = header(rows.hits.len(), &label).len() + ELISION.len();
let Some(mut budget) = max_bytes.checked_sub(reserved) else {
return RecallOutcome::NoMatch;
};
let mut lines: Vec<String> = Vec::new();
let mut truncated = false;
for hit in &rows.hits {
let cover = if total == 0 {
String::new()
} else {
format!(" ({}/{total} terms)", hit.covered)
};
let shown = sanitize(&hit.key);
let line = match &hit.summary {
Some(summary) => format!(" {shown} — {}{cover}\n", sanitize(summary)),
None => format!(" {shown}{cover}\n"),
};
if line.len() > budget {
truncated = true;
break;
}
budget -= line.len();
lines.push(line);
}
if lines.is_empty() {
return RecallOutcome::NoMatch;
}
let mut out = header(lines.len(), &label);
for line in &lines {
out.push_str(line);
}
if truncated {
out.push_str(ELISION);
}
RecallOutcome::Hits(out)
}
const ELISION: &str = " …\n";
fn header(count: usize, label: &str) -> String {
format!("mushroomdb recall ({count} related nodes in {label}):\n")
}
#[cfg(test)]
mod tests {
use super::{is_stopword, search_terms, topic_query, MAX_QUERY_TERMS, STOPWORDS};
use core_storage::fulltext::parse_query;
#[test]
fn the_all_stopword_fallback_is_capped_and_carries_no_grammar() {
let prompt: String = STOPWORDS
.iter()
.map(|w| format!("-{w}* \"{w} OR {w}\" AND {w}* ({w}) or "))
.collect();
let (terms, query) = topic_query(&prompt);
assert!(terms.is_empty(), "the prompt is all stopwords: {terms:?}");
let words: Vec<&str> = query.split(' ').collect();
assert!(
!query.is_empty() && words.len() <= MAX_QUERY_TERMS,
"{} words: {query:?}",
words.len()
);
assert!(
!query.contains(['"', '-', '*', '(', ')']),
"grammar characters reached the query: {query:?}"
);
let groups = parse_query(&query);
assert_eq!(groups.len(), 1, "one AND-group, no OR: {query:?}");
assert_eq!(groups[0].len(), words.len(), "no word read as a keyword");
assert!(
groups[0].iter().all(|t| !t.negated && !t.prefix),
"{query:?}"
);
}
#[test]
fn search_terms_drops_a_word_that_stems_like_an_earlier_one() {
assert_eq!(search_terms("bug bugs Bugs crash"), vec!["bug", "crash"]);
}
#[test]
fn search_terms_stops_at_the_cap_and_keeps_the_earliest() {
let long: String = (0..200).map(|i| format!("tok{i} ")).collect();
let terms = search_terms(&format!("{long} matthew"));
assert_eq!(terms.len(), MAX_QUERY_TERMS);
assert_eq!(terms[0], "tok0");
assert!(!terms.contains(&"matthew".to_string()), "{terms:?}");
}
#[test]
fn the_stopword_list_is_sorted_and_unique() {
for pair in STOPWORDS.windows(2) {
assert!(
pair[0] < pair[1],
"STOPWORDS must be sorted and duplicate-free: {:?} then {:?}",
pair[0],
pair[1]
);
}
for word in STOPWORDS {
assert!(is_stopword(word), "STOPWORDS: {word:?} is not matched");
}
assert!(
!is_stopword("matthew"),
"a subject word must stay searchable"
);
assert!(!is_stopword("recall"));
}
}