Skip to main content

wm_memory/
lifecycle.rs

1//! Memory lifecycle — consolidation and mindful forgetting.
2//!
3//! Consolidation boosts importance of frequently-accessed memories.
4//! Mindful forgetting decays importance over time and removes memories
5//! that fall below a threshold.
6
7use crate::MemoryStore;
8
9use chrono::Utc;
10use uuid::Uuid;
11use wm_core::{Galaxy, Result};
12
13/// Configuration for the memory lifecycle.
14#[derive(Debug, Clone)]
15pub struct LifecycleConfig {
16    /// Importance threshold below which memories are candidates for forgetting.
17    pub forget_threshold: f32,
18    /// Decay factor applied per day since last access.
19    pub daily_decay_factor: f32,
20    /// Boost factor applied per access (consolidation).
21    pub access_boost: f32,
22    /// Maximum importance cap.
23    pub max_importance: f32,
24    /// Recency boost: memories accessed in the last N hours get a boost.
25    pub recency_window_hours: i64,
26    /// Recency boost amount.
27    pub recency_boost: f32,
28}
29
30impl Default for LifecycleConfig {
31    fn default() -> Self {
32        Self {
33            forget_threshold: 0.1,
34            daily_decay_factor: 0.95, // 5% decay per day
35            access_boost: 0.05,       // +5% per access
36            max_importance: 1.0,
37            recency_window_hours: 24,
38            recency_boost: 0.1,
39        }
40    }
41}
42
43/// Runs memory lifecycle operations on a `MemoryStore`.
44pub struct Lifecycle {
45    config: LifecycleConfig,
46}
47
48/// Result of a consolidation pass on a single galaxy.
49#[derive(Debug, Clone)]
50pub struct ConsolidationResult {
51    /// Galaxy processed
52    pub galaxy: Galaxy,
53    /// Memories examined
54    pub examined: usize,
55    /// Memories boosted
56    pub boosted: usize,
57    /// Memories decayed
58    pub decayed: usize,
59}
60
61/// Result of a mindful forgetting pass on a single galaxy.
62#[derive(Debug, Clone)]
63pub struct ForgettingResult {
64    /// Galaxy processed
65    pub galaxy: Galaxy,
66    /// Memories examined
67    pub examined: usize,
68    /// Memories forgotten (deleted)
69    pub forgotten: usize,
70    /// IDs of forgotten memories
71    pub forgotten_ids: Vec<Uuid>,
72}
73
74impl Lifecycle {
75    /// Create a new lifecycle manager with the given config.
76    #[must_use]
77    pub const fn new(config: LifecycleConfig) -> Self {
78        Self { config }
79    }
80
81    /// Create with default config.
82    #[must_use]
83    pub fn default_config() -> Self {
84        Self::new(LifecycleConfig::default())
85    }
86
87    /// Consolidate memories in a galaxy — boost frequently-accessed and recent memories.
88    pub fn consolidate(&self, store: &MemoryStore, galaxy: Galaxy) -> Result<ConsolidationResult> {
89        let memories = store.scan(galaxy, 10_000)?;
90        let now = Utc::now();
91        let mut examined = 0;
92        let mut boosted = 0;
93        let mut decayed = 0;
94
95        for mut mem in memories {
96            examined += 1;
97            let original_importance = mem.metadata.importance;
98
99            // Skip protected memories from consolidation changes
100            if mem.metadata.is_protected {
101                continue;
102            }
103
104            // Access-based boost
105            if mem.metadata.access_count > 0 {
106                let boost = self.config.access_boost * mem.metadata.access_count as f32;
107                mem.metadata.importance += boost;
108            }
109
110            // Recency boost
111            let hours_since_access = (now - mem.metadata.accessed_at).num_hours();
112            if hours_since_access < self.config.recency_window_hours {
113                mem.metadata.importance += self.config.recency_boost;
114            }
115
116            // Daily decay — uses per-memory half_life_days if configured
117            let days_since_access = (now - mem.metadata.accessed_at).num_days();
118            if days_since_access > 0 {
119                let decay = self
120                    .config
121                    .daily_decay_factor
122                    .powi(days_since_access as i32);
123                mem.metadata.importance *= decay;
124            }
125
126            // Apply neuro_score decay based on per-memory half-life
127            mem.decay(now);
128
129            // Clamp
130            mem.metadata.importance = mem
131                .metadata
132                .importance
133                .clamp(0.0, self.config.max_importance);
134
135            if mem.metadata.importance > original_importance {
136                boosted += 1;
137            } else if mem.metadata.importance < original_importance {
138                decayed += 1;
139            }
140
141            // Write back if changed
142            if (mem.metadata.importance - original_importance).abs() > f32::EPSILON {
143                store.put(galaxy, &mem)?;
144            }
145        }
146
147        Ok(ConsolidationResult {
148            galaxy,
149            examined,
150            boosted,
151            decayed,
152        })
153    }
154
155    /// Run mindful forgetting on a galaxy — delete memories below the importance threshold.
156    pub fn forget(&self, store: &MemoryStore, galaxy: Galaxy) -> Result<ForgettingResult> {
157        let memories = store.scan(galaxy, 10_000)?;
158        let mut examined = 0;
159        let mut forgotten = 0;
160        let mut forgotten_ids = Vec::new();
161
162        for mem in memories {
163            examined += 1;
164            if mem.should_forget(self.config.forget_threshold) {
165                let id = mem.metadata.id;
166                store.delete(galaxy, id)?;
167                forgotten += 1;
168                forgotten_ids.push(id);
169            }
170        }
171
172        Ok(ForgettingResult {
173            galaxy,
174            examined,
175            forgotten,
176            forgotten_ids,
177        })
178    }
179
180    /// Run a full lifecycle pass: consolidate then forget, across all galaxies.
181    pub fn run_full_cycle(
182        &self,
183        store: &MemoryStore,
184    ) -> Result<(Vec<ConsolidationResult>, Vec<ForgettingResult>)> {
185        let mut consol_results = Vec::new();
186        let mut forget_results = Vec::new();
187
188        for galaxy in wm_core::Galaxy::all() {
189            // Skip system galaxies
190            match galaxy {
191                Galaxy::Substrate | Galaxy::Dharma | Galaxy::Karma | Galaxy::Embeddings => continue,
192                _ => {}
193            }
194            let count = store.count(galaxy).unwrap_or(0);
195            if count == 0 {
196                continue;
197            }
198            consol_results.push(self.consolidate(store, galaxy)?);
199            forget_results.push(self.forget(store, galaxy)?);
200        }
201
202        Ok((consol_results, forget_results))
203    }
204
205    /// Run non-destructive phagic digestion on a galaxy.
206    ///
207    /// the project's sacred rule: Phagic systems must NEVER delete anything.
208    /// Irrelevant or distant outer-rim memories are gently migrated into compressed
209    /// cold storage, synthesizing distilled thematic nodes in the active hot tier.
210    pub fn phagic_digest(
211        &self,
212        store: &MemoryStore,
213        galaxy: Galaxy,
214    ) -> Result<crate::cold_storage::PhagicDigestReport> {
215        let digester = crate::cold_storage::PhagicDigester::default_config();
216        digester.digest_galaxy(store, None, galaxy)
217    }
218
219    /// Run a full non-destructive lifecycle pass: consolidate hot memories, then gently
220    /// migrate outer-rim memories into cold storage across all galaxies.
221    pub fn run_full_cycle_phagic(
222        &self,
223        store: &MemoryStore,
224    ) -> Result<(
225        Vec<ConsolidationResult>,
226        Vec<crate::cold_storage::PhagicDigestReport>,
227    )> {
228        let mut consol_results = Vec::new();
229        let mut phagic_results = Vec::new();
230
231        for galaxy in wm_core::Galaxy::all() {
232            // Skip system galaxies
233            match galaxy {
234                Galaxy::Substrate
235                | Galaxy::Dharma
236                | Galaxy::Karma
237                | Galaxy::Embeddings
238                | Galaxy::Associations => continue,
239                _ => {}
240            }
241            let count = store.count(galaxy).unwrap_or(0);
242            if count == 0 {
243                continue;
244            }
245            consol_results.push(self.consolidate(store, galaxy)?);
246            phagic_results.push(self.phagic_digest(store, galaxy)?);
247        }
248
249        Ok((consol_results, phagic_results))
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::Memory;
257    use tempfile::tempdir;
258
259    fn test_store() -> MemoryStore {
260        let tmp = tempdir().unwrap();
261        MemoryStore::open_default(tmp.path()).unwrap()
262    }
263
264    #[test]
265    fn consolidate_boosts_accessed_memories() {
266        let store = test_store();
267        let galaxy = Galaxy::Codex;
268
269        // Create a memory and simulate access
270        let mut mem = Memory::new(galaxy, "frequently accessed".into());
271        mem.metadata.access_count = 5;
272        store.put(galaxy, &mem).unwrap();
273
274        let lifecycle = Lifecycle::default_config();
275        let result = lifecycle.consolidate(&store, galaxy).unwrap();
276
277        assert_eq!(result.examined, 1);
278        assert!(result.boosted >= 1);
279
280        // Verify importance was boosted
281        let retrieved = store.get(galaxy, mem.metadata.id).unwrap().unwrap();
282        assert!(retrieved.metadata.importance > 0.5);
283    }
284
285    #[test]
286    fn forget_removes_low_importance() {
287        let store = test_store();
288        let galaxy = Galaxy::Codex;
289
290        // Create a low-importance memory
291        let mem = Memory::new(galaxy, "unimportant".into()).with_importance(0.05);
292        store.put(galaxy, &mem).unwrap();
293
294        let lifecycle = Lifecycle::default_config();
295        let result = lifecycle.forget(&store, galaxy).unwrap();
296
297        assert_eq!(result.forgotten, 1);
298        assert!(store.get(galaxy, mem.metadata.id).unwrap().is_none());
299    }
300
301    #[test]
302    fn forget_keeps_important_memories() {
303        let store = test_store();
304        let galaxy = Galaxy::Codex;
305
306        let mem = Memory::new(galaxy, "important".into()).with_importance(0.9);
307        store.put(galaxy, &mem).unwrap();
308
309        let lifecycle = Lifecycle::default_config();
310        let result = lifecycle.forget(&store, galaxy).unwrap();
311
312        assert_eq!(result.forgotten, 0);
313        assert!(store.get(galaxy, mem.metadata.id).unwrap().is_some());
314    }
315
316    #[test]
317    fn full_cycle_processes_all_galaxies() {
318        let store = test_store();
319
320        // Add memories to a couple galaxies
321        let mem1 = Memory::new(Galaxy::Codex, "codex memory".into());
322        store.put(Galaxy::Codex, &mem1).unwrap();
323        let mem2 = Memory::new(Galaxy::Research, "research memory".into());
324        store.put(Galaxy::Research, &mem2).unwrap();
325
326        let lifecycle = Lifecycle::default_config();
327        let (consol, _forget) = lifecycle.run_full_cycle(&store).unwrap();
328
329        assert!(consol.iter().any(|c| c.galaxy == Galaxy::Codex));
330        assert!(consol.iter().any(|c| c.galaxy == Galaxy::Research));
331    }
332
333    #[test]
334    fn consolidate_skips_protected_memories() {
335        let store = test_store();
336        let galaxy = Galaxy::Codex;
337
338        let mut mem = Memory::new(galaxy, "protected".into());
339        mem.metadata.is_protected = true;
340        mem.metadata.importance = 0.5;
341        let id = mem.metadata.id;
342        store.put(galaxy, &mem).unwrap();
343
344        let lifecycle = Lifecycle::default_config();
345        let _result = lifecycle.consolidate(&store, galaxy).unwrap();
346
347        // Importance should be unchanged
348        let back = store.get(galaxy, id).unwrap().unwrap();
349        assert!((back.metadata.importance - 0.5).abs() < f32::EPSILON);
350    }
351
352    #[test]
353    fn forget_respects_is_protected() {
354        let store = test_store();
355        let galaxy = Galaxy::Codex;
356
357        let mem = Memory::new(galaxy, "protected low".into())
358            .with_importance(0.01)
359            .with_protection(true);
360        let id = mem.metadata.id;
361        store.put(galaxy, &mem).unwrap();
362
363        let lifecycle = Lifecycle::default_config();
364        let result = lifecycle.forget(&store, galaxy).unwrap();
365
366        assert_eq!(result.forgotten, 0);
367        assert!(store.get(galaxy, id).unwrap().is_some());
368    }
369
370    #[test]
371    fn phagic_lifecycle_non_destructive_preservation() {
372        let store = test_store();
373        let galaxy = Galaxy::Codex;
374        let now = Utc::now();
375
376        let mut mem = Memory::new(galaxy, "Outer-rim lifecycle candidate".into())
377            .with_importance(0.02)
378            .with_tags(vec!["obsolete".into()]);
379        mem.metadata.accessed_at = now - chrono::Duration::days(160);
380        let id = mem.metadata.id;
381        store.put(galaxy, &mem).unwrap();
382
383        let lifecycle = Lifecycle::default_config();
384        let report = lifecycle.phagic_digest(&store, galaxy).unwrap();
385
386        assert_eq!(report.memories_digested, 1);
387        // Original memory is not in hot store...
388        assert!(store.get(galaxy, id).unwrap().is_none());
389        // ...but preserved in cold storage (the project's sacred rule: NEVER delete)
390        let cold = store.get_cold_record(id).unwrap();
391        assert!(cold.is_some());
392        assert_eq!(cold.unwrap().id, id);
393    }
394}