hermes_core/query/vector/
combiner.rs1#[derive(Debug, Clone, Copy, PartialEq)]
5pub enum MultiValueCombiner {
6 Sum,
8 Max,
10 Avg,
12 LogSumExp {
24 temperature: f32,
26 },
27 WeightedTopK {
30 k: usize,
32 decay: f32,
34 },
35}
36
37impl Default for MultiValueCombiner {
38 fn default() -> Self {
39 MultiValueCombiner::LogSumExp { temperature: 1.5 }
42 }
43}
44
45impl MultiValueCombiner {
46 pub(crate) fn validate(self) -> Result<(), String> {
47 match self {
48 Self::LogSumExp { temperature } if !temperature.is_finite() || temperature <= 0.0 => {
49 Err(format!(
50 "LogSumExp temperature must be finite and greater than zero, got {temperature}"
51 ))
52 }
53 Self::WeightedTopK { k: 0, .. } => {
54 Err("WeightedTopK k must be greater than zero".to_string())
55 }
56 Self::WeightedTopK { decay, .. }
57 if !decay.is_finite() || !(0.0..=1.0).contains(&decay) =>
58 {
59 Err(format!(
60 "WeightedTopK decay must be finite and in [0, 1], got {decay}"
61 ))
62 }
63 _ => Ok(()),
64 }
65 }
66
67 pub fn log_sum_exp() -> Self {
69 Self::LogSumExp { temperature: 1.5 }
70 }
71
72 pub fn log_sum_exp_with_temperature(temperature: f32) -> Self {
74 Self::LogSumExp { temperature }
75 }
76
77 pub fn weighted_top_k() -> Self {
79 Self::WeightedTopK { k: 5, decay: 0.7 }
80 }
81
82 pub fn weighted_top_k_with_params(k: usize, decay: f32) -> Self {
84 Self::WeightedTopK { k, decay }
85 }
86
87 pub fn combine(&self, scores: &[(u32, f32)]) -> f32 {
89 if scores.is_empty() {
90 return 0.0;
91 }
92
93 match self {
98 MultiValueCombiner::Sum => scores.iter().map(|(_, s)| s).sum(),
99 MultiValueCombiner::Max => scores
100 .iter()
101 .map(|(_, s)| *s)
102 .max_by(|a, b| a.total_cmp(b))
103 .unwrap_or(0.0),
104 MultiValueCombiner::Avg => {
105 let sum: f32 = scores.iter().map(|(_, s)| s).sum();
106 sum / scores.len() as f32
107 }
108 MultiValueCombiner::LogSumExp { temperature } => {
109 let t = *temperature;
113 let max_score = scores
114 .iter()
115 .map(|(_, s)| *s)
116 .max_by(|a, b| a.total_cmp(b))
117 .unwrap_or(0.0);
118
119 let mut weight_sum = 0.0f32;
120 let mut weighted = 0.0f32;
121 for &(_, s) in scores {
122 let weight = (t * (s - max_score)).exp();
123 weight_sum += weight;
124 weighted += weight * s;
125 }
126 weighted / weight_sum
127 }
128 MultiValueCombiner::WeightedTopK { k, decay } => {
129 let k = (*k).min(scores.len());
130 if k == 0 {
131 return 0.0;
132 }
133 const INLINE: usize = 16;
138 let mut inline = [0.0f32; INLINE];
139 let mut spilled: Vec<f32>;
140 let values: &mut [f32] = if scores.len() <= INLINE {
141 for (slot, &(_, s)) in inline.iter_mut().zip(scores) {
142 *slot = s;
143 }
144 &mut inline[..scores.len()]
145 } else {
146 spilled = scores.iter().map(|&(_, s)| s).collect();
147 spilled.as_mut_slice()
148 };
149 if k < values.len() {
150 values.select_nth_unstable_by(k - 1, |a, b| b.total_cmp(a));
151 }
152 let top = &mut values[..k];
153 top.sort_unstable_by(|a, b| b.total_cmp(a));
154
155 let mut weight = 1.0f32;
157 let mut weighted_sum = 0.0f32;
158 let mut weight_total = 0.0f32;
159
160 for &score in top.iter() {
161 weighted_sum += weight * score;
162 weight_total += weight;
163 weight *= decay;
164 }
165
166 if weight_total > 0.0 {
167 weighted_sum / weight_total
168 } else {
169 0.0
170 }
171 }
172 }
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_combiner_sum() {
182 let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
183 let combiner = MultiValueCombiner::Sum;
184 assert!((combiner.combine(&scores) - 6.0).abs() < 1e-6);
185 }
186
187 #[test]
188 fn test_combiner_max() {
189 let scores = vec![(0, 1.0), (1, 3.0), (2, 2.0)];
190 let combiner = MultiValueCombiner::Max;
191 assert!((combiner.combine(&scores) - 3.0).abs() < 1e-6);
192 }
193
194 #[test]
195 fn test_combiner_avg() {
196 let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
197 let combiner = MultiValueCombiner::Avg;
198 assert!((combiner.combine(&scores) - 2.0).abs() < 1e-6);
199 }
200
201 #[test]
202 fn test_combiner_log_sum_exp() {
203 let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
204 let combiner = MultiValueCombiner::log_sum_exp();
205 let result = combiner.combine(&scores);
206 assert!(result > 2.0, "must exceed the mean, got {result}");
209 assert!(result <= 3.0, "must never exceed the max, got {result}");
210 }
211
212 #[test]
219 fn log_sum_exp_is_count_invariant_and_bounded_by_max() {
220 let combiner = MultiValueCombiner::log_sum_exp();
221
222 let identical: Vec<(u32, f32)> = (0..300).map(|i| (i, 0.7)).collect();
224 let combined = combiner.combine(&identical);
225 assert!(
226 (combined - 0.7).abs() < 1e-3,
227 "300 identical 0.7 chunks must combine to 0.7, got {combined}"
228 );
229
230 let mut compendium: Vec<(u32, f32)> = (0..300).map(|i| (i, 0.5)).collect();
232 compendium.push((300, 0.55));
233 let paper = vec![(0, 0.72), (1, 0.70), (2, 0.65)];
234 let compendium_score = combiner.combine(&compendium);
235 let paper_score = combiner.combine(&paper);
236 assert!(
237 paper_score > compendium_score,
238 "3 strong chunks ({paper_score}) must beat 301 mediocre ones ({compendium_score})"
239 );
240 }
241
242 #[test]
248 fn log_sum_exp_tracks_a_dominant_score_at_sparse_scale() {
249 let combiner = MultiValueCombiner::log_sum_exp_with_temperature(0.7);
250 let mut scores: Vec<(u32, f32)> = (0..299).map(|i| (i, 0.0)).collect();
251 scores.push((299, 15.0));
252 let combined = combiner.combine(&scores);
253 assert!(
254 (combined - 15.0).abs() < 0.3,
255 "one dominant sparse chunk must keep its score, got {combined}"
256 );
257 }
258
259 #[test]
260 fn test_combiner_log_sum_exp_approaches_max_with_high_temp() {
261 let scores = vec![(0, 1.0), (1, 5.0), (2, 2.0)];
262 let combiner = MultiValueCombiner::log_sum_exp_with_temperature(10.0);
264 let result = combiner.combine(&scores);
265 assert!((result - 5.0).abs() < 0.5);
267 }
268
269 #[test]
270 fn test_combiner_weighted_top_k() {
271 let scores = vec![(0, 5.0), (1, 3.0), (2, 1.0), (3, 0.5)];
272 let combiner = MultiValueCombiner::weighted_top_k_with_params(3, 0.5);
273 let result = combiner.combine(&scores);
274 assert!((result - 3.857).abs() < 0.01);
279 }
280
281 #[test]
282 fn test_combiner_weighted_top_k_less_than_k() {
283 let scores = vec![(0, 2.0), (1, 1.0)];
284 let combiner = MultiValueCombiner::weighted_top_k_with_params(5, 0.7);
285 let result = combiner.combine(&scores);
286 assert!((result - 1.588).abs() < 0.01);
291 }
292
293 #[test]
297 fn weighted_top_k_selection_matches_full_sort_across_inline_boundary() {
298 for len in [1usize, 2, 5, 15, 16, 17, 40] {
299 let scores: Vec<(u32, f32)> = (0..len)
300 .map(|i| (i as u32, ((i * 7919) % 13) as f32 / 13.0))
301 .collect();
302 for k in [1usize, 2, 3, 5, 16, 17, 64] {
303 let combiner = MultiValueCombiner::weighted_top_k_with_params(k, 0.7);
304 let actual = combiner.combine(&scores);
305
306 let mut sorted: Vec<f32> = scores.iter().map(|&(_, s)| s).collect();
307 sorted.sort_unstable_by(|a, b| b.total_cmp(a));
308 sorted.truncate(k);
309 let (mut w, mut ws, mut wt) = (1.0f32, 0.0f32, 0.0f32);
310 for s in sorted {
311 ws += w * s;
312 wt += w;
313 w *= 0.7;
314 }
315 let expected = ws / wt;
316 assert!(
317 (actual - expected).abs() < 1e-6,
318 "len {len} k {k}: {actual} vs {expected}"
319 );
320 }
321 }
322 }
323
324 #[test]
325 fn test_combiner_empty_scores() {
326 let scores: Vec<(u32, f32)> = vec![];
327 assert_eq!(MultiValueCombiner::Sum.combine(&scores), 0.0);
328 assert_eq!(MultiValueCombiner::Max.combine(&scores), 0.0);
329 assert_eq!(MultiValueCombiner::Avg.combine(&scores), 0.0);
330 assert_eq!(MultiValueCombiner::log_sum_exp().combine(&scores), 0.0);
331 assert_eq!(MultiValueCombiner::weighted_top_k().combine(&scores), 0.0);
332 }
333
334 #[test]
335 fn test_combiner_single_score() {
336 let scores = vec![(0, 5.0)];
337 assert!((MultiValueCombiner::Sum.combine(&scores) - 5.0).abs() < 1e-6);
339 assert!((MultiValueCombiner::Max.combine(&scores) - 5.0).abs() < 1e-6);
340 assert!((MultiValueCombiner::Avg.combine(&scores) - 5.0).abs() < 1e-6);
341 assert!((MultiValueCombiner::log_sum_exp().combine(&scores) - 5.0).abs() < 1e-6);
342 assert!((MultiValueCombiner::weighted_top_k().combine(&scores) - 5.0).abs() < 1e-6);
343 }
344
345 #[test]
346 fn test_default_combiner_is_log_sum_exp() {
347 let combiner = MultiValueCombiner::default();
348 match combiner {
349 MultiValueCombiner::LogSumExp { temperature } => {
350 assert!((temperature - 1.5).abs() < 1e-6);
351 }
352 _ => panic!("Default combiner should be LogSumExp"),
353 }
354 }
355}