Skip to main content

csm_memory/
singularity_retrieval.rs

1//! Retrieval optimization types and extension trait for Singularity.
2//!
3//! This module provides:
4//! - `RetrievalStats`: Observability for retrieval operations
5//! - `CandidateSource`: Where candidates came from
6//! - `RetrievalConfig`: Configuration for candidate generation
7//! - Extension trait for reduced-candidate retrieval
8
9// Casts are intentional for retrieval similarity math
10#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
11
12use serde::{Deserialize, Serialize};
13use std::collections::VecDeque;
14use std::sync::Arc;
15
16#[cfg(all(not(target_arch = "wasm32"), feature = "parallel"))]
17use rayon::prelude::*;
18
19use crate::singularity::{Singularity, unix_now_ns};
20use csm_core_lib::error::Result;
21use csm_core_lib::hyperdim::HVec10240;
22
23/// Statistics from the last retrieval operation.
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct RetrievalStats {
26    pub candidate_count: usize,
27    pub scored_count: usize,
28    pub fell_back_to_exact_scan: bool,
29    pub candidate_ns: u64,
30    pub scoring_ns: u64,
31    pub best_score_seen: Option<f32>,
32    /// ADR-0065: Filter selectivity ratio (matching_count / total_count)
33    pub selectivity_ratio: f32,
34    /// ADR-0065: Strategy used for filtered retrieval
35    pub filter_strategy: Option<FilterStrategy>,
36}
37
38/// Source of candidates in reduced-candidate retrieval.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum CandidateSource {
41    Metadata,
42    Graph,
43    Bucket,
44    ExactFallback,
45}
46
47/// Strategy used for filtered retrieval (ADR-0065).
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49pub enum FilterStrategy {
50    /// Pre-filter candidates, then score (optimal for low selectivity)
51    Pre,
52    /// Generate bucket candidates, score, post-filter (optimal for medium selectivity)
53    BucketPost,
54    /// Full similarity scan, post-filter results (optimal for high selectivity)
55    ScanPost,
56}
57
58/// Parameters for scored candidate retrieval.
59pub struct ScoredCandidateParams<'a> {
60    pub query: &'a HVec10240,
61    pub top_k: usize,
62    pub candidates: Vec<usize>,
63    pub start_ns: u64,
64    pub cand_ns: u64,
65    pub source: CandidateSource,
66    pub bypass_cache: bool,
67}
68
69/// Configuration for retrieval optimization.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct RetrievalConfig {
72    pub max_candidates: usize,
73    pub candidate_ratio_fallback: f32,
74    pub graph_depth: u8,
75    pub graph_fanout: usize,
76    pub bucket_probe_width: usize,
77    pub enable_graph_candidates: bool,
78    pub enable_bucket_candidates: bool,
79}
80
81/// Maximum allowed bucket probe width to prevent excessive memory usage.
82const MAX_BUCKET_PROBE_WIDTH: usize = 16;
83
84impl RetrievalConfig {
85    pub fn validate(&self) -> Result<()> {
86        if self.bucket_probe_width > MAX_BUCKET_PROBE_WIDTH {
87            return Err(csm_core_lib::error::MemoryError::InvalidInput {
88                field: "bucket_probe_width".to_string(),
89                reason: format!("bucket_probe_width exceeds {MAX_BUCKET_PROBE_WIDTH}"),
90            });
91        }
92        Ok(())
93    }
94}
95
96impl Default for RetrievalConfig {
97    fn default() -> Self {
98        Self {
99            max_candidates: 1000,
100            candidate_ratio_fallback: 0.5,
101            graph_depth: 2,
102            graph_fanout: 10,
103            bucket_probe_width: 2,
104            enable_graph_candidates: false,
105            enable_bucket_candidates: false,
106        }
107    }
108}
109
110impl Singularity {
111    /// Set the retrieval configuration.
112    pub fn set_retrieval_config(&mut self, config: RetrievalConfig) -> Result<()> {
113        config.validate()?;
114        self._retrieval_config = config;
115        Ok(())
116    }
117
118    /// Get the retrieval configuration.
119    /// Get statistics from the last retrieval operation.
120    pub fn last_retrieval_stats(&self, ns: &str) -> RetrievalStats {
121        self.get_namespace(ns)
122            .and_then(|n| n.last_retrieval_stats.read().ok())
123            .map(|s| s.clone())
124            .unwrap_or_default()
125    }
126
127    /// Generate candidates by expanding the association graph.
128    pub(crate) fn generate_graph_candidates(&self, ns: &str, query: &HVec10240) -> Vec<usize> {
129        let Some(ns_state) = self.get_namespace(ns) else {
130            return Vec::new();
131        };
132        let mut candidates = std::collections::HashSet::new();
133        let results = self.exact_similarity_scan(ns, query, 1, unix_now_ns(), true);
134        if let Some((seed_id, _)) = results.first() {
135            let mut queue = VecDeque::new();
136            queue.push_back((seed_id.clone(), 0u8));
137            candidates.insert(seed_id.clone());
138
139            while let Some((id, depth)) = queue.pop_front() {
140                if depth >= self._retrieval_config.graph_depth {
141                    continue;
142                }
143                if let Some(links) = ns_state.associations.get(&id) {
144                    let mut sorted_links: Vec<_> = links.iter().collect();
145                    let fanout = self._retrieval_config.graph_fanout.min(sorted_links.len());
146                    if fanout > 0 {
147                        sorted_links
148                            .select_nth_unstable_by(fanout - 1, |a, b| b.1.0.total_cmp(&a.1.0));
149                        sorted_links.truncate(fanout);
150                        sorted_links.sort_unstable_by(|a, b| b.1.0.total_cmp(&a.1.0));
151                    }
152
153                    for (neighbor_id, _) in sorted_links {
154                        if !candidates.contains(neighbor_id) {
155                            candidates.insert(neighbor_id.clone());
156                            queue.push_back((neighbor_id.clone(), depth + 1));
157                        }
158                    }
159                }
160            }
161        }
162
163        candidates
164            .into_iter()
165            .filter_map(|id| ns_state.id_to_index.get(&id).copied())
166            .collect()
167    }
168
169    /// Generate candidates by coarse bucketing.
170    pub(crate) fn generate_bucket_candidates(&self, ns: &str, query: &HVec10240) -> Vec<usize> {
171        let Some(ns_state) = self.get_namespace(ns) else {
172            return Vec::new();
173        };
174        debug_assert!(self._retrieval_config.bucket_probe_width <= 127);
175        let bucket_mask = (1u128 << self._retrieval_config.bucket_probe_width) - 1;
176        let query_bucket = query.data[0] & bucket_mask;
177
178        let filter = |(idx, vec): (usize, &HVec10240)| {
179            if (vec.data[0] & bucket_mask) == query_bucket {
180                Some(idx)
181            } else {
182                None
183            }
184        };
185
186        // Algorithmic Optimization: Parallelize O(N) candidate generation via Rayon.
187        // Reduces latency from O(N) to O(N/P) where P is the number of execution units.
188        #[cfg(all(not(target_arch = "wasm32"), feature = "parallel"))]
189        {
190            ns_state
191                .concept_vectors
192                .par_iter()
193                .enumerate()
194                .filter_map(filter)
195                .collect()
196        }
197
198        #[cfg(any(target_arch = "wasm32", not(feature = "parallel")))]
199        {
200            ns_state
201                .concept_vectors
202                .iter()
203                .enumerate()
204                .filter_map(filter)
205                .collect()
206        }
207    }
208
209    /// Perform exact similarity scan over all vectors.
210    pub(crate) fn exact_similarity_scan(
211        &self,
212        ns: &str,
213        query: &HVec10240,
214        top_k: usize,
215        start_ns: u64,
216        bypass_cache: bool,
217    ) -> Arc<[(String, f32)]> {
218        let Some(ns_state) = self.get_namespace(ns) else {
219            return Arc::from(Vec::new());
220        };
221        let scoring_start = unix_now_ns();
222
223        // Algorithmic Optimization: Use integer Hamming distance for ranking to avoid floating-point
224        // overhead and use a fused allocation to improve cache locality.
225        #[cfg(all(not(target_arch = "wasm32"), feature = "parallel"))]
226        let mut scores: Vec<(usize, u32)> = ns_state
227            .concept_vectors
228            .par_iter()
229            .enumerate()
230            .with_min_len(128)
231            .map(|(idx, v)| (idx, query.hamming_distance(v)))
232            .collect();
233
234        #[cfg(any(target_arch = "wasm32", not(feature = "parallel")))]
235        let mut scores: Vec<(usize, u32)> = ns_state
236            .concept_vectors
237            .iter()
238            .enumerate()
239            .map(|(idx, v)| (idx, query.hamming_distance(v)))
240            .collect();
241
242        let scoring_ns = unix_now_ns().saturating_sub(scoring_start);
243        let scored_count = scores.len();
244
245        // Sort by Hamming distance (ascending = more similar)
246        if scored_count <= top_k {
247            scores.sort_unstable_by_key(|&(_, dist)| dist);
248        } else {
249            scores.select_nth_unstable_by(top_k - 1, |a, b| a.1.cmp(&b.1));
250            scores.truncate(top_k);
251            scores.sort_unstable_by_key(|&(_, dist)| dist);
252        }
253
254        let results: Vec<(String, f32)> = scores
255            .into_iter()
256            .map(|(idx, dist)| {
257                // Defer cosine similarity calculation until the final top_k results
258                let similarity = 1.0 - (dist as f32 / 5120.0);
259                (ns_state.concept_indices[idx].clone(), similarity)
260            })
261            .collect();
262
263        let best_score = results.first().map(|r| r.1);
264        let results_arc = Arc::from(results);
265        if !bypass_cache {
266            if let Ok(mut cache) = ns_state.query_cache.write() {
267                let cache_key = crate::singularity::similarity_cache_key(query, top_k);
268                if cache.put(cache_key, Arc::clone(&results_arc)) {
269                    ns_state
270                        .cache_metrics
271                        .evictions_total
272                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
273                }
274            }
275        }
276        self.update_stats(
277            ns,
278            scored_count,
279            scored_count,
280            true,
281            scoring_start.saturating_sub(start_ns),
282            scoring_ns,
283            best_score,
284            1.0,  // Full scan means 100% selectivity for unfiltered
285            None, // No filter strategy for unfiltered
286        );
287        results_arc
288    }
289
290    /// Score a subset of candidates for reduced-candidate retrieval.
291    pub(crate) fn scored_candidate_retrieval(
292        &self,
293        ns: &str,
294        params: ScoredCandidateParams,
295    ) -> Arc<[(String, f32)]> {
296        self.scored_candidate_retrieval_with_stats(ns, params, 0.0, None)
297    }
298
299    /// Update retrieval statistics.
300    #[allow(clippy::too_many_arguments)]
301    fn update_stats(
302        &self,
303        ns: &str,
304        candidates: usize,
305        scored: usize,
306        fallback: bool,
307        cand_ns: u64,
308        score_ns: u64,
309        best_score: Option<f32>,
310        selectivity: f32,
311        strategy: Option<FilterStrategy>,
312    ) {
313        if let Some(ns_state) = self.get_namespace(ns) {
314            let stats = RetrievalStats {
315                candidate_count: candidates,
316                scored_count: scored,
317                fell_back_to_exact_scan: fallback,
318                candidate_ns: cand_ns,
319                scoring_ns: score_ns,
320                best_score_seen: best_score,
321                selectivity_ratio: selectivity,
322                filter_strategy: strategy,
323            };
324            if let Ok(mut s) = ns_state.last_retrieval_stats.write() {
325                *s = stats;
326            }
327        }
328    }
329
330    /// Score candidates with explicit selectivity stats (ADR-0065).
331    pub(crate) fn scored_candidate_retrieval_with_stats(
332        &self,
333        ns: &str,
334        params: ScoredCandidateParams,
335        selectivity: f32,
336        strategy: Option<FilterStrategy>,
337    ) -> Arc<[(String, f32)]> {
338        let Some(ns_state) = self.get_namespace(ns) else {
339            return Arc::from(Vec::new());
340        };
341        let ScoredCandidateParams {
342            query,
343            top_k,
344            candidates,
345            start_ns: _start_ns,
346            cand_ns,
347            source: _source,
348            bypass_cache,
349        } = params;
350        let scoring_start = unix_now_ns();
351        let candidate_count = candidates.len();
352
353        #[cfg(all(not(target_arch = "wasm32"), feature = "parallel"))]
354        let mut scores: Vec<(usize, u32)> = candidates
355            .into_par_iter()
356            .map(|idx| (idx, query.hamming_distance(&ns_state.concept_vectors[idx])))
357            .collect();
358
359        #[cfg(any(target_arch = "wasm32", not(feature = "parallel")))]
360        let mut scores: Vec<(usize, u32)> = candidates
361            .into_iter()
362            .map(|idx| (idx, query.hamming_distance(&ns_state.concept_vectors[idx])))
363            .collect();
364
365        let scoring_ns = unix_now_ns().saturating_sub(scoring_start);
366        let scored_count = scores.len();
367
368        if scores.len() <= top_k {
369            scores.sort_unstable_by_key(|&(_, dist)| dist);
370        } else {
371            scores.select_nth_unstable_by(top_k - 1, |a, b| a.1.cmp(&b.1));
372            scores.truncate(top_k);
373            scores.sort_unstable_by_key(|&(_, dist)| dist);
374        }
375
376        let results: Vec<(String, f32)> = scores
377            .into_iter()
378            .map(|(idx, dist)| {
379                let similarity = 1.0 - (dist as f32 / 5120.0);
380                (ns_state.concept_indices[idx].clone(), similarity)
381            })
382            .collect();
383
384        let best_score = results.first().map(|r| r.1);
385        let results_arc = Arc::from(results);
386        if !bypass_cache {
387            if let Ok(mut cache) = ns_state.query_cache.write() {
388                let cache_key = crate::singularity::similarity_cache_key(query, top_k);
389                if cache.put(cache_key, Arc::clone(&results_arc)) {
390                    ns_state
391                        .cache_metrics
392                        .evictions_total
393                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
394                }
395            }
396        }
397
398        self.update_stats(
399            ns,
400            candidate_count,
401            scored_count,
402            false,
403            cand_ns,
404            scoring_ns,
405            best_score,
406            selectivity,
407            strategy,
408        );
409
410        results_arc
411    }
412}
413
414#[cfg(test)]
415mod tests_v2 {
416    use crate::singularity::{Singularity, SingularityConfig};
417    use csm_core_lib::hyperdim::HVec10240;
418
419    #[test]
420    fn singularity_last_stats_v2() {
421        let s = Singularity::<HVec10240>::new(SingularityConfig::default());
422        assert_eq!(s.last_retrieval_stats("_default").candidate_count, 0);
423    }
424
425    #[test]
426    fn singularity_get_config_v2() {
427        let s = Singularity::<HVec10240>::new(SingularityConfig::default());
428        assert_eq!(s.retrieval_config().max_candidates, 1000);
429    }
430
431    #[test]
432    fn test_generate_graph_candidates_logic() {
433        use super::RetrievalConfig;
434        use crate::singularity::ConceptBuilder;
435        let mut s = Singularity::<HVec10240>::new(SingularityConfig::default());
436        let mut config = RetrievalConfig::default();
437        config.enable_graph_candidates = true;
438        config.graph_depth = 1;
439        config.graph_fanout = 2;
440        s.set_retrieval_config(config).unwrap();
441
442        let v1 = HVec10240::random();
443        let v2 = HVec10240::random();
444        let v3 = HVec10240::random();
445        let v4 = HVec10240::random();
446
447        s.inject(
448            "_default",
449            ConceptBuilder::new("c1")
450                .with_vector(v1.clone())
451                .build()
452                .unwrap(),
453        )
454        .unwrap();
455        s.inject(
456            "_default",
457            ConceptBuilder::new("c2").with_vector(v2).build().unwrap(),
458        )
459        .unwrap();
460        s.inject(
461            "_default",
462            ConceptBuilder::new("c3").with_vector(v3).build().unwrap(),
463        )
464        .unwrap();
465        s.inject(
466            "_default",
467            ConceptBuilder::new("c4").with_vector(v4).build().unwrap(),
468        )
469        .unwrap();
470
471        // c1 -> c2 (0.9), c1 -> c3 (0.8), c1 -> c4 (0.1)
472        s.associate("_default", "c1", "c2", 0.9).unwrap();
473        s.associate("_default", "c1", "c3", 0.8).unwrap();
474        s.associate("_default", "c1", "c4", 0.1).unwrap();
475
476        let candidates = s.generate_graph_candidates("_default", &v1);
477        // c1 is seed, c2 and c3 are top 2 neighbors. c4 is excluded by fanout=2.
478        assert_eq!(candidates.len(), 3);
479
480        let ns_state = s.get_namespace("_default").unwrap();
481        let ids: std::collections::HashSet<_> = candidates
482            .iter()
483            .map(|&idx| ns_state.concept_indices[idx].as_str())
484            .collect();
485        assert!(ids.contains("c1"));
486        assert!(ids.contains("c2"));
487        assert!(ids.contains("c3"));
488        assert!(!ids.contains("c4"));
489    }
490}