oxios_memory/memory/
decay.rs1use chrono::{DateTime, Utc};
7
8use crate::memory::types::{MemoryEntry, ProtectionLevel};
9
10#[derive(Debug, Clone)]
22pub struct DecayEngine {
23 pub multiplier: f32,
25}
26
27impl DecayEngine {
28 pub fn new(multiplier: f32) -> Self {
30 Self { multiplier }
31 }
32
33 pub fn default_engine() -> Self {
35 Self::new(1.0)
36 }
37
38 pub fn compute_decay(&self, entry: &MemoryEntry, now: DateTime<Utc>) -> f32 {
43 if entry.pinned || entry.protection == ProtectionLevel::Permanent {
45 return 1.0;
46 }
47
48 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 let access_boost = 1.0 + (1.0_f32 + entry.access_count as f32).ln();
55
56 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 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 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#[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); 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); 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); 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 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}