1use ipfrs_core::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use std::io::Write;
10use std::path::Path;
11use tracing::{debug, info, warn};
12
13const SNAPSHOT_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct IndexEntry {
19 pub id: u32,
21 pub cid: String,
23 pub vector: Vec<f32>,
25 pub max_layer: usize,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct IndexSnapshot {
37 pub version: u32,
39 pub dimension: usize,
41 pub ef_construction: usize,
43 pub m: usize,
45 pub entries: Vec<IndexEntry>,
47 pub layer_connections: Vec<Vec<Vec<u32>>>,
49 pub metadata_map: HashMap<String, String>,
51 pub created_at: u64,
53 pub entry_point: Option<u32>,
55}
56
57impl IndexSnapshot {
58 pub fn validate(&self) -> std::result::Result<(), String> {
62 if self.version != SNAPSHOT_VERSION {
63 return Err(format!(
64 "unsupported snapshot version {} (expected {})",
65 self.version, SNAPSHOT_VERSION
66 ));
67 }
68 if self.dimension == 0 {
69 return Err("dimension must be > 0".into());
70 }
71 for (idx, entry) in self.entries.iter().enumerate() {
72 if entry.vector.len() != self.dimension {
73 return Err(format!(
74 "entry {} has vector length {} but dimension is {}",
75 idx,
76 entry.vector.len(),
77 self.dimension
78 ));
79 }
80 }
81 let n = self.entries.len() as u32;
82 for (layer_idx, layer) in self.layer_connections.iter().enumerate() {
83 for (node_idx, neighbors) in layer.iter().enumerate() {
84 for &nb in neighbors {
85 if nb >= n {
86 return Err(format!(
87 "layer {} node {} has neighbor {} which is out of range (n={})",
88 layer_idx, node_idx, nb, n
89 ));
90 }
91 }
92 }
93 }
94 if let Some(ep) = self.entry_point {
95 if ep >= n {
96 return Err(format!("entry_point {} is out of range (n={})", ep, n));
97 }
98 }
99 Ok(())
100 }
101}
102
103pub struct IndexPersistence {
115 path: std::path::PathBuf,
116}
117
118impl IndexPersistence {
119 pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
121 Self { path: path.into() }
122 }
123
124 pub fn save(&self, snapshot: &IndexSnapshot) -> Result<()> {
129 if let Err(msg) = snapshot.validate() {
131 return Err(Error::InvalidInput(format!(
132 "snapshot validation failed: {}",
133 msg
134 )));
135 }
136
137 let bytes = oxicode::serde::encode_to_vec(snapshot, oxicode::config::standard())
139 .map_err(|e| Error::Serialization(format!("oxicode encode failed: {}", e)))?;
140
141 if let Some(parent) = self.path.parent() {
143 std::fs::create_dir_all(parent).map_err(Error::Io)?;
144 }
145
146 let tmp_path = self.path.with_extension("snap.tmp");
148 {
149 let mut f = std::fs::File::create(&tmp_path).map_err(Error::Io)?;
150 f.write_all(&bytes).map_err(Error::Io)?;
151 f.flush().map_err(Error::Io)?;
152 }
153 std::fs::rename(&tmp_path, &self.path).map_err(Error::Io)?;
154
155 info!(
156 path = %self.path.display(),
157 entries = snapshot.entries.len(),
158 bytes = bytes.len(),
159 "HNSW index snapshot saved"
160 );
161 Ok(())
162 }
163
164 pub fn load(&self) -> Result<IndexSnapshot> {
166 if !self.path.exists() {
167 return Err(Error::NotFound(format!(
168 "snapshot file not found: {}",
169 self.path.display()
170 )));
171 }
172
173 let bytes = std::fs::read(&self.path).map_err(Error::Io)?;
174
175 let (snapshot, _consumed): (IndexSnapshot, _) =
176 oxicode::serde::decode_from_slice(&bytes, oxicode::config::standard())
177 .map_err(|e| Error::Deserialization(format!("oxicode decode failed: {}", e)))?;
178
179 snapshot
181 .validate()
182 .map_err(|msg| Error::InvalidData(format!("loaded snapshot is corrupt: {}", msg)))?;
183
184 debug!(
185 path = %self.path.display(),
186 entries = snapshot.entries.len(),
187 dimension = snapshot.dimension,
188 "HNSW index snapshot loaded"
189 );
190 Ok(snapshot)
191 }
192
193 pub fn exists(&self) -> bool {
195 self.path.exists()
196 }
197
198 pub fn delete(&self) -> Result<()> {
200 if self.path.exists() {
201 std::fs::remove_file(&self.path).map_err(Error::Io)?;
202 warn!(path = %self.path.display(), "HNSW snapshot deleted");
203 }
204 Ok(())
205 }
206
207 pub fn path(&self) -> &Path {
209 &self.path
210 }
211
212 pub fn save_smart(&self, index: &crate::hnsw::VectorIndex) -> Result<()> {
227 let total = index.len();
228 let dirty = index.dirty_count();
229
230 let use_incremental = total > 0
231 && dirty < total / 10 && self.exists(); if use_incremental {
235 let base_version = index.tracker_version();
236 let delta = index.snapshot_incremental(base_version)?;
237 self.save_incremental(&delta)?;
238 index.mark_tracker_clean();
239 info!(
240 dirty,
241 total,
242 base_version,
243 delta_version = delta.delta_version,
244 "HNSW index saved as incremental delta"
245 );
246 } else {
247 let snap = index.snapshot()?;
248 self.save(&snap)?;
249 index.record_full_snapshot();
250 debug!(entries = total, "HNSW index saved as full snapshot");
251 }
252
253 Ok(())
254 }
255
256 pub fn incremental_path(&self) -> std::path::PathBuf {
260 let mut p = self.path.clone();
261 let ext = p
262 .extension()
263 .map(|e| {
264 let mut s = e.to_os_string();
265 s.push(".delta");
266 s
267 })
268 .unwrap_or_else(|| std::ffi::OsString::from("snap.delta"));
269 p.set_extension(ext);
270 p
271 }
272
273 pub fn save_incremental(&self, snapshot: &IncrementalSnapshot) -> Result<()> {
278 let delta_path = self.incremental_path();
279
280 let bytes =
281 oxicode::serde::encode_to_vec(snapshot, oxicode::config::standard()).map_err(|e| {
282 Error::Serialization(format!("oxicode encode incremental failed: {}", e))
283 })?;
284
285 if let Some(parent) = delta_path.parent() {
287 std::fs::create_dir_all(parent).map_err(Error::Io)?;
288 }
289
290 let tmp_path = delta_path.with_extension("delta.tmp");
291 {
292 let mut f = std::fs::File::create(&tmp_path).map_err(Error::Io)?;
293 f.write_all(&bytes).map_err(Error::Io)?;
294 f.flush().map_err(Error::Io)?;
295 }
296 std::fs::rename(&tmp_path, &delta_path).map_err(Error::Io)?;
297
298 info!(
299 path = %delta_path.display(),
300 changed = snapshot.changed_entries.len(),
301 deleted = snapshot.deleted_ids.len(),
302 base_version = snapshot.base_version,
303 delta_version = snapshot.delta_version,
304 "Incremental HNSW snapshot saved"
305 );
306 Ok(())
307 }
308
309 pub fn load_incremental(&self) -> Result<IncrementalSnapshot> {
311 let delta_path = self.incremental_path();
312
313 if !delta_path.exists() {
314 return Err(Error::NotFound(format!(
315 "incremental snapshot file not found: {}",
316 delta_path.display()
317 )));
318 }
319
320 let bytes = std::fs::read(&delta_path).map_err(Error::Io)?;
321
322 let (snapshot, _consumed): (IncrementalSnapshot, _) =
323 oxicode::serde::decode_from_slice(&bytes, oxicode::config::standard()).map_err(
324 |e| Error::Deserialization(format!("oxicode decode incremental failed: {}", e)),
325 )?;
326
327 debug!(
328 path = %delta_path.display(),
329 changed = snapshot.changed_entries.len(),
330 deleted = snapshot.deleted_ids.len(),
331 "Incremental HNSW snapshot loaded"
332 );
333 Ok(snapshot)
334 }
335
336 pub fn apply_incremental(base: &mut IndexSnapshot, delta: &IncrementalSnapshot) -> Result<()> {
342 if !delta.deleted_ids.is_empty() {
344 let deleted_set: HashSet<u32> = delta.deleted_ids.iter().copied().collect();
345 base.entries.retain(|e| !deleted_set.contains(&e.id));
346 }
347
348 for changed in &delta.changed_entries {
350 if let Some(existing) = base.entries.iter_mut().find(|e| e.id == changed.id) {
351 *existing = changed.clone();
352 } else {
353 base.entries.push(changed.clone());
354 }
355 }
356
357 debug!(
358 base_version = delta.base_version,
359 delta_version = delta.delta_version,
360 entries_after = base.entries.len(),
361 "Applied incremental snapshot delta"
362 );
363 Ok(())
364 }
365}
366
367pub struct IncrementalTracker {
378 dirty_ids: HashSet<u32>,
380 version: u64,
382 last_full_snapshot: Option<std::time::SystemTime>,
384}
385
386impl IncrementalTracker {
387 pub fn new() -> Self {
389 Self {
390 dirty_ids: HashSet::new(),
391 version: 0,
392 last_full_snapshot: None,
393 }
394 }
395
396 pub fn mark_dirty(&mut self, id: u32) {
398 self.dirty_ids.insert(id);
399 }
400
401 pub fn mark_clean(&mut self) {
406 self.dirty_ids.clear();
407 self.version = self.version.saturating_add(1);
408 }
409
410 pub fn record_full_snapshot(&mut self, time: std::time::SystemTime) {
412 self.last_full_snapshot = Some(time);
413 self.mark_clean();
414 }
415
416 pub fn dirty_ids(&self) -> &HashSet<u32> {
418 &self.dirty_ids
419 }
420
421 pub fn is_dirty(&self) -> bool {
423 !self.dirty_ids.is_empty()
424 }
425
426 pub fn dirty_count(&self) -> usize {
428 self.dirty_ids.len()
429 }
430
431 pub fn version(&self) -> u64 {
433 self.version
434 }
435
436 pub fn last_full_snapshot(&self) -> Option<std::time::SystemTime> {
438 self.last_full_snapshot
439 }
440}
441
442impl Default for IncrementalTracker {
443 fn default() -> Self {
444 Self::new()
445 }
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct IncrementalSnapshot {
455 pub base_version: u64,
457 pub delta_version: u64,
459 pub changed_entries: Vec<IndexEntry>,
461 pub deleted_ids: Vec<u32>,
463 pub created_at: u64,
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 fn make_snapshot() -> IndexSnapshot {
472 IndexSnapshot {
473 version: 1,
474 dimension: 4,
475 ef_construction: 200,
476 m: 16,
477 entries: vec![
478 IndexEntry {
479 id: 0,
480 cid: "test_cid_0".to_string(),
481 vector: vec![1.0, 0.0, 0.0, 0.0],
482 max_layer: 0,
483 },
484 IndexEntry {
485 id: 1,
486 cid: "test_cid_1".to_string(),
487 vector: vec![0.0, 1.0, 0.0, 0.0],
488 max_layer: 0,
489 },
490 ],
491 layer_connections: vec![vec![vec![1], vec![0]]],
492 metadata_map: HashMap::new(),
493 created_at: 12345,
494 entry_point: Some(0),
495 }
496 }
497
498 fn temp_snap_path(tag: &str) -> std::path::PathBuf {
499 let nanos = std::time::SystemTime::now()
500 .duration_since(std::time::UNIX_EPOCH)
501 .map(|d| d.subsec_nanos())
502 .unwrap_or(0);
503 std::env::temp_dir().join(format!("ipfrs_hnsw_test_{}_{}", tag, nanos))
504 }
505
506 #[test]
507 fn test_snapshot_save_load_roundtrip() {
508 let dir = temp_snap_path("roundtrip");
509 std::fs::create_dir_all(&dir).expect("create temp dir");
510
511 let persistence = IndexPersistence::new(dir.join("index.snap"));
512 let snapshot = make_snapshot();
513
514 persistence.save(&snapshot).expect("save");
515 assert!(persistence.exists(), "file must exist after save");
516
517 let loaded = persistence.load().expect("load");
518 assert_eq!(loaded.version, 1);
519 assert_eq!(loaded.dimension, 4);
520 assert_eq!(loaded.ef_construction, 200);
521 assert_eq!(loaded.m, 16);
522 assert_eq!(loaded.entries.len(), 2);
523 assert_eq!(loaded.entries[0].cid, "test_cid_0");
524 assert_eq!(loaded.entries[0].vector, vec![1.0f32, 0.0, 0.0, 0.0]);
525 assert_eq!(loaded.entries[1].cid, "test_cid_1");
526 assert_eq!(loaded.entries[1].vector, vec![0.0f32, 1.0, 0.0, 0.0]);
527 assert_eq!(loaded.entry_point, Some(0));
528 assert_eq!(loaded.created_at, 12345);
529
530 persistence.delete().expect("delete");
531 assert!(!persistence.exists(), "file must be gone after delete");
532 }
533
534 #[test]
535 fn test_persistence_not_found() {
536 let persistence = IndexPersistence::new("/nonexistent/path/index.snap");
537 assert!(!persistence.exists());
538 assert!(persistence.load().is_err());
539 }
540
541 #[test]
542 fn test_save_creates_parent_dirs() {
543 let base = temp_snap_path("mkdir");
544 let nested = base.join("a").join("b").join("c").join("index.snap");
546 let persistence = IndexPersistence::new(&nested);
547
548 let snapshot = make_snapshot();
549 persistence
550 .save(&snapshot)
551 .expect("save should create parent dirs");
552 assert!(persistence.exists());
553
554 let _ = persistence.delete();
556 let _ = std::fs::remove_dir_all(&base);
557 }
558
559 #[test]
560 fn test_overwrite_is_atomic() {
561 let dir = temp_snap_path("atomic");
562 std::fs::create_dir_all(&dir).expect("create temp dir");
563 let persistence = IndexPersistence::new(dir.join("index.snap"));
564
565 let snap_a = make_snapshot();
566 let mut snap_b = make_snapshot();
567 snap_b.created_at = 99999;
568 snap_b.entries.push(IndexEntry {
569 id: 2,
570 cid: "test_cid_2".to_string(),
571 vector: vec![0.0, 0.0, 1.0, 0.0],
572 max_layer: 1,
573 });
574
575 persistence.save(&snap_a).expect("first save");
576 persistence.save(&snap_b).expect("second save");
577
578 let loaded = persistence.load().expect("load");
579 assert_eq!(loaded.created_at, 99999);
580 assert_eq!(loaded.entries.len(), 3);
581
582 let _ = persistence.delete();
583 let _ = std::fs::remove_dir_all(&dir);
584 }
585
586 #[test]
587 fn test_snapshot_validate_version_mismatch() {
588 let mut snap = make_snapshot();
589 snap.version = 99;
590 assert!(snap.validate().is_err());
591 }
592
593 #[test]
594 fn test_snapshot_validate_dimension_mismatch() {
595 let mut snap = make_snapshot();
596 snap.entries[0].vector = vec![1.0, 2.0]; assert!(snap.validate().is_err());
598 }
599
600 #[test]
601 fn test_snapshot_validate_out_of_range_neighbor() {
602 let mut snap = make_snapshot();
603 snap.layer_connections[0][0] = vec![999]; assert!(snap.validate().is_err());
605 }
606}
607
608#[cfg(test)]
609mod incremental_tests {
610 use super::*;
611
612 fn temp_dir(tag: &str) -> std::path::PathBuf {
613 let nanos = std::time::SystemTime::now()
614 .duration_since(std::time::UNIX_EPOCH)
615 .map(|d| d.subsec_nanos())
616 .unwrap_or(0);
617 std::env::temp_dir().join(format!("ipfrs_incr_test_{}_{}", tag, nanos))
618 }
619
620 fn make_base_snapshot() -> IndexSnapshot {
621 IndexSnapshot {
622 version: 1,
623 dimension: 3,
624 ef_construction: 100,
625 m: 8,
626 entries: vec![
627 IndexEntry {
628 id: 0,
629 cid: "cid0".to_string(),
630 vector: vec![1.0, 0.0, 0.0],
631 max_layer: 0,
632 },
633 IndexEntry {
634 id: 1,
635 cid: "cid1".to_string(),
636 vector: vec![0.0, 1.0, 0.0],
637 max_layer: 0,
638 },
639 ],
640 layer_connections: vec![vec![vec![1], vec![0]]],
641 metadata_map: HashMap::new(),
642 created_at: 1000,
643 entry_point: Some(0),
644 }
645 }
646
647 #[test]
648 fn test_incremental_tracker_dirty_tracking() {
649 let mut tracker = IncrementalTracker::new();
650 assert!(!tracker.is_dirty());
651 assert_eq!(tracker.dirty_count(), 0);
652 assert_eq!(tracker.version(), 0);
653
654 tracker.mark_dirty(5);
655 tracker.mark_dirty(10);
656 tracker.mark_dirty(5); assert!(tracker.is_dirty());
658 assert_eq!(tracker.dirty_count(), 2);
659 assert!(tracker.dirty_ids().contains(&5));
660 assert!(tracker.dirty_ids().contains(&10));
661
662 tracker.mark_clean();
663 assert!(!tracker.is_dirty());
664 assert_eq!(tracker.dirty_count(), 0);
665 assert_eq!(tracker.version(), 1);
666
667 tracker.mark_dirty(99);
669 assert_eq!(tracker.dirty_count(), 1);
670 tracker.mark_clean();
671 assert_eq!(tracker.version(), 2);
672 }
673
674 #[test]
675 fn test_incremental_snapshot_save_load() {
676 let dir = temp_dir("save_load");
677 std::fs::create_dir_all(&dir).expect("create temp dir");
678 let persistence = IndexPersistence::new(dir.join("index.snap"));
679
680 let delta = IncrementalSnapshot {
681 base_version: 3,
682 delta_version: 4,
683 changed_entries: vec![IndexEntry {
684 id: 7,
685 cid: "cid7".to_string(),
686 vector: vec![0.5, 0.5, 0.0],
687 max_layer: 1,
688 }],
689 deleted_ids: vec![2, 3],
690 created_at: 9999,
691 };
692
693 persistence
694 .save_incremental(&delta)
695 .expect("save incremental");
696
697 let loaded = persistence.load_incremental().expect("load incremental");
698 assert_eq!(loaded.base_version, 3);
699 assert_eq!(loaded.delta_version, 4);
700 assert_eq!(loaded.changed_entries.len(), 1);
701 assert_eq!(loaded.changed_entries[0].id, 7);
702 assert_eq!(loaded.deleted_ids, vec![2, 3]);
703 assert_eq!(loaded.created_at, 9999);
704
705 let _ = std::fs::remove_dir_all(&dir);
706 }
707
708 #[test]
709 fn test_apply_incremental_to_base() {
710 let mut base = make_base_snapshot();
711
712 let delta = IncrementalSnapshot {
713 base_version: 0,
714 delta_version: 1,
715 changed_entries: vec![
716 IndexEntry {
718 id: 0,
719 cid: "cid0_v2".to_string(),
720 vector: vec![0.9, 0.1, 0.0],
721 max_layer: 0,
722 },
723 IndexEntry {
725 id: 2,
726 cid: "cid2".to_string(),
727 vector: vec![0.0, 0.0, 1.0],
728 max_layer: 0,
729 },
730 ],
731 deleted_ids: vec![1], created_at: 2000,
733 };
734
735 IndexPersistence::apply_incremental(&mut base, &delta).expect("apply incremental");
736
737 assert!(base.entries.iter().all(|e| e.id != 1));
739 let e0 = base.entries.iter().find(|e| e.id == 0).expect("entry 0");
741 assert_eq!(e0.cid, "cid0_v2");
742 assert_eq!(e0.vector, vec![0.9f32, 0.1, 0.0]);
743 assert!(base.entries.iter().any(|e| e.id == 2));
745 assert_eq!(base.entries.len(), 2);
747 }
748
749 #[test]
750 fn test_incremental_path_naming() {
751 let p = IndexPersistence::new("/some/dir/index.snap");
752 let delta = p.incremental_path();
753 assert!(
755 delta.to_string_lossy().ends_with(".snap.delta"),
756 "unexpected delta path: {}",
757 delta.display()
758 );
759 assert_eq!(delta.parent(), std::path::Path::new("/some/dir").into());
761 }
762
763 #[test]
764 fn test_incremental_tracker_record_full_snapshot() {
765 let mut tracker = IncrementalTracker::new();
766 tracker.mark_dirty(1);
767 tracker.mark_dirty(2);
768
769 let now = std::time::SystemTime::now();
770 tracker.record_full_snapshot(now);
771
772 assert!(!tracker.is_dirty());
774 assert_eq!(tracker.version(), 1);
775 assert!(tracker.last_full_snapshot().is_some());
776 }
777
778 #[test]
779 fn test_apply_incremental_empty_delta() {
780 let mut base = make_base_snapshot();
781 let original_len = base.entries.len();
782
783 let delta = IncrementalSnapshot {
784 base_version: 0,
785 delta_version: 1,
786 changed_entries: vec![],
787 deleted_ids: vec![],
788 created_at: 0,
789 };
790
791 IndexPersistence::apply_incremental(&mut base, &delta).expect("apply empty delta");
792 assert_eq!(base.entries.len(), original_len);
793 }
794
795 #[test]
798 fn test_incremental_snapshot_only_dirty() {
799 use crate::hnsw::{DistanceMetric, VectorIndex};
800
801 const DIM: usize = 8;
802 const TOTAL: usize = 100;
803 const DIRTY_COUNT: usize = 5;
804
805 let mut index =
807 VectorIndex::new(DIM, DistanceMetric::L2, 8, 50).expect("create VectorIndex");
808
809 let mut cids = Vec::with_capacity(TOTAL);
810 for i in 0..TOTAL {
811 let data = bytes::Bytes::from(format!("embed-data-{}", i));
814 let block = ipfrs_core::Block::new(data).expect("create block");
815 let cid = *block.cid();
816 cids.push(cid);
817 let mut v = vec![0.0f32; DIM];
819 v[i % DIM] = 1.0 + (i as f32) * 0.001; index.add_embedding(&cid, &v).expect("add_embedding");
821 }
822
823 index.record_full_snapshot();
826
827 let dirty_start = TOTAL;
831 for i in dirty_start..(dirty_start + DIRTY_COUNT) {
832 let data = bytes::Bytes::from(format!("new-embed-{}", i));
833 let block = ipfrs_core::Block::new(data).expect("create block");
834 let cid = *block.cid();
835 let mut v = vec![0.0f32; DIM];
836 v[i % DIM] = 2.0 + (i as f32) * 0.001;
837 index.add_embedding(&cid, &v).expect("add_embedding dirty");
838 }
839
840 assert_eq!(
841 index.dirty_count(),
842 DIRTY_COUNT,
843 "expected exactly {} dirty entries after insertions",
844 DIRTY_COUNT
845 );
846
847 let base_version = index.tracker_version();
849 let delta = index
850 .snapshot_incremental(base_version)
851 .expect("snapshot_incremental");
852
853 assert_eq!(
854 delta.changed_entries.len(),
855 DIRTY_COUNT,
856 "incremental delta must contain exactly {} changed entries",
857 DIRTY_COUNT
858 );
859 assert!(
860 delta.deleted_ids.is_empty(),
861 "no deletions should be recorded"
862 );
863 }
864}