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]
1312 async fn hybrid_search_with_dollar_sign_query_does_not_error() {
1313 let rt = KhiveRuntime::memory().unwrap();
1314 let tok = NamespaceToken::local();
1315 rt.create_entity(
1316 &tok,
1317 "concept",
1318 None,
1319 "DSL docs",
1320 Some("use $prev.id to chain calls"),
1321 None,
1322 vec![],
1323 )
1324 .await
1325 .unwrap();
1326
1327 let result = rt
1328 .hybrid_search(&tok, "$prev.id", None, 10, None, None, &[], None)
1329 .await;
1330
1331 assert!(
1332 result.is_ok(),
1333 "#388 hybrid_search must not hard-fail on a '$'-bearing query, got: {:?}",
1334 result.err()
1335 );
1336 }
1337
1338 #[tokio::test]
1344 async fn hybrid_search_with_residual_fts5_char_fails_loud() {
1345 let rt = KhiveRuntime::memory().unwrap();
1346 let tok = NamespaceToken::local();
1347 rt.create_entity(
1348 &tok,
1349 "concept",
1350 None,
1351 "DSL docs",
1352 Some("use foo@bar to chain calls"),
1353 None,
1354 vec![],
1355 )
1356 .await
1357 .unwrap();
1358
1359 let result = rt
1360 .hybrid_search(&tok, "foo@bar", None, 10, None, None, &[], None)
1361 .await;
1362
1363 assert!(
1364 result.is_err(),
1365 "#569 hybrid_search must fail loud when the FTS leg errors on a residual \
1366 FTS5 char ('@'), not silently degrade to vector-only fusion, got: {:?}",
1367 result.ok()
1368 );
1369 assert!(
1370 matches!(result.unwrap_err(), RuntimeError::InvalidInput(_)),
1371 "residual FTS5 parser failure must surface as RuntimeError::InvalidInput"
1372 );
1373 }
1374
1375 #[tokio::test]
1389 async fn hybrid_search_tag_filter_pushed_before_truncation() {
1390 let rt = KhiveRuntime::memory().unwrap();
1391 let tok = NamespaceToken::local();
1392
1393 rt.create_entity(
1395 &tok,
1396 "concept",
1397 None,
1398 "alpha beta gamma decoy alpha beta gamma",
1399 Some("alpha beta gamma decoy description alpha beta gamma"),
1400 None,
1401 vec!["other-tag".to_string()],
1402 )
1403 .await
1404 .unwrap();
1405
1406 let target = rt
1408 .create_entity(
1409 &tok,
1410 "concept",
1411 None,
1412 "alpha beta gamma target",
1413 Some("alpha beta gamma target description"),
1414 None,
1415 vec!["target-tag".to_string()],
1416 )
1417 .await
1418 .unwrap();
1419
1420 let hits = rt
1424 .hybrid_search(
1425 &tok,
1426 "alpha beta gamma",
1427 None,
1428 1,
1429 None,
1430 None,
1431 &["target-tag".to_string()],
1432 None,
1433 )
1434 .await
1435 .unwrap();
1436
1437 assert_eq!(
1438 hits.len(),
1439 1,
1440 "exactly one hit expected (the tag-matching entity)"
1441 );
1442 assert_eq!(
1443 hits[0].entity_id, target.id,
1444 "the tag-filtered entity must be returned even when ranked below limit in raw fusion"
1445 );
1446 }
1447
1448 #[tokio::test]
1457 async fn hybrid_search_props_filter_pushed_before_truncation() {
1458 let rt = KhiveRuntime::memory().unwrap();
1459 let tok = NamespaceToken::local();
1460
1461 rt.create_entity(
1462 &tok,
1463 "concept",
1464 None,
1465 "delta epsilon zeta decoy delta epsilon zeta",
1466 Some("delta epsilon zeta decoy description delta epsilon zeta"),
1467 Some(serde_json::json!({"domain": "other"})),
1468 vec![],
1469 )
1470 .await
1471 .unwrap();
1472
1473 let target = rt
1474 .create_entity(
1475 &tok,
1476 "concept",
1477 None,
1478 "delta epsilon zeta target",
1479 Some("delta epsilon zeta target description"),
1480 Some(serde_json::json!({"domain": "target"})),
1481 vec![],
1482 )
1483 .await
1484 .unwrap();
1485
1486 let filter = serde_json::json!({"domain": "target"});
1487 let hits = rt
1488 .hybrid_search(
1489 &tok,
1490 "delta epsilon zeta",
1491 None,
1492 1,
1493 None,
1494 None,
1495 &[],
1496 Some(&filter),
1497 )
1498 .await
1499 .unwrap();
1500
1501 assert_eq!(hits.len(), 1, "exactly one hit expected (properties match)");
1502 assert_eq!(
1503 hits[0].entity_id, target.id,
1504 "the properties-filtered entity must be returned even when ranked below limit"
1505 );
1506 }
1507
1508 struct CapturingEmbeddingService {
1511 captured: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
1512 }
1513
1514 #[async_trait::async_trait]
1515 impl EmbeddingService for CapturingEmbeddingService {
1516 async fn embed(
1517 &self,
1518 texts: &[String],
1519 _model: EmbeddingModel,
1520 ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
1521 self.captured.lock().unwrap().push(texts.to_vec());
1522 Ok(texts.iter().map(|_| vec![1.0]).collect())
1523 }
1524
1525 fn supports_model(&self, _model: EmbeddingModel) -> bool {
1526 true
1527 }
1528
1529 fn name(&self) -> &'static str {
1530 "capturing-embedding-service"
1531 }
1532 }
1533
1534 struct CapturingEmbedderProvider {
1535 name: String,
1536 captured: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
1537 }
1538
1539 #[async_trait::async_trait]
1540 impl EmbedderProvider for CapturingEmbedderProvider {
1541 fn name(&self) -> &str {
1542 &self.name
1543 }
1544
1545 fn dimensions(&self) -> usize {
1546 1
1547 }
1548
1549 async fn build(&self) -> crate::error::RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
1550 Ok(std::sync::Arc::new(CapturingEmbeddingService {
1551 captured: std::sync::Arc::clone(&self.captured),
1552 }))
1553 }
1554 }
1555
1556 fn runtime_with_capturing_embedder(
1557 model: EmbeddingModel,
1558 ) -> (
1559 KhiveRuntime,
1560 std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
1561 ) {
1562 let runtime = KhiveRuntime::memory().unwrap();
1563 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1564 runtime.register_embedder(CapturingEmbedderProvider {
1565 name: model.to_string(),
1566 captured: std::sync::Arc::clone(&captured),
1567 });
1568 (runtime, captured)
1569 }
1570
1571 #[tokio::test]
1572 async fn bge_query_paths_pass_raw_unprefixed_text() {
1573 const BGE_QUERY_INSTRUCTION: &str =
1574 "Represent this sentence for searching relevant passages: ";
1575 let single = "single raw query";
1576 let batch = vec![
1577 "first raw query".to_string(),
1578 "second raw query".to_string(),
1579 ];
1580
1581 for model in [
1582 EmbeddingModel::BgeSmallEnV15,
1583 EmbeddingModel::BgeBaseEnV15,
1584 EmbeddingModel::BgeLargeEnV15,
1585 ] {
1586 let (runtime, captured) = runtime_with_capturing_embedder(model);
1587 runtime
1588 .embed_query_with_model(&model.to_string(), single)
1589 .await
1590 .unwrap();
1591 runtime
1592 .embed_query_batch_with_model(&model.to_string(), &batch)
1593 .await
1594 .unwrap();
1595
1596 let calls = captured.lock().unwrap().clone();
1597 assert_eq!(
1598 calls,
1599 vec![vec![single.to_string()], batch.clone()],
1600 "{model} must receive raw query text through single and batch paths"
1601 );
1602 assert!(
1603 calls
1604 .iter()
1605 .flatten()
1606 .all(|text| !text.contains(BGE_QUERY_INSTRUCTION)),
1607 "{model} must not receive the BGE retrieval instruction"
1608 );
1609 }
1610 }
1611
1612 #[tokio::test]
1613 async fn e5_query_paths_apply_query_prefix() {
1614 let model = EmbeddingModel::MultilingualE5Small;
1615 let single = "single raw query";
1616 let batch = vec![
1617 "first raw query".to_string(),
1618 "second raw query".to_string(),
1619 ];
1620 let (runtime, captured) = runtime_with_capturing_embedder(model);
1621
1622 runtime
1623 .embed_query_with_model(&model.to_string(), single)
1624 .await
1625 .unwrap();
1626 runtime
1627 .embed_query_batch_with_model(&model.to_string(), &batch)
1628 .await
1629 .unwrap();
1630
1631 assert_eq!(
1632 captured.lock().unwrap().as_slice(),
1633 [
1634 vec!["query: single raw query".to_string()],
1635 vec![
1636 "query: first raw query".to_string(),
1637 "query: second raw query".to_string(),
1638 ],
1639 ],
1640 "E5 must receive its query prefix through single and batch paths"
1641 );
1642 }
1643
1644 #[test]
1645 #[ignore = "loads ~80 MB model; run with --include-ignored"]
1646 fn minilm_document_and_query_embed_are_identical_no_prefix_model() {
1647 let model = EmbeddingModel::AllMiniLmL6V2;
1651 let config = RuntimeConfig {
1652 db_path: None,
1653 default_namespace: Namespace::parse("test").unwrap(),
1654 embedding_model: Some(model),
1655 packs: vec!["kg".to_string()],
1656 ..RuntimeConfig::default()
1657 };
1658 let rt = KhiveRuntime::new(config).unwrap();
1659 let text = "attention is all you need".to_string();
1660 let rt_ref = &rt;
1661 let (doc_emb, query_emb) = tokio::runtime::Runtime::new().unwrap().block_on(async {
1662 let d = rt_ref
1663 .embed_document_with_model(&model.to_string(), &text)
1664 .await
1665 .unwrap();
1666 let q = rt_ref
1667 .embed_query_with_model(&model.to_string(), &text)
1668 .await
1669 .unwrap();
1670 (d, q)
1671 });
1672 assert_eq!(
1673 doc_emb, query_emb,
1674 "MiniLM has no instruction prefix: document and query embeds must be identical"
1675 );
1676 }
1677
1678 #[test]
1679 #[ignore = "loads multilingual-e5-small (~90 MB); run with --include-ignored"]
1680 fn e5_document_and_query_embed_differ_instruction_tuned_model() {
1681 let model = EmbeddingModel::MultilingualE5Small;
1686 let config = RuntimeConfig {
1687 db_path: None,
1688 default_namespace: Namespace::parse("test").unwrap(),
1689 embedding_model: Some(model),
1690 packs: vec!["kg".to_string()],
1691 ..RuntimeConfig::default()
1692 };
1693 let rt = KhiveRuntime::new(config).unwrap();
1694 let text = "attention is all you need".to_string();
1695 let rt_ref = &rt;
1696 let (doc_emb, query_emb) = tokio::runtime::Runtime::new().unwrap().block_on(async {
1697 let d = rt_ref
1698 .embed_document_with_model(&model.to_string(), &text)
1699 .await
1700 .unwrap();
1701 let q = rt_ref
1702 .embed_query_with_model(&model.to_string(), &text)
1703 .await
1704 .unwrap();
1705 (d, q)
1706 });
1707 assert_ne!(
1708 doc_emb, query_emb,
1709 "multilingual-e5-small uses asymmetric prefixes: document ('passage: ') \
1710 and query ('query: ') embeds of the same text must differ"
1711 );
1712 }
1713
1714 use crate::embedder_registry::EmbedderProvider;
1717 use lattice_embed::EmbeddingService;
1718
1719 struct StubEmbedderProvider;
1724
1725 #[async_trait::async_trait]
1726 impl EmbedderProvider for StubEmbedderProvider {
1727 fn name(&self) -> &str {
1728 "stub-model-m07"
1729 }
1730
1731 fn dimensions(&self) -> usize {
1732 4
1733 }
1734
1735 async fn build(&self) -> crate::error::RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
1736 struct StubSvc;
1737 #[async_trait::async_trait]
1738 impl EmbeddingService for StubSvc {
1739 async fn embed(
1740 &self,
1741 _texts: &[String],
1742 _model: lattice_embed::EmbeddingModel,
1743 ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
1744 Ok(vec![])
1745 }
1746
1747 fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
1748 true
1749 }
1750
1751 fn name(&self) -> &'static str {
1752 "stub-svc-m07"
1753 }
1754 }
1755 Ok(std::sync::Arc::new(StubSvc))
1756 }
1757 }
1758
1759 #[tokio::test]
1767 async fn backfill_reader_error_is_propagated_not_swallowed() {
1768 let rt = KhiveRuntime::memory().unwrap();
1769 rt.register_embedder(StubEmbedderProvider);
1770 let tok = NamespaceToken::local();
1771
1772 super::arm_backfill_reader_fail();
1775
1776 let result = rt.backfill_missing_embeddings(&tok).await;
1777 assert!(
1778 result.is_err(),
1779 "backfill_missing_embeddings must propagate the reader error (got Ok instead)"
1780 );
1781 let err_msg = result.unwrap_err().to_string();
1782 assert!(
1783 err_msg.contains("injected failure"),
1784 "error must originate from the injected reader failure, got: {err_msg}"
1785 );
1786 }
1787}