1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
//! HNSW-specific persistence methods.
use khive_hnsw::HnswIndex;
use khive_hnsw::HnswSnapshot;
use super::shadow::{log_validation_result, should_sample};
use super::{
PersistError, RetrievalPersistence, ShadowMetrics, ShadowValidationConfig,
ShadowValidationResult,
};
impl RetrievalPersistence {
/// Persist an HNSW index snapshot to SQLite.
///
/// Creates a snapshot of the index and stores it as a serialized BLOB.
pub async fn persist_hnsw_snapshot(&self, index: &HnswIndex) -> Result<(), PersistError> {
let snapshot = index.snapshot();
self.persist_snapshot("hnsw", &snapshot).await
}
/// Load the latest HNSW snapshot from SQLite.
///
/// Returns `None` if no snapshot exists for this namespace.
pub async fn load_hnsw_snapshot(&self) -> Result<Option<HnswSnapshot>, PersistError> {
self.load_snapshot::<HnswSnapshot>("hnsw").await
}
/// Persist an HNSW snapshot with optional shadow validation.
///
/// If shadow validation is enabled, the snapshot is immediately loaded
/// back and compared to verify integrity. Discrepancies are logged but
/// do not block the persist operation.
pub async fn persist_hnsw_with_validation(
&self,
index: &HnswIndex,
config: &ShadowValidationConfig,
) -> Result<Option<ShadowValidationResult>, PersistError> {
// Always persist first
self.persist_hnsw_snapshot(index).await?;
// Skip validation if disabled or not sampled
if !config.enabled || !should_sample(config.sample_rate) {
return Ok(None);
}
// Capture expected metrics
let expected = ShadowMetrics {
item_count: index.len(),
tombstone_count: index.tombstone_stats().tombstone_count,
snapshot_size: 0, // Will be filled by stats
};
// Perform shadow validation
let result = self.validate_hnsw_snapshot(expected).await;
// Log result (non-blocking)
log_validation_result(&result);
Ok(Some(result))
}
/// Validate an HNSW snapshot by loading it back and comparing metrics.
pub(crate) async fn validate_hnsw_snapshot(
&self,
expected: ShadowMetrics,
) -> ShadowValidationResult {
let mut result = ShadowValidationResult {
passed: false,
index_type: "hnsw".to_string(),
expected: expected.clone(),
actual: None,
discrepancies: Vec::new(),
};
// Try to load the snapshot back
match self.load_hnsw_snapshot().await {
Ok(Some(snapshot)) => {
// Issue #867: Deep verification using HnswSnapshot::verify()
// This checks internal consistency beyond just count comparison:
// - Count consistency: total_nodes == live_nodes + tombstone_count
// - ID count integrity: indexed_ids.len() == total_nodes
// - Tombstone containment: all tombstoned IDs exist in indexed_ids
if let Err(e) = snapshot.verify() {
result
.discrepancies
.push(format!("Snapshot verification failed: {e}"));
}
let actual = ShadowMetrics {
item_count: snapshot.total_nodes,
tombstone_count: snapshot.tombstone_count,
snapshot_size: 0, // Not easily available without re-serializing
};
// Compare metrics
if actual.item_count != expected.item_count {
result.discrepancies.push(format!(
"item_count mismatch: expected {}, got {}",
expected.item_count, actual.item_count
));
}
if actual.tombstone_count != expected.tombstone_count {
result.discrepancies.push(format!(
"tombstone_count mismatch: expected {}, got {}",
expected.tombstone_count, actual.tombstone_count
));
}
result.actual = Some(actual);
result.passed = result.discrepancies.is_empty();
}
Ok(None) => {
result
.discrepancies
.push("snapshot not found after persist".to_string());
}
Err(e) => {
result
.discrepancies
.push(format!("failed to load snapshot: {e}"));
}
}
result
}
}