1use chrono::{DateTime, Utc};
4
5#[derive(Debug, Clone)]
9pub struct ScoringWeights {
10 pub alpha: f64,
12 pub beta: f64,
14 pub gamma: f64,
16 pub delta: f64,
18 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
34pub 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
46pub fn importance_score(importance: u8) -> f64 {
50 let clamped = importance.clamp(1, 10);
51 (clamped as f64 - 1.0) / 9.0
52}
53
54pub fn strength_score(strength: f64) -> f64 {
58 strength.clamp(0.0, 1.0)
59}
60
61pub const STRENGTH_DECAY_RATE: f64 = 0.005;
66
67pub 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
89pub 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 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 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 assert!((importance_score(0) - 0.0).abs() < f64::EPSILON);
160 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 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 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 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 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}