1use std::cell::RefCell;
8
9use nodedb_types::vector_dtype::VectorStorageDtype;
10
11use crate::distance::DistanceMetric;
12use crate::error::VectorError;
13use crate::hnsw::arena::BeamSearchArena;
14use crate::hnsw::flat_neighbors::FlatNeighborStore;
15use crate::hnsw::graph::{ARENA_INITIAL_CAPACITY, HnswIndex, Node, NodeStorage, Xorshift64};
16
17const HNSW_RKYV_MAGIC: &[u8; 6] = b"RKHNS\0";
19pub const HNSW_FORMAT_VERSION: u8 = 1;
21
22const CHECKPOINT_DTYPE: VectorStorageDtype = VectorStorageDtype::F32;
27
28pub(crate) fn metric_from_code(code: u8) -> Result<DistanceMetric, VectorError> {
34 match code {
35 0 => Ok(DistanceMetric::L2),
36 1 => Ok(DistanceMetric::Cosine),
37 2 => Ok(DistanceMetric::InnerProduct),
38 3 => Ok(DistanceMetric::Manhattan),
39 4 => Ok(DistanceMetric::Chebyshev),
40 5 => Ok(DistanceMetric::Hamming),
41 6 => Ok(DistanceMetric::Jaccard),
42 7 => Ok(DistanceMetric::Pearson),
43 _ => Err(VectorError::CheckpointDeserializationError {
44 detail: format!("unknown distance metric code {code}"),
45 }),
46 }
47}
48
49fn validate_snapshot(snap: &HnswSnapshotRkyv) -> Result<(), VectorError> {
51 let count = snap.node_vectors.len();
52 if snap.node_neighbors.len() != count || snap.node_deleted.len() != count {
53 return Err(VectorError::CheckpointDeserializationError {
54 detail: format!(
55 "node counts disagree: {count} vectors, {} neighbor lists, {} deleted flags",
56 snap.node_neighbors.len(),
57 snap.node_deleted.len()
58 ),
59 });
60 }
61 if let Some(v) = snap
63 .node_vectors
64 .iter()
65 .find(|v| !v.is_empty() && v.len() != snap.dim)
66 {
67 return Err(VectorError::StoredDimensionMismatch {
68 expected: snap.dim,
69 got: v.len(),
70 });
71 }
72 let in_range = |id: u32| (id as usize) < count;
73 let neighbors_in_range = snap
74 .node_neighbors
75 .iter()
76 .flatten()
77 .flatten()
78 .all(|&id| in_range(id));
79 if !neighbors_in_range || snap.entry_point.is_some_and(|ep| !in_range(ep)) {
80 return Err(VectorError::CheckpointDeserializationError {
81 detail: format!("a neighbor or entry point id is outside {count} nodes"),
82 });
83 }
84 Ok(())
85}
86
87#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
90pub(crate) struct HnswSnapshotRkyv {
91 pub dim: usize,
92 pub m: usize,
93 pub m0: usize,
94 pub ef_construction: usize,
95 pub metric: u8,
96 pub entry_point: Option<u32>,
97 pub max_layer: usize,
98 pub rng_state: u64,
99 pub node_vectors: Vec<Vec<f32>>,
101 pub node_neighbors: Vec<Vec<Vec<u32>>>,
103 pub node_deleted: Vec<bool>,
105}
106
107impl HnswIndex {
108 pub fn graph_checkpoint_to_bytes(&self) -> Result<Vec<u8>, VectorError> {
119 let snapshot = HnswSnapshotRkyv {
120 dim: self.dim,
121 m: self.params.m,
122 m0: self.params.m0,
123 ef_construction: self.params.ef_construction,
124 metric: self.params.metric as u8,
125 entry_point: self.entry_point,
126 max_layer: self.max_layer,
127 rng_state: self.rng.0,
128 node_vectors: vec![Vec::new(); self.nodes.len()],
130 node_neighbors: if let Some(ref flat) = self.flat_neighbors {
131 flat.to_nested(self.nodes.len())
132 } else {
133 self.nodes.iter().map(|n| n.neighbors.clone()).collect()
134 },
135 node_deleted: self.nodes.iter().map(|n| n.deleted).collect(),
136 };
137 let rkyv_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&snapshot).map_err(|e| {
138 VectorError::CheckpointSerializationError {
139 detail: e.to_string(),
140 }
141 })?;
142 let mut buf = Vec::with_capacity(HNSW_RKYV_MAGIC.len() + 1 + rkyv_bytes.len());
143 buf.extend_from_slice(HNSW_RKYV_MAGIC);
144 buf.push(HNSW_FORMAT_VERSION);
145 buf.extend_from_slice(&rkyv_bytes);
146 Ok(buf)
147 }
148
149 pub fn checkpoint_to_bytes(&self) -> Result<Vec<u8>, VectorError> {
153 let snapshot = HnswSnapshotRkyv {
154 dim: self.dim,
155 m: self.params.m,
156 m0: self.params.m0,
157 ef_construction: self.params.ef_construction,
158 metric: self.params.metric as u8,
159 entry_point: self.entry_point,
160 max_layer: self.max_layer,
161 rng_state: self.rng.0,
162 node_vectors: self.export_vectors()?,
163 node_neighbors: if let Some(ref flat) = self.flat_neighbors {
164 flat.to_nested(self.nodes.len())
165 } else {
166 self.nodes.iter().map(|n| n.neighbors.clone()).collect()
167 },
168 node_deleted: self.nodes.iter().map(|n| n.deleted).collect(),
169 };
170 let rkyv_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&snapshot).map_err(|e| {
171 VectorError::CheckpointSerializationError {
172 detail: e.to_string(),
173 }
174 })?;
175 let mut buf = Vec::with_capacity(HNSW_RKYV_MAGIC.len() + 1 + rkyv_bytes.len());
176 buf.extend_from_slice(HNSW_RKYV_MAGIC);
177 buf.push(HNSW_FORMAT_VERSION);
178 buf.extend_from_slice(&rkyv_bytes);
179 Ok(buf)
180 }
181
182 pub fn from_checkpoint(bytes: &[u8]) -> Result<Option<Self>, crate::error::VectorError> {
194 let header_len = HNSW_RKYV_MAGIC.len() + 1; if bytes.len() > header_len && &bytes[..HNSW_RKYV_MAGIC.len()] == HNSW_RKYV_MAGIC {
196 let version = bytes[HNSW_RKYV_MAGIC.len()];
197 if version != HNSW_FORMAT_VERSION {
198 return Err(crate::error::VectorError::UnsupportedVersion {
199 found: version,
200 expected: HNSW_FORMAT_VERSION,
201 });
202 }
203 return Ok(Some(Self::from_rkyv_checkpoint(&bytes[header_len..])?));
204 }
205 Ok(None)
207 }
208
209 fn from_rkyv_checkpoint(bytes: &[u8]) -> Result<Self, VectorError> {
211 let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(bytes.len());
212 aligned.extend_from_slice(bytes);
213 let snap: HnswSnapshotRkyv =
214 rkyv::from_bytes::<HnswSnapshotRkyv, rkyv::rancor::Error>(&aligned).map_err(|e| {
215 VectorError::CheckpointDeserializationError {
216 detail: format!("hnsw rkyv decode: {e}"),
217 }
218 })?;
219 Self::from_hnsw_snapshot(snap)
220 }
221
222 fn from_hnsw_snapshot(snap: HnswSnapshotRkyv) -> Result<Self, VectorError> {
227 use nodedb_types::hnsw::HnswParams;
228
229 let metric = metric_from_code(snap.metric)?;
230 validate_snapshot(&snap)?;
231
232 let flat = FlatNeighborStore::from_nested(&snap.node_neighbors);
233
234 let nodes: Vec<Node> = snap
235 .node_vectors
236 .into_iter()
237 .zip(snap.node_deleted)
238 .map(|(vector, deleted)| Node {
239 storage: NodeStorage::F32(vector),
240 neighbors: Vec::new(),
241 deleted,
242 })
243 .collect();
244
245 let initial_capacity = snap.ef_construction.max(ARENA_INITIAL_CAPACITY);
246 Ok(Self {
247 dim: snap.dim,
248 params: HnswParams {
249 m: snap.m,
250 m0: snap.m0,
251 ef_construction: snap.ef_construction,
252 metric,
253 dtype: CHECKPOINT_DTYPE,
254 },
255 nodes,
256 entry_point: snap.entry_point,
257 max_layer: snap.max_layer,
258 rng: Xorshift64::new(snap.rng_state),
259 flat_neighbors: Some(flat),
260 arena: RefCell::new(BeamSearchArena::new(initial_capacity)),
261 #[cfg(not(target_arch = "wasm32"))]
262 backing: None,
263 })
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::HnswSnapshotRkyv;
270 use crate::distance::DistanceMetric;
271 use crate::error::VectorError;
272 use crate::hnsw::{HnswIndex, HnswParams};
273
274 fn make_index() -> HnswIndex {
275 HnswIndex::with_seed(
276 3,
277 HnswParams {
278 m: 4,
279 m0: 8,
280 ef_construction: 32,
281 metric: DistanceMetric::L2,
282 dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32,
283 },
284 12345,
285 )
286 }
287
288 #[test]
289 fn checkpoint_roundtrip() {
290 let mut idx = make_index();
291 for i in 0..50 {
292 idx.insert(vec![(i as f32) * 0.1, (i as f32) * 0.2, (i as f32) * 0.3])
293 .unwrap();
294 }
295
296 let bytes = idx.checkpoint_to_bytes().unwrap();
297 assert!(!bytes.is_empty());
298
299 let restored = HnswIndex::from_checkpoint(&bytes).unwrap().unwrap();
300 assert_eq!(restored.len(), 50);
301 assert_eq!(restored.dim(), 3);
302 assert_eq!(restored.entry_point(), idx.entry_point());
303 assert_eq!(restored.max_layer(), idx.max_layer());
304
305 let query = vec![1.0, 2.0, 3.0];
306 let orig_results = idx.search(&query, 5, 32).unwrap();
307 let rest_results = restored.search(&query, 5, 32).unwrap();
308 assert_eq!(orig_results.len(), rest_results.len());
309 for (a, b) in orig_results.iter().zip(rest_results.iter()) {
310 assert_eq!(a.id, b.id);
311 assert!((a.distance - b.distance).abs() < 1e-6);
312 }
313 }
314
315 #[test]
316 fn golden_header_layout() {
317 let mut idx = make_index();
318 idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
319 let bytes = idx.checkpoint_to_bytes().unwrap();
320 assert_eq!(&bytes[0..6], b"RKHNS\0");
322 assert_eq!(bytes[6], super::HNSW_FORMAT_VERSION);
324 assert!(bytes.len() > 7);
326 }
327
328 #[test]
329 fn version_mismatch_returns_error() {
330 let mut idx = make_index();
331 idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
332 let mut bytes = idx.checkpoint_to_bytes().unwrap();
333 bytes[6] = 0;
335 match HnswIndex::from_checkpoint(&bytes) {
336 Err(crate::error::VectorError::UnsupportedVersion { found, expected }) => {
337 assert_eq!(found, 0);
338 assert_eq!(expected, super::HNSW_FORMAT_VERSION);
339 }
340 Err(other) => panic!("unexpected error: {other}"),
341 Ok(_) => panic!("expected UnsupportedVersion error, got Ok"),
342 }
343 }
344
345 #[test]
346 fn corrupt_rkyv_payload_returns_deserialization_error() {
347 let mut bytes = Vec::new();
352 bytes.extend_from_slice(b"RKHNS\0");
353 bytes.push(super::HNSW_FORMAT_VERSION);
354 bytes.extend_from_slice(&[0xFFu8; 64]);
355
356 match HnswIndex::from_checkpoint(&bytes) {
357 Err(crate::error::VectorError::CheckpointDeserializationError { .. }) => {}
358 Err(other) => panic!("unexpected error: {other}"),
359 Ok(_) => panic!("expected CheckpointDeserializationError, got Ok"),
360 }
361 }
362
363 #[test]
365 fn every_metric_round_trips() {
366 for metric in [
367 DistanceMetric::L2,
368 DistanceMetric::Cosine,
369 DistanceMetric::InnerProduct,
370 DistanceMetric::Manhattan,
371 DistanceMetric::Chebyshev,
372 DistanceMetric::Hamming,
373 DistanceMetric::Jaccard,
374 DistanceMetric::Pearson,
375 ] {
376 let mut idx = HnswIndex::with_seed(
377 3,
378 HnswParams {
379 metric,
380 ..make_index().params().clone()
381 },
382 1,
383 );
384 idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
385 let restored = HnswIndex::from_checkpoint(&idx.checkpoint_to_bytes().unwrap())
386 .unwrap()
387 .unwrap();
388 assert_eq!(restored.metric(), metric);
389 }
390 }
391
392 fn snapshot() -> HnswSnapshotRkyv {
394 HnswSnapshotRkyv {
395 dim: 2,
396 m: 4,
397 m0: 8,
398 ef_construction: 32,
399 metric: DistanceMetric::L2 as u8,
400 entry_point: Some(0),
401 max_layer: 0,
402 rng_state: 1,
403 node_vectors: vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![2.0, 0.0]],
404 node_neighbors: vec![vec![vec![1]], vec![vec![0, 2]], vec![vec![1]]],
405 node_deleted: vec![false; 3],
406 }
407 }
408
409 fn encode(snap: &HnswSnapshotRkyv) -> Vec<u8> {
410 let mut buf = super::HNSW_RKYV_MAGIC.to_vec();
411 buf.push(super::HNSW_FORMAT_VERSION);
412 buf.extend_from_slice(&rkyv::to_bytes::<rkyv::rancor::Error>(snap).unwrap());
413 buf
414 }
415
416 #[test]
417 fn a_valid_snapshot_restores() {
418 let idx = HnswIndex::from_checkpoint(&encode(&snapshot()))
419 .unwrap()
420 .unwrap();
421 assert_eq!(idx.search(&[2.0, 0.0], 1, 8).unwrap()[0].id, 2);
422 }
423
424 #[test]
426 fn a_corrupt_snapshot_is_a_typed_error() {
427 let mut wrong_dim = snapshot();
428 wrong_dim.node_vectors[1] = vec![1.0, 0.0, 0.0];
429 let mut short_flags = snapshot();
430 short_flags.node_deleted.pop();
431 let mut bad_neighbor = snapshot();
432 bad_neighbor.node_neighbors[2][0].push(9);
433 let mut bad_entry = snapshot();
434 bad_entry.entry_point = Some(3);
435 let mut bad_metric = snapshot();
436 bad_metric.metric = 200;
437
438 assert!(matches!(
439 HnswIndex::from_checkpoint(&encode(&wrong_dim)),
440 Err(VectorError::StoredDimensionMismatch {
441 expected: 2,
442 got: 3
443 })
444 ));
445 for (name, snap) in [
446 ("short flags", short_flags),
447 ("bad neighbor", bad_neighbor),
448 ("bad entry", bad_entry),
449 ("bad metric", bad_metric),
450 ] {
451 assert!(
452 matches!(
453 HnswIndex::from_checkpoint(&encode(&snap)),
454 Err(VectorError::CheckpointDeserializationError { .. })
455 ),
456 "{name} must fail the restore"
457 );
458 }
459 }
460}