Skip to main content

khive_runtime/
retrieval.rs

1//! Retrieval operations: local embedding generation and hybrid search with RRF fusion.
2
3use 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// Fault-injection flag for backfill reader errors (test / `fault-injection` builds only).
21#[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/// Arm the backfill reader fault injection: the next `backfill_missing_embeddings`
27/// call substitutes a `StorageError::Pool` for `sql.reader().await`'s result, then
28/// resets the flag. The injected error passes through the same
29/// `map_err(RuntimeError::Storage)?` path as a real reader failure, exercising the
30/// fail-closed guard rather than bypassing it.
31#[cfg(any(test, feature = "fault-injection"))]
32pub fn arm_backfill_reader_fail() {
33    BACKFILL_READER_FAIL.with(|c| c.set(true));
34}
35
36/// A unified search result combining vector and text signals.
37#[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/// Which retrieval path(s) contributed to a hit.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum SearchSource {
49    Vector,
50    Text,
51    Both,
52}
53
54/// RRF constant. Controls how strongly top ranks dominate.
55///
56/// The paper's k=60 over-compresses scores at KG scale (tens–thousands of
57/// entities): rank 1 ≈ 0.016, rank 10 ≈ 0.014, spread ≈ 0.002. k=10 gives
58/// rank 1 ≈ 0.091, rank 10 ≈ 0.050, spread ≈ 0.041 — 20× better discrimination,
59/// which dedup-before-create needs at graph sizes of 50–2700 entities.
60const RRF_K: usize = 10;
61
62/// Candidates pulled per path before fusion. Higher = better recall, more work.
63const CANDIDATE_MULTIPLIER: u32 = 4;
64
65impl KhiveRuntime {
66    /// Generate an embedding vector for `text` using the configured default model.
67    ///
68    /// First call lazily loads model weights (cold start cost). Subsequent calls reuse them.
69    /// Returns `Unconfigured("embedding_model")` if no model is configured.
70    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    /// Generate an embedding vector for `text` using the named model.
79    ///
80    /// Accepts both built-in lattice model names/aliases and custom provider
81    /// names registered via [`KhiveRuntime::register_embedder`]. For lattice
82    /// models the resolved `EmbeddingModel` enum is forwarded to `embed_one`
83    /// so the service can select the correct model variant. For custom
84    /// providers, `embed_one` is called with `EmbeddingModel::default()`
85    /// because custom services are expected to ignore the enum argument (they
86    /// own a single model implicitly).
87    ///
88    /// Applies no instruction prefix (generic role). Use
89    /// [`Self::embed_document_with_model`] / [`Self::embed_query_with_model`] for
90    /// instruction-tuned models where the asymmetric prefix matters.
91    ///
92    /// Returns `UnknownModel` if `model_name` is not in the embedder registry.
93    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    /// Embed a document/passage for indexing using the named model.
101    ///
102    /// Applies `EmbeddingService::embed_passage`, which prepends the model's
103    /// `document_instruction()` prefix when defined (e.g. `"passage: "` for
104    /// multilingual-e5). For models with no document prefix (MiniLM, BGE) this
105    /// is identical to [`Self::embed_with_model`].
106    ///
107    /// Use this for all index/store/backfill paths so that instruction-tuned
108    /// models produce passage-side vectors.
109    ///
110    /// **Reindex caveat**: switching from an unprefixed model (or a model with no
111    /// `document_instruction`) to an instruction-tuned model changes the vector
112    /// representation. Vectors stored under the old scheme are not comparable to
113    /// newly prefixed vectors. Operators must trigger a full reindex
114    /// (`knowledge.index(rebuild_ann=true)` / `kkernel reindex`) after changing
115    /// the embedding model config.
116    ///
117    /// Returns `UnknownModel` if `model_name` is not registered.
118    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    /// Embed a query string for retrieval using the named model.
135    ///
136    /// Applies the pre-0.6 query-role behavior. E5 and Qwen models retain
137    /// their query instructions, while BGE and custom providers receive the
138    /// original unprefixed query text.
139    ///
140    /// Use this for all search/recall/suggest query embedding paths so that
141    /// instruction-tuned models land in the correct side of their retrieval
142    /// space.
143    ///
144    /// Returns `UnknownModel` if `model_name` is not registered.
145    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    /// Embed a document for indexing using the configured default model.
167    ///
168    /// Delegates to [`Self::embed_document_with_model`]. Use for entity/note
169    /// create and reindex paths.
170    ///
171    /// Returns `Unconfigured("embedding_model")` if no model is configured.
172    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    /// Embed a query for retrieval using the configured default model.
181    ///
182    /// Delegates to [`Self::embed_query_with_model`]. Use for vector search and
183    /// hybrid search query paths.
184    ///
185    /// Returns `Unconfigured("embedding_model")` if no model is configured.
186    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    /// Generate embeddings for multiple texts in one call using the configured default model.
195    ///
196    /// Delegates to the cached `EmbeddingService::embed`, so repeated texts within
197    /// and across calls benefit from the runtime-level LRU cache.
198    ///
199    /// Returns an empty vec for empty input without hitting the embedding service.
200    /// Returns `Unconfigured("embedding_model")` if no model is configured.
201    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    /// Generate embeddings for multiple texts using the named model.
213    ///
214    /// Accepts lattice model names/aliases and custom provider names.
215    /// Returns `UnknownModel` if `model_name` is not in the embedder registry.
216    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    /// Embed a batch of documents for indexing using the named model.
231    ///
232    /// Applies `EmbeddingService::embed_passage`. Use for all bulk
233    /// index/backfill/reindex operations to apply the passage-side prefix.
234    ///
235    /// **Reindex caveat**: see [`Self::embed_document_with_model`] — the same
236    /// incomparability applies to batch-indexed vectors when switching models.
237    ///
238    /// Returns `UnknownModel` if `model_name` is not registered.
239    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    /// Embed a batch of documents for indexing using the configured default model.
254    ///
255    /// Convenience delegate to [`Self::embed_document_batch_with_model`]. Use for
256    /// bulk knowledge-atom and section indexing paths.
257    ///
258    /// Returns `Unconfigured("embedding_model")` if no model is configured.
259    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    /// Embed a batch of queries for retrieval using the named model.
272    ///
273    /// Applies the same pre-0.6 query-role compatibility behavior as
274    /// [`Self::embed_query_with_model`].
275    ///
276    /// Returns `UnknownModel` if `model_name` is not registered.
277    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    /// Search vectors using either a caller-provided embedding or query text.
297    ///
298    /// Existing callers pass `query_embedding: Some(vec)` to avoid re-embedding.
299    /// Text callers pass `query_embedding: None, query_text: Some(...)` and the
300    /// runtime embeds internally.
301    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    /// Hybrid search: text (FTS5) + vector retrieval fused via Reciprocal Rank Fusion.
342    ///
343    /// - Always performs text search over `query_text`.
344    /// - If `query_vector` is `Some`, also performs vector search and fuses both lists.
345    /// - If `None`, returns text-only results — no vector store needed.
346    /// - If `entity_kind` is `Some`, the alive-set query filters to that kind.
347    ///   The text/vector candidate pools are unfiltered up front; the kind
348    ///   filter applies at the alive-check stage where we already fetch each
349    ///   candidate to confirm it isn't soft-deleted.
350    /// - `tags_any`: when non-empty, only entities that have at least one of these
351    ///   tags (case-insensitive) survive the alive-set intersection. Applied BEFORE
352    ///   truncation so matches ranked beyond `limit` in the raw fusion are not lost.
353    /// - `properties_filter`: when `Some`, only entities whose properties are a
354    ///   superset of the given JSON object survive. Applied BEFORE truncation.
355    ///
356    /// `limit` caps the final returned list; internally pulls `limit * 4` candidates per path.
357    ///
358    /// # Cross-namespace visibility (entity search — primary namespace only; deferred)
359    ///
360    /// Both the **FTS leg** and the **vector/ANN leg** of entity search (`hybrid_search`)
361    /// are restricted to the **primary namespace only**.
362    ///
363    /// Rationale: each namespace owns a separate FTS table (`fts_entities_{ns}`)
364    /// and a separate ANN index instance. Cross-namespace entity-search fanout
365    /// requires iterating over every visible namespace's store, issuing parallel
366    /// search requests, and fusing the results: this is deferred.
367    ///
368    /// Note: this is distinct from `memory.recall`'s cross-namespace fanout, which
369    /// already iterates `visible_namespaces` across both the FTS and vector legs.
370    /// Entity search fanout is the remaining deferred piece; memory recall fanout
371    /// is not deferred.
372    ///
373    /// The `visible_ns` list is forwarded in the `TextFilter.namespaces` field,
374    /// which limits results to those namespaces within the primary store. Because
375    /// entities from extra namespaces live in their own FTS tables, this filter has
376    /// no cross-namespace effect today.
377    ///
378    /// Callers with a multi-namespace visible set can READ cross-namespace entities
379    /// directly via `get_entity` / `resolve`, but `hybrid_search` returns only
380    /// primary-namespace hits until entity-search cross-namespace fanout ships.
381    #[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        // sanitize_fts5_query strips known-unsafe FTS5 metacharacters up front, but if
401        // the lexical leg still errors at runtime on residual punctuation the sanitizer
402        // doesn't strip, this fails loud instead of degrading to vector-only fusion.
403        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        // Keep the full candidate pool (untruncated) through the alive/kind/tag/property
436        // filter below, so matching hits ranked below `limit` in the raw fusion aren't
437        // lost when higher-ranked candidates get excluded by a filter.
438        let mut fused = rrf_fuse(text_hits, vector_hits, candidates as usize, query_text);
439
440        // tags_any has a SQL column and is pushed into query_entities; properties
441        // filtering has no SQL column and is applied in Rust below on the fetched records.
442        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                // Drop non-matching candidates here, before the alive set is built,
466                // so they're excluded ahead of truncation.
467                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            // Enrich vector-only hits (title/snippet == None) from entity record.
479            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    /// Exact KNN over the full namespace's vector store.
496    ///
497    /// sqlite-vec uses brute-force cosine — results are exact, not approximate.
498    /// Cost is O(N · D) per query. For small-to-medium namespaces (~hundreds of
499    /// thousands of vectors) this is well within latency budgets.
500    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    /// Exact KNN restricted to a candidate set.
522    ///
523    /// Useful for reranking the top-N results from `hybrid_search` (or any other
524    /// retrieval path) with exact cosine similarity against a query vector.
525    /// Returns hits sorted by similarity (highest first), truncated to `top_k`.
526    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    /// Backfill vector and FTS index entries for entities and notes that are missing them.
557    ///
558    /// Intended to run once at startup as a background task (warm-up sequence steps 2–4).
559    /// Queries the SQL substrate for entity descriptions and note contents that have no
560    /// corresponding entry in the vector store for any registered embedding model, then
561    /// embeds and inserts them. FTS entries missing for notes are also repopulated.
562    ///
563    /// The operation is best-effort: individual embed/insert failures are logged and
564    /// skipped rather than aborting the whole backfill. If no embedding models are
565    /// registered, returns immediately with 0.
566    ///
567    /// Returns the total number of records backfilled across all models.
568    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            // Must match vec_model_key's naming logic.
584            let vec_table = format!("vec_{}", sanitize_key(model_name));
585
586            // --- Entities: embed description where no vector entry exists ---
587            // Each inserted row satisfies the NOT IN (SELECT subject_id FROM vec_table ...)
588            // clause going forward, so no OFFSET is needed between pages.
589            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            // --- Notes: embed content where no vector entry exists ---
701            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                // Only the id is selected here; the full Note is fetched below so
706                // note_fts_document gets all fields and stays parity-correct.
707                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 &note_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 &note_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                    // Repopulate FTS entry using the shared constructor (first model only
775                    // to avoid N identical overwrites per note).
776                    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(&note)).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    /// Sweep orphaned vector entries for all registered embedding models.
846    ///
847    /// A vector entry is orphaned when its `subject_id` no longer exists as a
848    /// live row in the entity or note tables (i.e. either the row is absent or
849    /// has `deleted_at IS NOT NULL`). Orphaned entries accumulate after
850    /// hard-deletes because the vector store and SQL substrate are decoupled.
851    ///
852    /// Iterates over every registered embedding model and calls
853    /// [`khive_storage::VectorStore::orphan_sweep`] for the token's namespace. Models whose
854    /// backend returns [`khive_storage::StorageError::Unsupported`] are skipped without error —
855    /// this preserves forward-compat when a newly registered model does not yet
856    /// implement sweep.
857    ///
858    /// Returns the total number of vector rows deleted across all models.
859    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
947/// Returns `true` when `entity_props` is a superset of all key-value pairs in `filter`.
948///
949/// Mirrors the semantics of `khive_pack_kg::handlers::common::props_match` so that the
950/// storage-leg predicate is identical to the handler-side post-filter.
951fn 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
968/// Score bonus applied when an entity's title is an exact case-insensitive match for
969/// the query. Dominates RRF scores (~0.09–0.18 range with k=10) so that an exact
970/// name match always ranks above any partial or semantic match.
971const EXACT_MATCH_BOOST: f64 = 0.5;
972
973/// Fuse text + vector hits with Reciprocal Rank Fusion (k=10).
974///
975/// Entity search stays local because it uses k=10 plus exact-match boosting.
976/// Hits in both lists get RRF scores summed. If `query_text` exactly matches
977/// (case-insensitive) an entity's title from the text hits, a bonus of
978/// `EXACT_MATCH_BOOST` is added to ensure exact-name matches dominate.
979/// Sort by fused score, take top-`limit`.
980fn 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; // RRF is 1-indexed
999        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            // Apply exact-match boost before storing the title so we only check once.
1007            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        // Same UUID in both lists at rank 1 → score 2/(10+1). Different UUIDs → 1/(10+1) each.
1114        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        // With k=10: rank 1 → 1/11 ≈ 0.0909, rank 10 → 1/20 = 0.0500.
1127        // Spread ≈ 0.041, well above the 0.03 minimum required for reliable dedup.
1128        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        // An entity whose title exactly matches the query should receive a score
1148        // significantly above a non-matching entity ranked first by text search.
1149        let exact_id = Uuid::new_v4();
1150        let other_id = Uuid::new_v4();
1151        // other_id ranks 1 in text, exact_id ranks 2 — but exact_id matches query.
1152        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    // ---- embed_batch tests ----
1165
1166    #[test]
1167    fn embed_batch_unconfigured_on_memory_runtime() {
1168        // KhiveRuntime::memory() has no embedding model — embed_batch returns Unconfigured.
1169        let rt = KhiveRuntime::memory().unwrap();
1170        let result = tokio::runtime::Runtime::new()
1171            .unwrap()
1172            .block_on(rt.embed_batch(&[]));
1173        // Empty slice short-circuits before hitting the model check.
1174        assert!(result.is_ok());
1175        assert!(result.unwrap().is_empty());
1176    }
1177
1178    #[test]
1179    fn embed_batch_empty_input_returns_empty_vec() {
1180        // No model needed — empty slice is handled before the embedder is touched.
1181        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    // ---- hybrid_search enrichment ----
1276
1277    #[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    /// `hybrid_search` must not hard-fail on a query containing FTS5 metacharacters
1308    /// like `$` (e.g. the DSL doc query `$prev.id`). `sanitize_fts5_query` (khive-db)
1309    /// strips `$`, so this exercises the sanitizer path and takes the `Ok` arm; see
1310    /// `hybrid_search_with_residual_fts5_char_now_sanitized` below for the character
1311    /// class #916 closed (previously the fail-loud arm, prior to #916).
1312    #[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    /// #916: `@` was previously NOT stripped by `sanitize_fts5_query` and SQLite
1340    /// FTS5's bareword parser rejected it unconditionally, so this query used to
1341    /// reach the runtime-level fail-loud arm (`RuntimeError::InvalidInput`, #569).
1342    /// `sanitize_fts5_token_group`'s bareword-safety gate now recognizes `@` (and
1343    /// every other ASCII punctuation character not already handled) as unsafe for
1344    /// an unquoted bareword position and routes it through the quoted-phrase
1345    /// alternative instead, which FTS5 accepts literally, so the query
1346    /// now succeeds and finds the seeded content rather than erroring.
1347    #[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    /// #916 end-to-end regression, using the exact character classes from the
1378    /// issue's live-log evidence (`#682 Stage 2`, `Min-K%Prob`, `B=128`):
1379    /// `hybrid_search`'s FTS leg must not lose its lexical signal to a parser
1380    /// syntax error on `#`, `%`, or `=`. Each query below must both succeed
1381    /// and actually surface a `Text`/`Both`-sourced hit, proving the FTS leg
1382    /// contributed, not just that the vector leg papered over a degraded
1383    /// text leg.
1384    #[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    // ---- predicate pushdown before truncation ----
1440
1441    /// Entity-branch tag-filter regression.
1442    ///
1443    /// Scenario: `limit=1`, tag_filter=["target-tag"]. Two entities are inserted:
1444    ///   - "decoy_alpha_beta_gamma": many query tokens → ranks 1 in FTS (dominates).
1445    ///     Does NOT have "target-tag".
1446    ///   - "alpha_beta_gamma target": fewer query tokens → ranks 2 in FTS.
1447    ///     HAS "target-tag".
1448    ///
1449    /// Without predicate pushdown, `fused.truncate(1)` keeps only the decoy and the
1450    /// tag-matching entity is invisible: this requires `tags_any` to be passed into
1451    /// `query_entities`'s `EntityFilter` so the decoy is excluded before truncation.
1452    #[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        // Decoy: high-ranking FTS hit (content repeats query words), no target tag.
1458        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        // Target: lower-ranking FTS hit, has the tag we filter on.
1471        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        // With limit=1 and tag_filter, the fix must return the target entity despite
1485        // the decoy ranking higher. Without pushdown, the decoy occupies the single
1486        // slot and the target is silently dropped.
1487        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    /// Entity-branch properties-filter regression (analogous to the tag-filter test above).
1513    ///
1514    /// Scenario: `limit=1`, properties_filter={{"domain": "target"}}. Two entities:
1515    ///   - decoy: high FTS rank, properties {{"domain": "other"}}.
1516    ///   - target: lower FTS rank, properties {{"domain": "target"}}.
1517    ///
1518    /// Without pushdown: decoy fills the slot, target is dropped. With pushdown:
1519    /// only the target survives the properties filter before truncation.
1520    #[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    // ---- embed intent tests ----
1573
1574    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        // MiniLM has no instruction prefixes; document and query paths must
1712        // produce byte-identical vectors so that existing stored vectors remain
1713        // comparable after this change.
1714        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        // multilingual-e5 prepends "passage: " for documents and "query: " for
1746        // queries. The same raw text must produce different embeddings when the
1747        // correct prefixes are applied, confirming the asymmetric-retrieval
1748        // capability is now exercised.
1749        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    // ---- backfill reader error must be propagated, not swallowed ----
1779
1780    use crate::embedder_registry::EmbedderProvider;
1781    use lattice_embed::EmbeddingService;
1782
1783    /// A stub embedder that never actually loads weights — used to satisfy the
1784    /// `registered_embedding_model_names` check inside `backfill_missing_embeddings`
1785    /// without triggering a real model load. The test fault-injects a reader error
1786    /// before any embedding call is made, so `embed()` is never reached.
1787    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    /// `backfill_missing_embeddings` must propagate a reader error rather than
1824    /// treating it as "zero rows to embed" (a silent `Err(_) => vec![]` would
1825    /// return `Ok(0)` and skip all embeddings without any signal).
1826    ///
1827    /// The fault injection substitutes a `StorageError::Pool` for the result of
1828    /// `sql.reader().await`, exercising the `map_err(RuntimeError::Storage)?` path;
1829    /// falling back to `unwrap_or_default()` there would swallow the injected error.
1830    #[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        // Arm the fault injection: the next backfill call will substitute a
1837        // StorageError at the sql.reader().await boundary, then reset.
1838        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}