use std::collections::{HashMap, HashSet};
use super::{DurableMemoryStatus, LexicalIndexItem};
const K1: f64 = 1.2;
const B: f64 = 0.75;
const W_TITLE: f64 = 3.0;
const W_KEYWORD: f64 = 2.5;
const W_TAG: f64 = 2.0;
const W_ENTITY: f64 = 1.5;
const W_SUMMARY: f64 = 1.0;
const STALE_MULTIPLIER: f64 = 0.5;
const MIN_LATIN_LEN: usize = 2;
pub(super) fn tokenize(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut latin = String::new();
let mut cjk: Vec<char> = Vec::new();
for ch in text.chars() {
if is_cjk(ch) {
flush_latin(&mut latin, &mut tokens);
cjk.push(ch);
} else if ch.is_alphanumeric() {
flush_cjk(&mut cjk, &mut tokens);
latin.extend(ch.to_lowercase());
} else {
flush_latin(&mut latin, &mut tokens);
flush_cjk(&mut cjk, &mut tokens);
}
}
flush_latin(&mut latin, &mut tokens);
flush_cjk(&mut cjk, &mut tokens);
tokens
}
fn flush_latin(latin: &mut String, tokens: &mut Vec<String>) {
if latin.chars().count() >= MIN_LATIN_LEN {
tokens.push(std::mem::take(latin));
} else {
latin.clear();
}
}
fn flush_cjk(cjk: &mut Vec<char>, tokens: &mut Vec<String>) {
match cjk.len() {
0 => {}
1 => tokens.push(cjk[0].to_string()),
_ => {
for pair in cjk.windows(2) {
tokens.push(pair.iter().collect());
}
}
}
cjk.clear();
}
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0xF900..=0xFAFF | 0x3040..=0x30FF | 0xAC00..=0xD7AF )
}
struct DocBag {
scorable: bool,
status: DurableMemoryStatus,
tf: HashMap<String, f64>,
dl: f64,
embedding: Option<Vec<f32>>,
}
pub(super) struct Bm25Corpus {
docs: Vec<DocBag>, df: HashMap<String, usize>,
avgdl: f64,
n: usize,
}
impl Bm25Corpus {
pub(super) fn build(items: &[LexicalIndexItem]) -> Self {
let mut docs = Vec::with_capacity(items.len());
let mut df: HashMap<String, usize> = HashMap::new();
let mut total_dl = 0.0;
let mut n = 0usize;
for item in items {
let scorable = matches!(
item.status,
DurableMemoryStatus::Active | DurableMemoryStatus::Stale
);
if !scorable {
docs.push(DocBag {
scorable,
status: item.status,
tf: HashMap::new(),
dl: 0.0,
embedding: None,
});
continue;
}
let mut tf: HashMap<String, f64> = HashMap::new();
add_field(&mut tf, &item.title, W_TITLE);
for kw in &item.keywords {
add_field(&mut tf, kw, W_KEYWORD);
}
for tag in &item.tags {
add_field(&mut tf, tag, W_TAG);
}
for ent in &item.entities {
add_field(&mut tf, ent, W_ENTITY);
}
add_field(&mut tf, &item.summary, W_SUMMARY);
let dl: f64 = tf.values().sum();
for term in tf.keys() {
*df.entry(term.clone()).or_insert(0) += 1;
}
total_dl += dl;
n += 1;
docs.push(DocBag {
scorable,
status: item.status,
tf,
dl,
embedding: item.embedding.clone(),
});
}
let avgdl = if n > 0 { total_dl / n as f64 } else { 0.0 };
Bm25Corpus { docs, df, avgdl, n }
}
pub(super) fn score_hybrid(
&self,
index: usize,
query_tokens: &[String],
query_vec: Option<&[f32]>,
cosine_weight: f64,
) -> Option<f64> {
let doc = self.docs.get(index)?;
if !doc.scorable || self.n == 0 {
return None;
}
let mut bm25 = 0.0;
let mut lexical_matched = false;
let mut seen = HashSet::new();
for token in query_tokens {
if !seen.insert(token.as_str()) {
continue;
}
let Some(&tf) = doc.tf.get(token) else {
continue;
};
let df = *self.df.get(token).unwrap_or(&0);
if df == 0 {
continue;
}
let idf = (((self.n as f64 - df as f64 + 0.5) / (df as f64 + 0.5)) + 1.0).ln();
let denom = tf + K1 * (1.0 - B + B * (doc.dl / self.avgdl.max(f64::EPSILON)));
bm25 += idf * (tf * (K1 + 1.0)) / denom.max(f64::EPSILON);
lexical_matched = true;
}
let cos = match (query_vec, doc.embedding.as_deref()) {
(Some(q), Some(d)) => super::embedding::cosine(q, d),
_ => 0.0,
};
let semantic_matched = cos >= super::embedding::SEMANTIC_FLOOR;
if !lexical_matched && !semantic_matched {
return None;
}
let mut score = bm25 + cosine_weight * cos.max(0.0);
if matches!(doc.status, DurableMemoryStatus::Stale) {
score *= STALE_MULTIPLIER;
}
Some((score * 1000.0).round() / 1000.0)
}
pub(super) fn score(&self, index: usize, query_tokens: &[String]) -> Option<f64> {
self.score_hybrid(index, query_tokens, None, 0.0)
}
}
fn add_field(tf: &mut HashMap<String, f64>, text: &str, weight: f64) {
for tok in tokenize(text) {
*tf.entry(tok).or_insert(0.0) += weight;
}
}
pub(super) fn field_weighted_bag(
title: &str,
keywords: &[String],
tags: &[String],
entities: &[String],
summary: &str,
) -> HashMap<String, f64> {
let mut tf: HashMap<String, f64> = HashMap::new();
add_field(&mut tf, title, W_TITLE);
for kw in keywords {
add_field(&mut tf, kw, W_KEYWORD);
}
for tag in tags {
add_field(&mut tf, tag, W_TAG);
}
for ent in entities {
add_field(&mut tf, ent, W_ENTITY);
}
add_field(&mut tf, summary, W_SUMMARY);
tf
}
pub(super) struct SimilarityCorpus {
df: HashMap<String, usize>,
n: usize,
}
impl SimilarityCorpus {
pub(super) fn build(bags: &[&HashMap<String, f64>]) -> Self {
let mut df: HashMap<String, usize> = HashMap::new();
for bag in bags {
for term in bag.keys() {
*df.entry(term.clone()).or_insert(0) += 1;
}
}
SimilarityCorpus { df, n: bags.len() }
}
fn idf(&self, term: &str) -> f64 {
let df = (*self.df.get(term).unwrap_or(&1)).max(1) as f64;
(1.0 + self.n as f64 / df).ln()
}
pub(super) fn cosine(&self, a: &HashMap<String, f64>, b: &HashMap<String, f64>) -> f64 {
let mut dot = 0.0f64;
let mut norm_a = 0.0f64;
let mut norm_b = 0.0f64;
for (term, &wa) in a {
let idf = self.idf(term);
let va = wa * idf;
norm_a += va * va;
if let Some(&wb) = b.get(term) {
dot += va * (wb * idf);
}
}
for (term, &wb) in b {
let vb = wb * self.idf(term);
norm_b += vb * vb;
}
let denom = norm_a.sqrt() * norm_b.sqrt();
if denom <= f64::EPSILON {
0.0
} else {
(dot / denom).clamp(0.0, 1.0)
}
}
}
#[cfg(test)]
mod tests {
use super::super::{DurableMemoryStatus, DurableMemoryType, LexicalIndexItem, MemoryScope};
use super::{tokenize, Bm25Corpus};
fn item(
id: &str,
status: DurableMemoryStatus,
title: &str,
keywords: &[&str],
) -> LexicalIndexItem {
LexicalIndexItem {
id: id.to_string(),
title: title.to_string(),
scope: MemoryScope::Global,
project_key: None,
r#type: DurableMemoryType::Reference,
status,
tags: Vec::new(),
keywords: keywords.iter().map(|s| s.to_string()).collect(),
entities: Vec::new(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
summary: String::new(),
granularity: None,
embedding: None,
}
}
#[test]
fn similarity_discounts_common_terms_via_idf() {
use super::{field_weighted_bag, SimilarityCorpus};
let bag = |title: &str, body: &str| field_weighted_bag(title, &[], &[], &[], body);
let d = bag(
"blue green deploy soak",
"production deploy blue green ten minute soak window",
);
let e = bag(
"blue green deploy soak window",
"production deploy blue green ten minute soak cutover",
);
let f = bag(
"kafka consumer lag alerts",
"kafka consumer lag alerts fire when we deploy",
);
let g = bag(
"postgres autovacuum tuning",
"postgres autovacuum thresholds tuned per deploy",
);
let corpus = SimilarityCorpus::build(&[&d, &e, &f, &g]);
let near = corpus.cosine(&d, &e);
let common_only = corpus.cosine(&f, &g);
assert!(near > 0.6, "near-identical should score high, got {near}");
assert!(
common_only < 0.2,
"docs sharing only a common term should score low, got {common_only}"
);
assert!(near > common_only);
}
#[test]
fn similarity_is_symmetric_bounded_and_self_is_one() {
use super::{field_weighted_bag, SimilarityCorpus};
let a = field_weighted_bag("alpha beta", &[], &[], &[], "alpha beta gamma");
let b = field_weighted_bag("beta gamma", &[], &[], &[], "beta gamma delta");
let corpus = SimilarityCorpus::build(&[&a, &b]);
let ab = corpus.cosine(&a, &b);
assert!((ab - corpus.cosine(&b, &a)).abs() < 1e-9, "symmetric");
assert!((0.0..=1.0).contains(&ab), "bounded, got {ab}");
assert!((corpus.cosine(&a, &a) - 1.0).abs() < 1e-9, "self == 1.0");
assert_eq!(corpus.cosine(&a, &std::collections::HashMap::new()), 0.0);
}
#[test]
fn tokenize_splits_latin_words_lowercased() {
assert_eq!(
tokenize("Hello World-Foo_bar"),
vec!["hello", "world", "foo", "bar"]
);
assert_eq!(tokenize("gpt4 a x9"), vec!["gpt4", "x9"]);
}
#[test]
fn tokenize_cjk_produces_char_bigrams() {
assert_eq!(tokenize("多租户"), vec!["多租", "租户"]);
assert_eq!(tokenize("的"), vec!["的"]);
}
#[test]
fn tokenize_mixes_cjk_and_latin() {
assert_eq!(
tokenize("bamboo 多租户 SDK"),
vec!["bamboo", "多租", "租户", "sdk"]
);
}
#[test]
fn chinese_query_recalls_a_chinese_memory() {
let items = vec![
item(
"zh",
DurableMemoryStatus::Active,
"多租户隔离设计",
&["多租户", "隔离"],
),
item(
"en",
DurableMemoryStatus::Active,
"unrelated english note",
&["cache"],
),
];
let corpus = Bm25Corpus::build(&items);
let q = tokenize("多租户");
assert!(!q.is_empty(), "chinese query must tokenize to non-empty");
assert!(
corpus.score(0, &q).is_some(),
"chinese memory must be recalled"
);
assert!(
corpus.score(1, &q).is_none(),
"unrelated memory must not match"
);
}
#[test]
fn rarer_term_outscores_common_term_via_idf() {
let items = vec![
item("a", DurableMemoryStatus::Active, "", &["cache", "guardian"]),
item("b", DurableMemoryStatus::Active, "", &["cache"]),
item("c", DurableMemoryStatus::Active, "", &["cache"]),
];
let corpus = Bm25Corpus::build(&items);
let rare = corpus.score(0, &tokenize("guardian")).unwrap();
let common = corpus.score(1, &tokenize("cache")).unwrap();
assert!(
rare > common,
"a rare-term match should outscore a common-term match (IDF)"
);
}
#[test]
fn title_match_outranks_keyword_only_match() {
let items = vec![
item("t", DurableMemoryStatus::Active, "guardian", &[]),
item("k", DurableMemoryStatus::Active, "", &["guardian"]),
];
let corpus = Bm25Corpus::build(&items);
let title = corpus.score(0, &tokenize("guardian")).unwrap();
let keyword = corpus.score(1, &tokenize("guardian")).unwrap();
assert!(
title > keyword,
"title match ({title}) should outrank keyword-only ({keyword})"
);
}
#[test]
fn active_outranks_stale_for_equal_relevance() {
let items = vec![
item(
"a",
DurableMemoryStatus::Active,
"guardian resume",
&["guardian"],
),
item(
"s",
DurableMemoryStatus::Stale,
"guardian resume",
&["guardian"],
),
];
let corpus = Bm25Corpus::build(&items);
let active = corpus.score(0, &tokenize("guardian")).unwrap();
let stale = corpus.score(1, &tokenize("guardian")).unwrap();
assert!(
active > stale,
"active ({active}) must outrank equally-relevant stale ({stale})"
);
}
#[test]
fn superseded_and_archived_never_score() {
let items = vec![
item(
"x",
DurableMemoryStatus::Superseded,
"guardian",
&["guardian"],
),
item(
"y",
DurableMemoryStatus::Archived,
"guardian",
&["guardian"],
),
item(
"z",
DurableMemoryStatus::Contradicted,
"guardian",
&["guardian"],
),
];
let corpus = Bm25Corpus::build(&items);
let q = tokenize("guardian");
assert!(corpus.score(0, &q).is_none());
assert!(corpus.score(1, &q).is_none());
assert!(corpus.score(2, &q).is_none());
}
#[test]
fn chinese_recall_works_from_title_and_summary_without_chinese_keywords() {
let mut zh = item("zh", DurableMemoryStatus::Active, "多租户隔离设计", &[]);
zh.summary = "外层编排每租户一实例的方案".to_string();
let other = item(
"en",
DurableMemoryStatus::Active,
"cache architecture",
&["cache"],
);
let corpus = Bm25Corpus::build(&[zh, other]);
assert!(
corpus.score(0, &tokenize("多租户")).is_some(),
"title-only Chinese match"
);
assert!(
corpus.score(0, &tokenize("每租户")).is_some(),
"summary-only Chinese match"
);
assert!(corpus.score(1, &tokenize("多租户")).is_none());
}
#[test]
fn hybrid_surfaces_a_paraphrase_via_cosine_when_lexical_misses() {
let mut sem = item(
"sem",
DurableMemoryStatus::Active,
"guardian resume design",
&["guardian"],
);
sem.embedding = Some(vec![1.0, 0.0, 0.0]);
let mut orth = item(
"orth",
DurableMemoryStatus::Active,
"cache prefix stability",
&["cache"],
);
orth.embedding = Some(vec![0.0, 1.0, 0.0]);
let corpus = Bm25Corpus::build(&[sem, orth]);
let q = tokenize("hibernation checkpoint"); let qvec = [0.9_f32, 0.05, 0.0]; let w = super::super::embedding::HYBRID_COSINE_WEIGHT;
assert!(corpus.score(0, &q).is_none());
assert!(
corpus.score_hybrid(0, &q, Some(&qvec), w).is_some(),
"a cosine-close paraphrase must surface even with no lexical overlap"
);
assert!(
corpus.score_hybrid(1, &q, Some(&qvec), w).is_none(),
"an orthogonal-embedding doc stays below the semantic floor"
);
}
#[test]
fn hybrid_with_no_query_vector_equals_pure_bm25() {
let mut d = item(
"d",
DurableMemoryStatus::Active,
"guardian resume",
&["guardian"],
);
d.embedding = Some(vec![1.0, 0.0]); let corpus = Bm25Corpus::build(&[d]);
let q = tokenize("guardian");
let w = super::super::embedding::HYBRID_COSINE_WEIGHT;
assert_eq!(corpus.score_hybrid(0, &q, None, w), corpus.score(0, &q));
}
}