Skip to main content

csm_memory/
singularity.rs

1//! Core concept storage and indexing engine
2#![allow(clippy::cast_precision_loss)] // u64 timestamps → f32 for TTL decay math is intentional
3
4pub use crate::singularity_types::*;
5
6use crate::index::{AnnIndex, IndexBackend, IndexStats};
7use crate::singularity_cache::{CacheMetrics, CacheMetricsSnapshot};
8use crate::singularity_retrieval::RetrievalConfig;
9use crate::singularity_state::NamespaceState;
10use csm_core_lib::error::{MemoryError, Result};
11use csm_core_lib::hyperdim::Hypervector;
12use std::collections::HashMap;
13use std::sync::Arc;
14use tracing::instrument;
15
16pub struct Singularity<H: Hypervector = csm_core_lib::hyperdim::HVec10240> {
17    pub config: SingularityConfig,
18    pub namespaces: HashMap<String, NamespaceState<H>>,
19    pub(crate) _retrieval_config: RetrievalConfig,
20    pub cache_metrics: Arc<CacheMetrics>,
21}
22
23/// Type alias for binary (quantized) hypervector-backed singularity engine.
24#[allow(dead_code)]
25pub type BinarySingularity = Singularity<csm_core_lib::BHVec10240>;
26
27impl<H: Hypervector + 'static> Singularity<H> {
28    pub fn new(config: SingularityConfig) -> Self {
29        Self::new_with_metrics(config, Arc::new(CacheMetrics::default()))
30    }
31
32    pub fn new_with_metrics(config: SingularityConfig, cache_metrics: Arc<CacheMetrics>) -> Self {
33        Self {
34            config,
35            namespaces: HashMap::new(),
36            _retrieval_config: RetrievalConfig::default(),
37            cache_metrics,
38        }
39    }
40
41    pub fn with_config(config: SingularityConfig) -> Self {
42        Self::new(config)
43    }
44
45    pub fn with_config_and_backend(config: SingularityConfig, backend: IndexBackend) -> Self {
46        let mut cfg = config;
47        cfg.index_backend = backend;
48        Self::new(cfg)
49    }
50
51    pub fn with_config_backend_and_metrics(
52        config: SingularityConfig,
53        backend: IndexBackend,
54        cache_metrics: Arc<CacheMetrics>,
55    ) -> Self {
56        let mut cfg = config;
57        cfg.index_backend = backend;
58        Self::new_with_metrics(cfg, cache_metrics)
59    }
60
61    fn create_index(&self) -> Result<Box<dyn AnnIndex<H>>> {
62        crate::index::create_index(&self.config.index_backend)
63    }
64
65    pub fn get_namespace(&self, ns: &str) -> Option<&NamespaceState<H>> {
66        self.namespaces.get(ns)
67    }
68
69    /// Ensure a namespace exists, creating its ANN index if needed.
70    ///
71    /// Returns `Err(MemoryError::InvalidInput)` when the configured ANN backend
72    /// cannot be constructed (e.g. invalid HNSW/LSH parameters).
73    pub fn ensure_namespace(&mut self, ns: &str) -> Result<&mut NamespaceState<H>> {
74        if !self.namespaces.contains_key(ns) {
75            let index = self.create_index()?;
76            self.namespaces.insert(
77                ns.to_string(),
78                NamespaceState::new(&self.config, index, Arc::clone(&self.cache_metrics)),
79            );
80        }
81        // Key was present or just inserted; absence would be a logic bug.
82        self.namespaces
83            .get_mut(ns)
84            .ok_or_else(|| MemoryError::NotFound {
85                entity: "Namespace".to_string(),
86                id: ns.to_string(),
87            })
88    }
89
90    /// Mutable access to a namespace, creating it if absent.
91    ///
92    /// **Breaking change (0.3.x → next):** returns `Result` instead of a bare
93    /// `&mut NamespaceState`. Prefer [`Self::ensure_namespace`]. Invalid ANN
94    /// backend configuration propagates as `MemoryError::InvalidInput` rather
95    /// than panicking. Migration: use `?` or handle the `Result` at call sites.
96    pub fn get_namespace_mut(&mut self, ns: &str) -> Result<&mut NamespaceState<H>> {
97        self.ensure_namespace(ns)
98    }
99
100    #[instrument(skip(self, concept))]
101    pub fn inject(&mut self, ns: &str, concept: Concept<H>) -> Result<()> {
102        self.evict_oldest_if_needed(ns);
103        let id = concept.id.clone();
104        let vector = concept.vector;
105
106        let ns_state = self.ensure_namespace(ns)?;
107
108        // Update ANN index
109        ns_state.index.insert(id.clone(), &vector)?;
110
111        if let Some(_old) = ns_state.concepts.insert(id.clone(), concept) {
112            if let Some(pos) = ns_state.id_to_index.get(&id) {
113                ns_state.concept_vectors[*pos] = vector;
114            }
115            self.invalidate_cache(ns);
116        } else {
117            let pos = ns_state.concept_vectors.len();
118            ns_state.concept_vectors.push(vector);
119            ns_state.concept_indices.push(id.clone());
120            ns_state.id_to_index.insert(id, pos);
121        }
122
123        Ok(())
124    }
125
126    pub fn update(&mut self, ns: &str, id: &str, vector: H) -> Result<()> {
127        let ns_state = self.ensure_namespace(ns)?;
128        if let Some(concept) = ns_state.concepts.get_mut(id) {
129            concept.vector = vector;
130            concept.modified_at = unix_now_secs();
131            if let Some(pos) = ns_state.id_to_index.get(id) {
132                ns_state.concept_vectors[*pos] = vector;
133            }
134            ns_state.index.insert(id.to_string(), &vector)?;
135            self.invalidate_cache(ns);
136            Ok(())
137        } else {
138            Err(MemoryError::NotFound {
139                entity: "Concept".to_string(),
140                id: id.to_string(),
141            })
142        }
143    }
144
145    pub fn delete(&mut self, ns: &str, id: &str) -> Result<()> {
146        let ns_state = self.ensure_namespace(ns)?;
147        if ns_state.concepts.remove(id).is_some() {
148            ns_state.associations.remove(id);
149            for neighbors in ns_state.associations.values_mut() {
150                neighbors.remove(id);
151            }
152            if let Some(pos) = ns_state.id_to_index.remove(id) {
153                let _ = ns_state.concept_vectors.swap_remove(pos);
154                ns_state.concept_indices.swap_remove(pos);
155                if pos < ns_state.concept_indices.len() {
156                    let moved_id = &ns_state.concept_indices[pos];
157                    ns_state.id_to_index.insert(moved_id.clone(), pos);
158                }
159            }
160            ns_state.index.delete(id)?;
161            self.invalidate_cache(ns);
162            Ok(())
163        } else {
164            Err(MemoryError::NotFound {
165                entity: "Concept".to_string(),
166                id: id.to_string(),
167            })
168        }
169    }
170
171    pub fn clear(&mut self, ns: &str) {
172        if let Some(ns_state) = self.namespaces.get_mut(ns) {
173            ns_state.concepts.clear();
174            ns_state.associations.clear();
175            ns_state.concept_vectors.clear();
176            ns_state.concept_indices.clear();
177            ns_state.id_to_index.clear();
178            let _ = ns_state.index.rebuild(&HashMap::new());
179            self.invalidate_cache(ns);
180        }
181    }
182
183    pub fn get(&self, ns: &str, id: &str) -> Option<&Concept<H>> {
184        self.get_namespace(ns).and_then(|n| n.concepts.get(id))
185    }
186
187    pub fn associate(&mut self, ns: &str, from: &str, to: &str, strength: f32) -> Result<()> {
188        // Validate strength before any other checks
189        if !strength.is_finite() {
190            return Err(MemoryError::InvalidInput {
191                field: "strength".to_string(),
192                reason: "association strength must be finite".to_string(),
193            });
194        }
195        if !(0.0..=1.0).contains(&strength) {
196            return Err(MemoryError::InvalidInput {
197                field: "strength".to_string(),
198                reason: format!("association strength must be in [0.0, 1.0], got {strength}"),
199            });
200        }
201
202        // Read config limit before borrowing ns_state mutably
203        let max_assoc = self.config.max_associations_per_concept;
204
205        let ns_state = self.ensure_namespace(ns)?;
206        if !ns_state.concepts.contains_key(from) {
207            return Err(MemoryError::NotFound {
208                entity: "Concept".to_string(),
209                id: from.to_string(),
210            });
211        }
212        if !ns_state.concepts.contains_key(to) {
213            return Err(MemoryError::NotFound {
214                entity: "Concept".to_string(),
215                id: to.to_string(),
216            });
217        }
218
219        let neighbors = ns_state.associations.entry(from.to_string()).or_default();
220        neighbors.insert(to.to_string(), (strength, unix_now_secs()));
221
222        // Enforce max_associations_per_concept: evict weakest if over limit
223        if let Some(limit) = max_assoc {
224            while neighbors.len() > limit {
225                if let Some(weakest) = neighbors
226                    .iter()
227                    .min_by(|a, b| {
228                        a.1.0
229                            .partial_cmp(&b.1.0)
230                            .unwrap_or(std::cmp::Ordering::Equal)
231                    })
232                    .map(|(k, _)| k.clone())
233                {
234                    neighbors.remove(&weakest);
235                } else {
236                    break;
237                }
238            }
239        }
240
241        Ok(())
242    }
243
244    pub fn disassociate(&mut self, ns: &str, from: &str, to: &str) -> Result<()> {
245        let ns_state = self.ensure_namespace(ns)?;
246        if let Some(neighbors) = ns_state.associations.get_mut(from) {
247            neighbors.remove(to);
248        }
249        Ok(())
250    }
251
252    pub fn get_associations(&self, ns: &str, id: &str) -> Vec<(String, f32)> {
253        self.get_associations_with_decay(ns, id, DecayCurve::None)
254    }
255
256    /// Get associations with decay curve applied.
257    pub fn get_associations_with_decay(
258        &self,
259        ns: &str,
260        id: &str,
261        curve: DecayCurve,
262    ) -> Vec<(String, f32)> {
263        let now = unix_now_secs();
264        self.get_namespace(ns)
265            .and_then(|n| n.associations.get(id))
266            .map(|m| {
267                m.iter()
268                    .map(|(k, (strength, created_at))| {
269                        let elapsed = now.saturating_sub(*created_at);
270                        (k.clone(), curve.apply(*strength, elapsed))
271                    })
272                    .collect::<Vec<_>>()
273            })
274            .unwrap_or_default()
275    }
276
277    pub fn incoming_associations(&self, ns: &str, id: &str) -> Vec<(String, f32)> {
278        self.incoming_associations_with_decay(ns, id, DecayCurve::None)
279    }
280
281    /// Get incoming associations with decay curve applied.
282    pub fn incoming_associations_with_decay(
283        &self,
284        ns: &str,
285        id: &str,
286        curve: DecayCurve,
287    ) -> Vec<(String, f32)> {
288        let now = unix_now_secs();
289        let mut incoming = Vec::new();
290        if let Some(ns_state) = self.get_namespace(ns) {
291            for (from_id, neighbors) in &ns_state.associations {
292                if let Some((strength, created_at)) = neighbors.get(id) {
293                    let elapsed = now.saturating_sub(*created_at);
294                    incoming.push((from_id.clone(), curve.apply(*strength, elapsed)));
295                }
296            }
297        }
298        incoming
299            .sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
300        incoming
301    }
302
303    pub fn all_concepts(&self, ns: &str) -> Vec<Concept<H>> {
304        self.get_namespace(ns)
305            .map(|n| n.concepts.values().cloned().collect())
306            .unwrap_or_default()
307    }
308
309    pub fn all_associations(&self, ns: &str) -> Vec<(String, String, f32)> {
310        self.all_associations_with_decay(ns, DecayCurve::None)
311    }
312
313    /// Get all associations with decay curve applied.
314    pub fn all_associations_with_decay(
315        &self,
316        ns: &str,
317        curve: DecayCurve,
318    ) -> Vec<(String, String, f32)> {
319        let now = unix_now_secs();
320        let mut all = Vec::new();
321        if let Some(ns_state) = self.get_namespace(ns) {
322            for (from, neighbors) in &ns_state.associations {
323                for (to, (strength, created_at)) in neighbors {
324                    let elapsed = now.saturating_sub(*created_at);
325                    all.push((from.clone(), to.clone(), curve.apply(*strength, elapsed)));
326                }
327            }
328        }
329        all
330    }
331
332    pub fn len(&self, ns: &str) -> usize {
333        self.get_namespace(ns).map_or(0, |n| n.concepts.len())
334    }
335
336    pub fn is_empty(&self, ns: &str) -> bool {
337        self.get_namespace(ns).is_none_or(|n| n.concepts.is_empty())
338    }
339
340    pub fn cache_metrics_snapshot(&self, ns: &str) -> CacheMetricsSnapshot {
341        self.get_namespace(ns)
342            .map_or(CacheMetricsSnapshot::default(), |n| {
343                n.cache_metrics.snapshot()
344            })
345    }
346
347    fn evict_oldest_if_needed(&mut self, ns: &str) {
348        let Some(limit) = self.config.max_concepts else {
349            return;
350        };
351
352        while self.len(ns) >= limit {
353            let oldest = {
354                let Some(ns_state) = self.get_namespace(ns) else {
355                    break;
356                };
357                ns_state
358                    .concepts
359                    .values()
360                    .min_by_key(|c| c.created_at)
361                    .map(|c| c.id.clone())
362            };
363
364            if let Some(id) = oldest {
365                let _ = self.delete(ns, &id);
366            } else {
367                break;
368            }
369        }
370    }
371
372    pub fn invalidate_cache(&self, ns: &str) {
373        if let Some(ns_state) = self.get_namespace(ns) {
374            if let Ok(mut cache) = ns_state.query_cache.write() {
375                cache.clear();
376            }
377        }
378    }
379
380    pub fn index_stats(&self, ns: &str) -> IndexStats {
381        self.get_namespace(ns)
382            .map(|n| n.index.stats())
383            .unwrap_or_default()
384    }
385
386    pub const fn retrieval_config(&self) -> &RetrievalConfig {
387        &self._retrieval_config
388    }
389}
390
391/// Get current time in Unix nanoseconds.
392#[cfg(not(target_arch = "wasm32"))]
393pub fn unix_now_ns() -> u64 {
394    let nanos = std::time::SystemTime::now()
395        .duration_since(std::time::UNIX_EPOCH)
396        .unwrap_or_default()
397        .as_nanos();
398    u64::try_from(nanos).unwrap_or(u64::MAX)
399}
400
401/// Get current time in Unix nanoseconds (WASM version).
402#[cfg(target_arch = "wasm32")]
403pub fn unix_now_ns() -> u64 {
404    (js_sys::Date::new_0().get_time() * 1_000_000.0) as u64
405}
406
407pub fn similarity_cache_key<H: Hypervector>(query: &H, top_k: usize) -> u64 {
408    use std::hash::{Hash, Hasher};
409    let mut s = std::collections::hash_map::DefaultHasher::new();
410    // Optimization: Hash the hypervector directly instead of calling to_bytes().
411    // This eliminates a 1280-byte allocation/copy per cache lookup.
412    query.hash(&mut s);
413    top_k.hash(&mut s);
414    s.finish()
415}