klieo-embed-common 0.41.1

Shared Embedder trait + dummy/fake impls for klieo memory backends.
Documentation
#![deny(missing_docs)]
#![deny(rust_2018_idioms)]

//! Shared [`Embedder`] trait + dummy/fake implementations for klieo
//! memory backends.
//!
//! Before W3.A17 the trait was duplicated byte-for-byte across
//! `klieo-memory-sqlite::embedder` and `klieo-memory-qdrant::embedder`
//! — any downstream embedder (Ollama, OpenAI, fastembed) had to
//! `impl Embedder` twice. This crate is the single home; both memory
//! crates re-export from here to keep their public APIs source-stable.
//!
//! # Features
//!
//! - **Default** — `Embedder` trait + `DummyEmbedder` (zero vectors).
//! - **`test-utils`** — adds `FakeEmbedder` (deterministic per-text
//!   hashing) for downstream test harnesses.

use async_trait::async_trait;
use klieo_core::error::MemoryError;

/// Compute embeddings for one or more texts.
///
/// Output vectors must each be of length [`Embedder::dimension`].
/// Implementations must be deterministic at the type level — the
/// dimensionality cannot vary across calls on the same instance.
#[async_trait]
pub trait Embedder: Send + Sync {
    /// Embedding dimensionality. Must be constant for a given
    /// `Embedder` instance — long-term memory backends reject vectors
    /// of the wrong length at runtime.
    fn dimension(&self) -> usize;

    /// Compute one embedding per input text.
    async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError>;
}

/// Default embedder that returns zero vectors.
///
/// Lets long-term memory backends store and retrieve facts but cosine
/// similarity is always 1.0, so recall is FIFO order, not semantic.
pub struct DummyEmbedder;

#[async_trait]
impl Embedder for DummyEmbedder {
    fn dimension(&self) -> usize {
        384
    }

    async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
        Ok(texts.iter().map(|_| vec![0.0f32; 384]).collect())
    }
}

/// Test-only embedder that hashes each input text into a deterministic
/// vector. Identical texts produce identical embeddings, so cosine
/// recall behaves predictably under test.
#[cfg(any(test, feature = "test-utils"))]
pub struct FakeEmbedder {
    dim: usize,
}

#[cfg(any(test, feature = "test-utils"))]
impl FakeEmbedder {
    /// Build a deterministic embedder of the given dimensionality.
    pub fn new(dim: usize) -> Self {
        Self { dim }
    }
}

#[cfg(any(test, feature = "test-utils"))]
impl Default for FakeEmbedder {
    fn default() -> Self {
        Self::new(8)
    }
}

#[cfg(any(test, feature = "test-utils"))]
#[async_trait]
impl Embedder for FakeEmbedder {
    fn dimension(&self) -> usize {
        self.dim
    }

    async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
        let dim = self.dim;
        Ok(texts
            .iter()
            .map(|text| {
                // Deterministic per-text vector via FNV-1a per slot —
                // toolchain-stable across rustc/std hasher upgrades.
                let mut v = vec![0.0f32; dim];
                let bytes = text.as_bytes();
                for (i, slot) in v.iter_mut().enumerate() {
                    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
                    const FNV_PRIME: u64 = 0x0000_0001_0000_01b3;
                    let mut h: u64 = FNV_OFFSET;
                    h ^= i as u64;
                    h = h.wrapping_mul(FNV_PRIME);
                    for &b in bytes {
                        h ^= b as u64;
                        h = h.wrapping_mul(FNV_PRIME);
                    }
                    *slot = (h as f32 / u64::MAX as f32) - 0.5;
                }
                let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
                if norm > 0.0 {
                    for x in &mut v {
                        *x /= norm;
                    }
                }
                v
            })
            .collect())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn dummy_returns_zero_vectors_of_dim_384() {
        let e = DummyEmbedder;
        assert_eq!(e.dimension(), 384);
        let out = e.embed(&["a".into(), "b".into()]).await.unwrap();
        assert_eq!(out.len(), 2);
        assert_eq!(out[0].len(), 384);
        assert!(out[0].iter().all(|x| *x == 0.0));
    }

    #[tokio::test]
    async fn fake_embedder_is_deterministic() {
        let e = FakeEmbedder::new(16);
        let a = e.embed(&["hello".into()]).await.unwrap();
        let b = e.embed(&["hello".into()]).await.unwrap();
        assert_eq!(a, b);
    }

    #[tokio::test]
    async fn fake_embedder_distinguishes_inputs() {
        let e = FakeEmbedder::new(16);
        let a = e.embed(&["alpha".into()]).await.unwrap();
        let b = e.embed(&["beta".into()]).await.unwrap();
        assert_ne!(a, b);
    }

    #[tokio::test]
    async fn fake_embedder_outputs_unit_vectors() {
        let e = FakeEmbedder::new(8);
        let v = e.embed(&["hello".into()]).await.unwrap();
        let norm: f32 = v[0].iter().map(|x| x * x).sum::<f32>().sqrt();
        assert!(
            (norm - 1.0).abs() < 1e-5,
            "fake embedder must produce unit vectors, got norm={norm}"
        );
    }
}