velesdb_memory/migration/strategy.rs
1//! Which regime a rebuild runs in, and why (#1815).
2//!
3//! A rebuild has two possible regimes and the module below the CLI is
4//! deliberately neutral between them, because the caller supplies the vector:
5//! reuse the source vectors, or re-embed every fact with the target model. The
6//! arbitration on #1815 is that **reuse is permitted only where compatibility
7//! is proven, and every other case re-embeds**.
8//!
9//! # The one thing this file exists to refuse
10//!
11//! Two models that happen to produce the same width do NOT produce comparable
12//! vectors, and nothing on disk distinguishes them: the store records a
13//! dimension, and — only since #1751, and only for a store that was empty when
14//! it was first opened — a model name. So an equal-width model swap is
15//! **invisible**, and a rebuild that inferred the source model from the width
16//! would reuse vectors from one model in a store that claims another. Recall
17//! would then return nonsense without a single error anywhere.
18//!
19//! Hence: an unrecorded source model is never guessed at. It resolves to
20//! re-embedding, which is always sound because it reads the stored *text* and
21//! never the stored vector.
22//!
23//! # There is deliberately no `force-reuse`
24//!
25//! An override that reused vectors against an unproven provenance would be an
26//! official route to a semantically incoherent store, so [`Strategy::parse`]
27//! names it and refuses it rather than leaving an operator to discover it does
28//! not exist. `--strategy reembed` is the escape hatch, and it escapes towards
29//! the *safe* regime.
30
31use super::SourceProvenance;
32
33// ---------------------------------------------------------------------------
34// THE VOCABULARY
35//
36// A closed set of five sentences, so that every diagnostic an operator can see
37// is one of five and each is tested. A `format!` at the call site would let a
38// sixth phrasing appear without anybody deciding it should.
39// ---------------------------------------------------------------------------
40
41/// The source records the target model at the target width.
42const MATCH: &str = "source and target embedding provenance match";
43/// A recorded model that is not the target's — including at equal width.
44const MODEL_DIFFERS: &str = "target model differs";
45/// The recorded width is not the target's.
46const DIMENSION_DIFFERS: &str = "target dimension differs";
47/// No record at all: the nominal case for every store predating #1751.
48const PROVENANCE_UNKNOWN: &str = "source provenance is unknown";
49/// A record that disagrees with the vectors it claims to describe.
50const PROVENANCE_CONTRADICTS: &str = "source provenance contradicts the stored dimension";
51
52// ---------------------------------------------------------------------------
53// WHAT THE STORE PERMITS
54// ---------------------------------------------------------------------------
55
56/// What the source's own record permits, independently of what was asked for.
57///
58/// Split from [`Strategy`] on purpose: this is a property of the store, and
59/// mixing it with the operator's request is what would make "the operator asked
60/// nicely" look like a reason vectors are compatible.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum Compatibility {
64 /// Known provenance, same model, same width. The only value that permits
65 /// reuse.
66 Match,
67 /// Known provenance naming a different model — **including at equal
68 /// width**, which is exactly the case no measurement can detect.
69 ModelDiffers,
70 /// Known provenance at a width the target does not produce.
71 DimensionDiffers,
72 /// The store records nothing. Not a fault: the nominal state of every store
73 /// created before the record existed, including the one #1762 was opened
74 /// for.
75 ProvenanceUnknown,
76 /// The record and the collections disagree, or the collections establish no
77 /// shared width at all. Neither side can be trusted to describe the other.
78 ProvenanceContradictsDimension,
79}
80
81impl Compatibility {
82 /// The sentence that names this state, from the closed vocabulary.
83 #[must_use]
84 pub fn reason(self) -> &'static str {
85 match self {
86 Self::Match => MATCH,
87 Self::ModelDiffers => MODEL_DIFFERS,
88 Self::DimensionDiffers => DIMENSION_DIFFERS,
89 Self::ProvenanceUnknown => PROVENANCE_UNKNOWN,
90 Self::ProvenanceContradictsDimension => PROVENANCE_CONTRADICTS,
91 }
92 }
93
94 /// Whether reusing the source vectors is defensible.
95 ///
96 /// One variant, and it is the point of the whole file: proof, not absence
97 /// of evidence.
98 #[must_use]
99 pub fn permits_reuse(self) -> bool {
100 matches!(self, Self::Match)
101 }
102
103 /// Every variant, so an exhaustive check cannot silently miss one added
104 /// later.
105 #[must_use]
106 pub fn all() -> Vec<Self> {
107 vec![
108 Self::Match,
109 Self::ModelDiffers,
110 Self::DimensionDiffers,
111 Self::ProvenanceUnknown,
112 Self::ProvenanceContradictsDimension,
113 ]
114 }
115}
116
117/// Read the source's record against the target contract.
118///
119/// The record is checked against the DATA first: a provenance naming a width
120/// the collections do not have describes something other than this store, and
121/// reading its model name off it would be reading a record already shown wrong.
122/// `source_dimension` is `None` when the collections disagree or the store has
123/// none, which fails the same reconciliation for the same reason.
124#[must_use]
125pub fn assess(
126 provenance: &SourceProvenance,
127 source_dimension: Option<usize>,
128 target_model: &str,
129 target_dimension: usize,
130) -> Compatibility {
131 let SourceProvenance::Known { model, dimension } = provenance else {
132 return Compatibility::ProvenanceUnknown;
133 };
134 if source_dimension != Some(*dimension) {
135 return Compatibility::ProvenanceContradictsDimension;
136 }
137 // Model before width: a model change is the fact that matters, and at equal
138 // width it is the ONLY thing that distinguishes two incomparable stores.
139 if model != target_model {
140 return Compatibility::ModelDiffers;
141 }
142 if *dimension != target_dimension {
143 return Compatibility::DimensionDiffers;
144 }
145 Compatibility::Match
146}
147
148// ---------------------------------------------------------------------------
149// WHAT THE OPERATOR ASKED FOR
150// ---------------------------------------------------------------------------
151
152/// What the operator selected on the command line.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum Strategy {
156 /// Decide from the source's own record. The default.
157 Auto,
158 /// Reuse the source vectors — honoured only against a proven match.
159 Reuse,
160 /// Re-embed every fact. Always available.
161 Reembed,
162}
163
164impl Strategy {
165 /// Parse a `--strategy` value.
166 ///
167 /// # Errors
168 /// Names the three accepted values. `force-reuse`, in any spelling, gets
169 /// its own message: it is refused by design rather than merely absent, and
170 /// an operator who reached for it is asking the one question this batch
171 /// answered with "no".
172 pub fn parse(value: &str) -> Result<Self, String> {
173 match value {
174 "auto" => Ok(Self::Auto),
175 "reuse" => Ok(Self::Reuse),
176 "reembed" => Ok(Self::Reembed),
177 "force-reuse" | "force_reuse" => Err(format!(
178 "--strategy {value} does not exist, and not by oversight: reusing vectors against \
179 an unproven provenance is an official route to a store whose vectors and whose \
180 recorded model disagree, which recall would answer from without ever failing. \
181 Use --strategy reembed to rebuild from the stored text"
182 )),
183 other => Err(format!(
184 "--strategy expects auto, reuse or reembed, got {other:?}"
185 )),
186 }
187 }
188
189 /// Every variant, so an exhaustive check cannot silently miss one added
190 /// later.
191 #[must_use]
192 pub fn all() -> Vec<Self> {
193 vec![Self::Auto, Self::Reuse, Self::Reembed]
194 }
195}
196
197// ---------------------------------------------------------------------------
198// WHAT WILL HAPPEN
199// ---------------------------------------------------------------------------
200
201/// What the rebuild will do, or why it will not run.
202///
203/// There is no variant meaning "reuse, but flagged": a rebuild either reuses
204/// vectors it has grounds to reuse, or it does not reuse them.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
206#[serde(tag = "resolution", rename_all = "snake_case")]
207pub enum Resolution {
208 /// Carry the source vectors across unchanged. The target embedder is never
209 /// called.
210 Reuse,
211 /// Compute every vector with the target embedder, from the stored text.
212 Reembed {
213 /// What made reuse unavailable — or, under `--strategy reembed`, what
214 /// the store would have permitted anyway.
215 because: Compatibility,
216 },
217 /// Change nothing.
218 Refuse {
219 /// The state of the store that makes the request unanswerable.
220 because: Compatibility,
221 /// What was asked for, which decides what the operator should do next.
222 requested: Strategy,
223 },
224}
225
226impl Resolution {
227 /// The one line, from the closed vocabulary, that names this decision.
228 #[must_use]
229 pub fn diagnostic(self) -> String {
230 match self {
231 Self::Reuse => format!("REUSE: {MATCH}"),
232 Self::Reembed { because } => format!("REEMBED: {}", because.reason()),
233 Self::Refuse {
234 because,
235 requested: Strategy::Reuse,
236 } => format!("REFUSE: reuse was requested, but {}", because.reason()),
237 Self::Refuse { because, .. } => format!("REFUSE: {}", because.reason()),
238 }
239 }
240
241 /// What the operator can do about it, or `None` when nothing is wrong.
242 ///
243 /// A refusal that named only the problem would leave an operator with a
244 /// store they cannot migrate and no next step; both refusals have one, and
245 /// neither is "reuse it anyway".
246 #[must_use]
247 pub fn guidance(self) -> Option<&'static str> {
248 match self {
249 Self::Reuse | Self::Reembed { .. } => None,
250 Self::Refuse {
251 requested: Strategy::Reuse,
252 ..
253 } => Some(
254 "reuse is legitimate only when the source records the target model at the target \
255 width. Re-run with --strategy auto to let the source's own record decide, or \
256 --strategy reembed to rebuild every vector from the stored text.",
257 ),
258 Self::Refuse { .. } => Some(
259 "the record and the vectors cannot both be right, so neither is read as truth. \
260 Re-run with --strategy reembed to rebuild from the stored text on the measured \
261 width — it never reads a source vector, so the contradiction cannot propagate.",
262 ),
263 }
264 }
265
266 /// Whether this decision runs a rebuild at all.
267 #[must_use]
268 pub fn runs(self) -> bool {
269 !matches!(self, Self::Refuse { .. })
270 }
271}
272
273/// Decide the regime from what was asked and what the store permits.
274///
275/// `Auto` refuses only the self-contradicting store: re-embedding is otherwise
276/// always sound, since it reads the stored text and never the stored vector.
277/// That refusal is not a dead end — `--strategy reembed` performs exactly the
278/// rebuild `Auto` declined to choose on the operator's behalf.
279#[must_use]
280pub fn resolve(requested: Strategy, compatibility: Compatibility) -> Resolution {
281 // Reuse, and only against a proven match. `reembed` is excluded even here:
282 // an operator who named the safe regime gets it, whatever the store would
283 // have permitted.
284 if requested != Strategy::Reembed && compatibility.permits_reuse() {
285 return Resolution::Reuse;
286 }
287 // Reuse asked for and not earned above, or `auto` meeting a store whose
288 // record and vectors contradict each other — the one state `auto` will not
289 // choose on the operator's behalf.
290 let unearned_reuse = requested == Strategy::Reuse;
291 let unreadable_store = requested == Strategy::Auto
292 && compatibility == Compatibility::ProvenanceContradictsDimension;
293 if unearned_reuse || unreadable_store {
294 return Resolution::Refuse {
295 because: compatibility,
296 requested,
297 };
298 }
299 // Everything else re-embeds: it reads the stored text, so no property of
300 // the source's vectors can make it unsound.
301 Resolution::Reembed {
302 because: compatibility,
303 }
304}