use crate::dedup::GroupedResult;
pub fn query_terms(query: &str) -> Vec<String> {
tokenize(query)
}
fn raw_score(g: &GroupedResult, terms: &[String]) -> f64 {
let mut score = 0.0;
score += g.count as f64 * 1.5;
let pos = g.result.position.max(1) as f64;
score += (10.0 / pos).min(3.0);
let host = crate::parse::host_of(&g.result.url).unwrap_or_default();
if host.contains("wikipedia.org") || host.contains("grokipedia") {
score += 2.5;
}
score += bm25_match(&g.result.title, &g.result.description, terms);
score
}
pub fn rank(groups: Vec<GroupedResult>, query: &str) -> Vec<(f64, GroupedResult)> {
let terms = query_terms(query);
let mut scored: Vec<(f64, GroupedResult)> = groups
.into_iter()
.map(|g| (raw_score(&g, &terms), g))
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored
}
pub fn calculate_score(grouped: &GroupedResult, query_terms: &[String]) -> f64 {
let raw = raw_score(grouped, query_terms);
let norm = raw / (1.0 + raw);
let rounded = (norm * 1000.0).round() / 1000.0;
rounded.clamp(0.001, 0.999)
}
fn bm25_match(title: &str, body: &str, terms: &[String]) -> f64 {
if terms.is_empty() {
return 0.0;
}
const K1: f64 = 1.2;
let title_words = tokenize(title);
let body_words = tokenize(body);
let mut score = 0.0;
for t in terms {
let tf_t = title_words.iter().filter(|w| *w == t).count() as f64;
if tf_t > 0.0 {
score += 2.0 * (K1 * tf_t) / (tf_t + K1);
}
let tf_b = body_words.iter().filter(|w| *w == t).count() as f64;
if tf_b > 0.0 {
score += (K1 * tf_b) / (tf_b + K1);
}
}
score
}
fn tokenize(s: &str) -> Vec<String> {
s.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| w.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dedup::GroupedResult;
use crate::models::RawResult;
fn group(title: &str, url: &str, desc: &str, engine: &str, position: u32) -> GroupedResult {
GroupedResult {
result: RawResult {
title: title.into(),
url: url.into(),
description: desc.into(),
engine: engine.into(),
position,
..Default::default()
},
engines: vec![engine.into()],
count: 1,
}
}
fn rerank(g: &GroupedResult, n: usize, engines: Vec<&str>) -> GroupedResult {
let mut g = g.clone();
g.count = n;
g.engines = engines.iter().map(|s| s.to_string()).collect();
g
}
#[test]
fn query_terms_matches_document_tokenizer() {
assert_eq!(query_terms("Go"), vec!["go".to_string()]);
assert_eq!(query_terms("C#"), vec!["c".to_string()]);
assert_eq!(query_terms("AI"), vec!["ai".to_string()]);
assert_eq!(query_terms("R"), vec!["r".to_string()]);
assert_eq!(
query_terms("rust book"),
vec!["rust".to_string(), "book".to_string()]
);
assert_eq!(
query_terms("rust-book 2.0"),
vec![
"rust".to_string(),
"book".to_string(),
"2".to_string(),
"0".to_string()
]
);
}
#[test]
fn agreement_dominates() {
let singles = group(
"rust book",
"https://a.com",
"rust programming book",
"bing",
1,
);
let agreed = rerank(
&group(
"rust book",
"https://b.com",
"rust programming book",
"brave",
3,
),
3,
vec!["bing", "brave", "duckduckgo"],
);
let ranked = rank(vec![singles, agreed], "rust book");
assert_eq!(ranked[0].1.result.url, "https://b.com");
assert!(ranked[0].0 > ranked[1].0);
assert!((ranked[0].0 - ranked[1].0 - 3.0).abs() < 1e-9);
}
#[test]
fn position_and_text_matter() {
let pos1 = group("rust", "https://a.com", "", "bing", 1);
let pos5 = group("rust", "https://b.com", "", "bing", 5);
let ranked = rank(vec![pos1, pos5], "rust");
assert_eq!(ranked[0].1.result.url, "https://a.com");
}
#[test]
fn wikipedia_gets_bonus() {
let wiki = group(
"rust",
"https://en.wikipedia.org/wiki/Rust",
"",
"wikipedia",
10,
);
let other = group("rust", "https://c.com", "", "bing", 1);
let ranked = rank(vec![other, wiki], "rust");
assert_eq!(ranked[0].1.result.url, "https://en.wikipedia.org/wiki/Rust");
}
#[test]
fn query_terms_boost_title_matches() {
let title_hit = group("learn rust fast", "https://a.com", "", "bing", 1);
let no_hit = group("something else", "https://b.com", "", "bing", 1);
let ranked = rank(vec![no_hit, title_hit], "learn rust");
assert_eq!(ranked[0].1.result.url, "https://a.com");
}
#[test]
fn ranking_is_stable_and_total() {
let a = group("x", "https://a.com", "same", "bing", 1);
let b = group("x", "https://b.com", "same", "brave", 2);
let mut ranked = rank(vec![a.clone(), b.clone()], "x");
assert_eq!(ranked.len(), 2);
let total: f64 = ranked.iter().map(|(s, _)| s).sum();
ranked.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap());
assert!((total - ranked.iter().map(|(s, _)| s).sum::<f64>()).abs() < 1e-9);
}
#[test]
fn calculate_score_normalizes_to_unit_interval() {
let g = group("rust book", "https://a.com", "rust programming", "bing", 1);
for count in [1usize, 2, 3, 5] {
let s = calculate_score(&rerank(&g, count, vec!["bing"]), &query_terms("rust book"));
assert!((0.001..=0.999).contains(&s), "count={count}: {s}");
let rounded = (s * 1000.0).fract().abs();
assert!(rounded < 1e-9, "count={count}: not 3 decimals: {s}");
}
let top = calculate_score(
&rerank(
&group(
"rust book",
"https://en.wikipedia.org/wiki/Rust",
"rust programming book",
"wikipedia",
1,
),
5,
vec!["bing", "brave", "ddg", "google", "mojeek"],
),
&query_terms("rust book rust book rust book"),
);
assert!((0.001..1.000).contains(&top), "top={top}");
}
#[test]
fn calculate_score_is_monotonic_in_components() {
let terms = query_terms("rust book");
let base = group("rust book", "https://a.com", "rust programming", "bing", 1);
let agreed = rerank(&base, 3, vec!["bing", "brave", "ddg"]);
assert!(calculate_score(&agreed, &terms) > calculate_score(&base, &terms));
let late = group("rust book", "https://a.com", "rust programming", "bing", 8);
assert!(calculate_score(&base, &terms) > calculate_score(&late, &terms));
let wiki = group(
"rust book",
"https://en.wikipedia.org/wiki/Rust",
"rust",
"wikipedia",
1,
);
assert!(calculate_score(&wiki, &terms) > calculate_score(&base, &terms));
let none = group("totally unrelated", "https://a.com", "x", "bing", 1);
assert!(calculate_score(&base, &terms) > calculate_score(&none, &terms));
}
}