1use std::collections::HashMap;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum AggregationStrategy {
13 ScoreSum,
15 ScoreMax,
17 ScoreAverage,
19 RankFusion,
21 WeightedCombination,
23}
24
25#[derive(Debug, Clone)]
27pub struct SearchResult {
28 pub doc_id: String,
30 pub score: f64,
32 pub source: String,
34 pub metadata: HashMap<String, String>,
36}
37
38#[derive(Debug, Clone)]
40pub struct AggregatedResult {
41 pub doc_id: String,
43 pub final_score: f64,
45 pub sources: Vec<String>,
47 pub source_scores: Vec<(String, f64)>,
49 pub rank: usize,
51}
52
53#[derive(Debug, Clone)]
55pub struct AggregatorConfig {
56 pub strategy: AggregationStrategy,
58 pub max_results: usize,
60 pub min_score_threshold: f64,
62 pub source_weights: HashMap<String, f64>,
64 pub rrf_k: f64,
66}
67
68impl Default for AggregatorConfig {
69 fn default() -> Self {
70 Self {
71 strategy: AggregationStrategy::RankFusion,
72 max_results: 100,
73 min_score_threshold: 0.0,
74 source_weights: HashMap::new(),
75 rrf_k: 60.0,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct AggregatorStats {
83 pub aggregations_performed: u64,
85 pub total_input_results: u64,
87 pub total_output_results: u64,
89 pub avg_compression_ratio: f64,
91}
92
93pub struct ResultAggregator {
98 config: AggregatorConfig,
99 result_sets: HashMap<String, Vec<SearchResult>>,
100 stats: AggregatorStats,
101}
102
103impl ResultAggregator {
104 pub fn new(config: AggregatorConfig) -> Self {
106 Self {
107 config,
108 result_sets: HashMap::new(),
109 stats: AggregatorStats::default(),
110 }
111 }
112
113 pub fn add_results(&mut self, source: &str, results: Vec<SearchResult>) {
115 self.result_sets
116 .entry(source.to_string())
117 .or_default()
118 .extend(results);
119 }
120
121 pub fn aggregate(&mut self) -> Vec<AggregatedResult> {
125 let input_count: u64 = self.result_sets.values().map(|v| v.len() as u64).sum();
126
127 let mut results = match self.config.strategy {
128 AggregationStrategy::ScoreSum => Self::aggregate_score_sum(&self.result_sets),
129 AggregationStrategy::ScoreMax => Self::aggregate_score_max(&self.result_sets),
130 AggregationStrategy::ScoreAverage => Self::aggregate_score_avg(&self.result_sets),
131 AggregationStrategy::RankFusion => {
132 Self::aggregate_rrf(&self.result_sets, self.config.rrf_k)
133 }
134 AggregationStrategy::WeightedCombination => {
135 Self::aggregate_weighted(&self.result_sets, &self.config.source_weights)
136 }
137 };
138
139 self.apply_threshold(&mut results);
140
141 results.sort_by(|a, b| {
143 b.final_score
144 .partial_cmp(&a.final_score)
145 .unwrap_or(std::cmp::Ordering::Equal)
146 });
147
148 if results.len() > self.config.max_results {
149 results.truncate(self.config.max_results);
150 }
151
152 for (i, r) in results.iter_mut().enumerate() {
154 r.rank = i + 1;
155 }
156
157 let output_count = results.len() as u64;
159 self.stats.aggregations_performed += 1;
160 self.stats.total_input_results += input_count;
161 self.stats.total_output_results += output_count;
162
163 let n = self.stats.aggregations_performed as f64;
164 let ratio = if output_count > 0 {
165 input_count as f64 / output_count as f64
166 } else if input_count > 0 {
167 input_count as f64
168 } else {
169 1.0
170 };
171 self.stats.avg_compression_ratio =
173 self.stats.avg_compression_ratio * ((n - 1.0) / n) + ratio / n;
174
175 results
176 }
177
178 pub fn aggregate_rrf(
183 result_sets: &HashMap<String, Vec<SearchResult>>,
184 k: f64,
185 ) -> Vec<AggregatedResult> {
186 let mut doc_rrf_scores: HashMap<String, f64> = HashMap::new();
188 let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
189 let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
190
191 for (source, results) in result_sets {
192 let mut sorted: Vec<&SearchResult> = results.iter().collect();
194 sorted.sort_by(|a, b| {
195 b.score
196 .partial_cmp(&a.score)
197 .unwrap_or(std::cmp::Ordering::Equal)
198 });
199
200 let mut seen_in_source: HashMap<&str, bool> = HashMap::new();
202 let mut rank: usize = 0;
203
204 for res in &sorted {
205 if seen_in_source.contains_key(res.doc_id.as_str()) {
206 continue;
207 }
208 seen_in_source.insert(&res.doc_id, true);
209 rank += 1;
210
211 let rrf_score = 1.0 / (k + rank as f64);
212 *doc_rrf_scores.entry(res.doc_id.clone()).or_default() += rrf_score;
213
214 doc_sources
215 .entry(res.doc_id.clone())
216 .or_default()
217 .push(source.clone());
218
219 doc_source_scores
220 .entry(res.doc_id.clone())
221 .or_default()
222 .push((source.clone(), res.score));
223 }
224 }
225
226 Self::build_aggregated_results(doc_rrf_scores, doc_sources, doc_source_scores)
227 }
228
229 pub fn aggregate_score_sum(
231 result_sets: &HashMap<String, Vec<SearchResult>>,
232 ) -> Vec<AggregatedResult> {
233 let mut doc_scores: HashMap<String, f64> = HashMap::new();
234 let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
235 let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
236
237 for (source, results) in result_sets {
238 let mut seen: HashMap<&str, bool> = HashMap::new();
239 for res in results {
240 if seen.contains_key(res.doc_id.as_str()) {
241 continue;
242 }
243 seen.insert(&res.doc_id, true);
244
245 *doc_scores.entry(res.doc_id.clone()).or_default() += res.score;
246
247 doc_sources
248 .entry(res.doc_id.clone())
249 .or_default()
250 .push(source.clone());
251
252 doc_source_scores
253 .entry(res.doc_id.clone())
254 .or_default()
255 .push((source.clone(), res.score));
256 }
257 }
258
259 Self::build_aggregated_results(doc_scores, doc_sources, doc_source_scores)
260 }
261
262 pub fn aggregate_score_max(
264 result_sets: &HashMap<String, Vec<SearchResult>>,
265 ) -> Vec<AggregatedResult> {
266 let mut doc_scores: HashMap<String, f64> = HashMap::new();
267 let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
268 let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
269
270 for (source, results) in result_sets {
271 let mut seen: HashMap<&str, bool> = HashMap::new();
272 for res in results {
273 if seen.contains_key(res.doc_id.as_str()) {
274 continue;
275 }
276 seen.insert(&res.doc_id, true);
277
278 let entry = doc_scores
279 .entry(res.doc_id.clone())
280 .or_insert(f64::NEG_INFINITY);
281 if res.score > *entry {
282 *entry = res.score;
283 }
284
285 doc_sources
286 .entry(res.doc_id.clone())
287 .or_default()
288 .push(source.clone());
289
290 doc_source_scores
291 .entry(res.doc_id.clone())
292 .or_default()
293 .push((source.clone(), res.score));
294 }
295 }
296
297 Self::build_aggregated_results(doc_scores, doc_sources, doc_source_scores)
298 }
299
300 pub fn aggregate_score_avg(
302 result_sets: &HashMap<String, Vec<SearchResult>>,
303 ) -> Vec<AggregatedResult> {
304 let mut doc_score_sums: HashMap<String, f64> = HashMap::new();
305 let mut doc_score_counts: HashMap<String, usize> = HashMap::new();
306 let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
307 let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
308
309 for (source, results) in result_sets {
310 let mut seen: HashMap<&str, bool> = HashMap::new();
311 for res in results {
312 if seen.contains_key(res.doc_id.as_str()) {
313 continue;
314 }
315 seen.insert(&res.doc_id, true);
316
317 *doc_score_sums.entry(res.doc_id.clone()).or_default() += res.score;
318 *doc_score_counts.entry(res.doc_id.clone()).or_default() += 1;
319
320 doc_sources
321 .entry(res.doc_id.clone())
322 .or_default()
323 .push(source.clone());
324
325 doc_source_scores
326 .entry(res.doc_id.clone())
327 .or_default()
328 .push((source.clone(), res.score));
329 }
330 }
331
332 let doc_scores: HashMap<String, f64> = doc_score_sums
333 .into_iter()
334 .map(|(doc_id, sum)| {
335 let count = doc_score_counts.get(&doc_id).copied().unwrap_or(1);
336 (doc_id, sum / count as f64)
337 })
338 .collect();
339
340 Self::build_aggregated_results(doc_scores, doc_sources, doc_source_scores)
341 }
342
343 pub fn aggregate_weighted(
347 result_sets: &HashMap<String, Vec<SearchResult>>,
348 weights: &HashMap<String, f64>,
349 ) -> Vec<AggregatedResult> {
350 let mut doc_scores: HashMap<String, f64> = HashMap::new();
351 let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
352 let mut doc_source_scores: HashMap<String, Vec<(String, f64)>> = HashMap::new();
353
354 for (source, results) in result_sets {
355 let weight = weights.get(source).copied().unwrap_or(1.0);
356 let mut seen: HashMap<&str, bool> = HashMap::new();
357
358 for res in results {
359 if seen.contains_key(res.doc_id.as_str()) {
360 continue;
361 }
362 seen.insert(&res.doc_id, true);
363
364 *doc_scores.entry(res.doc_id.clone()).or_default() += res.score * weight;
365
366 doc_sources
367 .entry(res.doc_id.clone())
368 .or_default()
369 .push(source.clone());
370
371 doc_source_scores
372 .entry(res.doc_id.clone())
373 .or_default()
374 .push((source.clone(), res.score));
375 }
376 }
377
378 Self::build_aggregated_results(doc_scores, doc_sources, doc_source_scores)
379 }
380
381 pub fn clear(&mut self) {
383 self.result_sets.clear();
384 }
385
386 pub fn source_count(&self) -> usize {
388 self.result_sets.len()
389 }
390
391 pub fn total_results(&self) -> usize {
393 self.result_sets.values().map(|v| v.len()).sum()
394 }
395
396 pub fn stats(&self) -> &AggregatorStats {
398 &self.stats
399 }
400
401 pub fn apply_threshold(&self, results: &mut Vec<AggregatedResult>) {
403 results.retain(|r| r.final_score >= self.config.min_score_threshold);
404 }
405
406 fn build_aggregated_results(
410 doc_scores: HashMap<String, f64>,
411 doc_sources: HashMap<String, Vec<String>>,
412 doc_source_scores: HashMap<String, Vec<(String, f64)>>,
413 ) -> Vec<AggregatedResult> {
414 let mut results: Vec<AggregatedResult> = doc_scores
415 .into_iter()
416 .map(|(doc_id, final_score)| {
417 let sources = doc_sources.get(&doc_id).cloned().unwrap_or_default();
418 let source_scores = doc_source_scores.get(&doc_id).cloned().unwrap_or_default();
419 AggregatedResult {
420 doc_id,
421 final_score,
422 sources,
423 source_scores,
424 rank: 0, }
426 })
427 .collect();
428
429 results.sort_by(|a, b| {
431 b.final_score
432 .partial_cmp(&a.final_score)
433 .unwrap_or(std::cmp::Ordering::Equal)
434 });
435
436 results
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 fn make_result(doc_id: &str, score: f64, source: &str) -> SearchResult {
445 SearchResult {
446 doc_id: doc_id.to_string(),
447 score,
448 source: source.to_string(),
449 metadata: HashMap::new(),
450 }
451 }
452
453 fn make_result_with_meta(
454 doc_id: &str,
455 score: f64,
456 source: &str,
457 meta: Vec<(&str, &str)>,
458 ) -> SearchResult {
459 let metadata: HashMap<String, String> = meta
460 .into_iter()
461 .map(|(k, v)| (k.to_string(), v.to_string()))
462 .collect();
463 SearchResult {
464 doc_id: doc_id.to_string(),
465 score,
466 source: source.to_string(),
467 metadata,
468 }
469 }
470
471 #[test]
474 fn test_score_sum_basic() {
475 let mut agg = ResultAggregator::new(AggregatorConfig {
476 strategy: AggregationStrategy::ScoreSum,
477 ..Default::default()
478 });
479 agg.add_results(
480 "a",
481 vec![make_result("d1", 0.5, "a"), make_result("d2", 0.3, "a")],
482 );
483 agg.add_results(
484 "b",
485 vec![make_result("d1", 0.4, "b"), make_result("d3", 0.6, "b")],
486 );
487
488 let results = agg.aggregate();
489 let d1 = results
490 .iter()
491 .find(|r| r.doc_id == "d1")
492 .expect("d1 present");
493 assert!(
494 (d1.final_score - 0.9).abs() < 1e-9,
495 "d1 sum = 0.5+0.4 = 0.9"
496 );
497 }
498
499 #[test]
500 fn test_score_sum_single_source() {
501 let mut agg = ResultAggregator::new(AggregatorConfig {
502 strategy: AggregationStrategy::ScoreSum,
503 ..Default::default()
504 });
505 agg.add_results("a", vec![make_result("d1", 0.7, "a")]);
506 let results = agg.aggregate();
507 assert_eq!(results.len(), 1);
508 assert!((results[0].final_score - 0.7).abs() < 1e-9);
509 }
510
511 #[test]
514 fn test_score_max_basic() {
515 let mut agg = ResultAggregator::new(AggregatorConfig {
516 strategy: AggregationStrategy::ScoreMax,
517 ..Default::default()
518 });
519 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
520 agg.add_results("b", vec![make_result("d1", 0.9, "b")]);
521
522 let results = agg.aggregate();
523 let d1 = results
524 .iter()
525 .find(|r| r.doc_id == "d1")
526 .expect("d1 present");
527 assert!((d1.final_score - 0.9).abs() < 1e-9);
528 }
529
530 #[test]
531 fn test_score_max_picks_highest() {
532 let mut agg = ResultAggregator::new(AggregatorConfig {
533 strategy: AggregationStrategy::ScoreMax,
534 ..Default::default()
535 });
536 agg.add_results("a", vec![make_result("d1", 0.1, "a")]);
537 agg.add_results("b", vec![make_result("d1", 0.3, "b")]);
538 agg.add_results("c", vec![make_result("d1", 0.2, "c")]);
539
540 let results = agg.aggregate();
541 assert!((results[0].final_score - 0.3).abs() < 1e-9);
542 }
543
544 #[test]
547 fn test_score_avg_basic() {
548 let mut agg = ResultAggregator::new(AggregatorConfig {
549 strategy: AggregationStrategy::ScoreAverage,
550 ..Default::default()
551 });
552 agg.add_results("a", vec![make_result("d1", 0.6, "a")]);
553 agg.add_results("b", vec![make_result("d1", 0.4, "b")]);
554
555 let results = agg.aggregate();
556 let d1 = results
557 .iter()
558 .find(|r| r.doc_id == "d1")
559 .expect("d1 present");
560 assert!((d1.final_score - 0.5).abs() < 1e-9, "avg(0.6, 0.4) = 0.5");
561 }
562
563 #[test]
564 fn test_score_avg_three_sources() {
565 let mut agg = ResultAggregator::new(AggregatorConfig {
566 strategy: AggregationStrategy::ScoreAverage,
567 ..Default::default()
568 });
569 agg.add_results("a", vec![make_result("d1", 0.3, "a")]);
570 agg.add_results("b", vec![make_result("d1", 0.6, "b")]);
571 agg.add_results("c", vec![make_result("d1", 0.9, "c")]);
572
573 let results = agg.aggregate();
574 assert!((results[0].final_score - 0.6).abs() < 1e-9);
575 }
576
577 #[test]
580 fn test_rrf_formula() {
581 let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
583 sets.insert("a".to_string(), vec![make_result("d1", 1.0, "a")]);
584
585 let results = ResultAggregator::aggregate_rrf(&sets, 60.0);
586 let expected = 1.0 / 61.0;
587 assert!(
588 (results[0].final_score - expected).abs() < 1e-9,
589 "RRF score for rank-1 doc with k=60 should be 1/61"
590 );
591 }
592
593 #[test]
594 fn test_rrf_multi_source() {
595 let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
596 sets.insert(
598 "a".to_string(),
599 vec![make_result("d1", 1.0, "a"), make_result("d2", 0.5, "a")],
600 );
601 sets.insert(
602 "b".to_string(),
603 vec![make_result("d1", 0.9, "b"), make_result("d3", 0.8, "b")],
604 );
605
606 let results = ResultAggregator::aggregate_rrf(&sets, 60.0);
607 let d1 = results
608 .iter()
609 .find(|r| r.doc_id == "d1")
610 .expect("d1 present");
611 let expected = 2.0 / 61.0; assert!((d1.final_score - expected).abs() < 1e-9);
613 }
614
615 #[test]
616 fn test_rrf_respects_k_parameter() {
617 let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
618 sets.insert("a".to_string(), vec![make_result("d1", 1.0, "a")]);
619
620 let results_low_k = ResultAggregator::aggregate_rrf(&sets, 10.0);
621 let results_high_k = ResultAggregator::aggregate_rrf(&sets, 100.0);
622
623 assert!(results_low_k[0].final_score > results_high_k[0].final_score);
625 }
626
627 #[test]
628 fn test_rrf_rank_ordering() {
629 let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
631 sets.insert(
632 "a".to_string(),
633 vec![make_result("d1", 1.0, "a"), make_result("d2", 0.5, "a")],
634 );
635
636 let results = ResultAggregator::aggregate_rrf(&sets, 60.0);
637 assert!(results[0].final_score > results[1].final_score);
638 let expected_d1 = 1.0 / 61.0;
640 let expected_d2 = 1.0 / 62.0;
641 assert!((results[0].final_score - expected_d1).abs() < 1e-9);
642 assert!((results[1].final_score - expected_d2).abs() < 1e-9);
643 }
644
645 #[test]
648 fn test_weighted_basic() {
649 let mut weights = HashMap::new();
650 weights.insert("a".to_string(), 2.0);
651 weights.insert("b".to_string(), 1.0);
652
653 let mut agg = ResultAggregator::new(AggregatorConfig {
654 strategy: AggregationStrategy::WeightedCombination,
655 source_weights: weights,
656 ..Default::default()
657 });
658 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
659 agg.add_results("b", vec![make_result("d1", 0.5, "b")]);
660
661 let results = agg.aggregate();
662 let d1 = results
663 .iter()
664 .find(|r| r.doc_id == "d1")
665 .expect("d1 present");
666 assert!((d1.final_score - 1.5).abs() < 1e-9);
668 }
669
670 #[test]
671 fn test_weighted_default_weight() {
672 let weights = HashMap::new();
674
675 let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
676 sets.insert(
677 "unknown".to_string(),
678 vec![make_result("d1", 0.7, "unknown")],
679 );
680
681 let results = ResultAggregator::aggregate_weighted(&sets, &weights);
682 assert!((results[0].final_score - 0.7).abs() < 1e-9);
683 }
684
685 #[test]
686 fn test_weighted_zero_weight() {
687 let mut weights = HashMap::new();
688 weights.insert("a".to_string(), 0.0);
689
690 let mut sets: HashMap<String, Vec<SearchResult>> = HashMap::new();
691 sets.insert("a".to_string(), vec![make_result("d1", 0.9, "a")]);
692
693 let results = ResultAggregator::aggregate_weighted(&sets, &weights);
694 assert!(
695 (results[0].final_score).abs() < 1e-9,
696 "zero weight => zero score"
697 );
698 }
699
700 #[test]
703 fn test_threshold_filters_low_scores() {
704 let mut agg = ResultAggregator::new(AggregatorConfig {
705 strategy: AggregationStrategy::ScoreSum,
706 min_score_threshold: 0.5,
707 ..Default::default()
708 });
709 agg.add_results(
710 "a",
711 vec![
712 make_result("d1", 0.8, "a"),
713 make_result("d2", 0.3, "a"),
714 make_result("d3", 0.5, "a"),
715 ],
716 );
717
718 let results = agg.aggregate();
719 assert_eq!(results.len(), 2); assert!(results.iter().all(|r| r.final_score >= 0.5));
721 }
722
723 #[test]
724 fn test_threshold_zero_passes_all() {
725 let mut agg = ResultAggregator::new(AggregatorConfig {
726 strategy: AggregationStrategy::ScoreSum,
727 min_score_threshold: 0.0,
728 ..Default::default()
729 });
730 agg.add_results("a", vec![make_result("d1", 0.001, "a")]);
731 let results = agg.aggregate();
732 assert_eq!(results.len(), 1);
733 }
734
735 #[test]
738 fn test_max_results_truncation() {
739 let mut agg = ResultAggregator::new(AggregatorConfig {
740 strategy: AggregationStrategy::ScoreSum,
741 max_results: 2,
742 ..Default::default()
743 });
744 agg.add_results(
745 "a",
746 vec![
747 make_result("d1", 0.9, "a"),
748 make_result("d2", 0.8, "a"),
749 make_result("d3", 0.7, "a"),
750 ],
751 );
752
753 let results = agg.aggregate();
754 assert_eq!(results.len(), 2);
755 assert_eq!(results[0].doc_id, "d1");
756 assert_eq!(results[1].doc_id, "d2");
757 }
758
759 #[test]
762 fn test_dedup_within_source() {
763 let mut agg = ResultAggregator::new(AggregatorConfig {
764 strategy: AggregationStrategy::ScoreSum,
765 ..Default::default()
766 });
767 agg.add_results(
769 "a",
770 vec![make_result("d1", 0.5, "a"), make_result("d1", 0.3, "a")],
771 );
772 let results = agg.aggregate();
773 assert_eq!(results.len(), 1);
774 assert!((results[0].final_score - 0.5).abs() < 1e-9);
776 }
777
778 #[test]
779 fn test_dedup_across_sources_merges() {
780 let mut agg = ResultAggregator::new(AggregatorConfig {
781 strategy: AggregationStrategy::ScoreSum,
782 ..Default::default()
783 });
784 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
785 agg.add_results("b", vec![make_result("d1", 0.4, "b")]);
786
787 let results = agg.aggregate();
788 assert_eq!(results.len(), 1); assert!(results[0].sources.len() >= 2);
790 }
791
792 #[test]
795 fn test_empty_no_sources() {
796 let mut agg = ResultAggregator::new(AggregatorConfig::default());
797 let results = agg.aggregate();
798 assert!(results.is_empty());
799 }
800
801 #[test]
802 fn test_empty_source_list() {
803 let mut agg = ResultAggregator::new(AggregatorConfig {
804 strategy: AggregationStrategy::ScoreSum,
805 ..Default::default()
806 });
807 agg.add_results("a", vec![]);
808 let results = agg.aggregate();
809 assert!(results.is_empty());
810 }
811
812 #[test]
815 fn test_single_source_passthrough() {
816 let mut agg = ResultAggregator::new(AggregatorConfig {
817 strategy: AggregationStrategy::ScoreSum,
818 ..Default::default()
819 });
820 agg.add_results(
821 "only",
822 vec![
823 make_result("d1", 0.9, "only"),
824 make_result("d2", 0.7, "only"),
825 ],
826 );
827 let results = agg.aggregate();
828 assert_eq!(results.len(), 2);
829 assert_eq!(results[0].doc_id, "d1");
830 assert_eq!(results[1].doc_id, "d2");
831 assert_eq!(results[0].sources, vec!["only"]);
832 }
833
834 #[test]
837 fn test_stats_initial() {
838 let agg = ResultAggregator::new(AggregatorConfig::default());
839 assert_eq!(agg.stats().aggregations_performed, 0);
840 assert_eq!(agg.stats().total_input_results, 0);
841 assert_eq!(agg.stats().total_output_results, 0);
842 }
843
844 #[test]
845 fn test_stats_after_aggregate() {
846 let mut agg = ResultAggregator::new(AggregatorConfig {
847 strategy: AggregationStrategy::ScoreSum,
848 ..Default::default()
849 });
850 agg.add_results(
851 "a",
852 vec![make_result("d1", 0.5, "a"), make_result("d2", 0.3, "a")],
853 );
854 agg.add_results("b", vec![make_result("d1", 0.4, "b")]);
855
856 let _results = agg.aggregate();
857 assert_eq!(agg.stats().aggregations_performed, 1);
858 assert_eq!(agg.stats().total_input_results, 3);
859 assert_eq!(agg.stats().total_output_results, 2);
860 }
861
862 #[test]
863 fn test_stats_multiple_aggregations() {
864 let mut agg = ResultAggregator::new(AggregatorConfig {
865 strategy: AggregationStrategy::ScoreSum,
866 ..Default::default()
867 });
868
869 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
870 let _r1 = agg.aggregate();
871
872 agg.clear();
873 agg.add_results(
874 "b",
875 vec![make_result("d2", 0.8, "b"), make_result("d3", 0.3, "b")],
876 );
877 let _r2 = agg.aggregate();
878
879 assert_eq!(agg.stats().aggregations_performed, 2);
880 assert_eq!(agg.stats().total_input_results, 3); assert_eq!(agg.stats().total_output_results, 3); }
883
884 #[test]
887 fn test_source_weights_high_boost() {
888 let mut weights = HashMap::new();
889 weights.insert("premium".to_string(), 10.0);
890 weights.insert("basic".to_string(), 1.0);
891
892 let mut agg = ResultAggregator::new(AggregatorConfig {
893 strategy: AggregationStrategy::WeightedCombination,
894 source_weights: weights,
895 ..Default::default()
896 });
897 agg.add_results("premium", vec![make_result("d1", 0.3, "premium")]);
898 agg.add_results("basic", vec![make_result("d2", 0.9, "basic")]);
899
900 let results = agg.aggregate();
901 assert_eq!(results[0].doc_id, "d1");
903 assert!((results[0].final_score - 3.0).abs() < 1e-9);
904 }
905
906 #[test]
909 fn test_clear_resets_results() {
910 let mut agg = ResultAggregator::new(AggregatorConfig {
911 strategy: AggregationStrategy::ScoreSum,
912 ..Default::default()
913 });
914 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
915 assert_eq!(agg.source_count(), 1);
916 assert_eq!(agg.total_results(), 1);
917
918 agg.clear();
919 assert_eq!(agg.source_count(), 0);
920 assert_eq!(agg.total_results(), 0);
921
922 let results = agg.aggregate();
923 assert!(results.is_empty());
924 }
925
926 #[test]
927 fn test_clear_and_reaggregate() {
928 let mut agg = ResultAggregator::new(AggregatorConfig {
929 strategy: AggregationStrategy::ScoreSum,
930 ..Default::default()
931 });
932
933 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
934 let r1 = agg.aggregate();
935 assert_eq!(r1.len(), 1);
936
937 agg.clear();
938 agg.add_results("b", vec![make_result("d2", 0.8, "b")]);
939 let r2 = agg.aggregate();
940 assert_eq!(r2.len(), 1);
941 assert_eq!(r2[0].doc_id, "d2");
942 }
943
944 #[test]
947 fn test_rank_assignment() {
948 let mut agg = ResultAggregator::new(AggregatorConfig {
949 strategy: AggregationStrategy::ScoreSum,
950 ..Default::default()
951 });
952 agg.add_results(
953 "a",
954 vec![
955 make_result("d1", 0.9, "a"),
956 make_result("d2", 0.7, "a"),
957 make_result("d3", 0.5, "a"),
958 ],
959 );
960
961 let results = agg.aggregate();
962 assert_eq!(results[0].rank, 1);
963 assert_eq!(results[1].rank, 2);
964 assert_eq!(results[2].rank, 3);
965 }
966
967 #[test]
970 fn test_multi_source_merge_three() {
971 let mut agg = ResultAggregator::new(AggregatorConfig {
972 strategy: AggregationStrategy::ScoreSum,
973 ..Default::default()
974 });
975 agg.add_results("a", vec![make_result("d1", 0.3, "a")]);
976 agg.add_results("b", vec![make_result("d1", 0.3, "b")]);
977 agg.add_results("c", vec![make_result("d1", 0.3, "c")]);
978
979 let results = agg.aggregate();
980 assert_eq!(results.len(), 1);
981 assert!((results[0].final_score - 0.9).abs() < 1e-9);
982 assert_eq!(results[0].sources.len(), 3);
983 }
984
985 #[test]
988 fn test_source_count() {
989 let mut agg = ResultAggregator::new(AggregatorConfig::default());
990 assert_eq!(agg.source_count(), 0);
991 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
992 assert_eq!(agg.source_count(), 1);
993 agg.add_results("b", vec![make_result("d2", 0.3, "b")]);
994 assert_eq!(agg.source_count(), 2);
995 }
996
997 #[test]
998 fn test_total_results() {
999 let mut agg = ResultAggregator::new(AggregatorConfig::default());
1000 assert_eq!(agg.total_results(), 0);
1001 agg.add_results(
1002 "a",
1003 vec![make_result("d1", 0.5, "a"), make_result("d2", 0.3, "a")],
1004 );
1005 assert_eq!(agg.total_results(), 2);
1006 agg.add_results("b", vec![make_result("d3", 0.4, "b")]);
1007 assert_eq!(agg.total_results(), 3);
1008 }
1009
1010 #[test]
1013 fn test_metadata_preserved() {
1014 let r = make_result_with_meta("d1", 0.5, "a", vec![("key", "value")]);
1015 assert_eq!(r.metadata.get("key").map(|s| s.as_str()), Some("value"));
1016 }
1017
1018 #[test]
1021 fn test_source_scores_tracked() {
1022 let mut agg = ResultAggregator::new(AggregatorConfig {
1023 strategy: AggregationStrategy::ScoreSum,
1024 ..Default::default()
1025 });
1026 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
1027 agg.add_results("b", vec![make_result("d1", 0.3, "b")]);
1028
1029 let results = agg.aggregate();
1030 let d1 = results
1031 .iter()
1032 .find(|r| r.doc_id == "d1")
1033 .expect("d1 present");
1034 assert_eq!(d1.source_scores.len(), 2);
1035 }
1036
1037 #[test]
1040 fn test_default_config() {
1041 let config = AggregatorConfig::default();
1042 assert_eq!(config.strategy, AggregationStrategy::RankFusion);
1043 assert_eq!(config.max_results, 100);
1044 assert!((config.min_score_threshold).abs() < 1e-9);
1045 assert!((config.rrf_k - 60.0).abs() < 1e-9);
1046 }
1047
1048 #[test]
1051 fn test_aggregate_dispatches_rrf() {
1052 let mut agg = ResultAggregator::new(AggregatorConfig {
1053 strategy: AggregationStrategy::RankFusion,
1054 rrf_k: 60.0,
1055 ..Default::default()
1056 });
1057 agg.add_results("a", vec![make_result("d1", 1.0, "a")]);
1058 let results = agg.aggregate();
1059 let expected = 1.0 / 61.0;
1060 assert!((results[0].final_score - expected).abs() < 1e-9);
1061 }
1062
1063 #[test]
1064 fn test_aggregate_dispatches_weighted() {
1065 let mut weights = HashMap::new();
1066 weights.insert("a".to_string(), 3.0);
1067
1068 let mut agg = ResultAggregator::new(AggregatorConfig {
1069 strategy: AggregationStrategy::WeightedCombination,
1070 source_weights: weights,
1071 ..Default::default()
1072 });
1073 agg.add_results("a", vec![make_result("d1", 0.5, "a")]);
1074 let results = agg.aggregate();
1075 assert!((results[0].final_score - 1.5).abs() < 1e-9);
1076 }
1077
1078 #[test]
1081 fn test_compression_ratio() {
1082 let mut agg = ResultAggregator::new(AggregatorConfig {
1083 strategy: AggregationStrategy::ScoreSum,
1084 ..Default::default()
1085 });
1086 agg.add_results(
1088 "a",
1089 vec![make_result("d1", 0.5, "a"), make_result("d2", 0.3, "a")],
1090 );
1091 agg.add_results(
1092 "b",
1093 vec![make_result("d1", 0.4, "b"), make_result("d2", 0.2, "b")],
1094 );
1095 let _results = agg.aggregate();
1096 assert!((agg.stats().avg_compression_ratio - 2.0).abs() < 1e-9);
1097 }
1098}