Skip to main content

heartbit_core/memory/
scoring.rs

1//! Composite memory recall scoring — recency, importance, relevance, and Ebbinghaus strength.
2
3use chrono::{DateTime, Utc};
4
5/// Weights for composite memory scoring (Park et al., 2023).
6///
7/// `alpha * recency + beta * importance + gamma * relevance + delta * strength`
8#[derive(Debug, Clone)]
9pub struct ScoringWeights {
10    /// Weight for recency component (default: 0.25).
11    pub alpha: f64,
12    /// Weight for importance component (default: 0.25).
13    pub beta: f64,
14    /// Weight for relevance component (default: 0.3).
15    pub gamma: f64,
16    /// Weight for strength component (default: 0.2).
17    pub delta: f64,
18    /// Exponential decay rate for recency (default: 0.01, ~69h half-life).
19    pub decay_rate: f64,
20}
21
22impl Default for ScoringWeights {
23    fn default() -> Self {
24        Self {
25            alpha: 0.25,
26            beta: 0.25,
27            gamma: 0.3,
28            delta: 0.2,
29            decay_rate: 0.01,
30        }
31    }
32}
33
34/// Recency score: `e^(-decay_rate * hours)`, returns `[0.0, 1.0]`.
35///
36/// Clamps negative durations (future timestamps) to 1.0.
37pub fn recency_score(created_at: DateTime<Utc>, now: DateTime<Utc>, decay_rate: f64) -> f64 {
38    let duration = now.signed_duration_since(created_at);
39    let hours = duration.num_seconds() as f64 / 3600.0;
40    if hours <= 0.0 {
41        return 1.0;
42    }
43    (-decay_rate * hours).exp()
44}
45
46/// Normalize importance `[1, 10]` to `[0.0, 1.0]`.
47///
48/// Values outside `[1, 10]` are clamped.
49pub fn importance_score(importance: u8) -> f64 {
50    let clamped = importance.clamp(1, 10);
51    (clamped as f64 - 1.0) / 9.0
52}
53
54/// Strength score: identity mapping (already in `[0.0, 1.0]`).
55///
56/// Clamps to `[0.0, 1.0]` for safety.
57pub fn strength_score(strength: f64) -> f64 {
58    strength.clamp(0.0, 1.0)
59}
60
61/// Ebbinghaus strength decay rate (per hour). Default: 0.005.
62///
63/// Half-life ≈ ln(2)/0.005 ≈ 139 hours ≈ 5.8 days.
64/// This means unused memories lose half their strength every ~6 days.
65pub const STRENGTH_DECAY_RATE: f64 = 0.005;
66
67/// Compute effective strength with Ebbinghaus decay from `last_accessed`.
68///
69/// `effective = stored_strength * e^(-decay_rate * hours_since_last_access)`
70///
71/// This gives recently-accessed memories their full stored strength, while
72/// memories not accessed in a long time decay toward zero. Reinforcement
73/// on access (+0.2, capped at 1.0) resets the decay clock.
74pub fn effective_strength(
75    strength: f64,
76    last_accessed: DateTime<Utc>,
77    now: DateTime<Utc>,
78    decay_rate: f64,
79) -> f64 {
80    let hours = now
81        .signed_duration_since(last_accessed)
82        .num_seconds()
83        .max(0) as f64
84        / 3600.0;
85    let decayed = strength * (-decay_rate * hours).exp();
86    decayed.clamp(0.0, 1.0)
87}
88
89/// Composite score: `alpha * recency + beta * importance + gamma * relevance + delta * strength`.
90pub fn composite_score(
91    weights: &ScoringWeights,
92    created_at: DateTime<Utc>,
93    now: DateTime<Utc>,
94    importance: u8,
95    relevance: f64,
96    strength: f64,
97) -> f64 {
98    let r = recency_score(created_at, now, weights.decay_rate);
99    let i = importance_score(importance);
100    let rel = relevance.clamp(0.0, 1.0);
101    let s = strength_score(strength);
102    weights.alpha * r + weights.beta * i + weights.gamma * rel + weights.delta * s
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use chrono::Duration;
109
110    #[test]
111    fn recency_score_now_is_one() {
112        let now = Utc::now();
113        let score = recency_score(now, now, 0.01);
114        assert!((score - 1.0).abs() < f64::EPSILON);
115    }
116
117    #[test]
118    fn recency_score_decays_over_time() {
119        let now = Utc::now();
120        let one_day_ago = now - Duration::hours(24);
121        let score = recency_score(one_day_ago, now, 0.01);
122        // e^(-0.01 * 24) ≈ 0.787
123        assert!(score < 1.0);
124        assert!(score > 0.5);
125    }
126
127    #[test]
128    fn recency_score_very_old_approaches_zero() {
129        let now = Utc::now();
130        let long_ago = now - Duration::hours(10000);
131        let score = recency_score(long_ago, now, 0.01);
132        assert!(score < 0.001);
133    }
134
135    #[test]
136    fn recency_score_negative_duration_clamps() {
137        let now = Utc::now();
138        let future = now + Duration::hours(5);
139        let score = recency_score(future, now, 0.01);
140        assert!((score - 1.0).abs() < f64::EPSILON);
141    }
142
143    #[test]
144    fn importance_score_range() {
145        assert!((importance_score(1) - 0.0).abs() < f64::EPSILON);
146        assert!((importance_score(10) - 1.0).abs() < f64::EPSILON);
147    }
148
149    #[test]
150    fn importance_score_midpoint() {
151        // importance 5 → (5-1)/9 ≈ 0.444
152        let score = importance_score(5);
153        assert!((score - 4.0 / 9.0).abs() < f64::EPSILON);
154    }
155
156    #[test]
157    fn importance_score_clamps_out_of_range() {
158        // 0 clamps to 1 → 0.0
159        assert!((importance_score(0) - 0.0).abs() < f64::EPSILON);
160        // 15 clamps to 10 → 1.0
161        assert!((importance_score(15) - 1.0).abs() < f64::EPSILON);
162    }
163
164    #[test]
165    fn strength_score_identity() {
166        assert!((strength_score(0.5) - 0.5).abs() < f64::EPSILON);
167        assert!((strength_score(1.0) - 1.0).abs() < f64::EPSILON);
168        assert!((strength_score(0.0) - 0.0).abs() < f64::EPSILON);
169    }
170
171    #[test]
172    fn strength_score_clamps() {
173        assert!((strength_score(-0.5) - 0.0).abs() < f64::EPSILON);
174        assert!((strength_score(1.5) - 1.0).abs() < f64::EPSILON);
175    }
176
177    #[test]
178    fn composite_score_all_max() {
179        let now = Utc::now();
180        let weights = ScoringWeights::default();
181        let score = composite_score(&weights, now, now, 10, 1.0, 1.0);
182        // recency=1.0, importance=1.0, relevance=1.0, strength=1.0
183        // 0.25 + 0.25 + 0.3 + 0.2 = 1.0
184        assert!((score - 1.0).abs() < f64::EPSILON);
185    }
186
187    #[test]
188    fn composite_score_all_zero() {
189        let now = Utc::now();
190        let old = now - Duration::hours(100_000);
191        let weights = ScoringWeights::default();
192        let score = composite_score(&weights, old, now, 1, 0.0, 0.0);
193        // recency≈0, importance=0.0, relevance=0.0, strength=0.0
194        assert!(score < 0.01);
195    }
196
197    #[test]
198    fn composite_score_importance_dominates() {
199        let now = Utc::now();
200        let old = now - Duration::hours(100_000);
201        let weights = ScoringWeights {
202            alpha: 0.0,
203            beta: 1.0,
204            gamma: 0.0,
205            delta: 0.0,
206            decay_rate: 0.01,
207        };
208        let score = composite_score(&weights, old, now, 10, 0.0, 0.0);
209        assert!((score - 1.0).abs() < f64::EPSILON);
210    }
211
212    #[test]
213    fn composite_score_strength_contributes() {
214        let now = Utc::now();
215        let weights = ScoringWeights {
216            alpha: 0.0,
217            beta: 0.0,
218            gamma: 0.0,
219            delta: 1.0,
220            decay_rate: 0.01,
221        };
222        let score = composite_score(&weights, now, now, 1, 0.0, 0.8);
223        assert!((score - 0.8).abs() < f64::EPSILON);
224    }
225
226    #[test]
227    fn low_strength_entries_rank_lower() {
228        let now = Utc::now();
229        let weights = ScoringWeights::default();
230        let strong = composite_score(&weights, now, now, 5, 0.5, 1.0);
231        let weak = composite_score(&weights, now, now, 5, 0.5, 0.1);
232        assert!(
233            strong > weak,
234            "higher strength should yield higher composite score"
235        );
236    }
237
238    #[test]
239    fn default_weights_sum_to_one() {
240        let w = ScoringWeights::default();
241        assert!((w.alpha + w.beta + w.gamma + w.delta - 1.0).abs() < f64::EPSILON);
242    }
243
244    #[test]
245    fn effective_strength_no_decay_when_just_accessed() {
246        let now = Utc::now();
247        let eff = effective_strength(0.8, now, now, STRENGTH_DECAY_RATE);
248        assert!((eff - 0.8).abs() < 1e-10);
249    }
250
251    #[test]
252    fn effective_strength_decays_over_time() {
253        let now = Utc::now();
254        let week_ago = now - Duration::hours(7 * 24);
255        let eff = effective_strength(1.0, week_ago, now, STRENGTH_DECAY_RATE);
256        // e^(-0.005 * 168) ≈ e^(-0.84) ≈ 0.432
257        assert!(eff < 0.5, "should decay significantly after a week: {eff}");
258        assert!(eff > 0.3, "should not fully decay after a week: {eff}");
259    }
260
261    #[test]
262    fn effective_strength_approaches_zero_for_very_old() {
263        let now = Utc::now();
264        let month_ago = now - Duration::hours(30 * 24);
265        let eff = effective_strength(1.0, month_ago, now, STRENGTH_DECAY_RATE);
266        // e^(-0.005 * 720) ≈ e^(-3.6) ≈ 0.027
267        assert!(eff < 0.05, "should be near zero after a month: {eff}");
268    }
269
270    #[test]
271    fn effective_strength_clamps_negative_duration() {
272        let now = Utc::now();
273        let future = now + Duration::hours(5);
274        let eff = effective_strength(0.8, future, now, STRENGTH_DECAY_RATE);
275        assert!((eff - 0.8).abs() < 1e-10);
276    }
277}