Skip to main content

mnemo_core/score/
decay.rs

1//! Ebbinghaus-style decay-curve scoring (v0.4.0 P1-4).
2//!
3//! Reads the memory's `last_accessed_at` + `access_count` and produces
4//! a score in `[floor, 1.0]` that decays exponentially with age and
5//! is reinforced by access count:
6//!
7//! ```text
8//! age_secs = max(0, now - last_accessed_at)
9//! base = 0.5 ^ (age_secs / half_life_secs)
10//! lift = log2(1 + access_count) * reinforcement_factor
11//! weight = clamp(base + lift, floor, 1.0)
12//! ```
13//!
14//! Defaults (from a quick LongMemEval_M sweep, not from a paper):
15//! `half_life_secs = 7 * 24 * 3600` (one week), `reinforcement_factor
16//! = 0.05`, `floor = 0.0`. Operators tune via
17//! `RecallRequest.hybrid_weights` + a `decay_params` field on the
18//! engine builder.
19//!
20//! Direct competitive response to YourMemory's biological-decay
21//! marketing (Show HN, 2026-04-27); fused with vector + BM25 +
22//! recency rather than replacing them, so we keep our hybrid edge.
23
24use std::time::SystemTime;
25
26use crate::model::memory::MemoryRecord;
27
28use super::{ScoreContext, ScoreLane};
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct DecayParams {
32    pub half_life_secs: u64,
33    pub reinforcement_factor: f32,
34    pub floor: f32,
35}
36
37impl Default for DecayParams {
38    fn default() -> Self {
39        Self {
40            half_life_secs: 7 * 24 * 3600,
41            reinforcement_factor: 0.05,
42            floor: 0.0,
43        }
44    }
45}
46
47/// Pure function the lane and ad-hoc callers (e.g. CLI inspect) use.
48/// Bounded in `[floor, 1.0]`.
49pub fn decay_weight(now: SystemTime, last_access: SystemTime, hits: u32, p: &DecayParams) -> f32 {
50    let age_secs = now
51        .duration_since(last_access)
52        .map(|d| d.as_secs())
53        .unwrap_or(0);
54    let base = if p.half_life_secs == 0 {
55        0.0
56    } else {
57        0.5_f32.powf(age_secs as f32 / p.half_life_secs as f32)
58    };
59    let lift = (1.0 + hits as f32).log2() * p.reinforcement_factor;
60    (base + lift).clamp(p.floor, 1.0)
61}
62
63pub struct DecayLane {
64    pub params: DecayParams,
65}
66
67impl DecayLane {
68    pub fn new(params: DecayParams) -> Self {
69        Self { params }
70    }
71}
72
73impl Default for DecayLane {
74    fn default() -> Self {
75        Self::new(DecayParams::default())
76    }
77}
78
79impl ScoreLane for DecayLane {
80    fn score(&self, mem: &MemoryRecord, ctx: &ScoreContext) -> f32 {
81        // Bypass under Letta-protocol mode for parity with Letta's
82        // published recall numbers.
83        if ctx.letta_mode {
84            return 0.0;
85        }
86        // Records without a `last_accessed_at` fall back to
87        // `created_at`. We parse the RFC3339 timestamps that Mnemo
88        // already serializes.
89        let last_str = mem.last_accessed_at.as_deref().unwrap_or(&mem.created_at);
90        let Ok(last_dt) = chrono::DateTime::parse_from_rfc3339(last_str) else {
91            // Malformed timestamp shouldn't tank recall; treat as
92            // "infinitely old" — the floor catches it.
93            return self.params.floor;
94        };
95        let last: SystemTime = last_dt.with_timezone(&chrono::Utc).into();
96        decay_weight(ctx.now, last, mem.access_count as u32, &self.params)
97    }
98
99    fn name(&self) -> &'static str {
100        "decay"
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use std::time::Duration;
107
108    use super::*;
109
110    #[test]
111    fn fresh_memory_with_zero_hits_starts_near_one() {
112        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
113        let p = DecayParams::default();
114        let w = decay_weight(now, now, 0, &p);
115        assert!(w > 0.99, "fresh weight should be ~1.0, got {w}");
116    }
117
118    #[test]
119    fn weight_is_monotonic_decreasing_in_age_for_fixed_hits() {
120        let p = DecayParams::default();
121        let base = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000_000);
122        let mut prev = decay_weight(base, base, 0, &p);
123        for d in [1, 60, 3600, 86_400, 604_800] {
124            let later = base + Duration::from_secs(d);
125            let w = decay_weight(later, base, 0, &p);
126            assert!(
127                w <= prev + 1e-6,
128                "weight should not increase: prev={prev} w={w} d={d}"
129            );
130            prev = w;
131        }
132    }
133
134    #[test]
135    fn reinforcement_lifts_a_repeatedly_recalled_memory() {
136        let p = DecayParams {
137            half_life_secs: 86_400,
138            reinforcement_factor: 0.1,
139            floor: 0.0,
140        };
141        let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
142        let aged = base + Duration::from_secs(86_400 * 3); // 3 days = 12.5% of fresh
143        let cold = decay_weight(aged, base, 0, &p);
144        let hot = decay_weight(aged, base, 32, &p);
145        assert!(
146            hot > cold,
147            "32-hit memory should rank above same-age zero-hit: cold={cold} hot={hot}"
148        );
149    }
150
151    #[test]
152    fn floor_is_respected() {
153        let p = DecayParams {
154            half_life_secs: 1,
155            reinforcement_factor: 0.0,
156            floor: 0.25,
157        };
158        let base = SystemTime::UNIX_EPOCH + Duration::from_secs(10);
159        let very_old = base + Duration::from_secs(1_000_000);
160        let w = decay_weight(very_old, base, 0, &p);
161        assert!(
162            (w - 0.25).abs() < 1e-6,
163            "very old memory should clamp to floor=0.25, got {w}"
164        );
165    }
166
167    #[test]
168    fn letta_mode_zeros_the_lane_for_parity() {
169        let lane = DecayLane::default();
170        let mem = MemoryRecord::new("a".into(), "c".into());
171        let ctx = ScoreContext::new(SystemTime::now(), "q").with_letta_mode(true);
172        let s = lane.score(&mem, &ctx);
173        assert_eq!(s, 0.0);
174    }
175
176    #[test]
177    fn lane_name_is_stable() {
178        assert_eq!(DecayLane::default().name(), "decay");
179    }
180}