Skip to main content

ipfrs_semantic/
result_aggregator.rs

1//! Multi-source search result aggregation and ranking.
2//!
3//! This module provides facilities for combining search results from multiple
4//! sources (e.g., HNSW, DiskANN, federated peers) into a single ranked list
5//! using various aggregation strategies including Reciprocal Rank Fusion,
6//! score summation, weighted combination, and more.
7
8use std::collections::HashMap;
9
10/// Strategy for aggregating search results from multiple sources.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum AggregationStrategy {
13    /// Sum scores across all sources for each document.
14    ScoreSum,
15    /// Take the maximum score across all sources for each document.
16    ScoreMax,
17    /// Average scores across all sources for each document.
18    ScoreAverage,
19    /// Reciprocal Rank Fusion: score = sum(1/(k+rank)).
20    RankFusion,
21    /// Weighted combination using per-source weights.
22    WeightedCombination,
23}
24
25/// A single search result from one source.
26#[derive(Debug, Clone)]
27pub struct SearchResult {
28    /// Unique document identifier.
29    pub doc_id: String,
30    /// Relevance score (higher is better).
31    pub score: f64,
32    /// Name of the source that produced this result.
33    pub source: String,
34    /// Arbitrary metadata associated with the result.
35    pub metadata: HashMap<String, String>,
36}
37
38/// An aggregated result combining information from multiple sources.
39#[derive(Debug, Clone)]
40pub struct AggregatedResult {
41    /// Unique document identifier.
42    pub doc_id: String,
43    /// Final aggregated score.
44    pub final_score: f64,
45    /// List of sources that contributed to this result.
46    pub sources: Vec<String>,
47    /// Per-source scores as (source_name, score) pairs.
48    pub source_scores: Vec<(String, f64)>,
49    /// Rank in the final result list (1-based).
50    pub rank: usize,
51}
52
53/// Configuration for the result aggregator.
54#[derive(Debug, Clone)]
55pub struct AggregatorConfig {
56    /// Strategy used to combine scores.
57    pub strategy: AggregationStrategy,
58    /// Maximum number of results to return.
59    pub max_results: usize,
60    /// Minimum score threshold; results below this are filtered out.
61    pub min_score_threshold: f64,
62    /// Per-source weights for `WeightedCombination` strategy.
63    pub source_weights: HashMap<String, f64>,
64    /// RRF constant k (default 60.0). Higher values reduce the impact of rank differences.
65    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/// Tracks statistics about aggregation operations.
81#[derive(Debug, Clone, Default)]
82pub struct AggregatorStats {
83    /// Total number of aggregation operations performed.
84    pub aggregations_performed: u64,
85    /// Total number of input results across all aggregations.
86    pub total_input_results: u64,
87    /// Total number of output results across all aggregations.
88    pub total_output_results: u64,
89    /// Running average compression ratio (input/output).
90    pub avg_compression_ratio: f64,
91}
92
93/// Multi-source search result aggregator.
94///
95/// Collects search results from multiple sources and combines them using
96/// a configurable aggregation strategy.
97pub struct ResultAggregator {
98    config: AggregatorConfig,
99    result_sets: HashMap<String, Vec<SearchResult>>,
100    stats: AggregatorStats,
101}
102
103impl ResultAggregator {
104    /// Create a new `ResultAggregator` with the given configuration.
105    pub fn new(config: AggregatorConfig) -> Self {
106        Self {
107            config,
108            result_sets: HashMap::new(),
109            stats: AggregatorStats::default(),
110        }
111    }
112
113    /// Add a batch of results from a named source.
114    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    /// Aggregate all added result sets using the configured strategy.
122    ///
123    /// Returns a sorted, deduplicated, ranked list of aggregated results.
124    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        // Sort descending by score, then truncate
142        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        // Assign ranks (1-based)
153        for (i, r) in results.iter_mut().enumerate() {
154            r.rank = i + 1;
155        }
156
157        // Update stats
158        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        // Running average
172        self.stats.avg_compression_ratio =
173            self.stats.avg_compression_ratio * ((n - 1.0) / n) + ratio / n;
174
175        results
176    }
177
178    /// Reciprocal Rank Fusion aggregation.
179    ///
180    /// For each source, results are ranked by descending score. The RRF score
181    /// for a document is `sum(1 / (k + rank))` across all sources.
182    pub fn aggregate_rrf(
183        result_sets: &HashMap<String, Vec<SearchResult>>,
184        k: f64,
185    ) -> Vec<AggregatedResult> {
186        // Build per-source rankings (sorted descending by score)
187        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            // Sort by descending score to determine rank
193            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            // Deduplicate within source (keep highest score)
201            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    /// Sum scores across all sources for each document.
230    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    /// Take the maximum score across all sources for each document.
263    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    /// Average scores across all sources for each document.
301    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    /// Weighted combination of scores using per-source weights.
344    ///
345    /// If a source has no configured weight, it defaults to 1.0.
346    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    /// Remove all result sets.
382    pub fn clear(&mut self) {
383        self.result_sets.clear();
384    }
385
386    /// Return the number of distinct sources currently held.
387    pub fn source_count(&self) -> usize {
388        self.result_sets.len()
389    }
390
391    /// Return the total number of individual results across all sources.
392    pub fn total_results(&self) -> usize {
393        self.result_sets.values().map(|v| v.len()).sum()
394    }
395
396    /// Return a reference to the current aggregation statistics.
397    pub fn stats(&self) -> &AggregatorStats {
398        &self.stats
399    }
400
401    /// Filter out results whose `final_score` is below the configured threshold.
402    pub fn apply_threshold(&self, results: &mut Vec<AggregatedResult>) {
403        results.retain(|r| r.final_score >= self.config.min_score_threshold);
404    }
405
406    // ---- internal helpers ----
407
408    /// Build the final `AggregatedResult` list from the intermediate maps.
409    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, // assigned later
425                }
426            })
427            .collect();
428
429        // Sort descending by score for consistent ordering
430        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    // ---- ScoreSum strategy ----
472
473    #[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    // ---- ScoreMax strategy ----
512
513    #[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    // ---- ScoreAverage strategy ----
545
546    #[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    // ---- RRF strategy ----
578
579    #[test]
580    fn test_rrf_formula() {
581        // With k=60, rank 1 gives 1/(60+1) = 1/61
582        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        // d1 is rank 1 in both sources
597        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; // rank 1 in both sources
612        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        // Lower k means higher score for the same rank
624        assert!(results_low_k[0].final_score > results_high_k[0].final_score);
625    }
626
627    #[test]
628    fn test_rrf_rank_ordering() {
629        // d1 has score 1.0 (rank 1), d2 has score 0.5 (rank 2)
630        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        // rank 1: 1/61, rank 2: 1/62
639        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    // ---- WeightedCombination strategy ----
646
647    #[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        // 0.5*2.0 + 0.5*1.0 = 1.5
667        assert!((d1.final_score - 1.5).abs() < 1e-9);
668    }
669
670    #[test]
671    fn test_weighted_default_weight() {
672        // Source not in weights map should default to 1.0
673        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    // ---- Threshold filtering ----
701
702    #[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); // d2 filtered out
720        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    // ---- max_results limit ----
736
737    #[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    // ---- Deduplication by doc_id ----
760
761    #[test]
762    fn test_dedup_within_source() {
763        let mut agg = ResultAggregator::new(AggregatorConfig {
764            strategy: AggregationStrategy::ScoreSum,
765            ..Default::default()
766        });
767        // Same doc_id twice in one source (first occurrence wins for that source)
768        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        // Only the first d1 from source "a" is counted
775        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); // merged into one
789        assert!(results[0].sources.len() >= 2);
790    }
791
792    // ---- Empty sources ----
793
794    #[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    // ---- Single source passthrough ----
813
814    #[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    // ---- Stats tracking ----
835
836    #[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); // 1 + 2
881        assert_eq!(agg.stats().total_output_results, 3); // 1 + 2
882    }
883
884    // ---- Source weights ----
885
886    #[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        // d1: 0.3*10 = 3.0, d2: 0.9*1 = 0.9
902        assert_eq!(results[0].doc_id, "d1");
903        assert!((results[0].final_score - 3.0).abs() < 1e-9);
904    }
905
906    // ---- Clear and re-aggregate ----
907
908    #[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    // ---- Rank assignment ----
945
946    #[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    // ---- Multi-source merge ----
968
969    #[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    // ---- source_count and total_results ----
986
987    #[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    // ---- Metadata preservation ----
1011
1012    #[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    // ---- source_scores tracking ----
1019
1020    #[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    // ---- Default config ----
1038
1039    #[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    // ---- Aggregation strategy via aggregate() dispatch ----
1049
1050    #[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    // ---- Compression ratio ----
1079
1080    #[test]
1081    fn test_compression_ratio() {
1082        let mut agg = ResultAggregator::new(AggregatorConfig {
1083            strategy: AggregationStrategy::ScoreSum,
1084            ..Default::default()
1085        });
1086        // 4 input results, 2 unique doc_ids => output 2 => ratio 4/2 = 2.0
1087        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}