Skip to main content

sochdb_query/
temporal_decay.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! Temporal Decay Scoring (Task 4)
19//!
20//! This module implements recency-biased relevance scoring for memory retrieval.
21//! It applies exponential decay to blend temporal and semantic signals.
22//!
23//! ## Formula
24//!
25//! ```text
26//! decay(Δt) = λ^(Δt/τ)
27//! final_score = α × semantic_score + (1-α) × decay_score
28//! ```
29//!
30//! Where:
31//! - Δt = time since document creation/update
32//! - τ = decay half-life (time for score to halve)
33//! - λ = decay rate (typically 0.5 for half-life)
34//! - α = semantic weight (0.0 to 1.0)
35//!
36//! ## Complexity
37//!
38//! - Decay computation: O(1) per document
39//! - Resorting: O(K log K) for top-K candidates
40//! - Selection heap: O(K) if using heap-based selection
41
42use std::time::{Duration, SystemTime, UNIX_EPOCH};
43
44// ============================================================================
45// Configuration
46// ============================================================================
47
48/// Configuration for temporal decay scoring
49#[derive(Debug, Clone)]
50pub struct TemporalDecayConfig {
51    /// Decay rate (λ): 0.5 = half-life decay
52    pub decay_rate: f32,
53    
54    /// Half-life in seconds (τ): time for score to halve
55    pub half_life_secs: f64,
56    
57    /// Semantic weight (α): 0.0 = pure recency, 1.0 = pure semantic
58    pub semantic_weight: f32,
59    
60    /// Minimum decay score (floor)
61    pub min_decay: f32,
62    
63    /// Whether to apply decay before or after other scoring
64    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, // 24 hours
72            semantic_weight: 0.7,
73            min_decay: 0.01,
74            apply_stage: DecayStage::PostRetrieval,
75        }
76    }
77}
78
79impl TemporalDecayConfig {
80    /// Create config for short-term memory (fast decay)
81    pub fn short_term() -> Self {
82        Self {
83            decay_rate: 0.5,
84            half_life_secs: 3600.0, // 1 hour
85            semantic_weight: 0.5,
86            min_decay: 0.01,
87            apply_stage: DecayStage::PostRetrieval,
88        }
89    }
90    
91    /// Create config for long-term memory (slow decay)
92    pub fn long_term() -> Self {
93        Self {
94            decay_rate: 0.5,
95            half_life_secs: 3600.0 * 24.0 * 7.0, // 1 week
96            semantic_weight: 0.85,
97            min_decay: 0.05,
98            apply_stage: DecayStage::PostRetrieval,
99        }
100    }
101    
102    /// Create config for working memory (very fast decay)
103    pub fn working_memory() -> Self {
104        Self {
105            decay_rate: 0.5,
106            half_life_secs: 300.0, // 5 minutes
107            semantic_weight: 0.3,
108            min_decay: 0.0,
109            apply_stage: DecayStage::PostRetrieval,
110        }
111    }
112    
113    /// Create config with custom half-life
114    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/// When to apply decay scoring
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum DecayStage {
126    /// Apply decay during index search (modifies distance)
127    DuringSearch,
128    /// Apply decay after retrieval (reranking)
129    PostRetrieval,
130    /// Apply decay as final step before returning
131    Final,
132}
133
134// ============================================================================
135// Temporal Scorer
136// ============================================================================
137
138/// Temporal decay scorer
139#[derive(Debug, Clone)]
140pub struct TemporalScorer {
141    config: TemporalDecayConfig,
142    /// Reference time (usually current time)
143    reference_time: f64,
144}
145
146impl TemporalScorer {
147    /// Create a new temporal scorer with current time as reference
148    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    /// Create with specific reference time
161    pub fn with_reference_time(config: TemporalDecayConfig, reference_time: f64) -> Self {
162        Self {
163            config,
164            reference_time,
165        }
166    }
167    
168    /// Create with default config
169    pub fn default_scorer() -> Self {
170        Self::new(TemporalDecayConfig::default())
171    }
172    
173    /// Calculate decay score for a given timestamp
174    ///
175    /// Returns a value between min_decay and 1.0
176    pub fn decay_score(&self, timestamp_secs: f64) -> f32 {
177        let delta_t = (self.reference_time - timestamp_secs).max(0.0);
178        
179        // decay = λ^(Δt/τ)
180        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    /// Calculate decay score from Duration
187    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    /// Blend semantic and decay scores
196    ///
197    /// final = α × semantic + (1-α) × decay
198    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    /// Calculate final score from semantic score and timestamp
204    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    /// Apply temporal decay to a list of scored results
210    ///
211    /// Each result is (id, semantic_score, timestamp)
212    /// Returns (id, final_score) sorted by final_score descending
213    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        // Sort by score descending
229        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
230        
231        scored
232    }
233    
234    /// Apply decay to typed results
235    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    /// Get the half-life in human-readable format
253    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// ============================================================================
269// Scored Result Types
270// ============================================================================
271
272/// A result with temporal decay applied
273#[derive(Debug, Clone)]
274pub struct TemporallyDecayedResult {
275    /// Result identifier
276    pub id: String,
277    
278    /// Original semantic/similarity score
279    pub semantic_score: f32,
280    
281    /// Decay factor based on age
282    pub decay_factor: f32,
283    
284    /// Final blended score
285    pub final_score: f32,
286    
287    /// Document timestamp (seconds since epoch)
288    pub timestamp: f64,
289    
290    /// Age of the document
291    pub age_secs: f64,
292}
293
294impl TemporallyDecayedResult {
295    /// Create from components
296    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    /// Format age as human-readable string
317    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// ============================================================================
333// Decay Curve Analysis
334// ============================================================================
335
336/// Analyze decay curve for debugging/visualization
337#[derive(Debug, Clone)]
338pub struct DecayCurve {
339    /// Points on the curve: (age_secs, decay_score)
340    pub points: Vec<(f64, f32)>,
341    
342    /// Half-life in seconds
343    pub half_life: f64,
344    
345    /// Configuration used
346    pub config: TemporalDecayConfig,
347}
348
349impl DecayCurve {
350    /// Generate decay curve points
351    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    /// Find age where score drops to threshold
370    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    /// Format as ASCII chart
380    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        // Add axes
393        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
406// ============================================================================
407// Integration with Search Results
408// ============================================================================
409
410/// Extension trait for applying temporal decay to search results
411pub trait TemporalDecayExt {
412    /// Apply temporal decay and return sorted results
413    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        // Sort by final score descending
429        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
437// ============================================================================
438// Convenience Functions
439// ============================================================================
440
441/// Calculate decay score with default configuration
442pub 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
447/// Calculate final score with default configuration
448pub 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
454/// Apply temporal decay to search results with default configuration
455pub 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// ============================================================================
464// Tests
465// ============================================================================
466
467#[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, // 1 hour
476            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        // At reference time (age = 0), decay should be 1.0
484        let decay_now = scorer.decay_score(3600.0);
485        assert!((decay_now - 1.0).abs() < 0.01);
486        
487        // At half-life (age = 1 hour), decay should be 0.5
488        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        // At 2x half-life (age = 2 hours), decay should be 0.25
505        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        // semantic = 0.8, decay = 0.5
519        // final = 0.7 * 0.8 + 0.3 * 0.5 = 0.56 + 0.15 = 0.71
520        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, // Very fast decay
529            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        // Very old document should hit min_decay floor
537        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        // Old document with high semantic score vs new document with lower semantic score
554        let results = vec![
555            ("old_high".to_string(), 0.9, 0.0),    // Age = 200s, decay ≈ 0.25
556            ("new_low".to_string(), 0.6, 190.0),   // Age = 10s, decay ≈ 0.93
557        ];
558        
559        let decayed = scorer.apply_decay(results);
560        
561        // New document should rank higher despite lower semantic score
562        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        // First point should have score near 1.0
573        assert!(curve.points[0].1 > 0.9);
574        
575        // Last point should have lower score
576        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, // 1 hour old
588            &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, // 2 hours
601            ..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}