Skip to main content

memory_crystal/
decay.rs

1use std::time::Duration;
2
3use chrono::{DateTime, Utc};
4
5use crate::tile::Tile;
6
7/// Ebbinghaus-inspired memory decay schedule.
8#[derive(Debug, Clone)]
9pub struct DecaySchedule {
10    pub half_life: Duration,
11    pub valence_weight: f64,
12    pub min_valence_for_permanent: f64,
13}
14
15impl Default for DecaySchedule {
16    fn default() -> Self {
17        Self {
18            half_life: Duration::from_secs(7 * 24 * 3600), // 1 week
19            valence_weight: 0.5,
20            min_valence_for_permanent: 0.9,
21        }
22    }
23}
24
25impl DecaySchedule {
26    /// Ebbinghaus forgetting curve: retention = e^(-t/S)
27    ///
28    /// Returns a value between 0.0 and 1.0 representing how well this tile
29    /// should be retained. High-valence tiles decay slower.
30    pub fn retention(&self, tile: &Tile, now: DateTime<Utc>) -> f64 {
31        // Permanent memories never decay.
32        if tile.valence >= self.min_valence_for_permanent {
33            return 1.0;
34        }
35
36        let elapsed = now.signed_duration_since(tile.accessed_at);
37        let elapsed_secs = elapsed.num_seconds().max(0) as f64;
38        let half_life_secs = self.half_life.as_secs() as f64;
39
40        // Effective half-life: high-valence memories decay slower.
41        let valence_factor = 1.0 + self.valence_weight * tile.valence;
42        let effective_half_life = half_life_secs * valence_factor;
43
44        // Access count slows decay (spaced repetition effect).
45        let access_factor = 1.0 + (tile.access_count as f64).ln().max(0.0) * 0.3;
46        let adjusted_half_life = effective_half_life * access_factor;
47
48        let decay_constant = 0.693 / adjusted_half_life; // ln(2) / half_life
49        let retention = (-decay_constant * elapsed_secs).exp();
50
51        retention.clamp(0.0, 1.0)
52    }
53
54    /// Should this tile be forgotten based on decay?
55    pub fn should_forget(&self, tile: &Tile, now: DateTime<Utc>) -> bool {
56        self.retention(tile, now) < 0.1 // Below 10% retention = forget.
57    }
58
59    /// Reconsolidate: update tile with new context, resetting decay.
60    ///
61    /// This models the neurobiological process where recalling a memory
62    /// makes it labile again, and re-storing it can strengthen or modify it.
63    pub fn reconsolidate(&self, tile: &mut Tile, new_context: &str) {
64        // Reset the access clock.
65        tile.accessed_at = Utc::now();
66        tile.access_count += 1;
67
68        // If new context adds constraints, merge them.
69        if !new_context.is_empty() {
70            // Extract key phrases from new context and add to summary.
71            let additions: Vec<&str> = new_context
72                .split('.')
73                .map(|s| s.trim())
74                .filter(|s| !s.is_empty())
75                .take(3)
76                .collect();
77
78            if !additions.is_empty() {
79                tile.summary = format!("{} [updated: {}]", tile.summary, additions.join("; "));
80            }
81        }
82
83        // Slightly increase valence (memory is being reinforced).
84        tile.valence = (tile.valence + 0.05).min(1.0);
85    }
86
87    /// Create a fast-decay schedule (for testing).
88    pub fn fast_decay() -> Self {
89        Self {
90            half_life: Duration::from_secs(60), // 1 minute
91            valence_weight: 0.3,
92            min_valence_for_permanent: 0.95,
93        }
94    }
95
96    /// Create a slow-decay schedule (permanent-ish memories).
97    pub fn slow_decay() -> Self {
98        Self {
99            half_life: Duration::from_secs(365 * 24 * 3600), // 1 year
100            valence_weight: 1.0,
101            min_valence_for_permanent: 0.7,
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::collections::HashMap;
110
111    fn test_tile(valence: f64) -> Tile {
112        Tile {
113            id: crate::tile::TileId::new(),
114            source_hash: String::new(),
115            constraints: HashMap::new(),
116            summary: "test tile".into(),
117            context_required: vec![],
118            valence,
119            created_at: Utc::now(),
120            accessed_at: Utc::now(),
121            access_count: 0,
122            generation: 0,
123            parent_id: None,
124        }
125    }
126
127    #[test]
128    fn fresh_tile_full_retention() {
129        let schedule = DecaySchedule::default();
130        let tile = test_tile(0.5);
131        let retention = schedule.retention(&tile, Utc::now());
132        assert!(retention > 0.99, "fresh tile should have ~1.0 retention, got {}", retention);
133    }
134
135    #[test]
136    fn permanent_never_decays() {
137        let schedule = DecaySchedule::default();
138        let mut tile = test_tile(0.95);
139        tile.accessed_at = Utc::now() - chrono::Duration::days(365);
140        let retention = schedule.retention(&tile, Utc::now());
141        assert_eq!(retention, 1.0);
142    }
143
144    #[test]
145    fn fast_decay_forgets_quickly() {
146        let schedule = DecaySchedule::fast_decay();
147        let mut tile = test_tile(0.1);
148        tile.accessed_at = Utc::now() - chrono::Duration::minutes(10);
149        assert!(schedule.should_forget(&tile, Utc::now()));
150    }
151
152    #[test]
153    fn reconsolidate_resets_decay() {
154        let schedule = DecaySchedule::default();
155        let mut tile = test_tile(0.5);
156        // Age the tile.
157        tile.accessed_at = Utc::now() - chrono::Duration::days(30);
158        let before_retention = schedule.retention(&tile, Utc::now());
159
160        // Reconsolidate.
161        schedule.reconsolidate(&mut tile, "New information about the project");
162
163        let after_retention = schedule.retention(&tile, Utc::now());
164        assert!(after_retention > before_retention);
165        assert_eq!(tile.access_count, 1);
166        assert!(tile.valence > 0.5);
167    }
168
169    #[test]
170    fn access_count_slows_decay() {
171        let schedule = DecaySchedule::default();
172        let mut tile1 = test_tile(0.5);
173        tile1.accessed_at = Utc::now() - chrono::Duration::days(7);
174        tile1.access_count = 1;
175
176        let mut tile2 = test_tile(0.5);
177        tile2.accessed_at = Utc::now() - chrono::Duration::days(7);
178        tile2.access_count = 100;
179
180        let r1 = schedule.retention(&tile1, Utc::now());
181        let r2 = schedule.retention(&tile2, Utc::now());
182        assert!(r2 > r1, "more accesses should retain better: {} vs {}", r2, r1);
183    }
184
185    #[test]
186    fn high_valence_decays_slower() {
187        let schedule = DecaySchedule::default();
188        let mut low = test_tile(0.1);
189        low.accessed_at = Utc::now() - chrono::Duration::days(7);
190        let mut high = test_tile(0.8);
191        high.accessed_at = Utc::now() - chrono::Duration::days(7);
192
193        let r_low = schedule.retention(&low, Utc::now());
194        let r_high = schedule.retention(&high, Utc::now());
195        assert!(r_high > r_low);
196    }
197}