Skip to main content

construct/memory/
decay.rs

1//! Time-decay scoring for memory entries.
2//!
3//! `apply_time_decay` is used by `memory_loader.rs` and remains functional.
4//! All other decay-related helpers have been removed.
5
6use super::traits::{MemoryCategory, MemoryEntry};
7use chrono::{DateTime, Utc};
8
9/// Default half-life in days for time-decay scoring.
10/// After this many days, a non-Core memory's score drops to 50%.
11pub const DEFAULT_HALF_LIFE_DAYS: f64 = 7.0;
12
13/// Apply exponential time decay to memory entry scores.
14///
15/// - `Core` memories are exempt ("evergreen") -- their scores are never decayed.
16/// - Entries without a parseable RFC3339 timestamp are left unchanged.
17/// - Entries without a score (`None`) are left unchanged.
18///
19/// Decay formula: `score * 2^(-age_days / half_life_days)`
20pub fn apply_time_decay(entries: &mut [MemoryEntry], half_life_days: f64) {
21    let half_life = if half_life_days <= 0.0 {
22        DEFAULT_HALF_LIFE_DAYS
23    } else {
24        half_life_days
25    };
26
27    let now = Utc::now();
28
29    for entry in entries.iter_mut() {
30        // Core memories are evergreen -- never decay
31        if entry.category == MemoryCategory::Core {
32            continue;
33        }
34
35        let score = match entry.score {
36            Some(s) => s,
37            None => continue,
38        };
39
40        let ts = match DateTime::parse_from_rfc3339(&entry.timestamp) {
41            Ok(dt) => dt.with_timezone(&Utc),
42            Err(_) => continue,
43        };
44
45        let age_days = now.signed_duration_since(ts).num_seconds().max(0) as f64 / 86_400.0;
46
47        let decay_factor = (-age_days / half_life * std::f64::consts::LN_2).exp();
48        entry.score = Some(score * decay_factor);
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    fn make_entry(category: MemoryCategory, score: Option<f64>, timestamp: &str) -> MemoryEntry {
57        MemoryEntry {
58            id: "1".into(),
59            key: "test".into(),
60            content: "value".into(),
61            category,
62            timestamp: timestamp.into(),
63            session_id: None,
64            score,
65            namespace: "default".into(),
66            importance: None,
67            superseded_by: None,
68        }
69    }
70
71    fn days_ago_rfc3339(days: i64) -> String {
72        (Utc::now() - chrono::Duration::days(days)).to_rfc3339()
73    }
74
75    #[test]
76    fn core_memories_are_never_decayed() {
77        let mut entries = vec![make_entry(
78            MemoryCategory::Core,
79            Some(0.9),
80            &days_ago_rfc3339(30),
81        )];
82        apply_time_decay(&mut entries, 7.0);
83        assert_eq!(entries[0].score, Some(0.9));
84    }
85
86    #[test]
87    fn one_half_life_halves_score() {
88        let mut entries = vec![make_entry(
89            MemoryCategory::Conversation,
90            Some(1.0),
91            &days_ago_rfc3339(7),
92        )];
93        apply_time_decay(&mut entries, 7.0);
94        let decayed = entries[0].score.unwrap();
95        assert!(
96            (decayed - 0.5).abs() < 0.05,
97            "score after one half-life should be ~0.5, got {decayed}"
98        );
99    }
100
101    #[test]
102    fn no_score_entry_is_unchanged() {
103        let mut entries = vec![make_entry(
104            MemoryCategory::Conversation,
105            None,
106            &days_ago_rfc3339(30),
107        )];
108        apply_time_decay(&mut entries, 7.0);
109        assert_eq!(entries[0].score, None);
110    }
111}