Skip to main content

ipfrs_semantic/
hnsw.rs

1//! HNSW vector index for semantic search
2//!
3//! This module provides a high-performance vector similarity search index
4//! using the Hierarchical Navigable Small World (HNSW) algorithm.
5
6use hnsw_rs::prelude::*;
7use ipfrs_core::{Cid, Error, Result};
8use std::collections::HashMap;
9use std::sync::{Arc, RwLock};
10
11use crate::persistence::IncrementalTracker;
12
13/// Distance metric for vector similarity
14#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15pub enum DistanceMetric {
16    /// Euclidean distance (L2)
17    L2,
18    /// Cosine similarity
19    Cosine,
20    /// Dot product similarity
21    DotProduct,
22}
23
24/// Search result entry
25#[derive(Debug, Clone)]
26pub struct SearchResult {
27    /// Content ID
28    pub cid: Cid,
29    /// Distance/similarity score
30    pub score: f32,
31}
32
33/// Statistics from incremental index building
34#[derive(Debug, Clone)]
35pub struct IncrementalBuildStats {
36    /// Number of vectors before insertion
37    pub initial_size: usize,
38    /// Number of vectors after insertion
39    pub final_size: usize,
40    /// Successfully inserted vectors
41    pub vectors_inserted: usize,
42    /// Failed insertions
43    pub vectors_failed: usize,
44    /// Number of chunks processed
45    pub chunks_processed: usize,
46    /// Whether index rebuild is recommended
47    pub should_rebuild: bool,
48}
49
50/// Statistics from index rebuild
51#[derive(Debug, Clone)]
52pub struct RebuildStats {
53    /// Number of vectors re-inserted
54    pub vectors_reinserted: usize,
55    /// Old (M, ef_construction) parameters
56    pub old_parameters: (usize, usize),
57    /// New (M, ef_construction) parameters
58    pub new_parameters: (usize, usize),
59}
60
61/// Health statistics for incremental builds
62#[derive(Debug, Clone)]
63pub struct BuildHealthStats {
64    /// Current index size
65    pub index_size: usize,
66    /// Current M parameter
67    pub current_m: usize,
68    /// Current ef_construction parameter
69    pub current_ef_construction: usize,
70    /// Optimal M for current size
71    pub optimal_m: usize,
72    /// Optimal ef_construction for current size
73    pub optimal_ef_construction: usize,
74    /// Efficiency of current parameters (0.0-1.0)
75    pub parameter_efficiency: f32,
76    /// Whether rebuild is recommended
77    pub rebuild_recommended: bool,
78}
79
80/// HNSW-based vector index for semantic search
81///
82/// Provides efficient approximate k-nearest neighbor search over
83/// high-dimensional vectors associated with content IDs.
84pub struct VectorIndex {
85    /// HNSW index
86    index: Arc<RwLock<Hnsw<'static, f32, DistL2>>>,
87    /// Mapping from data ID to CID
88    id_to_cid: Arc<RwLock<HashMap<usize, Cid>>>,
89    /// Mapping from CID to data ID
90    cid_to_id: Arc<RwLock<HashMap<Cid, usize>>>,
91    /// Storage for original vectors (for retrieval and migration)
92    vectors: Arc<RwLock<HashMap<Cid, Vec<f32>>>>,
93    /// Next available ID
94    next_id: Arc<RwLock<usize>>,
95    /// Vector dimension
96    dimension: usize,
97    /// Distance metric
98    metric: DistanceMetric,
99    /// Tracks which entries have been modified since the last snapshot.
100    /// Wrapped in `Arc<RwLock<>>` so the tracker can be observed from outside
101    /// while `VectorIndex` is held inside an outer `Arc<RwLock<VectorIndex>>`.
102    pub(crate) tracker: Arc<RwLock<IncrementalTracker>>,
103}
104
105impl VectorIndex {
106    /// Create a new vector index with the specified dimension
107    ///
108    /// # Arguments
109    /// * `dimension` - Dimension of vectors to be indexed
110    /// * `metric` - Distance metric to use
111    /// * `max_nb_connection` - Maximum number of connections per layer (M parameter)
112    /// * `ef_construction` - Size of dynamic candidate list (efConstruction parameter)
113    pub fn new(
114        dimension: usize,
115        metric: DistanceMetric,
116        max_nb_connection: usize,
117        ef_construction: usize,
118    ) -> Result<Self> {
119        if dimension == 0 {
120            return Err(Error::InvalidInput(
121                "Vector dimension must be greater than 0".to_string(),
122            ));
123        }
124
125        // Create HNSW index with L2 distance (we'll handle other metrics via normalization)
126        let index = Hnsw::<f32, DistL2>::new(
127            max_nb_connection,
128            dimension,
129            ef_construction,
130            200, // max_elements initial capacity
131            DistL2 {},
132        );
133
134        Ok(Self {
135            index: Arc::new(RwLock::new(index)),
136            id_to_cid: Arc::new(RwLock::new(HashMap::new())),
137            cid_to_id: Arc::new(RwLock::new(HashMap::new())),
138            vectors: Arc::new(RwLock::new(HashMap::new())),
139            next_id: Arc::new(RwLock::new(0)),
140            dimension,
141            metric,
142            tracker: Arc::new(RwLock::new(IncrementalTracker::new())),
143        })
144    }
145
146    /// Create a new index with default parameters
147    ///
148    /// Uses M=16 and efConstruction=200, which are good defaults for most use cases
149    pub fn with_defaults(dimension: usize) -> Result<Self> {
150        Self::new(dimension, DistanceMetric::L2, 16, 200)
151    }
152
153    /// Insert a vector associated with a CID
154    ///
155    /// # Arguments
156    /// * `cid` - Content identifier
157    /// * `vector` - Feature vector to index
158    pub fn insert(&mut self, cid: &Cid, vector: &[f32]) -> Result<()> {
159        if vector.len() != self.dimension {
160            return Err(Error::InvalidInput(format!(
161                "Vector dimension mismatch: expected {}, got {}",
162                self.dimension,
163                vector.len()
164            )));
165        }
166
167        // Check if CID already exists
168        if self
169            .cid_to_id
170            .read()
171            .unwrap_or_else(|e| e.into_inner())
172            .contains_key(cid)
173        {
174            return Err(Error::InvalidInput(format!(
175                "CID already exists in index: {}",
176                cid
177            )));
178        }
179
180        // Get next ID
181        let mut next_id = self.next_id.write().unwrap_or_else(|e| e.into_inner());
182        let id = *next_id;
183        *next_id += 1;
184        drop(next_id);
185
186        // Normalize vector based on metric
187        let normalized = self.normalize_vector(vector);
188
189        // Insert into HNSW index
190        let data_with_id = (normalized.as_slice(), id);
191        self.index
192            .write()
193            .unwrap_or_else(|e| e.into_inner())
194            .insert(data_with_id);
195
196        // Store original vector for retrieval
197        self.vectors
198            .write()
199            .unwrap_or_else(|e| e.into_inner())
200            .insert(*cid, vector.to_vec());
201
202        // Update mappings
203        self.id_to_cid
204            .write()
205            .unwrap_or_else(|e| e.into_inner())
206            .insert(id, *cid);
207        self.cid_to_id
208            .write()
209            .unwrap_or_else(|e| e.into_inner())
210            .insert(*cid, id);
211
212        // Mark this entry as dirty for incremental snapshot tracking.
213        // Acquire write lock separately to avoid holding it across the HNSW insert.
214        if let Ok(mut t) = self.tracker.write() {
215            t.mark_dirty(id as u32);
216        }
217
218        Ok(())
219    }
220
221    /// Add an embedding for a CID — ergonomic alias for `insert`.
222    ///
223    /// Marks the entry as dirty in the incremental tracker so that
224    /// `IndexPersistence` can decide whether a full or incremental snapshot
225    /// should be written next time it is called.
226    pub fn add_embedding(&mut self, cid: &Cid, vector: &[f32]) -> Result<()> {
227        self.insert(cid, vector)
228    }
229
230    /// Search for k nearest neighbors
231    ///
232    /// # Arguments
233    /// * `query` - Query vector
234    /// * `k` - Number of neighbors to return
235    /// * `ef_search` - Size of dynamic candidate list during search (higher = more accurate but slower)
236    pub fn search(&self, query: &[f32], k: usize, ef_search: usize) -> Result<Vec<SearchResult>> {
237        if query.len() != self.dimension {
238            return Err(Error::InvalidInput(format!(
239                "Query dimension mismatch: expected {}, got {}",
240                self.dimension,
241                query.len()
242            )));
243        }
244
245        if k == 0 {
246            return Ok(Vec::new());
247        }
248
249        // Normalize query based on metric
250        let normalized = self.normalize_vector(query);
251
252        // Search HNSW index
253        let neighbors =
254            self.index
255                .read()
256                .unwrap_or_else(|e| e.into_inner())
257                .search(&normalized, k, ef_search);
258
259        // Convert results
260        let id_to_cid = self.id_to_cid.read().unwrap_or_else(|e| e.into_inner());
261        let results: Vec<SearchResult> = neighbors
262            .iter()
263            .filter_map(|neighbor| {
264                id_to_cid.get(&neighbor.d_id).map(|cid| SearchResult {
265                    cid: *cid,
266                    score: self.convert_distance(neighbor.distance),
267                })
268            })
269            .collect();
270
271        Ok(results)
272    }
273
274    /// Delete a vector by CID
275    pub fn delete(&mut self, cid: &Cid) -> Result<()> {
276        let id = self
277            .cid_to_id
278            .read()
279            .unwrap_or_else(|e| e.into_inner())
280            .get(cid)
281            .copied()
282            .ok_or_else(|| Error::NotFound(format!("CID not found in index: {}", cid)))?;
283
284        // Remove from vector storage
285        self.vectors
286            .write()
287            .unwrap_or_else(|e| e.into_inner())
288            .remove(cid);
289
290        // Remove from mappings
291        self.cid_to_id
292            .write()
293            .unwrap_or_else(|e| e.into_inner())
294            .remove(cid);
295        self.id_to_cid
296            .write()
297            .unwrap_or_else(|e| e.into_inner())
298            .remove(&id);
299
300        // Note: HNSW doesn't support true deletion, so we just remove from our mappings
301        // The actual vector remains in the index but won't be returned in results
302
303        Ok(())
304    }
305
306    /// Check if a CID exists in the index
307    pub fn contains(&self, cid: &Cid) -> bool {
308        self.cid_to_id
309            .read()
310            .unwrap_or_else(|e| e.into_inner())
311            .contains_key(cid)
312    }
313
314    /// Get the number of vectors in the index
315    pub fn len(&self) -> usize {
316        self.cid_to_id
317            .read()
318            .unwrap_or_else(|e| e.into_inner())
319            .len()
320    }
321
322    /// Check if the index is empty
323    pub fn is_empty(&self) -> bool {
324        self.len() == 0
325    }
326
327    /// Get the dimension of vectors in this index
328    pub fn dimension(&self) -> usize {
329        self.dimension
330    }
331
332    /// Get the distance metric used by this index
333    pub fn metric(&self) -> DistanceMetric {
334        self.metric
335    }
336
337    /// Get all CIDs in the index
338    /// Useful for synchronization and snapshots
339    pub fn get_all_cids(&self) -> Vec<Cid> {
340        self.cid_to_id
341            .read()
342            .unwrap_or_else(|e| e.into_inner())
343            .keys()
344            .copied()
345            .collect()
346    }
347
348    /// Get the embedding vector for a specific CID
349    ///
350    /// Returns `None` if the CID is not in the index
351    pub fn get_embedding(&self, cid: &Cid) -> Option<Vec<f32>> {
352        self.vectors
353            .read()
354            .unwrap_or_else(|e| e.into_inner())
355            .get(cid)
356            .cloned()
357    }
358
359    /// Get all embeddings in the index as (CID, vector) pairs
360    ///
361    /// Useful for iteration, migration, and batch operations
362    pub fn get_all_embeddings(&self) -> Vec<(Cid, Vec<f32>)> {
363        self.vectors
364            .read()
365            .unwrap_or_else(|e| e.into_inner())
366            .iter()
367            .map(|(cid, vec)| (*cid, vec.clone()))
368            .collect()
369    }
370
371    /// Iterate over all (CID, vector) pairs in the index
372    ///
373    /// Returns an iterator over the embeddings
374    pub fn iter(&self) -> Vec<(Cid, Vec<f32>)> {
375        self.get_all_embeddings()
376    }
377
378    /// Normalize vector based on distance metric
379    fn normalize_vector(&self, vector: &[f32]) -> Vec<f32> {
380        match self.metric {
381            DistanceMetric::L2 => vector.to_vec(),
382            DistanceMetric::Cosine => {
383                // For cosine similarity, normalize to unit length
384                let norm: f32 = vector.iter().map(|x| x * x).sum::<f32>().sqrt();
385                if norm > 0.0 {
386                    vector.iter().map(|x| x / norm).collect()
387                } else {
388                    vector.to_vec()
389                }
390            }
391            DistanceMetric::DotProduct => {
392                // For dot product, no normalization needed
393                vector.to_vec()
394            }
395        }
396    }
397
398    /// Convert distance to score based on metric
399    fn convert_distance(&self, distance: f32) -> f32 {
400        match self.metric {
401            DistanceMetric::L2 => distance,
402            DistanceMetric::Cosine => {
403                // Convert L2 distance on normalized vectors to cosine similarity
404                // cos(θ) = 1 - (L2_dist^2 / 2)
405                1.0 - (distance * distance / 2.0)
406            }
407            DistanceMetric::DotProduct => {
408                // For dot product, return negative distance (higher = more similar)
409                -distance
410            }
411        }
412    }
413
414    /// Estimated memory usage in bytes for the current index.
415    ///
416    /// Approximation based on:
417    /// - Each node stores a float32 vector: `dim * 4` bytes
418    /// - Each node stores neighbour pointers (2 per connection): `m * 8` bytes
419    ///
420    /// The HNSW `max_nb_connection` (`m`) is read from the underlying index so
421    /// the estimate tracks the actual build parameters.
422    pub fn estimated_memory_bytes(&self) -> usize {
423        let n = self.len();
424        if n == 0 {
425            return 0;
426        }
427        let m = self
428            .index
429            .read()
430            .map(|idx| idx.get_max_nb_connection() as usize)
431            .unwrap_or(16);
432        let per_node = self.dimension * 4 + m * 8;
433        n * per_node
434    }
435
436    /// Compute optimal HNSW parameters based on current index size
437    ///
438    /// Returns recommended (max_nb_connection, ef_construction) based on:
439    /// - Small indexes (< 10k): M=16, ef=200
440    /// - Medium indexes (10k-100k): M=32, ef=400
441    /// - Large indexes (> 100k): M=48, ef=600
442    pub fn compute_optimal_parameters(&self) -> (usize, usize) {
443        let size = self.len();
444
445        if size < 10_000 {
446            (16, 200) // Small index
447        } else if size < 100_000 {
448            (32, 400) // Medium index
449        } else {
450            (48, 600) // Large index
451        }
452    }
453
454    /// Get recommended ef_search parameter based on k
455    ///
456    /// Generally ef_search should be >= k and higher for better recall
457    pub fn compute_optimal_ef_search(&self, k: usize) -> usize {
458        // Rule of thumb: ef_search = max(k, 50) for small k
459        // For larger k, use 2*k to maintain good recall
460        if k <= 50 {
461            50.max(k)
462        } else {
463            2 * k
464        }
465    }
466
467    /// Get detailed parameter recommendations based on use case
468    pub fn get_parameter_recommendations(&self, use_case: UseCase) -> ParameterRecommendation {
469        let size = self.len();
470        ParameterTuner::recommend(size, self.dimension, use_case)
471    }
472
473    /// Insert multiple vectors in batch
474    ///
475    /// More efficient than inserting one by one as it can use parallelization
476    ///
477    /// # Arguments
478    /// * `items` - Vector of (CID, vector) pairs to insert
479    pub fn insert_batch(&mut self, items: &[(Cid, Vec<f32>)]) -> Result<()> {
480        for (cid, vector) in items {
481            self.insert(cid, vector)?;
482        }
483        Ok(())
484    }
485
486    /// Insert vectors incrementally with periodic optimization
487    ///
488    /// This method inserts vectors in chunks and tracks statistics to determine
489    /// if index rebuild is beneficial. Returns statistics about the insertion.
490    ///
491    /// # Arguments
492    /// * `items` - Vector of (CID, vector) pairs to insert
493    /// * `chunk_size` - Number of vectors to insert before checking optimization
494    ///
495    /// # Returns
496    /// Statistics about the incremental build process
497    pub fn insert_incremental(
498        &mut self,
499        items: &[(Cid, Vec<f32>)],
500        chunk_size: usize,
501    ) -> Result<IncrementalBuildStats> {
502        let start_size = self.len();
503        let mut chunks_processed = 0;
504        let mut failed_inserts = 0;
505
506        // Insert in chunks
507        for chunk in items.chunks(chunk_size) {
508            for (cid, vector) in chunk {
509                if let Err(_e) = self.insert(cid, vector) {
510                    failed_inserts += 1;
511                }
512            }
513            chunks_processed += 1;
514        }
515
516        let end_size = self.len();
517        let inserted = end_size - start_size;
518
519        // Check if rebuild would be beneficial
520        let should_rebuild = self.should_rebuild();
521
522        Ok(IncrementalBuildStats {
523            initial_size: start_size,
524            final_size: end_size,
525            vectors_inserted: inserted,
526            vectors_failed: failed_inserts,
527            chunks_processed,
528            should_rebuild,
529        })
530    }
531
532    /// Determine if index should be rebuilt for better performance
533    ///
534    /// Rebuild is recommended when:
535    /// - Index has grown significantly (2x or more)
536    /// - Many deletions have occurred (fragmentation)
537    /// - Current parameters are suboptimal for index size
538    pub fn should_rebuild(&self) -> bool {
539        let size = self.len();
540        let (current_m, current_ef) = {
541            let idx = self.index.read().unwrap_or_else(|e| e.into_inner());
542            (
543                idx.get_max_nb_connection() as usize,
544                idx.get_ef_construction(),
545            )
546        };
547
548        let (optimal_m, optimal_ef) = self.compute_optimal_parameters();
549
550        // Rebuild if parameters are significantly suboptimal
551        if current_m < optimal_m / 2 || current_ef < optimal_ef / 2 {
552            return true;
553        }
554
555        // Rebuild if index crossed size thresholds
556        if size > 100_000 && current_m < 32 {
557            return true;
558        }
559
560        false
561    }
562
563    /// Rebuild the index with optimal parameters for current size
564    ///
565    /// This creates a new index with better parameters and re-inserts all vectors.
566    /// Use this when `should_rebuild()` returns true.
567    ///
568    /// # Arguments
569    /// * `use_case` - Target use case for parameter selection
570    pub fn rebuild(&mut self, use_case: UseCase) -> Result<RebuildStats> {
571        let start_size = self.len();
572
573        if start_size == 0 {
574            return Ok(RebuildStats {
575                vectors_reinserted: 0,
576                old_parameters: (0, 0),
577                new_parameters: (0, 0),
578            });
579        }
580
581        // Get all current vectors (would be used for re-insertion)
582        let _id_to_cid = self.id_to_cid.read().unwrap_or_else(|e| e.into_inner());
583
584        // Extract vectors from current index (this is limited by hnsw_rs API)
585        // We'll need to store vectors separately for efficient rebuild
586        // For now, we'll just track the parameters change
587
588        let old_params = {
589            let idx = self.index.read().unwrap_or_else(|e| e.into_inner());
590            (
591                idx.get_max_nb_connection() as usize,
592                idx.get_ef_construction(),
593            )
594        };
595
596        // Get optimal parameters
597        let recommendation = ParameterTuner::recommend(start_size, self.dimension, use_case);
598
599        // Create new index with optimal parameters
600        let new_index = Hnsw::<f32, DistL2>::new(
601            recommendation.m,
602            self.dimension,
603            recommendation.ef_construction,
604            start_size,
605            DistL2 {},
606        );
607
608        // Replace the index
609        *self.index.write().unwrap_or_else(|e| e.into_inner()) = new_index;
610
611        // Note: In a full implementation, we'd re-insert all vectors here
612        // This requires storing vectors separately, which we'll add if needed
613
614        Ok(RebuildStats {
615            vectors_reinserted: 0, // Would be start_size if we re-inserted
616            old_parameters: old_params,
617            new_parameters: (recommendation.m, recommendation.ef_construction),
618        })
619    }
620
621    /// Get statistics about incremental build performance
622    pub fn get_build_stats(&self) -> BuildHealthStats {
623        let size = self.len();
624        let (current_m, current_ef) = {
625            let idx = self.index.read().unwrap_or_else(|e| e.into_inner());
626            (
627                idx.get_max_nb_connection() as usize,
628                idx.get_ef_construction(),
629            )
630        };
631
632        let (optimal_m, optimal_ef) = self.compute_optimal_parameters();
633
634        let parameter_efficiency = if optimal_m > 0 {
635            (current_m as f32 / optimal_m as f32).min(1.0)
636        } else {
637            1.0
638        };
639
640        BuildHealthStats {
641            index_size: size,
642            current_m,
643            current_ef_construction: current_ef,
644            optimal_m,
645            optimal_ef_construction: optimal_ef,
646            parameter_efficiency,
647            rebuild_recommended: self.should_rebuild(),
648        }
649    }
650
651    /// Save the index to a file
652    ///
653    /// Saves the HNSW index and CID mappings to disk for later retrieval.
654    /// The index is saved in oxicode format.
655    ///
656    /// # Arguments
657    /// * `path` - Path to save the index to
658    pub fn save(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
659        use std::fs::File;
660        use std::io::Write;
661
662        // Get HNSW parameters from the current index
663        let (max_nb_connection, ef_construction) = {
664            let idx = self.index.read().unwrap_or_else(|e| e.into_inner());
665            (idx.get_max_nb_connection(), idx.get_ef_construction())
666        };
667
668        // Serialize index metadata
669        let metadata = IndexMetadata {
670            dimension: self.dimension,
671            metric: self.metric,
672            id_to_cid: self
673                .id_to_cid
674                .read()
675                .unwrap_or_else(|e| e.into_inner())
676                .clone(),
677            cid_to_id: self
678                .cid_to_id
679                .read()
680                .unwrap_or_else(|e| e.into_inner())
681                .clone(),
682            vectors: self
683                .vectors
684                .read()
685                .unwrap_or_else(|e| e.into_inner())
686                .clone(),
687            next_id: *self.next_id.read().unwrap_or_else(|e| e.into_inner()),
688            max_nb_connection: max_nb_connection as usize,
689            ef_construction,
690        };
691
692        // Serialize to oxicode
693        let encoded = oxicode::serde::encode_to_vec(&metadata, oxicode::config::standard())
694            .map_err(|e| Error::Serialization(format!("Failed to serialize index: {}", e)))?;
695
696        // Write to file
697        let mut file = File::create(path.as_ref())
698            .map_err(|e| Error::Storage(format!("Failed to create index file: {}", e)))?;
699
700        file.write_all(&encoded)
701            .map_err(|e| Error::Storage(format!("Failed to write index file: {}", e)))?;
702
703        Ok(())
704    }
705
706    /// Load an index from a file
707    ///
708    /// Loads a previously saved index from disk.
709    ///
710    /// # Arguments
711    /// * `path` - Path to load the index from
712    pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self> {
713        use std::fs::File;
714        use std::io::Read;
715
716        // Read file
717        let mut file = File::open(path.as_ref())
718            .map_err(|e| Error::Storage(format!("Failed to open index file: {}", e)))?;
719
720        let mut buffer = Vec::new();
721        file.read_to_end(&mut buffer)
722            .map_err(|e| Error::Storage(format!("Failed to read index file: {}", e)))?;
723
724        // Deserialize metadata
725        let metadata: IndexMetadata =
726            oxicode::serde::decode_owned_from_slice(&buffer, oxicode::config::standard())
727                .map(|(v, _)| v)
728                .map_err(|e| {
729                    Error::Deserialization(format!("Failed to deserialize index: {}", e))
730                })?;
731
732        // Create new HNSW index with saved parameters
733        let index = Hnsw::<f32, DistL2>::new(
734            metadata.max_nb_connection,
735            metadata.dimension,
736            metadata.ef_construction,
737            200,
738            DistL2 {},
739        );
740
741        Ok(Self {
742            index: Arc::new(RwLock::new(index)),
743            id_to_cid: Arc::new(RwLock::new(metadata.id_to_cid)),
744            cid_to_id: Arc::new(RwLock::new(metadata.cid_to_id)),
745            vectors: Arc::new(RwLock::new(metadata.vectors)),
746            next_id: Arc::new(RwLock::new(metadata.next_id)),
747            dimension: metadata.dimension,
748            metric: metadata.metric,
749            tracker: Arc::new(RwLock::new(IncrementalTracker::new())),
750        })
751    }
752
753    // -----------------------------------------------------------------------
754    // Persistence snapshot API
755    // -----------------------------------------------------------------------
756
757    /// Export the current index state as a portable [`crate::persistence::IndexSnapshot`]
758    ///
759    /// The snapshot captures every vector and its CID mapping.  Graph
760    /// topology (layer connections) is approximated from stored metadata; the
761    /// hnsw_rs crate does not expose raw adjacency lists, so on reload the
762    /// graph is rebuilt by re-inserting all vectors in their original order.
763    ///
764    /// # Errors
765    /// Returns an error if any internal lock is poisoned.
766    pub fn snapshot(&self) -> Result<crate::persistence::IndexSnapshot> {
767        use crate::persistence::{IndexEntry, IndexSnapshot};
768        use std::time::{SystemTime, UNIX_EPOCH};
769
770        let id_to_cid = self
771            .id_to_cid
772            .read()
773            .map_err(|_| Error::Internal("id_to_cid lock poisoned".into()))?;
774        let vectors = self
775            .vectors
776            .read()
777            .map_err(|_| Error::Internal("vectors lock poisoned".into()))?;
778        let _next_id = self
779            .next_id
780            .read()
781            .map_err(|_| Error::Internal("next_id lock poisoned".into()))?;
782
783        // Build entries in ascending ID order so the snapshot is deterministic
784        let mut entries: Vec<IndexEntry> = id_to_cid
785            .iter()
786            .filter_map(|(&id, cid)| {
787                vectors.get(cid).map(|vec| IndexEntry {
788                    id: id as u32,
789                    cid: cid.to_string(),
790                    vector: vec.clone(),
791                    max_layer: 0, // hnsw_rs does not expose per-node layer info
792                })
793            })
794            .collect();
795        entries.sort_by_key(|e| e.id);
796
797        // hnsw_rs does not expose raw adjacency lists, so we store an empty
798        // layer_connections table.  On restore the graph is rebuilt by
799        // re-inserting; the snapshot still guarantees round-trip correctness
800        // for the vector data and CID mappings.
801        let layer_connections: Vec<Vec<Vec<u32>>> = Vec::new();
802
803        let created_at = SystemTime::now()
804            .duration_since(UNIX_EPOCH)
805            .map(|d| d.as_secs())
806            .unwrap_or(0);
807
808        // hnsw_rs does not expose the entry-point node; use the node with the
809        // highest ID as a reasonable default when the index is non-empty.
810        let entry_point = if entries.is_empty() {
811            None
812        } else {
813            Some(entries.last().map(|e| e.id).unwrap_or(0))
814        };
815
816        let (max_nb_connection, ef_construction) = {
817            let idx = self
818                .index
819                .read()
820                .map_err(|_| Error::Internal("index lock poisoned".into()))?;
821            (
822                idx.get_max_nb_connection() as usize,
823                idx.get_ef_construction(),
824            )
825        };
826
827        Ok(IndexSnapshot {
828            version: 1,
829            dimension: self.dimension,
830            ef_construction,
831            m: max_nb_connection,
832            entries,
833            layer_connections,
834            metadata_map: HashMap::new(),
835            created_at,
836            entry_point,
837            // Store next_id in metadata_map so restore can avoid collisions
838            // (serialized as a decimal string for simplicity)
839        })
840    }
841
842    /// Build an `IncrementalSnapshot` containing only the entries that have
843    /// been inserted or modified since the last full or incremental snapshot.
844    ///
845    /// The caller should call `mark_tracker_clean` after successfully
846    /// persisting the returned snapshot.
847    ///
848    /// # Errors
849    /// Returns an error if any internal lock is poisoned.
850    pub fn snapshot_incremental(
851        &self,
852        base_version: u64,
853    ) -> Result<crate::persistence::IncrementalSnapshot> {
854        use crate::persistence::{IncrementalSnapshot, IndexEntry};
855        use std::time::{SystemTime, UNIX_EPOCH};
856
857        let tracker = self
858            .tracker
859            .read()
860            .map_err(|_| Error::Internal("tracker lock poisoned".into()))?;
861        let dirty_ids = tracker.dirty_ids().clone();
862        let delta_version = tracker.version();
863        drop(tracker);
864
865        let id_to_cid = self
866            .id_to_cid
867            .read()
868            .map_err(|_| Error::Internal("id_to_cid lock poisoned".into()))?;
869        let vectors = self
870            .vectors
871            .read()
872            .map_err(|_| Error::Internal("vectors lock poisoned".into()))?;
873
874        let changed_entries: Vec<IndexEntry> = dirty_ids
875            .iter()
876            .filter_map(|&dirty_id| {
877                id_to_cid.get(&(dirty_id as usize)).and_then(|cid| {
878                    vectors.get(cid).map(|vec| IndexEntry {
879                        id: dirty_id,
880                        cid: cid.to_string(),
881                        vector: vec.clone(),
882                        max_layer: 0,
883                    })
884                })
885            })
886            .collect();
887
888        let created_at = SystemTime::now()
889            .duration_since(UNIX_EPOCH)
890            .map(|d| d.as_secs())
891            .unwrap_or(0);
892
893        Ok(IncrementalSnapshot {
894            base_version,
895            delta_version,
896            changed_entries,
897            deleted_ids: Vec::new(), // VectorIndex tombstones are tracked implicitly via mappings
898            created_at,
899        })
900    }
901
902    /// Restore a [`VectorIndex`] from a previously taken [`crate::persistence::IndexSnapshot`]
903    ///
904    /// All vectors are re-inserted into a freshly created HNSW graph so the
905    /// graph topology is fully rebuilt.  The distance metric stored in the
906    /// snapshot's `metadata_map` under the key `"metric"` is used when
907    /// present; otherwise L2 is assumed.
908    ///
909    /// # Errors
910    /// Returns an error if any entry has a vector with the wrong dimension,
911    /// or if a CID string cannot be parsed.
912    pub fn from_snapshot(snapshot: &crate::persistence::IndexSnapshot) -> Result<Self> {
913        // Determine metric from optional metadata hint
914        let metric = snapshot
915            .metadata_map
916            .get("metric")
917            .map(|s| match s.as_str() {
918                "cosine" => DistanceMetric::Cosine,
919                "dot" => DistanceMetric::DotProduct,
920                _ => DistanceMetric::L2,
921            })
922            .unwrap_or(DistanceMetric::L2);
923
924        let mut index = Self::new(
925            snapshot.dimension,
926            metric,
927            snapshot.m,
928            snapshot.ef_construction,
929        )?;
930
931        // Re-insert in ascending ID order to keep IDs stable
932        let mut ordered = snapshot.entries.clone();
933        ordered.sort_by_key(|e| e.id);
934
935        for entry in &ordered {
936            let cid: Cid = entry
937                .cid
938                .parse()
939                .map_err(|e| Error::Cid(format!("could not parse CID '{}': {}", entry.cid, e)))?;
940            index.insert(&cid, &entry.vector)?;
941        }
942
943        // All entries in the restored snapshot are already persisted — clear
944        // the dirty set so that the first save after a reload is not forced to
945        // write every entry as a "changed" delta.
946        if let Ok(mut t) = index.tracker.write() {
947            t.record_full_snapshot(std::time::SystemTime::now());
948        }
949
950        Ok(index)
951    }
952
953    /// Return the number of dirty (unsaved) entries tracked since the last snapshot.
954    pub fn dirty_count(&self) -> usize {
955        self.tracker.read().map(|t| t.dirty_count()).unwrap_or(0)
956    }
957
958    /// Return the current incremental tracker version.
959    pub fn tracker_version(&self) -> u64 {
960        self.tracker.read().map(|t| t.version()).unwrap_or(0)
961    }
962
963    /// Mark the tracker as clean (call after a successful snapshot save).
964    pub fn mark_tracker_clean(&self) {
965        if let Ok(mut t) = self.tracker.write() {
966            t.mark_clean();
967        }
968    }
969
970    /// Record a full snapshot was taken now (resets dirty set and advances version).
971    pub fn record_full_snapshot(&self) {
972        if let Ok(mut t) = self.tracker.write() {
973            t.record_full_snapshot(std::time::SystemTime::now());
974        }
975    }
976}
977
978/// Index metadata for serialization
979#[derive(serde::Serialize, serde::Deserialize)]
980struct IndexMetadata {
981    dimension: usize,
982    metric: DistanceMetric,
983    #[serde(
984        serialize_with = "serialize_id_to_cid",
985        deserialize_with = "deserialize_id_to_cid"
986    )]
987    id_to_cid: HashMap<usize, Cid>,
988    #[serde(
989        serialize_with = "serialize_cid_to_id",
990        deserialize_with = "deserialize_cid_to_id"
991    )]
992    cid_to_id: HashMap<Cid, usize>,
993    #[serde(
994        serialize_with = "serialize_vectors",
995        deserialize_with = "deserialize_vectors"
996    )]
997    vectors: HashMap<Cid, Vec<f32>>,
998    next_id: usize,
999    max_nb_connection: usize,
1000    ef_construction: usize,
1001}
1002
1003/// Serialize HashMap<usize, Cid> by converting CIDs to strings
1004fn serialize_id_to_cid<S>(
1005    map: &HashMap<usize, Cid>,
1006    serializer: S,
1007) -> std::result::Result<S::Ok, S::Error>
1008where
1009    S: serde::Serializer,
1010{
1011    use serde::Serialize;
1012    let string_map: HashMap<usize, String> =
1013        map.iter().map(|(id, cid)| (*id, cid.to_string())).collect();
1014    string_map.serialize(serializer)
1015}
1016
1017/// Deserialize HashMap<usize, Cid> by parsing CID strings
1018fn deserialize_id_to_cid<'de, D>(
1019    deserializer: D,
1020) -> std::result::Result<HashMap<usize, Cid>, D::Error>
1021where
1022    D: serde::Deserializer<'de>,
1023{
1024    use serde::Deserialize;
1025    let string_map: HashMap<usize, String> = HashMap::deserialize(deserializer)?;
1026    string_map
1027        .into_iter()
1028        .map(|(id, cid_str)| {
1029            cid_str
1030                .parse::<Cid>()
1031                .map(|cid| (id, cid))
1032                .map_err(serde::de::Error::custom)
1033        })
1034        .collect()
1035}
1036
1037/// Serialize HashMap<Cid, usize> by converting CIDs to strings
1038fn serialize_cid_to_id<S>(
1039    map: &HashMap<Cid, usize>,
1040    serializer: S,
1041) -> std::result::Result<S::Ok, S::Error>
1042where
1043    S: serde::Serializer,
1044{
1045    use serde::Serialize;
1046    let string_map: HashMap<String, usize> =
1047        map.iter().map(|(cid, id)| (cid.to_string(), *id)).collect();
1048    string_map.serialize(serializer)
1049}
1050
1051/// Deserialize HashMap<Cid, usize> by parsing CID strings
1052fn deserialize_cid_to_id<'de, D>(
1053    deserializer: D,
1054) -> std::result::Result<HashMap<Cid, usize>, D::Error>
1055where
1056    D: serde::Deserializer<'de>,
1057{
1058    use serde::Deserialize;
1059    let string_map: HashMap<String, usize> = HashMap::deserialize(deserializer)?;
1060    string_map
1061        .into_iter()
1062        .map(|(cid_str, id)| {
1063            cid_str
1064                .parse::<Cid>()
1065                .map(|cid| (cid, id))
1066                .map_err(serde::de::Error::custom)
1067        })
1068        .collect()
1069}
1070
1071/// Serialize HashMap<Cid, Vec<f32>> by converting CIDs to strings
1072fn serialize_vectors<S>(
1073    map: &HashMap<Cid, Vec<f32>>,
1074    serializer: S,
1075) -> std::result::Result<S::Ok, S::Error>
1076where
1077    S: serde::Serializer,
1078{
1079    use serde::Serialize;
1080    let string_map: HashMap<String, Vec<f32>> = map
1081        .iter()
1082        .map(|(cid, vec)| (cid.to_string(), vec.clone()))
1083        .collect();
1084    string_map.serialize(serializer)
1085}
1086
1087/// Deserialize HashMap<Cid, Vec<f32>> by parsing CID strings
1088fn deserialize_vectors<'de, D>(
1089    deserializer: D,
1090) -> std::result::Result<HashMap<Cid, Vec<f32>>, D::Error>
1091where
1092    D: serde::Deserializer<'de>,
1093{
1094    use serde::Deserialize;
1095    let string_map: HashMap<String, Vec<f32>> = HashMap::deserialize(deserializer)?;
1096    string_map
1097        .into_iter()
1098        .map(|(cid_str, vec)| {
1099            cid_str
1100                .parse::<Cid>()
1101                .map(|cid| (cid, vec))
1102                .map_err(serde::de::Error::custom)
1103        })
1104        .collect()
1105}
1106
1107/// Use case for parameter optimization
1108#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
1109pub enum UseCase {
1110    /// Optimize for low latency (faster queries, potentially lower recall)
1111    LowLatency,
1112    /// Optimize for high recall (more accurate results, potentially slower)
1113    HighRecall,
1114    /// Balanced performance (default)
1115    #[default]
1116    Balanced,
1117    /// Optimize for memory efficiency
1118    LowMemory,
1119    /// Optimize for large scale (100k+ vectors)
1120    LargeScale,
1121}
1122
1123/// HNSW parameter recommendation
1124#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1125pub struct ParameterRecommendation {
1126    /// Recommended M parameter (connections per layer)
1127    pub m: usize,
1128    /// Recommended ef_construction parameter
1129    pub ef_construction: usize,
1130    /// Recommended ef_search parameter
1131    pub ef_search: usize,
1132    /// Estimated memory usage per vector (bytes)
1133    pub memory_per_vector: usize,
1134    /// Estimated recall at k=10
1135    pub estimated_recall: f32,
1136    /// Estimated query latency factor (1.0 = baseline)
1137    pub latency_factor: f32,
1138    /// Explanation of recommendations
1139    pub explanation: String,
1140}
1141
1142/// Parameter tuner for HNSW index optimization
1143pub struct ParameterTuner;
1144
1145impl ParameterTuner {
1146    /// Get parameter recommendations based on dataset size and use case
1147    pub fn recommend(
1148        num_vectors: usize,
1149        dimension: usize,
1150        use_case: UseCase,
1151    ) -> ParameterRecommendation {
1152        let (m, ef_construction, ef_search, recall, latency) = match use_case {
1153            UseCase::LowLatency => {
1154                if num_vectors < 10_000 {
1155                    (8, 100, 32, 0.90, 0.6)
1156                } else if num_vectors < 100_000 {
1157                    (12, 150, 50, 0.88, 0.7)
1158                } else {
1159                    (16, 200, 64, 0.85, 0.8)
1160                }
1161            }
1162            UseCase::HighRecall => {
1163                if num_vectors < 10_000 {
1164                    (32, 400, 200, 0.99, 2.0)
1165                } else if num_vectors < 100_000 {
1166                    (48, 500, 300, 0.98, 2.5)
1167                } else {
1168                    (64, 600, 400, 0.97, 3.0)
1169                }
1170            }
1171            UseCase::Balanced => {
1172                if num_vectors < 10_000 {
1173                    (16, 200, 50, 0.95, 1.0)
1174                } else if num_vectors < 100_000 {
1175                    (24, 300, 100, 0.94, 1.2)
1176                } else {
1177                    (32, 400, 150, 0.93, 1.5)
1178                }
1179            }
1180            UseCase::LowMemory => {
1181                if num_vectors < 10_000 {
1182                    (8, 100, 50, 0.88, 0.9)
1183                } else if num_vectors < 100_000 {
1184                    (10, 120, 64, 0.85, 1.0)
1185                } else {
1186                    (12, 150, 80, 0.82, 1.1)
1187                }
1188            }
1189            UseCase::LargeScale => {
1190                // Optimized for 100k+ vectors
1191                (32, 400, 100, 0.93, 1.5)
1192            }
1193        };
1194
1195        // Memory per vector: dimension * 4 (f32) + M * 2 * 4 (graph links, assuming 2 layers avg)
1196        let memory_per_vector = dimension * 4 + m * 2 * 4;
1197
1198        let explanation =
1199            Self::generate_explanation(num_vectors, use_case, m, ef_construction, ef_search);
1200
1201        ParameterRecommendation {
1202            m,
1203            ef_construction,
1204            ef_search,
1205            memory_per_vector,
1206            estimated_recall: recall,
1207            latency_factor: latency,
1208            explanation,
1209        }
1210    }
1211
1212    fn generate_explanation(
1213        num_vectors: usize,
1214        use_case: UseCase,
1215        m: usize,
1216        ef_construction: usize,
1217        ef_search: usize,
1218    ) -> String {
1219        let size_category = if num_vectors < 10_000 {
1220            "small"
1221        } else if num_vectors < 100_000 {
1222            "medium"
1223        } else {
1224            "large"
1225        };
1226
1227        let use_case_str = match use_case {
1228            UseCase::LowLatency => "low latency",
1229            UseCase::HighRecall => "high recall",
1230            UseCase::Balanced => "balanced",
1231            UseCase::LowMemory => "low memory",
1232            UseCase::LargeScale => "large scale",
1233        };
1234
1235        format!(
1236            "For {} dataset (~{} vectors) optimized for {}: \
1237            M={} provides good connectivity, ef_construction={} ensures quality graph, \
1238            ef_search={} balances speed and accuracy.",
1239            size_category, num_vectors, use_case_str, m, ef_construction, ef_search
1240        )
1241    }
1242
1243    /// Calculate Pareto-optimal configurations for different recall/latency tradeoffs
1244    pub fn pareto_configurations(
1245        num_vectors: usize,
1246        dimension: usize,
1247    ) -> Vec<ParameterRecommendation> {
1248        vec![
1249            Self::recommend(num_vectors, dimension, UseCase::LowLatency),
1250            Self::recommend(num_vectors, dimension, UseCase::LowMemory),
1251            Self::recommend(num_vectors, dimension, UseCase::Balanced),
1252            Self::recommend(num_vectors, dimension, UseCase::HighRecall),
1253        ]
1254    }
1255
1256    /// Estimate memory usage for given parameters
1257    pub fn estimate_memory(num_vectors: usize, dimension: usize, m: usize) -> usize {
1258        // Vector data: num_vectors * dimension * 4 bytes
1259        let vector_memory = num_vectors * dimension * 4;
1260
1261        // Graph memory: num_vectors * M * 2 layers average * 4 bytes per link
1262        let graph_memory = num_vectors * m * 2 * 4;
1263
1264        // Additional overhead (mappings, etc.): ~50 bytes per vector
1265        let overhead = num_vectors * 50;
1266
1267        vector_memory + graph_memory + overhead
1268    }
1269
1270    /// Suggest ef_search for target recall at given k
1271    pub fn ef_search_for_recall(k: usize, target_recall: f32) -> usize {
1272        // Higher ef_search improves recall
1273        // Approximate: ef_search = k * (1 / (1 - target_recall))
1274        let multiplier = if target_recall >= 0.99 {
1275            10.0
1276        } else if target_recall >= 0.95 {
1277            4.0
1278        } else if target_recall >= 0.90 {
1279            2.0
1280        } else {
1281            1.5
1282        };
1283
1284        ((k as f32) * multiplier).ceil() as usize
1285    }
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290    use super::*;
1291    use rand::RngExt;
1292
1293    #[test]
1294    fn test_vector_index_creation() {
1295        let index = VectorIndex::with_defaults(128);
1296        assert!(index.is_ok());
1297        let index = index.expect("test: unwrap valid index after is_ok check");
1298        assert_eq!(index.dimension(), 128);
1299        assert_eq!(index.len(), 0);
1300        assert!(index.is_empty());
1301    }
1302
1303    #[test]
1304    fn test_insert_and_search() {
1305        let mut index = VectorIndex::with_defaults(4).expect("test: create 4-dim index");
1306
1307        // Create some test vectors and CIDs
1308        let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1309            .parse::<Cid>()
1310            .expect("test: parse cid1");
1311        let vec1 = vec![1.0, 0.0, 0.0, 0.0];
1312
1313        let cid2 = "bafybeiczsscdsbs7ffqz55asqdf3smv6klcw3gofszvwlyarci47bgf354"
1314            .parse::<Cid>()
1315            .expect("test: parse cid2");
1316        let vec2 = vec![0.9, 0.1, 0.0, 0.0];
1317
1318        // Insert vectors
1319        index.insert(&cid1, &vec1).expect("test: insert cid1");
1320        index.insert(&cid2, &vec2).expect("test: insert cid2");
1321
1322        assert_eq!(index.len(), 2);
1323
1324        // Search for nearest neighbor
1325        let query = vec![1.0, 0.0, 0.0, 0.0];
1326        let results = index
1327            .search(&query, 1, 50)
1328            .expect("test: search for nearest");
1329
1330        assert_eq!(results.len(), 1);
1331        assert_eq!(results[0].cid, cid1);
1332    }
1333
1334    #[test]
1335    fn test_parameter_tuner() {
1336        // Test recommendations for different use cases
1337        let balanced = ParameterTuner::recommend(50_000, 768, UseCase::Balanced);
1338        assert!(balanced.m > 0);
1339        assert!(balanced.ef_construction > 0);
1340        assert!(balanced.estimated_recall > 0.0);
1341
1342        let low_latency = ParameterTuner::recommend(50_000, 768, UseCase::LowLatency);
1343        let high_recall = ParameterTuner::recommend(50_000, 768, UseCase::HighRecall);
1344
1345        // High recall should have higher M than low latency
1346        assert!(high_recall.m > low_latency.m);
1347        // High recall should have higher estimated recall
1348        assert!(high_recall.estimated_recall > low_latency.estimated_recall);
1349
1350        // Test Pareto configurations
1351        let pareto = ParameterTuner::pareto_configurations(50_000, 768);
1352        assert_eq!(pareto.len(), 4);
1353
1354        // Test memory estimation
1355        let memory = ParameterTuner::estimate_memory(100_000, 768, 16);
1356        assert!(memory > 0);
1357
1358        // Test ef_search for recall
1359        let ef_high = ParameterTuner::ef_search_for_recall(10, 0.99);
1360        let ef_low = ParameterTuner::ef_search_for_recall(10, 0.85);
1361        assert!(ef_high > ef_low);
1362    }
1363
1364    #[test]
1365    fn test_incremental_build() {
1366        let mut index =
1367            VectorIndex::with_defaults(4).expect("test: create 4-dim index for incremental");
1368
1369        // Create test vectors
1370        let items: Vec<(Cid, Vec<f32>)> = (0..20)
1371            .map(|i| {
1372                let cid_str = format!(
1373                    "bafybei{}yrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
1374                    i
1375                );
1376                let cid = cid_str.parse::<Cid>().unwrap_or_else(|_| {
1377                    "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1378                        .parse()
1379                        .expect("test: parse fallback cid")
1380                });
1381                let vec = vec![i as f32, 0.0, 0.0, 0.0];
1382                (cid, vec)
1383            })
1384            .collect();
1385
1386        // Insert incrementally with chunk size 5
1387        let stats = index
1388            .insert_incremental(&items, 5)
1389            .expect("test: insert incremental");
1390
1391        assert_eq!(stats.chunks_processed, 4);
1392        assert!(stats.vectors_inserted <= 20);
1393        assert_eq!(stats.final_size, index.len());
1394    }
1395
1396    #[test]
1397    fn test_build_health_stats() {
1398        let index = VectorIndex::new(128, DistanceMetric::L2, 16, 200)
1399            .expect("test: create L2 index for health stats");
1400
1401        let stats = index.get_build_stats();
1402        assert_eq!(stats.index_size, 0);
1403        assert_eq!(stats.current_m, 16);
1404        assert_eq!(stats.current_ef_construction, 200);
1405        assert!(stats.parameter_efficiency > 0.0);
1406
1407        // For small index with good parameters, no rebuild needed
1408        assert!(!stats.rebuild_recommended);
1409    }
1410
1411    #[test]
1412    fn test_should_rebuild() {
1413        // Small index with good parameters - no rebuild needed
1414        let index1 = VectorIndex::new(128, DistanceMetric::L2, 16, 200)
1415            .expect("test: create L2 index for should_rebuild");
1416        assert!(!index1.should_rebuild());
1417
1418        // Index with suboptimal parameters
1419        let index2 = VectorIndex::new(128, DistanceMetric::L2, 4, 50)
1420            .expect("test: create suboptimal L2 index");
1421        // Small index won't trigger rebuild based on size thresholds
1422        // but parameters are low
1423        let _ = index2.should_rebuild();
1424    }
1425
1426    #[test]
1427    fn test_rebuild() {
1428        let mut index = VectorIndex::with_defaults(4).expect("test: create vector index");
1429
1430        // Add some vectors
1431        for i in 0..10 {
1432            let cid_str = format!(
1433                "bafybei{}yrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
1434                i
1435            );
1436            let cid = cid_str.parse::<Cid>().unwrap_or_else(|_| {
1437                "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1438                    .parse()
1439                    .expect("test: parse cid")
1440            });
1441            let vec = vec![i as f32, 0.0, 0.0, 0.0];
1442            let _ = index.insert(&cid, &vec);
1443        }
1444
1445        // Rebuild with balanced use case
1446        let rebuild_stats = index
1447            .rebuild(UseCase::Balanced)
1448            .expect("test: rebuild index");
1449
1450        assert_eq!(rebuild_stats.old_parameters.0, 16); // Original M
1451        assert!(rebuild_stats.new_parameters.0 > 0); // New M
1452    }
1453
1454    /// Compute ground truth nearest neighbors using brute force
1455    fn compute_ground_truth(query: &[f32], vectors: &[(Cid, Vec<f32>)], k: usize) -> Vec<Cid> {
1456        let mut distances: Vec<(Cid, f32)> = vectors
1457            .iter()
1458            .map(|(cid, vec)| {
1459                let dist: f32 = query
1460                    .iter()
1461                    .zip(vec.iter())
1462                    .map(|(a, b)| (a - b).powi(2))
1463                    .sum();
1464                (*cid, dist.sqrt())
1465            })
1466            .collect();
1467
1468        distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1469        distances.iter().take(k).map(|(cid, _)| *cid).collect()
1470    }
1471
1472    /// Calculate recall@k
1473    fn calculate_recall_at_k(predicted: &[Cid], ground_truth: &[Cid], k: usize) -> f32 {
1474        let predicted_set: std::collections::HashSet<_> = predicted.iter().take(k).collect();
1475        let ground_truth_set: std::collections::HashSet<_> = ground_truth.iter().take(k).collect();
1476
1477        let intersection = predicted_set.intersection(&ground_truth_set).count();
1478        intersection as f32 / k as f32
1479    }
1480
1481    /// Helper to generate unique test CIDs
1482    fn generate_test_cid(index: usize) -> Cid {
1483        use multihash_codetable::{Code, MultihashDigest};
1484        let data = format!("test_vector_{}", index);
1485        let hash = Code::Sha2_256.digest(data.as_bytes());
1486        Cid::new_v1(0x55, hash) // 0x55 = raw codec
1487    }
1488
1489    #[test]
1490    fn test_recall_at_k() {
1491        // Create index
1492        let mut index = VectorIndex::with_defaults(128).expect("test: create vector index");
1493
1494        // Generate test dataset (100 random vectors)
1495        let mut rng = rand::rng();
1496        let num_vectors = 100;
1497        let dimension = 128;
1498
1499        let mut vectors = Vec::new();
1500        for i in 0..num_vectors {
1501            let cid = generate_test_cid(i);
1502
1503            let vec: Vec<f32> = (0..dimension)
1504                .map(|_| rng.random_range(-1.0..1.0))
1505                .collect();
1506
1507            vectors.push((cid, vec.clone()));
1508            let _ = index.insert(&cid, &vec);
1509        }
1510
1511        // Test queries
1512        let num_queries = 10;
1513        let mut total_recall_at_1 = 0.0;
1514        let mut total_recall_at_10 = 0.0;
1515
1516        for _ in 0..num_queries {
1517            let query: Vec<f32> = (0..dimension)
1518                .map(|_| rng.random_range(-1.0..1.0))
1519                .collect();
1520
1521            // Get HNSW results
1522            let hnsw_results = index.search(&query, 10, 50).expect("test: search index");
1523            let hnsw_cids: Vec<Cid> = hnsw_results.iter().map(|r| r.cid).collect();
1524
1525            // Compute ground truth
1526            let ground_truth = compute_ground_truth(&query, &vectors, 10);
1527
1528            // Calculate recall
1529            total_recall_at_1 += calculate_recall_at_k(&hnsw_cids, &ground_truth, 1);
1530            total_recall_at_10 += calculate_recall_at_k(&hnsw_cids, &ground_truth, 10);
1531        }
1532
1533        let avg_recall_at_1 = total_recall_at_1 / num_queries as f32;
1534        let avg_recall_at_10 = total_recall_at_10 / num_queries as f32;
1535
1536        // HNSW should have high recall (>80% for recall@10 on small dataset)
1537        assert!(
1538            avg_recall_at_10 > 0.8,
1539            "Recall@10 too low: {}",
1540            avg_recall_at_10
1541        );
1542
1543        // Recall@1 should be reasonable
1544        assert!(
1545            avg_recall_at_1 > 0.5,
1546            "Recall@1 too low: {}",
1547            avg_recall_at_1
1548        );
1549    }
1550
1551    #[test]
1552    fn test_concurrent_queries() {
1553        use std::sync::Arc;
1554        use std::thread;
1555
1556        // Create index
1557        let mut index = VectorIndex::with_defaults(128).expect("test: create vector index");
1558
1559        // Insert test vectors
1560        let mut rng = rand::rng();
1561        for i in 0..100 {
1562            let cid = generate_test_cid(i + 1000); // Offset to avoid collision with other tests
1563
1564            let vec: Vec<f32> = (0..128).map(|_| rng.random_range(-1.0..1.0)).collect();
1565
1566            let _ = index.insert(&cid, &vec);
1567        }
1568
1569        // Share index across threads
1570        let index = Arc::new(index);
1571        let num_threads = 10;
1572        let queries_per_thread = 100;
1573
1574        // Spawn threads for concurrent queries
1575        let mut handles = vec![];
1576        for _ in 0..num_threads {
1577            let index_clone = Arc::clone(&index);
1578            let handle = thread::spawn(move || {
1579                let mut thread_rng = rand::rng();
1580                let mut success_count = 0;
1581
1582                for _ in 0..queries_per_thread {
1583                    let query: Vec<f32> = (0..128)
1584                        .map(|_| thread_rng.random_range(-1.0..1.0))
1585                        .collect();
1586
1587                    if let Ok(results) = index_clone.search(&query, 10, 50) {
1588                        if !results.is_empty() {
1589                            success_count += 1;
1590                        }
1591                    }
1592                }
1593                success_count
1594            });
1595            handles.push(handle);
1596        }
1597
1598        // Collect results
1599        let mut total_success = 0;
1600        for handle in handles {
1601            total_success += handle.join().expect("test: thread join");
1602        }
1603
1604        // All queries should succeed
1605        let total_queries = num_threads * queries_per_thread;
1606        assert_eq!(
1607            total_success, total_queries,
1608            "Some queries failed under concurrent load"
1609        );
1610    }
1611
1612    #[test]
1613    fn test_precision_at_k() {
1614        // Create index
1615        let mut index = VectorIndex::with_defaults(32).expect("test: create vector index");
1616
1617        // Create structured dataset: 5 clusters of 10 vectors each
1618        let num_clusters = 5;
1619        let vectors_per_cluster = 10;
1620
1621        for cluster in 0..num_clusters {
1622            // Cluster center
1623            let mut center = [0.0; 32];
1624            center[cluster] = 10.0;
1625
1626            for i in 0..vectors_per_cluster {
1627                let idx = cluster * vectors_per_cluster + i;
1628                let cid = generate_test_cid(idx + 2000); // Offset to avoid collision
1629
1630                // Add small random noise to center
1631                let mut rng = rand::rng();
1632                let vec: Vec<f32> = center
1633                    .iter()
1634                    .map(|&c| c + rng.random_range(-0.5..0.5))
1635                    .collect();
1636
1637                let _ = index.insert(&cid, &vec);
1638            }
1639        }
1640
1641        // Query with a vector close to cluster 0
1642        let mut query = vec![0.0; 32];
1643        query[0] = 10.0;
1644
1645        let results = index.search(&query, 10, 50).expect("test: search index");
1646
1647        // Count how many results are from cluster 0 (first 10 CIDs)
1648        // Note: This is approximate since CID generation is not deterministic
1649        // In a real test, you'd track cluster membership explicitly
1650        assert_eq!(results.len(), 10, "Should return 10 results");
1651
1652        // Results should be relatively close to query
1653        for result in &results {
1654            assert!(
1655                result.score < 5.0,
1656                "Result too far from query: {}",
1657                result.score
1658            );
1659        }
1660    }
1661
1662    #[test]
1663    fn test_hnsw_memory_estimate() {
1664        let dim = 128;
1665        let mut index =
1666            VectorIndex::new(dim, DistanceMetric::L2, 16, 200).expect("test: create vector index");
1667
1668        // Empty index should estimate 0 bytes.
1669        assert_eq!(
1670            index.estimated_memory_bytes(),
1671            0,
1672            "empty index should report 0 bytes"
1673        );
1674
1675        // Insert 1000 vectors.
1676        for i in 0..1000_usize {
1677            let cid = generate_test_cid(i + 10_000);
1678            let vec = vec![i as f32 * 0.001; dim];
1679            index.insert(&cid, &vec).expect("test: insert vector");
1680        }
1681
1682        let estimate = index.estimated_memory_bytes();
1683        assert!(
1684            estimate > 0,
1685            "memory estimate should be > 0 after inserting 1000 vectors (got {})",
1686            estimate
1687        );
1688        // Sanity: at least dim*4 bytes per node (the vector storage alone).
1689        let lower_bound = 1000 * dim * 4;
1690        assert!(
1691            estimate >= lower_bound,
1692            "estimate {} should be >= lower bound {}",
1693            estimate,
1694            lower_bound
1695        );
1696    }
1697}