1use semantic_memory::SearchResult;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct MergePolicy {
19 pub limit: usize,
21 pub normalization: ScoreNormalization,
23 pub multi_leg_boost: f64,
27}
28
29impl Default for MergePolicy {
30 fn default() -> Self {
31 Self {
32 limit: 10,
33 normalization: ScoreNormalization::MinMax,
34 multi_leg_boost: 0.1,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum ScoreNormalization {
43 MinMax,
46 None,
48}
49
50#[derive(Debug, Clone)]
52pub struct LegResult {
53 pub leg_index: usize,
55 pub result: SearchResult,
57}
58
59#[derive(Debug, Clone)]
61pub struct MergedResults {
62 pub results: Vec<MergedItem>,
64 pub duplicates_fused: usize,
66 pub total_raw: usize,
68}
69
70#[derive(Debug, Clone)]
72pub struct MergedItem {
73 pub result: SearchResult,
75 pub final_score: f64,
77 pub source_legs: Vec<usize>,
79 pub per_leg_scores: Vec<(usize, f64)>,
81}
82
83struct FusedEntry {
85 best_result: SearchResult,
87 source_legs: Vec<usize>,
89 per_leg_scores: Vec<(usize, f64)>,
91 best_score: f64,
93}
94
95pub fn merge(leg_results: Vec<Vec<LegResult>>, policy: &MergePolicy) -> MergedResults {
97 let limit = policy.limit;
98 if limit == 0 {
99 return MergedResults {
100 results: Vec::new(),
101 duplicates_fused: 0,
102 total_raw: leg_results.into_iter().map(|leg| leg.len()).sum(),
103 };
104 }
105 let multi_leg_boost = if policy.multi_leg_boost.is_finite() && policy.multi_leg_boost >= 0.0 {
106 policy.multi_leg_boost
107 } else {
108 0.0
109 };
110
111 let all_results: Vec<LegResult> = leg_results.into_iter().flatten().collect();
113 let total_raw = all_results.len();
114
115 let mut fused_map: BTreeMap<String, FusedEntry> = BTreeMap::new();
117 let mut insertion_order: Vec<String> = Vec::new();
118
119 for lr in all_results {
120 let key = result_identity_key(&lr.result);
121 if let Some(entry) = fused_map.get_mut(&key) {
122 if !entry.source_legs.contains(&lr.leg_index) {
124 entry.source_legs.push(lr.leg_index);
125 entry.source_legs.sort_unstable();
126 }
127 entry.per_leg_scores.push((lr.leg_index, lr.result.score));
128 if lr.result.score > entry.best_score {
129 entry.best_score = lr.result.score;
130 entry.best_result = lr.result;
131 }
132 } else {
133 insertion_order.push(key.clone());
134 fused_map.insert(
135 key,
136 FusedEntry {
137 best_score: lr.result.score,
138 per_leg_scores: vec![(lr.leg_index, lr.result.score)],
139 source_legs: vec![lr.leg_index],
140 best_result: lr.result,
141 },
142 );
143 }
144 }
145
146 let duplicates_fused = total_raw.saturating_sub(fused_map.len());
147
148 let fused: Vec<FusedEntry> = insertion_order
150 .into_iter()
151 .filter_map(|k| fused_map.remove(&k))
152 .collect();
153
154 let items: Vec<MergedItem> = match policy.normalization {
156 ScoreNormalization::MinMax => {
157 let (min_score, max_score) = fused
158 .iter()
159 .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), e| {
160 (mn.min(e.best_score), mx.max(e.best_score))
161 });
162 let range = max_score - min_score;
163
164 fused
165 .into_iter()
166 .map(|e| {
167 let normalized = if range > f64::EPSILON {
168 (e.best_score - min_score) / range
169 } else {
170 1.0
171 };
172 let extra_legs = (e.source_legs.len() as f64 - 1.0).max(0.0);
174 let boosted =
175 (normalized * (1.0 + extra_legs * multi_leg_boost)).clamp(0.0, 1.0);
176
177 MergedItem {
178 result: e.best_result,
179 final_score: boosted,
180 source_legs: e.source_legs,
181 per_leg_scores: e.per_leg_scores,
182 }
183 })
184 .collect()
185 }
186 ScoreNormalization::None => fused
187 .into_iter()
188 .map(|e| {
189 let extra_legs = (e.source_legs.len() as f64 - 1.0).max(0.0);
190 let boosted = e.best_score * (1.0 + extra_legs * multi_leg_boost);
191
192 MergedItem {
193 result: e.best_result,
194 final_score: boosted,
195 source_legs: e.source_legs,
196 per_leg_scores: e.per_leg_scores,
197 }
198 })
199 .collect(),
200 };
201
202 let mut ranked = items;
205 ranked.sort_by(|a, b| {
206 b.final_score
207 .partial_cmp(&a.final_score)
208 .unwrap_or_else(|| {
209 if a.final_score.is_nan() {
210 std::cmp::Ordering::Greater
211 } else {
212 std::cmp::Ordering::Less
213 }
214 })
215 .then_with(|| b.source_legs.len().cmp(&a.source_legs.len()))
216 .then_with(|| result_identity_key(&a.result).cmp(&result_identity_key(&b.result)))
217 });
218
219 ranked.truncate(limit);
221
222 MergedResults {
223 results: ranked,
224 duplicates_fused,
225 total_raw,
226 }
227}
228
229fn result_identity_key(result: &SearchResult) -> String {
231 match &result.source {
232 semantic_memory::SearchSource::Fact { fact_id, .. } => format!("fact:{fact_id}"),
233 semantic_memory::SearchSource::Chunk { chunk_id, .. } => format!("chunk:{chunk_id}"),
234 semantic_memory::SearchSource::Message {
235 message_id,
236 session_id,
237 ..
238 } => {
239 format!("msg:{session_id}:{message_id}")
240 }
241 semantic_memory::SearchSource::Episode { episode_id, .. } => {
242 format!("episode:{episode_id}")
243 }
244 semantic_memory::SearchSource::Projection {
245 projection_kind,
246 projection_id,
247 ..
248 } => {
249 format!("projection:{projection_kind}:{projection_id}")
250 }
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use semantic_memory::{SearchResult, SearchSource};
258
259 fn make_fact_result(id: &str, score: f64) -> SearchResult {
260 SearchResult {
261 content: format!("fact {id}"),
262 source: SearchSource::Fact {
263 fact_id: id.to_string(),
264 namespace: "test".to_string(),
265 },
266 score,
267 bm25_rank: None,
268 vector_rank: None,
269 cosine_similarity: None,
270 }
271 }
272
273 #[test]
274 fn fuses_duplicates_across_legs_with_provenance() {
275 let leg0 = vec![LegResult {
276 leg_index: 0,
277 result: make_fact_result("f1", 0.9),
278 }];
279 let leg1 = vec![LegResult {
280 leg_index: 1,
281 result: make_fact_result("f1", 0.8),
282 }];
283
284 let merged = merge(vec![leg0, leg1], &MergePolicy::default());
285 assert_eq!(merged.results.len(), 1);
286 assert_eq!(merged.duplicates_fused, 1);
287
288 let item = &merged.results[0];
290 assert!(item.source_legs.contains(&0));
291 assert!(item.source_legs.contains(&1));
292 assert_eq!(item.per_leg_scores.len(), 2);
293
294 assert_eq!(item.result.score, 0.9);
296 }
297
298 #[test]
299 fn multi_leg_boost_increases_score() {
300 let leg0 = vec![LegResult {
301 leg_index: 0,
302 result: make_fact_result("f1", 0.8),
303 }];
304 let leg1 = vec![LegResult {
305 leg_index: 1,
306 result: make_fact_result("f1", 0.7),
307 }];
308 let single = vec![LegResult {
309 leg_index: 0,
310 result: make_fact_result("f2", 0.8),
311 }];
312
313 let policy = MergePolicy {
314 limit: 10,
315 normalization: ScoreNormalization::None,
316 multi_leg_boost: 0.1,
317 };
318
319 let merged = merge(vec![leg0, leg1, single], &policy);
320 assert_eq!(merged.results[0].result.content, "fact f1");
322 assert!(merged.results[0].final_score > merged.results[1].final_score);
323 }
324
325 #[test]
326 fn respects_limit() {
327 let leg: Vec<LegResult> = (0..20)
328 .map(|i| LegResult {
329 leg_index: 0,
330 result: make_fact_result(&format!("f{i}"), 1.0 - (i as f64 * 0.01)),
331 })
332 .collect();
333
334 let policy = MergePolicy {
335 limit: 5,
336 ..Default::default()
337 };
338 let merged = merge(vec![leg], &policy);
339 assert_eq!(merged.results.len(), 5);
340 }
341
342 #[test]
343 fn zero_limit_returns_empty_results() {
344 let leg = vec![LegResult {
345 leg_index: 0,
346 result: make_fact_result("f1", 1.0),
347 }];
348 let policy = MergePolicy {
349 limit: 0,
350 ..Default::default()
351 };
352 let merged = merge(vec![leg], &policy);
353 assert!(merged.results.is_empty());
354 assert_eq!(merged.total_raw, 1);
355 }
356
357 #[test]
358 fn minmax_boost_stays_in_unit_range() {
359 let leg0 = vec![LegResult {
360 leg_index: 0,
361 result: make_fact_result("f1", 1.0),
362 }];
363 let leg1 = vec![LegResult {
364 leg_index: 1,
365 result: make_fact_result("f1", 1.0),
366 }];
367 let policy = MergePolicy {
368 limit: 10,
369 normalization: ScoreNormalization::MinMax,
370 multi_leg_boost: 10.0,
371 };
372 let merged = merge(vec![leg0, leg1], &policy);
373 assert!(merged.results[0].final_score <= 1.0);
374 }
375
376 #[test]
377 fn ranks_by_score_descending() {
378 let leg = vec![
379 LegResult {
380 leg_index: 0,
381 result: make_fact_result("low", 0.1),
382 },
383 LegResult {
384 leg_index: 0,
385 result: make_fact_result("high", 0.9),
386 },
387 ];
388
389 let merged = merge(vec![leg], &MergePolicy::default());
390 assert!(merged.results[0].final_score >= merged.results[1].final_score);
391 }
392
393 #[test]
394 fn merge_is_deterministic() {
395 let make_legs = || {
396 vec![
397 vec![
398 LegResult {
399 leg_index: 0,
400 result: make_fact_result("a", 0.5),
401 },
402 LegResult {
403 leg_index: 0,
404 result: make_fact_result("b", 0.5),
405 },
406 ],
407 vec![LegResult {
408 leg_index: 1,
409 result: make_fact_result("a", 0.5),
410 }],
411 ]
412 };
413
414 let r1 = merge(make_legs(), &MergePolicy::default());
415 let r2 = merge(make_legs(), &MergePolicy::default());
416
417 assert_eq!(r1.results.len(), r2.results.len());
418 for (a, b) in r1.results.iter().zip(r2.results.iter()) {
419 assert_eq!(a.result.content, b.result.content);
420 assert_eq!(a.source_legs, b.source_legs);
421 }
422 }
423}