1use 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
20pub type CandidateStream = Vec<(Uuid, DeterministicScore)>;
24
25pub type RankedHit = (Uuid, DeterministicScore);
27
28#[async_trait::async_trait]
36pub trait FusionExecutor: Send + Sync + 'static {
37 fn rank_score_kind(&self) -> RankScoreKind;
39
40 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
54pub(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 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 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 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 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 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 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 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 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 #[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 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 assert_eq!(hits_k1[0].entity_id, a);
788 assert_eq!(hits_k60[0].entity_id, a);
789 assert!(hits_k1[0].score > hits_k60[0].score);
791 }
792
793 #[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 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 #[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 #[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 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 #[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 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 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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[tokio::test]
1178 async fn custom_executor_output_is_sorted_by_canonical_comparator() {
1179 let rt = KhiveRuntime::memory().unwrap();
1180 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 #[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 #[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 #[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 #[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}