Skip to main content

khive_runtime/
fusion.rs

1//! Fusion strategies for combining ranked result lists.
2
3use std::collections::{HashMap, HashSet};
4
5use uuid::Uuid;
6
7use khive_retrieval::hit::{merge_hit_metadata, SignalMerge};
8use khive_score::DeterministicScore;
9use khive_storage::types::{
10    PageRequest, TextFilter, TextQueryMode, TextSearchHit, TextSearchRequest, VectorSearchHit,
11};
12use khive_storage::EntityFilter;
13use khive_types::SubstrateKind;
14
15use crate::error::{RuntimeError, RuntimeResult};
16use crate::retrieval::{RankScoreKind, SearchHit, SearchSignals, SearchSource};
17use crate::runtime::{KhiveRuntime, NamespaceToken};
18
19pub use khive_fusion::FusionStrategy;
20
21/// A single ranked candidate stream fed into a [`FusionExecutor`] โ€” the
22/// entity/note ID keyed shape used throughout hybrid search (ADR-012
23/// `FusionStrategy::Custom` ยง"strategy executor").
24pub type CandidateStream = Vec<(Uuid, DeterministicScore)>;
25
26/// One fused, ranked `(id, score)` pair returned by a [`FusionExecutor`].
27pub type RankedHit = (Uuid, DeterministicScore);
28
29/// Runtime-registered custom fusion strategy (ADR-012).
30///
31/// Packs implement this to plug a strategy into `FusionStrategy::Custom { name,
32/// .. }` via [`KhiveRuntime::register_fusion_strategy`] โ€” the seam a
33/// learned-sparse (SPLADE) retrieval leg plugs into. Async so an executor can
34/// perform I/O (e.g. a decay/posterior lookup) while fusing, and fallible so
35/// it can reject malformed `params` instead of degrading silently.
36#[async_trait::async_trait]
37pub trait FusionExecutor: Send + Sync + 'static {
38    /// Declare the strategy represented by the executor's ordering score.
39    fn rank_score_kind(&self) -> RankScoreKind;
40
41    /// Combine `streams` into a single ranked list, honoring `limit` as a
42    /// hint (the dispatch boundary re-sorts and truncates the result with the
43    /// crate's canonical comparator regardless, so an executor need not sort
44    /// or truncate defensively itself).
45    async fn fuse(
46        &self,
47        streams: Vec<CandidateStream>,
48        params: &serde_json::Value,
49        limit: usize,
50    ) -> RuntimeResult<Vec<RankedHit>>;
51}
52
53const CANDIDATE_MULTIPLIER: u32 = 4;
54
55impl KhiveRuntime {
56    /// Fuse text and vector hits using the given strategy, returning at most
57    /// `limit` results. Positional weighted strategies use `[vector, keyword]`
58    /// order.
59    ///
60    /// `FusionStrategy::Custom { name, .. }` is resolved against this
61    /// runtime's registered executors (see
62    /// [`register_fusion_strategy`](KhiveRuntime::register_fusion_strategy)).
63    /// An unregistered name fails closed with
64    /// `RuntimeError::UnknownFusionStrategy` rather than silently falling
65    /// back to RRF.
66    pub(crate) async fn fuse_with_strategy(
67        &self,
68        text_hits: Vec<TextSearchHit>,
69        vector_hits: Vec<VectorSearchHit>,
70        strategy: &FusionStrategy,
71        limit: usize,
72    ) -> RuntimeResult<Vec<SearchHit>> {
73        match strategy {
74            FusionStrategy::VectorOnly => {
75                self.fuse_sources(Vec::new(), vector_hits, strategy, limit)
76                    .await
77            }
78            FusionStrategy::KeywordOnly => {
79                self.fuse_sources(text_hits, Vec::new(), strategy, limit)
80                    .await
81            }
82            FusionStrategy::Rrf { .. }
83            | FusionStrategy::WeightedRrf { .. }
84            | FusionStrategy::Weighted { .. }
85            | FusionStrategy::Union
86            | FusionStrategy::Custom { .. } => {
87                self.fuse_sources(text_hits, vector_hits, strategy, limit)
88                    .await
89            }
90        }
91    }
92
93    async fn fuse_sources(
94        &self,
95        text_hits: Vec<TextSearchHit>,
96        vector_hits: Vec<VectorSearchHit>,
97        strategy: &FusionStrategy,
98        limit: usize,
99    ) -> RuntimeResult<Vec<SearchHit>> {
100        let mut metadata: HashMap<Uuid, SearchHit> =
101            HashMap::with_capacity(text_hits.len() + vector_hits.len());
102        let signal_merge = if matches!(
103            strategy,
104            FusionStrategy::Weighted { .. } | FusionStrategy::Union
105        ) {
106            SignalMerge::Maximum
107        } else {
108            SignalMerge::FirstPresent
109        };
110
111        let text_source: Vec<(Uuid, DeterministicScore)> = text_hits
112            .into_iter()
113            .map(|h| {
114                let hit = SearchHit {
115                    entity_id: h.subject_id,
116                    score: h.score,
117                    rank_score_kind: RankScoreKind::Keyword,
118                    signals: SearchSignals {
119                        vector_similarity: None,
120                        keyword_score: Some(h.score),
121                    },
122                    source: SearchSource::Text,
123                    title: h.title,
124                    snippet: h.snippet,
125                };
126                let id = hit.entity_id;
127                let score = hit.score;
128                merge_hit_metadata(&mut metadata, hit, signal_merge);
129                (id, score)
130            })
131            .collect();
132
133        let vector_source: Vec<(Uuid, DeterministicScore)> = vector_hits
134            .into_iter()
135            .map(|h| {
136                let hit = SearchHit {
137                    entity_id: h.subject_id,
138                    score: h.score,
139                    rank_score_kind: RankScoreKind::Vector,
140                    signals: SearchSignals {
141                        vector_similarity: Some(h.score),
142                        keyword_score: None,
143                    },
144                    source: SearchSource::Vector,
145                    title: None,
146                    snippet: None,
147                };
148                let id = hit.entity_id;
149                let score = hit.score;
150                merge_hit_metadata(&mut metadata, hit, signal_merge);
151                (id, score)
152            })
153            .collect();
154
155        // Canonical positional order is [vector, keyword]. Empty arms remain in
156        // place: removing one would shift the surviving arm onto the wrong weight.
157        let sources: Vec<Vec<(Uuid, DeterministicScore)>> = vec![vector_source, text_source];
158
159        let (rank_score_kind, fused) = self.dispatch_fusion(sources, strategy, limit).await?;
160
161        Ok(fused
162            .into_iter()
163            .filter_map(|(id, score)| {
164                let mut hit = metadata.remove(&id)?;
165                hit.score = score;
166                hit.rank_score_kind = rank_score_kind;
167                Some(hit)
168            })
169            .collect())
170    }
171
172    /// Resolve `strategy` against either the built-in `khive-fusion`
173    /// dispatcher or a registered [`FusionExecutor`], applying the crate's
174    /// canonical score-desc/id-asc ordering at the boundary either way.
175    ///
176    /// `Custom` names are resolved *before* the empty-input/zero-limit short
177    /// circuit, so a misconfigured name errors on every call -- including
178    /// zero-result ones -- rather than being indistinguishable from a valid
179    /// empty result.
180    async fn dispatch_fusion(
181        &self,
182        sources: Vec<Vec<(Uuid, DeterministicScore)>>,
183        strategy: &FusionStrategy,
184        limit: usize,
185    ) -> RuntimeResult<(RankScoreKind, Vec<RankedHit>)> {
186        let rank_score_kind = match RankScoreKind::of(strategy) {
187            Ok(kind) => kind,
188            Err((name, params)) => {
189                let executor = self.fusion_executor(name)?;
190                let rank_score_kind = executor.rank_score_kind();
191                if limit == 0 || sources.iter().all(Vec::is_empty) {
192                    return Ok((rank_score_kind, Vec::new()));
193                }
194                let mut hits = executor.fuse(sources, params, limit).await?;
195                hits.sort_by(khive_fusion::cmp_desc_then_id);
196                hits.truncate(limit);
197                return Ok((rank_score_kind, hits));
198            }
199        };
200        Ok((
201            rank_score_kind,
202            khive_fusion::fuse(sources, strategy, limit)?,
203        ))
204    }
205}
206
207impl KhiveRuntime {
208    async fn retain_alive_search_hits(
209        &self,
210        token: &NamespaceToken,
211        mut fused: Vec<SearchHit>,
212        limit: usize,
213    ) -> RuntimeResult<Vec<SearchHit>> {
214        // Filter out soft-deleted entities. A single query fetches all alive IDs from the
215        // fused candidate pool; any ID absent from the result has been soft-deleted.
216        if !fused.is_empty() {
217            let candidate_ids: Vec<Uuid> = fused.iter().map(|h| h.entity_id).collect();
218            let alive_page = self
219                .entities(token)?
220                .query_entities(
221                    token.namespace().as_str(),
222                    EntityFilter {
223                        ids: candidate_ids,
224                        ..EntityFilter::default()
225                    },
226                    PageRequest {
227                        offset: 0,
228                        limit: u32::try_from(fused.len()).unwrap_or(u32::MAX),
229                    },
230                )
231                .await?;
232            let alive: HashSet<Uuid> = alive_page.items.into_iter().map(|e| e.id).collect();
233            fused.retain(|h| alive.contains(&h.entity_id));
234        }
235
236        fused.truncate(limit);
237        Ok(fused)
238    }
239
240    /// Hybrid search with a caller-supplied fusion strategy.
241    ///
242    /// `FusionStrategy::Custom { name, .. }` is resolved against this
243    /// runtime's registered executors (see
244    /// [`register_fusion_strategy`](KhiveRuntime::register_fusion_strategy));
245    /// an unregistered name fails closed with
246    /// `RuntimeError::UnknownFusionStrategy`.
247    pub async fn hybrid_search_with_strategy(
248        &self,
249        token: &NamespaceToken,
250        query_text: &str,
251        query_vector: Option<Vec<f32>>,
252        strategy: FusionStrategy,
253        limit: u32,
254    ) -> RuntimeResult<Vec<SearchHit>> {
255        let candidates = limit.saturating_mul(CANDIDATE_MULTIPLIER).max(limit);
256
257        let text_hits = if matches!(&strategy, FusionStrategy::VectorOnly) {
258            Vec::new()
259        } else {
260            let ns = token.namespace().as_str().to_owned();
261            // sanitize_fts5_query strips known-unsafe metacharacters, but residual
262            // punctuation can still trip the FTS5 parser at runtime; that error must
263            // fail loud rather than silently degrade to vector-only fusion. Errors
264            // from other legs (vector search) still propagate normally.
265            let text_search_result = self
266                .text(token)?
267                .search(TextSearchRequest {
268                    query: query_text.to_string(),
269                    mode: TextQueryMode::Plain,
270                    filter: Some(TextFilter {
271                        namespaces: vec![ns],
272                        ..TextFilter::default()
273                    }),
274                    top_k: candidates,
275                    snippet_chars: 200,
276                })
277                .await;
278            crate::error::fts_text_leg_or_err(
279                text_search_result.map_err(RuntimeError::from),
280                "hybrid_search_with_strategy",
281                query_text,
282            )?
283        };
284
285        let vector_hits = if !matches!(&strategy, FusionStrategy::KeywordOnly)
286            && (query_vector.is_some() || self.config().embedding_model.is_some())
287        {
288            self.vector_search(
289                token,
290                query_vector,
291                Some(query_text),
292                candidates,
293                Some(SubstrateKind::Entity),
294            )
295            .await?
296        } else {
297            Vec::new()
298        };
299
300        // Each arm fetched `candidates` independently, so their union can contain
301        // twice that many distinct IDs. Keep the complete fetched pool through
302        // ranking and the alive check; truncating it first lets stale hits hide
303        // live candidates from the other arm.
304        let fusion_limit = text_hits.len().saturating_add(vector_hits.len());
305        let fused = self
306            .fuse_with_strategy(text_hits, vector_hits, &strategy, fusion_limit)
307            .await?;
308        self.retain_alive_search_hits(token, fused, limit as usize)
309            .await
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use chrono::Utc;
317    use khive_storage::types::{TextDocument, TextSearchHit, VectorSearchHit, VectorSearchRequest};
318    use khive_storage::Entity;
319    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
320    use std::sync::atomic::{AtomicUsize, Ordering};
321    use std::sync::Arc;
322
323    use crate::{EmbedderProvider, RuntimeConfig};
324
325    fn text_hit(id: Uuid, score: f64, title: &str) -> TextSearchHit {
326        TextSearchHit {
327            subject_id: id,
328            score: DeterministicScore::from_f64(score),
329            rank: 1,
330            title: Some(title.to_string()),
331            snippet: Some("...".to_string()),
332        }
333    }
334
335    fn vector_hit(id: Uuid, score: f64) -> VectorSearchHit {
336        VectorSearchHit {
337            subject_id: id,
338            score: DeterministicScore::from_f64(score),
339            rank: 1,
340        }
341    }
342
343    fn evidence_runtime() -> KhiveRuntime {
344        let backend = Arc::new(crate::StorageBackend::memory().expect("in-memory backend"));
345        backend.prepare_core_schema().expect("core schema");
346        KhiveRuntime::from_backend(
347            backend,
348            RuntimeConfig {
349                db_path: None,
350                events_split: None,
351                actor_id: Some("test:fusion-evidence".into()),
352                ..RuntimeConfig::no_embeddings()
353            },
354        )
355    }
356
357    struct CountingEmbeddingService {
358        calls: Arc<AtomicUsize>,
359        dimensions: usize,
360    }
361
362    #[async_trait::async_trait]
363    impl EmbeddingService for CountingEmbeddingService {
364        async fn embed(
365            &self,
366            texts: &[String],
367            _model: EmbeddingModel,
368        ) -> Result<Vec<Vec<f32>>, EmbedError> {
369            self.calls.fetch_add(1, Ordering::SeqCst);
370            Ok(texts.iter().map(|_| vec![1.0; self.dimensions]).collect())
371        }
372
373        fn supports_model(&self, _model: EmbeddingModel) -> bool {
374            true
375        }
376
377        fn name(&self) -> &'static str {
378            "fusion-counting-embedding"
379        }
380    }
381
382    struct CountingEmbedderProvider {
383        name: String,
384        calls: Arc<AtomicUsize>,
385        dimensions: usize,
386    }
387
388    #[async_trait::async_trait]
389    impl EmbedderProvider for CountingEmbedderProvider {
390        fn name(&self) -> &str {
391            &self.name
392        }
393
394        fn dimensions(&self) -> usize {
395            self.dimensions
396        }
397
398        async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
399            Ok(Arc::new(CountingEmbeddingService {
400                calls: Arc::clone(&self.calls),
401                dimensions: self.dimensions,
402            }))
403        }
404    }
405
406    fn counting_embedding_runtime() -> (KhiveRuntime, Arc<AtomicUsize>) {
407        let model = EmbeddingModel::AllMiniLmL6V2;
408        let rt = KhiveRuntime::new(RuntimeConfig {
409            db_path: None,
410            embedding_model: Some(model),
411            packs: vec!["kg".to_string()],
412            ..RuntimeConfig::no_embeddings()
413        })
414        .expect("in-memory runtime");
415        let calls = Arc::new(AtomicUsize::new(0));
416        rt.register_embedder(CountingEmbedderProvider {
417            name: model.to_string(),
418            calls: Arc::clone(&calls),
419            dimensions: model.dimensions(),
420        });
421        (rt, calls)
422    }
423
424    #[tokio::test]
425    async fn keyword_only_search_skips_embedding_and_vector_arm() {
426        let (rt, embed_calls) = counting_embedding_runtime();
427        let tok = NamespaceToken::local();
428        let entity = Entity::new("local", "concept", "keyword candidate");
429        rt.entities(&tok)
430            .unwrap()
431            .upsert_entities(vec![entity.clone()])
432            .await
433            .unwrap();
434        rt.text(&tok)
435            .unwrap()
436            .upsert_document(TextDocument {
437                subject_id: entity.id,
438                kind: SubstrateKind::Entity,
439                record_kind: None,
440                namespace: "local".to_string(),
441                title: None,
442                body: "fusionkeyword".to_string(),
443                tags: vec![],
444                metadata: None,
445                updated_at: Utc::now(),
446            })
447            .await
448            .unwrap();
449
450        let hits = rt
451            .hybrid_search_with_strategy(
452                &tok,
453                "fusionkeyword",
454                None,
455                FusionStrategy::KeywordOnly,
456                10,
457            )
458            .await
459            .unwrap();
460        assert_eq!(hits.len(), 1);
461        assert_eq!(hits[0].entity_id, entity.id);
462        assert_eq!(hits[0].source, SearchSource::Text);
463        assert_eq!(embed_calls.load(Ordering::SeqCst), 0);
464
465        let mixed = rt
466            .hybrid_search_with_strategy(&tok, "fusionkeyword", None, FusionStrategy::rrf(), 10)
467            .await
468            .unwrap();
469        assert_eq!(mixed[0].entity_id, entity.id);
470        assert_eq!(embed_calls.load(Ordering::SeqCst), 1);
471    }
472
473    #[tokio::test]
474    async fn vector_only_search_skips_unavailable_text_arm() {
475        let (rt, embed_calls) = counting_embedding_runtime();
476        let tok = NamespaceToken::local();
477        let entity = Entity::new("local", "concept", "vector candidate");
478        rt.entities(&tok)
479            .unwrap()
480            .upsert_entities(vec![entity.clone()])
481            .await
482            .unwrap();
483        let query_vector = vec![1.0; EmbeddingModel::AllMiniLmL6V2.dimensions()];
484        rt.vectors(&tok)
485            .unwrap()
486            .insert(
487                entity.id,
488                SubstrateKind::Entity,
489                "local",
490                "entity.body",
491                vec![query_vector.clone()],
492            )
493            .await
494            .unwrap();
495        rt.text(&tok).unwrap();
496        let mut writer = rt.sql().writer().await.unwrap();
497        writer
498            .execute_script(
499                "DROP TABLE fts_entities; CREATE TABLE fts_entities (id INTEGER PRIMARY KEY);"
500                    .to_string(),
501            )
502            .await
503            .unwrap();
504        drop(writer);
505
506        let hits = rt
507            .hybrid_search_with_strategy(
508                &tok,
509                "fusionkeyword",
510                Some(query_vector),
511                FusionStrategy::VectorOnly,
512                10,
513            )
514            .await
515            .unwrap();
516        assert_eq!(hits.len(), 1);
517        assert_eq!(hits[0].entity_id, entity.id);
518        assert_eq!(hits[0].source, SearchSource::Vector);
519        assert_eq!(embed_calls.load(Ordering::SeqCst), 0);
520    }
521
522    #[tokio::test]
523    async fn fusion_evidence_labels_builtin_strategies_and_preserves_components() {
524        let rt = evidence_runtime();
525        let id = Uuid::from_u128(1);
526        let keyword = DeterministicScore::from_raw(1_i64 << 30);
527        let vector = DeterministicScore::from_raw(3_i64 << 30);
528        for (strategy, kind, raw_score, signals) in [
529            (
530                FusionStrategy::Rrf { k: 60 },
531                RankScoreKind::Rrf,
532                140_818_600,
533                SearchSignals {
534                    vector_similarity: Some(vector),
535                    keyword_score: Some(keyword),
536                },
537            ),
538            (
539                FusionStrategy::VectorOnly,
540                RankScoreKind::Vector,
541                vector.to_raw(),
542                SearchSignals {
543                    vector_similarity: Some(vector),
544                    keyword_score: None,
545                },
546            ),
547            (
548                FusionStrategy::KeywordOnly,
549                RankScoreKind::Keyword,
550                keyword.to_raw(),
551                SearchSignals {
552                    vector_similarity: None,
553                    keyword_score: Some(keyword),
554                },
555            ),
556            (
557                FusionStrategy::weighted(vec![0.5, 0.5]),
558                RankScoreKind::Weighted,
559                1_i64 << 32,
560                SearchSignals {
561                    vector_similarity: Some(vector),
562                    keyword_score: Some(keyword),
563                },
564            ),
565            (
566                FusionStrategy::Union,
567                RankScoreKind::Union,
568                vector.to_raw(),
569                SearchSignals {
570                    vector_similarity: Some(vector),
571                    keyword_score: Some(keyword),
572                },
573            ),
574        ] {
575            let hits = rt
576                .fuse_with_strategy(
577                    vec![text_hit(id, 0.25, "candidate")],
578                    vec![vector_hit(id, 0.75)],
579                    &strategy,
580                    10,
581                )
582                .await
583                .unwrap();
584            assert_eq!(hits.len(), 1);
585            assert_eq!(hits[0].entity_id, id);
586            assert_eq!(hits[0].score.to_raw(), raw_score);
587            assert_eq!(hits[0].rank_score_kind, kind);
588            assert_eq!(hits[0].signals, signals);
589        }
590        assert_eq!(RankScoreKind::Rrf.as_str(), "rrf");
591        assert_eq!(RankScoreKind::Vector.as_str(), "vector");
592        assert_eq!(RankScoreKind::Keyword.as_str(), "keyword");
593        assert_eq!(RankScoreKind::Weighted.as_str(), "weighted");
594        assert_eq!(RankScoreKind::Union.as_str(), "union");
595    }
596
597    #[tokio::test]
598    async fn fusion_evidence_distinguishes_absence_from_zero() {
599        let rt = evidence_runtime();
600        let id = Uuid::from_u128(1);
601        for (text, vector, signals) in [
602            (
603                vec![text_hit(id, 0.0, "zero keyword")],
604                vec![],
605                SearchSignals {
606                    vector_similarity: None,
607                    keyword_score: Some(DeterministicScore::ZERO),
608                },
609            ),
610            (
611                vec![],
612                vec![vector_hit(id, 0.0)],
613                SearchSignals {
614                    vector_similarity: Some(DeterministicScore::ZERO),
615                    keyword_score: None,
616                },
617            ),
618        ] {
619            let hits = rt
620                .fuse_with_strategy(text, vector, &FusionStrategy::rrf(), 10)
621                .await
622                .unwrap();
623            assert_eq!(hits.len(), 1);
624            assert_eq!(hits[0].signals, signals);
625        }
626        assert_eq!(
627            SearchSignals::default(),
628            SearchSignals {
629                vector_similarity: None,
630                keyword_score: None,
631            }
632        );
633    }
634
635    #[tokio::test]
636    async fn fusion_evidence_golden_preserves_true_ties_across_permutations() {
637        let rt = evidence_runtime();
638        let a = Uuid::from_u128(1);
639        let b = Uuid::from_u128(2);
640        let expected = vec![
641            (
642                a,
643                139_682_966,
644                RankScoreKind::Rrf,
645                SearchSignals {
646                    vector_similarity: Some(DeterministicScore::from_raw(1_i64 << 31)),
647                    keyword_score: Some(DeterministicScore::from_raw(1_i64 << 30)),
648                },
649            ),
650            (
651                b,
652                139_682_966,
653                RankScoreKind::Rrf,
654                SearchSignals {
655                    vector_similarity: Some(DeterministicScore::from_raw(1_i64 << 32)),
656                    keyword_score: Some(DeterministicScore::from_raw(3_i64 << 30)),
657                },
658            ),
659        ];
660        for (text_ids, vector_ids) in [([a, b], [b, a]), ([b, a], [a, b])] {
661            for _ in 0..4 {
662                let text = text_ids
663                    .into_iter()
664                    .map(|id| text_hit(id, if id == a { 0.25 } else { 0.75 }, "candidate"))
665                    .collect();
666                let vector = vector_ids
667                    .into_iter()
668                    .map(|id| vector_hit(id, if id == a { 0.5 } else { 1.0 }))
669                    .collect();
670                let hits = rt
671                    .fuse_with_strategy(text, vector, &FusionStrategy::Rrf { k: 60 }, 10)
672                    .await
673                    .unwrap();
674                assert_eq!(hits.len(), 2);
675                assert_eq!(hits[0].score, hits[1].score);
676                assert!(hits.iter().all(|hit| hit.source == SearchSource::Both));
677                let snapshot: Vec<_> = hits
678                    .iter()
679                    .map(|hit| {
680                        (
681                            hit.entity_id,
682                            hit.score.to_raw(),
683                            hit.rank_score_kind,
684                            hit.signals,
685                        )
686                    })
687                    .collect();
688                assert_eq!(snapshot, expected);
689            }
690        }
691    }
692
693    #[tokio::test]
694    async fn fusion_evidence_duplicate_selection_follows_strategy() {
695        let rt = evidence_runtime();
696        let id = Uuid::from_u128(1);
697        for (strategy, keyword_raw) in [
698            (FusionStrategy::rrf(), 1_i64 << 30),
699            (FusionStrategy::Union, 3_i64 << 30),
700            (FusionStrategy::weighted(vec![0.5, 0.5]), 3_i64 << 30),
701        ] {
702            let hits = rt
703                .fuse_with_strategy(
704                    vec![text_hit(id, 0.25, "first"), text_hit(id, 0.75, "second")],
705                    vec![vector_hit(id, 0.5)],
706                    &strategy,
707                    10,
708                )
709                .await
710                .unwrap();
711            assert_eq!(hits.len(), 1);
712            assert_eq!(
713                hits[0].signals,
714                SearchSignals {
715                    vector_similarity: Some(DeterministicScore::from_raw(1_i64 << 31)),
716                    keyword_score: Some(DeterministicScore::from_raw(keyword_raw)),
717                }
718            );
719            assert_eq!(hits[0].title.as_deref(), Some("first"));
720        }
721    }
722
723    #[tokio::test]
724    async fn weighted_rrf_fusion_reports_the_rrf_kind() {
725        let rt = evidence_runtime();
726        let id = Uuid::from_u128(1);
727        let strategy = FusionStrategy::weighted_rrf(60, vec![1.0, 1.0]);
728        let hits = rt
729            .fuse_with_strategy(vec![text_hit(id, 0.75, "candidate")], vec![], &strategy, 10)
730            .await
731            .unwrap();
732        assert_eq!(hits.len(), 1);
733        assert_eq!(hits[0].rank_score_kind, RankScoreKind::Rrf);
734    }
735
736    #[tokio::test]
737    async fn custom_fusion_evidence_uses_declared_kind() {
738        let rt = evidence_runtime();
739        let id = Uuid::from_u128(1);
740        rt.register_fusion_strategy("invert", Arc::new(InvertScoreExecutor));
741        let strategy =
742            FusionStrategy::try_custom("invert".into(), serde_json::Value::Null).unwrap();
743        let hits = rt
744            .fuse_with_strategy(vec![text_hit(id, 0.75, "candidate")], vec![], &strategy, 10)
745            .await
746            .unwrap();
747        assert_eq!(hits.len(), 1);
748        assert_eq!(hits[0].score.to_raw(), 1_i64 << 30);
749        assert_eq!(hits[0].rank_score_kind, RankScoreKind::Weighted);
750        assert_eq!(
751            hits[0].signals,
752            SearchSignals {
753                vector_similarity: None,
754                keyword_score: Some(DeterministicScore::from_raw(3_i64 << 30)),
755            }
756        );
757    }
758
759    fn cosine_fixture_vector(dimensions: usize, x: f32, y: f32) -> Vec<f32> {
760        let mut vector = vec![0.0; dimensions];
761        vector[0] = x;
762        vector[1] = y;
763        vector
764    }
765
766    async fn stale_full_prefix_fixture() -> (
767        KhiveRuntime,
768        NamespaceToken,
769        &'static str,
770        Vec<f32>,
771        Vec<TextSearchHit>,
772        Vec<VectorSearchHit>,
773        HashSet<Uuid>,
774    ) {
775        let model = EmbeddingModel::AllMiniLmL6V2;
776        let dimensions = model.dimensions();
777        let rt = KhiveRuntime::new(RuntimeConfig {
778            db_path: None,
779            embedding_model: Some(model),
780            additional_embedding_models: vec![],
781            ..RuntimeConfig::default()
782        })
783        .unwrap();
784        let tok = NamespaceToken::local();
785        let query_text = "fusionrefillterm";
786        let query_vector = cosine_fixture_vector(dimensions, 1.0, 0.0);
787
788        let common_stale_a = Uuid::from_u128(1);
789        let common_stale_b = Uuid::from_u128(2);
790        let text_only_stale = Uuid::from_u128(3);
791        let vector_only_stale = Uuid::from_u128(4);
792
793        let live_text = Entity::new("local", "concept", "live text candidate");
794        let live_vector = Entity::new("local", "concept", "live vector candidate");
795        rt.entities(&tok)
796            .unwrap()
797            .upsert_entities(vec![live_text.clone(), live_vector.clone()])
798            .await
799            .unwrap();
800
801        let document = |subject_id, repetitions: usize| TextDocument {
802            subject_id,
803            kind: SubstrateKind::Entity,
804            record_kind: None,
805            namespace: "local".to_string(),
806            title: None,
807            body: std::iter::repeat_n(query_text, repetitions)
808                .collect::<Vec<_>>()
809                .join(" "),
810            tags: vec![],
811            metadata: None,
812            updated_at: Utc::now(),
813        };
814        rt.text(&tok)
815            .unwrap()
816            .upsert_documents(vec![
817                document(common_stale_a, 12),
818                document(common_stale_b, 8),
819                document(text_only_stale, 4),
820                document(live_text.id, 1),
821            ])
822            .await
823            .unwrap();
824
825        let vectors = rt.vectors(&tok).unwrap();
826        for (id, vector) in [
827            (common_stale_a, cosine_fixture_vector(dimensions, 1.0, 0.0)),
828            (common_stale_b, cosine_fixture_vector(dimensions, 0.8, 0.6)),
829            (
830                vector_only_stale,
831                cosine_fixture_vector(dimensions, 0.5, 0.866_025_4),
832            ),
833            (live_vector.id, cosine_fixture_vector(dimensions, -1.0, 0.0)),
834        ] {
835            vectors
836                .insert(
837                    id,
838                    SubstrateKind::Entity,
839                    "local",
840                    "entity.body",
841                    vec![vector],
842                )
843                .await
844                .unwrap();
845        }
846
847        let text_hits = rt
848            .text(&tok)
849            .unwrap()
850            .search(TextSearchRequest {
851                query: query_text.to_string(),
852                mode: TextQueryMode::Plain,
853                filter: Some(TextFilter {
854                    namespaces: vec!["local".to_string()],
855                    ..TextFilter::default()
856                }),
857                top_k: CANDIDATE_MULTIPLIER,
858                snippet_chars: 0,
859            })
860            .await
861            .unwrap();
862        let vector_hits = vectors
863            .search(VectorSearchRequest {
864                query_vectors: vec![query_vector.clone()],
865                top_k: CANDIDATE_MULTIPLIER,
866                namespace: Some("local".to_string()),
867                kind: Some(SubstrateKind::Entity),
868                embedding_model: None,
869                filter: None,
870                backend_hints: None,
871            })
872            .await
873            .unwrap();
874
875        assert_eq!(text_hits.len(), CANDIDATE_MULTIPLIER as usize);
876        assert_eq!(vector_hits.len(), CANDIDATE_MULTIPLIER as usize);
877        let live = HashSet::from([live_text.id, live_vector.id]);
878        (
879            rt,
880            tok,
881            query_text,
882            query_vector,
883            text_hits,
884            vector_hits,
885            live,
886        )
887    }
888
889    // 1. RRF with custom k produces different ordering than k=60
890    #[tokio::test]
891    async fn rrf_custom_k_differs_from_k60() {
892        let rt = KhiveRuntime::memory().unwrap();
893        let a = Uuid::new_v4();
894        let b = Uuid::new_v4();
895        // Single-source input makes a and b tie in relative order at both k values,
896        // so assert on raw score magnitude (smaller k widens the rank-1-vs-rank-2 gap)
897        // rather than ordering.
898        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
899        let hits_k1 = rt
900            .fuse_with_strategy(text.clone(), vec![], &FusionStrategy::Rrf { k: 1 }, 10)
901            .await
902            .unwrap();
903        let hits_k60 = rt
904            .fuse_with_strategy(text, vec![], &FusionStrategy::Rrf { k: 60 }, 10)
905            .await
906            .unwrap();
907        // Both should have a first (rank 1 always wins in single-source)
908        assert_eq!(hits_k1[0].entity_id, a);
909        assert_eq!(hits_k60[0].entity_id, a);
910        // k=1 produces higher raw score for rank 1 than k=60
911        assert!(hits_k1[0].score > hits_k60[0].score);
912    }
913
914    // 2. Canonical [vector, keyword] weights change ordering as documented.
915    #[tokio::test]
916    async fn weighted_ordering_depends_on_weights() {
917        let rt = KhiveRuntime::memory().unwrap();
918        let a = Uuid::new_v4();
919        let b = Uuid::new_v4();
920        // a scores high in text, b scores high in vector
921        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
922        let vec_hits = vec![vector_hit(b, 0.9), vector_hit(a, 0.1)];
923
924        let heavy_vector = rt
925            .fuse_with_strategy(
926                text.clone(),
927                vec_hits.clone(),
928                &FusionStrategy::Weighted {
929                    weights: vec![0.7, 0.3],
930                },
931                10,
932            )
933            .await
934            .unwrap();
935        let heavy_keyword = rt
936            .fuse_with_strategy(
937                text,
938                vec_hits,
939                &FusionStrategy::Weighted {
940                    weights: vec![0.3, 0.7],
941                },
942                10,
943            )
944            .await
945            .unwrap();
946
947        assert_eq!(heavy_vector[0].entity_id, b);
948        assert_eq!(heavy_keyword[0].entity_id, a);
949    }
950
951    // 3. Weighted [7.0, 3.0] = Weighted [0.7, 0.3] (normalization)
952    #[tokio::test]
953    async fn weighted_scale_invariant() {
954        let rt = KhiveRuntime::memory().unwrap();
955        let a = Uuid::new_v4();
956        let b = Uuid::new_v4();
957        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
958        let vec_hits = vec![vector_hit(b, 0.9), vector_hit(a, 0.1)];
959
960        let w1 = rt
961            .fuse_with_strategy(
962                text.clone(),
963                vec_hits.clone(),
964                &FusionStrategy::Weighted {
965                    weights: vec![0.7, 0.3],
966                },
967                10,
968            )
969            .await
970            .unwrap();
971        let w2 = rt
972            .fuse_with_strategy(
973                text,
974                vec_hits,
975                &FusionStrategy::Weighted {
976                    weights: vec![7.0, 3.0],
977                },
978                10,
979            )
980            .await
981            .unwrap();
982
983        assert_eq!(w1[0].entity_id, w2[0].entity_id);
984        assert_eq!(w1[1].entity_id, w2[1].entity_id);
985        let diff = (w1[0].score.to_f64() - w2[0].score.to_f64()).abs();
986        assert!(diff < 1e-9, "scores differ by {diff}");
987    }
988
989    // 4. Weighted [0.0, 0.0] falls back to equal weights
990    #[tokio::test]
991    async fn weighted_zero_weights_equal_fallback() {
992        let rt = KhiveRuntime::memory().unwrap();
993        let a = Uuid::new_v4();
994        let b = Uuid::new_v4();
995        // Both sources agree: a > b
996        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
997        let vec_hits = vec![vector_hit(a, 0.9), vector_hit(b, 0.1)];
998
999        let hits = rt
1000            .fuse_with_strategy(
1001                text,
1002                vec_hits,
1003                &FusionStrategy::Weighted {
1004                    weights: vec![0.0, 0.0],
1005                },
1006                10,
1007            )
1008            .await
1009            .unwrap();
1010        assert_eq!(hits[0].entity_id, a);
1011    }
1012
1013    // 5. Weighted with negative weight clamps to 0
1014    #[tokio::test]
1015    async fn weighted_negative_weight_clamped() {
1016        let rt = KhiveRuntime::memory().unwrap();
1017        let a = Uuid::new_v4();
1018        let text = vec![text_hit(a, 0.9, "a")];
1019        // Negative vector weight โ†’ only keyword/text contributes.
1020        let hits = rt
1021            .fuse_with_strategy(
1022                text,
1023                vec![],
1024                &FusionStrategy::Weighted {
1025                    weights: vec![-0.5, 1.0],
1026                },
1027                10,
1028            )
1029            .await
1030            .unwrap();
1031        assert_eq!(hits.len(), 1);
1032        assert_eq!(hits[0].entity_id, a);
1033    }
1034
1035    #[tokio::test]
1036    async fn weighted_empty_arm_keeps_canonical_position() {
1037        let rt = KhiveRuntime::memory().unwrap();
1038        let text_only = Uuid::new_v4();
1039        let hits = rt
1040            .fuse_with_strategy(
1041                vec![text_hit(text_only, 0.9, "text")],
1042                vec![],
1043                &FusionStrategy::Weighted {
1044                    // Canonical [vector, keyword]: the only non-empty arm has zero weight.
1045                    weights: vec![1.0, 0.0],
1046                },
1047                10,
1048            )
1049            .await
1050            .unwrap();
1051        assert!(
1052            hits.is_empty(),
1053            "dropping the empty vector arm would incorrectly rebind text to its weight"
1054        );
1055    }
1056
1057    // 6. Union returns max score per entity when same id appears in both lists
1058    #[tokio::test]
1059    async fn union_max_score_per_entity() {
1060        let rt = KhiveRuntime::memory().unwrap();
1061        let a = Uuid::new_v4();
1062        let text = vec![text_hit(a, 0.3, "a")];
1063        let vec_hits = vec![vector_hit(a, 0.9)];
1064
1065        let hits = rt
1066            .fuse_with_strategy(text, vec_hits, &FusionStrategy::Union, 10)
1067            .await
1068            .unwrap();
1069        assert_eq!(hits.len(), 1);
1070        assert!((hits[0].score.to_f64() - 0.9).abs() < 1e-6);
1071        assert_eq!(hits[0].source, SearchSource::Both);
1072    }
1073
1074    // 7. VectorOnly returns vector hits only (text hits dropped)
1075    #[tokio::test]
1076    async fn vector_only_drops_text() {
1077        let rt = KhiveRuntime::memory().unwrap();
1078        let a = Uuid::new_v4();
1079        let b = Uuid::new_v4();
1080        let text = vec![text_hit(b, 0.9, "b")];
1081        let vec_hits = vec![vector_hit(a, 0.8)];
1082
1083        let hits = rt
1084            .fuse_with_strategy(text, vec_hits, &FusionStrategy::VectorOnly, 10)
1085            .await
1086            .unwrap();
1087        assert_eq!(hits.len(), 1);
1088        assert_eq!(hits[0].entity_id, a);
1089        assert_eq!(hits[0].source, SearchSource::Vector);
1090        assert!(hits[0].title.is_none());
1091    }
1092
1093    #[tokio::test]
1094    async fn keyword_only_drops_vector() {
1095        let rt = KhiveRuntime::memory().unwrap();
1096        let text_id = Uuid::new_v4();
1097        let vector_id = Uuid::new_v4();
1098        let hits = rt
1099            .fuse_with_strategy(
1100                vec![text_hit(text_id, 0.8, "text")],
1101                vec![vector_hit(vector_id, 0.9)],
1102                &FusionStrategy::KeywordOnly,
1103                10,
1104            )
1105            .await
1106            .unwrap();
1107        assert_eq!(hits.len(), 1);
1108        assert_eq!(hits[0].entity_id, text_id);
1109        assert_eq!(hits[0].source, SearchSource::Text);
1110    }
1111
1112    /// Test-only executor: flattens all streams and reverses their order,
1113    /// keeping each candidate's original score.
1114    struct ReverseOrderExecutor;
1115
1116    #[async_trait::async_trait]
1117    impl FusionExecutor for ReverseOrderExecutor {
1118        fn rank_score_kind(&self) -> RankScoreKind {
1119            RankScoreKind::Union
1120        }
1121
1122        async fn fuse(
1123            &self,
1124            streams: Vec<CandidateStream>,
1125            _params: &serde_json::Value,
1126            _limit: usize,
1127        ) -> RuntimeResult<Vec<RankedHit>> {
1128            let mut flat: Vec<_> = streams.into_iter().flatten().collect();
1129            flat.reverse();
1130            Ok(flat)
1131        }
1132    }
1133
1134    /// Test-only executor: inverts each candidate's score (`1.0 - score`) so
1135    /// the fused ranking is the reverse of what score-descending built-ins
1136    /// (RRF, Union, Weighted) would produce on the same fixture -- unlike a
1137    /// mere insertion-order reversal, this survives the dispatch boundary's
1138    /// canonical re-sort, since the *scores* (not just the order) differ.
1139    struct InvertScoreExecutor;
1140
1141    #[async_trait::async_trait]
1142    impl FusionExecutor for InvertScoreExecutor {
1143        fn rank_score_kind(&self) -> RankScoreKind {
1144            RankScoreKind::Weighted
1145        }
1146
1147        async fn fuse(
1148            &self,
1149            streams: Vec<CandidateStream>,
1150            _params: &serde_json::Value,
1151            _limit: usize,
1152        ) -> RuntimeResult<Vec<RankedHit>> {
1153            Ok(streams
1154                .into_iter()
1155                .flatten()
1156                .map(|(id, score)| (id, DeterministicScore::from_f64(1.0 - score.to_f64())))
1157                .collect())
1158        }
1159    }
1160
1161    /// Test-only executor: returns every candidate at an identical score, in
1162    /// the arbitrary order the input streams happened to flatten to -- used
1163    /// to prove the dispatch boundary re-sorts by the canonical comparator
1164    /// rather than trusting executor output order.
1165    struct EqualScoreExecutor;
1166
1167    #[async_trait::async_trait]
1168    impl FusionExecutor for EqualScoreExecutor {
1169        fn rank_score_kind(&self) -> RankScoreKind {
1170            RankScoreKind::Weighted
1171        }
1172
1173        async fn fuse(
1174            &self,
1175            streams: Vec<CandidateStream>,
1176            _params: &serde_json::Value,
1177            _limit: usize,
1178        ) -> RuntimeResult<Vec<RankedHit>> {
1179            Ok(streams
1180                .into_iter()
1181                .flatten()
1182                .map(|(id, _)| (id, DeterministicScore::from_f64(1.0)))
1183                .collect())
1184        }
1185    }
1186
1187    // 7b. A registered custom executor dispatches and yields a different
1188    // ranking than RRF on the same fixture.
1189    #[tokio::test]
1190    async fn custom_strategy_dispatches_through_executor_and_differs_from_rrf() {
1191        let rt = KhiveRuntime::memory().unwrap();
1192        let a = Uuid::new_v4();
1193        let b = Uuid::new_v4();
1194        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.5, "b")];
1195
1196        rt.register_fusion_strategy("invert", Arc::new(InvertScoreExecutor));
1197        let strategy =
1198            FusionStrategy::try_custom("invert".to_string(), serde_json::Value::Null).unwrap();
1199
1200        let custom = rt
1201            .fuse_with_strategy(text.clone(), vec![], &strategy, 10)
1202            .await
1203            .unwrap();
1204        let rrf = rt
1205            .fuse_with_strategy(text, vec![], &FusionStrategy::rrf(), 10)
1206            .await
1207            .unwrap();
1208
1209        let custom_ids: Vec<_> = custom.iter().map(|h| h.entity_id).collect();
1210        let rrf_ids: Vec<_> = rrf.iter().map(|h| h.entity_id).collect();
1211        assert_ne!(
1212            custom_ids, rrf_ids,
1213            "custom and RRF must yield different orderings on this fixture"
1214        );
1215    }
1216
1217    // 7c. An unregistered Custom name fails closed rather than falling back to RRF.
1218    #[tokio::test]
1219    async fn custom_strategy_unknown_name_fails_closed() {
1220        let rt = KhiveRuntime::memory().unwrap();
1221        let a = Uuid::new_v4();
1222        let text = vec![text_hit(a, 0.9, "a")];
1223        let strategy =
1224            FusionStrategy::try_custom("nonexistent".to_string(), serde_json::Value::Null).unwrap();
1225
1226        let result = rt.fuse_with_strategy(text, vec![], &strategy, 10).await;
1227        assert!(matches!(
1228            result,
1229            Err(RuntimeError::UnknownFusionStrategy(name)) if name == "nonexistent"
1230        ));
1231    }
1232
1233    // 7d. Unknown name errors even with empty sources -- it must not be
1234    // indistinguishable from a valid empty result.
1235    #[tokio::test]
1236    async fn custom_strategy_unknown_name_fails_closed_even_on_empty_input() {
1237        let rt = KhiveRuntime::memory().unwrap();
1238        let strategy =
1239            FusionStrategy::try_custom("nonexistent".to_string(), serde_json::Value::Null).unwrap();
1240
1241        let result = rt.fuse_with_strategy(vec![], vec![], &strategy, 10).await;
1242        assert!(matches!(
1243            result,
1244            Err(RuntimeError::UnknownFusionStrategy(name)) if name == "nonexistent"
1245        ));
1246    }
1247
1248    // 7e. Empty sources with a *registered* name is a valid empty result, not
1249    // an error -- distinguishing "misconfigured" from "genuinely nothing".
1250    #[tokio::test]
1251    async fn custom_strategy_registered_name_empty_input_returns_ok_empty() {
1252        let rt = KhiveRuntime::memory().unwrap();
1253        rt.register_fusion_strategy("reverse", Arc::new(ReverseOrderExecutor));
1254        let strategy =
1255            FusionStrategy::try_custom("reverse".to_string(), serde_json::Value::Null).unwrap();
1256
1257        let result = rt
1258            .fuse_with_strategy(vec![], vec![], &strategy, 10)
1259            .await
1260            .unwrap();
1261        assert!(result.is_empty());
1262    }
1263
1264    // 7f. Registering a custom strategy never perturbs the default (non-Custom) path.
1265    #[tokio::test]
1266    async fn registered_custom_strategy_leaves_default_path_unaffected() {
1267        let rt = KhiveRuntime::memory().unwrap();
1268        let a = Uuid::new_v4();
1269        let b = Uuid::new_v4();
1270        let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.5, "b")];
1271
1272        rt.register_fusion_strategy("reverse", Arc::new(ReverseOrderExecutor));
1273
1274        let via_rt_with_registration = rt
1275            .fuse_with_strategy(text.clone(), vec![], &FusionStrategy::rrf(), 10)
1276            .await
1277            .unwrap();
1278        let rt2 = KhiveRuntime::memory().unwrap();
1279        let via_rt_without_registration = rt2
1280            .fuse_with_strategy(text, vec![], &FusionStrategy::rrf(), 10)
1281            .await
1282            .unwrap();
1283
1284        let ids_with: Vec<_> = via_rt_with_registration
1285            .iter()
1286            .map(|h| h.entity_id)
1287            .collect();
1288        let ids_without: Vec<_> = via_rt_without_registration
1289            .iter()
1290            .map(|h| h.entity_id)
1291            .collect();
1292        assert_eq!(ids_with, ids_without);
1293    }
1294
1295    // 7g. A custom executor returning equal-score IDs in arbitrary/reversed
1296    // order still yields the crate's canonical score-desc/id-asc order --
1297    // the dispatch boundary re-sorts rather than trusting executor output.
1298    #[tokio::test]
1299    async fn custom_executor_output_is_sorted_by_canonical_comparator() {
1300        let rt = KhiveRuntime::memory().unwrap();
1301        // Deliberately not in ID order, so a passthrough bug would be visible.
1302        let ids: Vec<Uuid> = vec![Uuid::from_u128(3), Uuid::from_u128(1), Uuid::from_u128(2)];
1303        let text: Vec<TextSearchHit> = ids.iter().map(|&id| text_hit(id, 0.5, "tied")).collect();
1304
1305        rt.register_fusion_strategy("equal_score", Arc::new(EqualScoreExecutor));
1306        let strategy =
1307            FusionStrategy::try_custom("equal_score".to_string(), serde_json::Value::Null).unwrap();
1308
1309        let hits = rt
1310            .fuse_with_strategy(text, vec![], &strategy, 10)
1311            .await
1312            .unwrap();
1313
1314        let mut expected = ids.clone();
1315        expected.sort();
1316        let actual: Vec<_> = hits.iter().map(|h| h.entity_id).collect();
1317        assert_eq!(
1318            actual, expected,
1319            "equal-score executor output must be tie-broken by ascending ID"
1320        );
1321    }
1322
1323    // 8. Default strategy is Rrf{k:60}
1324    #[test]
1325    fn default_strategy_is_rrf_k60() {
1326        assert_eq!(FusionStrategy::default(), FusionStrategy::Rrf { k: 60 });
1327    }
1328
1329    #[tokio::test]
1330    async fn hybrid_union_alive_filter_refills_below_complete_four_x_prefix() {
1331        let (rt, tok, query_text, query_vector, text_hits, vector_hits, live) =
1332            stale_full_prefix_fixture().await;
1333        let truncated = rt
1334            .fuse_with_strategy(
1335                text_hits,
1336                vector_hits,
1337                &FusionStrategy::Union,
1338                CANDIDATE_MULTIPLIER as usize,
1339            )
1340            .await
1341            .unwrap();
1342        assert!(truncated.iter().all(|hit| !live.contains(&hit.entity_id)));
1343
1344        let hits = rt
1345            .hybrid_search_with_strategy(
1346                &tok,
1347                query_text,
1348                Some(query_vector),
1349                FusionStrategy::Union,
1350                1,
1351            )
1352            .await
1353            .unwrap();
1354
1355        assert_eq!(hits.len(), 1);
1356        assert!(live.contains(&hits[0].entity_id));
1357    }
1358
1359    #[tokio::test]
1360    async fn hybrid_rrf_alive_filter_refills_below_complete_four_x_prefix() {
1361        let (rt, tok, query_text, query_vector, text_hits, vector_hits, live) =
1362            stale_full_prefix_fixture().await;
1363        let strategy = FusionStrategy::Rrf { k: 60 };
1364        let truncated = rt
1365            .fuse_with_strategy(
1366                text_hits,
1367                vector_hits,
1368                &strategy,
1369                CANDIDATE_MULTIPLIER as usize,
1370            )
1371            .await
1372            .unwrap();
1373        assert!(truncated.iter().all(|hit| !live.contains(&hit.entity_id)));
1374
1375        let hits = rt
1376            .hybrid_search_with_strategy(&tok, query_text, Some(query_vector), strategy, 1)
1377            .await
1378            .unwrap();
1379
1380        assert_eq!(hits.len(), 1);
1381        assert!(live.contains(&hits[0].entity_id));
1382    }
1383
1384    #[tokio::test]
1385    async fn hybrid_default_rrf_alive_filter_refills_below_complete_four_x_prefix() {
1386        let (rt, tok, query_text, query_vector, _text_hits, _vector_hits, live) =
1387            stale_full_prefix_fixture().await;
1388
1389        let hits = rt
1390            .hybrid_search(
1391                &tok,
1392                query_text,
1393                Some(query_vector),
1394                1,
1395                None,
1396                None,
1397                &[],
1398                None,
1399            )
1400            .await
1401            .unwrap();
1402
1403        assert_eq!(hits.len(), 1);
1404        assert!(live.contains(&hits[0].entity_id));
1405    }
1406
1407    // 9. Roundtrip serde preserves variant
1408    #[test]
1409    fn serde_roundtrip() {
1410        let cases = vec![
1411            FusionStrategy::Rrf { k: 60 },
1412            FusionStrategy::Rrf { k: 20 },
1413            FusionStrategy::Weighted {
1414                weights: vec![0.7, 0.3],
1415            },
1416            FusionStrategy::Union,
1417            FusionStrategy::VectorOnly,
1418            FusionStrategy::KeywordOnly,
1419        ];
1420        for strategy in cases {
1421            let json = serde_json::to_string(&strategy).expect("serialize");
1422            let back: FusionStrategy = serde_json::from_str(&json).expect("deserialize");
1423            assert_eq!(strategy, back, "roundtrip failed for {json}");
1424        }
1425    }
1426
1427    // 10. hybrid_search_with_strategy must not hard-fail on a query containing FTS5
1428    // metacharacters like `$`, since sanitize_fts5_query strips them before the query
1429    // reaches SQLite. This covers the sanitizer path; test 11 covers the fail-loud
1430    // path for characters the sanitizer does not strip.
1431    #[tokio::test]
1432    async fn hybrid_search_with_strategy_dollar_sign_query_does_not_error() {
1433        let rt = KhiveRuntime::memory().unwrap();
1434        let tok = NamespaceToken::local();
1435        rt.create_entity(
1436            &tok,
1437            "concept",
1438            None,
1439            "DSL docs",
1440            Some("use $prev.id to chain calls"),
1441            None,
1442            vec![],
1443        )
1444        .await
1445        .unwrap();
1446
1447        let result = rt
1448            .hybrid_search_with_strategy(&tok, "$prev.id", None, FusionStrategy::default(), 10)
1449            .await;
1450
1451        assert!(
1452            result.is_ok(),
1453            "#388 hybrid_search_with_strategy must not hard-fail on a '$'-bearing query, got: {:?}",
1454            result.err()
1455        );
1456    }
1457
1458    // 11. #916: `@` used to reach SQLite FTS5's bareword parser raw and error,
1459    // surfacing as RuntimeError::InvalidInput per #569's fail-loud policy.
1460    // sanitize_fts5_token_group's bareword-safety gate now routes it through the
1461    // quoted-phrase alternative instead, so the query succeeds and the fail-loud
1462    // arm is no longer reached for ordinary punctuation.
1463    #[tokio::test]
1464    async fn hybrid_search_with_strategy_residual_fts5_char_now_sanitized() {
1465        let rt = KhiveRuntime::memory().unwrap();
1466        let tok = NamespaceToken::local();
1467        rt.create_entity(
1468            &tok,
1469            "concept",
1470            None,
1471            "DSL docs",
1472            Some("use foo@bar to chain calls"),
1473            None,
1474            vec![],
1475        )
1476        .await
1477        .unwrap();
1478
1479        let result = rt
1480            .hybrid_search_with_strategy(&tok, "foo@bar", None, FusionStrategy::default(), 10)
1481            .await;
1482
1483        let hits = result.unwrap_or_else(|e| {
1484            panic!(
1485                "#916 hybrid_search_with_strategy must not fail on an '@'-bearing query, got: {e:?}"
1486            )
1487        });
1488        assert!(
1489            !hits.is_empty(),
1490            "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
1491             quoted-phrase alternative"
1492        );
1493    }
1494}