mnemo_core/score/
decay.rs1use 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
47pub 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 if ctx.letta_mode {
84 return 0.0;
85 }
86 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 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); 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}