Skip to main content

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}