Skip to main content

velesdb_memory/migration/
validate.rs

1//! Validation of the rebuilt destination (#1762, PR C3).
2//!
3//! The rebuild re-reads what it writes, but it does so collection by
4//! collection, WHILE writing. This is the other proof: one pass over the
5//! finished destination against the source as it stands, before anything is
6//! allowed to move. [`Phase::DestinationValidated`] — defined since C1 and
7//! never produced by any code until now — is exactly this pass's journal
8//! entry.
9//!
10//! # What is compared, and what deliberately is not
11//!
12//! Facts are compared as id → payload maps, both sides walked by the same
13//! cursor the rebuild used; edges as sets of complete tuples through
14//! [`super::edges::export_edges_verified`] on both stores. Vectors are NOT
15//! compared: under `reembed` they differ from the source's by design, and
16//! re-embedding every fact to check them would repeat the rebuild to validate
17//! the rebuild. What stands in for them is the embedder witness the journal
18//! already carries.
19//!
20//! # The one tolerated divergence
21//!
22//! A fact whose ABSOLUTE expiry passes between the source walk and the
23//! destination walk is visible in one and hidden in the other, with nothing
24//! lost — the clock window named in C2b's review, discriminated mechanically
25//! here as promised there: a diverging id explains itself if and only if the
26//! point's payload carries `_veles_expires_at <= now`. A durably expired fact
27//! is invisible to BOTH walks, so this discriminator can never excuse real
28//! loss: a live intruder or a missing live fact has no expiry to hide behind.
29//!
30//! # The provenance stamp
31//!
32//! The daemon reads `embedding-provenance.json` BEFORE it opens a store. A
33//! destination switched live without a stamp would degrade into the
34//! unrecorded-model warning on every start, and stamping it any earlier than
35//! validation would stamp a store nobody had proven. The stamp is written from
36//! the JOURNAL's identity — the same model and dimension every resume was
37//! checked against — after the comparison passes and before the phase
38//! advances.
39
40use super::query_error;
41use std::collections::BTreeMap;
42use std::path::Path;
43
44use velesdb_core::Database;
45
46use super::diagnosis::TargetContract;
47use super::edges::export_edges_verified;
48use super::enumeration::{enumerate_by_cursor, AGENT_COLLECTIONS};
49use super::execute::journal_workspace;
50use super::state::{CollectionProgress, MigrationLock, MigrationState, Phase};
51use velesdb_core::agent::AgentMemory;
52use velesdb_core::collection::graph::GraphEdge;
53
54/// What one validation pass established.
55#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
56pub struct ValidationOutcome {
57    /// Facts compared across the two stores.
58    pub facts: u64,
59    /// Edges compared across the two stores.
60    pub edges: u64,
61    /// Divergences explained by an absolute expiry crossing the walk window —
62    /// tolerated, counted, and reported rather than silently absorbed.
63    pub explained_by_expiry: u64,
64}
65
66/// Validate `destination` against `store` and journal the result.
67///
68/// Requires the journal at [`Phase::Prepared`] with every collection
69/// `Complete` (or already at [`Phase::DestinationValidated`], making the call
70/// an idempotent re-validation). The source must still match the journalled
71/// fingerprint: a source that moved since the rebuild invalidates the
72/// comparison, not the destination, and the refusal says which.
73///
74/// # Errors
75/// Returns [`crate::MemoryError`] when there is no journal, the rebuild is
76/// unfinished, the source changed, the identity mismatches, the stores
77/// diverge beyond the expiry window, or the stamp or journal write fails.
78pub fn validate_destination(
79    store: &Path,
80    destination: &Path,
81    target: &TargetContract,
82    batch: usize,
83) -> Result<ValidationOutcome, crate::MemoryError> {
84    let workspace = journal_workspace(destination)?;
85    let lock = MigrationLock::acquire(&workspace, "migrate-validate").map_err(query_error)?;
86    let result = validate_locked(store, destination, target, batch, &workspace, &lock);
87    super::execute::reconcile(result, lock.release())
88}
89
90fn validate_locked(
91    store: &Path,
92    destination: &Path,
93    target: &TargetContract,
94    batch: usize,
95    workspace: &Path,
96    lock: &MigrationLock,
97) -> Result<ValidationOutcome, crate::MemoryError> {
98    let mut state = journalled_state(target, workspace)?;
99    let outcome = compare_stores(store, destination, &state, batch)?;
100
101    crate::embedding_provenance::write(
102        destination,
103        &crate::embedding_provenance::EmbeddingProvenance::new(
104            &state.target_model,
105            state.target_dimension,
106        ),
107    )
108    .map_err(query_error)?;
109
110    if state.phase == Phase::Prepared {
111        state.phase = Phase::DestinationValidated;
112        state.write(workspace, lock).map_err(query_error)?;
113    }
114    Ok(outcome)
115}
116
117/// Open both stores and compare every collection's facts and edges.
118fn compare_stores(
119    store: &Path,
120    destination: &Path,
121    state: &MigrationState,
122    batch: usize,
123) -> Result<ValidationOutcome, crate::MemoryError> {
124    let source = StoreView::open_source(store, state.target_dimension)?;
125    let destination = StoreView::open_destination(destination, state.target_dimension)?;
126    let mut outcome = ValidationOutcome::default();
127    for collection in AGENT_COLLECTIONS {
128        Comparison {
129            source: &source,
130            destination: &destination,
131            collection,
132            batch,
133            outcome: &mut outcome,
134        }
135        .run()?;
136    }
137    Ok(outcome)
138}
139
140/// Read the journal and refuse everything a validation cannot stand on.
141fn journalled_state(
142    target: &TargetContract,
143    workspace: &Path,
144) -> Result<MigrationState, crate::MemoryError> {
145    let state = MigrationState::read(workspace)
146        .map_err(query_error)?
147        .ok_or_else(|| {
148            query_error(format!(
149                "no migration journal at {}; there is nothing to validate — run \
150                 the rebuild first",
151                workspace.display()
152            ))
153        })?;
154    require_validatable(&state)?;
155    let fingerprint = super::filesystem::fingerprint(&state.source_path)?;
156    state
157        .may_resume(
158            &state.source_path,
159            &fingerprint,
160            &target.model,
161            target.dimension,
162        )
163        .map_err(|reason| {
164            query_error(format!(
165                "the comparison would be against a store the destination was \
166                 not built from: {reason}"
167            ))
168        })?;
169    Ok(state)
170}
171
172/// The journal must stand where a validation makes sense: rebuild finished,
173/// switch not yet begun.
174fn require_validatable(state: &MigrationState) -> Result<(), crate::MemoryError> {
175    if state.phase != Phase::Prepared && state.phase != Phase::DestinationValidated {
176        return Err(query_error(format!(
177            "the journal stands at {:?}; validation runs before the switch, \
178             not after it",
179            state.phase
180        )));
181    }
182    for (name, progress) in &state.progress {
183        if *progress != CollectionProgress::Complete {
184            return Err(query_error(format!(
185                "collection '{name}' stands at {progress:?}; an unfinished \
186                 rebuild cannot be validated — resume it first"
187            )));
188        }
189    }
190    Ok(())
191}
192
193/// One store as the validation reads it: the database handle and the agent
194/// view the edge export goes through, opened together because neither is
195/// meaningful for this pass without the other.
196struct StoreView {
197    db: std::sync::Arc<Database>,
198    memory: AgentMemory,
199}
200
201impl StoreView {
202    /// The source opens its `AgentMemory` at its OWN width, discovered from
203    /// the store — the fact walk does not care, but the edge export does, and
204    /// under `reembed` the source's width is not the target's.
205    fn open_source(dir: &Path, target_dimension: usize) -> Result<Self, crate::MemoryError> {
206        let db = std::sync::Arc::new(Database::open(dir)?);
207        let dimension = db
208            .get_any_collection(AGENT_COLLECTIONS[0])
209            .map_or(target_dimension, |collection| collection.config().dimension);
210        let memory = AgentMemory::with_dimension(std::sync::Arc::clone(&db), dimension)?;
211        Ok(Self { db, memory })
212    }
213
214    /// The destination was built at the target's width, and says so.
215    fn open_destination(dir: &Path, target_dimension: usize) -> Result<Self, crate::MemoryError> {
216        let db = std::sync::Arc::new(Database::open(dir)?);
217        let memory = AgentMemory::with_dimension(std::sync::Arc::clone(&db), target_dimension)?;
218        Ok(Self { db, memory })
219    }
220
221    fn facts(
222        &self,
223        collection: &str,
224        batch: usize,
225    ) -> Result<BTreeMap<u64, serde_json::Value>, crate::MemoryError> {
226        let mut facts = BTreeMap::new();
227        for fact in enumerate_by_cursor(&self.db, collection, batch)? {
228            let payload: serde_json::Value =
229                serde_json::from_str(&fact.payload).map_err(|err| {
230                    query_error(format!(
231                        "fact {} in '{collection}' carries unreadable payload: {err}",
232                        fact.id
233                    ))
234                })?;
235            facts.insert(fact.id, payload);
236        }
237        Ok(facts)
238    }
239
240    fn edges(&self, collection: &str, batch: usize) -> Result<Vec<GraphEdge>, crate::MemoryError> {
241        export_edges_verified(&self.memory, &self.db, collection, batch)
242    }
243
244    /// See [`divergence_explained_by_expiry`] for why absence means expiry.
245    fn vanished(&self, collection: &str, id: u64) -> bool {
246        divergence_explained_by_expiry(&self.db, collection, id)
247    }
248}
249
250/// Which of the two stores an observation belongs to — named, because a
251/// refusal that cannot say WHICH side held the stray fact is half a refusal.
252#[derive(Debug, Clone, Copy)]
253enum Side {
254    Source,
255    Destination,
256}
257
258impl Side {
259    fn name(self) -> &'static str {
260        match self {
261            Self::Source => "source",
262            Self::Destination => "destination",
263        }
264    }
265}
266
267/// One collection compared across the two views, accumulating into the
268/// outcome. A struct rather than a parameter list, because every method needs
269/// the same five things and a signature repeating them five times is where
270/// the sixth one gets threaded through wrongly.
271struct Comparison<'a> {
272    source: &'a StoreView,
273    destination: &'a StoreView,
274    collection: &'a str,
275    batch: usize,
276    outcome: &'a mut ValidationOutcome,
277}
278
279impl Comparison<'_> {
280    fn run(&mut self) -> Result<(), crate::MemoryError> {
281        self.compare_facts()?;
282        self.compare_edges()
283    }
284
285    fn view(&self, side: Side) -> &StoreView {
286        match side {
287            Side::Source => self.source,
288            Side::Destination => self.destination,
289        }
290    }
291
292    fn compare_facts(&mut self) -> Result<(), crate::MemoryError> {
293        let source_facts = self.source.facts(self.collection, self.batch)?;
294        let destination_facts = self.destination.facts(self.collection, self.batch)?;
295        self.outcome.facts += source_facts.len() as u64;
296
297        for (id, payload) in &source_facts {
298            self.compare_one_fact(*id, payload, destination_facts.get(id))?;
299        }
300        for id in destination_facts.keys() {
301            if !source_facts.contains_key(id) {
302                self.fact_explained_or_loss(Side::Destination, *id)?;
303            }
304        }
305        Ok(())
306    }
307
308    /// One source fact against what the destination holds under the same id.
309    fn compare_one_fact(
310        &mut self,
311        id: u64,
312        payload: &serde_json::Value,
313        found: Option<&serde_json::Value>,
314    ) -> Result<(), crate::MemoryError> {
315        match found {
316            Some(found) if found == payload => Ok(()),
317            Some(_) => Err(query_error(format!(
318                "fact {id} in '{}' differs between source and destination; a \
319                 payload that changed in transit is loss, and no expiry \
320                 explains a fact both stores still hold",
321                self.collection
322            ))),
323            None => self.fact_explained_or_loss(Side::Source, id),
324        }
325    }
326
327    /// A diverging id either explains itself by expiry or fails the pass.
328    fn fact_explained_or_loss(&mut self, side: Side, id: u64) -> Result<(), crate::MemoryError> {
329        if self.view(side).vanished(self.collection, id) {
330            self.outcome.explained_by_expiry += 1;
331            return Ok(());
332        }
333        Err(query_error(format!(
334            "fact {id} in '{}' exists only on the {} side and is still live \
335             there; this is loss, not a clock window",
336            self.collection,
337            side.name(),
338        )))
339    }
340
341    fn compare_edges(&mut self) -> Result<(), crate::MemoryError> {
342        let exported = self.source.edges(self.collection, self.batch)?;
343        let back = self.destination.edges(self.collection, self.batch)?;
344        self.outcome.edges += exported.len() as u64;
345
346        let source_tuples = edge_map(&exported);
347        let destination_tuples = edge_map(&back);
348        self.sweep_missing_or_changed(&source_tuples, &destination_tuples, &exported)?;
349        self.sweep_surplus(&source_tuples, &destination_tuples, &back)
350    }
351
352    /// Source edges the destination lacks or holds differently.
353    fn sweep_missing_or_changed(
354        &mut self,
355        source_tuples: &BTreeMap<u64, super::edges::CanonicalEdge>,
356        destination_tuples: &BTreeMap<u64, super::edges::CanonicalEdge>,
357        exported: &[GraphEdge],
358    ) -> Result<(), crate::MemoryError> {
359        for (id, tuple) in source_tuples {
360            if destination_tuples.get(id) != Some(tuple) {
361                self.edge_explained_or_loss(Side::Source, exported, *id)?;
362            }
363        }
364        Ok(())
365    }
366
367    /// Destination edges the source never exported.
368    fn sweep_surplus(
369        &mut self,
370        source_tuples: &BTreeMap<u64, super::edges::CanonicalEdge>,
371        destination_tuples: &BTreeMap<u64, super::edges::CanonicalEdge>,
372        back: &[GraphEdge],
373    ) -> Result<(), crate::MemoryError> {
374        for id in destination_tuples.keys() {
375            if !source_tuples.contains_key(id) {
376                self.edge_explained_or_loss(Side::Destination, back, *id)?;
377            }
378        }
379        Ok(())
380    }
381
382    /// A diverging edge explains itself iff one of its endpoints expired.
383    fn edge_explained_or_loss(
384        &mut self,
385        side: Side,
386        edges: &[GraphEdge],
387        id: u64,
388    ) -> Result<(), crate::MemoryError> {
389        let Some(edge) = edges.iter().find(|edge| edge.id() == id) else {
390            return Err(query_error(format!(
391                "edge {id} in '{}' diverges and its tuple is not in the export \
392                 that reported it; the comparison itself is inconsistent",
393                self.collection
394            )));
395        };
396        let holder = self.view(side);
397        if holder.vanished(self.collection, edge.source())
398            || holder.vanished(self.collection, edge.target())
399        {
400            self.outcome.explained_by_expiry += 1;
401            return Ok(());
402        }
403        Err(query_error(format!(
404            "edge {id} ({} -{}-> {}) in '{}' diverges between source and \
405             destination and both endpoints are still live; this is loss, not \
406             a clock window",
407            edge.source(),
408            edge.label(),
409            edge.target(),
410            self.collection,
411        )))
412    }
413}
414
415/// Edges keyed by id, in the migration's one canonical form
416/// ([`super::edges::canonical_edge`]).
417fn edge_map(edges: &[GraphEdge]) -> BTreeMap<u64, super::edges::CanonicalEdge> {
418    edges
419        .iter()
420        .map(|edge| (edge.id(), super::edges::canonical_edge(edge)))
421        .collect()
422}
423
424/// Whether an id one of THIS validation's walks returned now reads back as
425/// absent — which, under the lock this validation holds, can only mean its
426/// absolute expiry passed between the walk and this probe.
427///
428/// The reasoning is deliberately indirect, because it has to be: an expired
429/// point is invisible on EVERY public read surface — `get` answers `None` for
430/// it exactly as for a deleted one — so its expiry cannot be read back
431/// directly. What makes `None` conclusive here is the flock: this validation
432/// holds both stores open for its whole pass, nothing else can write or
433/// delete under it, and so the only mover left between a walk that saw the id
434/// and a probe that does not is the clock crossing the point's own
435/// `_veles_expires_at`.
436///
437/// The contract is therefore narrow: call this ONLY with ids a walk of this
438/// same session returned. An arbitrary id the store never held also reads
439/// back `None`, and this function cannot tell the two apart — the caller's
440/// provenance of the id is what gives the answer its meaning.
441pub(crate) fn divergence_explained_by_expiry(db: &Database, collection: &str, id: u64) -> bool {
442    let Some(any) = db.get_any_collection(collection) else {
443        return false;
444    };
445    !matches!(any.get(&[id]).into_iter().next(), Some(Some(_)))
446}