wm-memory 9.1.4

Local-first persistent memory store with sessions and continuity for AI coding agents.
Documentation
//! Memory lifecycle — consolidation and mindful forgetting.
//!
//! Consolidation boosts importance of frequently-accessed memories.
//! Mindful forgetting decays importance over time and removes memories
//! that fall below a threshold.

use crate::MemoryStore;

use chrono::Utc;
use uuid::Uuid;
use wm_core::{Galaxy, Result};

/// Configuration for the memory lifecycle.
#[derive(Debug, Clone)]
pub struct LifecycleConfig {
    /// Importance threshold below which memories are candidates for forgetting.
    pub forget_threshold: f32,
    /// Decay factor applied per day since last access.
    pub daily_decay_factor: f32,
    /// Boost factor applied per access (consolidation).
    pub access_boost: f32,
    /// Maximum importance cap.
    pub max_importance: f32,
    /// Recency boost: memories accessed in the last N hours get a boost.
    pub recency_window_hours: i64,
    /// Recency boost amount.
    pub recency_boost: f32,
}

impl Default for LifecycleConfig {
    fn default() -> Self {
        Self {
            forget_threshold: 0.1,
            daily_decay_factor: 0.95, // 5% decay per day
            access_boost: 0.05,       // +5% per access
            max_importance: 1.0,
            recency_window_hours: 24,
            recency_boost: 0.1,
        }
    }
}

/// Runs memory lifecycle operations on a `MemoryStore`.
pub struct Lifecycle {
    config: LifecycleConfig,
}

/// Result of a consolidation pass on a single galaxy.
#[derive(Debug, Clone)]
pub struct ConsolidationResult {
    /// Galaxy processed
    pub galaxy: Galaxy,
    /// Memories examined
    pub examined: usize,
    /// Memories boosted
    pub boosted: usize,
    /// Memories decayed
    pub decayed: usize,
}

/// Result of a mindful forgetting pass on a single galaxy.
#[derive(Debug, Clone)]
pub struct ForgettingResult {
    /// Galaxy processed
    pub galaxy: Galaxy,
    /// Memories examined
    pub examined: usize,
    /// Memories forgotten (deleted)
    pub forgotten: usize,
    /// IDs of forgotten memories
    pub forgotten_ids: Vec<Uuid>,
}

impl Lifecycle {
    /// Create a new lifecycle manager with the given config.
    #[must_use]
    pub const fn new(config: LifecycleConfig) -> Self {
        Self { config }
    }

    /// Create with default config.
    #[must_use]
    pub fn default_config() -> Self {
        Self::new(LifecycleConfig::default())
    }

    /// Consolidate memories in a galaxy — boost frequently-accessed and recent memories.
    pub fn consolidate(&self, store: &MemoryStore, galaxy: Galaxy) -> Result<ConsolidationResult> {
        let memories = store.scan(galaxy, 10_000)?;
        let now = Utc::now();
        let mut examined = 0;
        let mut boosted = 0;
        let mut decayed = 0;

        for mut mem in memories {
            examined += 1;
            let original_importance = mem.metadata.importance;

            // Skip protected memories from consolidation changes
            if mem.metadata.is_protected {
                continue;
            }

            // Access-based boost
            if mem.metadata.access_count > 0 {
                let boost = self.config.access_boost * mem.metadata.access_count as f32;
                mem.metadata.importance += boost;
            }

            // Recency boost
            let hours_since_access = (now - mem.metadata.accessed_at).num_hours();
            if hours_since_access < self.config.recency_window_hours {
                mem.metadata.importance += self.config.recency_boost;
            }

            // Daily decay — uses per-memory half_life_days if configured
            let days_since_access = (now - mem.metadata.accessed_at).num_days();
            if days_since_access > 0 {
                let decay = self
                    .config
                    .daily_decay_factor
                    .powi(days_since_access as i32);
                mem.metadata.importance *= decay;
            }

            // Apply neuro_score decay based on per-memory half-life
            mem.decay(now);

            // Clamp
            mem.metadata.importance = mem
                .metadata
                .importance
                .clamp(0.0, self.config.max_importance);

            if mem.metadata.importance > original_importance {
                boosted += 1;
            } else if mem.metadata.importance < original_importance {
                decayed += 1;
            }

            // Write back if changed
            if (mem.metadata.importance - original_importance).abs() > f32::EPSILON {
                store.put(galaxy, &mem)?;
            }
        }

        Ok(ConsolidationResult {
            galaxy,
            examined,
            boosted,
            decayed,
        })
    }

    /// Run mindful forgetting on a galaxy — delete memories below the importance threshold.
    pub fn forget(&self, store: &MemoryStore, galaxy: Galaxy) -> Result<ForgettingResult> {
        let memories = store.scan(galaxy, 10_000)?;
        let mut examined = 0;
        let mut forgotten = 0;
        let mut forgotten_ids = Vec::new();

        for mem in memories {
            examined += 1;
            if mem.should_forget(self.config.forget_threshold) {
                let id = mem.metadata.id;
                store.delete(galaxy, id)?;
                forgotten += 1;
                forgotten_ids.push(id);
            }
        }

        Ok(ForgettingResult {
            galaxy,
            examined,
            forgotten,
            forgotten_ids,
        })
    }

    /// Run a full lifecycle pass: consolidate then forget, across all galaxies.
    pub fn run_full_cycle(
        &self,
        store: &MemoryStore,
    ) -> Result<(Vec<ConsolidationResult>, Vec<ForgettingResult>)> {
        let mut consol_results = Vec::new();
        let mut forget_results = Vec::new();

        for galaxy in wm_core::Galaxy::all() {
            // Skip system galaxies
            match galaxy {
                Galaxy::Substrate
                | Galaxy::Dharma
                | Galaxy::Karma
                | Galaxy::Embeddings
                | Galaxy::Telemetry => continue,
                _ => {}
            }
            let count = store.count(galaxy).unwrap_or(0);
            if count == 0 {
                continue;
            }
            consol_results.push(self.consolidate(store, galaxy)?);
            forget_results.push(self.forget(store, galaxy)?);
        }

        Ok((consol_results, forget_results))
    }

    /// Run non-destructive phagic digestion on a galaxy.
    ///
    /// the project's sacred rule: Phagic systems must NEVER delete anything.
    /// Irrelevant or distant outer-rim memories are gently migrated into compressed
    /// cold storage, synthesizing distilled thematic nodes in the active hot tier.
    pub fn phagic_digest(
        &self,
        store: &MemoryStore,
        galaxy: Galaxy,
    ) -> Result<crate::cold_storage::PhagicDigestReport> {
        let digester = crate::cold_storage::PhagicDigester::default_config();
        digester.digest_galaxy(store, None, galaxy)
    }

    /// Run a full non-destructive lifecycle pass: consolidate hot memories, then gently
    /// migrate outer-rim memories into cold storage across all galaxies.
    pub fn run_full_cycle_phagic(
        &self,
        store: &MemoryStore,
    ) -> Result<(
        Vec<ConsolidationResult>,
        Vec<crate::cold_storage::PhagicDigestReport>,
    )> {
        let mut consol_results = Vec::new();
        let mut phagic_results = Vec::new();

        for galaxy in wm_core::Galaxy::all() {
            // Skip system galaxies
            match galaxy {
                Galaxy::Substrate
                | Galaxy::Dharma
                | Galaxy::Karma
                | Galaxy::Embeddings
                | Galaxy::Associations
                | Galaxy::Telemetry => continue,
                _ => {}
            }
            let count = store.count(galaxy).unwrap_or(0);
            if count == 0 {
                continue;
            }
            consol_results.push(self.consolidate(store, galaxy)?);
            phagic_results.push(self.phagic_digest(store, galaxy)?);
        }

        Ok((consol_results, phagic_results))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Memory;
    use tempfile::tempdir;

    fn test_store() -> MemoryStore {
        let tmp = tempdir().unwrap();
        MemoryStore::open_default(tmp.path()).unwrap()
    }

    #[test]
    fn consolidate_boosts_accessed_memories() {
        let store = test_store();
        let galaxy = Galaxy::Codex;

        // Create a memory and simulate access
        let mut mem = Memory::new(galaxy, "frequently accessed".into());
        mem.metadata.access_count = 5;
        store.put(galaxy, &mem).unwrap();

        let lifecycle = Lifecycle::default_config();
        let result = lifecycle.consolidate(&store, galaxy).unwrap();

        assert_eq!(result.examined, 1);
        assert!(result.boosted >= 1);

        // Verify importance was boosted
        let retrieved = store.get(galaxy, mem.metadata.id).unwrap().unwrap();
        assert!(retrieved.metadata.importance > 0.5);
    }

    #[test]
    fn forget_removes_low_importance() {
        let store = test_store();
        let galaxy = Galaxy::Codex;

        // Create a low-importance memory
        let mem = Memory::new(galaxy, "unimportant".into()).with_importance(0.05);
        store.put(galaxy, &mem).unwrap();

        let lifecycle = Lifecycle::default_config();
        let result = lifecycle.forget(&store, galaxy).unwrap();

        assert_eq!(result.forgotten, 1);
        assert!(store.get(galaxy, mem.metadata.id).unwrap().is_none());
    }

    #[test]
    fn forget_keeps_important_memories() {
        let store = test_store();
        let galaxy = Galaxy::Codex;

        let mem = Memory::new(galaxy, "important".into()).with_importance(0.9);
        store.put(galaxy, &mem).unwrap();

        let lifecycle = Lifecycle::default_config();
        let result = lifecycle.forget(&store, galaxy).unwrap();

        assert_eq!(result.forgotten, 0);
        assert!(store.get(galaxy, mem.metadata.id).unwrap().is_some());
    }

    #[test]
    fn full_cycle_processes_all_galaxies() {
        let store = test_store();

        // Add memories to a couple galaxies
        let mem1 = Memory::new(Galaxy::Codex, "codex memory".into());
        store.put(Galaxy::Codex, &mem1).unwrap();
        let mem2 = Memory::new(Galaxy::Research, "research memory".into());
        store.put(Galaxy::Research, &mem2).unwrap();

        let lifecycle = Lifecycle::default_config();
        let (consol, _forget) = lifecycle.run_full_cycle(&store).unwrap();

        assert!(consol.iter().any(|c| c.galaxy == Galaxy::Codex));
        assert!(consol.iter().any(|c| c.galaxy == Galaxy::Research));
    }

    #[test]
    fn consolidate_skips_protected_memories() {
        let store = test_store();
        let galaxy = Galaxy::Codex;

        let mut mem = Memory::new(galaxy, "protected".into());
        mem.metadata.is_protected = true;
        mem.metadata.importance = 0.5;
        let id = mem.metadata.id;
        store.put(galaxy, &mem).unwrap();

        let lifecycle = Lifecycle::default_config();
        let _result = lifecycle.consolidate(&store, galaxy).unwrap();

        // Importance should be unchanged
        let back = store.get(galaxy, id).unwrap().unwrap();
        assert!((back.metadata.importance - 0.5).abs() < f32::EPSILON);
    }

    #[test]
    fn forget_respects_is_protected() {
        let store = test_store();
        let galaxy = Galaxy::Codex;

        let mem = Memory::new(galaxy, "protected low".into())
            .with_importance(0.01)
            .with_protection(true);
        let id = mem.metadata.id;
        store.put(galaxy, &mem).unwrap();

        let lifecycle = Lifecycle::default_config();
        let result = lifecycle.forget(&store, galaxy).unwrap();

        assert_eq!(result.forgotten, 0);
        assert!(store.get(galaxy, id).unwrap().is_some());
    }

    #[test]
    fn phagic_lifecycle_non_destructive_preservation() {
        let store = test_store();
        let galaxy = Galaxy::Codex;
        let now = Utc::now();

        let mut mem = Memory::new(galaxy, "Outer-rim lifecycle candidate".into())
            .with_importance(0.02)
            .with_tags(vec!["obsolete".into()]);
        mem.metadata.accessed_at = now - chrono::Duration::days(160);
        let id = mem.metadata.id;
        store.put(galaxy, &mem).unwrap();

        let lifecycle = Lifecycle::default_config();
        let report = lifecycle.phagic_digest(&store, galaxy).unwrap();

        assert_eq!(report.memories_digested, 1);
        // Original memory is not in hot store...
        assert!(store.get(galaxy, id).unwrap().is_none());
        // ...but preserved in cold storage (the project's sacred rule: NEVER delete)
        let cold = store.get_cold_record(id).unwrap();
        assert!(cold.is_some());
        assert_eq!(cold.unwrap().id, id);
    }
}