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        let hours_since_access = (now - entry.accessed_at).num_hours().max(0) as f32;
49        let base_rate = entry.memory_type.base_decay_rate();
50
51        // Access boost: frequently read memories decay slower
52        let access_boost = 1.0 + (1.0_f32 + entry.access_count as f32).ln();
53
54        // Protection multiplier: higher protection = slower decay
55        let protection_mult = entry.protection.decay_multiplier();
56
57        let effective_rate = base_rate * self.multiplier * protection_mult / access_boost;
58        let retention = (-effective_rate * hours_since_access).exp();
59        retention.clamp(0.0, 1.0)
60    }
61
62    /// Compute effective importance of a memory entry.
63    ///
64    /// Effective importance = base_importance × (1 + ln(1 + access_count)) × decay_score.
65    pub fn effective_importance(entry: &MemoryEntry) -> f32 {
66        let access_boost = 1.0 + (1.0_f32 + entry.access_count as f32).ln();
67        entry.importance * access_boost * entry.decay_score
68    }
69
70    /// Check if an entry should be considered for pruning.
71    ///
72    /// An entry is a pruning candidate when:
73    /// - decay_score < threshold
74    /// - protection is None or Low
75    /// - not pinned
76    /// - not auto-protected type
77    pub fn is_prunable(&self, entry: &MemoryEntry, threshold: f32) -> bool {
78        if entry.pinned {
79            return false;
80        }
81        if entry.protection >= super::ProtectionLevel::Medium {
82            return false;
83        }
84        if entry.memory_type.is_auto_protected() {
85            return false;
86        }
87        entry.decay_score < threshold
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Tests
93// ---------------------------------------------------------------------------
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::memory::{MemoryEntry, MemoryType, ProtectionLevel};
99    use chrono::Duration;
100
101    fn make_entry(hours_ago: i64) -> MemoryEntry {
102        MemoryEntry {
103            id: "test".to_string(),
104            memory_type: MemoryType::Fact,
105            tier: crate::memory::MemoryTier::Warm,
106            content: "test content".to_string(),
107            content_hash: 0,
108            tags: vec![],
109            source: "test".to_string(),
110            session_id: None,
111            importance: 0.5,
112            pinned: false,
113            protection: ProtectionLevel::None,
114            auto_classified: false,
115            session_appearances: 0,
116            user_corrected: false,
117            seen_in_sessions: vec![],
118            created_at: Utc::now(),
119            accessed_at: Utc::now() - Duration::hours(hours_ago),
120            modified_at: Utc::now(),
121            access_count: 0,
122            decay_score: 1.0,
123            compaction_level: 0,
124            compacted_from: vec![],
125            related_ids: vec![],
126            contradicts: None,
127        }
128    }
129
130    #[test]
131    fn test_decay_fresh() {
132        let engine = DecayEngine::new(1.0);
133        let entry = make_entry(0); // Just accessed
134        let score = engine.compute_decay(&entry, Utc::now());
135        assert!(
136            score > 0.99,
137            "Fresh entry should have decay ~1.0, got {}",
138            score
139        );
140    }
141
142    #[test]
143    fn test_decay_old() {
144        let engine = DecayEngine::new(1.0);
145        let entry = make_entry(720); // 30 days ago
146        let score = engine.compute_decay(&entry, Utc::now());
147        assert!(
148            score < 0.5,
149            "Old entry should have significant decay, got {}",
150            score
151        );
152    }
153
154    #[test]
155    fn test_decay_permanent_protection() {
156        let engine = DecayEngine::new(1.0);
157        let mut entry = make_entry(720);
158        entry.protection = ProtectionLevel::Permanent;
159        let score = engine.compute_decay(&entry, Utc::now());
160        assert_eq!(score, 1.0, "Permanent protection should always be 1.0");
161    }
162
163    #[test]
164    fn test_decay_pinned() {
165        let engine = DecayEngine::new(1.0);
166        let mut entry = make_entry(720);
167        entry.pinned = true;
168        let score = engine.compute_decay(&entry, Utc::now());
169        assert_eq!(score, 1.0, "Pinned entry should always be 1.0");
170    }
171
172    #[test]
173    fn test_decay_high_protection_slower() {
174        let engine = DecayEngine::new(1.0);
175        let mut entry_none = make_entry(168); // 7 days
176        entry_none.protection = ProtectionLevel::None;
177
178        let mut entry_high = make_entry(168);
179        entry_high.protection = ProtectionLevel::High;
180
181        let score_none = engine.compute_decay(&entry_none, Utc::now());
182        let score_high = engine.compute_decay(&entry_high, Utc::now());
183        assert!(
184            score_high > score_none,
185            "High protection should decay slower (high={}, none={})",
186            score_high,
187            score_none
188        );
189    }
190
191    #[test]
192    fn test_decay_access_boost() {
193        let engine = DecayEngine::new(1.0);
194        let mut entry_low = make_entry(168);
195        entry_low.access_count = 0;
196
197        let mut entry_high = make_entry(168);
198        entry_high.access_count = 10;
199
200        let score_low = engine.compute_decay(&entry_low, Utc::now());
201        let score_high = engine.compute_decay(&entry_high, Utc::now());
202        assert!(
203            score_high > score_low,
204            "Frequently accessed should decay slower (high={}, low={})",
205            score_high,
206            score_low
207        );
208    }
209
210    #[test]
211    fn test_effective_importance() {
212        let mut entry = make_entry(0);
213        entry.importance = 0.6;
214        entry.access_count = 5;
215        entry.decay_score = 0.8;
216        let eff = DecayEngine::effective_importance(&entry);
217        assert!(
218            eff > 0.6,
219            "Effective importance should be boosted, got {}",
220            eff
221        );
222    }
223
224    #[test]
225    fn test_prunable() {
226        let engine = DecayEngine::new(1.0);
227        let mut entry = make_entry(0);
228        entry.decay_score = 0.01;
229        assert!(engine.is_prunable(&entry, 0.05));
230
231        entry.pinned = true;
232        assert!(!engine.is_prunable(&entry, 0.05));
233
234        entry.pinned = false;
235        entry.protection = ProtectionLevel::Medium;
236        assert!(!engine.is_prunable(&entry, 0.05));
237    }
238}