Skip to main content

conch_core/
decay.rs

1use crate::store::MemoryStore;
2
3/// Default decay factor.
4const DEFAULT_DECAY_FACTOR: f64 = 0.5;
5
6/// Default half-life in hours (24 hours = memories lose half strength per day of inactivity).
7const DEFAULT_HALF_LIFE_HOURS: f64 = 24.0;
8
9/// Minimum strength before a memory is deleted.
10const MIN_STRENGTH: f64 = 0.01;
11
12/// Result of a decay pass.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct DecayResult {
15    pub decayed: usize,
16    pub deleted: usize,
17}
18
19/// Run a decay pass over all memories.
20///
21/// For each memory, strength is reduced based on time since last access:
22///   new_strength = strength * decay_factor ^ (hours_since_access / half_life_hours)
23///
24/// With defaults, memories lose half their strength for each 24 hours of inactivity.
25/// Memories that fall below MIN_STRENGTH (0.01) are deleted.
26pub fn run_decay(
27    store: &MemoryStore,
28    decay_factor: Option<f64>,
29    half_life_hours: Option<f64>,
30) -> Result<DecayResult, rusqlite::Error> {
31    run_decay_ns(store, decay_factor, half_life_hours, "default")
32}
33
34pub fn run_decay_ns(
35    store: &MemoryStore,
36    decay_factor: Option<f64>,
37    half_life_hours: Option<f64>,
38    namespace: &str,
39) -> Result<DecayResult, rusqlite::Error> {
40    let factor = decay_factor.unwrap_or(DEFAULT_DECAY_FACTOR);
41    let half_life = half_life_hours.unwrap_or(DEFAULT_HALF_LIFE_HOURS);
42
43    let decayed = store.decay_all_ns(factor, half_life, namespace)?;
44
45    // Delete memories that have decayed below minimum strength (namespace-scoped)
46    let deleted: usize = store.conn().execute(
47        "DELETE FROM memories WHERE strength < ?1 AND namespace = ?2",
48        rusqlite::params![MIN_STRENGTH, namespace],
49    )?;
50
51    if deleted > 0 {
52        store.log_audit("decay_delete", None, "system", Some(&format!("{{\"deleted\":{deleted},\"namespace\":{}}}", serde_json::json!(namespace))))?;
53    }
54
55    Ok(DecayResult { decayed, deleted })
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn test_decay_pass() {
64        let store = MemoryStore::open_in_memory().unwrap();
65        let id = store.remember_fact("A", "is", "B", None).unwrap();
66
67        // Set importance to 0 so we get the base half-life of 24h
68        store.update_importance(id, 0.0).unwrap();
69
70        // Manually set last_accessed_at to 48 hours ago
71        let old_time = (chrono::Utc::now() - chrono::Duration::hours(48)).to_rfc3339();
72        store
73            .conn()
74            .execute(
75                "UPDATE memories SET last_accessed_at = ?1",
76                rusqlite::params![old_time],
77            )
78            .unwrap();
79
80        let result = run_decay(&store, None, None).unwrap();
81        assert_eq!(result.decayed, 1);
82
83        // Strength should be reduced (0.5^(48/24) = 0.25)
84        let mem = store.get_memory(1).unwrap().unwrap();
85        assert!(mem.strength < 0.3);
86        assert!(mem.strength > 0.2);
87    }
88
89    #[test]
90    fn test_no_decay_for_fresh_memories() {
91        let store = MemoryStore::open_in_memory().unwrap();
92        store.remember_fact("A", "is", "B", None).unwrap();
93
94        let result = run_decay(&store, None, None).unwrap();
95        assert_eq!(result.decayed, 0);
96        assert_eq!(result.deleted, 0);
97
98        let mem = store.get_memory(1).unwrap().unwrap();
99        assert!((mem.strength - 1.0).abs() < 0.01);
100    }
101
102    #[test]
103    fn test_decay_respects_importance() {
104        let store = MemoryStore::open_in_memory().unwrap();
105        let id_low = store.remember_fact("low", "importance", "mem", None).unwrap();
106        let id_high = store.remember_fact("high", "importance", "mem", None).unwrap();
107
108        // Set importance: low=0.0, high=1.0
109        store.update_importance(id_low, 0.0).unwrap();
110        store.update_importance(id_high, 1.0).unwrap();
111
112        // Set both to 48 hours ago
113        let old_time = (chrono::Utc::now() - chrono::Duration::hours(48)).to_rfc3339();
114        store
115            .conn()
116            .execute(
117                "UPDATE memories SET last_accessed_at = ?1",
118                rusqlite::params![old_time],
119            )
120            .unwrap();
121
122        let _result = run_decay(&store, None, None).unwrap();
123
124        let low = store.get_memory(id_low).unwrap().unwrap();
125        let high = store.get_memory(id_high).unwrap().unwrap();
126
127        // High importance memory should retain more strength
128        assert!(
129            high.strength > low.strength,
130            "high importance ({:.4}) should decay slower than low importance ({:.4})",
131            high.strength, low.strength
132        );
133    }
134
135    #[test]
136    fn test_decay_deletes_very_weak() {
137        let store = MemoryStore::open_in_memory().unwrap();
138        store.remember_fact("A", "is", "B", None).unwrap();
139
140        // Set strength very low and last_accessed a long time ago
141        let old_time = (chrono::Utc::now() - chrono::Duration::hours(24 * 30)).to_rfc3339();
142        store
143            .conn()
144            .execute(
145                "UPDATE memories SET strength = 0.001, last_accessed_at = ?1",
146                rusqlite::params![old_time],
147            )
148            .unwrap();
149
150        let result = run_decay(&store, None, None).unwrap();
151        assert!(result.deleted > 0);
152
153        // Memory should be gone
154        let mem = store.get_memory(1).unwrap();
155        assert!(mem.is_none());
156    }
157}