use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct MmrConfig {
pub lambda: f64,
pub top_k: usize,
pub max_candidates: usize,
}
impl Default for MmrConfig {
fn default() -> Self {
Self {
lambda: 0.5,
top_k: 10,
max_candidates: 50,
}
}
}
pub trait MmrHit: Clone {
fn score(&self) -> f64;
fn text(&self) -> &str;
}
pub fn mmr_rerank<H: MmrHit>(candidates: Vec<H>, config: &MmrConfig) -> Vec<H> {
if candidates.is_empty() || config.top_k == 0 {
return Vec::new();
}
let candidates: Vec<H> = candidates.into_iter().take(config.max_candidates).collect();
let limit = config.top_k.min(candidates.len());
let mut selected: Vec<H> = Vec::with_capacity(limit);
let mut selected_tokens: Vec<HashSet<String>> = Vec::with_capacity(limit);
let mut remaining: Vec<H> = candidates;
let mut token_sets: Vec<HashSet<String>> =
remaining.iter().map(|h| tokenize(h.text())).collect();
while selected.len() < limit && !remaining.is_empty() {
let mut best_idx = 0;
let mut best_mmr = f64::MIN;
for (i, candidate) in remaining.iter().enumerate() {
let relevance = candidate.score();
let max_sim = selected_tokens
.iter()
.map(|s_tokens| jaccard(&token_sets[i], s_tokens))
.max_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap_or(0.0);
let mmr = config.lambda * relevance - (1.0 - config.lambda) * max_sim;
if mmr > best_mmr {
best_mmr = mmr;
best_idx = i;
}
}
let hit = remaining.remove(best_idx);
let tokens = token_sets.remove(best_idx);
selected_tokens.push(tokens);
selected.push(hit);
}
selected
}
fn tokenize(text: &str) -> HashSet<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
let intersection = a.intersection(b).count();
let union = a.union(b).count();
if union == 0 {
0.0
} else {
intersection as f64 / union as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone)]
struct TestHit {
text: String,
score: f64,
}
impl MmrHit for TestHit {
fn score(&self) -> f64 {
self.score
}
fn text(&self) -> &str {
&self.text
}
}
#[test]
fn test_mmr_rerank_diversity() {
let candidates = vec![
TestHit {
text: "hello world".to_string(),
score: 0.9,
},
TestHit {
text: "hello world test".to_string(),
score: 0.85,
},
TestHit {
text: "foo bar".to_string(),
score: 0.8,
},
];
let config = MmrConfig {
lambda: 0.5,
top_k: 2,
max_candidates: 50,
};
let result = mmr_rerank(candidates, &config);
assert_eq!(result.len(), 2);
assert!((result[0].score() - 0.9).abs() < 0.001);
assert!(result[1].text().contains("foo"));
}
#[test]
fn test_mmr_rerank_empty() {
let candidates: Vec<TestHit> = vec![];
let config = MmrConfig::default();
let result = mmr_rerank(candidates, &config);
assert!(result.is_empty());
}
#[test]
fn test_mmr_rerank_single() {
let candidates = vec![TestHit {
text: "single".to_string(),
score: 0.9,
}];
let config = MmrConfig {
lambda: 0.5,
top_k: 5,
max_candidates: 50,
};
let result = mmr_rerank(candidates, &config);
assert_eq!(result.len(), 1);
}
#[test]
fn test_tokenize() {
let tokens = tokenize("Hello World!");
assert!(tokens.contains("hello"));
assert!(tokens.contains("world"));
}
#[test]
fn test_jaccard_identical() {
let a = tokenize("hello world");
let b = tokenize("hello world");
assert!((jaccard(&a, &b) - 1.0).abs() < 0.001);
}
#[test]
fn test_jaccard_disjoint() {
let a = tokenize("hello");
let b = tokenize("world");
assert!((jaccard(&a, &b) - 0.0).abs() < 0.001);
}
#[test]
fn test_mmr_rerank_pure_relevance() {
let candidates = vec![
TestHit {
text: "hello world".to_string(),
score: 0.9,
},
TestHit {
text: "hello world test".to_string(),
score: 0.7,
},
];
let config = MmrConfig {
lambda: 1.0,
top_k: 2,
max_candidates: 50,
};
let result = mmr_rerank(candidates, &config);
assert_eq!(result.len(), 2);
assert!((result[0].score() - 0.9).abs() < 0.001);
assert!((result[1].score() - 0.7).abs() < 0.001);
}
#[test]
fn test_mmr_max_candidates_truncation() {
let candidates: Vec<TestHit> = (0..100)
.map(|i| TestHit {
text: format!("doc {}", i),
score: 1.0 - i as f64 * 0.01,
})
.collect();
let config = MmrConfig {
lambda: 0.5,
top_k: 10,
max_candidates: 5,
};
let result = mmr_rerank(candidates, &config);
assert!(result.len() <= 5);
}
}