use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AggregationStrategy {
ScoreSum,
ScoreMax,
ScoreAverage,
RankFusion,
WeightedCombination,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub doc_id: String,
pub score: f64,
pub source: String,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct AggregatedResult {
pub doc_id: String,
pub final_score: f64,
pub sources: Vec<String>,
pub source_scores: Vec<(String, f64)>,
pub rank: usize,
}
#[derive(Debug, Clone)]
pub struct AggregatorConfig {
pub strategy: AggregationStrategy,
pub max_results: usize,
pub min_score_threshold: f64,
pub source_weights: HashMap<String, f64>,
pub rrf_k: f64,
}
impl Default for AggregatorConfig {
fn default() -> Self {
Self {
strategy: AggregationStrategy::RankFusion,
max_results: 100,
min_score_threshold: 0.0,
source_weights: HashMap::new(),
rrf_k: 60.0,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AggregatorStats {
pub aggregations_performed: u64,
pub total_input_results: u64,
pub total_output_results: u64,
pub avg_compression_ratio: f64,
}
pub struct ResultAggregator {
config: AggregatorConfig,
result_sets: HashMap<String, Vec<SearchResult>>,
stats: AggregatorStats,
}
impl ResultAggregator {
pub fn new(config: AggregatorConfig) -> Self {
Self {
config,
result_sets: HashMap::new(),
stats: AggregatorStats::default(),
}
}
pub fn add_results(&mut self, source: &str, results: Vec<SearchResult>) {
self.result_sets
.entry(source.to_string())
.or_default()
.extend(results);
}
pub fn aggregate(&mut self) -> Vec<AggregatedResult> {
let input_count: u64 = self.result_sets.values().map(|v| v.len() as u64).sum();
let mut results = match self.config.strategy {
AggregationStrategy::ScoreSum => Self::aggregate_score_sum(&self.result_sets),
AggregationStrategy::ScoreMax => Self::aggregate_score_max(&self.result_sets),
AggregationStrategy::ScoreAverage => Self::aggregate_score_avg(&self.result_sets),
AggregationStrategy::RankFusion => {
Self::aggregate_rrf(&self.result_sets, self.config.rrf_k)
}
AggregationStrategy::WeightedCombination => {
Self::aggregate_weighted(&self.result_sets, &self.config.source_weights)
}
};
self.apply_threshold(&mut results);
results.sort_by(|a, b| {
b.final_score
.partial_cmp(&a.final_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
if results.len() > self.config.max_results {
results.truncate(self.config.max_results);
}
for (i, r) in results.iter_mut().enumerate() {
r.rank = i + 1;
}
let output_count = results.len() as u64;
self.stats.aggregations_performed += 1;
self.stats.total_input_results += input_count;
self.stats.total_output_results += output_count;
let n = self.stats.aggregations_performed as f64;
let ratio = if output_count > 0 {
input_count as f64 / output_count as f64
} else if input_count > 0 {
input_count as f64
} else {
1.0
};
self.stats.avg_compression_ratio =
self.stats.avg_compression_ratio * ((n - 1.0) / n) + ratio / n;
results
}
pub fn aggregate_rrf(
result_sets: &HashMap<String, Vec<SearchResult>>,
k: f64,
) -> Vec<AggregatedResult> {
let mut doc_rrf_scores: HashMap<String, f64> = HashMap::new();
let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
for (source, results) in result_sets {
let mut sorted: Vec<&SearchResult> = results.iter().collect();
sorted.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut seen_in_source: HashMap<&str, bool> = HashMap::new();
let mut rank: usize = 0;
for res in &sorted {
if seen_in_source.contains_key(res.doc_id.as_str()) {
continue;
}
seen_in_source.insert(&res.doc_id, true);
rank += 1;
let rrf_score = 1.0 / (k + rank as f64);
*doc_rrf_scores.entry(res.doc_id.clone()).or_default() += rrf_score;
doc_sources
.entry(res.doc_id.clone())
.or_default()
.push(source.clone());
doc_source_scores
.entry(res.doc_id.clone())
.or_default()
.push((source.clone(), res.score));
}
}
Self::build_aggregated_results(doc_rrf_scores, doc_sources, doc_source_scores)
}
pub fn aggregate_score_sum(
result_sets: &HashMap<String, Vec<SearchResult>>,
) -> Vec<AggregatedResult> {
let mut doc_scores: HashMap<String, f64> = HashMap::new();
let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
for (source, results) in result_sets {
let mut seen: HashMap<&str, bool> = HashMap::new();
for res in results {
if seen.contains_key(res.doc_id.as_str()) {
continue;
}
seen.insert(&res.doc_id, true);
*doc_scores.entry(res.doc_id.clone()).or_default() += res.score;
doc_sources
.entry(res.doc_id.clone())
.or_default()
.push(source.clone());
doc_source_scores
.entry(res.doc_id.clone())
.or_default()
.push((source.clone(), res.score));
}
}
Self::build_aggregated_results(doc_scores, doc_sources, doc_source_scores)
}
pub fn aggregate_score_max(
result_sets: &HashMap<String, Vec<SearchResult>>,
) -> Vec<AggregatedResult> {
let mut doc_scores: HashMap<String, f64> = HashMap::new();
let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
for (source, results) in result_sets {
let mut seen: HashMap<&str, bool> = HashMap::new();
for res in results {
if seen.contains_key(res.doc_id.as_str()) {
continue;
}
seen.insert(&res.doc_id, true);
let entry = doc_scores
.entry(res.doc_id.clone())
.or_insert(f64::NEG_INFINITY);
if res.score > *entry {
*entry = res.score;
}
doc_sources
.entry(res.doc_id.clone())
.or_default()
.push(source.clone());
doc_source_scores
.entry(res.doc_id.clone())
.or_default()
.push((source.clone(), res.score));
}
}
Self::build_aggregated_results(doc_scores, doc_sources, doc_source_scores)
}
pub fn aggregate_score_avg(
result_sets: &HashMap<String, Vec<SearchResult>>,
) -> Vec<AggregatedResult> {
let mut doc_score_sums: HashMap<String, f64> = HashMap::new();
let mut doc_score_counts: HashMap<String, usize> = HashMap::new();
let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
for (source, results) in result_sets {
let mut seen: HashMap<&str, bool> = HashMap::new();
for res in results {
if seen.contains_key(res.doc_id.as_str()) {
continue;
}
seen.insert(&res.doc_id, true);
*doc_score_sums.entry(res.doc_id.clone()).or_default() += res.score;
*doc_score_counts.entry(res.doc_id.clone()).or_default() += 1;
doc_sources
.entry(res.doc_id.clone())
.or_default()
.push(source.clone());
doc_source_scores
.entry(res.doc_id.clone())
.or_default()
.push((source.clone(), res.score));
}
}
let doc_scores: HashMap<String, f64> = doc_score_sums
.into_iter()
.map(|(doc_id, sum)| {
let count = doc_score_counts.get(&doc_id).copied().unwrap_or(1);
(doc_id, sum / count as f64)
})
.collect();
Self::build_aggregated_results(doc_scores, doc_sources, doc_source_scores)
}
pub fn aggregate_weighted(
result_sets: &HashMap<String, Vec<SearchResult>>,
weights: &HashMap<String, f64>,
) -> Vec<AggregatedResult> {
let mut doc_scores: HashMap<String, f64> = HashMap::new();
let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
for (source, results) in result_sets {
let weight = weights.get(source).copied().unwrap_or(1.0);
let mut seen: HashMap<&str, bool> = HashMap::new();
for res in results {
if seen.contains_key(res.doc_id.as_str()) {
continue;
}
seen.insert(&res.doc_id, true);
*doc_scores.entry(res.doc_id.clone()).or_default() += res.score * weight;
doc_sources
.entry(res.doc_id.clone())
.or_default()
.push(source.clone());
doc_source_scores
.entry(res.doc_id.clone())
.or_default()
.push((source.clone(), res.score));
}
}
Self::build_aggregated_results(doc_scores, doc_sources, doc_source_scores)
}
pub fn clear(&mut self) {
self.result_sets.clear();
}
pub fn source_count(&self) -> usize {
self.result_sets.len()
}
pub fn total_results(&self) -> usize {
self.result_sets.values().map(|v| v.len()).sum()
}
pub fn stats(&self) -> &AggregatorStats {
&self.stats
}
pub fn apply_threshold(&self, results: &mut Vec<AggregatedResult>) {
results.retain(|r| r.final_score >= self.config.min_score_threshold);
}
fn build_aggregated_results(
doc_scores: HashMap<String, f64>,
doc_sources: HashMap<String, Vec<String>>,
doc_source_scores: HashMap<String, Vec<(String, f64)>>,
) -> Vec<AggregatedResult> {
let mut results: Vec<AggregatedResult> = doc_scores
.into_iter()
.map(|(doc_id, final_score)| {
let sources = doc_sources.get(&doc_id).cloned().unwrap_or_default();
let source_scores = doc_source_scores.get(&doc_id).cloned().unwrap_or_default();
AggregatedResult {
doc_id,
final_score,
sources,
source_scores,
rank: 0, }
})
.collect();
results.sort_by(|a, b| {
b.final_score
.partial_cmp(&a.final_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_result(doc_id: &str, score: f64, source: &str) -> SearchResult {
SearchResult {
doc_id: doc_id.to_string(),
score,
source: source.to_string(),
metadata: HashMap::new(),
}
}
fn make_result_with_meta(
doc_id: &str,
score: f64,
source: &str,
meta: Vec<(&str, &str)>,
) -> SearchResult {
let metadata: HashMap<String, String> = meta
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
SearchResult {
doc_id: doc_id.to_string(),
score,
source: source.to_string(),
metadata,
}
}
#[test]
fn test_score_sum_basic() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results(
"a",
vec![make_result("d1", 0.5, "a"), make_result("d2", 0.3, "a")],
);
agg.add_results(
"b",
vec![make_result("d1", 0.4, "b"), make_result("d3", 0.6, "b")],
);
let results = agg.aggregate();
let d1 = results
.iter()
.find(|r| r.doc_id == "d1")
.expect("d1 present");
assert!(
(d1.final_score - 0.9).abs() < 1e-9,
"d1 sum = 0.5+0.4 = 0.9"
);
}
#[test]
fn test_score_sum_single_source() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.7, "a")]);
let results = agg.aggregate();
assert_eq!(results.len(), 1);
assert!((results[0].final_score - 0.7).abs() < 1e-9);
}
#[test]
fn test_score_max_basic() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreMax,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
agg.add_results("b", vec![make_result("d1", 0.9, "b")]);
let results = agg.aggregate();
let d1 = results
.iter()
.find(|r| r.doc_id == "d1")
.expect("d1 present");
assert!((d1.final_score - 0.9).abs() < 1e-9);
}
#[test]
fn test_score_max_picks_highest() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreMax,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.1, "a")]);
agg.add_results("b", vec![make_result("d1", 0.3, "b")]);
agg.add_results("c", vec![make_result("d1", 0.2, "c")]);
let results = agg.aggregate();
assert!((results[0].final_score - 0.3).abs() < 1e-9);
}
#[test]
fn test_score_avg_basic() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreAverage,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.6, "a")]);
agg.add_results("b", vec![make_result("d1", 0.4, "b")]);
let results = agg.aggregate();
let d1 = results
.iter()
.find(|r| r.doc_id == "d1")
.expect("d1 present");
assert!((d1.final_score - 0.5).abs() < 1e-9, "avg(0.6, 0.4) = 0.5");
}
#[test]
fn test_score_avg_three_sources() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreAverage,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.3, "a")]);
agg.add_results("b", vec![make_result("d1", 0.6, "b")]);
agg.add_results("c", vec![make_result("d1", 0.9, "c")]);
let results = agg.aggregate();
assert!((results[0].final_score - 0.6).abs() < 1e-9);
}
#[test]
fn test_rrf_formula() {
let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
sets.insert("a".to_string(), vec![make_result("d1", 1.0, "a")]);
let results = ResultAggregator::aggregate_rrf(&sets, 60.0);
let expected = 1.0 / 61.0;
assert!(
(results[0].final_score - expected).abs() < 1e-9,
"RRF score for rank-1 doc with k=60 should be 1/61"
);
}
#[test]
fn test_rrf_multi_source() {
let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
sets.insert(
"a".to_string(),
vec![make_result("d1", 1.0, "a"), make_result("d2", 0.5, "a")],
);
sets.insert(
"b".to_string(),
vec![make_result("d1", 0.9, "b"), make_result("d3", 0.8, "b")],
);
let results = ResultAggregator::aggregate_rrf(&sets, 60.0);
let d1 = results
.iter()
.find(|r| r.doc_id == "d1")
.expect("d1 present");
let expected = 2.0 / 61.0; assert!((d1.final_score - expected).abs() < 1e-9);
}
#[test]
fn test_rrf_respects_k_parameter() {
let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
sets.insert("a".to_string(), vec![make_result("d1", 1.0, "a")]);
let results_low_k = ResultAggregator::aggregate_rrf(&sets, 10.0);
let results_high_k = ResultAggregator::aggregate_rrf(&sets, 100.0);
assert!(results_low_k[0].final_score > results_high_k[0].final_score);
}
#[test]
fn test_rrf_rank_ordering() {
let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
sets.insert(
"a".to_string(),
vec![make_result("d1", 1.0, "a"), make_result("d2", 0.5, "a")],
);
let results = ResultAggregator::aggregate_rrf(&sets, 60.0);
assert!(results[0].final_score > results[1].final_score);
let expected_d1 = 1.0 / 61.0;
let expected_d2 = 1.0 / 62.0;
assert!((results[0].final_score - expected_d1).abs() < 1e-9);
assert!((results[1].final_score - expected_d2).abs() < 1e-9);
}
#[test]
fn test_weighted_basic() {
let mut weights = HashMap::new();
weights.insert("a".to_string(), 2.0);
weights.insert("b".to_string(), 1.0);
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::WeightedCombination,
source_weights: weights,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
agg.add_results("b", vec![make_result("d1", 0.5, "b")]);
let results = agg.aggregate();
let d1 = results
.iter()
.find(|r| r.doc_id == "d1")
.expect("d1 present");
assert!((d1.final_score - 1.5).abs() < 1e-9);
}
#[test]
fn test_weighted_default_weight() {
let weights = HashMap::new();
let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
sets.insert(
"unknown".to_string(),
vec![make_result("d1", 0.7, "unknown")],
);
let results = ResultAggregator::aggregate_weighted(&sets, &weights);
assert!((results[0].final_score - 0.7).abs() < 1e-9);
}
#[test]
fn test_weighted_zero_weight() {
let mut weights = HashMap::new();
weights.insert("a".to_string(), 0.0);
let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
sets.insert("a".to_string(), vec![make_result("d1", 0.9, "a")]);
let results = ResultAggregator::aggregate_weighted(&sets, &weights);
assert!(
(results[0].final_score).abs() < 1e-9,
"zero weight => zero score"
);
}
#[test]
fn test_threshold_filters_low_scores() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
min_score_threshold: 0.5,
..Default::default()
});
agg.add_results(
"a",
vec![
make_result("d1", 0.8, "a"),
make_result("d2", 0.3, "a"),
make_result("d3", 0.5, "a"),
],
);
let results = agg.aggregate();
assert_eq!(results.len(), 2); assert!(results.iter().all(|r| r.final_score >= 0.5));
}
#[test]
fn test_threshold_zero_passes_all() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
min_score_threshold: 0.0,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.001, "a")]);
let results = agg.aggregate();
assert_eq!(results.len(), 1);
}
#[test]
fn test_max_results_truncation() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
max_results: 2,
..Default::default()
});
agg.add_results(
"a",
vec![
make_result("d1", 0.9, "a"),
make_result("d2", 0.8, "a"),
make_result("d3", 0.7, "a"),
],
);
let results = agg.aggregate();
assert_eq!(results.len(), 2);
assert_eq!(results[0].doc_id, "d1");
assert_eq!(results[1].doc_id, "d2");
}
#[test]
fn test_dedup_within_source() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results(
"a",
vec![make_result("d1", 0.5, "a"), make_result("d1", 0.3, "a")],
);
let results = agg.aggregate();
assert_eq!(results.len(), 1);
assert!((results[0].final_score - 0.5).abs() < 1e-9);
}
#[test]
fn test_dedup_across_sources_merges() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
agg.add_results("b", vec![make_result("d1", 0.4, "b")]);
let results = agg.aggregate();
assert_eq!(results.len(), 1); assert!(results[0].sources.len() >= 2);
}
#[test]
fn test_empty_no_sources() {
let mut agg = ResultAggregator::new(AggregatorConfig::default());
let results = agg.aggregate();
assert!(results.is_empty());
}
#[test]
fn test_empty_source_list() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results("a", vec![]);
let results = agg.aggregate();
assert!(results.is_empty());
}
#[test]
fn test_single_source_passthrough() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results(
"only",
vec![
make_result("d1", 0.9, "only"),
make_result("d2", 0.7, "only"),
],
);
let results = agg.aggregate();
assert_eq!(results.len(), 2);
assert_eq!(results[0].doc_id, "d1");
assert_eq!(results[1].doc_id, "d2");
assert_eq!(results[0].sources, vec!["only"]);
}
#[test]
fn test_stats_initial() {
let agg = ResultAggregator::new(AggregatorConfig::default());
assert_eq!(agg.stats().aggregations_performed, 0);
assert_eq!(agg.stats().total_input_results, 0);
assert_eq!(agg.stats().total_output_results, 0);
}
#[test]
fn test_stats_after_aggregate() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results(
"a",
vec![make_result("d1", 0.5, "a"), make_result("d2", 0.3, "a")],
);
agg.add_results("b", vec![make_result("d1", 0.4, "b")]);
let _results = agg.aggregate();
assert_eq!(agg.stats().aggregations_performed, 1);
assert_eq!(agg.stats().total_input_results, 3);
assert_eq!(agg.stats().total_output_results, 2);
}
#[test]
fn test_stats_multiple_aggregations() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
let _r1 = agg.aggregate();
agg.clear();
agg.add_results(
"b",
vec![make_result("d2", 0.8, "b"), make_result("d3", 0.3, "b")],
);
let _r2 = agg.aggregate();
assert_eq!(agg.stats().aggregations_performed, 2);
assert_eq!(agg.stats().total_input_results, 3); assert_eq!(agg.stats().total_output_results, 3); }
#[test]
fn test_source_weights_high_boost() {
let mut weights = HashMap::new();
weights.insert("premium".to_string(), 10.0);
weights.insert("basic".to_string(), 1.0);
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::WeightedCombination,
source_weights: weights,
..Default::default()
});
agg.add_results("premium", vec![make_result("d1", 0.3, "premium")]);
agg.add_results("basic", vec![make_result("d2", 0.9, "basic")]);
let results = agg.aggregate();
assert_eq!(results[0].doc_id, "d1");
assert!((results[0].final_score - 3.0).abs() < 1e-9);
}
#[test]
fn test_clear_resets_results() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
assert_eq!(agg.source_count(), 1);
assert_eq!(agg.total_results(), 1);
agg.clear();
assert_eq!(agg.source_count(), 0);
assert_eq!(agg.total_results(), 0);
let results = agg.aggregate();
assert!(results.is_empty());
}
#[test]
fn test_clear_and_reaggregate() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
let r1 = agg.aggregate();
assert_eq!(r1.len(), 1);
agg.clear();
agg.add_results("b", vec![make_result("d2", 0.8, "b")]);
let r2 = agg.aggregate();
assert_eq!(r2.len(), 1);
assert_eq!(r2[0].doc_id, "d2");
}
#[test]
fn test_rank_assignment() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results(
"a",
vec![
make_result("d1", 0.9, "a"),
make_result("d2", 0.7, "a"),
make_result("d3", 0.5, "a"),
],
);
let results = agg.aggregate();
assert_eq!(results[0].rank, 1);
assert_eq!(results[1].rank, 2);
assert_eq!(results[2].rank, 3);
}
#[test]
fn test_multi_source_merge_three() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.3, "a")]);
agg.add_results("b", vec![make_result("d1", 0.3, "b")]);
agg.add_results("c", vec![make_result("d1", 0.3, "c")]);
let results = agg.aggregate();
assert_eq!(results.len(), 1);
assert!((results[0].final_score - 0.9).abs() < 1e-9);
assert_eq!(results[0].sources.len(), 3);
}
#[test]
fn test_source_count() {
let mut agg = ResultAggregator::new(AggregatorConfig::default());
assert_eq!(agg.source_count(), 0);
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
assert_eq!(agg.source_count(), 1);
agg.add_results("b", vec![make_result("d2", 0.3, "b")]);
assert_eq!(agg.source_count(), 2);
}
#[test]
fn test_total_results() {
let mut agg = ResultAggregator::new(AggregatorConfig::default());
assert_eq!(agg.total_results(), 0);
agg.add_results(
"a",
vec![make_result("d1", 0.5, "a"), make_result("d2", 0.3, "a")],
);
assert_eq!(agg.total_results(), 2);
agg.add_results("b", vec![make_result("d3", 0.4, "b")]);
assert_eq!(agg.total_results(), 3);
}
#[test]
fn test_metadata_preserved() {
let r = make_result_with_meta("d1", 0.5, "a", vec![("key", "value")]);
assert_eq!(r.metadata.get("key").map(|s| s.as_str()), Some("value"));
}
#[test]
fn test_source_scores_tracked() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
agg.add_results("b", vec![make_result("d1", 0.3, "b")]);
let results = agg.aggregate();
let d1 = results
.iter()
.find(|r| r.doc_id == "d1")
.expect("d1 present");
assert_eq!(d1.source_scores.len(), 2);
}
#[test]
fn test_default_config() {
let config = AggregatorConfig::default();
assert_eq!(config.strategy, AggregationStrategy::RankFusion);
assert_eq!(config.max_results, 100);
assert!((config.min_score_threshold).abs() < 1e-9);
assert!((config.rrf_k - 60.0).abs() < 1e-9);
}
#[test]
fn test_aggregate_dispatches_rrf() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::RankFusion,
rrf_k: 60.0,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 1.0, "a")]);
let results = agg.aggregate();
let expected = 1.0 / 61.0;
assert!((results[0].final_score - expected).abs() < 1e-9);
}
#[test]
fn test_aggregate_dispatches_weighted() {
let mut weights = HashMap::new();
weights.insert("a".to_string(), 3.0);
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::WeightedCombination,
source_weights: weights,
..Default::default()
});
agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
let results = agg.aggregate();
assert!((results[0].final_score - 1.5).abs() < 1e-9);
}
#[test]
fn test_compression_ratio() {
let mut agg = ResultAggregator::new(AggregatorConfig {
strategy: AggregationStrategy::ScoreSum,
..Default::default()
});
agg.add_results(
"a",
vec![make_result("d1", 0.5, "a"), make_result("d2", 0.3, "a")],
);
agg.add_results(
"b",
vec![make_result("d1", 0.4, "b"), make_result("d2", 0.2, "b")],
);
let _results = agg.aggregate();
assert!((agg.stats().avg_compression_ratio - 2.0).abs() < 1e-9);
}
}