mnemo_engine/embed.rs
1//! Embedder trait + text-side convenience paths (Phase 3, PR 10a).
2//!
3//! Mnemo stores vectors; **embedders** are the thing that turns text
4//! into those vectors. This module adds a small trait that any Rust
5//! embedder (hand-written, feature-gated `fastembed`, or a bound Python
6//! callable) can implement, plus the two convenience paths every
7//! downstream caller actually wants:
8//!
9//! - [`Mnemo::remember_text`] — embed a string and `remember` the result.
10//! - [`RecallRequest::from_text`] — embed a query string and build a
11//! `RecallRequest`.
12//!
13//! Both validate the embedder's dimensionality against the database's
14//! configured width ([`MnemoError::DimensionMismatch`]) and compare the
15//! embedder's identity (name, dimensions, normalize) to the manifest
16//! (`EmbedderIdentity::MISMATCH_MSG`). An identity mismatch is a loud
17//! error — mixing two embedders in one file silently corrupts recall
18//! quality, so we refuse rather than warn.
19//!
20//! The identity check lives on the handle via [`Mnemo::set_embedder`]:
21//! one call registers the embedder, performs the identity check once,
22//! and upserts the manifest's `metadata.embedder = { name, dimensions,
23//! normalize }` nested object on first registration. Subsequent
24//! `remember_text`/`from_text` calls on the handle are a plain
25//! delegation.
26//!
27//! **Manifest schema (canonical, docs-matching):** the embedder
28//! identity lives at `metadata.embedder.{name,dimensions,normalize}`
29//! on any onboarding/manifest memory — the shape `test/scripts/seed.json`
30//! writes and `mnemo/README.md` documents. For back-compat with the
31//! very first PR 10a dev builds that wrote flat
32//! `embedder_name`/`embedder_dimensions`/`embedder_normalize` keys,
33//! those are still accepted on read as a legacy alias; writes are
34//! always the nested form.
35
36use crate::error::{MnemoError, Result};
37use serde_json::{Map, Value};
38
39/// Describe an embedder's observable identity. Used both in-memory on
40/// an open [`Mnemo`] handle and persisted at `metadata.embedder` on the
41/// scaffold manifest so that reopening with a different embedder is
42/// caught at `set_embedder` time instead of silently polluting the
43/// index.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct EmbedderIdentity {
46 /// Model identifier the caller chose. Convention: a short, stable
47 /// slug — `"all-MiniLM-L6-v2"`, `"text-embedding-3-small"`.
48 pub name: String,
49 /// Vector width this embedder emits. Validated against
50 /// [`Mnemo::dimensions`] on registration.
51 pub dimensions: usize,
52 /// Whether the embedder returns unit-normalized vectors. Caller's
53 /// claim — mnemo doesn't verify it, but records it on the manifest
54 /// so downstream readers can tell whether a dot product is cosine
55 /// equivalent.
56 pub normalize: bool,
57}
58
59impl EmbedderIdentity {
60 /// Message rendered inside [`MnemoError::Invalid`] on an identity
61 /// mismatch. Public so tests + binding layers can assert on it.
62 pub const MISMATCH_MSG: &'static str = "embedder identity mismatch";
63}
64
65/// Minimal trait any embedder implementation must satisfy. Returns
66/// vectors in the same order as the input `texts`.
67///
68/// Implementations are expected to be cheap to clone / `Send + Sync`
69/// when they wrap thread-safe state; the engine holds an `Arc<dyn
70/// Embedder>` on the handle via [`Mnemo::set_embedder`]. See
71/// [`Mnemo::remember_text`] for the full user-facing contract.
72pub trait Embedder: Send + Sync {
73 /// This embedder's observable identity (name, dims, normalize).
74 /// Must be stable for the lifetime of the embedder — mnemo caches
75 /// it on `set_embedder` and does not re-query per call.
76 fn identity(&self) -> EmbedderIdentity;
77
78 /// Embed `texts`, returning one vector per input in the same order.
79 /// Caller-level errors (network failure for a hosted embedder, OOM
80 /// in a local model) should bubble up as [`MnemoError::Invalid`]
81 /// with a short message — the specific crypto / page errors in
82 /// [`MnemoError`] are reserved for the engine's own concerns.
83 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
84}
85
86/// Returns `true` iff every component of `v` is exactly `0.0`. This is
87/// the predicate `remember` uses to refuse all-zero vectors (which
88/// score 0 on every cosine query and pollute recall).
89pub(crate) fn is_zero_vector(v: &[f32]) -> bool {
90 !v.is_empty() && v.iter().all(|x| *x == 0.0)
91}
92
93/// Canonical nested metadata key: `metadata.embedder = { name,
94/// dimensions, normalize }`. This is the shape `test/scripts/seed.json`
95/// writes and `mnemo/README.md` documents.
96pub(crate) const MANIFEST_EMBEDDER_KEY: &str = "embedder";
97
98/// Legacy flat keys written by the very first PR 10a dev builds
99/// (never released). Still accepted on read so a locally-bootstrapped
100/// repo doesn't trip the new check; writes are always the nested form
101/// above.
102pub(crate) const LEGACY_FLAT_NAME: &str = "embedder_name";
103pub(crate) const LEGACY_FLAT_DIMENSIONS: &str = "embedder_dimensions";
104pub(crate) const LEGACY_FLAT_NORMALIZE: &str = "embedder_normalize";
105
106/// Read the recorded embedder identity off a memory's metadata map.
107/// Returns `None` if neither the canonical nested form nor the legacy
108/// flat form is present (or either is present but malformed — missing
109/// a required field, wrong type).
110///
111/// Nested form (canonical) is tried first; legacy flat form is only
112/// consulted when nothing nested is found. This keeps a seed.json-
113/// shaped file hitting the canonical path even if a bugged earlier
114/// `set_embedder` call left stale flat keys alongside.
115pub(crate) fn read_identity_from_metadata(meta: &Map<String, Value>) -> Option<EmbedderIdentity> {
116 // Canonical nested form.
117 if let Some(obj) = meta.get(MANIFEST_EMBEDDER_KEY).and_then(Value::as_object) {
118 let name = obj.get("name").and_then(Value::as_str)?;
119 let dims = obj.get("dimensions").and_then(Value::as_u64)?;
120 let norm = obj.get("normalize").and_then(Value::as_bool)?;
121 return Some(EmbedderIdentity {
122 name: name.to_string(),
123 dimensions: dims as usize,
124 normalize: norm,
125 });
126 }
127 // Legacy flat fallback.
128 let name = meta.get(LEGACY_FLAT_NAME).and_then(Value::as_str)?;
129 let dims = meta.get(LEGACY_FLAT_DIMENSIONS).and_then(Value::as_u64)?;
130 let norm = meta.get(LEGACY_FLAT_NORMALIZE).and_then(Value::as_bool)?;
131 Some(EmbedderIdentity {
132 name: name.to_string(),
133 dimensions: dims as usize,
134 normalize: norm,
135 })
136}
137
138/// Write (or overwrite) the embedder identity on a memory's metadata
139/// map, in the canonical nested form. Also removes any legacy flat
140/// keys from the same map so the upsert doesn't leave duplicates
141/// behind.
142pub(crate) fn write_identity_into_metadata(
143 meta: &mut Map<String, Value>,
144 identity: &EmbedderIdentity,
145) {
146 let mut obj = Map::new();
147 obj.insert("name".into(), Value::String(identity.name.clone()));
148 obj.insert(
149 "dimensions".into(),
150 Value::Number(serde_json::Number::from(identity.dimensions as u64)),
151 );
152 obj.insert("normalize".into(), Value::Bool(identity.normalize));
153 meta.insert(MANIFEST_EMBEDDER_KEY.into(), Value::Object(obj));
154 // Drop legacy flat keys if present so the manifest canonicalizes
155 // on the next flush. Harmless no-op when they're absent.
156 meta.remove(LEGACY_FLAT_NAME);
157 meta.remove(LEGACY_FLAT_DIMENSIONS);
158 meta.remove(LEGACY_FLAT_NORMALIZE);
159}
160
161/// Build a clear `MnemoError::Invalid` describing an identity mismatch,
162/// shaped so a Python caller catching `RuntimeError` can grep the string.
163pub(crate) fn identity_mismatch_err(want: &EmbedderIdentity, saw: &EmbedderIdentity) -> MnemoError {
164 MnemoError::Invalid(format!(
165 "{}: database manifest records embedder (name={:?}, dimensions={}, normalize={}), \
166 but this handle was passed embedder (name={:?}, dimensions={}, normalize={}). \
167 Mixing embedders silently corrupts recall quality — open the file with the \
168 original embedder, or create a fresh database for the new one.",
169 EmbedderIdentity::MISMATCH_MSG,
170 want.name,
171 want.dimensions,
172 want.normalize,
173 saw.name,
174 saw.dimensions,
175 saw.normalize,
176 ))
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn is_zero_vector_detects_all_zero() {
185 assert!(is_zero_vector(&[0.0, 0.0, 0.0]));
186 assert!(!is_zero_vector(&[0.0, 0.0, 1e-9]));
187 assert!(!is_zero_vector(&[]));
188 }
189
190 #[test]
191 fn read_identity_prefers_nested_over_legacy() {
192 // Canonical nested form.
193 let json = serde_json::json!({
194 "area": "onboarding",
195 "embedder": { "name": "nested", "dimensions": 384, "normalize": true },
196 // Garbage legacy keys — must be ignored while the nested
197 // form is present.
198 "embedder_name": "legacy",
199 "embedder_dimensions": 999,
200 "embedder_normalize": false,
201 });
202 let obj = json.as_object().unwrap().clone();
203 let id = read_identity_from_metadata(&obj).expect("nested identity read");
204 assert_eq!(id.name, "nested");
205 assert_eq!(id.dimensions, 384);
206 assert!(id.normalize);
207 }
208
209 #[test]
210 fn read_identity_falls_back_to_legacy_flat() {
211 let json = serde_json::json!({
212 "embedder_name": "legacy",
213 "embedder_dimensions": 128,
214 "embedder_normalize": false,
215 });
216 let obj = json.as_object().unwrap().clone();
217 let id = read_identity_from_metadata(&obj).expect("legacy identity read");
218 assert_eq!(id.name, "legacy");
219 assert_eq!(id.dimensions, 128);
220 assert!(!id.normalize);
221 }
222
223 #[test]
224 fn read_identity_none_when_absent_or_malformed() {
225 // Empty.
226 assert!(read_identity_from_metadata(&Map::new()).is_none());
227 // Nested missing a field.
228 let json = serde_json::json!({ "embedder": { "name": "x", "dimensions": 4 } });
229 let obj = json.as_object().unwrap().clone();
230 assert!(read_identity_from_metadata(&obj).is_none());
231 }
232
233 #[test]
234 fn write_identity_canonicalizes_and_drops_legacy() {
235 let json = serde_json::json!({
236 "embedder_name": "old",
237 "embedder_dimensions": 128,
238 "embedder_normalize": false,
239 "unrelated": "keep-me",
240 });
241 let mut obj = json.as_object().unwrap().clone();
242 write_identity_into_metadata(
243 &mut obj,
244 &EmbedderIdentity {
245 name: "new".into(),
246 dimensions: 384,
247 normalize: true,
248 },
249 );
250 // Legacy keys gone.
251 assert!(!obj.contains_key(LEGACY_FLAT_NAME));
252 assert!(!obj.contains_key(LEGACY_FLAT_DIMENSIONS));
253 assert!(!obj.contains_key(LEGACY_FLAT_NORMALIZE));
254 // Unrelated metadata preserved.
255 assert_eq!(obj["unrelated"], serde_json::json!("keep-me"));
256 // Nested identity written correctly.
257 let nested = obj["embedder"].as_object().unwrap();
258 assert_eq!(nested["name"], serde_json::json!("new"));
259 assert_eq!(nested["dimensions"], serde_json::json!(384));
260 assert_eq!(nested["normalize"], serde_json::json!(true));
261 }
262
263 #[test]
264 fn mismatch_err_includes_both_identities() {
265 let want = EmbedderIdentity {
266 name: "A".into(),
267 dimensions: 4,
268 normalize: true,
269 };
270 let saw = EmbedderIdentity {
271 name: "B".into(),
272 dimensions: 4,
273 normalize: false,
274 };
275 let msg = format!("{}", identity_mismatch_err(&want, &saw));
276 assert!(msg.contains(EmbedderIdentity::MISMATCH_MSG));
277 assert!(msg.contains("name=\"A\""));
278 assert!(msg.contains("name=\"B\""));
279 }
280}