Skip to main content

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}