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}