1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
56pub struct ValidationOutcome {
57 pub facts: u64,
59 pub edges: u64,
61 pub explained_by_expiry: u64,
64}
65
66pub 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
117fn 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
140fn 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
172fn 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
193struct StoreView {
197 db: std::sync::Arc<Database>,
198 memory: AgentMemory,
199}
200
201impl StoreView {
202 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 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 fn vanished(&self, collection: &str, id: u64) -> bool {
246 divergence_explained_by_expiry(&self.db, collection, id)
247 }
248}
249
250#[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
267struct 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 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 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 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 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 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
415fn 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
424pub(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}