#[derive(Debug, Clone, Copy)]
pub struct Bm25Stats {
pub total_docs: usize,
pub docs_with_term: usize,
pub avg_doc_length: f64,
pub k1: f64,
pub b: f64,
}
impl Bm25Stats {
pub fn new(total_docs: usize, docs_with_term: usize, avg_doc_length: f64) -> Self {
Self {
total_docs,
docs_with_term,
avg_doc_length,
k1: 1.2,
b: 0.75,
}
}
fn idf(&self) -> f64 {
let n = self.docs_with_term as f64;
let n_total = self.total_docs as f64;
let numerator = n_total - n + 0.5;
let denominator = n + 0.5;
(numerator / denominator).ln() + 1.0
}
pub fn score(&self, term_freq: usize, doc_length: usize) -> f64 {
if self.total_docs == 0 || self.avg_doc_length == 0.0 {
return 0.0;
}
let tf = term_freq as f64;
let dl = doc_length as f64;
let idf = self.idf();
let tf_component = tf * (self.k1 + 1.0);
let normalization = tf + self.k1 * (1.0 - self.b + self.b * (dl / self.avg_doc_length));
idf * (tf_component / normalization)
}
pub fn normalized_score(&self, term_freq: usize, doc_length: usize) -> f64 {
let raw = self.score(term_freq, doc_length);
(raw * 10.0).clamp(0.0, 100.0)
}
}
pub fn compute_collection_stats(
files: &[std::path::PathBuf],
query: &str,
case_sensitive: bool,
) -> Bm25Stats {
let total_docs = files.len();
if total_docs == 0 {
return Bm25Stats::new(0, 0, 1.0);
}
let mut total_lines = 0usize;
let mut docs_with_term = 0usize;
for file_path in files {
if let Ok(content) = std::fs::read_to_string(file_path) {
let lines = content.lines().count();
total_lines += lines;
let contains = if case_sensitive {
content.contains(query)
} else {
content.to_lowercase().contains(&query.to_lowercase())
};
if contains {
docs_with_term += 1;
}
}
}
let avg_doc_length = if total_docs > 0 {
total_lines as f64 / total_docs as f64
} else {
1.0
};
Bm25Stats::new(total_docs, docs_with_term, avg_doc_length)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bm25_score_basic() {
let stats = Bm25Stats::new(100, 10, 50.0);
let score = stats.score(5, 50);
assert!(score > 0.0);
}
#[test]
fn test_bm25_longer_doc_penalty() {
let stats = Bm25Stats::new(100, 10, 50.0);
let score_short = stats.score(5, 50);
let score_long = stats.score(5, 100);
assert!(score_short > score_long, "Shorter doc should score higher");
}
#[test]
fn test_bm25_rare_term_boosts_score() {
let rare = Bm25Stats::new(100, 1, 50.0);
let common = Bm25Stats::new(100, 50, 50.0);
let score_rare = rare.score(3, 50);
let score_common = common.score(3, 50);
assert!(
score_rare > score_common,
"Rare term should score higher than common term"
);
}
#[test]
fn test_bm25_normalized_score_range() {
let stats = Bm25Stats::new(100, 10, 50.0);
let score = stats.normalized_score(10, 50);
assert!(score >= 0.0 && score <= 100.0);
}
#[test]
fn test_bm25_empty_collection() {
let stats = Bm25Stats::new(0, 0, 1.0);
assert_eq!(stats.score(5, 50), 0.0);
}
#[test]
fn test_idf_computation() {
let stats = Bm25Stats::new(100, 10, 50.0);
let idf = stats.idf();
assert!(idf > 0.0);
}
}