use crate::model::Scored;
use std::collections::HashMap;
pub const DEFAULT_RRF_K: f32 = 60.0;
pub fn rrf(rankings: &[Vec<Scored>], k: f32, top_k: usize) -> Vec<Scored> {
let mut fused: HashMap<String, (f32, Scored, usize)> = HashMap::new();
for list in rankings {
for (rank, hit) in list.iter().enumerate() {
let contribution = 1.0 / (k + (rank as f32 + 1.0));
let next = fused.len();
let entry = fused
.entry(hit.chunk.id.clone())
.or_insert_with(|| (0.0, hit.clone(), next));
entry.0 += contribution;
}
}
let mut out: Vec<(Scored, usize)> = fused
.into_values()
.map(|(score, mut hit, order)| {
hit.score = score;
(hit, order)
})
.collect();
out.sort_by(|a, b| {
b.0.score
.partial_cmp(&a.0.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.1.cmp(&b.1))
});
out.truncate(top_k);
out.into_iter().map(|(hit, _)| hit).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Chunk;
fn hit(id: &str, score: f32) -> Scored {
let mut c = Chunk::new("d", 0, "t", 0);
c.id = id.to_string();
Scored::new(c, score)
}
#[test]
fn rewards_agreement_across_lists() {
let l1 = vec![hit("a", 9.0), hit("b", 5.0), hit("c", 1.0)];
let l2 = vec![hit("b", 9.0), hit("d", 5.0), hit("a", 0.5)];
let fused = rrf(&[l1, l2], 1.0, 4);
assert_eq!(fused[0].chunk.id, "b");
}
#[test]
fn ties_follow_first_appearance() {
let l1 = vec![hit("a", 1.0)];
let l2 = vec![hit("b", 1.0)];
let l3 = vec![hit("c", 1.0)];
for _ in 0..8 {
let fused = rrf(&[l1.clone(), l2.clone(), l3.clone()], 60.0, 3);
let ids: Vec<&str> = fused.iter().map(|h| h.chunk.id.as_str()).collect();
assert_eq!(ids, ["a", "b", "c"]);
}
}
#[test]
fn dedups_and_limits() {
let l1 = vec![hit("a", 1.0), hit("b", 1.0)];
let l2 = vec![hit("a", 1.0)];
let fused = rrf(&[l1, l2], 60.0, 10);
assert_eq!(fused.len(), 2); assert_eq!(fused[0].chunk.id, "a"); }
}