Skip to main content

csm_memory/
singularity_ttl.rs

1//! TTL (Time-To-Live) operations for Singularity.
2
3use std::collections::HashMap;
4
5use crate::singularity::{Concept, Singularity, unix_now_secs};
6use csm_core_lib::hyperdim::HVec10240;
7
8impl Default for Concept {
9    fn default() -> Self {
10        Self {
11            id: String::new(),
12            vector: HVec10240::zero(),
13            metadata: HashMap::new(),
14            created_at: 0,
15            modified_at: 0,
16            expires_at: None,
17            canonical_concept_ids: Vec::new(),
18        }
19    }
20}
21
22impl Singularity {
23    /// Purge all expired concepts from memory.
24    ///
25    /// Returns the number of concepts removed.
26    pub fn purge_expired(&mut self, ns: &str) -> usize {
27        self.purge_expired_cascading(ns, false)
28    }
29
30    /// Purge expired concepts, optionally cascading through associations.
31    #[allow(clippy::unwrap_used)]
32    pub fn purge_expired_cascading(&mut self, ns: &str, cascading: bool) -> usize {
33        let now = unix_now_secs();
34        let Some(ns_state) = self.get_namespace(ns) else {
35            return 0;
36        };
37        let mut to_remove: std::collections::HashSet<String> = ns_state
38            .concepts
39            .iter()
40            .filter(|(_, c)| c.expires_at.is_some_and(|exp| exp <= now))
41            .map(|(id, _)| id.clone())
42            .collect();
43
44        if cascading && !to_remove.is_empty() {
45            let mut changed = true;
46            while changed {
47                changed = false;
48                let ns_state = self.get_namespace(ns).unwrap();
49                for (from_id, neighbors) in &ns_state.associations {
50                    if to_remove.contains(from_id) {
51                        for to_id in neighbors.keys() {
52                            if !to_remove.contains(to_id) {
53                                to_remove.insert(to_id.clone());
54                                changed = true;
55                            }
56                        }
57                    }
58                }
59            }
60        }
61
62        let count = to_remove.len();
63        for id in to_remove {
64            self.delete(ns, &id).ok();
65        }
66        if count > 0 {
67            self.invalidate_cache(ns);
68        }
69        count
70    }
71
72    /// Check if a concept is expired.
73    pub fn is_expired(&self, ns: &str, id: &str) -> bool {
74        let now = unix_now_secs();
75        self.get(ns, id)
76            .is_some_and(|c| c.expires_at.is_some_and(|exp| exp <= now))
77    }
78
79    /// Get all non-expired concept IDs.
80    pub fn active_concept_ids(&self, ns: &str) -> Vec<String> {
81        let now = unix_now_secs();
82        self.get_namespace(ns)
83            .map(|n| {
84                n.concepts
85                    .iter()
86                    .filter(|(_, c)| c.expires_at.is_none_or(|exp| exp > now))
87                    .map(|(id, _)| id.clone())
88                    .collect()
89            })
90            .unwrap_or_default()
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
97    use super::*;
98    use crate::singularity::SingularityConfig;
99
100    #[test]
101    fn test_purge_expired() {
102        let mut sing = Singularity::<HVec10240>::new(SingularityConfig::default());
103        let now = unix_now_secs();
104
105        let concept1 = Concept {
106            id: "expired".to_string(),
107            expires_at: Some(now - 100),
108            ..Default::default()
109        };
110
111        let concept2 = Concept {
112            id: "active".to_string(),
113            expires_at: Some(now + 100),
114            ..Default::default()
115        };
116
117        let concept3 = Concept {
118            id: "no_exp".to_string(),
119            expires_at: None,
120            ..Default::default()
121        };
122
123        let ns = "_default";
124        sing.inject("_default", concept1).unwrap();
125        sing.inject("_default", concept2).unwrap();
126        sing.inject("_default", concept3).unwrap();
127
128        assert_eq!(sing.active_concept_ids(ns).len(), 2);
129
130        let purged = sing.purge_expired("_default");
131        assert_eq!(purged, 1);
132
133        assert!(
134            !sing
135                .get_namespace(ns)
136                .unwrap()
137                .concepts
138                .contains_key("expired")
139        );
140        assert!(
141            sing.get_namespace(ns)
142                .unwrap()
143                .concepts
144                .contains_key("active")
145        );
146        assert!(
147            sing.get_namespace(ns)
148                .unwrap()
149                .concepts
150                .contains_key("no_exp")
151        );
152    }
153
154    #[test]
155    fn test_is_expired() {
156        let mut sing = Singularity::<HVec10240>::new(SingularityConfig::default());
157        let now = unix_now_secs();
158
159        let concept1 = Concept {
160            id: "expired".to_string(),
161            expires_at: Some(now - 100),
162            ..Default::default()
163        };
164
165        let concept2 = Concept {
166            id: "active".to_string(),
167            expires_at: Some(now + 100),
168            ..Default::default()
169        };
170
171        let concept3 = Concept {
172            id: "no_exp".to_string(),
173            expires_at: None,
174            ..Default::default()
175        };
176
177        let concept4 = Concept {
178            id: "just_expired".to_string(),
179            expires_at: Some(now),
180            ..Default::default()
181        };
182
183        let ns = "_default";
184        sing.inject("_default", concept1).unwrap();
185        sing.inject("_default", concept2).unwrap();
186        sing.inject("_default", concept3).unwrap();
187        sing.inject("_default", concept4).unwrap();
188
189        assert!(sing.is_expired(ns, "expired"));
190        assert!(!sing.is_expired(ns, "active"));
191        assert!(!sing.is_expired(ns, "no_exp"));
192        assert!(sing.is_expired(ns, "just_expired"));
193        assert!(!sing.is_expired(ns, "nonexistent"));
194    }
195
196    #[test]
197    fn test_active_concept_ids() {
198        let mut sing = Singularity::<HVec10240>::new(SingularityConfig::default());
199        let now = unix_now_secs();
200
201        let concept1 = Concept {
202            id: "expired".to_string(),
203            expires_at: Some(now - 100),
204            ..Default::default()
205        };
206
207        let concept2 = Concept {
208            id: "active".to_string(),
209            expires_at: Some(now + 100),
210            ..Default::default()
211        };
212
213        let concept3 = Concept {
214            id: "no_exp".to_string(),
215            expires_at: None,
216            ..Default::default()
217        };
218
219        let ns = "_default";
220        sing.inject("_default", concept1).unwrap();
221        sing.inject("_default", concept2).unwrap();
222        sing.inject("_default", concept3).unwrap();
223
224        let mut active = sing.active_concept_ids(ns);
225        active.sort();
226
227        let mut expected = vec!["active".to_string(), "no_exp".to_string()];
228        expected.sort();
229
230        assert_eq!(active, expected);
231    }
232}