velesdb_memory/embedding_provenance.rs
1//! What embedder filled this store, and whether the configured one can still
2//! read it (#1751, arbitration A1).
3//!
4//! # The problem this exists for
5//!
6//! A store's vectors are only comparable to vectors from the **same embedding
7//! model**. `velesdb-core` already refuses to open a collection whose
8//! dimension differs from the embedder's, which catches the loud half of the
9//! problem — `bge-m3` (1024) against `all-minilm` (384) fails immediately and
10//! clearly.
11//!
12//! It does not catch the quiet half. Two different models of the *same*
13//! dimension open perfectly and then return nonsense: this crate's own `hash`
14//! embedder is 384-dimensional, and so is `all-minilm`. Recall degrades to
15//! noise with nothing anywhere reporting a fault. Recording the model closes
16//! that gap.
17//!
18//! # What is recorded, and what deliberately is not
19//!
20//! The model identifier and the dimension. **Not the backend.** A backend is a
21//! *transport*: the same model served by Ollama, by oMLX or by a hosted
22//! OpenAI-compatible API produces the same vectors, so refusing an open
23//! because the transport changed would block a valid migration — precisely the
24//! migration #1751 exists to enable.
25//!
26//! # When it is recorded
27//!
28//! Only when the store holds **no facts**. Never retroactively over data: a
29//! single open with the wrong model would carve a false provenance into the
30//! store, and every later check would trust it. A store that predates this
31//! record therefore stays unrecorded for good, and its check degrades to the
32//! dimension alone — with [`unrecorded_model_note`] saying so rather than
33//! letting a successful open read as a verified match.
34
35use std::path::Path;
36
37/// Name of the record inside the store directory.
38///
39/// Sits beside the engine's own files rather than inside them: this is
40/// `velesdb-memory`'s knowledge about its embedder, not something
41/// `velesdb-core` — which only ever sees raw `&[f32]` — has any business
42/// carrying.
43pub const PROVENANCE_FILE: &str = "embedding-provenance.json";
44
45/// The embedder a store was filled by.
46#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub struct EmbeddingProvenance {
48 /// The model identifier as configured — `bge-m3`, `all-minilm`, or `hash`
49 /// for this crate's built-in offline embedder.
50 pub model: String,
51 /// The vector width that model produces.
52 pub dimension: usize,
53}
54
55impl EmbeddingProvenance {
56 /// Record `model` at `dimension`.
57 #[must_use]
58 pub fn new(model: impl Into<String>, dimension: usize) -> Self {
59 Self {
60 model: model.into(),
61 dimension,
62 }
63 }
64}
65
66/// Read the record from `store_dir`, or `None` when there is none.
67///
68/// An absent record is a normal outcome — every store created before this
69/// existed has none — and is deliberately distinct from a failure to read one,
70/// because the two lead to opposite actions: the first degrades the check, the
71/// second stops the daemon.
72///
73/// # Errors
74/// A record that exists but cannot be read or parsed. Treating that as
75/// "absent" would silently disable the guard on exactly the store whose
76/// metadata is already damaged; the message names the file so the operator can
77/// delete it and let the check degrade knowingly.
78pub fn read(store_dir: &Path) -> Result<Option<EmbeddingProvenance>, String> {
79 let path = store_dir.join(PROVENANCE_FILE);
80 let raw = match std::fs::read_to_string(&path) {
81 Ok(raw) => raw,
82 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
83 Err(err) => return Err(format!("cannot read {PROVENANCE_FILE}: {err}")),
84 };
85 // Unknown fields are IGNORED on purpose (no `deny_unknown_fields` here,
86 // unlike the operator-facing config file): a store stamped by a newer
87 // version must stay readable by an older binary, which can still run the
88 // check it does understand. A typo is impossible — nobody writes this file
89 // by hand.
90 serde_json::from_str(&raw)
91 .map(Some)
92 .map_err(|err| format!("cannot parse {PROVENANCE_FILE}: {err} — delete it to reset the embedding record (the store's own data is untouched)"))
93}
94
95/// Write the record into `store_dir`.
96///
97/// # Errors
98/// The directory is unwritable, or serialisation fails.
99pub fn write(store_dir: &Path, provenance: &EmbeddingProvenance) -> Result<(), String> {
100 let body = serde_json::to_string_pretty(provenance)
101 .map_err(|err| format!("cannot serialise the embedding record: {err}"))?;
102 std::fs::write(store_dir.join(PROVENANCE_FILE), body)
103 .map_err(|err| format!("cannot write {PROVENANCE_FILE}: {err}"))
104}
105
106/// Compare what the store recorded against what the daemon is configured for.
107///
108/// `stored` is `None` for a store that predates the record; there is then
109/// nothing to compare and the core's own dimension check remains the only
110/// guard — see [`unrecorded_model_note`].
111///
112/// # Errors
113/// A message naming **both** configurations and what can be done about them.
114/// Naming only the mismatch would leave the operator guessing which side to
115/// change.
116pub fn check(
117 stored: Option<&EmbeddingProvenance>,
118 model: &str,
119 dimension: usize,
120) -> Result<(), String> {
121 let Some(stored) = stored else {
122 return Ok(());
123 };
124 if stored.model == model && stored.dimension == dimension {
125 return Ok(());
126 }
127 // The dimension is stated on both sides even when only the name differs:
128 // it is what tells an operator whether a re-index is a re-embed or a
129 // rebuild, and a served model can change what its own name means.
130 Err(format!(
131 "this store was filled with the embedding model '{}' ({} dimensions), and the daemon is \
132 configured for '{}' ({} dimensions). Vectors from two different models are not \
133 comparable, so recall would silently return nonsense. Either point \
134 VELESDB_MEMORY_EMBEDDER_MODEL back at '{}', or migrate the store against the new model \
135 with `velesdb-memory migrate-embeddings` (start with --dry-run; see #1762). Which \
136 backend serves the model does not matter and is not recorded: the same model over \
137 Ollama, oMLX or an OpenAI-compatible API produces the same vectors.",
138 stored.model, stored.dimension, model, dimension, stored.model
139 ))
140}
141
142/// What to say when the store carries no model record.
143///
144/// The disclosure is the point. A store created before this record existed
145/// opens on a dimension match alone, and an operator reading that as "my model
146/// matches" would be reading something nobody verified.
147#[must_use]
148pub fn unrecorded_model_note(model: &str) -> String {
149 format!(
150 "[velesdb-memory] this store predates embedding-model recording, so only the vector \
151 dimension could be compared against '{model}' — not the model itself. Two different \
152 models of the same width would pass this check. The record is written only for a store \
153 with no facts in it, never over existing data, because a wrong stamp would be trusted \
154 forever."
155 )
156}
157
158#[cfg(test)]
159#[path = "embedding_provenance_tests.rs"]
160mod tests;