1use 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
21pub type CandidateStream = Vec<(Uuid, DeterministicScore)>;
25
26pub type RankedHit = (Uuid, DeterministicScore);
28
29#[async_trait::async_trait]
37pub trait FusionExecutor: Send + Sync + 'static {
38 fn rank_score_kind(&self) -> RankScoreKind;
40
41 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 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 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 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 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 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 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 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 #[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 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 assert_eq!(hits_k1[0].entity_id, a);
909 assert_eq!(hits_k60[0].entity_id, a);
910 assert!(hits_k1[0].score > hits_k60[0].score);
912 }
913
914 #[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 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 #[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 #[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 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 #[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 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 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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[tokio::test]
1299 async fn custom_executor_output_is_sorted_by_canonical_comparator() {
1300 let rt = KhiveRuntime::memory().unwrap();
1301 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 #[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 #[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 #[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 #[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}