#![allow(clippy::expect_used, clippy::indexing_slicing, clippy::panic)]
use zeppelin_embed::fts::bm25::{Bm25Params, CorpusStats, Df, DocLen, Tf, idf, term_score};
const TOLERANCE: f64 = 1e-6;
fn close(actual: f64, expected: f64, what: &str) {
assert!(
(actual - expected).abs() < TOLERANCE,
"{what}: got {actual}, expected {expected}"
);
}
#[test]
fn idf_is_the_lucene_variant_not_the_classic_robertson_form() {
close(idf(Df(3), 10), (1.0 + 7.5 / 3.5_f64).ln(), "N=10 df=3");
assert!(
idf(Df(8), 10) > 0.0,
"the Lucene variant must never produce a negative idf"
);
close(idf(Df(8), 10), (1.0 + 2.5 / 8.5_f64).ln(), "N=10 df=8");
close(idf(Df(10), 10), (1.0 + 0.5 / 10.5_f64).ln(), "df == N");
}
#[test]
fn idf_is_monotonically_non_increasing_in_df() {
let mut previous = f64::INFINITY;
for df in 1..=64_u32 {
let value = idf(Df(df), 64);
assert!(value <= previous, "idf rose at df={df}");
assert!(value >= 0.0, "idf went negative at df={df}");
previous = value;
}
}
#[test]
fn bm25_term_score_matches_hand_computed_values_for_the_tiny_corpus() {
let stats = CorpusStats::new(4, 20).expect("4 docs, 20 tokens -> avgdl 5");
close(stats.average_document_length(), 5.0, "avgdl");
let params = Bm25Params::default();
assert!((params.k1 - 1.2).abs() < TOLERANCE);
assert!((params.b - 0.75).abs() < TOLERANCE);
close(
term_score(Tf(3), Df(2), DocLen(5), &stats, params),
2.0_f64.ln() * 6.6 / 4.2,
"doc A",
);
close(
term_score(Tf(1), Df(2), DocLen(10), &stats, params),
2.0_f64.ln() * 2.2 / 3.1,
"doc B",
);
}
#[test]
fn a_zero_frequency_term_scores_zero() {
let stats = CorpusStats::new(4, 20).expect("valid stats");
close(
term_score(Tf(0), Df(2), DocLen(5), &stats, Bm25Params::default()),
0.0,
"tf == 0",
);
}
#[test]
fn k1_of_zero_makes_the_score_independent_of_term_frequency() {
let stats = CorpusStats::new(4, 20).expect("valid stats");
let params = Bm25Params::new(0.0, 0.75).expect("k1 = 0 is legal");
let expected = idf(Df(2), 4);
for tf in [1_u32, 2, 7, 1000] {
close(
term_score(Tf(tf), Df(2), DocLen(5), &stats, params),
expected,
"k1 = 0",
);
}
}
#[test]
fn b_of_zero_makes_the_score_independent_of_document_length() {
let stats = CorpusStats::new(4, 20).expect("valid stats");
let params = Bm25Params::new(1.2, 0.0).expect("b = 0 is legal");
let baseline = term_score(Tf(3), Df(2), DocLen(5), &stats, params);
for length in [1_u32, 5, 50, 5000] {
close(
term_score(Tf(3), Df(2), DocLen(length), &stats, params),
baseline,
"b = 0",
);
}
}
#[test]
fn b_of_one_applies_the_full_length_normalization() {
let stats = CorpusStats::new(4, 20).expect("valid stats");
let params = Bm25Params::new(1.2, 1.0).expect("b = 1 is legal");
let expected = idf(Df(2), 4) * 3.0 * 2.2 / (3.0 + 1.2 * 10.0 / 5.0);
close(
term_score(Tf(3), Df(2), DocLen(10), &stats, params),
expected,
"b = 1",
);
}
#[test]
fn a_huge_term_frequency_saturates_below_the_idf_ceiling() {
let stats = CorpusStats::new(4, 20).expect("valid stats");
let params = Bm25Params::default();
let ceiling = idf(Df(2), 4) * (params.k1 + 1.0);
let score = term_score(Tf(u32::MAX), Df(2), DocLen(5), &stats, params);
assert!(
score < ceiling,
"score {score} reached the ceiling {ceiling}"
);
assert!(
score > ceiling * 0.999,
"score {score} did not approach the ceiling {ceiling}"
);
}
#[test]
fn a_single_token_document_scores_finitely() {
let stats = CorpusStats::new(4, 20).expect("valid stats");
let score = term_score(Tf(1), Df(1), DocLen(1), &stats, Bm25Params::default());
assert!(score.is_finite() && score > 0.0, "got {score}");
}
#[test]
fn higher_score_is_always_better() {
let stats = CorpusStats::new(4, 20).expect("valid stats");
let params = Bm25Params::default();
let rare = term_score(Tf(2), Df(1), DocLen(5), &stats, params);
let common = term_score(Tf(2), Df(4), DocLen(5), &stats, params);
assert!(rare > common, "a rarer term must score higher");
assert!(rare > 0.0 && common > 0.0, "scores must be positive");
}
#[test]
fn degenerate_parameters_and_stats_are_typed_errors() {
assert!(Bm25Params::new(-0.1, 0.5).is_err(), "negative k1");
assert!(Bm25Params::new(1.2, -0.1).is_err(), "b below zero");
assert!(Bm25Params::new(1.2, 1.1).is_err(), "b above one");
assert!(Bm25Params::new(f64::NAN, 0.5).is_err(), "NaN k1");
assert!(
CorpusStats::new(0, 0).is_err(),
"an empty corpus has no avgdl"
);
assert!(CorpusStats::new(4, 0).is_err(), "zero tokens has no avgdl");
}
#[test]
fn anserini_defaults_are_available_and_documented() {
let anserini = Bm25Params::anserini();
assert!((anserini.k1 - 0.9).abs() < TOLERANCE);
assert!((anserini.b - 0.4).abs() < TOLERANCE);
}