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