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
192                | Galaxy::Dharma
193                | Galaxy::Karma
194                | Galaxy::Embeddings
195                | Galaxy::Telemetry => continue,
196                _ => {}
197            }
198            let count = store.count(galaxy).unwrap_or(0);
199            if count == 0 {
200                continue;
201            }
202            consol_results.push(self.consolidate(store, galaxy)?);
203            forget_results.push(self.forget(store, galaxy)?);
204        }
205
206        Ok((consol_results, forget_results))
207    }
208
209    /// Run non-destructive phagic digestion on a galaxy.
210    ///
211    /// the project's sacred rule: Phagic systems must NEVER delete anything.
212    /// Irrelevant or distant outer-rim memories are gently migrated into compressed
213    /// cold storage, synthesizing distilled thematic nodes in the active hot tier.
214    pub fn phagic_digest(
215        &self,
216        store: &MemoryStore,
217        galaxy: Galaxy,
218    ) -> Result<crate::cold_storage::PhagicDigestReport> {
219        let digester = crate::cold_storage::PhagicDigester::default_config();
220        digester.digest_galaxy(store, None, galaxy)
221    }
222
223    /// Run a full non-destructive lifecycle pass: consolidate hot memories, then gently
224    /// migrate outer-rim memories into cold storage across all galaxies.
225    pub fn run_full_cycle_phagic(
226        &self,
227        store: &MemoryStore,
228    ) -> Result<(
229        Vec<ConsolidationResult>,
230        Vec<crate::cold_storage::PhagicDigestReport>,
231    )> {
232        let mut consol_results = Vec::new();
233        let mut phagic_results = Vec::new();
234
235        for galaxy in wm_core::Galaxy::all() {
236            // Skip system galaxies
237            match galaxy {
238                Galaxy::Substrate
239                | Galaxy::Dharma
240                | Galaxy::Karma
241                | Galaxy::Embeddings
242                | Galaxy::Associations
243                | Galaxy::Telemetry => continue,
244                _ => {}
245            }
246            let count = store.count(galaxy).unwrap_or(0);
247            if count == 0 {
248                continue;
249            }
250            consol_results.push(self.consolidate(store, galaxy)?);
251            phagic_results.push(self.phagic_digest(store, galaxy)?);
252        }
253
254        Ok((consol_results, phagic_results))
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::Memory;
262    use tempfile::tempdir;
263
264    fn test_store() -> MemoryStore {
265        let tmp = tempdir().unwrap();
266        MemoryStore::open_default(tmp.path()).unwrap()
267    }
268
269    #[test]
270    fn consolidate_boosts_accessed_memories() {
271        let store = test_store();
272        let galaxy = Galaxy::Codex;
273
274        // Create a memory and simulate access
275        let mut mem = Memory::new(galaxy, "frequently accessed".into());
276        mem.metadata.access_count = 5;
277        store.put(galaxy, &mem).unwrap();
278
279        let lifecycle = Lifecycle::default_config();
280        let result = lifecycle.consolidate(&store, galaxy).unwrap();
281
282        assert_eq!(result.examined, 1);
283        assert!(result.boosted >= 1);
284
285        // Verify importance was boosted
286        let retrieved = store.get(galaxy, mem.metadata.id).unwrap().unwrap();
287        assert!(retrieved.metadata.importance > 0.5);
288    }
289
290    #[test]
291    fn forget_removes_low_importance() {
292        let store = test_store();
293        let galaxy = Galaxy::Codex;
294
295        // Create a low-importance memory
296        let mem = Memory::new(galaxy, "unimportant".into()).with_importance(0.05);
297        store.put(galaxy, &mem).unwrap();
298
299        let lifecycle = Lifecycle::default_config();
300        let result = lifecycle.forget(&store, galaxy).unwrap();
301
302        assert_eq!(result.forgotten, 1);
303        assert!(store.get(galaxy, mem.metadata.id).unwrap().is_none());
304    }
305
306    #[test]
307    fn forget_keeps_important_memories() {
308        let store = test_store();
309        let galaxy = Galaxy::Codex;
310
311        let mem = Memory::new(galaxy, "important".into()).with_importance(0.9);
312        store.put(galaxy, &mem).unwrap();
313
314        let lifecycle = Lifecycle::default_config();
315        let result = lifecycle.forget(&store, galaxy).unwrap();
316
317        assert_eq!(result.forgotten, 0);
318        assert!(store.get(galaxy, mem.metadata.id).unwrap().is_some());
319    }
320
321    #[test]
322    fn full_cycle_processes_all_galaxies() {
323        let store = test_store();
324
325        // Add memories to a couple galaxies
326        let mem1 = Memory::new(Galaxy::Codex, "codex memory".into());
327        store.put(Galaxy::Codex, &mem1).unwrap();
328        let mem2 = Memory::new(Galaxy::Research, "research memory".into());
329        store.put(Galaxy::Research, &mem2).unwrap();
330
331        let lifecycle = Lifecycle::default_config();
332        let (consol, _forget) = lifecycle.run_full_cycle(&store).unwrap();
333
334        assert!(consol.iter().any(|c| c.galaxy == Galaxy::Codex));
335        assert!(consol.iter().any(|c| c.galaxy == Galaxy::Research));
336    }
337
338    #[test]
339    fn consolidate_skips_protected_memories() {
340        let store = test_store();
341        let galaxy = Galaxy::Codex;
342
343        let mut mem = Memory::new(galaxy, "protected".into());
344        mem.metadata.is_protected = true;
345        mem.metadata.importance = 0.5;
346        let id = mem.metadata.id;
347        store.put(galaxy, &mem).unwrap();
348
349        let lifecycle = Lifecycle::default_config();
350        let _result = lifecycle.consolidate(&store, galaxy).unwrap();
351
352        // Importance should be unchanged
353        let back = store.get(galaxy, id).unwrap().unwrap();
354        assert!((back.metadata.importance - 0.5).abs() < f32::EPSILON);
355    }
356
357    #[test]
358    fn forget_respects_is_protected() {
359        let store = test_store();
360        let galaxy = Galaxy::Codex;
361
362        let mem = Memory::new(galaxy, "protected low".into())
363            .with_importance(0.01)
364            .with_protection(true);
365        let id = mem.metadata.id;
366        store.put(galaxy, &mem).unwrap();
367
368        let lifecycle = Lifecycle::default_config();
369        let result = lifecycle.forget(&store, galaxy).unwrap();
370
371        assert_eq!(result.forgotten, 0);
372        assert!(store.get(galaxy, id).unwrap().is_some());
373    }
374
375    #[test]
376    fn phagic_lifecycle_non_destructive_preservation() {
377        let store = test_store();
378        let galaxy = Galaxy::Codex;
379        let now = Utc::now();
380
381        let mut mem = Memory::new(galaxy, "Outer-rim lifecycle candidate".into())
382            .with_importance(0.02)
383            .with_tags(vec!["obsolete".into()]);
384        mem.metadata.accessed_at = now - chrono::Duration::days(160);
385        let id = mem.metadata.id;
386        store.put(galaxy, &mem).unwrap();
387
388        let lifecycle = Lifecycle::default_config();
389        let report = lifecycle.phagic_digest(&store, galaxy).unwrap();
390
391        assert_eq!(report.memories_digested, 1);
392        // Original memory is not in hot store...
393        assert!(store.get(galaxy, id).unwrap().is_none());
394        // ...but preserved in cold storage (the project's sacred rule: NEVER delete)
395        let cold = store.get_cold_record(id).unwrap();
396        assert!(cold.is_some());
397        assert_eq!(cold.unwrap().id, id);
398    }
399}