Skip to main content

zeroclaw_memory/
decay.rs

1use super::traits::{MemoryCategory, MemoryEntry};
2use chrono::{DateTime, Utc};
3
4/// Default half-life in days for time-decay scoring.
5/// After this many days, a non-Core memory's score drops to 50%.
6pub const DEFAULT_HALF_LIFE_DAYS: f64 = 7.0;
7
8/// Apply exponential time decay to memory entry scores.
9///
10/// - `Core` memories are exempt ("evergreen") — their scores are never decayed.
11/// - Entries without a parseable RFC3339 timestamp are left unchanged.
12/// - Entries without a score (`None`) are left unchanged.
13///
14/// Decay formula: `score * 2^(-age_days / half_life_days)`
15pub fn apply_time_decay(entries: &mut [MemoryEntry], half_life_days: f64) {
16    let half_life = if half_life_days <= 0.0 {
17        DEFAULT_HALF_LIFE_DAYS
18    } else {
19        half_life_days
20    };
21
22    let now = Utc::now();
23
24    for entry in entries.iter_mut() {
25        // Core memories are evergreen — never decay
26        if entry.category == MemoryCategory::Core {
27            continue;
28        }
29
30        let score = match entry.score {
31            Some(s) => s,
32            None => continue,
33        };
34
35        let ts = match DateTime::parse_from_rfc3339(&entry.timestamp) {
36            Ok(dt) => dt.with_timezone(&Utc),
37            Err(_) => continue,
38        };
39
40        let age_days = now.signed_duration_since(ts).num_seconds().max(0) as f64 / 86_400.0;
41
42        let decay_factor = (-age_days / half_life * std::f64::consts::LN_2).exp();
43        entry.score = Some(score * decay_factor);
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    fn make_entry(category: MemoryCategory, score: Option<f64>, timestamp: &str) -> MemoryEntry {
52        MemoryEntry {
53            id: "1".into(),
54            key: "test".into(),
55            content: "value".into(),
56            category,
57            timestamp: timestamp.into(),
58            session_id: None,
59            score,
60            namespace: "default".into(),
61            importance: None,
62            superseded_by: None,
63            agent_alias: None,
64            agent_id: None,
65        }
66    }
67
68    fn recent_rfc3339() -> String {
69        Utc::now().to_rfc3339()
70    }
71
72    fn days_ago_rfc3339(days: i64) -> String {
73        (Utc::now() - chrono::Duration::days(days)).to_rfc3339()
74    }
75
76    #[test]
77    fn core_memories_are_never_decayed() {
78        let mut entries = vec![make_entry(
79            MemoryCategory::Core,
80            Some(0.9),
81            &days_ago_rfc3339(30),
82        )];
83        apply_time_decay(&mut entries, 7.0);
84        assert_eq!(entries[0].score, Some(0.9));
85    }
86
87    #[test]
88    fn recent_entry_score_barely_changes() {
89        let mut entries = vec![make_entry(
90            MemoryCategory::Conversation,
91            Some(0.8),
92            &recent_rfc3339(),
93        )];
94        apply_time_decay(&mut entries, 7.0);
95        let decayed = entries[0].score.unwrap();
96        assert!(
97            (decayed - 0.8).abs() < 0.01,
98            "recent entry should barely decay, got {decayed}"
99        );
100    }
101
102    #[test]
103    fn one_half_life_halves_score() {
104        let mut entries = vec![make_entry(
105            MemoryCategory::Conversation,
106            Some(1.0),
107            &days_ago_rfc3339(7),
108        )];
109        apply_time_decay(&mut entries, 7.0);
110        let decayed = entries[0].score.unwrap();
111        assert!(
112            (decayed - 0.5).abs() < 0.05,
113            "score after one half-life should be ~0.5, got {decayed}"
114        );
115    }
116
117    #[test]
118    fn two_half_lives_quarters_score() {
119        let mut entries = vec![make_entry(
120            MemoryCategory::Conversation,
121            Some(1.0),
122            &days_ago_rfc3339(14),
123        )];
124        apply_time_decay(&mut entries, 7.0);
125        let decayed = entries[0].score.unwrap();
126        assert!(
127            (decayed - 0.25).abs() < 0.05,
128            "score after two half-lives should be ~0.25, got {decayed}"
129        );
130    }
131
132    #[test]
133    fn no_score_entry_is_unchanged() {
134        let mut entries = vec![make_entry(
135            MemoryCategory::Conversation,
136            None,
137            &days_ago_rfc3339(30),
138        )];
139        apply_time_decay(&mut entries, 7.0);
140        assert_eq!(entries[0].score, None);
141    }
142
143    #[test]
144    fn unparseable_timestamp_is_unchanged() {
145        let mut entries = vec![make_entry(
146            MemoryCategory::Conversation,
147            Some(0.9),
148            "not-a-date",
149        )];
150        apply_time_decay(&mut entries, 7.0);
151        assert_eq!(entries[0].score, Some(0.9));
152    }
153}