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 {
94 MultiValueCombiner::Sum => scores.iter().map(|(_, s)| s).sum(),
95 MultiValueCombiner::Max => scores
96 .iter()
97 .map(|(_, s)| *s)
98 .max_by(|a, b| a.total_cmp(b))
99 .unwrap_or(0.0),
100 MultiValueCombiner::Avg => {
101 let sum: f32 = scores.iter().map(|(_, s)| s).sum();
102 sum / scores.len() as f32
103 }
104 MultiValueCombiner::LogSumExp { temperature } => {
105 let t = *temperature;
109 let max_score = scores
110 .iter()
111 .map(|(_, s)| *s)
112 .max_by(|a, b| a.total_cmp(b))
113 .unwrap_or(0.0);
114
115 let mut weight_sum = 0.0f32;
116 let mut weighted = 0.0f32;
117 for (_, s) in scores {
118 let weight = (t * (s - max_score)).exp();
119 weight_sum += weight;
120 weighted += weight * s;
121 }
122 weighted / weight_sum
123 }
124 MultiValueCombiner::WeightedTopK { k, decay } => {
125 let mut sorted: Vec<f32> = scores.iter().map(|(_, s)| *s).collect();
127 sorted.sort_unstable_by(|a, b| b.total_cmp(a));
128 sorted.truncate(*k);
129
130 let mut weight = 1.0f32;
132 let mut weighted_sum = 0.0f32;
133 let mut weight_total = 0.0f32;
134
135 for score in sorted {
136 weighted_sum += weight * score;
137 weight_total += weight;
138 weight *= decay;
139 }
140
141 if weight_total > 0.0 {
142 weighted_sum / weight_total
143 } else {
144 0.0
145 }
146 }
147 }
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn test_combiner_sum() {
157 let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
158 let combiner = MultiValueCombiner::Sum;
159 assert!((combiner.combine(&scores) - 6.0).abs() < 1e-6);
160 }
161
162 #[test]
163 fn test_combiner_max() {
164 let scores = vec![(0, 1.0), (1, 3.0), (2, 2.0)];
165 let combiner = MultiValueCombiner::Max;
166 assert!((combiner.combine(&scores) - 3.0).abs() < 1e-6);
167 }
168
169 #[test]
170 fn test_combiner_avg() {
171 let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
172 let combiner = MultiValueCombiner::Avg;
173 assert!((combiner.combine(&scores) - 2.0).abs() < 1e-6);
174 }
175
176 #[test]
177 fn test_combiner_log_sum_exp() {
178 let scores = vec![(0, 1.0), (1, 2.0), (2, 3.0)];
179 let combiner = MultiValueCombiner::log_sum_exp();
180 let result = combiner.combine(&scores);
181 assert!(result > 2.0, "must exceed the mean, got {result}");
184 assert!(result <= 3.0, "must never exceed the max, got {result}");
185 }
186
187 #[test]
194 fn log_sum_exp_is_count_invariant_and_bounded_by_max() {
195 let combiner = MultiValueCombiner::log_sum_exp();
196
197 let identical: Vec<(u32, f32)> = (0..300).map(|i| (i, 0.7)).collect();
199 let combined = combiner.combine(&identical);
200 assert!(
201 (combined - 0.7).abs() < 1e-3,
202 "300 identical 0.7 chunks must combine to 0.7, got {combined}"
203 );
204
205 let mut compendium: Vec<(u32, f32)> = (0..300).map(|i| (i, 0.5)).collect();
207 compendium.push((300, 0.55));
208 let paper = vec![(0, 0.72), (1, 0.70), (2, 0.65)];
209 let compendium_score = combiner.combine(&compendium);
210 let paper_score = combiner.combine(&paper);
211 assert!(
212 paper_score > compendium_score,
213 "3 strong chunks ({paper_score}) must beat 301 mediocre ones ({compendium_score})"
214 );
215 }
216
217 #[test]
223 fn log_sum_exp_tracks_a_dominant_score_at_sparse_scale() {
224 let combiner = MultiValueCombiner::log_sum_exp_with_temperature(0.7);
225 let mut scores: Vec<(u32, f32)> = (0..299).map(|i| (i, 0.0)).collect();
226 scores.push((299, 15.0));
227 let combined = combiner.combine(&scores);
228 assert!(
229 (combined - 15.0).abs() < 0.3,
230 "one dominant sparse chunk must keep its score, got {combined}"
231 );
232 }
233
234 #[test]
235 fn test_combiner_log_sum_exp_approaches_max_with_high_temp() {
236 let scores = vec![(0, 1.0), (1, 5.0), (2, 2.0)];
237 let combiner = MultiValueCombiner::log_sum_exp_with_temperature(10.0);
239 let result = combiner.combine(&scores);
240 assert!((result - 5.0).abs() < 0.5);
242 }
243
244 #[test]
245 fn test_combiner_weighted_top_k() {
246 let scores = vec![(0, 5.0), (1, 3.0), (2, 1.0), (3, 0.5)];
247 let combiner = MultiValueCombiner::weighted_top_k_with_params(3, 0.5);
248 let result = combiner.combine(&scores);
249 assert!((result - 3.857).abs() < 0.01);
254 }
255
256 #[test]
257 fn test_combiner_weighted_top_k_less_than_k() {
258 let scores = vec![(0, 2.0), (1, 1.0)];
259 let combiner = MultiValueCombiner::weighted_top_k_with_params(5, 0.7);
260 let result = combiner.combine(&scores);
261 assert!((result - 1.588).abs() < 0.01);
266 }
267
268 #[test]
269 fn test_combiner_empty_scores() {
270 let scores: Vec<(u32, f32)> = vec![];
271 assert_eq!(MultiValueCombiner::Sum.combine(&scores), 0.0);
272 assert_eq!(MultiValueCombiner::Max.combine(&scores), 0.0);
273 assert_eq!(MultiValueCombiner::Avg.combine(&scores), 0.0);
274 assert_eq!(MultiValueCombiner::log_sum_exp().combine(&scores), 0.0);
275 assert_eq!(MultiValueCombiner::weighted_top_k().combine(&scores), 0.0);
276 }
277
278 #[test]
279 fn test_combiner_single_score() {
280 let scores = vec![(0, 5.0)];
281 assert!((MultiValueCombiner::Sum.combine(&scores) - 5.0).abs() < 1e-6);
283 assert!((MultiValueCombiner::Max.combine(&scores) - 5.0).abs() < 1e-6);
284 assert!((MultiValueCombiner::Avg.combine(&scores) - 5.0).abs() < 1e-6);
285 assert!((MultiValueCombiner::log_sum_exp().combine(&scores) - 5.0).abs() < 1e-6);
286 assert!((MultiValueCombiner::weighted_top_k().combine(&scores) - 5.0).abs() < 1e-6);
287 }
288
289 #[test]
290 fn test_default_combiner_is_log_sum_exp() {
291 let combiner = MultiValueCombiner::default();
292 match combiner {
293 MultiValueCombiner::LogSumExp { temperature } => {
294 assert!((temperature - 1.5).abs() < 1e-6);
295 }
296 _ => panic!("Default combiner should be LogSumExp"),
297 }
298 }
299}