Skip to main content

oxios_memory/memory/
auto_protect.rs

1//! Automatic memory protection based on access patterns.
2//!
3//! Computes protection levels from behavior signals (access frequency,
4//! session appearances, user corrections) without user intervention.
5
6use chrono::Utc;
7
8use crate::memory::types::{MemoryEntry, ProtectionLevel};
9
10// ---------------------------------------------------------------------------
11// AutoProtector
12// ---------------------------------------------------------------------------
13
14/// Automatic protection calculator.
15///
16/// Protection levels are computed from access patterns:
17/// - None → Low: 2+ accesses
18/// - Low → Medium: 3+ accesses OR 2+ session appearances
19/// - Medium → High: 5+ accesses OR 3+ session appearances OR user correction
20/// - High → Permanent: UserProfile/Preference type OR explicit pin
21///
22/// Protection can also be demoted when entries become stale.
23#[derive(Debug, Clone)]
24pub struct AutoProtector {
25    /// Minimum access count for Low protection.
26    pub protection_low_access: u32,
27    /// Minimum access count for Medium protection.
28    pub protection_medium_access: u32,
29    /// Minimum access count for High protection.
30    pub protection_high_access: u32,
31    /// Minimum session appearances for Medium protection.
32    pub protection_medium_sessions: u32,
33    /// Minimum session appearances for High protection.
34    pub protection_high_sessions: u32,
35    /// Days without access before considering demotion.
36    pub demotion_stale_days: u32,
37}
38
39impl AutoProtector {
40    /// Create a new protector with the given thresholds.
41    pub fn new(
42        low_access: u32,
43        medium_access: u32,
44        high_access: u32,
45        medium_sessions: u32,
46        high_sessions: u32,
47        demotion_stale_days: u32,
48    ) -> Self {
49        Self {
50            protection_low_access: low_access,
51            protection_medium_access: medium_access,
52            protection_high_access: high_access,
53            protection_medium_sessions: medium_sessions,
54            protection_high_sessions: high_sessions,
55            demotion_stale_days,
56        }
57    }
58
59    /// Create with default thresholds from RFC-008.
60    pub fn default_protector() -> Self {
61        Self::new(2, 3, 5, 2, 3, 30)
62    }
63
64    /// Compute protection level for a memory entry based on access patterns.
65    ///
66    /// This is the core auto-protection logic. Dream calls this every run.
67    pub fn compute_protection(&self, entry: &MemoryEntry) -> ProtectionLevel {
68        // 1. Type-based default protection
69        if entry.memory_type.is_auto_protected() {
70            return ProtectionLevel::Permanent;
71        }
72
73        // 2. Explicit pin
74        if entry.pinned {
75            return ProtectionLevel::Permanent;
76        }
77
78        // 3. User correction → High
79        if entry.user_corrected {
80            return ProtectionLevel::High;
81        }
82
83        // 4. Access pattern-based promotion
84        let access_count = entry.access_count;
85        let session_span = entry.session_appearances;
86
87        // 5+ accesses OR 3+ sessions → High
88        if access_count >= self.protection_high_access
89            || session_span >= self.protection_high_sessions
90        {
91            return ProtectionLevel::High;
92        }
93
94        // 3+ accesses OR 2+ sessions → Medium
95        if access_count >= self.protection_medium_access
96            || session_span >= self.protection_medium_sessions
97        {
98            return ProtectionLevel::Medium;
99        }
100
101        // 2+ accesses → Low
102        if access_count >= self.protection_low_access {
103            return ProtectionLevel::Low;
104        }
105
106        // Default: no protection
107        ProtectionLevel::None
108    }
109
110    /// Evaluate whether a protection level should be demoted.
111    ///
112    /// Only demotes by one step at a time (High → Medium, Medium → Low, etc.).
113    /// Returns `None` if no demotion is warranted.
114    pub fn should_demote_protection(
115        &self,
116        entry: &MemoryEntry,
117        current: ProtectionLevel,
118    ) -> Option<ProtectionLevel> {
119        // Permanent and explicit pins are never demoted
120        if entry.pinned || current == ProtectionLevel::Permanent {
121            return None;
122        }
123
124        let days_since_access = (Utc::now() - entry.accessed_at).num_days() as u32;
125        let stale = self.demotion_stale_days;
126
127        // High → Medium: stale_days + current criteria no longer met
128        if current == ProtectionLevel::High
129            && days_since_access > stale
130            && entry.access_count < self.protection_medium_access
131        {
132            return Some(ProtectionLevel::Medium);
133        }
134
135        // Medium → Low: stale_days × 2
136        if current == ProtectionLevel::Medium && days_since_access > stale * 2 {
137            return Some(ProtectionLevel::Low);
138        }
139
140        // Low → None: stale_days × 3
141        if current == ProtectionLevel::Low && days_since_access > stale * 3 {
142            return Some(ProtectionLevel::None);
143        }
144
145        None
146    }
147
148    /// Record access to a memory entry (updates tracking fields).
149    ///
150    /// Call this whenever a memory is recalled or searched.
151    pub fn record_access(entry: &mut MemoryEntry, current_session_id: &str) {
152        entry.access_count += 1;
153        entry.accessed_at = Utc::now();
154
155        // Update session_appearances with dedup. Cap the tracking list at 100
156        // WITHOUT evicting the oldest entry: evicting (remove(0)) meant that an
157        // early session, once pushed out, would be mistaken for a brand-new
158        // session on revisit and re-counted — inflating session_appearances and
159        // prematurely promoting protection to Permanent. Capping in place keeps
160        // the first 100 sessions sticky so they are never double-counted; beyond
161        // 100 we simply stop tracking new session ids (the entry is already
162        // clearly well-accessed).
163        if !entry
164            .seen_in_sessions
165            .contains(&current_session_id.to_string())
166        {
167            entry.session_appearances += 1;
168            if entry.seen_in_sessions.len() < 100 {
169                entry.seen_in_sessions.push(current_session_id.to_string());
170            }
171        }
172
173        // Partial decay recovery on access
174        let boosted = 0.5 + 0.5 * entry.decay_score;
175        entry.decay_score = entry.decay_score.max(boosted);
176    }
177}
178
179// ---------------------------------------------------------------------------
180// Tests
181// ---------------------------------------------------------------------------
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::memory::{MemoryEntry, MemoryTier, MemoryType};
187    use chrono::Duration;
188
189    fn make_entry_with_access(access_count: u32, sessions: u32) -> MemoryEntry {
190        let mut entry = make_base_entry();
191        entry.access_count = access_count;
192        entry.session_appearances = sessions;
193        entry.seen_in_sessions = (0..sessions).map(|i| format!("session-{}", i)).collect();
194        entry
195    }
196
197    fn make_base_entry() -> MemoryEntry {
198        MemoryEntry {
199            id: "test".to_string(),
200            memory_type: MemoryType::Fact,
201            tier: MemoryTier::Warm,
202            content: "test".to_string(),
203            content_hash: 0,
204            tags: vec![],
205            source: "test".to_string(),
206            session_id: None,
207            importance: 0.5,
208            pinned: false,
209            protection: ProtectionLevel::None,
210            auto_classified: false,
211            session_appearances: 0,
212            user_corrected: false,
213            seen_in_sessions: vec![],
214            created_at: Utc::now(),
215            accessed_at: Utc::now(),
216            modified_at: Utc::now(),
217            access_count: 0,
218            decay_score: 1.0,
219            compaction_level: 0,
220            compacted_from: vec![],
221            related_ids: vec![],
222            contradicts: None,
223        }
224    }
225
226    #[test]
227    fn test_protection_none_default() {
228        let protector = AutoProtector::default_protector();
229        let entry = make_entry_with_access(0, 0);
230        assert_eq!(protector.compute_protection(&entry), ProtectionLevel::None);
231    }
232
233    #[test]
234    fn test_protection_low() {
235        let protector = AutoProtector::default_protector();
236        let entry = make_entry_with_access(2, 0);
237        assert_eq!(protector.compute_protection(&entry), ProtectionLevel::Low);
238    }
239
240    #[test]
241    fn test_protection_medium_access() {
242        let protector = AutoProtector::default_protector();
243        let entry = make_entry_with_access(3, 0);
244        assert_eq!(
245            protector.compute_protection(&entry),
246            ProtectionLevel::Medium
247        );
248    }
249
250    #[test]
251    fn test_protection_medium_sessions() {
252        let protector = AutoProtector::default_protector();
253        let entry = make_entry_with_access(0, 2);
254        assert_eq!(
255            protector.compute_protection(&entry),
256            ProtectionLevel::Medium
257        );
258    }
259
260    #[test]
261    fn test_protection_high_access() {
262        let protector = AutoProtector::default_protector();
263        let entry = make_entry_with_access(5, 0);
264        assert_eq!(protector.compute_protection(&entry), ProtectionLevel::High);
265    }
266
267    #[test]
268    fn test_protection_high_sessions() {
269        let protector = AutoProtector::default_protector();
270        let entry = make_entry_with_access(0, 3);
271        assert_eq!(protector.compute_protection(&entry), ProtectionLevel::High);
272    }
273
274    #[test]
275    fn test_protection_permanent_for_profile() {
276        let protector = AutoProtector::default_protector();
277        let mut entry = make_base_entry();
278        entry.memory_type = MemoryType::UserProfile;
279        assert_eq!(
280            protector.compute_protection(&entry),
281            ProtectionLevel::Permanent
282        );
283    }
284
285    #[test]
286    fn test_protection_permanent_for_preference() {
287        let protector = AutoProtector::default_protector();
288        let mut entry = make_base_entry();
289        entry.memory_type = MemoryType::Preference;
290        assert_eq!(
291            protector.compute_protection(&entry),
292            ProtectionLevel::Permanent
293        );
294    }
295
296    #[test]
297    fn test_protection_user_correction() {
298        let protector = AutoProtector::default_protector();
299        let mut entry = make_base_entry();
300        entry.user_corrected = true;
301        assert_eq!(protector.compute_protection(&entry), ProtectionLevel::High);
302    }
303
304    #[test]
305    fn test_protection_pinned() {
306        let protector = AutoProtector::default_protector();
307        let mut entry = make_base_entry();
308        entry.pinned = true;
309        assert_eq!(
310            protector.compute_protection(&entry),
311            ProtectionLevel::Permanent
312        );
313    }
314
315    #[test]
316    fn test_demote_high_to_medium() {
317        let protector = AutoProtector::default_protector();
318        let mut entry = make_entry_with_access(2, 0); // Below medium threshold
319        entry.accessed_at = Utc::now() - Duration::days(35); // > 30 days stale
320        let result = protector.should_demote_protection(&entry, ProtectionLevel::High);
321        assert_eq!(result, Some(ProtectionLevel::Medium));
322    }
323
324    #[test]
325    fn test_demote_medium_to_low() {
326        let protector = AutoProtector::default_protector();
327        let mut entry = make_entry_with_access(3, 1);
328        entry.accessed_at = Utc::now() - Duration::days(65); // > 60 days stale
329        let result = protector.should_demote_protection(&entry, ProtectionLevel::Medium);
330        assert_eq!(result, Some(ProtectionLevel::Low));
331    }
332
333    #[test]
334    fn test_demote_low_to_none() {
335        let protector = AutoProtector::default_protector();
336        let mut entry = make_entry_with_access(2, 0);
337        entry.accessed_at = Utc::now() - Duration::days(95); // > 90 days stale
338        let result = protector.should_demote_protection(&entry, ProtectionLevel::Low);
339        assert_eq!(result, Some(ProtectionLevel::None));
340    }
341
342    #[test]
343    fn test_no_demote_permanent() {
344        let protector = AutoProtector::default_protector();
345        let mut entry = make_base_entry();
346        entry.accessed_at = Utc::now() - Duration::days(365);
347        let result = protector.should_demote_protection(&entry, ProtectionLevel::Permanent);
348        assert_eq!(result, None);
349    }
350
351    #[test]
352    fn test_no_demote_pinned() {
353        let protector = AutoProtector::default_protector();
354        let mut entry = make_base_entry();
355        entry.pinned = true;
356        entry.accessed_at = Utc::now() - Duration::days(365);
357        let result = protector.should_demote_protection(&entry, ProtectionLevel::High);
358        assert_eq!(result, None);
359    }
360
361    #[test]
362    fn test_record_access() {
363        let mut entry = make_base_entry();
364        entry.decay_score = 0.2;
365        AutoProtector::record_access(&mut entry, "session-1");
366
367        assert_eq!(entry.access_count, 1);
368        assert_eq!(entry.session_appearances, 1);
369        assert!(entry.seen_in_sessions.contains(&"session-1".to_string()));
370        assert!(entry.decay_score > 0.2, "Should recover decay on access");
371    }
372
373    #[test]
374    fn test_record_access_dedup_session() {
375        let mut entry = make_base_entry();
376        AutoProtector::record_access(&mut entry, "session-1");
377        AutoProtector::record_access(&mut entry, "session-1");
378        assert_eq!(entry.access_count, 2);
379        assert_eq!(
380            entry.session_appearances, 1,
381            "Same session should not increment appearances"
382        );
383    }
384}