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}