use super::*;
use crate::bm25::bm25_score;
fn seg() -> TextSegment {
let mut s = TextSegment::new();
s.apply(b"d1", Some("rust full text search engine".as_bytes()));
s.apply(b"d2", Some("rust systems programming".as_bytes()));
s.apply(b"d3", Some("全文检索引擎 rust 実装".as_bytes()));
s
}
#[test]
fn ranked_or_semantics() {
let s = seg();
let hits = s.matches(b"rust search", 10);
assert_eq!(hits.len(), 3, "OR semantics: every rust doc matches");
assert_eq!(hits[0].key, b"d1".to_vec(), "d1 matches both terms → top");
let hits = s.matches(b"programming", 10);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key, b"d2".to_vec());
}
#[test]
fn cjk_query_bigrams() {
let s = seg();
let hits = s.matches("检索".as_bytes(), 10);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key, b"d3".to_vec());
assert!(s.matches("数据库".as_bytes(), 10).is_empty());
}
#[test]
fn update_and_remove() {
let mut s = seg();
s.apply(b"d1", Some(b"totally different now"));
assert!(s.matches(b"engine", 10).is_empty(), "old tokens gone");
assert_eq!(s.matches(b"different", 10)[0].key, b"d1".to_vec());
s.apply(b"d2", None);
assert!(!s.contains(b"d2"));
assert!(s.matches(b"programming", 10).is_empty());
let st = s.stats();
assert_eq!(st.docs, 2);
assert!(st.tokens > 0 && st.approx_bytes > 0);
}
#[test]
fn maxscore_pruning_matches_naive() {
let mut s = TextSegment::new();
for i in 0..500u32 {
let mut body = String::from("common filler words here");
if i % 5 == 0 {
body.push_str(" mid");
}
if i == 42 || i == 99 {
body.push_str(" rare");
}
for _ in 0..(i % 7) {
body.push_str(" pad");
}
s.apply(format!("k{i:03}").as_bytes(), Some(body.as_bytes()));
}
let naive = |query: &str, limit: usize| -> Vec<(Vec<u8>, f64)> {
let q = tokenize(query.as_bytes());
let n_docs = s.docs.len() as f64;
let avgdl = s.total_len as f64 / n_docs;
let mut sc: HashMap<Vec<u8>, f64> = HashMap::new();
for t in &q {
let Some(list) = s.postings.get(t) else { continue };
let df = list.len() as f64;
for (tf, bands) in list.tf_groups() {
for (_b, band) in bands.iter() {
for &id in band {
let k = s.id_key[id as usize].clone().expect("live id");
let dl = f64::from(s.id_dl[id as usize]);
*sc.entry(k).or_insert(0.0) +=
bm25_score(f64::from(tf), df, n_docs, dl, avgdl);
}
}
}
}
let mut v: Vec<(Vec<u8>, f64)> = sc.into_iter().collect();
v.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
v.truncate(limit);
v
};
for (q, limit) in [("rare common", 10), ("mid common", 5), ("rare mid common", 3), ("common", 7)] {
let got: Vec<(Vec<u8>, f64)> =
s.matches(q.as_bytes(), limit).into_iter().map(|m| (m.key, m.score)).collect();
let want = naive(q, limit);
assert_eq!(got, want, "query {q:?} limit {limit}");
}
}
#[test]
fn bucket_stop_keeps_walked_doc_contributions() {
let mut s = TextSegment::new();
for i in 0..2000u32 {
s.apply(format!("c{i:04}").as_bytes(), Some(b"common common"));
}
s.apply(b"special", Some(b"rare common pad pad pad"));
let naive_ok = {
let hits = s.matches(b"rare common", 5);
hits[0].key == b"special".to_vec()
};
assert!(naive_ok);
let mut s2 = TextSegment::new();
for i in 0..2000u32 {
s2.apply(format!("c{i:04}").as_bytes(), Some(b"common common"));
}
s2.apply(b"special", Some(b"rare only pad pad pad"));
let with_common = s.matches(b"rare common", 1)[0].score;
let without_common = s2.matches(b"rare common", 1)[0].score;
assert!(
with_common > without_common + 1e-9,
"skipped-bucket contribution lost: {with_common} vs {without_common}"
);
}
#[test]
fn limit_and_empty_query() {
let s = seg();
assert_eq!(s.matches(b"rust", 2).len(), 2);
assert!(s.matches(b"", 10).is_empty());
assert!(s.matches(b"!!!", 10).is_empty());
}
#[test]
fn limit_zero_is_empty() {
let s = seg();
assert!(s.matches(b"rust", 0).is_empty());
assert!(s.matches(b"rust search engine", 0).is_empty());
assert!(s.matches(b"", 0).is_empty());
assert!(TextSegment::new().matches(b"rust", 0).is_empty());
}
#[test]
fn a_weighted_field_outranks_the_same_term_in_a_plain_one() {
let mut s = TextSegment::new();
s.apply_fields(b"doc:title", Some(&[(b"rust".to_vec(), 3.0), (b"filler text here".to_vec(), 1.0)]));
s.apply_fields(b"doc:body", Some(&[(b"other".to_vec(), 3.0), (b"rust filler text".to_vec(), 1.0)]));
let hits = s.matches(b"rust", 10);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].key, b"doc:title".to_vec(), "the weighted field must rank first");
assert!(hits[0].score > hits[1].score);
}
#[test]
fn document_length_is_summed_unweighted() {
let mut heavy = TextSegment::new();
heavy.apply_fields(b"d", Some(&[(b"alpha beta".to_vec(), 5.0)]));
let mut plain = TextSegment::new();
plain.apply_fields(b"d", Some(&[(b"alpha beta".to_vec(), 1.0)]));
assert_eq!(heavy.stats().docs, plain.stats().docs);
assert_eq!(heavy.stats().tokens, plain.stats().tokens);
}
#[test]
fn a_weighted_document_is_removed_exactly() {
let mut s = TextSegment::new();
s.apply_fields(b"d", Some(&[(b"alpha".to_vec(), 4.0), (b"beta".to_vec(), 2.0)]));
assert_eq!(s.matches(b"alpha", 10).len(), 1);
s.apply_fields(b"d", None);
assert!(s.matches(b"alpha", 10).is_empty(), "alpha must not survive its document");
assert!(s.matches(b"beta", 10).is_empty(), "beta must not survive either");
assert_eq!(s.stats().tokens, 0, "no posting list may be left behind");
assert_eq!(s.stats().postings, 0);
}
#[test]
fn reindexing_at_a_new_weight_replaces_rather_than_accumulates() {
let mut s = TextSegment::new();
s.apply_fields(b"d", Some(&[(b"alpha".to_vec(), 5.0)]));
let before = s.stats();
s.apply_fields(b"d", Some(&[(b"alpha".to_vec(), 1.0)]));
let after = s.stats();
assert_eq!(after.docs, 1);
assert_eq!(after.postings, before.postings, "one document, one posting");
}
#[test]
fn a_fractional_weight_never_rounds_a_hit_away() {
let mut s = TextSegment::new();
s.apply_fields(b"d", Some(&[(b"rare".to_vec(), 0.1)]));
assert_eq!(s.matches(b"rare", 10).len(), 1, "0.1 weight must not erase the term");
}
#[test]
fn apply_is_exactly_a_neutral_single_field() {
let mut sugar = TextSegment::new();
sugar.apply(b"d", Some(b"alpha beta alpha"));
let mut explicit = TextSegment::new();
explicit.apply_fields(b"d", Some(&[(b"alpha beta alpha".to_vec(), 1.0)]));
let a = sugar.matches(b"alpha", 10);
let b = explicit.matches(b"alpha", 10);
assert_eq!(a.len(), b.len());
assert_eq!(a[0].score, b[0].score);
assert_eq!(sugar.stats().approx_bytes, explicit.stats().approx_bytes);
}
use std::collections::HashMap;
fn global_of(segments: &[&TextSegment], q_tokens: &[&[u8]]) -> CorpusStats {
let n_docs: f64 = segments.iter().map(|s| s.stats().docs as f64).sum();
let total_len: f64 = segments.iter().map(|s| s.total_len() as f64).sum();
let mut df = HashMap::new();
for t in q_tokens {
let d: u32 = segments.iter().map(|s| s.local_df(t)).sum();
df.insert(t.to_vec(), d);
}
CorpusStats { n_docs, avgdl: total_len / n_docs, df }
}
#[test]
fn global_stats_make_split_and_whole_score_identically() {
let docs: &[(&[u8], &str)] = &[
(b"d1", "rust systems programming language rust"),
(b"d2", "kevy pure rust key value store"),
(b"d3", "the quick brown fox jumps"),
(b"d4", "rust memory safety without garbage collection"),
(b"d5", "a document with no query terms at all here"),
(b"d6", "rust rust rust and more rust"),
];
let mut whole = TextSegment::new();
for (k, t) in docs {
whole.apply(k, Some(t.as_bytes()));
}
let mut a = TextSegment::new();
let mut b = TextSegment::new();
for (i, (k, t)) in docs.iter().enumerate() {
if i % 2 == 0 { &mut a } else { &mut b }.apply(k, Some(t.as_bytes()));
}
let q: &[&[u8]] = &[b"rust"];
let g_whole = global_of(&[&whole], q);
let g_split = global_of(&[&a, &b], q);
assert_eq!(g_whole.n_docs, g_split.n_docs);
assert_eq!(g_whole.df.get(b"rust".as_slice()), g_split.df.get(b"rust".as_slice()));
let whole_hits = whole.matches_scored(b"rust", 10, Some(&g_whole));
let mut split_hits = a.matches_scored(b"rust", 10, Some(&g_split));
split_hits.extend(b.matches_scored(b"rust", 10, Some(&g_split)));
split_hits.sort_by(|x, y| y.score.total_cmp(&x.score).then_with(|| x.key.cmp(&y.key)));
assert_eq!(whole_hits.len(), split_hits.len(), "same documents match");
for (w, s) in whole_hits.iter().zip(&split_hits) {
assert_eq!(w.key, s.key, "same ranking order");
assert!((w.score - s.score).abs() < 1e-9, "same score for {:?}", w.key);
}
}
#[test]
fn no_stats_matches_the_local_path() {
let mut s = TextSegment::new();
for (i, t) in ["rust here", "rust and rust", "nothing"].iter().enumerate() {
s.apply(format!("d{i}").as_bytes(), Some(t.as_bytes()));
}
let a = s.matches(b"rust", 10);
let b = s.matches_scored(b"rust", 10, None);
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert_eq!(x.key, y.key);
assert_eq!(x.score, y.score);
}
}
fn phrase_seg() -> TextSegment {
let mut s = TextSegment::with_positions();
s.apply(b"d1", Some(b"the quick brown fox jumps"));
s.apply(b"d2", Some(b"quick red and then a brown hare"));
s.apply(b"d3", Some(b"a brown quick animal appears"));
s
}
#[test]
fn phrase_requires_adjacency_and_order() {
let s = phrase_seg();
let hits = s.phrase_matches(b"quick brown", 10, None);
assert_eq!(hits.len(), 1, "only d1 has quick-brown consecutive");
assert_eq!(hits[0].key, b"d1".to_vec());
let rev = s.phrase_matches(b"brown quick", 10, None);
assert_eq!(rev.len(), 1);
assert_eq!(rev[0].key, b"d3".to_vec());
assert!(s.phrase_matches(b"quick fox", 10, None).is_empty());
}
#[test]
fn phrase_without_positions_is_empty() {
let mut s = TextSegment::new();
s.apply(b"d1", Some(b"the quick brown fox"));
assert!(!s.has_positions());
assert!(s.phrase_matches(b"quick brown", 10, None).is_empty());
let single = s.phrase_matches(b"quick", 10, None);
assert_eq!(single, s.matches(b"quick", 10));
}
#[test]
fn single_token_phrase_is_a_term_query() {
let s = phrase_seg();
let phrase = s.phrase_matches(b"brown", 10, None);
let term = s.matches(b"brown", 10);
assert_eq!(phrase, term);
assert_eq!(phrase.len(), 3, "every doc has brown");
}
#[test]
fn positions_do_not_change_ranking() {
let docs: &[(&[u8], &str)] = &[
(b"d1", "the quick brown fox jumps"),
(b"d2", "quick red and then a brown hare"),
(b"d3", "a brown quick animal appears"),
(b"d4", "quick quick quick brown"),
];
let mut plain = TextSegment::new();
let mut pos = TextSegment::with_positions();
for (k, t) in docs {
plain.apply(k, Some(t.as_bytes()));
pos.apply(k, Some(t.as_bytes()));
}
for q in [&b"quick"[..], b"brown", b"quick brown fox"] {
assert_eq!(plain.matches(q, 10), pos.matches(q, 10), "ranking for {q:?}");
}
assert!(pos.stats().approx_bytes > plain.stats().approx_bytes);
assert_eq!(pos.stats().docs, plain.stats().docs);
assert_eq!(pos.stats().postings, plain.stats().postings);
}
#[test]
fn reindexing_withdraws_old_positions() {
let mut s = TextSegment::with_positions();
s.apply(b"d", Some(b"the quick brown fox"));
assert_eq!(s.phrase_matches(b"quick brown", 10, None).len(), 1);
s.apply(b"d", Some(b"slow green turtle"));
assert!(
s.phrase_matches(b"quick brown", 10, None).is_empty(),
"old positions must not survive the update"
);
assert_eq!(s.phrase_matches(b"green turtle", 10, None)[0].key, b"d".to_vec());
s.apply(b"d", None);
assert!(s.phrase_matches(b"green turtle", 10, None).is_empty());
}
#[test]
fn phrase_with_repeated_token() {
let mut s = TextSegment::with_positions();
s.apply(b"twice", Some(b"it was very very good indeed"));
s.apply(b"once", Some(b"it was very good indeed"));
let hits = s.phrase_matches(b"very very", 10, None);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key, b"twice".to_vec());
}
#[test]
fn matches_query_pure_phrase_equals_phrase_matches() {
let s = phrase_seg();
let via_query = s.matches_query(b"\"quick brown\"", 10, None);
let via_phrase = s.phrase_matches(b"quick brown", 10, None);
assert_eq!(via_query, via_phrase);
assert_eq!(via_query.len(), 1);
assert_eq!(via_query[0].key, b"d1".to_vec());
}
#[test]
fn matches_query_without_quotes_delegates_to_matches_scored() {
let s = phrase_seg();
for q in [&b"quick"[..], b"quick brown", b"brown fox jumps"] {
assert_eq!(s.matches_query(q, 10, None), s.matches_scored(q, 10, None), "query {q:?}");
}
}
#[test]
fn matches_query_mixes_terms_and_phrases() {
let mut s = TextSegment::with_positions();
s.apply(b"both", Some(b"the quick brown fox and a jumps word"));
s.apply(b"term_only", Some(b"jumps over something unrelated here"));
s.apply(b"phrase_only", Some(b"a quick brown hare"));
let hits = s.matches_query(b"jumps \"quick brown\"", 10, None);
let keys: Vec<_> = hits.iter().map(|h| h.key.clone()).collect();
assert!(keys.contains(&b"both".to_vec()), "matches both clauses");
assert!(keys.contains(&b"term_only".to_vec()), "matches the bare term");
assert!(keys.contains(&b"phrase_only".to_vec()), "matches the phrase");
assert_eq!(hits[0].key, b"both".to_vec(), "both clauses beat one: {keys:?}");
}
#[test]
fn matches_query_unterminated_quote_is_lenient() {
let s = phrase_seg();
let lenient = s.matches_query(b"\"quick brown", 10, None);
let or = s.matches_scored(b"quick brown", 10, None);
assert_eq!(lenient, or);
}
#[test]
fn matches_query_phrase_clause_needs_positions_but_term_survives() {
let mut s = TextSegment::new();
s.apply(b"d1", Some(b"the quick brown fox"));
s.apply(b"d2", Some(b"a slow green turtle named fox"));
let hits = s.matches_query(b"fox \"quick brown\"", 10, None);
assert_eq!(hits.len(), 2, "the bare term still matches: {hits:?}");
assert!(s.matches_query(b"\"quick brown\"", 10, None).is_empty());
}
#[test]
fn phrase_scores_against_injected_stats() {
let mut a = TextSegment::with_positions();
let mut b = TextSegment::with_positions();
a.apply(b"a1", Some(b"quick brown fox"));
a.apply(b"a2", Some(b"only quick here"));
b.apply(b"b1", Some(b"quick brown bear"));
b.apply(b"b2", Some(b"nothing relevant"));
let g = global_of(&[&a, &b], &[b"quick", b"brown"]);
let ha = a.phrase_matches(b"quick brown", 10, Some(&g));
let hb = b.phrase_matches(b"quick brown", 10, Some(&g));
assert_eq!(ha.len(), 1);
assert_eq!(hb.len(), 1);
assert!((ha[0].score - hb[0].score).abs() < 1e-9, "{} vs {}", ha[0].score, hb[0].score);
}
#[test]
fn highlight_marks_every_term_occurrence() {
let text = "Rust is a systems language and rust is fast";
let mut s = TextSegment::new();
s.apply(b"d", Some(text.as_bytes()));
let hl = s.highlight_spans(b"d", b"rust");
assert_eq!(hl.len(), 1, "one field");
let (fi, spans) = &hl[0];
assert_eq!(*fi, 0);
let hits: Vec<&str> = spans.iter().map(|(a, b)| &text[*a..*b]).collect();
assert_eq!(hits, vec!["Rust", "rust"], "both occurrences, source-cased");
}
#[test]
fn highlight_marks_only_the_adjacent_phrase() {
let text = "the quick brown fox and a quick red brown thing";
let mut s = TextSegment::new();
s.apply(b"d", Some(text.as_bytes()));
let hl = s.highlight_spans(b"d", b"\"quick brown\"");
let (_, spans) = &hl[0];
let hits: Vec<&str> = spans.iter().map(|(a, b)| &text[*a..*b]).collect();
assert_eq!(hits, vec!["quick", "brown"], "only the adjacent pair, not the scattered words");
}
#[test]
fn highlight_is_per_field() {
let (f0, f1) = ("rust title", "body about rust systems");
let mut s = TextSegment::new();
s.apply_fields(b"d", Some(&[(f0.as_bytes().to_vec(), 2.0), (f1.as_bytes().to_vec(), 1.0)]));
let hl = s.highlight_spans(b"d", b"rust");
assert_eq!(hl.len(), 2, "both fields match");
assert_eq!(hl[0].0, 0);
assert_eq!(&f0[hl[0].1[0].0..hl[0].1[0].1], "rust");
assert_eq!(hl[1].0, 1);
assert_eq!(&f1[hl[1].1[0].0..hl[1].1[0].1], "rust");
}
#[test]
fn highlight_cjk_phrase() {
let text = "全文检索引擎";
let mut s = TextSegment::new();
s.apply(b"d", Some(text.as_bytes()));
let hl = s.highlight_spans(b"d", "检索".as_bytes());
let (_, spans) = &hl[0];
let hits: Vec<&str> = spans.iter().map(|(a, b)| &text[*a..*b]).collect();
assert_eq!(hits, vec!["检索"]);
}
#[test]
fn highlight_absent_or_no_match_is_empty() {
let mut s = TextSegment::new();
s.apply(b"d", Some(b"rust systems"));
assert!(s.highlight_spans(b"nope", b"rust").is_empty(), "absent key");
assert!(s.highlight_spans(b"d", b"python").is_empty(), "no match");
}
fn prefix_seg() -> TextSegment {
let mut s = TextSegment::new();
s.apply(b"d1", Some(b"quick fox"));
s.apply(b"d2", Some(b"quiet night"));
s.apply(b"d3", Some(b"quality code"));
s.apply(b"d4", Some(b"slow turtle"));
s.apply(b"d5", Some(b"quick quiet quality")); s
}
#[test]
fn prefix_expands_to_all_matching_terms() {
let s = prefix_seg();
let hits = s.matches_prefix(b"qui", 10, None);
let keys: std::collections::HashSet<Vec<u8>> = hits.iter().map(|h| h.key.clone()).collect();
assert_eq!(
keys,
[b"d1".to_vec(), b"d2".to_vec(), b"d5".to_vec()].into_iter().collect(),
"every qui- doc, and only those"
);
assert_eq!(hits[0].key, b"d5".to_vec(), "the doc matching most expansions ranks first");
}
#[test]
fn prefix_is_case_insensitive_and_narrows() {
let s = prefix_seg();
let upper = s.matches_prefix(b"QUI", 10, None);
let lower = s.matches_prefix(b"qui", 10, None);
assert_eq!(upper, lower, "prefix is ASCII case-insensitive");
let narrow = s.matches_prefix(b"quie", 10, None);
let keys: Vec<Vec<u8>> = narrow.iter().map(|h| h.key.clone()).collect();
assert_eq!(keys, vec![b"d2".to_vec(), b"d5".to_vec()], "only the quiet docs");
}
#[test]
fn prefix_edge_cases_are_empty() {
let s = prefix_seg();
assert!(s.matches_prefix(b"", 10, None).is_empty(), "empty prefix is not 'match all'");
assert!(s.matches_prefix(b"zzz", 10, None).is_empty(), "no term has this prefix");
assert!(s.matches_prefix(b"qui", 0, None).is_empty(), "limit 0 is empty");
}
#[test]
fn prefix_equal_to_a_term_matches_it() {
let s = prefix_seg();
let hits = s.matches_prefix(b"quick", 10, None);
let keys: std::collections::HashSet<Vec<u8>> = hits.iter().map(|h| h.key.clone()).collect();
assert_eq!(keys, [b"d1".to_vec(), b"d5".to_vec()].into_iter().collect());
}
#[test]
fn query_grammar_prefix_equals_primitive() {
let s = prefix_seg();
let via_query = s.matches_query(b"qui*", 10, None);
let via_prefix = s.matches_prefix(b"qui", 10, None);
assert_eq!(via_query, via_prefix);
assert!(!via_query.is_empty());
}
#[test]
fn query_grammar_mixes_prefix_with_terms() {
let s = prefix_seg();
let hits = s.matches_query(b"slow qui*", 10, None);
let keys: std::collections::HashSet<Vec<u8>> = hits.iter().map(|h| h.key.clone()).collect();
assert_eq!(
keys,
[b"d1".to_vec(), b"d2".to_vec(), b"d4".to_vec(), b"d5".to_vec()].into_iter().collect(),
);
assert_eq!(s.matches_query(b"quick", 10, None), s.matches_scored(b"quick", 10, None));
}
#[test]
fn highlight_marks_prefix_matches() {
let text = "quick quiet slowly";
let mut s = TextSegment::new();
s.apply(b"d", Some(text.as_bytes()));
let hl = s.highlight_spans(b"d", b"qui*");
let (_, spans) = &hl[0];
let hits: Vec<&str> = spans.iter().map(|(a, b)| &text[*a..*b]).collect();
assert_eq!(hits, vec!["quick", "quiet"], "both qui- tokens, not slowly");
}
fn typo_seg() -> TextSegment {
let mut s = TextSegment::new();
s.apply(b"d1", Some(b"quick brown fox"));
s.apply(b"d2", Some(b"slow green turtle"));
s
}
#[test]
fn typo_budget_reaches_near_terms() {
let s = typo_seg();
let fuzzy = s.matches_query_typo(b"quik", 10, None, 1);
assert_eq!(fuzzy.len(), 1);
assert_eq!(fuzzy[0].key, b"d1".to_vec());
assert!(s.matches_query_typo(b"quik", 10, None, 0).is_empty());
assert_eq!(s.matches_query(b"quik", 10, None), s.matches_query_typo(b"quik", 10, None, 0));
}
#[test]
fn typo_budget_is_a_bound() {
let s = typo_seg();
assert!(s.matches_query_typo(b"qvik", 10, None, 1).is_empty(), "over a budget of 1");
let two = s.matches_query_typo(b"qvik", 10, None, 2);
assert_eq!(two.len(), 1);
assert_eq!(two[0].key, b"d1".to_vec());
}
#[test]
fn typo_keeps_exact_and_rejects_far() {
let s = typo_seg();
let exact = s.matches_query_typo(b"quick", 10, None, 2);
assert_eq!(exact.len(), 1);
assert_eq!(exact[0].key, b"d1".to_vec());
assert!(s.matches_query_typo(b"zzzzz", 10, None, 2).is_empty());
}
#[test]
fn typo_does_not_widen_phrases() {
let mut s = TextSegment::with_positions();
s.apply(b"d", Some(b"quick brown fox"));
assert!(s.matches_query_typo(b"\"quik brown\"", 10, None, 2).is_empty());
assert_eq!(s.matches_query_typo(b"\"quick brown\"", 10, None, 2).len(), 1);
}
#[test]
fn typo_df_terms_include_neighbours() {
let s = typo_seg();
let exact = s.query_df_terms(b"quik");
assert_eq!(exact, vec![b"quik".to_vec()], "budget 0 reports the term as written");
let fuzzy = s.query_df_terms_typo(b"quik", 1);
assert!(fuzzy.contains(&b"quick".to_vec()), "the neighbour is reported: {fuzzy:?}");
}
fn field_seg() -> TextSegment {
let mut s = TextSegment::with_shape(SegmentShape { fields: 2, positions: true, ..Default::default() });
s.apply_fields(
b"a",
Some(&[(b"rust engine".to_vec(), 1.0), (b"a long body about gardening".to_vec(), 1.0)]),
);
s.apply_fields(
b"b",
Some(&[
(b"gardening weekly".to_vec(), 1.0),
(b"this body mentions rust once among many other words here".to_vec(), 1.0),
]),
);
s
}
#[test]
fn scoping_to_every_field_equals_the_unscoped_query() {
let s = field_seg();
let all = &[0usize, 1][..];
for q in ["rust", "gardening", "rust gardening", "body"] {
let plain = s.matches_query(q.as_bytes(), 10, None);
let scoped =
s.matches_query_with(q.as_bytes(), 10, QueryOpts { fields: all, ..Default::default() });
assert_eq!(plain.len(), scoped.len(), "hit count for {q:?}");
for (p, sc) in plain.iter().zip(&scoped) {
assert_eq!(p.key, sc.key, "ranking for {q:?}");
assert!((p.score - sc.score).abs() < 1e-9, "score for {q:?}: {p:?} vs {sc:?}");
}
}
}
#[test]
fn scoping_restricts_matches_to_the_named_field() {
let s = field_seg();
assert_eq!(s.matches_query(b"rust", 10, None).len(), 2);
let title =
s.matches_query_with(b"rust", 10, QueryOpts { fields: &[0], ..Default::default() });
assert_eq!(title.len(), 1, "one title mentions rust");
assert_eq!(title[0].key, b"a".to_vec());
let body = s.matches_query_with(b"rust", 10, QueryOpts { fields: &[1], ..Default::default() });
assert_eq!(body.len(), 1);
assert_eq!(body[0].key, b"b".to_vec());
}
#[test]
fn scoped_score_normalises_by_the_field_not_the_document() {
let s = field_seg();
let title =
s.matches_query_with(b"gardening", 10, QueryOpts { fields: &[0], ..Default::default() });
assert_eq!(title.len(), 1);
assert_eq!(title[0].key, b"b".to_vec());
let whole = s.matches_query(b"gardening", 10, None);
let whole_b = whole.iter().find(|h| h.key == b"b".to_vec()).expect("b matches");
assert!(
title[0].score > whole_b.score,
"field-scoped {} should beat whole-document {}",
title[0].score,
whole_b.score
);
}
#[test]
fn scoped_document_frequency_counts_a_document_once() {
let mut s = TextSegment::with_shape(SegmentShape { fields: 2, ..Default::default() });
s.apply_fields(b"a", Some(&[(b"rust".to_vec(), 1.0), (b"rust again".to_vec(), 1.0)]));
s.apply_fields(b"b", Some(&[(b"other".to_vec(), 1.0), (b"rust".to_vec(), 1.0)]));
let both = s.query_df_in(b"rust", QueryOpts { fields: &[0, 1], ..Default::default() });
assert_eq!(both, vec![(b"rust".to_vec(), 2)], "a is one document, not two");
let title = s.query_df_in(b"rust", QueryOpts { fields: &[0], ..Default::default() });
assert_eq!(title, vec![(b"rust".to_vec(), 1)], "only a has it in field 0");
}
#[test]
fn scoped_phrase_must_lie_inside_one_wanted_field() {
let mut s = TextSegment::with_shape(SegmentShape { fields: 2, positions: true, ..Default::default() });
s.apply_fields(
b"a",
Some(&[(b"quick brown".to_vec(), 1.0), (b"nothing to see".to_vec(), 1.0)]),
);
s.apply_fields(b"b", Some(&[(b"the brown".to_vec(), 1.0), (b"fox sleeps".to_vec(), 1.0)]));
let q = br#""quick brown""#;
assert_eq!(s.matches_query(q, 10, None).len(), 1, "a has the phrase");
let scoped = s.matches_query_with(q, 10, QueryOpts { fields: &[0], ..Default::default() });
assert_eq!(scoped.len(), 1, "and it is in the title");
assert!(
s.matches_query_with(q, 10, QueryOpts { fields: &[1], ..Default::default() }).is_empty(),
"not in the body"
);
let across = br#""brown fox""#;
assert_eq!(s.matches_query(across, 10, None).len(), 1, "adjacent when concatenated");
for f in [0usize, 1] {
assert!(
s.matches_query_with(across, 10, QueryOpts { fields: &[f], ..Default::default() })
.is_empty(),
"a phrase straddling the field boundary is in neither field ({f})"
);
}
}
#[test]
fn scoping_a_single_field_segment() {
let s = seg();
assert_eq!(s.field_arity(), 1);
let plain = s.matches_query(b"rust", 10, None);
let scoped =
s.matches_query_with(b"rust", 10, QueryOpts { fields: &[0], ..Default::default() });
assert_eq!(plain, scoped);
assert!(
s.matches_query_with(b"rust", 10, QueryOpts { fields: &[1], ..Default::default() })
.is_empty(),
"no second field to scope to"
);
}
#[test]
fn reindexing_keeps_the_field_channel_exact() {
let mut s = field_seg();
s.apply_fields(
b"a",
Some(&[(b"garden engine".to_vec(), 1.0), (b"now the body says rust".to_vec(), 1.0)]),
);
let title =
s.matches_query_with(b"rust", 10, QueryOpts { fields: &[0], ..Default::default() });
assert!(title.is_empty(), "no title mentions rust any more");
let mut body: Vec<Vec<u8>> = s
.matches_query_with(b"rust", 10, QueryOpts { fields: &[1], ..Default::default() })
.into_iter()
.map(|h| h.key)
.collect();
body.sort();
assert_eq!(body, vec![b"a".to_vec(), b"b".to_vec()], "both bodies do");
s.apply_fields(b"a", None);
s.apply_fields(b"b", None);
assert_eq!(s.total_len_in(&[0, 1]), 0, "field totals drop with the documents");
assert_eq!(s.total_len(), 0);
}
#[test]
fn scoped_typo_and_prefix_stay_inside_the_field() {
let s = field_seg();
let opts = |f: &'static [usize], typo: u32| QueryOpts { fields: f, typo, ..Default::default() };
let pfx = s.matches_query_with(b"rus*", 10, opts(&[0], 0));
assert_eq!(pfx.len(), 1);
assert_eq!(pfx[0].key, b"a".to_vec());
let typo = s.matches_query_with(b"rusty", 10, opts(&[0], 1));
assert_eq!(typo.len(), 1);
assert_eq!(typo[0].key, b"a".to_vec());
assert_eq!(s.matches_query_with(b"rusty", 10, opts(&[1], 1)).len(), 1, "b's body has rust");
}
fn value_seg() -> TextSegment {
let mut s = TextSegment::with_shape(SegmentShape { values: 1, ..Default::default() });
for i in 0..10u32 {
let body = format!("rust {}", "rust ".repeat((10 - i) as usize));
let price = format!("{}", (10 - i) * 10);
s.apply_doc(
format!("d{i}").as_bytes(),
Some(&[(body.into_bytes(), 1.0)]),
&[Some(price.as_bytes())],
);
}
s
}
#[test]
fn a_predicate_reaches_past_the_unfiltered_leaders() {
let s = value_seg();
let cheap = |v: &[u8]| std::str::from_utf8(v).unwrap().parse::<u32>().unwrap() < 55;
let f = [Filter { field: 0, test: &cheap }];
let hits = s.matches_query_with(b"rust", 5, QueryOpts { filter: &f, ..Default::default() });
let mut keys: Vec<Vec<u8>> = hits.into_iter().map(|h| h.key).collect();
keys.sort();
assert_eq!(
keys,
vec![b"d5".to_vec(), b"d6".to_vec(), b"d7".to_vec(), b"d8".to_vec(), b"d9".to_vec()],
"the qualifying documents are found even though every one ranks below the top 5"
);
let plain = s.matches_query(b"rust", 5, None);
assert_eq!(plain[0].key, b"d0".to_vec(), "d0 repeats 'rust' most");
let top: Vec<Vec<u8>> = plain.into_iter().map(|h| h.key).collect();
assert_eq!(
top,
vec![b"d0".to_vec(), b"d1".to_vec(), b"d2".to_vec(), b"d3".to_vec(), b"d4".to_vec()]
);
}
#[test]
fn predicates_are_anded_and_absent_never_passes() {
let s = value_seg();
let ge = |v: &[u8]| std::str::from_utf8(v).unwrap().parse::<u32>().unwrap() >= 30;
let lt = |v: &[u8]| std::str::from_utf8(v).unwrap().parse::<u32>().unwrap() < 60;
let both = [Filter { field: 0, test: &ge }, Filter { field: 0, test: < }];
let hits = s.matches_query_with(b"rust", 10, QueryOpts { filter: &both, ..Default::default() });
let mut keys: Vec<Vec<u8>> = hits.into_iter().map(|h| h.key).collect();
keys.sort();
assert_eq!(keys, vec![b"d5".to_vec(), b"d6".to_vec(), b"d7".to_vec()], "30 <= price < 60");
let any = |_: &[u8]| true;
let missing = [Filter { field: 7, test: &any }];
assert!(
s.matches_query_with(b"rust", 10, QueryOpts { filter: &missing, ..Default::default() })
.is_empty(),
"an undeclared value field passes nobody"
);
let plain = seg();
assert!(
plain
.matches_query_with(b"rust", 10, QueryOpts { filter: &missing, ..Default::default() })
.is_empty(),
"a segment storing no values passes nobody"
);
}
#[test]
fn stored_values_follow_the_document() {
let mut s = value_seg();
let is5 = |v: &[u8]| v == b"5";
let f = [Filter { field: 0, test: &is5 }];
let q = |s: &TextSegment| {
s.matches_query_with(b"rust", 10, QueryOpts { filter: &f, ..Default::default() }).len()
};
assert_eq!(q(&s), 0, "nothing is priced 5 yet");
s.apply_doc(b"d3", Some(&[(b"rust".to_vec(), 1.0)]), &[Some(b"5")]);
assert_eq!(q(&s), 1, "a re-index updates the stored value");
s.apply_doc(b"d3", None, &[]);
assert_eq!(q(&s), 0, "and a removal takes it away");
s.apply_doc(b"fresh", Some(&[(b"rust".to_vec(), 1.0)]), &[]);
assert_eq!(q(&s), 0, "a reused id slot carries no stale value");
}
#[test]
fn the_memory_formula_counts_stored_values() {
let bare = TextSegment::new().stats().approx_bytes;
let s = value_seg();
let with_values = s.stats().approx_bytes;
let mut without = TextSegment::new();
for i in 0..10u32 {
let body = format!("rust {}", "rust ".repeat((10 - i) as usize));
without.apply(format!("d{i}").as_bytes(), Some(body.as_bytes()));
}
assert!(with_values > without.stats().approx_bytes, "the value column is accounted for");
assert!(with_values > bare);
}
#[test]
fn sorting_selects_by_the_key_not_the_score() {
let s = value_seg();
let key = |raw: &[u8]| {
let n: u32 = std::str::from_utf8(raw).ok()?.parse().ok()?;
Some(n.to_be_bytes().to_vec())
};
let asc = QueryOpts {
sort: Some(Sort { field: 0, desc: false, key: &key }),
..Default::default()
};
let hits = s.matches_query_with(b"rust", 3, asc);
let keys: Vec<Vec<u8>> = hits.iter().map(|h| h.key.clone()).collect();
assert_eq!(keys, vec![b"d9".to_vec(), b"d8".to_vec(), b"d7".to_vec()], "cheapest first");
assert!(hits[0].score < hits[2].score, "scores are still reported, just not ranked by");
let desc = QueryOpts {
sort: Some(Sort { field: 0, desc: true, key: &key }),
..Default::default()
};
let keys: Vec<Vec<u8>> =
s.matches_query_with(b"rust", 3, desc).into_iter().map(|h| h.key).collect();
assert_eq!(keys, vec![b"d0".to_vec(), b"d1".to_vec(), b"d2".to_vec()], "priciest first");
}
#[test]
fn documents_without_a_value_sort_last_in_both_directions() {
let mut s = value_seg();
s.apply_doc(b"none1", Some(&[(b"rust".to_vec(), 1.0)]), &[]);
s.apply_doc(b"none2", Some(&[(b"rust".to_vec(), 1.0)]), &[Some(b"not a number")]);
let key = |raw: &[u8]| {
let n: u32 = std::str::from_utf8(raw).ok()?.parse().ok()?;
Some(n.to_be_bytes().to_vec())
};
for desc in [false, true] {
let opts =
QueryOpts { sort: Some(Sort { field: 0, desc, key: &key }), ..Default::default() };
let all: Vec<Vec<u8>> =
s.matches_query_with(b"rust", 12, opts).into_iter().map(|h| h.key).collect();
assert_eq!(all.len(), 12);
let tail = &all[10..];
assert!(
tail.contains(&b"none1".to_vec()) && tail.contains(&b"none2".to_vec()),
"missing and uncoercible sort last (desc={desc}): {all:?}"
);
}
}
#[test]
fn sorting_composes_with_a_filter() {
let s = value_seg();
let key = |raw: &[u8]| {
let n: u32 = std::str::from_utf8(raw).ok()?.parse().ok()?;
Some(n.to_be_bytes().to_vec())
};
let cheap = |v: &[u8]| std::str::from_utf8(v).unwrap().parse::<u32>().unwrap() >= 50;
let f = [Filter { field: 0, test: &cheap }];
let opts = QueryOpts {
filter: &f,
sort: Some(Sort { field: 0, desc: false, key: &key }),
..Default::default()
};
let keys: Vec<Vec<u8>> =
s.matches_query_with(b"rust", 3, opts).into_iter().map(|h| h.key).collect();
assert_eq!(keys, vec![b"d5".to_vec(), b"d4".to_vec(), b"d3".to_vec()]);
}
fn group_seg() -> TextSegment {
let mut s = TextSegment::with_shape(SegmentShape { values: 1, ..Default::default() });
for (k, reps, price) in [
("a1", 9, "10"),
("a2", 8, "10"),
("b1", 7, "20"),
("b2", 6, "20"),
("c1", 5, "30"),
("c2", 4, "30"),
] {
let body = format!("rust {}", "rust ".repeat(reps));
s.apply_doc(k.as_bytes(), Some(&[(body.into_bytes(), 1.0)]), &[Some(price.as_bytes())]);
}
s
}
#[test]
fn distinct_fills_the_page_with_distinct_values() {
let s = group_seg();
let key = |raw: &[u8]| Some(raw.to_vec());
let plain: Vec<Vec<u8>> =
s.matches_query(b"rust", 3, None).into_iter().map(|h| h.key).collect();
assert_eq!(plain, vec![b"a1".to_vec(), b"a2".to_vec(), b"b1".to_vec()]);
let opts = QueryOpts { distinct: Some(Distinct { field: 0, key: &key }), ..Default::default() };
let hits: Vec<Vec<u8>> =
s.matches_query_with(b"rust", 3, opts).into_iter().map(|h| h.key).collect();
assert_eq!(
hits,
vec![b"a1".to_vec(), b"b1".to_vec(), b"c1".to_vec()],
"one per price, the best of each — collapsing a top-3 after the fact would have \
returned two rows, not three"
);
}
#[test]
fn a_document_with_no_value_is_its_own_group() {
let mut s = group_seg();
s.apply_doc(b"n1", Some(&[(b"rust".to_vec(), 1.0)]), &[]);
s.apply_doc(b"n2", Some(&[(b"rust".to_vec(), 1.0)]), &[]);
let key = |raw: &[u8]| Some(raw.to_vec());
let opts = QueryOpts { distinct: Some(Distinct { field: 0, key: &key }), ..Default::default() };
let hits: Vec<Vec<u8>> =
s.matches_query_with(b"rust", 10, opts).into_iter().map(|h| h.key).collect();
assert_eq!(hits.len(), 5, "three priced groups plus two priceless rows: {hits:?}");
assert!(hits.contains(&b"n1".to_vec()) && hits.contains(&b"n2".to_vec()));
}
#[test]
fn distinct_composes_with_sort_and_filter() {
let s = group_seg();
let key = |raw: &[u8]| Some(raw.to_vec());
let okey = |raw: &[u8]| {
let n: u32 = std::str::from_utf8(raw).ok()?.parse().ok()?;
Some(n.to_be_bytes().to_vec())
};
let opts = QueryOpts {
distinct: Some(Distinct { field: 0, key: &key }),
sort: Some(Sort { field: 0, desc: false, key: &okey }),
..Default::default()
};
let hits: Vec<Vec<u8>> =
s.matches_query_with(b"rust", 5, opts).into_iter().map(|h| h.key).collect();
assert_eq!(hits, vec![b"a1".to_vec(), b"b1".to_vec(), b"c1".to_vec()]);
let dear = |v: &[u8]| v != b"10";
let f = [Filter { field: 0, test: &dear }];
let opts = QueryOpts {
distinct: Some(Distinct { field: 0, key: &key }),
filter: &f,
..Default::default()
};
let hits: Vec<Vec<u8>> =
s.matches_query_with(b"rust", 5, opts).into_iter().map(|h| h.key).collect();
assert_eq!(hits, vec![b"b1".to_vec(), b"c1".to_vec()]);
}
#[test]
fn facets_count_every_match_not_the_page() {
let s = group_seg();
let key = |raw: &[u8]| Some(raw.to_vec());
let f = [Facet { field: 0, key: &key }];
let r = s.matches_query_faceted(b"rust", 1, QueryOpts::default(), &f);
assert_eq!(r.hits.len(), 1, "the page is still one document");
let counts: Vec<(Vec<u8>, u64)> =
r.facets[0].iter().map(|(_, label, n)| (label.clone(), *n)).collect();
let mut sorted = counts.clone();
sorted.sort();
assert_eq!(
sorted,
vec![(b"10".to_vec(), 2), (b"20".to_vec(), 2), (b"30".to_vec(), 2)],
"every match is counted: {counts:?}"
);
}
#[test]
fn a_filter_restricts_the_counts_but_distinct_does_not() {
let s = group_seg();
let key = |raw: &[u8]| Some(raw.to_vec());
let f = [Facet { field: 0, key: &key }];
let dear = |v: &[u8]| v != b"10";
let pred = [Filter { field: 0, test: &dear }];
let r = s.matches_query_faceted(
b"rust",
10,
QueryOpts { filter: &pred, ..Default::default() },
&f,
);
assert_eq!(r.facets[0].len(), 2, "the excluded price has no bucket");
let r = s.matches_query_faceted(
b"rust",
10,
QueryOpts { distinct: Some(Distinct { field: 0, key: &key }), ..Default::default() },
&f,
);
assert_eq!(r.hits.len(), 3, "one document per price on the page");
let total: u64 = r.facets[0].iter().map(|(_, _, n)| n).sum();
assert_eq!(total, 6, "all six still matched: {:?}", r.facets[0]);
}
#[test]
fn buckets_are_the_coerced_value_and_absence_is_not_one() {
let mut s = TextSegment::with_shape(SegmentShape { values: 1, ..Default::default() });
s.apply_doc(b"a", Some(&[(b"rust".to_vec(), 1.0)]), &[Some(b"1")]);
s.apply_doc(b"b", Some(&[(b"rust".to_vec(), 1.0)]), &[Some(b"1.0")]);
s.apply_doc(b"c", Some(&[(b"rust".to_vec(), 1.0)]), &[]);
let key = |raw: &[u8]| {
let v: f64 = std::str::from_utf8(raw).ok()?.parse().ok()?;
Some(v.to_bits().to_be_bytes().to_vec())
};
let f = [Facet { field: 0, key: &key }];
let r = s.matches_query_faceted(b"rust", 10, QueryOpts::default(), &f);
assert_eq!(r.facets[0].len(), 1, "1 and 1.0 are one bucket: {:?}", r.facets[0]);
assert_eq!(r.facets[0][0].2, 2, "counting both spellings");
let label = &r.facets[0][0].1;
assert!(label == b"1" || label == b"1.0", "the label is a spelling that occurs: {label:?}");
assert_eq!(r.hits.len(), 3, "the document with no value still matched");
}