Skip to main content

lunaris_core/
embedder.rs

1//! Embedder trait + a deterministic stub impl returning 768-dim vectors.
2//!
3//! Real `candle`-backed EmbeddingGemma lands in Phase 2.
4
5use std::collections::hash_map::DefaultHasher;
6use std::hash::{Hash, Hasher};
7
8use async_trait::async_trait;
9
10use crate::error::LunarisError;
11
12#[async_trait]
13pub trait Embedder: Send + Sync + 'static {
14    fn dim(&self) -> usize;
15    async fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError>;
16
17    /// Lower-priority batch embed for background/bulk work (e.g. ingest
18    /// promotion). Semantically identical to [`Embedder::embed_batch`] — same
19    /// vectors, same order — but signals the embedder may defer this batch
20    /// behind interactive recall queries. The default just delegates; only a
21    /// backend with an internal scheduler (the llama.cpp worker) overrides it
22    /// to ride a background lane so ingest never head-of-line-blocks recall.
23    async fn embed_batch_lowpri(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
24        self.embed_batch(inputs).await
25    }
26}
27
28#[derive(Debug, Clone)]
29pub struct StubEmbedder {
30    dim: usize,
31}
32
33impl StubEmbedder {
34    pub fn new(dim: usize) -> Self {
35        Self { dim }
36    }
37}
38
39#[async_trait]
40impl Embedder for StubEmbedder {
41    fn dim(&self) -> usize {
42        self.dim
43    }
44
45    async fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
46        Ok(inputs.iter().map(|s| det_vec(s, self.dim)).collect())
47    }
48}
49
50/// Always-failing [`Embedder`] for use in unit tests that must verify error
51/// propagation.  Every `embed_batch` call returns
52/// `LunarisError::Storage(StorageError::Backend(...))`.
53///
54/// Use `wrong_row_count: true` to simulate a wrong-row-count return instead
55/// of a hard `Err`.
56#[derive(Debug, Clone)]
57pub struct FailingEmbedder {
58    dim: usize,
59    /// When `true`, returns `Ok` with `inputs.len() + 1` vectors instead of
60    /// `Err` — exercises the row-count mismatch arm.
61    pub wrong_row_count: bool,
62}
63
64impl FailingEmbedder {
65    /// Construct an embedder that always errors.
66    #[must_use]
67    pub fn new(dim: usize) -> Self {
68        Self { dim, wrong_row_count: false }
69    }
70
71    /// Construct an embedder that returns the wrong row count.
72    #[must_use]
73    pub fn wrong_count(dim: usize) -> Self {
74        Self { dim, wrong_row_count: true }
75    }
76}
77
78#[async_trait::async_trait]
79impl Embedder for FailingEmbedder {
80    fn dim(&self) -> usize {
81        self.dim
82    }
83
84    async fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
85        if self.wrong_row_count {
86            // Return one extra row — contract violation; callers must not zero-fill.
87            Ok((0..inputs.len() + 1).map(|_| vec![0.0f32; self.dim]).collect())
88        } else {
89            Err(crate::error::LunarisError::Storage(crate::error::StorageError::Backend(
90                "FailingEmbedder: injected failure".into(),
91            )))
92        }
93    }
94}
95
96/// Default dim for [`NoopEmbedder`] when no override is supplied. Matches the
97/// historical EmbeddingGemma 300M / granite-r2 output width (768) so an
98/// operator who flips to the noop backend doesn't have to re-create their
99/// `FT.CREATE` HNSW index.
100///
101/// Moon's `FT.CREATE` requires `dim > 0`, so `NoopEmbedder::new(0)` surfaces
102/// as a `StorageError::Backend("vector dim must be > 0")` at
103/// `Lunaris::open()` time. The constructor itself accepts `0` so the error
104/// path stays uniform across `Stub` / `Noop` / real backends.
105pub const NOOP_DEFAULT_DIM: usize = 768;
106
107/// Zero-vector [`Embedder`] of caller-configured `dim`. Used when no real
108/// embedder backend is wired (air-gapped builds, metadata-only ingest, tests
109/// that need a working `Arc<dyn Embedder>` without similarity semantics).
110///
111/// `StubEmbedder` is the *deterministic-random-vector* niche; `NoopEmbedder`
112/// is the *true-zero-vector* niche. Both live in `lunaris-core` so backend
113/// crates can be feature-gated without losing the no-op fallback (the v0.4
114/// N-03 cutover moved this out of the retired `lunaris-embed` crate into the
115/// core trait module).
116#[derive(Debug, Clone, Copy)]
117pub struct NoopEmbedder {
118    dim: usize,
119}
120
121impl NoopEmbedder {
122    /// Construct a noop embedder reporting `dim`. `0` is accepted at the
123    /// constructor level — the storage backends reject it downstream.
124    #[must_use]
125    pub const fn new(dim: usize) -> Self {
126        Self { dim }
127    }
128}
129
130impl Default for NoopEmbedder {
131    fn default() -> Self {
132        Self::new(NOOP_DEFAULT_DIM)
133    }
134}
135
136#[async_trait]
137impl Embedder for NoopEmbedder {
138    fn dim(&self) -> usize {
139        self.dim
140    }
141
142    async fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
143        Ok((0..inputs.len()).map(|_| vec![0.0_f32; self.dim]).collect())
144    }
145}
146
147/// Number of clusters [`det_vec`] draws from. At the 1M-vector corpus the
148/// benchmarks build this averages ~4k vectors per cluster — dense enough that
149/// a nearest-neighbour query has an unambiguous answer.
150const DET_VEC_CLUSTERS: u64 = 256;
151
152/// How far a vector strays from its cluster centroid, before normalisation.
153const DET_VEC_JITTER: f32 = 0.15;
154
155#[inline]
156fn lcg(state: &mut u64) -> f32 {
157    *state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
158    // `>> 32`, not `>> 33`. The original shifted 33 bits, leaving 31 bits of
159    // entropy in a value divided by `u32::MAX`, so the quotient never exceeded
160    // ~0.5 and `q * 2.0 - 1.0` never became positive. Every "uniform [-1, 1]"
161    // vector this crate ever produced sat in the all-negative orthant, which
162    // put every pair at high cosine similarity — the opposite of the spread
163    // the comment promised.
164    ((*state >> 32) as u32) as f32 / u32::MAX as f32 * 2.0 - 1.0
165}
166
167/// Deterministic stand-in for a real embedding: unit-norm and clustered.
168///
169/// Two things were wrong with the previous generator. It claimed to emit
170/// "floats in [-1, 1]" and emitted [-1, 0] (see `lcg`), so every vector sat
171/// in the all-negative orthant at high mutual cosine similarity. And the
172/// coordinates were independent, which in high dimension puts every pair at
173/// nearly the same distance — "the nearest neighbour" becomes a coin-flip
174/// among thousands of ties. A real embedder does neither: its output is
175/// L2-normalised and concentrated near a low-dimensional manifold, and that
176/// structure is the whole reason an ANN index can index it. A test double
177/// without it makes every similarity-search test unrepresentative of the
178/// system it stands in for.
179///
180/// Scope note, so nobody re-derives a story this does not support: this was
181/// written while chasing a Moon compaction wedge (`merge recall 0.0000` →
182/// `MOONERR: busy: compaction backlog`), on the theory that tie-saturated
183/// vectors were collapsing Moon's merge-recall check. **That theory was
184/// tested and refuted** — the wedge reproduces identically with these
185/// clustered vectors. The fix stands on its own merits; it is not a fix for
186/// the wedge.
187///
188/// Determinism is unchanged: the same string always yields the same vector.
189pub fn det_vec(s: &str, dim: usize) -> Vec<f32> {
190    let mut h = DefaultHasher::new();
191    s.hash(&mut h);
192    let hashed = h.finish().max(1);
193
194    // The centroid depends only on the cluster, so every string landing in a
195    // cluster shares it; the jitter depends on the string, so members stay
196    // distinct.
197    let mut centroid_state = (hashed % DET_VEC_CLUSTERS).wrapping_mul(0x9E37_79B9_7F4A_7C15).max(1);
198    let mut jitter_state = hashed;
199
200    let mut v: Vec<f32> = (0..dim)
201        .map(|_| lcg(&mut centroid_state) + lcg(&mut jitter_state) * DET_VEC_JITTER)
202        .collect();
203
204    let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
205    if norm > f32::EPSILON {
206        for x in &mut v {
207            *x /= norm;
208        }
209    }
210    v
211}
212
213#[cfg(test)]
214mod det_vec_tests {
215    use super::*;
216
217    /// The doc on `det_vec` claims the vectors are clustered on the unit
218    /// sphere. Assert the geometry, not the prose: uniform noise passes a
219    /// "looks like floats" check just as well.
220    #[test]
221    fn det_vec_is_unit_norm_and_actually_clustered() {
222        use std::collections::hash_map::DefaultHasher;
223        use std::hash::{Hash, Hasher};
224
225        let dim = 768;
226        let cluster_of = |s: &str| {
227            let mut h = DefaultHasher::new();
228            s.hash(&mut h);
229            h.finish().max(1) % DET_VEC_CLUSTERS
230        };
231        let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
232
233        let labels: Vec<String> = (0..4000).map(|i| format!("fact-{i}")).collect();
234        for l in labels.iter().take(50) {
235            let v = det_vec(l, dim);
236            assert!((dot(&v, &v) - 1.0).abs() < 1e-3, "{l}: not unit-norm ({})", dot(&v, &v));
237        }
238
239        // Average cosine within a cluster vs across clusters. On uniform noise
240        // both land near 0 and the gap vanishes; that indistinguishability is
241        // exactly what collapsed Moon's merge-recall check.
242        let (mut same, mut same_n, mut diff, mut diff_n) = (0.0f32, 0u32, 0.0f32, 0u32);
243        for (i, a) in labels.iter().enumerate().take(300) {
244            let va = det_vec(a, dim);
245            for b in labels.iter().skip(i + 1).take(300) {
246                let cos = dot(&va, &det_vec(b, dim));
247                if cluster_of(a) == cluster_of(b) {
248                    same += cos;
249                    same_n += 1;
250                } else {
251                    diff += cos;
252                    diff_n += 1;
253                }
254            }
255        }
256        assert!(same_n > 50 && diff_n > 50, "too few pairs: {same_n}/{diff_n}");
257        let (same, diff) = (same / same_n as f32, diff / diff_n as f32);
258        assert!(
259            same - diff > 0.5,
260            "cluster structure is too weak for an ANN index to find: \
261             same-cluster cosine {same:.3} vs cross-cluster {diff:.3}"
262        );
263    }
264}
265
266#[cfg(test)]
267mod noop_tests {
268    use super::*;
269
270    #[tokio::test]
271    async fn default_dim_is_768() {
272        let e = NoopEmbedder::default();
273        assert_eq!(e.dim(), NOOP_DEFAULT_DIM);
274        assert_eq!(e.dim(), 768);
275    }
276
277    #[tokio::test]
278    async fn embed_batch_returns_zero_vectors_at_configured_dim() {
279        let e = NoopEmbedder::new(384);
280        let out = e.embed_batch(&["a", "b", "c"]).await.unwrap();
281        assert_eq!(out.len(), 3);
282        for row in &out {
283            assert_eq!(row.len(), 384);
284            assert!(row.iter().all(|&v| v == 0.0));
285        }
286    }
287
288    #[tokio::test]
289    async fn empty_input_yields_empty_output() {
290        let e = NoopEmbedder::new(768);
291        let out = e.embed_batch(&[]).await.unwrap();
292        assert!(out.is_empty());
293    }
294
295    #[tokio::test]
296    async fn dim_zero_is_accepted_at_constructor() {
297        let e = NoopEmbedder::new(0);
298        assert_eq!(e.dim(), 0);
299        let out = e.embed_batch(&["x"]).await.unwrap();
300        assert_eq!(out.len(), 1);
301        assert!(out[0].is_empty());
302    }
303}
304
305#[cfg(test)]
306mod lowpri_default_tests {
307    use super::*;
308
309    #[tokio::test]
310    async fn lowpri_defaults_to_embed_batch() {
311        // scenario: non-llamacpp embedders unaffected — an Embedder that does
312        // NOT override embed_batch_lowpri gets the default, byte-identical to
313        // embed_batch in value AND input order.
314        let e = StubEmbedder::new(768);
315        let inputs = ["alpha", "beta", "gamma"];
316        let hi = e.embed_batch(&inputs).await.unwrap();
317        let lo = e.embed_batch_lowpri(&inputs).await.unwrap();
318        assert_eq!(hi, lo, "default embed_batch_lowpri must byte-match embed_batch");
319    }
320
321    #[tokio::test]
322    async fn lowpri_empty_is_noop() {
323        // scenario: empty input is a no-op on both lanes.
324        let e = StubEmbedder::new(768);
325        assert!(e.embed_batch_lowpri(&[]).await.unwrap().is_empty());
326    }
327}