Skip to main content

ipfrs_semantic/
persistence.rs

1//! HNSW index persistence
2//!
3//! Serializes HNSW vector indexes to content-addressed blocks and restores
4//! them on startup. Uses oxicode for binary serialization.
5
6use 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
13/// Snapshot version constant for forward-compatibility checks
14const SNAPSHOT_VERSION: u32 = 1;
15
16/// A serializable entry representing a single indexed vector
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct IndexEntry {
19    /// Internal node ID within the HNSW graph
20    pub id: u32,
21    /// CID string representation for the indexed content
22    pub cid: String,
23    /// The raw (un-normalized) feature vector
24    pub vector: Vec<f32>,
25    /// Maximum layer this node participates in
26    pub max_layer: usize,
27}
28
29/// Serializable snapshot of the complete HNSW index state
30///
31/// Captures every field needed to reconstruct an identical index at load time.
32/// The `layer_connections` tensor encodes the sparse adjacency lists of the
33/// multi-layer graph: `layer_connections[layer][node_id]` is the sorted list of
34/// neighbor IDs at that layer.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct IndexSnapshot {
37    /// Format version — must equal `SNAPSHOT_VERSION`
38    pub version: u32,
39    /// Vector dimension of every entry
40    pub dimension: usize,
41    /// efConstruction parameter used when building the index
42    pub ef_construction: usize,
43    /// Maximum number of bi-directional connections per layer (M parameter)
44    pub m: usize,
45    /// Indexed vectors in insertion order
46    pub entries: Vec<IndexEntry>,
47    /// Adjacency lists: `layer_connections[layer][node_id]` → neighbor IDs
48    pub layer_connections: Vec<Vec<Vec<u32>>>,
49    /// Map from CID string to arbitrary JSON metadata
50    pub metadata_map: HashMap<String, String>,
51    /// Unix timestamp (seconds) at snapshot creation
52    pub created_at: u64,
53    /// Node ID of the HNSW entry-point (top-layer entry node), if any
54    pub entry_point: Option<u32>,
55}
56
57impl IndexSnapshot {
58    /// Validate that the snapshot is internally consistent
59    ///
60    /// Returns an error string describing the first inconsistency found.
61    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
103/// Manages saving and loading [`IndexSnapshot`] files
104///
105/// # Example
106///
107/// ```rust,no_run
108/// use ipfrs_semantic::persistence::{IndexPersistence, IndexSnapshot};
109///
110/// let p = IndexPersistence::new("/path/to/my_index.snap");
111/// // p.save(&snapshot)?;
112/// // let snapshot = p.load()?;
113/// ```
114pub struct IndexPersistence {
115    path: std::path::PathBuf,
116}
117
118impl IndexPersistence {
119    /// Create a new `IndexPersistence` that reads/writes to `path`
120    pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
121        Self { path: path.into() }
122    }
123
124    /// Serialize `snapshot` and write it atomically to the configured path
125    ///
126    /// Uses a write-to-temp-then-rename strategy so partial writes never
127    /// corrupt an existing good snapshot.
128    pub fn save(&self, snapshot: &IndexSnapshot) -> Result<()> {
129        // Validate before serializing
130        if let Err(msg) = snapshot.validate() {
131            return Err(Error::InvalidInput(format!(
132                "snapshot validation failed: {}",
133                msg
134            )));
135        }
136
137        // Serialize with oxicode serde compat layer
138        let bytes = oxicode::serde::encode_to_vec(snapshot, oxicode::config::standard())
139            .map_err(|e| Error::Serialization(format!("oxicode encode failed: {}", e)))?;
140
141        // Ensure parent directory exists
142        if let Some(parent) = self.path.parent() {
143            std::fs::create_dir_all(parent).map_err(Error::Io)?;
144        }
145
146        // Write atomically via a sibling temp file
147        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    /// Deserialize and return the [`IndexSnapshot`] stored at the configured path
165    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        // Validate the loaded snapshot
180        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    /// Return `true` if the snapshot file exists on disk
194    pub fn exists(&self) -> bool {
195        self.path.exists()
196    }
197
198    /// Delete the snapshot file.  No-ops silently if the file is absent.
199    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    /// Return the configured snapshot path
208    pub fn path(&self) -> &Path {
209        &self.path
210    }
211
212    /// Smart save: use an incremental delta when fewer than 10 % of total
213    /// entries are dirty, otherwise fall back to a full snapshot.
214    ///
215    /// This is the preferred entry-point for `Node::stop` because it avoids
216    /// re-writing the entire index on every shutdown when only a handful of
217    /// embeddings have changed.
218    ///
219    /// After a successful write the tracker inside `index` is marked clean.
220    ///
221    /// # Decision rule
222    /// * If the index is empty → full snapshot (no-op, but valid state).
223    /// * If `dirty_count / total_count < 0.10` AND a full base snapshot
224    ///   already exists on disk → incremental delta.
225    /// * Otherwise → full snapshot.
226    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  // <10 % dirty
232            && self.exists(); // base snapshot must exist
233
234        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    /// Path for the incremental (delta) snapshot alongside the full snapshot
257    ///
258    /// For a base path of `foo/index.snap` this returns `foo/index.snap.delta`.
259    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    /// Serialize `snapshot` and write it as an incremental (delta) snapshot
274    ///
275    /// The delta file sits alongside the full snapshot at `<base>.delta`.
276    /// Uses the same atomic write-then-rename strategy as `save`.
277    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        // Ensure parent directory exists
286        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    /// Deserialize and return the [`IncrementalSnapshot`] stored at the delta path
310    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    /// Apply an incremental delta to an existing full [`IndexSnapshot`] in place
337    ///
338    /// Entries whose IDs appear in `delta.deleted_ids` are removed, then all
339    /// `delta.changed_entries` are upserted (replacing existing entries with
340    /// the same `id` or appending new ones).
341    pub fn apply_incremental(base: &mut IndexSnapshot, delta: &IncrementalSnapshot) -> Result<()> {
342        // Remove deleted entries
343        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        // Upsert changed entries
349        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
367// ═══════════════════════════════════════════════════════════════════════════
368// Incremental snapshot types
369// ═══════════════════════════════════════════════════════════════════════════
370
371/// Tracks which HNSW entries have been modified since the last full snapshot
372///
373/// The caller is responsible for calling `mark_dirty` whenever a vector is
374/// inserted, updated, or logically removed from the index.  After writing a
375/// full or incremental snapshot the caller should call `mark_clean` so the
376/// dirty set is cleared and the version counter is advanced.
377pub struct IncrementalTracker {
378    /// Entry IDs that have changed since the last snapshot
379    dirty_ids: HashSet<u32>,
380    /// Snapshot version counter — incremented on every `mark_clean` call
381    version: u64,
382    /// Wall-clock time of the last full snapshot (not the last incremental one)
383    last_full_snapshot: Option<std::time::SystemTime>,
384}
385
386impl IncrementalTracker {
387    /// Create a fresh tracker with no dirty entries and version 0
388    pub fn new() -> Self {
389        Self {
390            dirty_ids: HashSet::new(),
391            version: 0,
392            last_full_snapshot: None,
393        }
394    }
395
396    /// Record that entry `id` has been inserted or modified
397    pub fn mark_dirty(&mut self, id: u32) {
398        self.dirty_ids.insert(id);
399    }
400
401    /// Clear the dirty set and advance the version counter
402    ///
403    /// Should be called immediately after a snapshot (full or incremental) has
404    /// been successfully written to stable storage.
405    pub fn mark_clean(&mut self) {
406        self.dirty_ids.clear();
407        self.version = self.version.saturating_add(1);
408    }
409
410    /// Record that a full snapshot was taken at `time`
411    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    /// Return a reference to the current set of dirty entry IDs
417    pub fn dirty_ids(&self) -> &HashSet<u32> {
418        &self.dirty_ids
419    }
420
421    /// Return `true` if any entries have been modified since the last snapshot
422    pub fn is_dirty(&self) -> bool {
423        !self.dirty_ids.is_empty()
424    }
425
426    /// Number of entries that are currently dirty
427    pub fn dirty_count(&self) -> usize {
428        self.dirty_ids.len()
429    }
430
431    /// Current snapshot version (incremented after each `mark_clean`)
432    pub fn version(&self) -> u64 {
433        self.version
434    }
435
436    /// Timestamp of the last full snapshot, if any
437    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/// A lightweight delta snapshot: only the entries that changed since a base
449/// full snapshot was taken
450///
451/// To reconstruct the full state, load the base [`IndexSnapshot`] first and
452/// then call [`IndexPersistence::apply_incremental`].
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct IncrementalSnapshot {
455    /// Version of the base full snapshot this delta was derived from
456    pub base_version: u64,
457    /// Version assigned to this incremental snapshot
458    pub delta_version: u64,
459    /// Entries that were inserted or modified since the base snapshot
460    pub changed_entries: Vec<IndexEntry>,
461    /// IDs of entries that were logically deleted since the base snapshot
462    pub deleted_ids: Vec<u32>,
463    /// Unix timestamp (seconds) at the time this delta was created
464    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        // path does not exist yet
545        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        // cleanup
555        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]; // only 2 dims, but dimension = 4
597        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]; // no node 999
604        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); // duplicate — set semantics
657        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        // Second clean cycle
668        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                // Modify entry 0
717                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                // Append new entry 2
724                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], // remove entry 1
732            created_at: 2000,
733        };
734
735        IndexPersistence::apply_incremental(&mut base, &delta).expect("apply incremental");
736
737        // Entry 1 was deleted
738        assert!(base.entries.iter().all(|e| e.id != 1));
739        // Entry 0 was updated
740        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        // Entry 2 was added
744        assert!(base.entries.iter().any(|e| e.id == 2));
745        // Overall count: started with 2, deleted 1, added 1 new → still 2
746        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        // Should end with .snap.delta
754        assert!(
755            delta.to_string_lossy().ends_with(".snap.delta"),
756            "unexpected delta path: {}",
757            delta.display()
758        );
759        // Parent directory should be the same
760        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        // Should be clean and version bumped
773        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    /// An incremental snapshot built from a [`VectorIndex`] with 100 entries
796    /// and 5 dirty entries must contain exactly 5 changed entries.
797    #[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        // Build an index with 100 embeddings
806        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            // Create a unique valid CID using Block::new
812            // We'll construct CIDs from dummy bytes via ipfrs_core
813            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            // Build a simple unit vector with one hot at position i % DIM
818            let mut v = vec![0.0f32; DIM];
819            v[i % DIM] = 1.0 + (i as f32) * 0.001; // small variation to avoid duplicates
820            index.add_embedding(&cid, &v).expect("add_embedding");
821        }
822
823        // Simulate that the full snapshot was already taken: reset dirty set.
824        // (record_full_snapshot clears dirty_ids and advances version)
825        index.record_full_snapshot();
826
827        // Now "dirty" exactly 5 entries by inserting 5 new embeddings after the
828        // snapshot.  We cannot modify existing entries (VectorIndex does not
829        // support updates), so we use 5 new CIDs.
830        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        // Build incremental snapshot from the dirty tracker
848        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}