Skip to main content

khive_runtime/
fusion.rs

1//! Fusion strategies for combining ranked result lists.
2
3use std::collections::{hash_map::Entry, HashMap, HashSet};
4
5use uuid::Uuid;
6
7use khive_score::DeterministicScore;
8use khive_storage::types::{
9    PageRequest, TextFilter, TextQueryMode, TextSearchHit, TextSearchRequest, VectorSearchHit,
10};
11use khive_storage::EntityFilter;
12use khive_types::SubstrateKind;
13
14use crate::error::{RuntimeError, RuntimeResult};
15use crate::retrieval::{SearchHit, SearchSource};
16use crate::runtime::{KhiveRuntime, NamespaceToken};
17
18pub use khive_fusion::FusionStrategy;
19
20const CANDIDATE_MULTIPLIER: u32 = 4;
21
22/// Fuse text and vector hits using the given strategy, returning at most `limit` results.
23pub fn fuse_with_strategy(
24    text_hits: Vec<TextSearchHit>,
25    vector_hits: Vec<VectorSearchHit>,
26    strategy: &FusionStrategy,
27    limit: usize,
28) -> RuntimeResult<Vec<SearchHit>> {
29    match strategy {
30        FusionStrategy::VectorOnly => fuse_sources(Vec::new(), vector_hits, strategy, limit),
31        FusionStrategy::KeywordOnly => fuse_sources(text_hits, Vec::new(), strategy, limit),
32        FusionStrategy::Rrf { .. } | FusionStrategy::Weighted { .. } | FusionStrategy::Union => {
33            fuse_sources(text_hits, vector_hits, strategy, limit)
34        }
35        FusionStrategy::Custom { ref name, .. } => {
36            Err(khive_fusion::FuseError::CustomRequiresRuntime(name.clone()).into())
37        }
38    }
39}
40
41/// RRF convenience wrapper used by operations.rs (k=60 note search path).
42pub(crate) fn rrf_fuse_k(
43    text_hits: Vec<TextSearchHit>,
44    vector_hits: Vec<VectorSearchHit>,
45    k: usize,
46    limit: usize,
47) -> RuntimeResult<Vec<SearchHit>> {
48    fuse_with_strategy(text_hits, vector_hits, &FusionStrategy::Rrf { k }, limit)
49}
50
51fn fuse_sources(
52    text_hits: Vec<TextSearchHit>,
53    vector_hits: Vec<VectorSearchHit>,
54    strategy: &FusionStrategy,
55    limit: usize,
56) -> RuntimeResult<Vec<SearchHit>> {
57    let mut metadata: HashMap<Uuid, SearchHit> =
58        HashMap::with_capacity(text_hits.len() + vector_hits.len());
59
60    let text_source: Vec<(Uuid, DeterministicScore)> = text_hits
61        .into_iter()
62        .map(|h| {
63            let hit = SearchHit {
64                entity_id: h.subject_id,
65                score: h.score,
66                source: SearchSource::Text,
67                title: h.title,
68                snippet: h.snippet,
69            };
70            let id = hit.entity_id;
71            let score = hit.score;
72            merge_metadata(&mut metadata, hit);
73            (id, score)
74        })
75        .collect();
76
77    let vector_source: Vec<(Uuid, DeterministicScore)> = vector_hits
78        .into_iter()
79        .map(|h| {
80            let hit = SearchHit {
81                entity_id: h.subject_id,
82                score: h.score,
83                source: SearchSource::Vector,
84                title: None,
85                snippet: None,
86            };
87            let id = hit.entity_id;
88            let score = hit.score;
89            merge_metadata(&mut metadata, hit);
90            (id, score)
91        })
92        .collect();
93
94    let sources: Vec<Vec<(Uuid, DeterministicScore)>> = vec![text_source, vector_source]
95        .into_iter()
96        .filter(|s| !s.is_empty())
97        .collect();
98
99    Ok(khive_fusion::fuse(sources, strategy, limit)?
100        .into_iter()
101        .filter_map(|(id, score)| {
102            let mut hit = metadata.remove(&id)?;
103            hit.score = score;
104            Some(hit)
105        })
106        .collect())
107}
108
109fn merge_metadata(metadata: &mut HashMap<Uuid, SearchHit>, hit: SearchHit) {
110    match metadata.entry(hit.entity_id) {
111        Entry::Occupied(mut entry) => {
112            let existing = entry.get_mut();
113            existing.source = merge_sources(existing.source, hit.source);
114            if existing.title.is_none() {
115                existing.title = hit.title;
116            }
117            if existing.snippet.is_none() {
118                existing.snippet = hit.snippet;
119            }
120        }
121        Entry::Vacant(entry) => {
122            entry.insert(hit);
123        }
124    }
125}
126
127fn merge_sources(left: SearchSource, right: SearchSource) -> SearchSource {
128    match (left, right) {
129        (SearchSource::Both, _) | (_, SearchSource::Both) => SearchSource::Both,
130        (SearchSource::Text, SearchSource::Vector) | (SearchSource::Vector, SearchSource::Text) => {
131            SearchSource::Both
132        }
133        (SearchSource::Text, SearchSource::Text) => SearchSource::Text,
134        (SearchSource::Vector, SearchSource::Vector) => SearchSource::Vector,
135    }
136}
137
138impl KhiveRuntime {
139    /// Hybrid search with a caller-supplied fusion strategy.
140    pub async fn hybrid_search_with_strategy(
141        &self,
142        token: &NamespaceToken,
143        query_text: &str,
144        query_vector: Option<Vec<f32>>,
145        strategy: FusionStrategy,
146        limit: u32,
147    ) -> RuntimeResult<Vec<SearchHit>> {
148        let candidates = limit.saturating_mul(CANDIDATE_MULTIPLIER).max(limit);
149
150        let ns = token.namespace().as_str().to_owned();
151        // sanitize_fts5_query strips known-unsafe metacharacters, but residual
152        // punctuation can still trip the FTS5 parser at runtime; that error must
153        // fail loud rather than silently degrade to vector-only fusion. Errors
154        // from other legs (vector search) still propagate normally.
155        let text_search_result = self
156            .text(token)?
157            .search(TextSearchRequest {
158                query: query_text.to_string(),
159                mode: TextQueryMode::Plain,
160                filter: Some(TextFilter {
161                    namespaces: vec![ns.clone()],
162                    ..TextFilter::default()
163                }),
164                top_k: candidates,
165                snippet_chars: 200,
166            })
167            .await;
168        let text_hits = crate::error::fts_text_leg_or_err(
169            text_search_result.map_err(RuntimeError::from),
170            "hybrid_search_with_strategy",
171            query_text,
172        )?;
173
174        let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
175            self.vector_search(
176                token,
177                query_vector,
178                Some(query_text),
179                candidates,
180                Some(SubstrateKind::Entity),
181            )
182            .await?
183        } else {
184            Vec::new()
185        };
186
187        let mut fused = fuse_with_strategy(text_hits, vector_hits, &strategy, limit as usize)?;
188
189        // Filter out soft-deleted entities. A single query fetches all alive IDs from the
190        // fused set; any ID absent from the result has been soft-deleted (deleted_at IS NOT NULL).
191        if !fused.is_empty() {
192            let candidate_ids: Vec<Uuid> = fused.iter().map(|h| h.entity_id).collect();
193            let alive_page = self
194                .entities(token)?
195                .query_entities(
196                    token.namespace().as_str(),
197                    EntityFilter {
198                        ids: candidate_ids,
199                        ..EntityFilter::default()
200                    },
201                    PageRequest {
202                        offset: 0,
203                        limit: fused.len() as u32,
204                    },
205                )
206                .await?;
207            let alive: HashSet<Uuid> = alive_page.items.into_iter().map(|e| e.id).collect();
208            fused.retain(|h| alive.contains(&h.entity_id));
209        }
210
211        Ok(fused)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use khive_storage::types::{TextSearchHit, VectorSearchHit};
219
220    fn text_hit(id: Uuid, score: f64, title: &str) -> TextSearchHit {
221        TextSearchHit {
222            subject_id: id,
223            score: DeterministicScore::from_f64(score),
224            rank: 1,
225            title: Some(title.to_string()),
226            snippet: Some("...".to_string()),
227        }
228    }
229
230    fn vector_hit(id: Uuid, score: f64) -> VectorSearchHit {
231        VectorSearchHit {
232            subject_id: id,
233            score: DeterministicScore::from_f64(score),
234            rank: 1,
235        }
236    }
237
238    // 1. RRF with custom k produces different ordering than k=60
239    #[test]
240    fn rrf_custom_k_differs_from_k60() {
241        let a = Uuid::new_v4();
242        let b = Uuid::new_v4();
243        // Single-source input makes a and b tie in relative order at both k values,
244        // so assert on raw score magnitude (smaller k widens the rank-1-vs-rank-2 gap)
245        // rather than ordering.
246        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
247        let hits_k1 =
248            fuse_with_strategy(text.clone(), vec![], &FusionStrategy::Rrf { k: 1 }, 10).unwrap();
249        let hits_k60 =
250            fuse_with_strategy(text, vec![], &FusionStrategy::Rrf { k: 60 }, 10).unwrap();
251        // Both should have a first (rank 1 always wins in single-source)
252        assert_eq!(hits_k1[0].entity_id, a);
253        assert_eq!(hits_k60[0].entity_id, a);
254        // k=1 produces higher raw score for rank 1 than k=60
255        assert!(hits_k1[0].score > hits_k60[0].score);
256    }
257
258    // 2. Weighted [0.7, 0.3] gives different ordering than [0.3, 0.7]
259    #[test]
260    fn weighted_ordering_depends_on_weights() {
261        let a = Uuid::new_v4();
262        let b = Uuid::new_v4();
263        // a scores high in text, b scores high in vector
264        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
265        let vec_hits = vec![vector_hit(b, 0.9), vector_hit(a, 0.1)];
266
267        let heavy_text = fuse_with_strategy(
268            text.clone(),
269            vec_hits.clone(),
270            &FusionStrategy::Weighted {
271                weights: vec![0.7, 0.3],
272            },
273            10,
274        )
275        .unwrap();
276        let heavy_vec = fuse_with_strategy(
277            text,
278            vec_hits,
279            &FusionStrategy::Weighted {
280                weights: vec![0.3, 0.7],
281            },
282            10,
283        )
284        .unwrap();
285
286        assert_eq!(heavy_text[0].entity_id, a);
287        assert_eq!(heavy_vec[0].entity_id, b);
288    }
289
290    // 3. Weighted [7.0, 3.0] = Weighted [0.7, 0.3] (normalization)
291    #[test]
292    fn weighted_scale_invariant() {
293        let a = Uuid::new_v4();
294        let b = Uuid::new_v4();
295        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
296        let vec_hits = vec![vector_hit(b, 0.9), vector_hit(a, 0.1)];
297
298        let w1 = fuse_with_strategy(
299            text.clone(),
300            vec_hits.clone(),
301            &FusionStrategy::Weighted {
302                weights: vec![0.7, 0.3],
303            },
304            10,
305        )
306        .unwrap();
307        let w2 = fuse_with_strategy(
308            text,
309            vec_hits,
310            &FusionStrategy::Weighted {
311                weights: vec![7.0, 3.0],
312            },
313            10,
314        )
315        .unwrap();
316
317        assert_eq!(w1[0].entity_id, w2[0].entity_id);
318        assert_eq!(w1[1].entity_id, w2[1].entity_id);
319        let diff = (w1[0].score.to_f64() - w2[0].score.to_f64()).abs();
320        assert!(diff < 1e-9, "scores differ by {diff}");
321    }
322
323    // 4. Weighted [0.0, 0.0] falls back to equal weights
324    #[test]
325    fn weighted_zero_weights_equal_fallback() {
326        let a = Uuid::new_v4();
327        let b = Uuid::new_v4();
328        // Both sources agree: a > b
329        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
330        let vec_hits = vec![vector_hit(a, 0.9), vector_hit(b, 0.1)];
331
332        let hits = fuse_with_strategy(
333            text,
334            vec_hits,
335            &FusionStrategy::Weighted {
336                weights: vec![0.0, 0.0],
337            },
338            10,
339        )
340        .unwrap();
341        assert_eq!(hits[0].entity_id, a);
342    }
343
344    // 5. Weighted with negative weight clamps to 0
345    #[test]
346    fn weighted_negative_weight_clamped() {
347        let a = Uuid::new_v4();
348        let text = vec![text_hit(a, 0.9, "a")];
349        // Negative vector weight → only text contributes
350        let hits = fuse_with_strategy(
351            text,
352            vec![],
353            &FusionStrategy::Weighted {
354                weights: vec![1.0, -0.5],
355            },
356            10,
357        )
358        .unwrap();
359        assert_eq!(hits.len(), 1);
360        assert_eq!(hits[0].entity_id, a);
361    }
362
363    // 6. Union returns max score per entity when same id appears in both lists
364    #[test]
365    fn union_max_score_per_entity() {
366        let a = Uuid::new_v4();
367        let text = vec![text_hit(a, 0.3, "a")];
368        let vec_hits = vec![vector_hit(a, 0.9)];
369
370        let hits = fuse_with_strategy(text, vec_hits, &FusionStrategy::Union, 10).unwrap();
371        assert_eq!(hits.len(), 1);
372        assert!((hits[0].score.to_f64() - 0.9).abs() < 1e-6);
373        assert_eq!(hits[0].source, SearchSource::Both);
374    }
375
376    // 7. VectorOnly returns vector hits only (text hits dropped)
377    #[test]
378    fn vector_only_drops_text() {
379        let a = Uuid::new_v4();
380        let b = Uuid::new_v4();
381        let text = vec![text_hit(b, 0.9, "b")];
382        let vec_hits = vec![vector_hit(a, 0.8)];
383
384        let hits = fuse_with_strategy(text, vec_hits, &FusionStrategy::VectorOnly, 10).unwrap();
385        assert_eq!(hits.len(), 1);
386        assert_eq!(hits[0].entity_id, a);
387        assert_eq!(hits[0].source, SearchSource::Vector);
388        assert!(hits[0].title.is_none());
389    }
390
391    // 8. Default strategy is Rrf{k:60}
392    #[test]
393    fn default_strategy_is_rrf_k60() {
394        assert_eq!(FusionStrategy::default(), FusionStrategy::Rrf { k: 60 });
395    }
396
397    // 9. Roundtrip serde preserves variant
398    #[test]
399    fn serde_roundtrip() {
400        let cases = vec![
401            FusionStrategy::Rrf { k: 60 },
402            FusionStrategy::Rrf { k: 20 },
403            FusionStrategy::Weighted {
404                weights: vec![0.7, 0.3],
405            },
406            FusionStrategy::Union,
407            FusionStrategy::VectorOnly,
408        ];
409        for strategy in cases {
410            let json = serde_json::to_string(&strategy).expect("serialize");
411            let back: FusionStrategy = serde_json::from_str(&json).expect("deserialize");
412            assert_eq!(strategy, back, "roundtrip failed for {json}");
413        }
414    }
415
416    // 10. hybrid_search_with_strategy must not hard-fail on a query containing FTS5
417    // metacharacters like `$`, since sanitize_fts5_query strips them before the query
418    // reaches SQLite. This covers the sanitizer path; test 11 covers the fail-loud
419    // path for characters the sanitizer does not strip.
420    #[tokio::test]
421    async fn hybrid_search_with_strategy_dollar_sign_query_does_not_error() {
422        let rt = KhiveRuntime::memory().unwrap();
423        let tok = NamespaceToken::local();
424        rt.create_entity(
425            &tok,
426            "concept",
427            None,
428            "DSL docs",
429            Some("use $prev.id to chain calls"),
430            None,
431            vec![],
432        )
433        .await
434        .unwrap();
435
436        let result = rt
437            .hybrid_search_with_strategy(&tok, "$prev.id", None, FusionStrategy::default(), 10)
438            .await;
439
440        assert!(
441            result.is_ok(),
442            "#388 hybrid_search_with_strategy must not hard-fail on a '$'-bearing query, got: {:?}",
443            result.err()
444        );
445    }
446
447    // 11. #916: `@` used to reach SQLite FTS5's bareword parser raw and error,
448    // surfacing as RuntimeError::InvalidInput per #569's fail-loud policy.
449    // sanitize_fts5_token_group's bareword-safety gate now routes it through the
450    // quoted-phrase alternative instead, so the query succeeds and the fail-loud
451    // arm is no longer reached for ordinary punctuation.
452    #[tokio::test]
453    async fn hybrid_search_with_strategy_residual_fts5_char_now_sanitized() {
454        let rt = KhiveRuntime::memory().unwrap();
455        let tok = NamespaceToken::local();
456        rt.create_entity(
457            &tok,
458            "concept",
459            None,
460            "DSL docs",
461            Some("use foo@bar to chain calls"),
462            None,
463            vec![],
464        )
465        .await
466        .unwrap();
467
468        let result = rt
469            .hybrid_search_with_strategy(&tok, "foo@bar", None, FusionStrategy::default(), 10)
470            .await;
471
472        let hits = result.unwrap_or_else(|e| {
473            panic!(
474                "#916 hybrid_search_with_strategy must not fail on an '@'-bearing query, got: {e:?}"
475            )
476        });
477        assert!(
478            !hits.is_empty(),
479            "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
480             quoted-phrase alternative"
481        );
482    }
483}