Skip to main content

knowledge_runtime/query/
merge.rs

1//! Deterministic result merge pipeline: fuse duplicates, normalize, boost, rank, and truncate.
2
3use semantic_memory::SearchResult;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7/// Merge policy for combining results from multiple route legs.
8///
9/// The merge pipeline:
10/// 1. **Collect** — gather raw results from each leg
11/// 2. **Fuse duplicates** — merge duplicates by source identity, preserving
12///    provenance from all contributing legs (union of source legs, best score)
13/// 3. **Normalize** — bring scores to a common scale
14/// 4. **Boost** — apply multi-leg support boost to fused results
15/// 5. **Rank** — order by final score descending
16/// 6. **Truncate** — apply the final limit
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct MergePolicy {
19    /// Final result limit after merge.
20    pub limit: usize,
21    /// How to handle score normalization across legs.
22    pub normalization: ScoreNormalization,
23    /// Boost factor applied to results that appeared in multiple legs.
24    /// Each additional leg multiplies the score by `(1.0 + multi_leg_boost)`.
25    /// Set to 0.0 to disable boosting.
26    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/// Strategy for normalizing scores across route legs before ranking.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum ScoreNormalization {
43    /// COR-004: Global min-max normalization to [0, 1] range across all fused results.
44    /// Applied after duplicate fusion, before multi-leg boosting.
45    MinMax,
46    /// No normalization — raw scores from each leg.
47    None,
48}
49
50/// A search result from a single route leg, tagged with its source leg index.
51#[derive(Debug, Clone)]
52pub struct LegResult {
53    /// Which leg produced this result.
54    pub leg_index: usize,
55    /// The search result.
56    pub result: SearchResult,
57}
58
59/// Output of the merge pipeline: ranked, deduplicated results with provenance metadata.
60#[derive(Debug, Clone)]
61pub struct MergedResults {
62    /// Final ordered results.
63    pub results: Vec<MergedItem>,
64    /// How many duplicate occurrences were fused (not discarded).
65    pub duplicates_fused: usize,
66    /// Total raw results before merge.
67    pub total_raw: usize,
68}
69
70/// A single ranked item in the merged result set, with fused provenance from all contributing legs.
71#[derive(Debug, Clone)]
72pub struct MergedItem {
73    /// The search result (uses the highest-scoring occurrence).
74    pub result: SearchResult,
75    /// Final score after normalization and boosting.
76    pub final_score: f64,
77    /// Which leg(s) contributed this result (fused provenance).
78    pub source_legs: Vec<usize>,
79    /// Per-leg raw scores for transparency.
80    pub per_leg_scores: Vec<(usize, f64)>,
81}
82
83/// Intermediate accumulator for fusing duplicates.
84struct FusedEntry {
85    /// Best result (highest raw score).
86    best_result: SearchResult,
87    /// All legs that produced this result.
88    source_legs: Vec<usize>,
89    /// Per-leg scores.
90    per_leg_scores: Vec<(usize, f64)>,
91    /// Best raw score.
92    best_score: f64,
93}
94
95/// Execute the merge pipeline on results from multiple legs.
96pub 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    // Phase 1: Collect
112    let all_results: Vec<LegResult> = leg_results.into_iter().flatten().collect();
113    let total_raw = all_results.len();
114
115    // COR-029: Use BTreeMap for deterministic iteration order in hot paths
116    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            // Fuse: add this leg's provenance
123            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    // Collect in insertion order for determinism
149    let fused: Vec<FusedEntry> = insertion_order
150        .into_iter()
151        .filter_map(|k| fused_map.remove(&k))
152        .collect();
153
154    // Phase 3: Normalize scores
155    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                    // Phase 4: Boost for multi-leg support
173                    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    // Phase 5: Rank by final score (descending), with deterministic tie-breaking.
203    // Tie-break: more source legs first, then by content identity key (lexicographic).
204    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    // Phase 6: Truncate
220    ranked.truncate(limit);
221
222    MergedResults {
223        results: ranked,
224        duplicates_fused,
225        total_raw,
226    }
227}
228
229/// Generate a dedup key from a search result's source identity.
230fn 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        // Fused result should have provenance from both legs
289        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        // Should use the higher score
295        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        // f1 (2 legs, score 0.8 * 1.1 = 0.88) should rank above f2 (1 leg, score 0.8)
321        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}