Skip to main content

llm_kernel/search/
fusion.rs

1//! Score normalization and alternative list-fusion strategies.
2//!
3//! These operate on [`crate::search::SearchResult`] lists and complement the
4//! rank-based [`crate::search::rrf_fuse`]. Score-based fusion requires
5//! comparable scales, so normalize each list (e.g. with [`normalize_minmax`])
6//! before fusing with [`weighted_sum_fuse`] or [`combmnz_fuse`].
7
8use std::collections::HashMap;
9
10use crate::search::SearchResult;
11
12/// Normalize scores to `[0, 1]` in place via min-max scaling.
13///
14/// If the slice is empty this is a no-op. If every score is equal (min == max)
15/// all scores are set to `1.0`. Otherwise each score becomes
16/// `(score - min) / (max - min)`.
17///
18/// Non-finite scores (`NaN`, `±Infinity`) are clamped to `0.0` before scaling so
19/// the `[0, 1]` output contract holds even when an upstream backend emits an
20/// invalid score.
21pub fn normalize_minmax(results: &mut [SearchResult]) {
22    if results.is_empty() {
23        return;
24    }
25    for r in results.iter_mut() {
26        if !r.score.is_finite() {
27            r.score = 0.0;
28        }
29    }
30    let min = results
31        .iter()
32        .map(|r| r.score)
33        .fold(f32::INFINITY, f32::min);
34    let max = results
35        .iter()
36        .map(|r| r.score)
37        .fold(f32::NEG_INFINITY, f32::max);
38    if (min - max).abs() < f32::EPSILON {
39        for r in results.iter_mut() {
40            r.score = 1.0;
41        }
42        return;
43    }
44    let span = max - min;
45    for r in results.iter_mut() {
46        r.score = (r.score - min) / span;
47    }
48}
49
50/// Fuse score-normalized lists by weighted sum.
51///
52/// Computes `score(d) = Σ_i weight_i * score_i(d)` across the input lists,
53/// merging duplicate ids (keeping the first text seen) and sorting the output
54/// descending by score.
55///
56/// # Precondition
57///
58/// `weights.len()` must equal `result_sets.len()` — this is enforced with a
59/// runtime assertion (a mismatch is programmer error and panics rather than
60/// silently dropping inputs). Each result set should already be normalized
61/// (e.g. via [`normalize_minmax`]) so the weighted sum is meaningful.
62pub fn weighted_sum_fuse(result_sets: &[Vec<SearchResult>], weights: &[f32]) -> Vec<SearchResult> {
63    assert_eq!(
64        result_sets.len(),
65        weights.len(),
66        "weighted_sum_fuse: result_sets.len() ({}) must equal weights.len() ({})",
67        result_sets.len(),
68        weights.len(),
69    );
70    let mut scores: HashMap<String, f32> = HashMap::new();
71    let mut texts: HashMap<String, String> = HashMap::new();
72
73    for (results, weight) in result_sets.iter().zip(weights.iter()) {
74        for result in results {
75            *scores.entry(result.id.clone()).or_insert(0.0) += weight * result.score;
76            texts
77                .entry(result.id.clone())
78                .or_insert_with(|| result.text.clone());
79        }
80    }
81
82    let mut fused: Vec<SearchResult> = scores
83        .into_iter()
84        .map(|(id, score)| SearchResult {
85            text: texts.remove(&id).unwrap_or_default(),
86            id,
87            score,
88        })
89        .collect();
90
91    fused.sort_by(|a, b| {
92        b.score
93            .partial_cmp(&a.score)
94            .unwrap_or(std::cmp::Ordering::Equal)
95    });
96    fused
97}
98
99/// CombMNZ fusion.
100///
101/// For each distinct id, the fused score is `MNZ(d) * Σ_i score_i(d)`, where
102/// `MNZ(d)` is the number of result lists whose top-`k` contains the document.
103/// Text is taken from the first list that contains the document. The result is
104/// sorted descending by score.
105///
106/// This is the canonical CombMNZ combination (Fox & Shaw, 1994): a document
107/// appearing near the top of many lists is boosted multiplicatively, so broad
108/// agreement across backends outranks a single high score.
109///
110/// Inputs should be normalized first (e.g. via [`normalize_minmax`]) so that
111/// score magnitudes are comparable across lists.
112///
113/// # Precondition
114///
115/// `k` must be at least 1. With `k == 0` no document is ever in any top-`k`, so
116/// every `MNZ(d)` is `0` and all fused scores collapse to zero; the call is
117/// therefore treated as programmer error and panics (matching
118/// [`weighted_sum_fuse`]'s precondition handling).
119pub fn combmnz_fuse(result_sets: &[Vec<SearchResult>], k: usize) -> Vec<SearchResult> {
120    assert!(
121        k >= 1,
122        "combmnz_fuse: k must be >= 1 (got {k}); k == 0 zeroes every score"
123    );
124    let mut scores: HashMap<String, f32> = HashMap::new();
125    let mut texts: HashMap<String, String> = HashMap::new();
126    let mut topk_counts: HashMap<String, usize> = HashMap::new();
127
128    for results in result_sets {
129        let topk = results.len().min(k);
130        for (rank, result) in results.iter().enumerate() {
131            *scores.entry(result.id.clone()).or_insert(0.0) += result.score;
132            texts
133                .entry(result.id.clone())
134                .or_insert_with(|| result.text.clone());
135            if rank < topk {
136                *topk_counts.entry(result.id.clone()).or_insert(0) += 1;
137            }
138        }
139    }
140
141    let mut fused: Vec<SearchResult> = scores
142        .into_iter()
143        .map(|(id, sum)| SearchResult {
144            text: texts.remove(&id).unwrap_or_default(),
145            score: topk_counts.get(&id).copied().unwrap_or(0) as f32 * sum,
146            id,
147        })
148        .collect();
149
150    fused.sort_by(|a, b| {
151        b.score
152            .partial_cmp(&a.score)
153            .unwrap_or(std::cmp::Ordering::Equal)
154    });
155    fused
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn make_results(ids: &[(&str, f32)]) -> Vec<SearchResult> {
163        ids.iter()
164            .map(|(id, score)| SearchResult {
165                id: id.to_string(),
166                score: *score,
167                text: format!("text for {id}"),
168            })
169            .collect()
170    }
171
172    #[test]
173    fn normalize_minmax_maps_extremes() {
174        let mut results = make_results(&[("a", 0.2), ("b", 0.5), ("c", 0.9)]);
175        normalize_minmax(&mut results);
176        let a = results.iter().find(|r| r.id == "a").unwrap().score;
177        let b = results.iter().find(|r| r.id == "b").unwrap().score;
178        let c = results.iter().find(|r| r.id == "c").unwrap().score;
179        assert!((a - 0.0).abs() < 1e-6);
180        assert!((c - 1.0).abs() < 1e-6);
181        assert!((b - (0.5 - 0.2) / (0.9 - 0.2)).abs() < 1e-6);
182    }
183
184    #[test]
185    fn normalize_minmax_all_equal_sets_one() {
186        let mut results = make_results(&[("a", 0.5), ("b", 0.5), ("c", 0.5)]);
187        normalize_minmax(&mut results);
188        for r in &results {
189            assert!((r.score - 1.0).abs() < 1e-6);
190        }
191    }
192
193    #[test]
194    fn normalize_minmax_empty_is_noop() {
195        let mut results: Vec<SearchResult> = vec![];
196        normalize_minmax(&mut results);
197        assert!(results.is_empty());
198    }
199
200    #[test]
201    fn normalize_minmax_clamps_non_finite() {
202        // NaN and Infinity must be folded to 0.0 so the output stays in [0, 1].
203        let mut results = make_results(&[("a", f32::NAN), ("b", f32::INFINITY), ("c", 0.5)]);
204        normalize_minmax(&mut results);
205        for r in &results {
206            assert!(r.score.is_finite(), "score {:?} is not finite", r.score);
207            assert!(
208                (0.0..=1.0).contains(&r.score),
209                "score {:?} out of [0,1]",
210                r.score
211            );
212        }
213    }
214
215    #[test]
216    fn weighted_sum_fuse_formula() {
217        // weights [0.7, 0.3]
218        // a: 0.7*1.0 + 0.3*0   = 0.70
219        // b: 0.7*0.5 + 0.3*1.0 = 0.65
220        // c: 0.7*0   + 0.3*0.4 = 0.12
221        // order: a > b > c
222        let a = make_results(&[("a", 1.0), ("b", 0.5)]);
223        let b = make_results(&[("b", 1.0), ("c", 0.4)]);
224        let fused = weighted_sum_fuse(&[a, b], &[0.7, 0.3]);
225        assert_eq!(fused.len(), 3);
226        assert_eq!(fused[0].id, "a");
227        assert_eq!(fused[1].id, "b");
228        assert_eq!(fused[2].id, "c");
229        let score_a = fused.iter().find(|r| r.id == "a").unwrap().score;
230        let score_b = fused.iter().find(|r| r.id == "b").unwrap().score;
231        let score_c = fused.iter().find(|r| r.id == "c").unwrap().score;
232        assert!((score_a - 0.70).abs() < 1e-6);
233        assert!((score_b - 0.65).abs() < 1e-6);
234        assert!((score_c - 0.12).abs() < 1e-6);
235    }
236
237    #[test]
238    fn combmnz_boosts_multi_list_doc() {
239        // hot: top-1 of A only (count 1); cold: top-1 of B and C (count 2).
240        // hot sum = 0.99 (A) + 0.1 (B) = 1.09  -> 1 * 1.09 = 1.09
241        // cold sum = 0.1 (A) + 0.99 (B) + 0.99 (C) = 2.08 -> 2 * 2.08 = 4.16
242        // cold ranks above hot despite hot's higher single-list score.
243        let a = make_results(&[("hot", 0.99), ("cold", 0.1)]);
244        let b = make_results(&[("cold", 0.99), ("hot", 0.1)]);
245        let c = make_results(&[("cold", 0.99), ("warm", 0.1)]);
246        let fused = combmnz_fuse(&[a, b, c], 1);
247        assert_eq!(fused[0].id, "cold");
248        let cold = fused.iter().find(|r| r.id == "cold").unwrap().score;
249        let hot = fused.iter().find(|r| r.id == "hot").unwrap().score;
250        assert!(cold > hot);
251        assert!((cold - 4.16).abs() < 1e-5);
252        assert!((hot - 1.09).abs() < 1e-5);
253    }
254
255    #[test]
256    #[should_panic(expected = "must equal weights.len()")]
257    fn weighted_sum_fuse_panics_on_length_mismatch() {
258        let a = make_results(&[("a", 1.0)]);
259        // Two result sets but only one weight -> precondition violation.
260        let _ = weighted_sum_fuse(&[a.clone(), a], &[0.5]);
261    }
262}