klieo-embed-common 3.12.0

Shared Embedder trait + non-ranking/fake impls for klieo memory backends.
Documentation
#![deny(missing_docs)]
#![deny(rust_2018_idioms)]
#![deny(rustdoc::broken_intra_doc_links)]

//! Shared [`Embedder`] trait + non-ranking/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 + `NonRankingEmbedder` (zero vectors).
//! - **`test-utils`** — adds `FakeEmbedder` (deterministic per-text
//!   hashing) for downstream test harnesses.
//! - **`fastembed`** — adds `FastEmbedEmbedder` (fastembed-rs ONNX CPU
//!   embeddings). Heavy; pulls the ONNX runtime.

use async_trait::async_trait;
use klieo_core::error::MemoryError;
use klieo_core::memory::RecallSemantics;

#[cfg(feature = "fastembed")]
mod fastembed;
#[cfg(feature = "fastembed")]
pub use fastembed::{FastEmbedEmbedder, DEFAULT_DIM, DEFAULT_MODEL};

/// 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>;

    /// Whether distinct inputs produce distinct directions, i.e. whether a
    /// vector store wired with this embedder actually ranks by relevance.
    ///
    /// Defaults to `true` — a real embedding model needs no ceremony. Only
    /// [`NonRankingEmbedder`] and its like override it, which is what lets a
    /// store report [`klieo_core::memory::RecallSemantics::NonRanking`]
    /// instead of claiming vector recall it cannot deliver.
    fn is_ranking(&self) -> bool {
        true
    }
}

/// What a nearest-neighbour store backed by `embedder` can honestly claim as
/// its [`RecallSemantics`].
///
/// A vector backend is only a semantic substrate when its embedder separates
/// distinct inputs; wired with [`NonRankingEmbedder`] the same backend stores,
/// retrieves and ranks by nothing. Every `LongTermMemory` in this workspace
/// whose ranking comes from an injected embedder answers
/// `LongTermMemory::recall_semantics` through this function.
pub fn vector_recall_semantics(embedder: &dyn Embedder) -> RecallSemantics {
    if embedder.is_ranking() {
        RecallSemantics::Vector
    } else {
        RecallSemantics::NonRanking
    }
}

/// Zero-vector embedder: stores and retrieves facts, ranks nothing.
///
/// Every text embeds to the same all-zero vector, so cosine similarity is
/// always 1.0 (or undefined) and recall degenerates to insertion order. It
/// exists so the capability-shaped constructors — `MemorySqlite::open`,
/// `MemoryQdrant::connect`, `MemoryPgvector::connect` — can hand back a
/// working store with no embedding model present.
///
/// **It is not a semantic substrate.** Two adopters shipped production-shaped
/// vector tiers on it and measured substring-grade recall before noticing. The
/// safeguards, all of which the old `DummyEmbedder` name lacked:
///
/// - the name says what it does,
/// - the first [`Embedder::embed`] call logs a `tracing::warn!` once, and
/// - [`Embedder::is_ranking`] answers `false`, so a store built on it reports
///   [`klieo_core::memory::RecallSemantics::NonRanking`] and a pipeline that
///   asserts semantic recall at startup fails there instead of in a
///   measurement.
pub struct NonRankingEmbedder;

/// Legacy name for [`NonRankingEmbedder`], behaving identically.
///
/// A separate unit struct rather than `pub use NonRankingEmbedder as
/// DummyEmbedder`, because **rustc silently ignores `#[deprecated]` on a
/// re-export**: downstream code kept compiling with no warning at all, so the
/// alias would have announced a migration it never actually prompted. A real
/// deprecated item warns in both type and value position, which is what
/// `Arc::new(DummyEmbedder)` call sites need.
#[deprecated(
    since = "3.11.0",
    note = "renamed to NonRankingEmbedder — the old name did not say that recall is unranked"
)]
pub struct DummyEmbedder;

#[allow(deprecated)]
#[async_trait]
impl Embedder for DummyEmbedder {
    fn dimension(&self) -> usize {
        NonRankingEmbedder.dimension()
    }

    async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
        NonRankingEmbedder.embed(texts).await
    }

    fn is_ranking(&self) -> bool {
        NonRankingEmbedder.is_ranking()
    }
}

/// Dimensionality [`NonRankingEmbedder`] claims, matching the common
/// 384-dim sentence-transformer default so a store provisioned with it can be
/// re-pointed at a real model without a collection migration.
const NON_RANKING_DIM: usize = 384;

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

    async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
        static WARNED: std::sync::Once = std::sync::Once::new();
        WARNED.call_once(|| {
            tracing::warn!(
                embedder = "NonRankingEmbedder",
                "embedding with zero vectors — recall returns k facts in arbitrary order, \
                 not by relevance; wire a real Embedder (e.g. FastEmbedEmbedder) for \
                 semantic retrieval"
            );
        });
        Ok(texts
            .iter()
            .map(|_| vec![0.0f32; NON_RANKING_DIM])
            .collect())
    }

    fn is_ranking(&self) -> bool {
        false
    }
}

/// 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 non_ranking_returns_zero_vectors_of_dim_384() {
        let e = NonRankingEmbedder;
        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 non_ranking_declares_itself_unranked_and_fake_does_not() {
        assert!(!NonRankingEmbedder.is_ranking());
        assert!(FakeEmbedder::new(8).is_ranking());
    }

    /// The legacy name must keep behaving identically — a rename that changed
    /// behaviour under the old name would be worse than no alias at all.
    ///
    /// That it still *warns* is a compile-time diagnostic and is not asserted
    /// here; it is a property of `DummyEmbedder` being a deprecated item
    /// rather than a deprecated re-export, which rustc ignores. Turning it
    /// back into a re-export would silently un-deprecate it.
    #[tokio::test]
    #[allow(deprecated)]
    async fn the_deprecated_alias_still_behaves_identically() {
        let legacy = DummyEmbedder;
        assert_eq!(legacy.dimension(), NonRankingEmbedder.dimension());
        assert_eq!(legacy.is_ranking(), NonRankingEmbedder.is_ranking());
        assert_eq!(
            legacy.embed(&["a".into()]).await.unwrap(),
            NonRankingEmbedder.embed(&["a".into()]).await.unwrap()
        );
    }

    #[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}"
        );
    }
}