Skip to main content

kimetsu_brain/
embeddings.rs

1//! Embeddings + hybrid retrieval scaffolding (v0.4.2).
2//!
3//! The broker is FTS-only through v0.4.1: right answers with wrong
4//! words get missed. v0.4.2 lays the infrastructure for hybrid
5//! retrieval (lexical + semantic) without binding to a specific
6//! embedder. v0.4.3 wires fastembed-rs as the production default.
7//!
8//! Layers introduced here:
9//!   1. The [`Embedder`] trait — anything that can map a text to a
10//!      fixed-dimension float vector plus identify the model that
11//!      produced it.
12//!   2. [`NoopEmbedder`] — production default when no real embedder
13//!      is wired. `embed()` errors with `NotImplemented`; the write
14//!      path treats that as "store NULL" and the retrieval path
15//!      treats it as "skip the cosine blend, FTS only".
16//!   3. [`StubEmbedder`] — deterministic, dependency-free, test-only
17//!      pseudo-embedder. Lets us exercise the hybrid scoring path
18//!      end-to-end without depending on fastembed-rs or downloading
19//!      a model in CI.
20//!   4. [`cosine_similarity`] + BLOB codec helpers so the brain.db
21//!      schema can store embeddings as little-endian `f32` blobs.
22//!
23//! Wire compatibility:
24//!   * Embeddings are nullable. Pre-v0.4.2 rows have NULL embedding
25//!     + NULL embedding_model. The retrieval blender treats them as
26//!     "lexical-only" — they still score via FTS, they just don't
27//!     contribute to the cosine term.
28//!   * The `embedding_model` column carries an opaque string id
29//!     ("bge-small-en-v1.5", "stub-d8", etc.). Queries blend only
30//!     when the query's embedder id matches the row's stored id,
31//!     so mixing models inside one brain.db is safe (rows with a
32//!     different model fall back to lexical-only).
33//!
34//! Scoring (added in v0.4.2):
35//!   `final_relevance = (1 - alpha) * lexical + alpha * cosine`
36//!   where `alpha = brain.broker.hybrid_alpha` (defaulted to 0.5,
37//!   tuned later in v0.4.3 after live measurements). When no
38//!   cosine signal exists, `alpha = 0` effectively — i.e. pure
39//!   lexical. See [`context::memory_candidates`] for the wiring.
40
41use kimetsu_core::KimetsuResult;
42
43/// Default blend factor for hybrid scoring. 0.0 = pure lexical
44/// (v0.4.1 behavior), 1.0 = pure cosine. v0.4.2 ships 0.5 as a
45/// starting point; live data in v0.4.3 will tune it.
46pub const DEFAULT_HYBRID_ALPHA: f32 = 0.5;
47
48/// Embedder trait. Every implementation maps a text to a fixed
49/// dimension `Vec<f32>` and identifies itself via a stable model id.
50///
51/// `Send + Sync` so a single embedder instance can be shared across
52/// the chat REPL's threads (drainer, REPL, hook runner) without
53/// requiring per-call locking.
54pub trait Embedder: Send + Sync {
55    /// Compute an embedding for `text`. The returned vector MUST have
56    /// length == `self.dim()`. Implementations may normalize.
57    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedderError>;
58
59    /// Stable identifier for the model used. Stored alongside each
60    /// embedding so retrieval can detect cross-model mismatches and
61    /// skip the cosine blend rather than comparing apples-to-oranges.
62    fn model_id(&self) -> &str;
63
64    /// Embedding dimension. Used by the BLOB codec to validate
65    /// stored vectors against the active model on retrieval.
66    fn dim(&self) -> usize;
67
68    /// Convenience: true when this embedder is the production no-op.
69    /// Callers can short-circuit the write/retrieval cosine path
70    /// instead of allocating a vec only to discard it.
71    fn is_noop(&self) -> bool {
72        false
73    }
74}
75
76/// Implement `Embedder` for `Box<dyn Embedder>` so callers can hold
77/// an owned trait object without ceremony.
78impl Embedder for Box<dyn Embedder> {
79    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
80        (**self).embed(text)
81    }
82    fn model_id(&self) -> &str {
83        (**self).model_id()
84    }
85    fn dim(&self) -> usize {
86        (**self).dim()
87    }
88    fn is_noop(&self) -> bool {
89        (**self).is_noop()
90    }
91}
92
93/// Failure modes for an embedder.
94#[derive(Debug, Clone)]
95pub enum EmbedderError {
96    /// The embedder is intentionally a no-op — no embeddings will be
97    /// produced. Callers should fall back to a NULL embedding /
98    /// lexical-only retrieval.
99    NotImplemented,
100    /// Model failed to load. v0.4.3+ — e.g. the fastembed backend
101    /// can't download the model.
102    LoadFailed(String),
103    /// Inference failed (rare).
104    EmbedFailed(String),
105    /// Dimension mismatch between the embedder and a stored row.
106    /// The retrieval path skips this row's cosine contribution.
107    DimMismatch { expected: usize, got: usize },
108}
109
110impl std::fmt::Display for EmbedderError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            Self::NotImplemented => write!(f, "embedder not implemented"),
114            Self::LoadFailed(msg) => write!(f, "embedder load failed: {msg}"),
115            Self::EmbedFailed(msg) => write!(f, "embed call failed: {msg}"),
116            Self::DimMismatch { expected, got } => {
117                write!(f, "embedding dim mismatch: expected {expected}, got {got}")
118            }
119        }
120    }
121}
122
123impl std::error::Error for EmbedderError {}
124
125/// Production default when no real embedder is configured.
126/// `embed()` returns `Err(NotImplemented)`; callers interpret that
127/// as "store NULL" / "skip the cosine blend".
128#[derive(Debug, Default, Clone, Copy)]
129pub struct NoopEmbedder;
130
131impl NoopEmbedder {
132    pub const MODEL_ID: &'static str = "noop";
133}
134
135impl Embedder for NoopEmbedder {
136    fn embed(&self, _text: &str) -> Result<Vec<f32>, EmbedderError> {
137        Err(EmbedderError::NotImplemented)
138    }
139
140    fn model_id(&self) -> &str {
141        Self::MODEL_ID
142    }
143
144    fn dim(&self) -> usize {
145        0
146    }
147
148    fn is_noop(&self) -> bool {
149        true
150    }
151}
152
153/// Deterministic, dependency-free pseudo-embedder used in tests.
154///
155/// Hashes each word into a fixed-dim bucket (count-of-hash-buckets),
156/// then L2-normalizes the resulting vector. NOT semantic — texts
157/// that share words will be close; texts that don't share words
158/// will be far. Good enough to exercise the hybrid-scoring code
159/// path without depending on a real ML model.
160///
161/// Default dimension is 8 (small enough to keep tests fast).
162#[derive(Debug, Clone, Copy)]
163pub struct StubEmbedder {
164    dim: usize,
165}
166
167impl StubEmbedder {
168    pub const MODEL_ID: &'static str = "stub-d8";
169
170    pub const fn new() -> Self {
171        Self { dim: 8 }
172    }
173
174    pub const fn with_dim(dim: usize) -> Self {
175        Self { dim }
176    }
177}
178
179impl Default for StubEmbedder {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl Embedder for StubEmbedder {
186    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
187        let mut bucket = vec![0.0f32; self.dim];
188        for word in text.split_whitespace() {
189            // Cheap, stable hash. Don't use DefaultHasher — its seed
190            // randomizes across processes and tests would be flaky.
191            // FNV-1a over the lowercased UTF-8 bytes is plenty.
192            let normalized = word.to_lowercase();
193            let mut h: u64 = 0xcbf2_9ce4_8422_2325;
194            for byte in normalized.bytes() {
195                h ^= byte as u64;
196                h = h.wrapping_mul(0x0000_0100_0000_01B3);
197            }
198            let idx = (h as usize) % self.dim.max(1);
199            bucket[idx] += 1.0;
200        }
201        // L2-normalize so cosine similarity reduces to a dot product.
202        let norm = bucket.iter().map(|v| v * v).sum::<f32>().sqrt();
203        if norm > 0.0 {
204            for v in &mut bucket {
205                *v /= norm;
206            }
207        }
208        Ok(bucket)
209    }
210
211    fn model_id(&self) -> &str {
212        Self::MODEL_ID
213    }
214
215    fn dim(&self) -> usize {
216        self.dim
217    }
218}
219
220/// Open the production-default embedder.
221///
222/// Resolution (v0.4.3):
223///   1. `KIMETSU_BRAIN_EMBEDDER=noop|off|none` → always `NoopEmbedder`,
224///      regardless of Cargo features. Useful for CI, hooks, and
225///      transient subprocesses that shouldn't pay the model-load
226///      cost.
227///   2. Cargo feature `embeddings` enabled →
228///      [`fastembed_backend::open_cached`] returns a process-wide
229///      cached [`FastembedEmbedder`] for the model picked by
230///      [`pick_builtin_model_from_env`] (default `bge-small-en-v1.5`,
231///      `bge-m3` or `jina-v2-base-code` opt-in via env). On model
232///      load failure (network, disk, ort runtime missing) we log
233///      and fall through to Noop so the brain stays usable on FTS
234///      alone.
235///   3. Cargo feature `embeddings` disabled → `NoopEmbedder`,
236///      identical to v0.4.2 build.
237///
238/// The returned trait object is borrowed from a process-static
239/// `OnceLock`; production callers get model-load cost paid exactly
240/// once over the process lifetime. Tests that need a different
241/// embedder must use [`crate::context::retrieve_context_with_embedder`]
242/// with an explicit [`StubEmbedder`] (or any other [`Embedder`])
243/// instead of going through this function.
244pub fn open_default_embedder() -> &'static (dyn Embedder + Send + Sync) {
245    static CACHE: std::sync::OnceLock<Box<dyn Embedder + Send + Sync>> = std::sync::OnceLock::new();
246    let embedder = CACHE.get_or_init(build_default_embedder);
247    embedder.as_ref()
248}
249
250fn build_default_embedder() -> Box<dyn Embedder + Send + Sync> {
251    if env_disables_embedder() {
252        return Box::new(NoopEmbedder);
253    }
254    #[cfg(feature = "embeddings")]
255    {
256        match fastembed_backend::open_cached() {
257            Ok(handle) => return Box::new(handle),
258            Err(err) => {
259                eprintln!(
260                    "kimetsu-brain: fastembed init failed ({err}); falling back to NoopEmbedder. \
261                     Retrieval will stay FTS-only this session. Re-run with \
262                     KIMETSU_BRAIN_EMBEDDER=noop to silence this warning."
263                );
264            }
265        }
266    }
267    Box::new(NoopEmbedder)
268}
269
270/// v0.8: open a FRESH (uncached) embedder for an explicit built-in
271/// model id. Unlike [`open_default_embedder`], this bypasses the
272/// process-static cache AND the env/override resolution — the caller
273/// asked for a *specific* model (e.g. `kimetsu brain model set` and the
274/// MCP `model_set` reindex, which must re-embed with the newly-chosen
275/// model even though the running process may have a different default
276/// embedder cached). Returns [`NoopEmbedder`] on the lean build or if
277/// the model fails to load.
278pub fn open_embedder_for_model(model_id: &str) -> Box<dyn Embedder + Send + Sync> {
279    #[cfg(feature = "embeddings")]
280    {
281        match fastembed_backend::FastembedEmbedder::try_open(model_id) {
282            Ok(engine) => return Box::new(engine),
283            Err(err) => {
284                eprintln!(
285                    "kimetsu-brain: failed to open embedder `{model_id}` ({err}); \
286                     using NoopEmbedder (no vectors produced)."
287                );
288            }
289        }
290    }
291    #[cfg(not(feature = "embeddings"))]
292    {
293        let _ = model_id;
294    }
295    Box::new(NoopEmbedder)
296}
297
298/// v0.4.3: env-driven kill switch. Truthy values (1/true/yes/on)
299/// force-disable the embedder for this process; "noop", "off",
300/// "none" do the same. Anything else (or unset) leaves the
301/// `embeddings` feature in control.
302fn env_disables_embedder() -> bool {
303    match std::env::var("KIMETSU_BRAIN_EMBEDDER") {
304        Ok(value) => is_disable_value(&value.trim().to_ascii_lowercase()),
305        Err(_) => false,
306    }
307}
308
309fn is_disable_value(v: &str) -> bool {
310    matches!(v, "noop" | "off" | "none" | "0" | "false" | "no")
311}
312
313/// v0.8: curated built-in embedding models, surfaced by
314/// `kimetsu brain model list` and `kimetsu_brain_model_list`. Tuple
315/// = (stable id, vector dimension, human blurb). This is the single
316/// source of truth for the selectable set; the fastembed backend
317/// maps these ids → `EmbeddingModel` in `try_open`.
318pub const BUILTIN_MODELS: &[(&str, usize, &str)] = &[
319    ("bge-small-en-v1.5", 384, "English, default, ~67 MB int8"),
320    ("bge-m3", 1024, "Multilingual, ~600 MB int8"),
321    (
322        "jina-v2-base-code",
323        768,
324        "English + code-tuned, ~165 MB int8",
325    ),
326];
327
328/// v0.8: process-global embedder override recorded by
329/// [`apply_embedder_selection`]. Brain-internal callers
330/// ([`pick_builtin_model_from_env`], the fastembed backend, reindex)
331/// have no `ProjectConfig` in hand, so the CLI/MCP layer stashes the
332/// config-selected id here once, early, before any embed happens.
333static EMBEDDER_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
334
335/// v0.8: record the config-provided embedder id so brain-internal
336/// callers resolve it when `KIMETSU_BRAIN_EMBEDDER` is unset (the env
337/// var always wins). Call once, early, before the first retrieval or
338/// embed — after the embedder `OnceLock` initializes this has no
339/// effect. No-op for `None`/empty, and only the first call sticks.
340pub fn apply_embedder_selection(config_embedder: Option<&str>) {
341    if let Some(id) = config_embedder {
342        let id = id.trim();
343        if !id.is_empty() {
344            let _ = EMBEDDER_OVERRIDE.set(id.to_string());
345        }
346    }
347}
348
349/// v0.8: map any accepted alias (env value, config value, built-in
350/// id) to a stable built-in id. Unknown values warn and fall back to
351/// the lean English default. Disable values map to the default too;
352/// the *actual* disable is handled separately by
353/// [`env_disables_embedder`].
354fn map_builtin_id(v: &str) -> &'static str {
355    match v {
356        "" | "default" | "bge-small" | "bge-small-en-v1.5" => "bge-small-en-v1.5",
357        "bge-m3" | "m3" => "bge-m3",
358        "jina-code" | "jina-v2-base-code" | "jina-embeddings-v2-base-code" => "jina-v2-base-code",
359        "noop" | "off" | "none" | "0" | "false" | "no" => "bge-small-en-v1.5",
360        other => {
361            eprintln!(
362                "kimetsu-brain: unknown embedder {other:?}, \
363                 falling back to bge-small-en-v1.5"
364            );
365            "bge-small-en-v1.5"
366        }
367    }
368}
369
370/// v0.8: resolve the active built-in model id. Precedence:
371///   1. `KIMETSU_BRAIN_EMBEDDER` env (unless it's a disable value)
372///   2. the explicit `config_embedder` arg, else the override set by
373///      [`apply_embedder_selection`]
374///   3. `bge-small-en-v1.5` default
375pub fn resolve_embedder_id(config_embedder: Option<&str>) -> &'static str {
376    if let Ok(raw) = std::env::var("KIMETSU_BRAIN_EMBEDDER") {
377        let v = raw.trim().to_ascii_lowercase();
378        if !v.is_empty() && !is_disable_value(&v) {
379            return map_builtin_id(&v);
380        }
381        // empty / disable values fall through: the model *id* still
382        // resolves from config/default even when retrieval is off.
383    }
384    let cfg = config_embedder
385        .map(str::to_string)
386        .or_else(|| EMBEDDER_OVERRIDE.get().cloned());
387    if let Some(c) = cfg {
388        let v = c.trim().to_ascii_lowercase();
389        if !v.is_empty() {
390            return map_builtin_id(&v);
391        }
392    }
393    "bge-small-en-v1.5"
394}
395
396/// v0.4.3: pick which builtin model to load from the env, returning
397/// a stable identifier. Used both by the fastembed backend (to map
398/// id → `EmbeddingModel`) and by `kimetsu brain reindex` (to label
399/// new rows with the right `embedding_model`).
400///
401/// Resolution:
402///   * unset / "" / "default" / "bge-small" / "bge-small-en-v1.5"
403///     → `"bge-small-en-v1.5"` (384 dim, ~67 MB int8, English)
404///   * "bge-m3"
405///     → `"bge-m3"` (1024 dim, ~600 MB int8, multilingual)
406///   * "jina-code" / "jina-v2-base-code" /
407///     "jina-embeddings-v2-base-code"
408///     → `"jina-v2-base-code"` (768 dim, ~165 MB int8, English +
409///     code-tuned)
410///   * anything else falls back to bge-small with a warning.
411pub fn pick_builtin_model_from_env() -> &'static str {
412    // v0.8: env > config-override (set via `apply_embedder_selection`)
413    // > default. Kept as a named entry point for the fastembed backend
414    // and `reindex`, which have no `ProjectConfig` to pass.
415    resolve_embedder_id(None)
416}
417
418// v0.4.3: real fastembed-backed embedder. Lives behind the
419// `embeddings` Cargo feature so the default build skips the
420// ~50-transitive-crate dep tree (ONNX runtime, tokenizers, etc).
421#[cfg(feature = "embeddings")]
422mod fastembed_backend {
423    use super::{Embedder, EmbedderError, pick_builtin_model_from_env};
424    use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
425    use std::sync::{Arc, Mutex, OnceLock};
426
427    /// fastembed-backed embedder. Wraps the ONNX runtime in a
428    /// `Mutex` because `TextEmbedding::embed` takes `&mut self`.
429    /// The lock window is short (one inference per call); the
430    /// chat REPL's threads serialize cleanly through it.
431    pub struct FastembedEmbedder {
432        model_id: &'static str,
433        dim: usize,
434        engine: Mutex<TextEmbedding>,
435    }
436
437    impl FastembedEmbedder {
438        pub fn try_open(builtin_id: &str) -> Result<Self, EmbedderError> {
439            let (kind, model_id, dim) = match builtin_id {
440                "bge-m3" => (EmbeddingModel::BGEM3, "bge-m3", 1024),
441                "jina-v2-base-code" => (
442                    EmbeddingModel::JinaEmbeddingsV2BaseCode,
443                    "jina-v2-base-code",
444                    768,
445                ),
446                // bge-small-en-v1.5 is the default + fallback.
447                _ => (EmbeddingModel::BGESmallENV15, "bge-small-en-v1.5", 384),
448            };
449            let opts = InitOptions::new(kind).with_show_download_progress(false);
450            let engine = TextEmbedding::try_new(opts)
451                .map_err(|e| EmbedderError::LoadFailed(format!("fastembed init: {e}")))?;
452            Ok(Self {
453                model_id,
454                dim,
455                engine: Mutex::new(engine),
456            })
457        }
458    }
459
460    impl Embedder for FastembedEmbedder {
461        fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
462            let mut guard = self
463                .engine
464                .lock()
465                .unwrap_or_else(|poisoned| poisoned.into_inner());
466            let mut out = guard
467                .embed(vec![text], None)
468                .map_err(|e| EmbedderError::EmbedFailed(format!("fastembed embed: {e}")))?;
469            let vec = out
470                .pop()
471                .ok_or_else(|| EmbedderError::EmbedFailed("empty result".into()))?;
472            if vec.len() != self.dim {
473                return Err(EmbedderError::DimMismatch {
474                    expected: self.dim,
475                    got: vec.len(),
476                });
477            }
478            Ok(vec)
479        }
480
481        fn model_id(&self) -> &str {
482            self.model_id
483        }
484
485        fn dim(&self) -> usize {
486            self.dim
487        }
488    }
489
490    /// Shared handle. `open_default_embedder` boxes this into a
491    /// `dyn Embedder` and stashes it in a process-static `OnceLock`,
492    /// so we only call into ONNX once per process.
493    #[derive(Clone)]
494    pub struct EmbedderHandle(Arc<FastembedEmbedder>);
495
496    impl Embedder for EmbedderHandle {
497        fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
498            self.0.embed(text)
499        }
500        fn model_id(&self) -> &str {
501            self.0.model_id()
502        }
503        fn dim(&self) -> usize {
504            self.0.dim()
505        }
506    }
507
508    /// Open (or return the cached) fastembed embedder for the model
509    /// picked by `KIMETSU_BRAIN_EMBEDDER`. Errors here propagate up
510    /// to `open_default_embedder`, which falls back to Noop +
511    /// prints a one-line warning.
512    pub fn open_cached() -> Result<EmbedderHandle, EmbedderError> {
513        static CELL: OnceLock<Result<Arc<FastembedEmbedder>, EmbedderError>> = OnceLock::new();
514        let init = CELL.get_or_init(|| {
515            let builtin = pick_builtin_model_from_env();
516            FastembedEmbedder::try_open(builtin).map(Arc::new)
517        });
518        match init {
519            Ok(arc) => Ok(EmbedderHandle(arc.clone())),
520            Err(err) => Err(err.clone()),
521        }
522    }
523}
524
525// --------- math helpers ---------
526
527/// Cosine similarity between two vectors. Returns 0.0 when either
528/// vector is empty or all-zeros. Does NOT assume the vectors are
529/// pre-normalized — divides by both norms.
530pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
531    if a.is_empty() || b.is_empty() || a.len() != b.len() {
532        return 0.0;
533    }
534    let mut dot = 0.0f32;
535    let mut na = 0.0f32;
536    let mut nb = 0.0f32;
537    for (x, y) in a.iter().zip(b.iter()) {
538        dot += x * y;
539        na += x * x;
540        nb += y * y;
541    }
542    if na == 0.0 || nb == 0.0 {
543        return 0.0;
544    }
545    dot / (na.sqrt() * nb.sqrt())
546}
547
548// --------- write-path helper ---------
549
550/// Compute the embedding for `text` and persist it onto an existing
551/// `memories.memory_id` row.
552///
553/// No-op when the embedder is intentionally a [`NoopEmbedder`] (it
554/// returns [`EmbedderError::NotImplemented`] which we silently
555/// swallow — the column stays NULL, retrieval falls back to
556/// FTS-only for the row, exact v0.4.1 behavior).
557///
558/// For other embedder errors we surface them up. The caller
559/// (`add_memory`, `add_user_memory`) can decide whether to fail the
560/// whole insert or log+continue — today they propagate.
561pub fn embed_and_persist(
562    conn: &rusqlite::Connection,
563    memory_id: &str,
564    text: &str,
565    embedder: &dyn Embedder,
566) -> KimetsuResult<()> {
567    if embedder.is_noop() {
568        return Ok(());
569    }
570    let vec = match embedder.embed(text) {
571        Ok(v) => v,
572        // NotImplemented is the contract for "skip silently". Treat
573        // any embedder that signals it the same way as NoopEmbedder.
574        Err(EmbedderError::NotImplemented) => return Ok(()),
575        Err(e) => return Err(format!("embed failed for memory {memory_id}: {e}").into()),
576    };
577    if vec.len() != embedder.dim() {
578        return Err(format!(
579            "embedder {} produced {} dims, expected {}",
580            embedder.model_id(),
581            vec.len(),
582            embedder.dim()
583        )
584        .into());
585    }
586    let blob = encode_embedding(&vec);
587    conn.execute(
588        "UPDATE memories SET embedding = ?1, embedding_model = ?2 WHERE memory_id = ?3",
589        rusqlite::params![blob, embedder.model_id(), memory_id],
590    )?;
591    Ok(())
592}
593
594// --------- BLOB codec ---------
595//
596// Embeddings are stored as little-endian f32 BLOBs. The encoder
597// fixes byte order so brain.db files move between architectures.
598// The decoder is strict: it returns Err if the byte length isn't a
599// multiple of 4, or if the resulting dim doesn't match expectations.
600
601/// Serialize a float vector to little-endian bytes for storage.
602pub fn encode_embedding(vec: &[f32]) -> Vec<u8> {
603    let mut out = Vec::with_capacity(vec.len() * 4);
604    for v in vec {
605        out.extend_from_slice(&v.to_le_bytes());
606    }
607    out
608}
609
610/// Decode a BLOB back into a float vector. Optionally validates the
611/// expected dimension; pass `None` to accept any length.
612pub fn decode_embedding(bytes: &[u8], expected_dim: Option<usize>) -> KimetsuResult<Vec<f32>> {
613    if !bytes.len().is_multiple_of(4) {
614        return Err(format!("embedding blob length {} not a multiple of 4", bytes.len()).into());
615    }
616    let dim = bytes.len() / 4;
617    if let Some(expected) = expected_dim
618        && dim != expected
619    {
620        return Err(format!("embedding blob dim {dim} does not match expected {expected}").into());
621    }
622    let mut out = Vec::with_capacity(dim);
623    for chunk in bytes.chunks_exact(4) {
624        let mut buf = [0u8; 4];
625        buf.copy_from_slice(chunk);
626        out.push(f32::from_le_bytes(buf));
627    }
628    Ok(out)
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn map_builtin_id_maps_aliases_and_defaults_unknown() {
637        assert_eq!(map_builtin_id("bge-small-en-v1.5"), "bge-small-en-v1.5");
638        assert_eq!(map_builtin_id("default"), "bge-small-en-v1.5");
639        assert_eq!(map_builtin_id("m3"), "bge-m3");
640        assert_eq!(map_builtin_id("bge-m3"), "bge-m3");
641        assert_eq!(map_builtin_id("jina-code"), "jina-v2-base-code");
642        assert_eq!(
643            map_builtin_id("jina-embeddings-v2-base-code"),
644            "jina-v2-base-code"
645        );
646        // disable values resolve to the lean default (the kill-switch
647        // is handled separately by env_disables_embedder).
648        assert_eq!(map_builtin_id("noop"), "bge-small-en-v1.5");
649        // unknown -> warn + default.
650        assert_eq!(map_builtin_id("totally-made-up"), "bge-small-en-v1.5");
651    }
652
653    #[test]
654    fn builtin_models_table_is_consistent() {
655        // Every advertised id must map back to itself.
656        for (id, _dim, _blurb) in BUILTIN_MODELS {
657            assert_eq!(map_builtin_id(id), *id, "id {id} must be stable");
658        }
659    }
660
661    #[test]
662    fn resolve_embedder_id_uses_config_when_env_unset() {
663        // Env mutation in parallel tests is racy + unsafe in edition
664        // 2024, so we only assert the config/default paths when the env
665        // var is genuinely absent. The env-wins path is exercised
666        // manually (see the plan's verification section).
667        if std::env::var_os("KIMETSU_BRAIN_EMBEDDER").is_some() {
668            return;
669        }
670        assert_eq!(resolve_embedder_id(Some("bge-m3")), "bge-m3");
671        assert_eq!(resolve_embedder_id(Some("jina-code")), "jina-v2-base-code");
672        // unknown config value -> default.
673        assert_eq!(resolve_embedder_id(Some("nope")), "bge-small-en-v1.5");
674        // None + no override stored in this test binary -> default.
675        assert_eq!(resolve_embedder_id(None), "bge-small-en-v1.5");
676    }
677
678    #[test]
679    fn noop_embedder_returns_not_implemented_and_is_noop() {
680        let e = NoopEmbedder;
681        assert!(e.is_noop());
682        assert_eq!(e.dim(), 0);
683        assert_eq!(e.model_id(), "noop");
684        assert!(matches!(
685            e.embed("hello").unwrap_err(),
686            EmbedderError::NotImplemented
687        ));
688    }
689
690    #[test]
691    fn stub_embedder_is_deterministic() {
692        let e = StubEmbedder::new();
693        let a = e.embed("hello rust").expect("embed a");
694        let b = e.embed("hello rust").expect("embed b");
695        let c = e.embed("hello RUST").expect("embed c");
696        assert_eq!(a, b, "same input -> same output");
697        assert_eq!(
698            a, c,
699            "lowercasing means case differences collapse to the same vector"
700        );
701        assert_eq!(a.len(), 8);
702        // L2-normalized: norm == 1 within float tolerance.
703        let norm = a.iter().map(|v| v * v).sum::<f32>().sqrt();
704        assert!((norm - 1.0).abs() < 1e-5, "expected unit norm, got {norm}");
705    }
706
707    #[test]
708    fn stub_embedder_distinguishes_disjoint_inputs() {
709        let e = StubEmbedder::new();
710        let a = e.embed("foo bar").expect("a");
711        let b = e.embed("qux quux").expect("b");
712        let sim = cosine_similarity(&a, &b);
713        // Disjoint word sets *can* still collide in the 8-bucket
714        // hash, but on average should be low. Sanity bound: not 1.0.
715        assert!(
716            sim < 0.99,
717            "disjoint inputs should not be near-identical: {sim}"
718        );
719    }
720
721    #[test]
722    fn stub_embedder_handles_empty_input() {
723        let e = StubEmbedder::new();
724        let v = e.embed("").expect("empty embed");
725        assert_eq!(v.len(), 8);
726        // All zeros: cosine similarity with self is 0 (we guard against
727        // division by zero), which is exactly the behavior the
728        // retrieval blender wants for content-free queries.
729        assert!(v.iter().all(|&x| x == 0.0));
730    }
731
732    #[test]
733    fn cosine_similarity_handles_edge_cases() {
734        // Identical normalized vectors -> 1.0.
735        let a = [1.0f32, 0.0, 0.0];
736        assert!((cosine_similarity(&a, &a) - 1.0).abs() < 1e-6);
737
738        // Orthogonal -> 0.0.
739        let b = [0.0f32, 1.0, 0.0];
740        assert!((cosine_similarity(&a, &b)).abs() < 1e-6);
741
742        // Anti-parallel -> -1.0.
743        let c = [-1.0f32, 0.0, 0.0];
744        assert!((cosine_similarity(&a, &c) + 1.0).abs() < 1e-6);
745
746        // Empty / mismatched dim -> 0.0 by contract.
747        assert_eq!(cosine_similarity(&[], &a), 0.0);
748        assert_eq!(cosine_similarity(&a, &[0.0]), 0.0);
749
750        // Zero norm -> 0.0 by contract (don't divide by zero).
751        let zeros = [0.0f32, 0.0, 0.0];
752        assert_eq!(cosine_similarity(&zeros, &a), 0.0);
753    }
754
755    #[test]
756    fn cosine_similarity_is_symmetric() {
757        let a = [0.6f32, 0.8, 0.0];
758        let b = [0.0f32, 1.0, 0.0];
759        let ab = cosine_similarity(&a, &b);
760        let ba = cosine_similarity(&b, &a);
761        assert!((ab - ba).abs() < 1e-6);
762        // Dot is 0.8, |a|=1, |b|=1 -> sim = 0.8.
763        assert!((ab - 0.8).abs() < 1e-5);
764    }
765
766    #[test]
767    fn encode_decode_embedding_round_trip() {
768        let vec = vec![0.1f32, -0.2, 3.125, -0.000_001, 42.0];
769        let blob = encode_embedding(&vec);
770        assert_eq!(blob.len(), vec.len() * 4);
771        let back = decode_embedding(&blob, Some(vec.len())).expect("decode");
772        assert_eq!(back.len(), vec.len());
773        for (orig, got) in vec.iter().zip(back.iter()) {
774            assert!(
775                (orig - got).abs() < 1e-7,
776                "f32 round-trip should be bit-exact"
777            );
778        }
779    }
780
781    #[test]
782    fn decode_embedding_rejects_unaligned_blob() {
783        let bad = [0u8, 1, 2]; // 3 bytes - not a multiple of 4
784        let err = decode_embedding(&bad, None).unwrap_err();
785        assert!(err.to_string().contains("not a multiple of 4"));
786    }
787
788    #[test]
789    fn decode_embedding_rejects_dim_mismatch() {
790        let vec = vec![1.0f32, 2.0, 3.0];
791        let blob = encode_embedding(&vec);
792        let err = decode_embedding(&blob, Some(5)).unwrap_err();
793        assert!(err.to_string().contains("does not match expected"));
794    }
795
796    /// v0.4.3: under the default Cargo build (no `embeddings` feature)
797    /// `open_default_embedder` MUST return Noop so a `cargo install
798    /// kimetsu-cli` user doesn't accidentally start downloading a
799    /// model from $HOME. Skip when `--features embeddings` is on —
800    /// that build path has its own integration tests (run with
801    /// `cargo test --features embeddings -- --ignored`).
802    #[cfg(not(feature = "embeddings"))]
803    #[test]
804    fn open_default_embedder_returns_noop_on_default_build() {
805        let e = open_default_embedder();
806        assert!(e.is_noop());
807        assert_eq!(e.dim(), 0);
808        assert!(matches!(
809            e.embed("anything").unwrap_err(),
810            EmbedderError::NotImplemented
811        ));
812    }
813
814    /// v0.4.3: env kill-switch works even when the `embeddings`
815    /// feature is on — `KIMETSU_BRAIN_EMBEDDER=noop` returns Noop
816    /// regardless. Tests the env parser directly rather than going
817    /// through the cached `open_default_embedder`, which would
818    /// otherwise be poisoned by whatever the previous test in the
819    /// process initialized.
820    #[test]
821    fn env_disables_embedder_recognizes_off_values() {
822        let lock = crate::user_brain::test_env_lock()
823            .lock()
824            .unwrap_or_else(|p| p.into_inner());
825        let prev = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
826        for value in ["noop", "off", "NONE", "0", "false", "no"] {
827            // SAFETY: serialized via the shared brain test env lock.
828            unsafe {
829                std::env::set_var("KIMETSU_BRAIN_EMBEDDER", value);
830            }
831            assert!(env_disables_embedder(), "value {value:?} must disable");
832        }
833        for value in ["", "default", "bge-small", "bge-m3", "jina-code"] {
834            unsafe {
835                std::env::set_var("KIMETSU_BRAIN_EMBEDDER", value);
836            }
837            assert!(!env_disables_embedder(), "value {value:?} must NOT disable");
838        }
839        // Restore.
840        unsafe {
841            match prev {
842                Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
843                None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
844            }
845        }
846        drop(lock);
847    }
848
849    /// v0.4.3: model picker maps the user-facing env string onto a
850    /// stable model id used by both fastembed init AND the
851    /// `embedding_model` column on each memory row.
852    #[test]
853    fn pick_builtin_model_from_env_handles_aliases() {
854        let lock = crate::user_brain::test_env_lock()
855            .lock()
856            .unwrap_or_else(|p| p.into_inner());
857        let prev = std::env::var("KIMETSU_BRAIN_EMBEDDER").ok();
858        let cases = [
859            ("", "bge-small-en-v1.5"),
860            ("default", "bge-small-en-v1.5"),
861            ("bge-small", "bge-small-en-v1.5"),
862            ("BGE-SMALL-EN-V1.5", "bge-small-en-v1.5"),
863            ("bge-m3", "bge-m3"),
864            ("M3", "bge-m3"),
865            ("jina-code", "jina-v2-base-code"),
866            ("jina-v2-base-code", "jina-v2-base-code"),
867            ("jina-embeddings-v2-base-code", "jina-v2-base-code"),
868            // Unknown values fall back to bge-small with a warning.
869            ("totally-made-up", "bge-small-en-v1.5"),
870        ];
871        for (input, expected) in cases {
872            // SAFETY: serialized via the shared brain test env lock.
873            unsafe {
874                std::env::set_var("KIMETSU_BRAIN_EMBEDDER", input);
875            }
876            assert_eq!(
877                pick_builtin_model_from_env(),
878                expected,
879                "input {input:?} -> expected {expected}"
880            );
881        }
882        unsafe {
883            match prev {
884                Some(v) => std::env::set_var("KIMETSU_BRAIN_EMBEDDER", v),
885                None => std::env::remove_var("KIMETSU_BRAIN_EMBEDDER"),
886            }
887        }
888        drop(lock);
889    }
890}