use std::collections::HashMap;
use crate::config::SearchConfig;
pub(crate) fn merge_results(
vector_results: &[(String, f32)],
sparse_results: &[(String, f64)],
search: &SearchConfig,
) -> Vec<(String, f64)> {
let k = search.rrf_k as f64;
let mut combined: HashMap<String, f64> = HashMap::new();
for (rank, (memory_id, _)) in vector_results.iter().enumerate() {
*combined.entry(memory_id.clone()).or_insert(0.0) +=
search.vector_weight / (k + (rank + 1) as f64);
}
for (rank, (memory_id, _)) in sparse_results.iter().enumerate() {
*combined.entry(memory_id.clone()).or_insert(0.0) +=
search.sparse_weight / (k + (rank + 1) as f64);
}
let mut results: Vec<(String, f64)> = combined.into_iter().collect();
results.sort_by(|a, b| b.1.total_cmp(&a.1));
results
}
pub(crate) fn limit_results(results: Vec<(String, f64)>, top_k: usize) -> Vec<(String, f64)> {
results.into_iter().take(top_k).collect()
}
pub(crate) const SHADOW_K_SET: [usize; 4] = [3, 5, 10, 20];
pub(crate) fn shadow_rewards_for_k_set(
merged: &[(String, f64)],
k_set: &[usize],
) -> Vec<(usize, f64)> {
let total: f64 = merged.iter().map(|(_, score)| score).sum();
if merged.is_empty() || total <= 0.0 {
return Vec::new();
}
k_set
.iter()
.map(|&k| {
let covered: f64 = merged.iter().take(k).map(|(_, score)| score).sum();
(k, covered / total)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn vector_hit(id: &str) -> (String, f32) {
(id.to_string(), 0.0)
}
fn sparse_hit(id: &str) -> (String, f64) {
(id.to_string(), 0.0)
}
#[test]
fn merge_ranks_by_position_not_score() {
let vector = vec![vector_hit("a"), vector_hit("b")];
let merged = merge_results(&vector, &[], &SearchConfig::default());
assert_eq!(merged[0].0, "a");
assert_eq!(merged[1].0, "b");
assert!(merged[0].1 > merged[1].1);
}
#[test]
fn merge_sums_contributions_for_shared_id() {
let search = SearchConfig::default();
let vector = vec![vector_hit("a"), vector_hit("b")];
let sparse = vec![sparse_hit("a"), sparse_hit("c")];
let merged = merge_results(&vector, &sparse, &search);
let k = search.rrf_k as f64;
let score_a = merged.iter().find(|(id, _)| id == "a").unwrap().1;
let expected_a = search.vector_weight / (k + 1.0) + search.sparse_weight / (k + 1.0);
assert!((score_a - expected_a).abs() < 1e-12);
let score_b = merged.iter().find(|(id, _)| id == "b").unwrap().1;
assert!(score_a > score_b);
}
#[test]
fn asymmetric_weights_drive_final_ranking() {
let vector = vec![
vector_hit("x"),
vector_hit("v2"),
vector_hit("v3"),
vector_hit("v4"),
vector_hit("v5"),
vector_hit("v6"),
vector_hit("v7"),
vector_hit("v8"),
vector_hit("y"),
];
let sparse = vec![sparse_hit("y")];
let heavy_vector = SearchConfig {
rrf_k: 60,
vector_weight: 0.9,
sparse_weight: 0.1,
};
let merged = merge_results(&vector, &sparse, &heavy_vector);
let score_x = merged.iter().find(|(id, _)| id == "x").unwrap().1;
let score_y = merged.iter().find(|(id, _)| id == "y").unwrap().1;
let expected_x = 0.9 / 61.0;
let expected_y = 0.9 / 69.0 + 0.1 / 61.0;
assert!((score_x - expected_x).abs() < 1e-12);
assert!((score_y - expected_y).abs() < 1e-12);
assert!(
score_x > score_y,
"heavy vector weighting must let the vector rank-1 id win: x={score_x}, y={score_y}"
);
let x_position = merged.iter().position(|(id, _)| id == "x").unwrap();
let y_position = merged.iter().position(|(id, _)| id == "y").unwrap();
assert!(x_position < y_position, "x must outrank y under 0.9 / 0.1");
let symmetric = SearchConfig::default();
let merged_symmetric = merge_results(&vector, &sparse, &symmetric);
let symmetric_x = merged_symmetric.iter().find(|(id, _)| id == "x").unwrap().1;
let symmetric_y = merged_symmetric.iter().find(|(id, _)| id == "y").unwrap().1;
assert!(
symmetric_y > symmetric_x,
"the symmetric default must produce the opposite order: x={symmetric_x}, y={symmetric_y}"
);
}
#[test]
fn merge_empty_inputs_yield_empty() {
let merged = merge_results(&[], &[], &SearchConfig::default());
assert!(merged.is_empty());
}
#[test]
fn merge_scores_sparse_only_doc_absent_from_vector() {
let search = SearchConfig::default();
let sparse = vec![sparse_hit("only_sparse")];
let merged = merge_results(&[], &sparse, &search);
let k = search.rrf_k as f64;
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].0, "only_sparse");
let expected = search.sparse_weight / (k + 1.0);
assert!((merged[0].1 - expected).abs() < 1e-12);
}
fn scored(id: &str, score: f64) -> (String, f64) {
(id.to_string(), score)
}
#[test]
fn shadow_rewards_are_monotonic_non_decreasing() {
let merged = vec![
scored("a", 0.5),
scored("b", 0.3),
scored("c", 0.1),
scored("d", 0.1),
];
let rewards = shadow_rewards_for_k_set(&merged, &SHADOW_K_SET);
assert_eq!(rewards.len(), SHADOW_K_SET.len());
for window in rewards.windows(2) {
assert!(
window[1].1 >= window[0].1,
"reward must not decrease as k grows: {window:?}"
);
}
}
#[test]
fn shadow_reward_is_one_when_k_covers_all() {
let merged = vec![scored("a", 0.6), scored("b", 0.4)];
let rewards = shadow_rewards_for_k_set(&merged, &[10]);
assert_eq!(rewards.len(), 1);
assert_eq!(rewards[0].0, 10);
assert!((rewards[0].1 - 1.0).abs() < 1e-12);
}
#[test]
fn shadow_rewards_empty_merged_yields_empty() {
let rewards = shadow_rewards_for_k_set(&[], &SHADOW_K_SET);
assert!(rewards.is_empty());
}
#[test]
fn shadow_rewards_non_positive_total_yields_empty() {
let merged = vec![scored("a", 0.0), scored("b", 0.0)];
let rewards = shadow_rewards_for_k_set(&merged, &SHADOW_K_SET);
assert!(rewards.is_empty());
}
#[test]
fn shadow_reward_single_element_is_one() {
let merged = vec![scored("a", 0.42)];
let rewards = shadow_rewards_for_k_set(&merged, &SHADOW_K_SET);
assert_eq!(rewards.len(), SHADOW_K_SET.len());
for (_, reward) in rewards {
assert!((reward - 1.0).abs() < 1e-12);
}
}
}