Skip to main content

oxios_memory/memory/
decay.rs

1//! Ebbinghaus-inspired decay engine for memory importance scoring.
2//!
3//! Implements a forgetting curve: R(t) = e^(-rate × t), where the rate
4//! is adjusted by memory type, protection level, and access frequency.
5
6use chrono::{DateTime, Utc};
7
8use crate::memory::types::{MemoryEntry, ProtectionLevel};
9
10// ---------------------------------------------------------------------------
11// DecayEngine
12// ---------------------------------------------------------------------------
13
14/// Decay engine — computes current retention scores for memory entries.
15///
16/// Uses an Ebbinghaus-inspired forgetting curve with adjustments for:
17/// - Memory type (UserProfile decays slower than Conversation)
18/// - Protection level (Higher protection = slower decay)
19/// - Access frequency (Frequently accessed = slower decay)
20/// - Global multiplier (user-configurable)
21#[derive(Debug, Clone)]
22pub struct DecayEngine {
23    /// Global decay multiplier. 1.0 = default speed.
24    pub multiplier: f32,
25}
26
27impl DecayEngine {
28    /// Create a new decay engine with the given multiplier.
29    pub fn new(multiplier: f32) -> Self {
30        Self { multiplier }
31    }
32
33    /// Create with default multiplier (1.0).
34    pub fn default_engine() -> Self {
35        Self::new(1.0)
36    }
37
38    /// Compute current decay score for an entry.
39    ///
40    /// Returns a value between 0.0 (fully decayed) and 1.0 (fresh).
41    /// Permanent protection always returns 1.0.
42    pub fn compute_decay(&self, entry: &MemoryEntry, now: DateTime<Utc>) -> f32 {
43        // Permanent protection = always 1.0
44        if entry.pinned || entry.protection == ProtectionLevel::Permanent {
45            return 1.0;
46        }
47
48        // Use fractional hours (not num_hours, which truncates to whole hours
49        // and would treat a 59-minute-old access the same as a fresh one).
50        let hours_since_access = ((now - entry.accessed_at).num_seconds().max(0) as f32) / 3600.0;
51        let base_rate = entry.memory_type.base_decay_rate();
52
53        // Access boost: frequently read memories decay slower
54        let access_boost = 1.0 + (1.0_f32 + entry.access_count as f32).ln();
55
56        // Protection multiplier: higher protection = slower decay
57        let protection_mult = entry.protection.decay_multiplier();
58
59        let effective_rate = base_rate * self.multiplier * protection_mult / access_boost;
60        let retention = (-effective_rate * hours_since_access).exp();
61        retention.clamp(0.0, 1.0)
62    }
63
64    /// Compute effective importance of a memory entry.
65    ///
66    /// Effective importance = base_importance × (1 + ln(1 + access_count)) × decay_score.
67    pub fn effective_importance(entry: &MemoryEntry) -> f32 {
68        let access_boost = 1.0 + (1.0_f32 + entry.access_count as f32).ln();
69        entry.importance * access_boost * entry.decay_score
70    }
71
72    /// Check if an entry should be considered for pruning.
73    ///
74    /// An entry is a pruning candidate when:
75    /// - its *current* decay score (recomputed at `now`) < threshold
76    /// - protection is None or Low
77    /// - not pinned
78    /// - not auto-protected type
79    ///
80    /// The decay is recomputed against `now` rather than reading the stale
81    /// persisted `entry.decay_score`, so an entry that has crossed the
82    /// threshold since the last Dream run is still detected.
83    pub fn is_prunable(&self, entry: &MemoryEntry, threshold: f32, now: DateTime<Utc>) -> bool {
84        if entry.pinned {
85            return false;
86        }
87        if entry.protection >= super::ProtectionLevel::Medium {
88            return false;
89        }
90        if entry.memory_type.is_auto_protected() {
91            return false;
92        }
93        self.compute_decay(entry, now) < threshold
94    }
95}
96
97// ---------------------------------------------------------------------------
98// Tests
99// ---------------------------------------------------------------------------
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::memory::{MemoryEntry, MemoryType, ProtectionLevel};
105    use chrono::Duration;
106
107    fn make_entry(hours_ago: i64) -> MemoryEntry {
108        MemoryEntry {
109            id: "test".to_string(),
110            memory_type: MemoryType::Fact,
111            tier: crate::memory::MemoryTier::Warm,
112            content: "test content".to_string(),
113            content_hash: 0,
114            tags: vec![],
115            source: "test".to_string(),
116            session_id: None,
117            importance: 0.5,
118            pinned: false,
119            protection: ProtectionLevel::None,
120            auto_classified: false,
121            session_appearances: 0,
122            user_corrected: false,
123            seen_in_sessions: vec![],
124            created_at: Utc::now(),
125            accessed_at: Utc::now() - Duration::hours(hours_ago),
126            modified_at: Utc::now(),
127            access_count: 0,
128            decay_score: 1.0,
129            compaction_level: 0,
130            compacted_from: vec![],
131            related_ids: vec![],
132            contradicts: None,
133        }
134    }
135
136    #[test]
137    fn test_decay_fresh() {
138        let engine = DecayEngine::new(1.0);
139        let entry = make_entry(0); // Just accessed
140        let score = engine.compute_decay(&entry, Utc::now());
141        assert!(
142            score > 0.99,
143            "Fresh entry should have decay ~1.0, got {}",
144            score
145        );
146    }
147
148    #[test]
149    fn test_decay_old() {
150        let engine = DecayEngine::new(1.0);
151        let entry = make_entry(720); // 30 days ago
152        let score = engine.compute_decay(&entry, Utc::now());
153        assert!(
154            score < 0.5,
155            "Old entry should have significant decay, got {}",
156            score
157        );
158    }
159
160    #[test]
161    fn test_decay_permanent_protection() {
162        let engine = DecayEngine::new(1.0);
163        let mut entry = make_entry(720);
164        entry.protection = ProtectionLevel::Permanent;
165        let score = engine.compute_decay(&entry, Utc::now());
166        assert_eq!(score, 1.0, "Permanent protection should always be 1.0");
167    }
168
169    #[test]
170    fn test_decay_pinned() {
171        let engine = DecayEngine::new(1.0);
172        let mut entry = make_entry(720);
173        entry.pinned = true;
174        let score = engine.compute_decay(&entry, Utc::now());
175        assert_eq!(score, 1.0, "Pinned entry should always be 1.0");
176    }
177
178    #[test]
179    fn test_decay_high_protection_slower() {
180        let engine = DecayEngine::new(1.0);
181        let mut entry_none = make_entry(168); // 7 days
182        entry_none.protection = ProtectionLevel::None;
183
184        let mut entry_high = make_entry(168);
185        entry_high.protection = ProtectionLevel::High;
186
187        let score_none = engine.compute_decay(&entry_none, Utc::now());
188        let score_high = engine.compute_decay(&entry_high, Utc::now());
189        assert!(
190            score_high > score_none,
191            "High protection should decay slower (high={}, none={})",
192            score_high,
193            score_none
194        );
195    }
196
197    #[test]
198    fn test_decay_access_boost() {
199        let engine = DecayEngine::new(1.0);
200        let mut entry_low = make_entry(168);
201        entry_low.access_count = 0;
202
203        let mut entry_high = make_entry(168);
204        entry_high.access_count = 10;
205
206        let score_low = engine.compute_decay(&entry_low, Utc::now());
207        let score_high = engine.compute_decay(&entry_high, Utc::now());
208        assert!(
209            score_high > score_low,
210            "Frequently accessed should decay slower (high={}, low={})",
211            score_high,
212            score_low
213        );
214    }
215
216    #[test]
217    fn test_effective_importance() {
218        let mut entry = make_entry(0);
219        entry.importance = 0.6;
220        entry.access_count = 5;
221        entry.decay_score = 0.8;
222        let eff = DecayEngine::effective_importance(&entry);
223        assert!(
224            eff > 0.6,
225            "Effective importance should be boosted, got {}",
226            eff
227        );
228    }
229
230    #[test]
231    fn test_prunable() {
232        let engine = DecayEngine::new(1.0);
233        // 30 days old → recomputed decay well below the 0.05 threshold.
234        let now = Utc::now();
235        let mut entry = make_entry(720);
236        assert!(
237            engine.is_prunable(&entry, 0.05, now),
238            "old, unprotected, unpinned entry should be prunable"
239        );
240
241        entry.pinned = true;
242        assert!(!engine.is_prunable(&entry, 0.05, now));
243
244        entry.pinned = false;
245        entry.protection = ProtectionLevel::Medium;
246        assert!(!engine.is_prunable(&entry, 0.05, now));
247    }
248}