1use std::collections::{HashMap, HashSet};
4
5use lattice_embed::EmbeddingModel;
6use uuid::Uuid;
7
8use crate::config::{parse_embedding_model_alias, sanitize_key};
9use crate::curation::note_fts_document;
10use crate::error::{RuntimeError, RuntimeResult};
11use crate::runtime::{KhiveRuntime, NamespaceToken};
12use khive_score::{rrf_score, DeterministicScore};
13use khive_storage::types::{
14 PageRequest, TextFilter, TextQueryMode, TextSearchHit, TextSearchRequest, VectorSearchHit,
15 VectorSearchRequest,
16};
17use khive_storage::EntityFilter;
18use khive_types::SubstrateKind;
19
20#[cfg(any(test, feature = "fault-injection"))]
22std::thread_local! {
23 static BACKFILL_READER_FAIL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
24}
25
26#[cfg(any(test, feature = "fault-injection"))]
32pub fn arm_backfill_reader_fail() {
33 BACKFILL_READER_FAIL.with(|c| c.set(true));
34}
35
36#[derive(Clone, Debug)]
38pub struct SearchHit {
39 pub entity_id: Uuid,
40 pub score: DeterministicScore,
41 pub source: SearchSource,
42 pub title: Option<String>,
43 pub snippet: Option<String>,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum SearchSource {
49 Vector,
50 Text,
51 Both,
52}
53
54const RRF_K: usize = 10;
61
62const CANDIDATE_MULTIPLIER: u32 = 4;
64
65impl KhiveRuntime {
66 pub async fn embed(&self, text: &str) -> RuntimeResult<Vec<f32>> {
71 let model_name = self.default_embedder_name();
72 if model_name.is_empty() {
73 return Err(RuntimeError::Unconfigured("embedding_model".into()));
74 }
75 self.embed_with_model(model_name, text).await
76 }
77
78 pub async fn embed_with_model(&self, model_name: &str, text: &str) -> RuntimeResult<Vec<f32>> {
94 let model = parse_embedding_model_alias(model_name);
95 let service = self.embedder(model_name).await?;
96 let emb_model = model.unwrap_or_default();
97 Ok(service.embed_one(text, emb_model).await?)
98 }
99
100 pub async fn embed_document_with_model(
119 &self,
120 model_name: &str,
121 text: &str,
122 ) -> RuntimeResult<Vec<f32>> {
123 let model = parse_embedding_model_alias(model_name);
124 let service = self.embedder(model_name).await?;
125 let emb_model = model.unwrap_or_default();
126 service
127 .embed_passage(&[text.to_string()], emb_model)
128 .await?
129 .into_iter()
130 .next()
131 .ok_or_else(|| RuntimeError::Internal("embed_passage returned empty vec".into()))
132 }
133
134 pub async fn embed_query_with_model(
146 &self,
147 model_name: &str,
148 text: &str,
149 ) -> RuntimeResult<Vec<f32>> {
150 let model = parse_embedding_model_alias(model_name);
151 let service = self.embedder(model_name).await?;
152 let texts = [text.to_string()];
153 let emb_model = model.unwrap_or_default();
154 let embeddings = match emb_model {
155 EmbeddingModel::BgeSmallEnV15
156 | EmbeddingModel::BgeBaseEnV15
157 | EmbeddingModel::BgeLargeEnV15 => service.embed(&texts, emb_model).await?,
158 _ => service.embed_query(&texts, emb_model).await?,
159 };
160 embeddings
161 .into_iter()
162 .next()
163 .ok_or_else(|| RuntimeError::Internal("embed_query returned empty vec".into()))
164 }
165
166 pub async fn embed_document(&self, text: &str) -> RuntimeResult<Vec<f32>> {
173 let model_name = self.default_embedder_name();
174 if model_name.is_empty() {
175 return Err(RuntimeError::Unconfigured("embedding_model".into()));
176 }
177 self.embed_document_with_model(model_name, text).await
178 }
179
180 pub async fn embed_query(&self, text: &str) -> RuntimeResult<Vec<f32>> {
187 let model_name = self.default_embedder_name();
188 if model_name.is_empty() {
189 return Err(RuntimeError::Unconfigured("embedding_model".into()));
190 }
191 self.embed_query_with_model(model_name, text).await
192 }
193
194 pub async fn embed_batch(&self, texts: &[String]) -> RuntimeResult<Vec<Vec<f32>>> {
202 if texts.is_empty() {
203 return Ok(vec![]);
204 }
205 let model_name = self.default_embedder_name();
206 if model_name.is_empty() {
207 return Err(RuntimeError::Unconfigured("embedding_model".into()));
208 }
209 self.embed_batch_with_model(model_name, texts).await
210 }
211
212 pub async fn embed_batch_with_model(
217 &self,
218 model_name: &str,
219 texts: &[String],
220 ) -> RuntimeResult<Vec<Vec<f32>>> {
221 if texts.is_empty() {
222 return Ok(vec![]);
223 }
224 let model = parse_embedding_model_alias(model_name);
225 let service = self.embedder(model_name).await?;
226 let emb_model = model.unwrap_or_default();
227 Ok(service.embed(texts, emb_model).await?)
228 }
229
230 pub async fn embed_document_batch_with_model(
240 &self,
241 model_name: &str,
242 texts: &[String],
243 ) -> RuntimeResult<Vec<Vec<f32>>> {
244 if texts.is_empty() {
245 return Ok(vec![]);
246 }
247 let model = parse_embedding_model_alias(model_name);
248 let service = self.embedder(model_name).await?;
249 let emb_model = model.unwrap_or_default();
250 Ok(service.embed_passage(texts, emb_model).await?)
251 }
252
253 pub async fn embed_document_batch(&self, texts: &[String]) -> RuntimeResult<Vec<Vec<f32>>> {
260 if texts.is_empty() {
261 return Ok(vec![]);
262 }
263 let model_name = self.default_embedder_name();
264 if model_name.is_empty() {
265 return Err(RuntimeError::Unconfigured("embedding_model".into()));
266 }
267 self.embed_document_batch_with_model(model_name, texts)
268 .await
269 }
270
271 pub async fn embed_query_batch_with_model(
278 &self,
279 model_name: &str,
280 texts: &[String],
281 ) -> RuntimeResult<Vec<Vec<f32>>> {
282 if texts.is_empty() {
283 return Ok(vec![]);
284 }
285 let model = parse_embedding_model_alias(model_name);
286 let service = self.embedder(model_name).await?;
287 let emb_model = model.unwrap_or_default();
288 match emb_model {
289 EmbeddingModel::BgeSmallEnV15
290 | EmbeddingModel::BgeBaseEnV15
291 | EmbeddingModel::BgeLargeEnV15 => Ok(service.embed(texts, emb_model).await?),
292 _ => Ok(service.embed_query(texts, emb_model).await?),
293 }
294 }
295
296 pub async fn vector_search(
302 &self,
303 token: &NamespaceToken,
304 query_embedding: Option<Vec<f32>>,
305 query_text: Option<&str>,
306 top_k: u32,
307 kind: Option<SubstrateKind>,
308 ) -> RuntimeResult<Vec<VectorSearchHit>> {
309 let embedding = match query_embedding {
310 Some(vec) => vec,
311 None => {
312 let text = query_text.ok_or_else(|| {
313 RuntimeError::InvalidInput(
314 "vector search requires query_embedding or query_text".into(),
315 )
316 })?;
317 if text.trim().is_empty() {
318 return Err(RuntimeError::InvalidInput(
319 "query_text must not be empty".into(),
320 ));
321 }
322 self.embed_query(text).await?
323 }
324 };
325
326 let ns = token.namespace().as_str().to_owned();
327 Ok(self
328 .vectors(token)?
329 .search(VectorSearchRequest {
330 query_vectors: vec![embedding],
331 top_k,
332 namespace: Some(ns),
333 kind,
334 embedding_model: None,
335 filter: None,
336 backend_hints: None,
337 })
338 .await?)
339 }
340
341 #[allow(clippy::too_many_arguments)]
382 pub async fn hybrid_search(
383 &self,
384 token: &NamespaceToken,
385 query_text: &str,
386 query_vector: Option<Vec<f32>>,
387 limit: u32,
388 entity_kind: Option<&str>,
389 entity_type: Option<&str>,
390 tags_any: &[String],
391 properties_filter: Option<&serde_json::Value>,
392 ) -> RuntimeResult<Vec<SearchHit>> {
393 let candidates = limit.saturating_mul(CANDIDATE_MULTIPLIER).max(limit);
394
395 let visible_ns: Vec<String> = token
396 .visible_namespaces()
397 .iter()
398 .map(|ns| ns.as_str().to_owned())
399 .collect();
400 let text_search_result = self
404 .text(token)?
405 .search(TextSearchRequest {
406 query: query_text.to_string(),
407 mode: TextQueryMode::Plain,
408 filter: Some(TextFilter {
409 namespaces: visible_ns.clone(),
410 ..TextFilter::default()
411 }),
412 top_k: candidates,
413 snippet_chars: 200,
414 })
415 .await;
416 let text_hits = crate::error::fts_text_leg_or_err(
417 text_search_result.map_err(RuntimeError::from),
418 "hybrid_search",
419 query_text,
420 )?;
421
422 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
423 self.vector_search(
424 token,
425 query_vector,
426 Some(query_text),
427 candidates,
428 Some(SubstrateKind::Entity),
429 )
430 .await?
431 } else {
432 Vec::new()
433 };
434
435 let mut fused = rrf_fuse(text_hits, vector_hits, candidates as usize, query_text);
439
440 if !fused.is_empty() {
443 let candidate_ids: Vec<Uuid> = fused.iter().map(|h| h.entity_id).collect();
444 let alive_page = self
445 .entities(token)?
446 .query_entities(
447 token.namespace().as_str(),
448 EntityFilter {
449 ids: candidate_ids,
450 kinds: entity_kind.map(|k| vec![k.to_string()]).unwrap_or_default(),
451 entity_types: entity_type.map(|t| vec![t.to_string()]).unwrap_or_default(),
452 namespaces: visible_ns,
453 tags_any: tags_any.to_vec(),
454 ..EntityFilter::default()
455 },
456 PageRequest {
457 offset: 0,
458 limit: fused.len() as u32,
459 },
460 )
461 .await?;
462 let mut entity_meta: HashMap<Uuid, (String, Option<String>)> = HashMap::new();
463 let mut alive: HashSet<Uuid> = HashSet::new();
464 for e in alive_page.items {
465 if let Some(pf) = properties_filter {
468 if !entity_props_match(e.properties.as_ref(), pf) {
469 continue;
470 }
471 }
472 alive.insert(e.id);
473 entity_meta.insert(e.id, (e.name, e.description));
474 }
475
476 fused.retain(|h| alive.contains(&h.entity_id));
477
478 for hit in &mut fused {
480 if let Some((name, description)) = entity_meta.get(&hit.entity_id) {
481 if hit.title.is_none() {
482 hit.title = Some(name.clone());
483 }
484 if hit.snippet.is_none() {
485 hit.snippet = description.clone();
486 }
487 }
488 }
489 }
490
491 fused.truncate(limit as usize);
492 Ok(fused)
493 }
494
495 pub async fn knn(
501 &self,
502 token: &NamespaceToken,
503 query_vector: Vec<f32>,
504 top_k: u32,
505 ) -> RuntimeResult<Vec<VectorSearchHit>> {
506 let ns = token.namespace().as_str().to_owned();
507 Ok(self
508 .vectors(token)?
509 .search(VectorSearchRequest {
510 query_vectors: vec![query_vector],
511 top_k,
512 namespace: Some(ns),
513 kind: Some(SubstrateKind::Entity),
514 embedding_model: None,
515 filter: None,
516 backend_hints: None,
517 })
518 .await?)
519 }
520
521 pub async fn rerank(
527 &self,
528 token: &NamespaceToken,
529 query_vector: &[f32],
530 candidate_ids: &[Uuid],
531 top_k: u32,
532 ) -> RuntimeResult<Vec<VectorSearchHit>> {
533 let candidate_set: HashSet<Uuid> = candidate_ids.iter().copied().collect();
534 let ns = token.namespace().as_str().to_owned();
535 let all_hits = self
536 .vectors(token)?
537 .search(VectorSearchRequest {
538 query_vectors: vec![query_vector.to_vec()],
539 top_k: candidate_ids.len() as u32,
540 namespace: Some(ns),
541 kind: Some(SubstrateKind::Entity),
542 embedding_model: None,
543 filter: None,
544 backend_hints: None,
545 })
546 .await?;
547 let mut hits: Vec<VectorSearchHit> = all_hits
548 .into_iter()
549 .filter(|h| candidate_set.contains(&h.subject_id))
550 .collect();
551 hits.sort_by_key(|hit| std::cmp::Reverse(hit.score));
552 hits.truncate(top_k as usize);
553 Ok(hits)
554 }
555
556 pub async fn backfill_missing_embeddings(&self, token: &NamespaceToken) -> RuntimeResult<u64> {
569 use khive_storage::types::{SqlRow, SqlStatement, SqlValue};
570
571 let model_names = self.registered_embedding_model_names();
572 if model_names.is_empty() {
573 tracing::debug!(
574 "backfill_missing_embeddings: no embedding models registered, skipping"
575 );
576 return Ok(0);
577 }
578
579 let ns = token.namespace().as_str().to_string();
580 let mut total_backfilled = 0u64;
581
582 for model_name in &model_names {
583 let vec_table = format!("vec_{}", sanitize_key(model_name));
585
586 const PAGE_SIZE: usize = 500;
590 let mut entity_total = 0usize;
591 loop {
592 let entity_sql = SqlStatement {
593 sql: format!(
594 "SELECT id, name, description FROM entities \
595 WHERE namespace = ?1 AND deleted_at IS NULL \
596 AND id NOT IN (\
597 SELECT subject_id FROM {vec_table} \
598 WHERE namespace = ?1 AND embedding_model = ?2 \
599 ) LIMIT {PAGE_SIZE}"
600 ),
601 params: vec![
602 SqlValue::Text(ns.clone()),
603 SqlValue::Text(model_name.clone()),
604 ],
605 label: Some("backfill_entities".into()),
606 };
607
608 let entity_rows: Vec<SqlRow> = {
609 let sql = self.sql();
610 let reader_result = sql.reader().await;
611 #[cfg(any(test, feature = "fault-injection"))]
612 let reader_result = if BACKFILL_READER_FAIL.with(|c| c.get()) {
613 BACKFILL_READER_FAIL.with(|c| c.set(false));
614 Err(khive_storage::StorageError::Pool {
615 operation: "reader".into(),
616 message: "injected failure".into(),
617 })
618 } else {
619 reader_result
620 };
621 let mut reader = reader_result.map_err(RuntimeError::Storage)?;
622 reader
623 .query_all(entity_sql)
624 .await
625 .map_err(RuntimeError::Storage)?
626 };
627
628 let batch_len = entity_rows.len();
629 entity_total += batch_len;
630
631 for row in &entity_rows {
632 let id_str = row.columns.first().and_then(|c| {
633 if let SqlValue::Text(s) = &c.value {
634 Some(s.clone())
635 } else {
636 None
637 }
638 });
639 let description = row.columns.get(2).and_then(|c| {
640 if let SqlValue::Text(s) = &c.value {
641 Some(s.clone())
642 } else if let SqlValue::Null = &c.value {
643 None
644 } else {
645 None
646 }
647 });
648
649 let (Some(id_str), Some(desc)) = (id_str, description) else {
650 continue;
651 };
652 let Ok(id) = id_str.parse::<Uuid>() else {
653 continue;
654 };
655 if desc.trim().is_empty() {
656 continue;
657 }
658
659 match self.embed_document_with_model(model_name, &desc).await {
660 Ok(vector) => {
661 if let Ok(vs) = self.vectors_for_model(token, model_name) {
662 match vs
663 .insert(
664 id,
665 SubstrateKind::Entity,
666 &ns,
667 "entity.description",
668 vec![vector],
669 )
670 .await
671 {
672 Ok(()) => {
673 total_backfilled += 1;
674 }
675 Err(e) => {
676 tracing::warn!(
677 id = %id, model = %model_name,
678 error = %e,
679 "backfill_missing_embeddings: entity vector insert failed"
680 );
681 }
682 }
683 }
684 }
685 Err(e) => {
686 tracing::warn!(
687 id = %id, model = %model_name,
688 error = %e,
689 "backfill_missing_embeddings: entity embed failed"
690 );
691 }
692 }
693 }
694
695 if batch_len < PAGE_SIZE {
696 break;
697 }
698 }
699
700 let text_store = self.text_for_notes(token).ok();
702 let note_store = self.notes(token).ok();
703 let mut note_total = 0usize;
704 loop {
705 let note_sql = SqlStatement {
708 sql: format!(
709 "SELECT id FROM notes \
710 WHERE namespace = ?1 AND deleted_at IS NULL \
711 AND id NOT IN (\
712 SELECT subject_id FROM {vec_table} \
713 WHERE namespace = ?1 AND embedding_model = ?2 \
714 ) LIMIT {PAGE_SIZE}"
715 ),
716 params: vec![
717 SqlValue::Text(ns.clone()),
718 SqlValue::Text(model_name.clone()),
719 ],
720 label: Some("backfill_notes".into()),
721 };
722
723 let note_rows: Vec<SqlRow> = {
724 let sql = self.sql();
725 let reader_result = sql.reader().await;
726 #[cfg(any(test, feature = "fault-injection"))]
727 let reader_result = if BACKFILL_READER_FAIL.with(|c| c.get()) {
728 BACKFILL_READER_FAIL.with(|c| c.set(false));
729 Err(khive_storage::StorageError::Pool {
730 operation: "reader".into(),
731 message: "injected failure".into(),
732 })
733 } else {
734 reader_result
735 };
736 let mut reader = reader_result.map_err(RuntimeError::Storage)?;
737 reader
738 .query_all(note_sql)
739 .await
740 .map_err(RuntimeError::Storage)?
741 };
742
743 let batch_len = note_rows.len();
744 note_total += batch_len;
745
746 for row in ¬e_rows {
747 let id_str = row.columns.first().and_then(|c| {
748 if let SqlValue::Text(s) = &c.value {
749 Some(s.clone())
750 } else {
751 None
752 }
753 });
754
755 let Some(id_str) = id_str else {
756 continue;
757 };
758 let Ok(id) = id_str.parse::<Uuid>() else {
759 continue;
760 };
761
762 let note = match ¬e_store {
763 Some(store) => match store.get_note(id).await {
764 Ok(Some(n)) => n,
765 _ => continue,
766 },
767 None => continue,
768 };
769
770 if note.content.trim().is_empty() {
771 continue;
772 }
773
774 if model_names.first().map(|n| n.as_str()) == Some(model_name.as_str()) {
777 if let Some(ref ts) = text_store {
778 if let Err(e) = ts.upsert_document(note_fts_document(¬e)).await {
779 tracing::warn!(id = %id, error = %e,
780 "backfill_missing_embeddings: note FTS upsert failed");
781 }
782 }
783 }
784
785 let content = note.content.clone();
786 match self.embed_document_with_model(model_name, &content).await {
787 Ok(vector) => {
788 if let Ok(vs) = self.vectors_for_model(token, model_name) {
789 match vs
790 .insert(
791 id,
792 SubstrateKind::Note,
793 &ns,
794 "note.content",
795 vec![vector],
796 )
797 .await
798 {
799 Ok(()) => {
800 total_backfilled += 1;
801 }
802 Err(e) => {
803 tracing::warn!(
804 id = %id, model = %model_name,
805 error = %e,
806 "backfill_missing_embeddings: note vector insert failed"
807 );
808 }
809 }
810 }
811 }
812 Err(e) => {
813 tracing::warn!(
814 id = %id, model = %model_name,
815 error = %e,
816 "backfill_missing_embeddings: note embed failed"
817 );
818 }
819 }
820 }
821
822 if batch_len < PAGE_SIZE {
823 break;
824 }
825 }
826
827 tracing::info!(
828 model = %model_name,
829 namespace = %ns,
830 entities = entity_total,
831 notes = note_total,
832 "backfill_missing_embeddings: model pass complete"
833 );
834 }
835
836 tracing::info!(
837 namespace = %ns,
838 total_backfilled = total_backfilled,
839 "backfill_missing_embeddings: finished"
840 );
841
842 Ok(total_backfilled)
843 }
844
845 pub async fn sweep_orphan_vectors(
860 &self,
861 token: &NamespaceToken,
862 max_delete_per_model: u32,
863 dry_run: bool,
864 ) -> RuntimeResult<u64> {
865 use khive_storage::types::OrphanSweepConfig;
866 use khive_storage::StorageError;
867
868 let model_names = self.registered_embedding_model_names();
869 if model_names.is_empty() {
870 tracing::debug!("sweep_orphan_vectors: no embedding models registered, skipping");
871 return Ok(0);
872 }
873
874 let ns = token.namespace().as_str().to_string();
875 let mut total_deleted = 0u64;
876
877 for model_name in &model_names {
878 let store = match self.vectors_for_model(token, model_name) {
879 Ok(s) => s,
880 Err(e) => {
881 tracing::warn!(
882 model = %model_name,
883 error = %e,
884 "sweep_orphan_vectors: failed to get vector store, skipping model"
885 );
886 continue;
887 }
888 };
889
890 let caps = store.capabilities();
891 if !caps.supports_orphan_sweep {
892 tracing::debug!(
893 model = %model_name,
894 "sweep_orphan_vectors: backend does not support orphan sweep, skipping"
895 );
896 continue;
897 }
898
899 let config = OrphanSweepConfig {
900 subject_id_allowlist: None,
901 namespaces: vec![ns.clone()],
902 substrate_kinds: vec![],
903 max_delete: max_delete_per_model,
904 dry_run,
905 };
906
907 match store.orphan_sweep(&config).await {
908 Ok(result) => {
909 tracing::info!(
910 model = %model_name,
911 namespace = %ns,
912 scanned = result.scanned,
913 deleted = result.deleted,
914 would_delete = result.would_delete,
915 dry_run = dry_run,
916 "sweep_orphan_vectors: sweep complete"
917 );
918 total_deleted += result.deleted;
919 }
920 Err(StorageError::Unsupported { .. }) => {
921 tracing::debug!(
922 model = %model_name,
923 "sweep_orphan_vectors: backend returned Unsupported, skipping"
924 );
925 }
926 Err(e) => {
927 tracing::warn!(
928 model = %model_name,
929 error = %e,
930 "sweep_orphan_vectors: sweep failed, continuing with other models"
931 );
932 }
933 }
934 }
935
936 tracing::info!(
937 namespace = %ns,
938 total_deleted = total_deleted,
939 dry_run = dry_run,
940 "sweep_orphan_vectors: finished"
941 );
942
943 Ok(total_deleted)
944 }
945}
946
947fn entity_props_match(
952 entity_props: Option<&serde_json::Value>,
953 filter: &serde_json::Value,
954) -> bool {
955 let required = match filter.as_object() {
956 Some(obj) if !obj.is_empty() => obj,
957 _ => return true,
958 };
959 let actual = match entity_props.and_then(serde_json::Value::as_object) {
960 Some(obj) => obj,
961 None => return false,
962 };
963 required
964 .iter()
965 .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
966}
967
968const EXACT_MATCH_BOOST: f64 = 0.5;
972
973fn rrf_fuse(
981 text_hits: Vec<TextSearchHit>,
982 vector_hits: Vec<VectorSearchHit>,
983 limit: usize,
984 query_text: &str,
985) -> Vec<SearchHit> {
986 #[derive(Default)]
987 struct Bucket {
988 score: DeterministicScore,
989 source: Option<SearchSource>,
990 title: Option<String>,
991 snippet: Option<String>,
992 }
993
994 let mut buckets: HashMap<Uuid, Bucket> = HashMap::new();
995
996 let query_lower = query_text.to_lowercase();
997 for (i, hit) in text_hits.into_iter().enumerate() {
998 let rank = i + 1; let entry = buckets.entry(hit.subject_id).or_default();
1000 entry.score = entry.score + rrf_score(rank, RRF_K);
1001 entry.source = Some(match entry.source {
1002 Some(SearchSource::Vector) => SearchSource::Both,
1003 _ => SearchSource::Text,
1004 });
1005 if entry.title.is_none() {
1006 if let Some(ref title) = hit.title {
1008 if title.to_lowercase() == query_lower {
1009 entry.score = entry.score + DeterministicScore::from_f64(EXACT_MATCH_BOOST);
1010 }
1011 }
1012 entry.title = hit.title;
1013 }
1014 if entry.snippet.is_none() {
1015 entry.snippet = hit.snippet;
1016 }
1017 }
1018
1019 for (i, hit) in vector_hits.into_iter().enumerate() {
1020 let rank = i + 1;
1021 let entry = buckets.entry(hit.subject_id).or_default();
1022 entry.score = entry.score + rrf_score(rank, RRF_K);
1023 entry.source = Some(match entry.source {
1024 Some(SearchSource::Text) => SearchSource::Both,
1025 _ => SearchSource::Vector,
1026 });
1027 }
1028
1029 let mut hits: Vec<SearchHit> = buckets
1030 .into_iter()
1031 .map(|(id, b)| SearchHit {
1032 entity_id: id,
1033 score: b.score,
1034 source: b.source.expect("each bucket gets a source"),
1035 title: b.title,
1036 snippet: b.snippet,
1037 })
1038 .collect();
1039
1040 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.entity_id.cmp(&b.entity_id)));
1041 hits.truncate(limit);
1042 hits
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048 use crate::runtime::{KhiveRuntime, NamespaceToken, RuntimeConfig};
1049 use khive_storage::types::{TextSearchHit, VectorSearchHit};
1050 use khive_types::namespace::Namespace;
1051 use lattice_embed::EmbeddingModel;
1052
1053 fn text_hit(id: Uuid, rank: u32, title: &str) -> TextSearchHit {
1054 TextSearchHit {
1055 subject_id: id,
1056 score: DeterministicScore::from_f64(1.0),
1057 rank,
1058 title: Some(title.to_string()),
1059 snippet: Some("...".to_string()),
1060 }
1061 }
1062
1063 fn vector_hit(id: Uuid, rank: u32) -> VectorSearchHit {
1064 VectorSearchHit {
1065 subject_id: id,
1066 score: DeterministicScore::from_f64(0.9),
1067 rank,
1068 }
1069 }
1070
1071 #[test]
1072 fn rrf_fuse_text_only() {
1073 let a = Uuid::new_v4();
1074 let b = Uuid::new_v4();
1075 let text = vec![text_hit(a, 1, "A"), text_hit(b, 2, "B")];
1076 let hits = rrf_fuse(text, vec![], 10, "query");
1077 assert_eq!(hits.len(), 2);
1078 assert_eq!(hits[0].entity_id, a);
1079 assert_eq!(hits[0].source, SearchSource::Text);
1080 assert_eq!(hits[0].title.as_deref(), Some("A"));
1081 }
1082
1083 #[test]
1084 fn rrf_fuse_vector_only() {
1085 let a = Uuid::new_v4();
1086 let hits = rrf_fuse(vec![], vec![vector_hit(a, 1)], 10, "query");
1087 assert_eq!(hits.len(), 1);
1088 assert_eq!(hits[0].source, SearchSource::Vector);
1089 assert!(hits[0].title.is_none());
1090 }
1091
1092 #[test]
1093 fn rrf_fuse_marks_both_when_in_both_lists() {
1094 let id = Uuid::new_v4();
1095 let text = vec![text_hit(id, 1, "A")];
1096 let vec = vec![vector_hit(id, 1)];
1097 let hits = rrf_fuse(text, vec, 10, "query");
1098 assert_eq!(hits.len(), 1);
1099 assert_eq!(hits[0].source, SearchSource::Both);
1100 }
1101
1102 #[test]
1103 fn rrf_fuse_respects_limit() {
1104 let hits: Vec<TextSearchHit> = (0..20)
1105 .map(|i| text_hit(Uuid::new_v4(), i + 1, "x"))
1106 .collect();
1107 let fused = rrf_fuse(hits, vec![], 5, "query");
1108 assert_eq!(fused.len(), 5);
1109 }
1110
1111 #[test]
1112 fn rrf_fuse_orders_higher_score_first() {
1113 let a = Uuid::new_v4();
1115 let b = Uuid::new_v4();
1116 let text = vec![text_hit(a, 1, "A")];
1117 let vec = vec![vector_hit(a, 1), vector_hit(b, 2)];
1118 let hits = rrf_fuse(text, vec, 10, "query");
1119 assert_eq!(hits[0].entity_id, a);
1120 assert_eq!(hits[0].source, SearchSource::Both);
1121 assert!(hits[0].score > hits[1].score);
1122 }
1123
1124 #[test]
1125 fn rrf_fuse_k10_score_spread_exceeds_threshold() {
1126 let ids: Vec<Uuid> = (0..10).map(|_| Uuid::new_v4()).collect();
1129 let text: Vec<TextSearchHit> = ids
1130 .iter()
1131 .enumerate()
1132 .map(|(i, &id)| text_hit(id, (i + 1) as u32, "x"))
1133 .collect();
1134 let hits = rrf_fuse(text, vec![], 10, "query");
1135 assert_eq!(hits.len(), 10);
1136 let top_score = hits[0].score.to_f64();
1137 let bottom_score = hits[9].score.to_f64();
1138 let spread = top_score - bottom_score;
1139 assert!(
1140 spread >= 0.03,
1141 "score spread {spread:.4} between rank 1 and rank 10 must be ≥ 0.03 (was {spread:.4})"
1142 );
1143 }
1144
1145 #[test]
1146 fn rrf_fuse_exact_match_boost_elevates_score() {
1147 let exact_id = Uuid::new_v4();
1150 let other_id = Uuid::new_v4();
1151 let text = vec![
1153 text_hit(other_id, 1, "something else"),
1154 text_hit(exact_id, 2, "FlashAttention"),
1155 ];
1156 let hits = rrf_fuse(text, vec![], 10, "flashattention");
1157 assert_eq!(hits.len(), 2);
1158 assert_eq!(
1159 hits[0].entity_id, exact_id,
1160 "exact match must rank first despite being rank-2 in raw text search"
1161 );
1162 }
1163
1164 #[test]
1167 fn embed_batch_unconfigured_on_memory_runtime() {
1168 let rt = KhiveRuntime::memory().unwrap();
1170 let result = tokio::runtime::Runtime::new()
1171 .unwrap()
1172 .block_on(rt.embed_batch(&[]));
1173 assert!(result.is_ok());
1175 assert!(result.unwrap().is_empty());
1176 }
1177
1178 #[test]
1179 fn embed_batch_empty_input_returns_empty_vec() {
1180 let rt = KhiveRuntime::memory().unwrap();
1182 let result = tokio::runtime::Runtime::new()
1183 .unwrap()
1184 .block_on(rt.embed_batch(&[]));
1185 assert_eq!(result.unwrap(), Vec::<Vec<f32>>::new());
1186 }
1187
1188 #[test]
1189 fn embed_batch_no_model_non_empty_returns_unconfigured() {
1190 let rt = KhiveRuntime::memory().unwrap();
1191 let texts = vec!["hello".to_string()];
1192 let result = tokio::runtime::Runtime::new()
1193 .unwrap()
1194 .block_on(rt.embed_batch(&texts));
1195 match result {
1196 Err(crate::RuntimeError::Unconfigured(s)) => assert_eq!(s, "embedding_model"),
1197 Err(other) => panic!("expected Unconfigured, got {:?}", other),
1198 Ok(_) => panic!("expected Err, got Ok"),
1199 }
1200 }
1201
1202 #[test]
1203 #[ignore = "loads ~80 MB model; run with --include-ignored"]
1204 fn embed_batch_count_matches_input() {
1205 let config = RuntimeConfig {
1206 db_path: None,
1207 default_namespace: Namespace::parse("test").unwrap(),
1208 embedding_model: Some(EmbeddingModel::AllMiniLmL6V2),
1209 packs: vec!["kg".to_string()],
1210 ..RuntimeConfig::default()
1211 };
1212 let rt = KhiveRuntime::new(config).unwrap();
1213 let texts: Vec<String> = vec!["foo".to_string(), "bar".to_string(), "baz".to_string()];
1214 let result = tokio::runtime::Runtime::new()
1215 .unwrap()
1216 .block_on(rt.embed_batch(&texts));
1217 let embeddings = result.unwrap();
1218 assert_eq!(embeddings.len(), texts.len());
1219 }
1220
1221 #[test]
1222 fn vector_search_requires_embedding_or_text() {
1223 let rt = KhiveRuntime::memory().unwrap();
1224 let tok = NamespaceToken::local();
1225 let result = tokio::runtime::Runtime::new()
1226 .unwrap()
1227 .block_on(rt.vector_search(&tok, None, None, 10, Some(SubstrateKind::Entity)));
1228 match result {
1229 Err(crate::RuntimeError::InvalidInput(msg)) => {
1230 assert!(msg.contains("query_embedding or query_text"), "msg: {msg}");
1231 }
1232 other => panic!("expected InvalidInput, got {other:?}"),
1233 }
1234 }
1235
1236 #[test]
1237 fn vector_search_text_without_model_returns_unconfigured() {
1238 let rt = KhiveRuntime::memory().unwrap();
1239 let tok = NamespaceToken::local();
1240 let result = tokio::runtime::Runtime::new()
1241 .unwrap()
1242 .block_on(rt.vector_search(
1243 &tok,
1244 None,
1245 Some("attention"),
1246 10,
1247 Some(SubstrateKind::Entity),
1248 ));
1249 match result {
1250 Err(crate::RuntimeError::Unconfigured(s)) => assert_eq!(s, "embedding_model"),
1251 other => panic!("expected Unconfigured, got {other:?}"),
1252 }
1253 }
1254
1255 #[test]
1256 #[ignore = "loads ~80 MB model; run with --include-ignored"]
1257 fn embed_batch_vectors_have_expected_dimensions() {
1258 let model = EmbeddingModel::AllMiniLmL6V2;
1259 let config = RuntimeConfig {
1260 db_path: None,
1261 default_namespace: Namespace::parse("test").unwrap(),
1262 embedding_model: Some(model),
1263 packs: vec!["kg".to_string()],
1264 ..RuntimeConfig::default()
1265 };
1266 let rt = KhiveRuntime::new(config).unwrap();
1267 let texts = vec!["hello world".to_string()];
1268 let result = tokio::runtime::Runtime::new()
1269 .unwrap()
1270 .block_on(rt.embed_batch(&texts));
1271 let embeddings = result.unwrap();
1272 assert_eq!(embeddings[0].len(), model.dimensions());
1273 }
1274
1275 #[tokio::test]
1278 async fn hybrid_search_entity_hit_has_title() {
1279 let rt = KhiveRuntime::memory().unwrap();
1280 let tok = NamespaceToken::local();
1281 rt.create_entity(
1282 &tok,
1283 "concept",
1284 None,
1285 "FlashAttention",
1286 Some("IO-aware exact attention using tiling"),
1287 None,
1288 vec![],
1289 )
1290 .await
1291 .unwrap();
1292
1293 let hits = rt
1294 .hybrid_search(&tok, "FlashAttention", None, 10, None, None, &[], None)
1295 .await
1296 .unwrap();
1297
1298 assert!(!hits.is_empty(), "should find the entity");
1299 let hit = &hits[0];
1300 assert!(hit.title.is_some(), "title must be populated");
1301 assert!(
1302 hit.title.as_deref().unwrap().contains("FlashAttention"),
1303 "title must contain entity name"
1304 );
1305 }
1306
1307 #[tokio::test]
1313 async fn hybrid_search_with_dollar_sign_query_does_not_error() {
1314 let rt = KhiveRuntime::memory().unwrap();
1315 let tok = NamespaceToken::local();
1316 rt.create_entity(
1317 &tok,
1318 "concept",
1319 None,
1320 "DSL docs",
1321 Some("use $prev.id to chain calls"),
1322 None,
1323 vec![],
1324 )
1325 .await
1326 .unwrap();
1327
1328 let result = rt
1329 .hybrid_search(&tok, "$prev.id", None, 10, None, None, &[], None)
1330 .await;
1331
1332 assert!(
1333 result.is_ok(),
1334 "#388 hybrid_search must not hard-fail on a '$'-bearing query, got: {:?}",
1335 result.err()
1336 );
1337 }
1338
1339 #[tokio::test]
1348 async fn hybrid_search_with_residual_fts5_char_now_sanitized() {
1349 let rt = KhiveRuntime::memory().unwrap();
1350 let tok = NamespaceToken::local();
1351 rt.create_entity(
1352 &tok,
1353 "concept",
1354 None,
1355 "DSL docs",
1356 Some("use foo@bar to chain calls"),
1357 None,
1358 vec![],
1359 )
1360 .await
1361 .unwrap();
1362
1363 let result = rt
1364 .hybrid_search(&tok, "foo@bar", None, 10, None, None, &[], None)
1365 .await;
1366
1367 let hits = result.unwrap_or_else(|e| {
1368 panic!("#916 hybrid_search must not fail on an '@'-bearing query, got: {e:?}")
1369 });
1370 assert!(
1371 !hits.is_empty(),
1372 "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
1373 quoted-phrase alternative"
1374 );
1375 }
1376
1377 #[tokio::test]
1385 async fn hybrid_search_with_916_issue_characters_finds_text_leg_hits() {
1386 let rt = KhiveRuntime::memory().unwrap();
1387 let tok = NamespaceToken::local();
1388
1389 rt.create_entity(
1390 &tok,
1391 "concept",
1392 None,
1393 "issue tracker",
1394 Some("tracking #682 Stage 2: MoE expert-cache prefetch work"),
1395 None,
1396 vec![],
1397 )
1398 .await
1399 .unwrap();
1400 rt.create_entity(
1401 &tok,
1402 "concept",
1403 None,
1404 "benchmark notes",
1405 Some("chunkwise B=128 traffic arithmetic simdgroup_matrix DPLR"),
1406 None,
1407 vec![],
1408 )
1409 .await
1410 .unwrap();
1411 rt.create_entity(
1412 &tok,
1413 "concept",
1414 None,
1415 "sampling notes",
1416 Some("evaluated with the Min-K%Prob membership inference method"),
1417 None,
1418 vec![],
1419 )
1420 .await
1421 .unwrap();
1422
1423 for query in ["#682 Stage 2", "B=128", "Min-K%Prob"] {
1424 let result = rt
1425 .hybrid_search(&tok, query, None, 10, None, None, &[], None)
1426 .await;
1427 let hits = result.unwrap_or_else(|e| {
1428 panic!("#916 hybrid_search must not fail on query {query:?}, got: {e:?}")
1429 });
1430 assert!(
1431 hits.iter()
1432 .any(|h| matches!(h.source, SearchSource::Text | SearchSource::Both)),
1433 "#916 query {query:?} must surface a Text/Both-sourced hit \
1434 (the FTS leg must contribute, not just the vector leg); got {hits:?}"
1435 );
1436 }
1437 }
1438
1439 #[tokio::test]
1453 async fn hybrid_search_tag_filter_pushed_before_truncation() {
1454 let rt = KhiveRuntime::memory().unwrap();
1455 let tok = NamespaceToken::local();
1456
1457 rt.create_entity(
1459 &tok,
1460 "concept",
1461 None,
1462 "alpha beta gamma decoy alpha beta gamma",
1463 Some("alpha beta gamma decoy description alpha beta gamma"),
1464 None,
1465 vec!["other-tag".to_string()],
1466 )
1467 .await
1468 .unwrap();
1469
1470 let target = rt
1472 .create_entity(
1473 &tok,
1474 "concept",
1475 None,
1476 "alpha beta gamma target",
1477 Some("alpha beta gamma target description"),
1478 None,
1479 vec!["target-tag".to_string()],
1480 )
1481 .await
1482 .unwrap();
1483
1484 let hits = rt
1488 .hybrid_search(
1489 &tok,
1490 "alpha beta gamma",
1491 None,
1492 1,
1493 None,
1494 None,
1495 &["target-tag".to_string()],
1496 None,
1497 )
1498 .await
1499 .unwrap();
1500
1501 assert_eq!(
1502 hits.len(),
1503 1,
1504 "exactly one hit expected (the tag-matching entity)"
1505 );
1506 assert_eq!(
1507 hits[0].entity_id, target.id,
1508 "the tag-filtered entity must be returned even when ranked below limit in raw fusion"
1509 );
1510 }
1511
1512 #[tokio::test]
1521 async fn hybrid_search_props_filter_pushed_before_truncation() {
1522 let rt = KhiveRuntime::memory().unwrap();
1523 let tok = NamespaceToken::local();
1524
1525 rt.create_entity(
1526 &tok,
1527 "concept",
1528 None,
1529 "delta epsilon zeta decoy delta epsilon zeta",
1530 Some("delta epsilon zeta decoy description delta epsilon zeta"),
1531 Some(serde_json::json!({"domain": "other"})),
1532 vec![],
1533 )
1534 .await
1535 .unwrap();
1536
1537 let target = rt
1538 .create_entity(
1539 &tok,
1540 "concept",
1541 None,
1542 "delta epsilon zeta target",
1543 Some("delta epsilon zeta target description"),
1544 Some(serde_json::json!({"domain": "target"})),
1545 vec![],
1546 )
1547 .await
1548 .unwrap();
1549
1550 let filter = serde_json::json!({"domain": "target"});
1551 let hits = rt
1552 .hybrid_search(
1553 &tok,
1554 "delta epsilon zeta",
1555 None,
1556 1,
1557 None,
1558 None,
1559 &[],
1560 Some(&filter),
1561 )
1562 .await
1563 .unwrap();
1564
1565 assert_eq!(hits.len(), 1, "exactly one hit expected (properties match)");
1566 assert_eq!(
1567 hits[0].entity_id, target.id,
1568 "the properties-filtered entity must be returned even when ranked below limit"
1569 );
1570 }
1571
1572 struct CapturingEmbeddingService {
1575 captured: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
1576 }
1577
1578 #[async_trait::async_trait]
1579 impl EmbeddingService for CapturingEmbeddingService {
1580 async fn embed(
1581 &self,
1582 texts: &[String],
1583 _model: EmbeddingModel,
1584 ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
1585 self.captured.lock().unwrap().push(texts.to_vec());
1586 Ok(texts.iter().map(|_| vec![1.0]).collect())
1587 }
1588
1589 fn supports_model(&self, _model: EmbeddingModel) -> bool {
1590 true
1591 }
1592
1593 fn name(&self) -> &'static str {
1594 "capturing-embedding-service"
1595 }
1596 }
1597
1598 struct CapturingEmbedderProvider {
1599 name: String,
1600 captured: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
1601 }
1602
1603 #[async_trait::async_trait]
1604 impl EmbedderProvider for CapturingEmbedderProvider {
1605 fn name(&self) -> &str {
1606 &self.name
1607 }
1608
1609 fn dimensions(&self) -> usize {
1610 1
1611 }
1612
1613 async fn build(&self) -> crate::error::RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
1614 Ok(std::sync::Arc::new(CapturingEmbeddingService {
1615 captured: std::sync::Arc::clone(&self.captured),
1616 }))
1617 }
1618 }
1619
1620 fn runtime_with_capturing_embedder(
1621 model: EmbeddingModel,
1622 ) -> (
1623 KhiveRuntime,
1624 std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
1625 ) {
1626 let runtime = KhiveRuntime::memory().unwrap();
1627 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1628 runtime.register_embedder(CapturingEmbedderProvider {
1629 name: model.to_string(),
1630 captured: std::sync::Arc::clone(&captured),
1631 });
1632 (runtime, captured)
1633 }
1634
1635 #[tokio::test]
1636 async fn bge_query_paths_pass_raw_unprefixed_text() {
1637 const BGE_QUERY_INSTRUCTION: &str =
1638 "Represent this sentence for searching relevant passages: ";
1639 let single = "single raw query";
1640 let batch = vec![
1641 "first raw query".to_string(),
1642 "second raw query".to_string(),
1643 ];
1644
1645 for model in [
1646 EmbeddingModel::BgeSmallEnV15,
1647 EmbeddingModel::BgeBaseEnV15,
1648 EmbeddingModel::BgeLargeEnV15,
1649 ] {
1650 let (runtime, captured) = runtime_with_capturing_embedder(model);
1651 runtime
1652 .embed_query_with_model(&model.to_string(), single)
1653 .await
1654 .unwrap();
1655 runtime
1656 .embed_query_batch_with_model(&model.to_string(), &batch)
1657 .await
1658 .unwrap();
1659
1660 let calls = captured.lock().unwrap().clone();
1661 assert_eq!(
1662 calls,
1663 vec![vec![single.to_string()], batch.clone()],
1664 "{model} must receive raw query text through single and batch paths"
1665 );
1666 assert!(
1667 calls
1668 .iter()
1669 .flatten()
1670 .all(|text| !text.contains(BGE_QUERY_INSTRUCTION)),
1671 "{model} must not receive the BGE retrieval instruction"
1672 );
1673 }
1674 }
1675
1676 #[tokio::test]
1677 async fn e5_query_paths_apply_query_prefix() {
1678 let model = EmbeddingModel::MultilingualE5Small;
1679 let single = "single raw query";
1680 let batch = vec![
1681 "first raw query".to_string(),
1682 "second raw query".to_string(),
1683 ];
1684 let (runtime, captured) = runtime_with_capturing_embedder(model);
1685
1686 runtime
1687 .embed_query_with_model(&model.to_string(), single)
1688 .await
1689 .unwrap();
1690 runtime
1691 .embed_query_batch_with_model(&model.to_string(), &batch)
1692 .await
1693 .unwrap();
1694
1695 assert_eq!(
1696 captured.lock().unwrap().as_slice(),
1697 [
1698 vec!["query: single raw query".to_string()],
1699 vec![
1700 "query: first raw query".to_string(),
1701 "query: second raw query".to_string(),
1702 ],
1703 ],
1704 "E5 must receive its query prefix through single and batch paths"
1705 );
1706 }
1707
1708 #[test]
1709 #[ignore = "loads ~80 MB model; run with --include-ignored"]
1710 fn minilm_document_and_query_embed_are_identical_no_prefix_model() {
1711 let model = EmbeddingModel::AllMiniLmL6V2;
1715 let config = RuntimeConfig {
1716 db_path: None,
1717 default_namespace: Namespace::parse("test").unwrap(),
1718 embedding_model: Some(model),
1719 packs: vec!["kg".to_string()],
1720 ..RuntimeConfig::default()
1721 };
1722 let rt = KhiveRuntime::new(config).unwrap();
1723 let text = "attention is all you need".to_string();
1724 let rt_ref = &rt;
1725 let (doc_emb, query_emb) = tokio::runtime::Runtime::new().unwrap().block_on(async {
1726 let d = rt_ref
1727 .embed_document_with_model(&model.to_string(), &text)
1728 .await
1729 .unwrap();
1730 let q = rt_ref
1731 .embed_query_with_model(&model.to_string(), &text)
1732 .await
1733 .unwrap();
1734 (d, q)
1735 });
1736 assert_eq!(
1737 doc_emb, query_emb,
1738 "MiniLM has no instruction prefix: document and query embeds must be identical"
1739 );
1740 }
1741
1742 #[test]
1743 #[ignore = "loads multilingual-e5-small (~90 MB); run with --include-ignored"]
1744 fn e5_document_and_query_embed_differ_instruction_tuned_model() {
1745 let model = EmbeddingModel::MultilingualE5Small;
1750 let config = RuntimeConfig {
1751 db_path: None,
1752 default_namespace: Namespace::parse("test").unwrap(),
1753 embedding_model: Some(model),
1754 packs: vec!["kg".to_string()],
1755 ..RuntimeConfig::default()
1756 };
1757 let rt = KhiveRuntime::new(config).unwrap();
1758 let text = "attention is all you need".to_string();
1759 let rt_ref = &rt;
1760 let (doc_emb, query_emb) = tokio::runtime::Runtime::new().unwrap().block_on(async {
1761 let d = rt_ref
1762 .embed_document_with_model(&model.to_string(), &text)
1763 .await
1764 .unwrap();
1765 let q = rt_ref
1766 .embed_query_with_model(&model.to_string(), &text)
1767 .await
1768 .unwrap();
1769 (d, q)
1770 });
1771 assert_ne!(
1772 doc_emb, query_emb,
1773 "multilingual-e5-small uses asymmetric prefixes: document ('passage: ') \
1774 and query ('query: ') embeds of the same text must differ"
1775 );
1776 }
1777
1778 use crate::embedder_registry::EmbedderProvider;
1781 use lattice_embed::EmbeddingService;
1782
1783 struct StubEmbedderProvider;
1788
1789 #[async_trait::async_trait]
1790 impl EmbedderProvider for StubEmbedderProvider {
1791 fn name(&self) -> &str {
1792 "stub-model-m07"
1793 }
1794
1795 fn dimensions(&self) -> usize {
1796 4
1797 }
1798
1799 async fn build(&self) -> crate::error::RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
1800 struct StubSvc;
1801 #[async_trait::async_trait]
1802 impl EmbeddingService for StubSvc {
1803 async fn embed(
1804 &self,
1805 _texts: &[String],
1806 _model: lattice_embed::EmbeddingModel,
1807 ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
1808 Ok(vec![])
1809 }
1810
1811 fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
1812 true
1813 }
1814
1815 fn name(&self) -> &'static str {
1816 "stub-svc-m07"
1817 }
1818 }
1819 Ok(std::sync::Arc::new(StubSvc))
1820 }
1821 }
1822
1823 #[tokio::test]
1831 async fn backfill_reader_error_is_propagated_not_swallowed() {
1832 let rt = KhiveRuntime::memory().unwrap();
1833 rt.register_embedder(StubEmbedderProvider);
1834 let tok = NamespaceToken::local();
1835
1836 super::arm_backfill_reader_fail();
1839
1840 let result = rt.backfill_missing_embeddings(&tok).await;
1841 assert!(
1842 result.is_err(),
1843 "backfill_missing_embeddings must propagate the reader error (got Ok instead)"
1844 );
1845 let err_msg = result.unwrap_err().to_string();
1846 assert!(
1847 err_msg.contains("injected failure"),
1848 "error must originate from the injected reader failure, got: {err_msg}"
1849 );
1850 }
1851}