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