Skip to main content

ipfrs_semantic/
router.rs

1//! Semantic router for content discovery
2//!
3//! This module provides semantic routing capabilities that combine
4//! CID-based lookups with vector similarity search for intelligent
5//! content discovery.
6
7use crate::diskann::DiskANNIndex;
8use crate::hnsw::{DistanceMetric, SearchResult, VectorIndex};
9use crate::quantization::{dequantize_i8_to_f32, quantize_f32_to_i8};
10use ipfrs_core::{Cid, Error, Result};
11use lru::LruCache;
12use std::collections::hash_map::DefaultHasher;
13use std::hash::{Hash, Hasher};
14use std::num::NonZeroUsize;
15use std::sync::{Arc, RwLock};
16
17// ─────────────────────────────────────────────────────────────────────────────
18// Index backend selection
19// ─────────────────────────────────────────────────────────────────────────────
20
21/// Selectable vector index backend for [`SemanticRouter`].
22///
23/// Choose based on dataset size and storage constraints:
24/// - **HNSW** — fully in-memory, optimal for ≤ 10 M vectors.
25/// - **DiskANN** — memory-mapped graph; handles 100 M+ vectors with bounded RAM.
26#[derive(Debug, Clone, Default)]
27pub enum IndexBackend {
28    /// HNSW (default) — in-memory, fast search with high recall.
29    #[default]
30    Hnsw,
31    /// DiskANN — disk-backed graph index for massive-scale datasets.
32    DiskAnn {
33        /// Path at which the DiskANN graph file is created or opened.
34        graph_path: std::path::PathBuf,
35    },
36}
37
38/// Internal handle that owns either an HNSW or a DiskANN index.
39enum IndexHandle {
40    Hnsw(Arc<RwLock<VectorIndex>>),
41    DiskAnn(Arc<RwLock<DiskANNIndex>>),
42}
43
44/// Configuration for semantic router
45#[derive(Debug, Clone)]
46pub struct RouterConfig {
47    /// Vector dimension for embeddings
48    pub dimension: usize,
49    /// Distance metric to use
50    pub metric: DistanceMetric,
51    /// Maximum connections per HNSW layer
52    pub max_connections: usize,
53    /// HNSW construction parameter
54    pub ef_construction: usize,
55    /// Default search parameter
56    pub ef_search: usize,
57    /// Query result cache size (number of queries to cache)
58    pub cache_size: usize,
59    /// Which index backend to use (HNSW or DiskANN).
60    /// Defaults to [`IndexBackend::Hnsw`].
61    pub index_backend: IndexBackend,
62    /// When `true`, vectors are quantized to INT8 before being stored in the
63    /// index.  This trades a tiny accuracy loss for ~4× memory savings.
64    pub quantize_vectors: bool,
65    /// Bit-width for quantization: `8` for INT8 (default) or `1` for binary.
66    /// Only relevant when `quantize_vectors` is `true`.
67    pub quantization_bits: u8,
68}
69
70impl Default for RouterConfig {
71    fn default() -> Self {
72        Self {
73            dimension: 768, // Common dimension for sentence transformers
74            metric: DistanceMetric::Cosine,
75            max_connections: 16,
76            ef_construction: 200,
77            ef_search: 50,
78            cache_size: 1000, // Cache up to 1000 recent queries
79            index_backend: IndexBackend::Hnsw,
80            quantize_vectors: false,
81            quantization_bits: 8,
82        }
83    }
84}
85
86impl RouterConfig {
87    /// Create configuration optimized for low latency queries
88    ///
89    /// Best for: Real-time applications, interactive search, chat systems
90    /// Trade-offs: Slightly lower recall (~90%), faster queries
91    ///
92    /// # Example
93    /// ```
94    /// use ipfrs_semantic::RouterConfig;
95    ///
96    /// let config = RouterConfig::low_latency(768);
97    /// assert_eq!(config.dimension, 768);
98    /// // Optimized for speed with reasonable accuracy
99    /// ```
100    pub fn low_latency(dimension: usize) -> Self {
101        Self {
102            dimension,
103            metric: DistanceMetric::Cosine,
104            max_connections: 12,
105            ef_construction: 150,
106            ef_search: 32,
107            cache_size: 2000,
108            ..Self::default()
109        }
110    }
111
112    /// Create configuration optimized for high recall (accuracy)
113    ///
114    /// Best for: Research applications, critical retrieval, high-quality recommendations
115    /// Trade-offs: Slower queries, higher memory usage
116    ///
117    /// # Example
118    /// ```
119    /// use ipfrs_semantic::RouterConfig;
120    ///
121    /// let config = RouterConfig::high_recall(768);
122    /// assert_eq!(config.dimension, 768);
123    /// // Optimized for accuracy with acceptable latency
124    /// ```
125    pub fn high_recall(dimension: usize) -> Self {
126        Self {
127            dimension,
128            metric: DistanceMetric::Cosine,
129            max_connections: 32,
130            ef_construction: 400,
131            ef_search: 200,
132            cache_size: 1000,
133            ..Self::default()
134        }
135    }
136
137    /// Create configuration optimized for memory efficiency
138    ///
139    /// Best for: Edge devices, constrained environments, large datasets with limited RAM
140    /// Trade-offs: Lower recall (~85%), smaller cache
141    ///
142    /// # Example
143    /// ```
144    /// use ipfrs_semantic::RouterConfig;
145    ///
146    /// let config = RouterConfig::memory_efficient(384);
147    /// assert_eq!(config.dimension, 384);
148    /// // Smaller connections and cache for low memory footprint
149    /// ```
150    pub fn memory_efficient(dimension: usize) -> Self {
151        Self {
152            dimension,
153            metric: DistanceMetric::Cosine,
154            max_connections: 8,
155            ef_construction: 100,
156            ef_search: 50,
157            cache_size: 500,
158            ..Self::default()
159        }
160    }
161
162    /// Create configuration optimized for large-scale datasets (100k+ vectors)
163    ///
164    /// Best for: Production systems, large knowledge bases, document collections
165    /// Trade-offs: Higher memory usage, optimized for throughput
166    ///
167    /// # Example
168    /// ```
169    /// use ipfrs_semantic::RouterConfig;
170    ///
171    /// let config = RouterConfig::large_scale(768);
172    /// assert_eq!(config.dimension, 768);
173    /// // Balanced for large datasets with good performance
174    /// ```
175    pub fn large_scale(dimension: usize) -> Self {
176        Self {
177            dimension,
178            metric: DistanceMetric::Cosine,
179            max_connections: 24,
180            ef_construction: 300,
181            ef_search: 100,
182            cache_size: 5000,
183            ..Self::default()
184        }
185    }
186
187    /// Create configuration for balanced performance (alias for default)
188    ///
189    /// Best for: General purpose, getting started, typical applications
190    /// Trade-offs: Balanced recall (~95%) and latency
191    ///
192    /// # Example
193    /// ```
194    /// use ipfrs_semantic::RouterConfig;
195    ///
196    /// let config = RouterConfig::balanced(768);
197    /// assert_eq!(config.dimension, 768);
198    /// // Good all-around configuration
199    /// ```
200    pub fn balanced(dimension: usize) -> Self {
201        Self {
202            dimension,
203            metric: DistanceMetric::Cosine,
204            max_connections: 16,
205            ef_construction: 200,
206            ef_search: 50,
207            cache_size: 1000,
208            ..Self::default()
209        }
210    }
211
212    /// Create configuration with custom distance metric
213    ///
214    /// # Example
215    /// ```
216    /// use ipfrs_semantic::{RouterConfig, DistanceMetric};
217    ///
218    /// let config = RouterConfig::with_metric(768, DistanceMetric::L2);
219    /// assert_eq!(config.dimension, 768);
220    /// ```
221    pub fn with_metric(dimension: usize, metric: DistanceMetric) -> Self {
222        Self {
223            dimension,
224            metric,
225            ..Self::balanced(dimension)
226        }
227    }
228
229    /// Set the query result cache size
230    ///
231    /// # Example
232    /// ```
233    /// use ipfrs_semantic::RouterConfig;
234    ///
235    /// let config = RouterConfig::balanced(768).with_cache_size(5000);
236    /// assert_eq!(config.cache_size, 5000);
237    /// ```
238    pub fn with_cache_size(mut self, size: usize) -> Self {
239        self.cache_size = size;
240        self
241    }
242
243    /// Set the ef_search parameter for query-time search
244    ///
245    /// Higher values improve recall but increase latency
246    ///
247    /// # Example
248    /// ```
249    /// use ipfrs_semantic::RouterConfig;
250    ///
251    /// let config = RouterConfig::balanced(768).with_ef_search(100);
252    /// assert_eq!(config.ef_search, 100);
253    /// ```
254    pub fn with_ef_search(mut self, ef_search: usize) -> Self {
255        self.ef_search = ef_search;
256        self
257    }
258}
259
260/// Query filter for semantic search
261#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
262pub struct QueryFilter {
263    /// Minimum similarity score threshold
264    pub min_score: Option<f32>,
265    /// Maximum similarity score threshold (for range queries)
266    pub max_score: Option<f32>,
267    /// Maximum number of results
268    pub max_results: Option<usize>,
269    /// Specific CID prefix to filter by
270    pub cid_prefix: Option<String>,
271}
272
273impl Default for QueryFilter {
274    fn default() -> Self {
275        Self {
276            min_score: None,
277            max_score: None,
278            max_results: Some(10),
279            cid_prefix: None,
280        }
281    }
282}
283
284impl QueryFilter {
285    /// Create a range filter for scores
286    pub fn range(min: f32, max: f32) -> Self {
287        Self {
288            min_score: Some(min),
289            max_score: Some(max),
290            max_results: None,
291            cid_prefix: None,
292        }
293    }
294
295    /// Create a threshold filter (minimum score)
296    pub fn threshold(min: f32) -> Self {
297        Self {
298            min_score: Some(min),
299            max_score: None,
300            max_results: None,
301            cid_prefix: None,
302        }
303    }
304
305    /// Create a prefix filter (CID prefix matching)
306    pub fn prefix(prefix: String) -> Self {
307        Self {
308            min_score: None,
309            max_score: None,
310            max_results: None,
311            cid_prefix: Some(prefix),
312        }
313    }
314
315    /// Combine with another filter (AND operation)
316    pub fn and(mut self, other: QueryFilter) -> Self {
317        if let Some(min) = other.min_score {
318            self.min_score = Some(self.min_score.unwrap_or(f32::MIN).max(min));
319        }
320        if let Some(max) = other.max_score {
321            self.max_score = Some(self.max_score.unwrap_or(f32::MAX).min(max));
322        }
323        if let Some(max_results) = other.max_results {
324            self.max_results = Some(self.max_results.unwrap_or(usize::MAX).min(max_results));
325        }
326        if other.cid_prefix.is_some() {
327            self.cid_prefix = other.cid_prefix;
328        }
329        self
330    }
331
332    /// Set maximum results
333    pub fn limit(mut self, max: usize) -> Self {
334        self.max_results = Some(max);
335        self
336    }
337}
338
339/// Cache key for query results
340type QueryCacheKey = u64;
341
342/// Semantic router combining CID-based and vector-based search
343///
344/// Provides intelligent content discovery through vector similarity
345/// search over content embeddings.  Supports both HNSW (in-memory) and
346/// DiskANN (disk-backed) backends, and optional INT8 vector quantization.
347pub struct SemanticRouter {
348    /// Underlying vector index (HNSW or DiskANN)
349    index: IndexHandle,
350    /// Router configuration
351    config: RouterConfig,
352    /// Query result cache (LRU)
353    query_cache: Arc<RwLock<LruCache<QueryCacheKey, Vec<SearchResult>>>>,
354}
355
356impl SemanticRouter {
357    /// Create a new semantic router with the given configuration.
358    ///
359    /// When the backend is [`IndexBackend::DiskAnn`] the graph file is created
360    /// at `graph_path` if it does not yet exist.
361    pub fn new(config: RouterConfig) -> Result<Self> {
362        let cache_size = NonZeroUsize::new(config.cache_size)
363            .unwrap_or_else(|| NonZeroUsize::new(1000).expect("1000 is non-zero"));
364        let query_cache = LruCache::new(cache_size);
365
366        let index = match &config.index_backend {
367            IndexBackend::Hnsw => {
368                let hnsw = VectorIndex::new(
369                    config.dimension,
370                    config.metric,
371                    config.max_connections,
372                    config.ef_construction,
373                )?;
374                IndexHandle::Hnsw(Arc::new(RwLock::new(hnsw)))
375            }
376            IndexBackend::DiskAnn { graph_path } => {
377                use crate::diskann::DiskANNConfig;
378                let da_config = DiskANNConfig {
379                    dimension: config.dimension,
380                    ..DiskANNConfig::default()
381                };
382                let mut da_index = DiskANNIndex::new(da_config);
383                da_index.create(graph_path)?;
384                IndexHandle::DiskAnn(Arc::new(RwLock::new(da_index)))
385            }
386        };
387
388        Ok(Self {
389            index,
390            config,
391            query_cache: Arc::new(RwLock::new(query_cache)),
392        })
393    }
394
395    /// Create a new router with default configuration
396    pub fn with_defaults() -> Result<Self> {
397        Self::new(RouterConfig::default())
398    }
399
400    /// Optionally quantize `embedding` to INT8 and back to f32.
401    ///
402    /// When `config.quantize_vectors` is `true` this reduces storage fidelity
403    /// slightly but saves ~4× memory inside the index.
404    fn maybe_quantize(&self, embedding: &[f32]) -> Vec<f32> {
405        if self.config.quantize_vectors {
406            let (q, scale, zero_point) = quantize_f32_to_i8(embedding);
407            dequantize_i8_to_f32(&q, scale, zero_point)
408        } else {
409            embedding.to_vec()
410        }
411    }
412
413    /// Helper: convert a `diskann::SearchResult` (distance-based) to the
414    /// common `hnsw::SearchResult` (score-based) used throughout the router.
415    ///
416    /// Score = 1 / (1 + distance) so that closer vectors get higher scores.
417    fn diskann_to_search_result(r: crate::diskann::SearchResult) -> SearchResult {
418        SearchResult {
419            cid: r.cid,
420            score: 1.0 / (1.0 + r.distance),
421        }
422    }
423
424    /// Add content with its embedding to the router.
425    ///
426    /// When `config.quantize_vectors` is `true` the embedding is first
427    /// quantized to INT8 and dequantized before being stored.
428    ///
429    /// # Arguments
430    /// * `cid` - Content identifier
431    /// * `embedding` - Vector embedding of the content
432    pub fn add(&self, cid: &Cid, embedding: &[f32]) -> Result<()> {
433        let v = self.maybe_quantize(embedding);
434        match &self.index {
435            IndexHandle::Hnsw(idx) => idx
436                .write()
437                .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
438                .insert(cid, &v),
439            IndexHandle::DiskAnn(idx) => idx
440                .write()
441                .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
442                .insert(cid, &v),
443        }
444    }
445
446    /// Add multiple content items in batch.
447    ///
448    /// For HNSW backends this uses the optimised bulk-insert path; for DiskANN
449    /// it falls back to sequential inserts.
450    ///
451    /// # Arguments
452    /// * `items` - Vector of (CID, embedding) pairs
453    pub fn add_batch(&self, items: &[(Cid, Vec<f32>)]) -> Result<()> {
454        match &self.index {
455            IndexHandle::Hnsw(idx) => {
456                if self.config.quantize_vectors {
457                    let quantized: Vec<(Cid, Vec<f32>)> = items
458                        .iter()
459                        .map(|(cid, emb)| (*cid, self.maybe_quantize(emb)))
460                        .collect();
461                    idx.write()
462                        .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
463                        .insert_batch(&quantized)
464                } else {
465                    idx.write()
466                        .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
467                        .insert_batch(items)
468                }
469            }
470            IndexHandle::DiskAnn(idx) => {
471                for (cid, emb) in items {
472                    let v = self.maybe_quantize(emb);
473                    idx.write()
474                        .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
475                        .insert(cid, &v)?;
476                }
477                Ok(())
478            }
479        }
480    }
481
482    /// Remove content from the router.
483    ///
484    /// **Note:** DiskANN does not support deletion; this returns an error for
485    /// that backend.
486    pub fn remove(&self, cid: &Cid) -> Result<()> {
487        match &self.index {
488            IndexHandle::Hnsw(idx) => idx
489                .write()
490                .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
491                .delete(cid),
492            IndexHandle::DiskAnn(_) => Err(Error::InvalidInput(
493                "DiskANN backend does not support deletion".to_string(),
494            )),
495        }
496    }
497
498    /// Check if content exists in the router.
499    ///
500    /// **Note:** For DiskANN this always returns `false` because the CID map
501    /// is not publicly exposed by the backend.
502    pub fn contains(&self, cid: &Cid) -> bool {
503        match &self.index {
504            IndexHandle::Hnsw(idx) => idx.read().map(|g| g.contains(cid)).unwrap_or(false),
505            IndexHandle::DiskAnn(_) => false,
506        }
507    }
508
509    /// Query for content by semantic similarity
510    ///
511    /// # Arguments
512    /// * `query_embedding` - Query vector
513    /// * `k` - Number of results to return
514    pub async fn query(&self, query_embedding: &[f32], k: usize) -> Result<Vec<SearchResult>> {
515        self.query_with_filter(query_embedding, k, QueryFilter::default())
516            .await
517    }
518
519    /// Query with auto-tuned ef_search parameter.
520    ///
521    /// For HNSW backends, computes the optimal ef_search based on k and index
522    /// size.  For DiskANN, falls back to the configured `ef_search` value.
523    ///
524    /// # Arguments
525    /// * `query_embedding` - Query vector
526    /// * `k` - Number of results to return
527    pub async fn query_auto(&self, query_embedding: &[f32], k: usize) -> Result<Vec<SearchResult>> {
528        let optimal_ef_search = match &self.index {
529            IndexHandle::Hnsw(idx) => idx
530                .read()
531                .map(|g| g.compute_optimal_ef_search(k))
532                .unwrap_or(self.config.ef_search),
533            IndexHandle::DiskAnn(_) => self.config.ef_search,
534        };
535        self.query_with_ef(query_embedding, k, optimal_ef_search)
536            .await
537    }
538
539    /// Query with custom ef_search parameter.
540    ///
541    /// The `ef_search` parameter is ignored for DiskANN (the backend controls
542    /// its own search list size).
543    ///
544    /// # Arguments
545    /// * `query_embedding` - Query vector
546    /// * `k` - Number of results to return
547    /// * `ef_search` - Search parameter (higher = more accurate but slower)
548    pub async fn query_with_ef(
549        &self,
550        query_embedding: &[f32],
551        k: usize,
552        ef_search: usize,
553    ) -> Result<Vec<SearchResult>> {
554        let cache_key = Self::compute_cache_key(query_embedding, k, &QueryFilter::default());
555
556        if let Some(cached) = self
557            .query_cache
558            .write()
559            .map_err(|_| Error::Internal("cache lock poisoned".into()))?
560            .get(&cache_key)
561        {
562            return Ok(cached.clone());
563        }
564
565        let results = self.search_backend(query_embedding, k, ef_search)?;
566
567        self.query_cache
568            .write()
569            .map_err(|_| Error::Internal("cache lock poisoned".into()))?
570            .put(cache_key, results.clone());
571
572        Ok(results)
573    }
574
575    /// Dispatch a search to the active backend and return unified `SearchResult`s.
576    fn search_backend(
577        &self,
578        query: &[f32],
579        k: usize,
580        ef_search: usize,
581    ) -> Result<Vec<SearchResult>> {
582        match &self.index {
583            IndexHandle::Hnsw(idx) => idx
584                .read()
585                .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
586                .search(query, k, ef_search),
587            IndexHandle::DiskAnn(idx) => {
588                let raw = idx
589                    .read()
590                    .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
591                    .search(query, k)?;
592                Ok(raw
593                    .into_iter()
594                    .map(Self::diskann_to_search_result)
595                    .collect())
596            }
597        }
598    }
599
600    /// Query with filtering options
601    ///
602    /// # Arguments
603    /// * `query_embedding` - Query vector
604    /// * `k` - Number of results to return
605    /// * `filter` - Query filter options
606    pub async fn query_with_filter(
607        &self,
608        query_embedding: &[f32],
609        k: usize,
610        filter: QueryFilter,
611    ) -> Result<Vec<SearchResult>> {
612        let cache_key = Self::compute_cache_key(query_embedding, k, &filter);
613
614        if filter.min_score.is_none() && filter.cid_prefix.is_none() {
615            if let Some(cached) = self
616                .query_cache
617                .write()
618                .map_err(|_| Error::Internal("cache lock poisoned".into()))?
619                .get(&cache_key)
620            {
621                return Ok(cached.clone());
622            }
623        }
624
625        let fetch_k = if filter.min_score.is_some() || filter.cid_prefix.is_some() {
626            k * 2
627        } else {
628            k
629        };
630
631        let mut results = self.search_backend(query_embedding, fetch_k, self.config.ef_search)?;
632
633        if let Some(min_score) = filter.min_score {
634            results.retain(|r| r.score >= min_score);
635        }
636
637        if let Some(max_score) = filter.max_score {
638            results.retain(|r| r.score <= max_score);
639        }
640
641        if let Some(ref prefix) = filter.cid_prefix {
642            results.retain(|r| r.cid.to_string().starts_with(prefix));
643        }
644
645        if let Some(max_results) = filter.max_results {
646            results.truncate(max_results);
647        }
648
649        if filter.min_score.is_none() && filter.cid_prefix.is_none() {
650            self.query_cache
651                .write()
652                .map_err(|_| Error::Internal("cache lock poisoned".into()))?
653                .put(cache_key, results.clone());
654        }
655
656        Ok(results)
657    }
658
659    /// Compute a cache key from query parameters
660    fn compute_cache_key(embedding: &[f32], k: usize, filter: &QueryFilter) -> QueryCacheKey {
661        let mut hasher = DefaultHasher::new();
662
663        // Hash embedding values (sample to avoid too much computation)
664        for (i, &val) in embedding.iter().enumerate().step_by(8) {
665            (i, (val * 1000.0) as i32).hash(&mut hasher);
666        }
667
668        k.hash(&mut hasher);
669        filter.max_results.hash(&mut hasher);
670
671        hasher.finish()
672    }
673
674    /// Clear the query result cache
675    pub fn clear_cache(&self) {
676        if let Ok(mut cache) = self.query_cache.write() {
677            cache.clear();
678        }
679    }
680
681    /// Get cache statistics
682    pub fn cache_stats(&self) -> CacheStats {
683        match self.query_cache.read() {
684            Ok(cache) => CacheStats {
685                size: cache.len(),
686                capacity: cache.cap().get(),
687            },
688            Err(_) => CacheStats {
689                size: 0,
690                capacity: 0,
691            },
692        }
693    }
694
695    /// Get statistics about the router
696    pub fn stats(&self) -> RouterStats {
697        match &self.index {
698            IndexHandle::Hnsw(idx) => {
699                let guard = idx.read().unwrap_or_else(|p| p.into_inner());
700                RouterStats {
701                    num_vectors: guard.len(),
702                    dimension: guard.dimension(),
703                    metric: guard.metric(),
704                }
705            }
706            IndexHandle::DiskAnn(idx) => {
707                let guard = idx.read().unwrap_or_else(|p| p.into_inner());
708                let s = guard.stats();
709                RouterStats {
710                    num_vectors: s.num_vectors,
711                    dimension: s.dimension,
712                    metric: DistanceMetric::L2,
713                }
714            }
715        }
716    }
717
718    /// Estimated memory usage in bytes for the underlying index.
719    ///
720    /// For HNSW delegates to [`VectorIndex::estimated_memory_bytes`].
721    /// For DiskANN returns the estimated disk size instead (most data is memory-mapped).
722    pub fn estimated_memory_bytes(&self) -> ipfrs_core::Result<usize> {
723        match &self.index {
724            IndexHandle::Hnsw(idx) => {
725                let guard = idx.read().map_err(|_| {
726                    ipfrs_core::Error::Storage("HNSW index lock poisoned".to_string())
727                })?;
728                Ok(guard.estimated_memory_bytes())
729            }
730            IndexHandle::DiskAnn(idx) => {
731                let guard = idx.read().map_err(|_| {
732                    ipfrs_core::Error::Storage("DiskANN index lock poisoned".to_string())
733                })?;
734                Ok(guard.stats().estimated_disk_size)
735            }
736        }
737    }
738
739    /// Get optimization recommendations.
740    ///
741    /// For DiskANN backends, returns defaults as HNSW-specific tuning does
742    /// not apply.
743    pub fn optimization_recommendations(&self) -> OptimizationRecommendations {
744        match &self.index {
745            IndexHandle::Hnsw(idx) => {
746                let guard = idx.read().unwrap_or_else(|p| p.into_inner());
747                let (m, ef_construction) = guard.compute_optimal_parameters();
748                OptimizationRecommendations {
749                    recommended_m: m,
750                    recommended_ef_construction: ef_construction,
751                    current_size: guard.len(),
752                }
753            }
754            IndexHandle::DiskAnn(idx) => {
755                let guard = idx.read().unwrap_or_else(|p| p.into_inner());
756                let s = guard.stats();
757                OptimizationRecommendations {
758                    recommended_m: 64,
759                    recommended_ef_construction: 100,
760                    current_size: s.num_vectors,
761                }
762            }
763        }
764    }
765
766    /// Save the semantic index to a file.
767    ///
768    /// For HNSW backends, serializes the full index to the given path.
769    /// For DiskANN, persists all in-memory graph data to the backing file.
770    ///
771    /// # Arguments
772    /// * `path` - Path to save the index file (only used for HNSW)
773    pub async fn save_index<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
774        match &self.index {
775            IndexHandle::Hnsw(idx) => idx
776                .read()
777                .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))?
778                .save(path.as_ref()),
779            IndexHandle::DiskAnn(idx) => idx
780                .read()
781                .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
782                .save(),
783        }
784    }
785
786    /// Save the semantic index using smart incremental logic.
787    ///
788    /// Only supported for HNSW backends (falls back to full save for DiskANN).
789    ///
790    /// # Arguments
791    /// * `path` - Base path of the snapshot file
792    pub async fn save_index_smart<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
793        match &self.index {
794            IndexHandle::Hnsw(idx) => {
795                use crate::persistence::IndexPersistence;
796                let persistence = IndexPersistence::new(path.as_ref());
797                let index_guard = idx.read().map_err(|_| {
798                    ipfrs_core::Error::Internal("index lock poisoned in save_index_smart".into())
799                })?;
800                persistence.save_smart(&index_guard)
801            }
802            IndexHandle::DiskAnn(idx) => idx
803                .read()
804                .map_err(|_| Error::Internal("DiskANN index lock poisoned".into()))?
805                .save(),
806        }
807    }
808
809    /// Load a semantic index from a file and apply any available incremental
810    /// delta on top of the loaded full snapshot.
811    ///
812    /// Only supported for HNSW backends.
813    ///
814    /// # Arguments
815    /// * `path` - Path to the full snapshot file
816    pub async fn load_index_with_delta<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
817        match &self.index {
818            IndexHandle::Hnsw(idx) => {
819                use crate::persistence::{IndexPersistence, IndexPersistence as IP};
820                let persistence = IP::new(path.as_ref());
821
822                let mut snap = persistence.load()?;
823
824                if let Ok(delta) = persistence.load_incremental() {
825                    if delta.delta_version > delta.base_version {
826                        tracing::debug!(
827                            base_version = delta.base_version,
828                            delta_version = delta.delta_version,
829                            changed = delta.changed_entries.len(),
830                            "Applying incremental HNSW delta on top of full snapshot"
831                        );
832                        IndexPersistence::apply_incremental(&mut snap, &delta)?;
833                    }
834                }
835
836                let loaded_index = VectorIndex::from_snapshot(&snap)?;
837                *idx.write().map_err(|_| {
838                    ipfrs_core::Error::Internal(
839                        "index write lock poisoned in load_index_with_delta".into(),
840                    )
841                })? = loaded_index;
842
843                self.clear_cache();
844                Ok(())
845            }
846            IndexHandle::DiskAnn(_) => Err(Error::InvalidInput(
847                "load_index_with_delta is not supported for DiskANN backend".to_string(),
848            )),
849        }
850    }
851
852    /// Load a semantic index from a file.
853    ///
854    /// Only supported for HNSW backends; DiskANN indices are always loaded via
855    /// `RouterConfig` at construction time.
856    ///
857    /// # Arguments
858    /// * `path` - Path to the saved index file
859    pub async fn load_index<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
860        match &self.index {
861            IndexHandle::Hnsw(idx) => {
862                let loaded_index = VectorIndex::load(path.as_ref())?;
863                *idx.write()
864                    .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))? =
865                    loaded_index;
866                self.clear_cache();
867                Ok(())
868            }
869            IndexHandle::DiskAnn(_) => Err(Error::InvalidInput(
870                "load_index is not supported for DiskANN backend; use RouterConfig at construction"
871                    .to_string(),
872            )),
873        }
874    }
875
876    /// Clear all content from the router.
877    ///
878    /// For DiskANN backends this returns an error; use a new router instance
879    /// with a fresh graph path to start over.
880    pub fn clear(&self) -> Result<()> {
881        match &self.index {
882            IndexHandle::Hnsw(idx) => {
883                let new_index = VectorIndex::new(
884                    self.config.dimension,
885                    self.config.metric,
886                    self.config.max_connections,
887                    self.config.ef_construction,
888                )?;
889                *idx.write()
890                    .map_err(|_| Error::Internal("HNSW index lock poisoned".into()))? = new_index;
891                self.clear_cache();
892                Ok(())
893            }
894            IndexHandle::DiskAnn(_) => Err(Error::InvalidInput(
895                "clear is not supported for DiskANN backend".to_string(),
896            )),
897        }
898    }
899
900    /// Query with aggregations
901    ///
902    /// Returns both results and aggregated statistics
903    ///
904    /// # Arguments
905    /// * `query_embedding` - Query vector
906    /// * `k` - Number of results to return
907    /// * `filter` - Query filter options
908    pub async fn query_with_aggregations(
909        &self,
910        query_embedding: &[f32],
911        k: usize,
912        filter: QueryFilter,
913    ) -> Result<(Vec<SearchResult>, SearchAggregations)> {
914        let results = self.query_with_filter(query_embedding, k, filter).await?;
915        let aggregations = SearchAggregations::from_results(&results);
916        Ok((results, aggregations))
917    }
918
919    /// Batch query for multiple embeddings at once
920    ///
921    /// More efficient than querying one by one due to parallelization
922    /// and amortized overhead.
923    ///
924    /// # Arguments
925    /// * `query_embeddings` - Multiple query vectors
926    /// * `k` - Number of results to return per query
927    ///
928    /// # Returns
929    /// Vector of search results, one for each query in the same order
930    pub async fn query_batch(
931        &self,
932        query_embeddings: &[Vec<f32>],
933        k: usize,
934    ) -> Result<Vec<Vec<SearchResult>>> {
935        self.query_batch_with_filter(query_embeddings, k, QueryFilter::default())
936            .await
937    }
938
939    /// Batch query with filtering options
940    ///
941    /// Processes multiple queries in parallel with filtering applied to each.
942    ///
943    /// # Arguments
944    /// * `query_embeddings` - Multiple query vectors
945    /// * `k` - Number of results to return per query
946    /// * `filter` - Query filter options (applied to all queries)
947    ///
948    /// # Returns
949    /// Vector of search results, one for each query in the same order
950    pub async fn query_batch_with_filter(
951        &self,
952        query_embeddings: &[Vec<f32>],
953        k: usize,
954        filter: QueryFilter,
955    ) -> Result<Vec<Vec<SearchResult>>> {
956        use rayon::prelude::*;
957
958        let ef_search = self.config.ef_search;
959
960        let results: Result<Vec<Vec<SearchResult>>> = query_embeddings
961            .par_iter()
962            .map(|embedding| {
963                let cache_key = Self::compute_cache_key(embedding, k, &filter);
964
965                if filter.min_score.is_none() && filter.cid_prefix.is_none() {
966                    if let Ok(mut cache) = self.query_cache.write() {
967                        if let Some(cached) = cache.get(&cache_key) {
968                            return Ok(cached.clone());
969                        }
970                    }
971                }
972
973                let fetch_k = if filter.min_score.is_some() || filter.cid_prefix.is_some() {
974                    k * 2
975                } else {
976                    k
977                };
978
979                let mut results = self.search_backend(embedding, fetch_k, ef_search)?;
980
981                if let Some(min_score) = filter.min_score {
982                    results.retain(|r| r.score >= min_score);
983                }
984                if let Some(max_score) = filter.max_score {
985                    results.retain(|r| r.score <= max_score);
986                }
987                if let Some(ref prefix) = filter.cid_prefix {
988                    results.retain(|r| r.cid.to_string().starts_with(prefix));
989                }
990                if let Some(max_results) = filter.max_results {
991                    results.truncate(max_results);
992                }
993
994                if filter.min_score.is_none() && filter.cid_prefix.is_none() {
995                    if let Ok(mut cache) = self.query_cache.write() {
996                        cache.put(cache_key, results.clone());
997                    }
998                }
999
1000                Ok(results)
1001            })
1002            .collect();
1003
1004        results
1005    }
1006
1007    /// Batch query with custom ef_search parameter
1008    ///
1009    /// Processes multiple queries in parallel with custom search parameter.
1010    ///
1011    /// # Arguments
1012    /// * `query_embeddings` - Multiple query vectors
1013    /// * `k` - Number of results to return per query
1014    /// * `ef_search` - Search parameter (higher = more accurate but slower)
1015    ///
1016    /// # Returns
1017    /// Vector of search results, one for each query in the same order
1018    pub async fn query_batch_with_ef(
1019        &self,
1020        query_embeddings: &[Vec<f32>],
1021        k: usize,
1022        ef_search: usize,
1023    ) -> Result<Vec<Vec<SearchResult>>> {
1024        use rayon::prelude::*;
1025
1026        let results: Result<Vec<Vec<SearchResult>>> = query_embeddings
1027            .par_iter()
1028            .map(|embedding| {
1029                let cache_key = Self::compute_cache_key(embedding, k, &QueryFilter::default());
1030
1031                if let Ok(mut cache) = self.query_cache.write() {
1032                    if let Some(cached) = cache.get(&cache_key) {
1033                        return Ok(cached.clone());
1034                    }
1035                }
1036
1037                let results = self.search_backend(embedding, k, ef_search)?;
1038
1039                if let Ok(mut cache) = self.query_cache.write() {
1040                    cache.put(cache_key, results.clone());
1041                }
1042
1043                Ok(results)
1044            })
1045            .collect();
1046
1047        results
1048    }
1049
1050    /// Get batch query statistics
1051    ///
1052    /// Returns aggregated statistics across a batch of queries
1053    pub fn batch_stats(&self, batch_results: &[Vec<SearchResult>]) -> BatchStats {
1054        let total_queries = batch_results.len();
1055        let total_results: usize = batch_results.iter().map(|r| r.len()).sum();
1056        let avg_results_per_query = if total_queries > 0 {
1057            total_results as f32 / total_queries as f32
1058        } else {
1059            0.0
1060        };
1061
1062        let all_scores: Vec<f32> = batch_results
1063            .iter()
1064            .flat_map(|results| results.iter().map(|r| r.score))
1065            .collect();
1066
1067        let avg_score = if !all_scores.is_empty() {
1068            all_scores.iter().sum::<f32>() / all_scores.len() as f32
1069        } else {
1070            0.0
1071        };
1072
1073        BatchStats {
1074            total_queries,
1075            total_results,
1076            avg_results_per_query,
1077            avg_score,
1078        }
1079    }
1080}
1081
1082/// Router statistics
1083#[derive(Debug, Clone)]
1084pub struct RouterStats {
1085    /// Number of indexed vectors
1086    pub num_vectors: usize,
1087    /// Vector dimension
1088    pub dimension: usize,
1089    /// Distance metric
1090    pub metric: DistanceMetric,
1091}
1092
1093/// Cache statistics
1094#[derive(Debug, Clone)]
1095pub struct CacheStats {
1096    /// Current number of cached entries
1097    pub size: usize,
1098    /// Maximum cache capacity
1099    pub capacity: usize,
1100}
1101
1102/// Batch query statistics
1103#[derive(Debug, Clone)]
1104pub struct BatchStats {
1105    /// Total number of queries in batch
1106    pub total_queries: usize,
1107    /// Total number of results across all queries
1108    pub total_results: usize,
1109    /// Average number of results per query
1110    pub avg_results_per_query: f32,
1111    /// Average similarity score across all results
1112    pub avg_score: f32,
1113}
1114
1115/// Optimization recommendations for HNSW index
1116#[derive(Debug, Clone)]
1117pub struct OptimizationRecommendations {
1118    /// Recommended M parameter (max connections per layer)
1119    pub recommended_m: usize,
1120    /// Recommended ef_construction parameter
1121    pub recommended_ef_construction: usize,
1122    /// Current index size
1123    pub current_size: usize,
1124}
1125
1126/// Search result aggregations
1127#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1128pub struct SearchAggregations {
1129    /// Total number of results
1130    pub total_count: usize,
1131    /// Average similarity score
1132    pub avg_score: f32,
1133    /// Minimum score in results
1134    pub min_score: f32,
1135    /// Maximum score in results
1136    pub max_score: f32,
1137    /// Score distribution by buckets
1138    pub score_buckets: Vec<ScoreBucket>,
1139}
1140
1141/// Score bucket for distribution analysis
1142#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1143pub struct ScoreBucket {
1144    /// Bucket range (min, max)
1145    pub range: (f32, f32),
1146    /// Count of results in this bucket
1147    pub count: usize,
1148}
1149
1150impl SearchAggregations {
1151    /// Compute aggregations from search results
1152    pub fn from_results(results: &[SearchResult]) -> Self {
1153        if results.is_empty() {
1154            return Self {
1155                total_count: 0,
1156                avg_score: 0.0,
1157                min_score: 0.0,
1158                max_score: 0.0,
1159                score_buckets: Vec::new(),
1160            };
1161        }
1162
1163        let total_count = results.len();
1164        let sum: f32 = results.iter().map(|r| r.score).sum();
1165        let avg_score = sum / total_count as f32;
1166        let min_score = results
1167            .iter()
1168            .map(|r| r.score)
1169            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1170            .expect("results is non-empty (total_count > 0)");
1171        let max_score = results
1172            .iter()
1173            .map(|r| r.score)
1174            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1175            .expect("results is non-empty (total_count > 0)");
1176
1177        // Create 10 buckets for score distribution
1178        let bucket_count = 10;
1179        let range = max_score - min_score;
1180        let bucket_size = if range > 0.0 {
1181            range / bucket_count as f32
1182        } else {
1183            1.0
1184        };
1185
1186        let mut buckets = vec![0; bucket_count];
1187        for result in results {
1188            let bucket_idx = if range > 0.0 {
1189                ((result.score - min_score) / bucket_size).floor() as usize
1190            } else {
1191                0
1192            };
1193            let bucket_idx = bucket_idx.min(bucket_count - 1);
1194            buckets[bucket_idx] += 1;
1195        }
1196
1197        let score_buckets = buckets
1198            .into_iter()
1199            .enumerate()
1200            .map(|(i, count)| ScoreBucket {
1201                range: (
1202                    min_score + i as f32 * bucket_size,
1203                    min_score + (i + 1) as f32 * bucket_size,
1204                ),
1205                count,
1206            })
1207            .collect();
1208
1209        Self {
1210            total_count,
1211            avg_score,
1212            min_score,
1213            max_score,
1214            score_buckets,
1215        }
1216    }
1217}
1218
1219impl Default for SemanticRouter {
1220    fn default() -> Self {
1221        Self::with_defaults().expect("Failed to create default SemanticRouter")
1222    }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228
1229    #[tokio::test]
1230    async fn test_router_creation() {
1231        let router = SemanticRouter::with_defaults();
1232        assert!(router.is_ok());
1233    }
1234
1235    #[tokio::test]
1236    async fn test_add_and_query() {
1237        let router =
1238            SemanticRouter::with_defaults().expect("test: SemanticRouter::with_defaults failed");
1239
1240        let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1241            .parse::<Cid>()
1242            .expect("test: CID parse failed");
1243        let embedding1 = vec![0.5; 768];
1244
1245        router
1246            .add(&cid1, &embedding1)
1247            .expect("test: router.add cid1 failed");
1248
1249        let results = router
1250            .query(&embedding1, 1)
1251            .await
1252            .expect("test: router.query failed");
1253        assert_eq!(results.len(), 1);
1254        assert_eq!(results[0].cid, cid1);
1255    }
1256
1257    #[tokio::test]
1258    async fn test_filtering() {
1259        let router =
1260            SemanticRouter::with_defaults().expect("test: SemanticRouter::with_defaults failed");
1261
1262        let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1263            .parse::<Cid>()
1264            .expect("test: CID parse failed");
1265        let embedding1 = vec![0.5; 768];
1266
1267        router
1268            .add(&cid1, &embedding1)
1269            .expect("test: router.add cid1 failed");
1270
1271        // Query with score filter
1272        let filter = QueryFilter {
1273            min_score: Some(0.9),
1274            max_score: None,
1275            max_results: Some(10),
1276            cid_prefix: None,
1277        };
1278
1279        let results = router
1280            .query_with_filter(&embedding1, 10, filter)
1281            .await
1282            .expect("test: router.query_with_filter failed");
1283
1284        // Should find the exact match
1285        assert!(!results.is_empty());
1286    }
1287
1288    #[tokio::test]
1289    async fn test_integration_with_blocks() {
1290        use bytes::Bytes;
1291        use ipfrs_core::Block;
1292
1293        // Create router with dimension 3 for this test
1294        let router = SemanticRouter::new(RouterConfig {
1295            dimension: 3,
1296            ..Default::default()
1297        })
1298        .expect("test: SemanticRouter::new with dimension 3 failed");
1299
1300        // Create test blocks
1301        let data1 = Bytes::from_static(b"Hello, semantic search!");
1302        let data2 = Bytes::from_static(b"Goodbye, semantic search!");
1303        let data3 = Bytes::from_static(b"Hello, world!");
1304
1305        let block1 = Block::new(data1).expect("test: Block::new data1 failed");
1306        let block2 = Block::new(data2).expect("test: Block::new data2 failed");
1307        let block3 = Block::new(data3).expect("test: Block::new data3 failed");
1308
1309        // Generate simple embeddings based on content
1310        // In real use, these would come from an embedding model
1311        let embedding1 = vec![1.0, 0.0, 0.0]; // "Hello" cluster
1312        let embedding2 = vec![0.0, 1.0, 0.0]; // "Goodbye" cluster
1313        let embedding3 = vec![0.9, 0.1, 0.0]; // Close to "Hello" cluster
1314
1315        // Index blocks with their embeddings
1316        router
1317            .add(block1.cid(), &embedding1)
1318            .expect("test: router.add block1 failed");
1319        router
1320            .add(block2.cid(), &embedding2)
1321            .expect("test: router.add block2 failed");
1322        router
1323            .add(block3.cid(), &embedding3)
1324            .expect("test: router.add block3 failed");
1325
1326        // Query for blocks similar to "Hello"
1327        let query_embedding = vec![1.0, 0.0, 0.0];
1328        let results = router
1329            .query(&query_embedding, 2)
1330            .await
1331            .expect("test: router.query failed");
1332
1333        // Should return block1 and block3 (both in "Hello" cluster)
1334        assert_eq!(results.len(), 2);
1335        assert_eq!(results[0].cid, *block1.cid());
1336    }
1337
1338    #[tokio::test]
1339    async fn test_integration_with_tensor_metadata() {
1340        use ipfrs_core::{TensorDtype, TensorMetadata, TensorShape};
1341
1342        let router = SemanticRouter::new(RouterConfig {
1343            dimension: 2,
1344            ..Default::default()
1345        })
1346        .expect("test: SemanticRouter::new with dimension 2 failed");
1347
1348        // Create tensor metadata
1349        let shape1 = TensorShape::new(vec![1, 768]);
1350        let mut metadata1 = TensorMetadata::new(shape1, TensorDtype::F32);
1351        metadata1.name = Some("vision_embedding".to_string());
1352        metadata1
1353            .metadata
1354            .insert("semantic_tag".to_string(), "vision".to_string());
1355
1356        let shape2 = TensorShape::new(vec![1, 768]);
1357        let mut metadata2 = TensorMetadata::new(shape2, TensorDtype::F32);
1358        metadata2.name = Some("text_embedding".to_string());
1359        metadata2
1360            .metadata
1361            .insert("semantic_tag".to_string(), "text".to_string());
1362
1363        // Generate embeddings for tensor types
1364        let vision_embedding = vec![1.0, 0.0];
1365        let text_embedding = vec![0.0, 1.0];
1366
1367        // Create CIDs for metadata (in real use, these would be the tensor CIDs)
1368        let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1369            .parse::<Cid>()
1370            .expect("test: CID1 parse failed");
1371        let cid2 = "bafybeibazl2z6vqxqqzmhmvx2hfpxqtwggqgbbyy3sxkq4vzq6cqsvwbjy"
1372            .parse::<Cid>()
1373            .expect("test: CID2 parse failed");
1374
1375        // Index tensors by their semantic embeddings
1376        router
1377            .add(&cid1, &vision_embedding)
1378            .expect("test: router.add cid1 vision failed");
1379        router
1380            .add(&cid2, &text_embedding)
1381            .expect("test: router.add cid2 text failed");
1382
1383        // Search for vision-type tensors
1384        let results = router
1385            .query(&vision_embedding, 1)
1386            .await
1387            .expect("test: router.query vision failed");
1388        assert_eq!(results.len(), 1);
1389        assert_eq!(results[0].cid, cid1);
1390    }
1391
1392    #[tokio::test]
1393    async fn test_large_scale_indexing() {
1394        use rand::RngExt;
1395
1396        let dimension = 128;
1397
1398        // Create router with dimension 128 for this test
1399        let router = SemanticRouter::new(RouterConfig {
1400            dimension,
1401            ..Default::default()
1402        })
1403        .expect("test: SemanticRouter::new with dimension 128 failed");
1404
1405        // Generate 1000 random embeddings and index them
1406        let mut rng = rand::rng();
1407        let num_items = 1000;
1408
1409        let mut indexed_cids = Vec::new();
1410
1411        for i in 0..num_items {
1412            // Generate unique CID
1413            use multihash_codetable::{Code, MultihashDigest};
1414            let data = format!("large_scale_test_{}", i);
1415            let hash = Code::Sha2_256.digest(data.as_bytes());
1416            let cid = Cid::new_v1(0x55, hash);
1417
1418            // Generate random embedding
1419            let embedding: Vec<f32> = (0..dimension)
1420                .map(|_| rng.random_range(-1.0..1.0))
1421                .collect();
1422
1423            router
1424                .add(&cid, &embedding)
1425                .expect("test: router.add large scale failed");
1426            indexed_cids.push((cid, embedding));
1427        }
1428
1429        // Verify index size
1430        let stats = router.stats();
1431        assert_eq!(stats.num_vectors, num_items);
1432
1433        // Test query on a known embedding
1434        let (test_cid, test_embedding) = &indexed_cids[42];
1435        let results = router
1436            .query(test_embedding, 1)
1437            .await
1438            .expect("test: router.query large scale failed");
1439
1440        // Should return the exact match as the top result
1441        assert_eq!(results.len(), 1);
1442        assert_eq!(results[0].cid, *test_cid);
1443    }
1444
1445    #[tokio::test]
1446    async fn test_cache_effectiveness() {
1447        let router =
1448            SemanticRouter::with_defaults().expect("test: SemanticRouter::with_defaults failed");
1449
1450        let cid1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
1451            .parse::<Cid>()
1452            .expect("test: CID parse failed");
1453        let embedding1 = vec![0.5; 768];
1454
1455        router
1456            .add(&cid1, &embedding1)
1457            .expect("test: router.add cid1 failed");
1458
1459        // Perform same query multiple times
1460        for _ in 0..10 {
1461            let _ = router
1462                .query(&embedding1, 1)
1463                .await
1464                .expect("test: router.query cache test failed");
1465        }
1466
1467        // Check cache stats - should have cached the query
1468        let cache_stats = router.cache_stats();
1469        assert_eq!(cache_stats.size, 1, "Cache should have 1 unique query");
1470        assert!(cache_stats.capacity > 0, "Cache should have capacity");
1471    }
1472
1473    #[tokio::test]
1474    async fn test_batch_query() {
1475        use rand::RngExt;
1476
1477        let dimension = 128;
1478
1479        // Create router with dimension 128 for this test
1480        let router = SemanticRouter::new(RouterConfig {
1481            dimension,
1482            ..Default::default()
1483        })
1484        .expect("test: SemanticRouter::new with dimension 128 failed");
1485
1486        // Generate and index 100 random embeddings
1487        let mut rng = rand::rng();
1488        let num_items = 100;
1489
1490        for i in 0..num_items {
1491            // Generate unique CID
1492            use multihash_codetable::{Code, MultihashDigest};
1493            let data = format!("batch_test_{}", i);
1494            let hash = Code::Sha2_256.digest(data.as_bytes());
1495            let cid = Cid::new_v1(0x55, hash);
1496
1497            // Generate random embedding
1498            let embedding: Vec<f32> = (0..dimension)
1499                .map(|_| rng.random_range(-1.0..1.0))
1500                .collect();
1501
1502            router
1503                .add(&cid, &embedding)
1504                .expect("test: router.add batch test failed");
1505        }
1506
1507        // Create batch of query embeddings
1508        let batch_size = 10;
1509        let query_batch: Vec<Vec<f32>> = (0..batch_size)
1510            .map(|_| {
1511                (0..dimension)
1512                    .map(|_| rng.random_range(-1.0..1.0))
1513                    .collect()
1514            })
1515            .collect();
1516
1517        // Execute batch query
1518        let results = router
1519            .query_batch(&query_batch, 5)
1520            .await
1521            .expect("test: router.query_batch failed");
1522
1523        // Verify results
1524        assert_eq!(results.len(), batch_size);
1525        for result in &results {
1526            assert!(!result.is_empty());
1527            assert!(result.len() <= 5);
1528        }
1529
1530        // Get batch statistics
1531        let stats = router.batch_stats(&results);
1532        assert_eq!(stats.total_queries, batch_size);
1533        assert!(stats.total_results > 0);
1534        assert!(stats.avg_results_per_query > 0.0);
1535    }
1536
1537    #[tokio::test]
1538    async fn test_batch_query_with_filter() {
1539        use rand::RngExt;
1540
1541        let dimension = 64;
1542
1543        let router = SemanticRouter::new(RouterConfig {
1544            dimension,
1545            ..Default::default()
1546        })
1547        .expect("test: SemanticRouter::new with dimension 64 failed");
1548
1549        // Generate and index embeddings
1550        let mut rng = rand::rng();
1551        let num_items = 50;
1552
1553        for i in 0..num_items {
1554            use multihash_codetable::{Code, MultihashDigest};
1555            let data = format!("filter_batch_test_{}", i);
1556            let hash = Code::Sha2_256.digest(data.as_bytes());
1557            let cid = Cid::new_v1(0x55, hash);
1558
1559            let embedding: Vec<f32> = (0..dimension)
1560                .map(|_| rng.random_range(-1.0..1.0))
1561                .collect();
1562
1563            router
1564                .add(&cid, &embedding)
1565                .expect("test: router.add filter batch test failed");
1566        }
1567
1568        // Create batch queries
1569        let batch_size = 5;
1570        let query_batch: Vec<Vec<f32>> = (0..batch_size)
1571            .map(|_| {
1572                (0..dimension)
1573                    .map(|_| rng.random_range(-1.0..1.0))
1574                    .collect()
1575            })
1576            .collect();
1577
1578        // Execute batch query with filter
1579        let filter = QueryFilter {
1580            min_score: Some(0.0),
1581            max_results: Some(3),
1582            ..Default::default()
1583        };
1584
1585        let results = router
1586            .query_batch_with_filter(&query_batch, 5, filter)
1587            .await
1588            .expect("test: router.query_batch_with_filter failed");
1589
1590        // Verify results
1591        assert_eq!(results.len(), batch_size);
1592        for result in &results {
1593            assert!(result.len() <= 3); // Max results filter applied
1594        }
1595    }
1596
1597    #[tokio::test]
1598    async fn test_batch_query_with_ef() {
1599        use rand::RngExt;
1600
1601        let dimension = 64;
1602
1603        let router = SemanticRouter::new(RouterConfig {
1604            dimension,
1605            ..Default::default()
1606        })
1607        .expect("test: SemanticRouter::new with dimension 64 failed");
1608
1609        // Generate and index embeddings
1610        let mut rng = rand::rng();
1611        let num_items = 50;
1612
1613        for i in 0..num_items {
1614            use multihash_codetable::{Code, MultihashDigest};
1615            let data = format!("ef_batch_test_{}", i);
1616            let hash = Code::Sha2_256.digest(data.as_bytes());
1617            let cid = Cid::new_v1(0x55, hash);
1618
1619            let embedding: Vec<f32> = (0..dimension)
1620                .map(|_| rng.random_range(-1.0..1.0))
1621                .collect();
1622
1623            router
1624                .add(&cid, &embedding)
1625                .expect("test: router.add ef batch test failed");
1626        }
1627
1628        // Create batch queries
1629        let batch_size = 5;
1630        let query_batch: Vec<Vec<f32>> = (0..batch_size)
1631            .map(|_| {
1632                (0..dimension)
1633                    .map(|_| rng.random_range(-1.0..1.0))
1634                    .collect()
1635            })
1636            .collect();
1637
1638        // Execute batch query with custom ef_search
1639        let results = router
1640            .query_batch_with_ef(&query_batch, 3, 100)
1641            .await
1642            .expect("test: router.query_batch_with_ef failed");
1643
1644        // Verify results
1645        assert_eq!(results.len(), batch_size);
1646        for result in &results {
1647            assert!(!result.is_empty());
1648            assert!(result.len() <= 3);
1649        }
1650    }
1651
1652    // ─────────────────────────────────────────────────────────────────────────
1653    // Tests for DiskANN backend and quantized mode
1654    // ─────────────────────────────────────────────────────────────────────────
1655
1656    #[tokio::test]
1657    async fn test_router_diskann_backend_selection() {
1658        // Use a temp directory to host the graph file
1659        let tmp = std::env::temp_dir().join(format!(
1660            "ipfrs_diskann_test_{}.idx",
1661            std::time::SystemTime::now()
1662                .duration_since(std::time::UNIX_EPOCH)
1663                .map(|d| d.as_nanos())
1664                .unwrap_or(0)
1665        ));
1666
1667        let config = RouterConfig {
1668            dimension: 8,
1669            index_backend: IndexBackend::DiskAnn {
1670                graph_path: tmp.clone(),
1671            },
1672            ..RouterConfig::balanced(8)
1673        };
1674
1675        let router = SemanticRouter::new(config).expect("should create DiskANN router");
1676
1677        // Insert a few vectors
1678        use multihash_codetable::{Code, MultihashDigest};
1679        for i in 0..3usize {
1680            let data = format!("diskann_test_{i}");
1681            let hash = Code::Sha2_256.digest(data.as_bytes());
1682            let cid = Cid::new_v1(0x55, hash);
1683            let emb: Vec<f32> = (0..8).map(|d| (i * 8 + d) as f32 * 0.01).collect();
1684            router.add(&cid, &emb).expect("insert should succeed");
1685        }
1686
1687        // Verify stats reflect the inserts
1688        let s = router.stats();
1689        assert_eq!(s.num_vectors, 3, "DiskANN should report 3 vectors");
1690        assert_eq!(s.dimension, 8);
1691
1692        // Search should return something
1693        let query: Vec<f32> = (0..8).map(|d| d as f32 * 0.01).collect();
1694        let results = router
1695            .query(&query, 2)
1696            .await
1697            .expect("search should succeed");
1698        assert!(!results.is_empty(), "DiskANN search should return results");
1699
1700        // Cleanup
1701        let _ = std::fs::remove_file(&tmp);
1702        let _ = std::fs::remove_file(format!("{}.vectors", tmp.display()));
1703    }
1704
1705    #[tokio::test]
1706    async fn test_router_quantized_mode() {
1707        use multihash_codetable::{Code, MultihashDigest};
1708
1709        let config = RouterConfig {
1710            dimension: 16,
1711            quantize_vectors: true,
1712            quantization_bits: 8,
1713            ..RouterConfig::balanced(16)
1714        };
1715
1716        let router = SemanticRouter::new(config).expect("should create quantized router");
1717
1718        // Add 5 embeddings
1719        let mut cids = Vec::new();
1720        for i in 0..5usize {
1721            let data = format!("quant_test_{i}");
1722            let hash = Code::Sha2_256.digest(data.as_bytes());
1723            let cid = Cid::new_v1(0x55, hash);
1724            let emb: Vec<f32> = (0..16).map(|d| (i as f32 + d as f32) * 0.05).collect();
1725            router
1726                .add(&cid, &emb)
1727                .expect("quantized insert should succeed");
1728            cids.push((cid, emb));
1729        }
1730
1731        assert_eq!(router.stats().num_vectors, 5);
1732
1733        // Search should return results
1734        let (_, ref query_emb) = cids[0];
1735        let results = router
1736            .query(query_emb, 3)
1737            .await
1738            .expect("quantized search should succeed");
1739        assert!(
1740            !results.is_empty(),
1741            "quantized search should return results"
1742        );
1743    }
1744}