1#![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#[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 pub selectivity_ratio: f32,
34 pub filter_strategy: Option<FilterStrategy>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum CandidateSource {
41 Metadata,
42 Graph,
43 Bucket,
44 ExactFallback,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49pub enum FilterStrategy {
50 Pre,
52 BucketPost,
54 ScanPost,
56}
57
58pub 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#[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
81const 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 pub fn set_retrieval_config(&mut self, config: RetrievalConfig) -> Result<()> {
113 config.validate()?;
114 self._retrieval_config = config;
115 Ok(())
116 }
117
118 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 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 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 #[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 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 #[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 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 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, None, );
287 results_arc
288 }
289
290 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 #[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 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 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 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}