1use std::time::{Duration, SystemTime, UNIX_EPOCH};
43
44#[derive(Debug, Clone)]
50pub struct TemporalDecayConfig {
51 pub decay_rate: f32,
53
54 pub half_life_secs: f64,
56
57 pub semantic_weight: f32,
59
60 pub min_decay: f32,
62
63 pub apply_stage: DecayStage,
65}
66
67impl Default for TemporalDecayConfig {
68 fn default() -> Self {
69 Self {
70 decay_rate: 0.5,
71 half_life_secs: 3600.0 * 24.0, semantic_weight: 0.7,
73 min_decay: 0.01,
74 apply_stage: DecayStage::PostRetrieval,
75 }
76 }
77}
78
79impl TemporalDecayConfig {
80 pub fn short_term() -> Self {
82 Self {
83 decay_rate: 0.5,
84 half_life_secs: 3600.0, semantic_weight: 0.5,
86 min_decay: 0.01,
87 apply_stage: DecayStage::PostRetrieval,
88 }
89 }
90
91 pub fn long_term() -> Self {
93 Self {
94 decay_rate: 0.5,
95 half_life_secs: 3600.0 * 24.0 * 7.0, semantic_weight: 0.85,
97 min_decay: 0.05,
98 apply_stage: DecayStage::PostRetrieval,
99 }
100 }
101
102 pub fn working_memory() -> Self {
104 Self {
105 decay_rate: 0.5,
106 half_life_secs: 300.0, semantic_weight: 0.3,
108 min_decay: 0.0,
109 apply_stage: DecayStage::PostRetrieval,
110 }
111 }
112
113 pub fn with_half_life(half_life_secs: f64, semantic_weight: f32) -> Self {
115 Self {
116 half_life_secs,
117 semantic_weight,
118 ..Default::default()
119 }
120 }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum DecayStage {
126 DuringSearch,
128 PostRetrieval,
130 Final,
132}
133
134#[derive(Debug, Clone)]
140pub struct TemporalScorer {
141 config: TemporalDecayConfig,
142 reference_time: f64,
144}
145
146impl TemporalScorer {
147 pub fn new(config: TemporalDecayConfig) -> Self {
149 let reference_time = SystemTime::now()
150 .duration_since(UNIX_EPOCH)
151 .unwrap_or_default()
152 .as_secs_f64();
153
154 Self {
155 config,
156 reference_time,
157 }
158 }
159
160 pub fn with_reference_time(config: TemporalDecayConfig, reference_time: f64) -> Self {
162 Self {
163 config,
164 reference_time,
165 }
166 }
167
168 pub fn default_scorer() -> Self {
170 Self::new(TemporalDecayConfig::default())
171 }
172
173 pub fn decay_score(&self, timestamp_secs: f64) -> f32 {
177 let delta_t = (self.reference_time - timestamp_secs).max(0.0);
178
179 let exponent = delta_t / self.config.half_life_secs;
181 let decay = self.config.decay_rate.powf(exponent as f32);
182
183 decay.max(self.config.min_decay)
184 }
185
186 pub fn decay_score_duration(&self, age: Duration) -> f32 {
188 let delta_t = age.as_secs_f64();
189 let exponent = delta_t / self.config.half_life_secs;
190 let decay = self.config.decay_rate.powf(exponent as f32);
191
192 decay.max(self.config.min_decay)
193 }
194
195 pub fn blend_scores(&self, semantic_score: f32, decay_score: f32) -> f32 {
199 let alpha = self.config.semantic_weight;
200 alpha * semantic_score + (1.0 - alpha) * decay_score
201 }
202
203 pub fn final_score(&self, semantic_score: f32, timestamp_secs: f64) -> f32 {
205 let decay = self.decay_score(timestamp_secs);
206 self.blend_scores(semantic_score, decay)
207 }
208
209 pub fn apply_decay<I>(
214 &self,
215 results: I,
216 ) -> Vec<(String, f32)>
217 where
218 I: IntoIterator<Item = (String, f32, f64)>,
219 {
220 let mut scored: Vec<_> = results
221 .into_iter()
222 .map(|(id, semantic, timestamp)| {
223 let final_score = self.final_score(semantic, timestamp);
224 (id, final_score)
225 })
226 .collect();
227
228 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
230
231 scored
232 }
233
234 pub fn apply_decay_typed<T, F>(
236 &self,
237 results: &mut [T],
238 get_score: impl Fn(&T) -> f32,
239 get_timestamp: impl Fn(&T) -> f64,
240 set_score: F,
241 ) where
242 F: Fn(&mut T, f32),
243 {
244 for result in results.iter_mut() {
245 let semantic = get_score(result);
246 let timestamp = get_timestamp(result);
247 let final_score = self.final_score(semantic, timestamp);
248 set_score(result, final_score);
249 }
250 }
251
252 pub fn half_life_display(&self) -> String {
254 let secs = self.config.half_life_secs;
255
256 if secs < 60.0 {
257 format!("{:.0} seconds", secs)
258 } else if secs < 3600.0 {
259 format!("{:.1} minutes", secs / 60.0)
260 } else if secs < 86400.0 {
261 format!("{:.1} hours", secs / 3600.0)
262 } else {
263 format!("{:.1} days", secs / 86400.0)
264 }
265 }
266}
267
268#[derive(Debug, Clone)]
274pub struct TemporallyDecayedResult {
275 pub id: String,
277
278 pub semantic_score: f32,
280
281 pub decay_factor: f32,
283
284 pub final_score: f32,
286
287 pub timestamp: f64,
289
290 pub age_secs: f64,
292}
293
294impl TemporallyDecayedResult {
295 pub fn new(
297 id: String,
298 semantic_score: f32,
299 timestamp: f64,
300 scorer: &TemporalScorer,
301 ) -> Self {
302 let decay_factor = scorer.decay_score(timestamp);
303 let final_score = scorer.blend_scores(semantic_score, decay_factor);
304 let age_secs = scorer.reference_time - timestamp;
305
306 Self {
307 id,
308 semantic_score,
309 decay_factor,
310 final_score,
311 timestamp,
312 age_secs,
313 }
314 }
315
316 pub fn age_display(&self) -> String {
318 let age = self.age_secs;
319
320 if age < 60.0 {
321 format!("{:.0}s ago", age)
322 } else if age < 3600.0 {
323 format!("{:.0}m ago", age / 60.0)
324 } else if age < 86400.0 {
325 format!("{:.1}h ago", age / 3600.0)
326 } else {
327 format!("{:.1}d ago", age / 86400.0)
328 }
329 }
330}
331
332#[derive(Debug, Clone)]
338pub struct DecayCurve {
339 pub points: Vec<(f64, f32)>,
341
342 pub half_life: f64,
344
345 pub config: TemporalDecayConfig,
347}
348
349impl DecayCurve {
350 pub fn generate(config: &TemporalDecayConfig, max_age_secs: f64, num_points: usize) -> Self {
352 let scorer = TemporalScorer::with_reference_time(config.clone(), max_age_secs);
353
354 let mut points = Vec::with_capacity(num_points);
355 for i in 0..num_points {
356 let age = (i as f64) * max_age_secs / (num_points as f64);
357 let timestamp = max_age_secs - age;
358 let score = scorer.decay_score(timestamp);
359 points.push((age, score));
360 }
361
362 Self {
363 points,
364 half_life: config.half_life_secs,
365 config: config.clone(),
366 }
367 }
368
369 pub fn age_at_threshold(&self, threshold: f32) -> Option<f64> {
371 for (age, score) in &self.points {
372 if *score <= threshold {
373 return Some(*age);
374 }
375 }
376 None
377 }
378
379 pub fn ascii_chart(&self, width: usize, height: usize) -> String {
381 let mut chart = vec![vec![' '; width]; height];
382
383 for (age, score) in &self.points {
384 let x = ((age / self.points.last().unwrap().0) * (width - 1) as f64) as usize;
385 let y = ((1.0 - *score) * (height - 1) as f32) as usize;
386
387 if x < width && y < height {
388 chart[y][x] = '█';
389 }
390 }
391
392 for row in &mut chart {
394 row[0] = '│';
395 }
396 chart[height - 1] = vec!['─'; width];
397 chart[height - 1][0] = '└';
398
399 chart.iter()
400 .map(|row| row.iter().collect::<String>())
401 .collect::<Vec<_>>()
402 .join("\n")
403 }
404}
405
406pub trait TemporalDecayExt {
412 fn with_temporal_decay(self, scorer: &TemporalScorer) -> Vec<TemporallyDecayedResult>;
414}
415
416impl<I> TemporalDecayExt for I
417where
418 I: IntoIterator<Item = (String, f32, f64)>,
419{
420 fn with_temporal_decay(self, scorer: &TemporalScorer) -> Vec<TemporallyDecayedResult> {
421 let mut results: Vec<_> = self
422 .into_iter()
423 .map(|(id, semantic_score, timestamp)| {
424 TemporallyDecayedResult::new(id, semantic_score, timestamp, scorer)
425 })
426 .collect();
427
428 results.sort_by(|a, b| {
430 b.final_score.partial_cmp(&a.final_score).unwrap_or(std::cmp::Ordering::Equal)
431 });
432
433 results
434 }
435}
436
437pub fn quick_decay(age_secs: f64) -> f32 {
443 let scorer = TemporalScorer::new(TemporalDecayConfig::default());
444 scorer.decay_score_duration(Duration::from_secs_f64(age_secs))
445}
446
447pub fn quick_temporal_score(semantic_score: f32, age_secs: f64) -> f32 {
449 let scorer = TemporalScorer::new(TemporalDecayConfig::default());
450 let decay = scorer.decay_score_duration(Duration::from_secs_f64(age_secs));
451 scorer.blend_scores(semantic_score, decay)
452}
453
454pub fn apply_default_decay<I>(results: I) -> Vec<(String, f32)>
456where
457 I: IntoIterator<Item = (String, f32, f64)>,
458{
459 let scorer = TemporalScorer::new(TemporalDecayConfig::default());
460 scorer.apply_decay(results)
461}
462
463#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn test_decay_at_half_life() {
473 let config = TemporalDecayConfig {
474 decay_rate: 0.5,
475 half_life_secs: 3600.0, semantic_weight: 0.5,
477 min_decay: 0.0,
478 apply_stage: DecayStage::PostRetrieval,
479 };
480
481 let scorer = TemporalScorer::with_reference_time(config, 3600.0);
482
483 let decay_now = scorer.decay_score(3600.0);
485 assert!((decay_now - 1.0).abs() < 0.01);
486
487 let decay_half = scorer.decay_score(0.0);
489 assert!((decay_half - 0.5).abs() < 0.01);
490 }
491
492 #[test]
493 fn test_decay_double_half_life() {
494 let config = TemporalDecayConfig {
495 decay_rate: 0.5,
496 half_life_secs: 3600.0,
497 semantic_weight: 0.5,
498 min_decay: 0.0,
499 apply_stage: DecayStage::PostRetrieval,
500 };
501
502 let scorer = TemporalScorer::with_reference_time(config, 7200.0);
503
504 let decay = scorer.decay_score(0.0);
506 assert!((decay - 0.25).abs() < 0.01);
507 }
508
509 #[test]
510 fn test_blend_scores() {
511 let config = TemporalDecayConfig {
512 semantic_weight: 0.7,
513 ..Default::default()
514 };
515
516 let scorer = TemporalScorer::new(config);
517
518 let final_score = scorer.blend_scores(0.8, 0.5);
521 assert!((final_score - 0.71).abs() < 0.01);
522 }
523
524 #[test]
525 fn test_min_decay_floor() {
526 let config = TemporalDecayConfig {
527 decay_rate: 0.5,
528 half_life_secs: 1.0, min_decay: 0.1,
530 semantic_weight: 0.5,
531 apply_stage: DecayStage::PostRetrieval,
532 };
533
534 let scorer = TemporalScorer::with_reference_time(config, 1000.0);
535
536 let decay = scorer.decay_score(0.0);
538 assert!((decay - 0.1).abs() < 0.01);
539 }
540
541 #[test]
542 fn test_apply_decay_reorders() {
543 let config = TemporalDecayConfig {
544 decay_rate: 0.5,
545 half_life_secs: 100.0,
546 semantic_weight: 0.5,
547 min_decay: 0.0,
548 apply_stage: DecayStage::PostRetrieval,
549 };
550
551 let scorer = TemporalScorer::with_reference_time(config, 200.0);
552
553 let results = vec![
555 ("old_high".to_string(), 0.9, 0.0), ("new_low".to_string(), 0.6, 190.0), ];
558
559 let decayed = scorer.apply_decay(results);
560
561 assert_eq!(decayed[0].0, "new_low");
563 }
564
565 #[test]
566 fn test_decay_curve_generation() {
567 let config = TemporalDecayConfig::default();
568 let curve = DecayCurve::generate(&config, 86400.0 * 7.0, 100);
569
570 assert_eq!(curve.points.len(), 100);
571
572 assert!(curve.points[0].1 > 0.9);
574
575 assert!(curve.points.last().unwrap().1 < curve.points[0].1);
577 }
578
579 #[test]
580 fn test_temporally_decayed_result() {
581 let config = TemporalDecayConfig::short_term();
582 let scorer = TemporalScorer::with_reference_time(config, 7200.0);
583
584 let result = TemporallyDecayedResult::new(
585 "doc1".to_string(),
586 0.85,
587 3600.0, &scorer,
589 );
590
591 assert_eq!(result.id, "doc1");
592 assert!((result.semantic_score - 0.85).abs() < 0.01);
593 assert!(result.decay_factor < 1.0);
594 assert!(result.age_secs > 0.0);
595 }
596
597 #[test]
598 fn test_half_life_display() {
599 let config = TemporalDecayConfig {
600 half_life_secs: 7200.0, ..Default::default()
602 };
603
604 let scorer = TemporalScorer::new(config);
605 let display = scorer.half_life_display();
606
607 assert!(display.contains("hours") || display.contains("2.0"));
608 }
609}