Skip to main content

sparse_vector/
handle.rs

1//! Sparse vector index handle with mmap persistence.
2//!
3//! Commit writes a flat binary mmap format (sparse.mmap) + bincode side files.
4//! Open mmap's the posting data (O(1)), vectors + dims loaded lazily.
5//! Search uses mmap iterators when available (no RAM postings or vectors needed).
6//! Mutations load postings + vectors into RAM on first access, set dirty flag.
7//!
8//! Two storage modes:
9//! - **Filesystem** (`create`/`open`): files live directly in the given directory.
10//! - **BlobStore** (`create_with_store`/`open_with_store`): source of truth is the
11//!   BlobStore; a local tmpdir is used as mmap cache. Cleaned up on Drop.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::{Arc, Mutex};
17
18use crate::blob_store::BlobStore;
19use crate::index::{SparseIndex, SparseVector};
20use crate::mmap_index::{self, MmapPostingData};
21use crate::wand::Postings;
22
23const MMAP_FILE: &str = "sparse.mmap";
24const VECTORS_FILE: &str = "sparse_vectors.bin";
25const DIMS_FILE: &str = "sparse_dims.bin";
26/// Legacy bincode file (read-only fallback).
27const LEGACY_FILE: &str = "sparse.bin";
28
29/// Files that make up a sparse index (new format).
30const INDEX_FILES: &[&str] = &[MMAP_FILE, VECTORS_FILE, DIMS_FILE];
31
32/// BlobStore key prefix — ensures no collision with other index types (FTS, etc.)
33const BLOB_PREFIX: &str = "Sparse_";
34
35/// Monotonic counter for unique tmpdir names.
36static CACHE_SEQ: AtomicUsize = AtomicUsize::new(0);
37
38/// Storage backend for persistence.
39enum StorageBackend {
40    /// Files live directly in `path`. No external store.
41    Filesystem,
42    /// Source of truth is a BlobStore. `path` is a local tmpdir cache for mmap.
43    Store {
44        store: Arc<dyn BlobStore>,
45        index_name: String,
46    },
47}
48
49struct Inner {
50    index: SparseIndex,
51    mmap: Option<MmapPostingData>,
52    /// True if RAM postings are loaded (always true after create or mutation).
53    postings_loaded: bool,
54    /// True if vectors HashMap is loaded (always true after create or mutation).
55    vectors_loaded: bool,
56    /// Cached doc count (valid even when vectors not loaded).
57    num_vectors: usize,
58    dirty: bool,
59}
60
61pub struct SparseHandle {
62    inner: Mutex<Inner>,
63    path: PathBuf,
64    backend: StorageBackend,
65}
66
67impl SparseHandle {
68    // -----------------------------------------------------------------------
69    // Filesystem lifecycle (existing API, unchanged behavior)
70    // -----------------------------------------------------------------------
71
72    /// Create a new empty sparse index at the given path.
73    pub fn create(path: &str) -> Result<Self, String> {
74        std::fs::create_dir_all(Path::new(path))
75            .map_err(|e| format!("cannot create directory {path}: {e}"))?;
76        let handle = Self {
77            inner: Mutex::new(Inner {
78                index: SparseIndex::new(),
79                mmap: None,
80                postings_loaded: true,
81                vectors_loaded: true,
82                num_vectors: 0,
83                dirty: false,
84            }),
85            path: PathBuf::from(path),
86            backend: StorageBackend::Filesystem,
87        };
88        handle.commit_inner()?;
89        Ok(handle)
90    }
91
92    /// Open an existing sparse index.
93    /// Tries new mmap format first, falls back to legacy bincode.
94    pub fn open(path: &str) -> Result<Self, String> {
95        let base = Path::new(path);
96        let mmap_path = base.join(MMAP_FILE);
97
98        if mmap_path.exists() {
99            Self::open_mmap(base, StorageBackend::Filesystem)
100        } else {
101            Self::open_legacy(base)
102        }
103    }
104
105    // -----------------------------------------------------------------------
106    // BlobStore lifecycle
107    // -----------------------------------------------------------------------
108
109    /// Create a new empty sparse index backed by a BlobStore.
110    ///
111    /// `cache_base` is the root directory for mmap caches. Inside it, a unique
112    /// subdirectory `{pid}/{index_name}_{seq}` is created automatically.
113    /// Source of truth is the store.
114    pub fn create_with_store(
115        store: Arc<dyn BlobStore>,
116        index_name: &str,
117        cache_base: &Path,
118    ) -> Result<Self, String> {
119        let blob_name = format!("{BLOB_PREFIX}{index_name}");
120        let cache_dir = Self::make_cache_dir(cache_base, &blob_name)?;
121
122        let handle = Self {
123            inner: Mutex::new(Inner {
124                index: SparseIndex::new(),
125                mmap: None,
126                postings_loaded: true,
127                vectors_loaded: true,
128                num_vectors: 0,
129                dirty: false,
130            }),
131            path: cache_dir,
132            backend: StorageBackend::Store {
133                store,
134                index_name: blob_name,
135            },
136        };
137        handle.commit_inner()?;
138        Ok(handle)
139    }
140
141    /// Open an existing sparse index from a BlobStore.
142    ///
143    /// `cache_base` is the root directory for mmap caches. Blobs are materialized
144    /// from the store into `{cache_base}/{pid}/{index_name}_{seq}/`, then mmap'd.
145    pub fn open_with_store(
146        store: Arc<dyn BlobStore>,
147        index_name: &str,
148        cache_base: &Path,
149    ) -> Result<Self, String> {
150        let blob_name = format!("{BLOB_PREFIX}{index_name}");
151        let cache_dir = Self::make_cache_dir(cache_base, &blob_name)?;
152
153        // Materialize all blobs from store to cache_dir
154        let files = store
155            .list(&blob_name)
156            .map_err(|e| format!("cannot list blobs for {blob_name}: {e}"))?;
157
158        for file_name in &files {
159            let data = store
160                .load(&blob_name, file_name)
161                .map_err(|e| format!("cannot load {blob_name}/{file_name}: {e}"))?;
162            std::fs::write(cache_dir.join(file_name), data)
163                .map_err(|e| format!("cannot write cache {file_name}: {e}"))?;
164        }
165
166        let backend = StorageBackend::Store {
167            store,
168            index_name: blob_name,
169        };
170
171        // Open from cache_dir (same logic as filesystem open)
172        if cache_dir.join(MMAP_FILE).exists() {
173            Self::open_mmap(&cache_dir, backend)
174        } else {
175            // Empty index (no files in store yet) — create fresh
176            let handle = Self {
177                inner: Mutex::new(Inner {
178                    index: SparseIndex::new(),
179                    mmap: None,
180                    postings_loaded: true,
181                    vectors_loaded: true,
182                    num_vectors: 0,
183                    dirty: false,
184                }),
185                path: cache_dir,
186                backend,
187            };
188            handle.commit_inner()?;
189            Ok(handle)
190        }
191    }
192
193    /// Create a unique cache directory for BlobStore mmap files.
194    ///
195    /// Layout: `{base}/{pid}/{index_name}_{seq}/`
196    /// - PID isolates between processes
197    /// - Atomic seq isolates between threads / multiple opens
198    fn make_cache_dir(base: &Path, index_name: &str) -> Result<PathBuf, String> {
199        let seq = CACHE_SEQ.fetch_add(1, Ordering::Relaxed);
200        let pid = std::process::id();
201        let dir = base
202            .join(format!("{pid}"))
203            .join(format!("{index_name}_{seq}"));
204        std::fs::create_dir_all(&dir)
205            .map_err(|e| format!("cannot create cache dir {}: {e}", dir.display()))?;
206        Ok(dir)
207    }
208
209    // -----------------------------------------------------------------------
210    // Shared open helpers
211    // -----------------------------------------------------------------------
212
213    /// Open using the new mmap format.
214    /// Only mmap + dims are loaded. Postings and vectors are lazy.
215    fn open_mmap(base: &Path, backend: StorageBackend) -> Result<Self, String> {
216        let mmap = MmapPostingData::open(&base.join(MMAP_FILE))?;
217
218        // Deserialize dimension mapping (small file, needed for search routing)
219        let dims_data = std::fs::read(base.join(DIMS_FILE))
220            .map_err(|e| format!("cannot read {DIMS_FILE}: {e}"))?;
221        let (dim_map, dim_reverse): (HashMap<u32, usize>, Vec<u32>) =
222            bincode::deserialize(&dims_data)
223                .map_err(|e| format!("cannot deserialize dims: {e}"))?;
224
225        let num_dims = mmap.num_dims();
226        let num_vectors = mmap.num_vectors();
227        let empty_postings: Vec<Postings> = (0..num_dims).map(|_| Postings::new()).collect();
228        // Empty vectors — will be loaded lazily on first mutation
229        let index =
230            SparseIndex::from_parts(dim_map, dim_reverse, empty_postings, HashMap::new());
231
232        Ok(Self {
233            inner: Mutex::new(Inner {
234                index,
235                mmap: Some(mmap),
236                postings_loaded: false,
237                vectors_loaded: false,
238                num_vectors,
239                dirty: false,
240            }),
241            path: base.to_path_buf(),
242            backend,
243        })
244    }
245
246    /// Open using legacy bincode format (sparse.bin).
247    fn open_legacy(base: &Path) -> Result<Self, String> {
248        let data_path = base.join(LEGACY_FILE);
249        let data = std::fs::read(&data_path)
250            .map_err(|e| format!("cannot read {}: {e}", data_path.display()))?;
251        let index: SparseIndex = bincode::deserialize(&data)
252            .map_err(|e| format!("cannot deserialize sparse index: {e}"))?;
253        let num_vectors = index.len();
254        Ok(Self {
255            inner: Mutex::new(Inner {
256                index,
257                mmap: None,
258                postings_loaded: true,
259                vectors_loaded: true,
260                num_vectors,
261                dirty: false,
262            }),
263            path: base.to_path_buf(),
264            backend: StorageBackend::Filesystem,
265        })
266    }
267
268    // -----------------------------------------------------------------------
269    // Lazy loading
270    // -----------------------------------------------------------------------
271
272    /// Ensure RAM postings are loaded (materializes from mmap if needed).
273    fn ensure_postings_loaded(inner: &mut Inner) {
274        if inner.postings_loaded {
275            return;
276        }
277        if let Some(ref mmap) = inner.mmap {
278            let postings = inner.index.postings_mut();
279            for (i, pl) in postings.iter_mut().enumerate() {
280                *pl = mmap.load_postings(i);
281            }
282        }
283        inner.postings_loaded = true;
284    }
285
286    /// Ensure vectors HashMap is loaded (deserializes from disk if needed).
287    fn ensure_vectors_loaded(inner: &mut Inner, path: &Path) -> Result<(), String> {
288        if inner.vectors_loaded {
289            return Ok(());
290        }
291        let vectors_path = path.join(VECTORS_FILE);
292        let data = std::fs::read(&vectors_path)
293            .map_err(|e| format!("cannot read {}: {e}", vectors_path.display()))?;
294        let vectors: HashMap<u64, SparseVector> = bincode::deserialize(&data)
295            .map_err(|e| format!("cannot deserialize vectors: {e}"))?;
296        inner.index.set_vectors(vectors);
297        inner.vectors_loaded = true;
298        Ok(())
299    }
300
301    // -----------------------------------------------------------------------
302    // Public API (called from bridge)
303    // -----------------------------------------------------------------------
304
305    pub fn insert(&self, node_id: u64, vector: &SparseVector) -> Result<(), String> {
306        let mut inner = self.inner.lock().map_err(|_| "lock poisoned".to_string())?;
307        Self::ensure_vectors_loaded(&mut inner, &self.path)?;
308        Self::ensure_postings_loaded(&mut inner);
309        inner.index.insert(node_id, vector);
310        inner.num_vectors = inner.index.len();
311        inner.dirty = true;
312        Ok(())
313    }
314
315    pub fn remove(&self, node_id: u64) -> Result<bool, String> {
316        let mut inner = self.inner.lock().map_err(|_| "lock poisoned".to_string())?;
317        Self::ensure_vectors_loaded(&mut inner, &self.path)?;
318        Self::ensure_postings_loaded(&mut inner);
319        let removed = inner.index.remove(node_id);
320        if removed {
321            inner.num_vectors = inner.index.len();
322            inner.dirty = true;
323        }
324        Ok(removed)
325    }
326
327    pub fn search(&self, query: &SparseVector, limit: usize) -> Vec<(u64, f32)> {
328        let inner = self.inner.lock().unwrap();
329        if !inner.dirty {
330            if let Some(ref mmap) = inner.mmap {
331                return mmap_index::search_mmap(
332                    mmap,
333                    inner.index.dim_map(),
334                    query,
335                    limit,
336                    &|_| true,
337                );
338            }
339        }
340        inner.index.search(query, limit)
341    }
342
343    pub fn search_filtered(
344        &self,
345        query: &SparseVector,
346        limit: usize,
347        allowed_ids: &[u64],
348    ) -> Vec<(u64, f32)> {
349        let inner = self.inner.lock().unwrap();
350        if !inner.dirty {
351            if let Some(ref mmap) = inner.mmap {
352                return mmap_index::search_mmap_allowed(
353                    mmap,
354                    inner.index.dim_map(),
355                    query,
356                    limit,
357                    allowed_ids,
358                );
359            }
360        }
361        inner.index.search_filtered(query, limit, allowed_ids)
362    }
363
364    pub fn len(&self) -> usize {
365        self.inner.lock().unwrap().num_vectors
366    }
367
368    pub fn is_empty(&self) -> bool {
369        self.len() == 0
370    }
371
372    /// Write index to disk in the new mmap format, then re-mmap.
373    /// If store-backed, also persists to BlobStore.
374    pub fn commit_inner(&self) -> Result<(), String> {
375        let mut inner = self.inner.lock().map_err(|_| "lock poisoned".to_string())?;
376
377        // If postings not loaded and not dirty, nothing to write (already on disk)
378        // But we still need to write on create (postings_loaded=true, dirty=false, mmap=None)
379        if !inner.postings_loaded && !inner.dirty && inner.mmap.is_some() {
380            return Ok(());
381        }
382
383        Self::ensure_postings_loaded(&mut inner);
384        Self::ensure_vectors_loaded(&mut inner, &self.path)?;
385
386        // Write sparse.mmap to local cache
387        mmap_index::write_mmap_file(
388            &self.path.join(MMAP_FILE),
389            inner.index.postings(),
390            inner.num_vectors as u32,
391        )?;
392
393        // Serialize vectors + dims
394        let vectors_data = bincode::serialize(inner.index.vectors())
395            .map_err(|e| format!("cannot serialize vectors: {e}"))?;
396        std::fs::write(self.path.join(VECTORS_FILE), &vectors_data)
397            .map_err(|e| format!("cannot write {VECTORS_FILE}: {e}"))?;
398
399        let dims_data =
400            bincode::serialize(&(inner.index.dim_map(), inner.index.dim_reverse()))
401                .map_err(|e| format!("cannot serialize dims: {e}"))?;
402        std::fs::write(self.path.join(DIMS_FILE), &dims_data)
403            .map_err(|e| format!("cannot write {DIMS_FILE}: {e}"))?;
404
405        // Sync to BlobStore if store-backed
406        if let StorageBackend::Store { ref store, ref index_name } = self.backend {
407            for &file in INDEX_FILES {
408                let data = std::fs::read(self.path.join(file))
409                    .map_err(|e| format!("cannot read cache {file}: {e}"))?;
410                store
411                    .save(index_name, file, &data)
412                    .map_err(|e| format!("cannot save {index_name}/{file} to store: {e}"))?;
413            }
414        }
415
416        // Re-mmap and reset to lazy state
417        let mmap = MmapPostingData::open(&self.path.join(MMAP_FILE))?;
418        inner.mmap = Some(mmap);
419        inner.dirty = false;
420        // Note: postings_loaded and vectors_loaded stay true — data is still in RAM.
421        // They'll be reset on next open().
422
423        // Remove legacy file if present
424        let legacy = self.path.join(LEGACY_FILE);
425        if legacy.exists() {
426            let _ = std::fs::remove_file(&legacy);
427            // Also remove from store if store-backed
428            if let StorageBackend::Store { ref store, ref index_name } = self.backend {
429                let _ = store.delete(index_name, LEGACY_FILE);
430            }
431        }
432
433        Ok(())
434    }
435}
436
437impl Drop for SparseHandle {
438    fn drop(&mut self) {
439        // Only clean up cache_dir for store-backed handles (tmpdir we created).
440        // Filesystem handles use the user's data directory — never delete it.
441        if let StorageBackend::Store { .. } = &self.backend {
442            let _ = std::fs::remove_dir_all(&self.path);
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::blob_store::MemBlobStore;
451
452    fn tmp_path(name: &str) -> PathBuf {
453        std::env::temp_dir().join(name)
454    }
455
456    fn cleanup(path: &Path) {
457        let _ = std::fs::remove_dir_all(path);
458    }
459
460    // -----------------------------------------------------------------------
461    // Filesystem tests (unchanged)
462    // -----------------------------------------------------------------------
463
464    #[test]
465    fn create_writes_mmap_format() {
466        let p = tmp_path("sparse_mmap_create_test");
467        cleanup(&p);
468        let path = p.to_str().unwrap();
469
470        let _handle = SparseHandle::create(path).unwrap();
471        assert!(p.join(MMAP_FILE).exists());
472        assert!(p.join(VECTORS_FILE).exists());
473        assert!(p.join(DIMS_FILE).exists());
474
475        let handle2 = SparseHandle::open(path).unwrap();
476        assert_eq!(handle2.len(), 0);
477
478        cleanup(&p);
479    }
480
481    #[test]
482    fn persistence_roundtrip_mmap() {
483        let p = tmp_path("sparse_mmap_roundtrip_test");
484        cleanup(&p);
485        let path = p.to_str().unwrap();
486
487        let handle = SparseHandle::create(path).unwrap();
488        handle
489            .insert(42, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]))
490            .unwrap();
491        handle
492            .insert(99, &SparseVector::new(vec![2, 3], vec![0.8, 0.2]))
493            .unwrap();
494        handle.commit_inner().unwrap();
495        drop(handle);
496
497        // Reopen — should use mmap path
498        let handle2 = SparseHandle::open(path).unwrap();
499        assert_eq!(handle2.len(), 2);
500
501        // Search via mmap (no RAM postings loaded)
502        let results = handle2.search(&SparseVector::new(vec![2], vec![1.0]), 10);
503        assert_eq!(results.len(), 2);
504        assert_eq!(results[0].0, 99);
505        assert!((results[0].1 - 0.8).abs() < 1e-6);
506        assert_eq!(results[1].0, 42);
507        assert!((results[1].1 - 0.3).abs() < 1e-6);
508
509        cleanup(&p);
510    }
511
512    #[test]
513    fn mmap_search_filtered() {
514        let p = tmp_path("sparse_mmap_filtered_test");
515        cleanup(&p);
516        let path = p.to_str().unwrap();
517
518        let handle = SparseHandle::create(path).unwrap();
519        handle
520            .insert(1, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]))
521            .unwrap();
522        handle
523            .insert(2, &SparseVector::new(vec![1, 3], vec![0.9, 0.1]))
524            .unwrap();
525        handle
526            .insert(3, &SparseVector::new(vec![1], vec![0.7]))
527            .unwrap();
528        handle.commit_inner().unwrap();
529        drop(handle);
530
531        let handle2 = SparseHandle::open(path).unwrap();
532        let results = handle2.search_filtered(&SparseVector::new(vec![1], vec![1.0]), 10, &[1, 3]);
533        assert_eq!(results.len(), 2);
534        assert_eq!(results[0].0, 3); // 0.7
535        assert_eq!(results[1].0, 1); // 0.5
536
537        cleanup(&p);
538    }
539
540    #[test]
541    fn mutation_after_mmap_open() {
542        let p = tmp_path("sparse_mmap_mutation_test");
543        cleanup(&p);
544        let path = p.to_str().unwrap();
545
546        let handle = SparseHandle::create(path).unwrap();
547        handle
548            .insert(1, &SparseVector::new(vec![10], vec![1.0]))
549            .unwrap();
550        handle.commit_inner().unwrap();
551        drop(handle);
552
553        // Reopen, mutate (triggers postings load from mmap), search
554        let handle2 = SparseHandle::open(path).unwrap();
555        handle2
556            .insert(2, &SparseVector::new(vec![10], vec![2.0]))
557            .unwrap();
558
559        let results = handle2.search(&SparseVector::new(vec![10], vec![1.0]), 10);
560        assert_eq!(results.len(), 2);
561        assert_eq!(results[0].0, 2); // 2.0
562        assert_eq!(results[1].0, 1); // 1.0
563
564        // Commit and reopen again
565        handle2.commit_inner().unwrap();
566        drop(handle2);
567
568        let handle3 = SparseHandle::open(path).unwrap();
569        let results = handle3.search(&SparseVector::new(vec![10], vec![1.0]), 10);
570        assert_eq!(results.len(), 2);
571        assert_eq!(results[0].0, 2);
572
573        cleanup(&p);
574    }
575
576    #[test]
577    fn legacy_fallback() {
578        let p = tmp_path("sparse_mmap_legacy_test");
579        cleanup(&p);
580        let path = p.to_str().unwrap();
581
582        // Write legacy format manually
583        std::fs::create_dir_all(&p).unwrap();
584        let mut index = SparseIndex::new();
585        index.insert(7, &SparseVector::new(vec![1], vec![0.42]));
586        let data = bincode::serialize(&index).unwrap();
587        std::fs::write(p.join(LEGACY_FILE), data).unwrap();
588
589        // Open should fall back to legacy
590        let handle = SparseHandle::open(path).unwrap();
591        assert_eq!(handle.len(), 1);
592        let results = handle.search(&SparseVector::new(vec![1], vec![1.0]), 10);
593        assert_eq!(results[0].0, 7);
594
595        // Commit migrates to new format
596        handle.commit_inner().unwrap();
597        assert!(p.join(MMAP_FILE).exists());
598        assert!(!p.join(LEGACY_FILE).exists());
599
600        cleanup(&p);
601    }
602
603    #[test]
604    fn many_docs_mmap_roundtrip() {
605        let p = tmp_path("sparse_mmap_many_docs_test");
606        cleanup(&p);
607        let path = p.to_str().unwrap();
608
609        let handle = SparseHandle::create(path).unwrap();
610        for i in 0..500u64 {
611            let token = (i % 50) as u32;
612            let weight = (i as f32) / 500.0;
613            handle
614                .insert(
615                    i,
616                    &SparseVector::new(vec![token, token + 50], vec![weight, weight * 0.5]),
617                )
618                .unwrap();
619        }
620        handle.commit_inner().unwrap();
621        drop(handle);
622
623        let handle2 = SparseHandle::open(path).unwrap();
624        assert_eq!(handle2.len(), 500);
625
626        let results = handle2.search(&SparseVector::new(vec![0, 50], vec![1.0, 1.0]), 5);
627        assert_eq!(results.len(), 5);
628        // Doc 450 has weight 0.9 for token 0, 0.45 for token 50 → score 1.35
629        assert_eq!(results[0].0, 450);
630
631        cleanup(&p);
632    }
633
634    // -----------------------------------------------------------------------
635    // BlobStore tests
636    // -----------------------------------------------------------------------
637
638    fn test_cache_base() -> PathBuf {
639        std::env::temp_dir().join("sparse_test_cache")
640    }
641
642    #[test]
643    fn blob_store_create_and_search() {
644        let store = Arc::new(MemBlobStore::new());
645        let cb = test_cache_base();
646        let handle = SparseHandle::create_with_store(store.clone(), "test_idx", &cb).unwrap();
647
648        handle
649            .insert(42, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]))
650            .unwrap();
651        handle
652            .insert(99, &SparseVector::new(vec![2, 3], vec![0.8, 0.2]))
653            .unwrap();
654        handle.commit_inner().unwrap();
655
656        // Data should be in store
657        assert!(store.exists("Sparse_test_idx", MMAP_FILE).unwrap());
658        assert!(store.exists("Sparse_test_idx", VECTORS_FILE).unwrap());
659        assert!(store.exists("Sparse_test_idx", DIMS_FILE).unwrap());
660        assert_eq!(store.list("Sparse_test_idx").unwrap().len(), 3);
661
662        // Search should work (mmap from cache)
663        let results = handle.search(&SparseVector::new(vec![2], vec![1.0]), 10);
664        assert_eq!(results.len(), 2);
665        assert_eq!(results[0].0, 99);
666    }
667
668    #[test]
669    fn blob_store_close_reopen() {
670        let store = Arc::new(MemBlobStore::new());
671        let cb = test_cache_base();
672
673        // Create, insert, commit, drop
674        {
675            let handle = SparseHandle::create_with_store(store.clone(), "reopen_idx", &cb).unwrap();
676            handle
677                .insert(1, &SparseVector::new(vec![10], vec![1.0]))
678                .unwrap();
679            handle
680                .insert(2, &SparseVector::new(vec![10, 20], vec![0.5, 0.8]))
681                .unwrap();
682            handle.commit_inner().unwrap();
683        }
684        // Handle dropped → cache_dir cleaned up
685
686        // Reopen from store
687        let handle2 = SparseHandle::open_with_store(store.clone(), "reopen_idx", &cb).unwrap();
688        assert_eq!(handle2.len(), 2);
689
690        let results = handle2.search(&SparseVector::new(vec![10], vec![1.0]), 10);
691        assert_eq!(results.len(), 2);
692        assert_eq!(results[0].0, 1); // 1.0
693        assert_eq!(results[1].0, 2); // 0.5
694    }
695
696    #[test]
697    fn blob_store_mutation_after_reopen() {
698        let store = Arc::new(MemBlobStore::new());
699        let cb = test_cache_base();
700
701        {
702            let handle = SparseHandle::create_with_store(store.clone(), "mut_idx", &cb).unwrap();
703            handle
704                .insert(1, &SparseVector::new(vec![5], vec![1.0]))
705                .unwrap();
706            handle.commit_inner().unwrap();
707        }
708
709        let handle2 = SparseHandle::open_with_store(store.clone(), "mut_idx", &cb).unwrap();
710        handle2
711            .insert(2, &SparseVector::new(vec![5], vec![2.0]))
712            .unwrap();
713        handle2.commit_inner().unwrap();
714
715        // Reopen again — should have both docs
716        drop(handle2);
717        let handle3 = SparseHandle::open_with_store(store.clone(), "mut_idx", &cb).unwrap();
718        assert_eq!(handle3.len(), 2);
719
720        let results = handle3.search(&SparseVector::new(vec![5], vec![1.0]), 10);
721        assert_eq!(results.len(), 2);
722        assert_eq!(results[0].0, 2); // 2.0
723        assert_eq!(results[1].0, 1); // 1.0
724    }
725
726    #[test]
727    fn blob_store_delete_and_reopen() {
728        let store = Arc::new(MemBlobStore::new());
729        let cb = test_cache_base();
730
731        {
732            let handle = SparseHandle::create_with_store(store.clone(), "del_idx", &cb).unwrap();
733            handle
734                .insert(1, &SparseVector::new(vec![1], vec![1.0]))
735                .unwrap();
736            handle
737                .insert(2, &SparseVector::new(vec![1], vec![2.0]))
738                .unwrap();
739            handle.commit_inner().unwrap();
740        }
741
742        // Reopen, delete, commit
743        let handle2 = SparseHandle::open_with_store(store.clone(), "del_idx", &cb).unwrap();
744        assert_eq!(handle2.len(), 2);
745        handle2.remove(1).unwrap();
746        assert_eq!(handle2.len(), 1);
747        handle2.commit_inner().unwrap();
748        drop(handle2);
749
750        // Reopen — should have 1 doc
751        let handle3 = SparseHandle::open_with_store(store.clone(), "del_idx", &cb).unwrap();
752        assert_eq!(handle3.len(), 1);
753
754        let results = handle3.search(&SparseVector::new(vec![1], vec![1.0]), 10);
755        assert_eq!(results.len(), 1);
756        assert_eq!(results[0].0, 2);
757    }
758
759    #[test]
760    fn blob_store_multiple_indexes_isolated() {
761        let store = Arc::new(MemBlobStore::new());
762        let cb = test_cache_base();
763
764        let h1 = SparseHandle::create_with_store(store.clone(), "idx_a", &cb).unwrap();
765        let h2 = SparseHandle::create_with_store(store.clone(), "idx_b", &cb).unwrap();
766
767        h1.insert(1, &SparseVector::new(vec![1], vec![1.0]))
768            .unwrap();
769        h1.insert(2, &SparseVector::new(vec![1], vec![0.5]))
770            .unwrap();
771        h2.insert(10, &SparseVector::new(vec![1], vec![3.0]))
772            .unwrap();
773
774        h1.commit_inner().unwrap();
775        h2.commit_inner().unwrap();
776
777        assert_eq!(h1.len(), 2);
778        assert_eq!(h2.len(), 1);
779
780        // Store has separate blobs
781        assert_eq!(store.list("Sparse_idx_a").unwrap().len(), 3);
782        assert_eq!(store.list("Sparse_idx_b").unwrap().len(), 3);
783    }
784
785    #[test]
786    fn blob_store_survives_cache_cleanup() {
787        let store = Arc::new(MemBlobStore::new());
788        let cb = test_cache_base();
789
790        {
791            let handle = SparseHandle::create_with_store(store.clone(), "surv_idx", &cb).unwrap();
792            for i in 0..50u64 {
793                handle
794                    .insert(i, &SparseVector::new(vec![(i % 10) as u32], vec![i as f32]))
795                    .unwrap();
796            }
797            handle.commit_inner().unwrap();
798        }
799        // Cache cleaned up on drop
800
801        // Reopen from store
802        let handle2 = SparseHandle::open_with_store(store.clone(), "surv_idx", &cb).unwrap();
803        assert_eq!(handle2.len(), 50);
804
805        let results = handle2.search(&SparseVector::new(vec![0], vec![1.0]), 5);
806        // Docs with token 0: 0 (w=0.0), 10, 20, 30, 40. Doc 0 has weight 0,
807        // which is not indexed (see `index`), so it is not a hit.
808        assert_eq!(results.len(), 4);
809        assert_eq!(results[0].0, 40);
810    }
811
812    #[test]
813    fn blob_store_search_filtered_after_reopen() {
814        let store = Arc::new(MemBlobStore::new());
815        let cb = test_cache_base();
816
817        {
818            let handle = SparseHandle::create_with_store(store.clone(), "filt_idx", &cb).unwrap();
819            handle
820                .insert(1, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]))
821                .unwrap();
822            handle
823                .insert(2, &SparseVector::new(vec![1, 3], vec![0.9, 0.1]))
824                .unwrap();
825            handle
826                .insert(3, &SparseVector::new(vec![1], vec![0.7]))
827                .unwrap();
828            handle.commit_inner().unwrap();
829        }
830
831        let handle2 = SparseHandle::open_with_store(store.clone(), "filt_idx", &cb).unwrap();
832        let results =
833            handle2.search_filtered(&SparseVector::new(vec![1], vec![1.0]), 10, &[1, 3]);
834        assert_eq!(results.len(), 2);
835        assert_eq!(results[0].0, 3); // 0.7
836        assert_eq!(results[1].0, 1); // 0.5
837    }
838}