sochdb_query/
temporal_decay.rs

1// Copyright 2025 Sushanth (https://github.com/sushanthpy)
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Temporal Decay Scoring (Task 4)
16//!
17//! This module implements recency-biased relevance scoring for memory retrieval.
18//! It applies exponential decay to blend temporal and semantic signals.
19//!
20//! ## Formula
21//!
22//! ```text
23//! decay(Δt) = λ^(Δt/τ)
24//! final_score = α × semantic_score + (1-α) × decay_score
25//! ```
26//!
27//! Where:
28//! - Δt = time since document creation/update
29//! - τ = decay half-life (time for score to halve)
30//! - λ = decay rate (typically 0.5 for half-life)
31//! - α = semantic weight (0.0 to 1.0)
32//!
33//! ## Complexity
34//!
35//! - Decay computation: O(1) per document
36//! - Resorting: O(K log K) for top-K candidates
37//! - Selection heap: O(K) if using heap-based selection
38
39use std::time::{Duration, SystemTime, UNIX_EPOCH};
40
41// ============================================================================
42// Configuration
43// ============================================================================
44
45/// Configuration for temporal decay scoring
46#[derive(Debug, Clone)]
47pub struct TemporalDecayConfig {
48    /// Decay rate (λ): 0.5 = half-life decay
49    pub decay_rate: f32,
50    
51    /// Half-life in seconds (τ): time for score to halve
52    pub half_life_secs: f64,
53    
54    /// Semantic weight (α): 0.0 = pure recency, 1.0 = pure semantic
55    pub semantic_weight: f32,
56    
57    /// Minimum decay score (floor)
58    pub min_decay: f32,
59    
60    /// Whether to apply decay before or after other scoring
61    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, // 24 hours
69            semantic_weight: 0.7,
70            min_decay: 0.01,
71            apply_stage: DecayStage::PostRetrieval,
72        }
73    }
74}
75
76impl TemporalDecayConfig {
77    /// Create config for short-term memory (fast decay)
78    pub fn short_term() -> Self {
79        Self {
80            decay_rate: 0.5,
81            half_life_secs: 3600.0, // 1 hour
82            semantic_weight: 0.5,
83            min_decay: 0.01,
84            apply_stage: DecayStage::PostRetrieval,
85        }
86    }
87    
88    /// Create config for long-term memory (slow decay)
89    pub fn long_term() -> Self {
90        Self {
91            decay_rate: 0.5,
92            half_life_secs: 3600.0 * 24.0 * 7.0, // 1 week
93            semantic_weight: 0.85,
94            min_decay: 0.05,
95            apply_stage: DecayStage::PostRetrieval,
96        }
97    }
98    
99    /// Create config for working memory (very fast decay)
100    pub fn working_memory() -> Self {
101        Self {
102            decay_rate: 0.5,
103            half_life_secs: 300.0, // 5 minutes
104            semantic_weight: 0.3,
105            min_decay: 0.0,
106            apply_stage: DecayStage::PostRetrieval,
107        }
108    }
109    
110    /// Create config with custom half-life
111    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/// When to apply decay scoring
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum DecayStage {
123    /// Apply decay during index search (modifies distance)
124    DuringSearch,
125    /// Apply decay after retrieval (reranking)
126    PostRetrieval,
127    /// Apply decay as final step before returning
128    Final,
129}
130
131// ============================================================================
132// Temporal Scorer
133// ============================================================================
134
135/// Temporal decay scorer
136#[derive(Debug, Clone)]
137pub struct TemporalScorer {
138    config: TemporalDecayConfig,
139    /// Reference time (usually current time)
140    reference_time: f64,
141}
142
143impl TemporalScorer {
144    /// Create a new temporal scorer with current time as reference
145    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    /// Create with specific reference time
158    pub fn with_reference_time(config: TemporalDecayConfig, reference_time: f64) -> Self {
159        Self {
160            config,
161            reference_time,
162        }
163    }
164    
165    /// Create with default config
166    pub fn default_scorer() -> Self {
167        Self::new(TemporalDecayConfig::default())
168    }
169    
170    /// Calculate decay score for a given timestamp
171    ///
172    /// Returns a value between min_decay and 1.0
173    pub fn decay_score(&self, timestamp_secs: f64) -> f32 {
174        let delta_t = (self.reference_time - timestamp_secs).max(0.0);
175        
176        // decay = λ^(Δt/τ)
177        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    /// Calculate decay score from Duration
184    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    /// Blend semantic and decay scores
193    ///
194    /// final = α × semantic + (1-α) × decay
195    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    /// Calculate final score from semantic score and timestamp
201    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    /// Apply temporal decay to a list of scored results
207    ///
208    /// Each result is (id, semantic_score, timestamp)
209    /// Returns (id, final_score) sorted by final_score descending
210    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        // Sort by score descending
226        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
227        
228        scored
229    }
230    
231    /// Apply decay to typed results
232    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    /// Get the half-life in human-readable format
250    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// ============================================================================
266// Scored Result Types
267// ============================================================================
268
269/// A result with temporal decay applied
270#[derive(Debug, Clone)]
271pub struct TemporallyDecayedResult {
272    /// Result identifier
273    pub id: String,
274    
275    /// Original semantic/similarity score
276    pub semantic_score: f32,
277    
278    /// Decay factor based on age
279    pub decay_factor: f32,
280    
281    /// Final blended score
282    pub final_score: f32,
283    
284    /// Document timestamp (seconds since epoch)
285    pub timestamp: f64,
286    
287    /// Age of the document
288    pub age_secs: f64,
289}
290
291impl TemporallyDecayedResult {
292    /// Create from components
293    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    /// Format age as human-readable string
314    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// ============================================================================
330// Decay Curve Analysis
331// ============================================================================
332
333/// Analyze decay curve for debugging/visualization
334#[derive(Debug, Clone)]
335pub struct DecayCurve {
336    /// Points on the curve: (age_secs, decay_score)
337    pub points: Vec<(f64, f32)>,
338    
339    /// Half-life in seconds
340    pub half_life: f64,
341    
342    /// Configuration used
343    pub config: TemporalDecayConfig,
344}
345
346impl DecayCurve {
347    /// Generate decay curve points
348    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    /// Find age where score drops to threshold
367    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    /// Format as ASCII chart
377    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        // Add axes
390        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
403// ============================================================================
404// Integration with Search Results
405// ============================================================================
406
407/// Extension trait for applying temporal decay to search results
408pub trait TemporalDecayExt {
409    /// Apply temporal decay and return sorted results
410    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        // Sort by final score descending
426        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
434// ============================================================================
435// Convenience Functions
436// ============================================================================
437
438/// Calculate decay score with default configuration
439pub 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
444/// Calculate final score with default configuration
445pub 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
451/// Apply temporal decay to search results with default configuration
452pub 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// ============================================================================
461// Tests
462// ============================================================================
463
464#[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, // 1 hour
473            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        // At reference time (age = 0), decay should be 1.0
481        let decay_now = scorer.decay_score(3600.0);
482        assert!((decay_now - 1.0).abs() < 0.01);
483        
484        // At half-life (age = 1 hour), decay should be 0.5
485        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        // At 2x half-life (age = 2 hours), decay should be 0.25
502        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        // semantic = 0.8, decay = 0.5
516        // final = 0.7 * 0.8 + 0.3 * 0.5 = 0.56 + 0.15 = 0.71
517        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, // Very fast decay
526            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        // Very old document should hit min_decay floor
534        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        // Old document with high semantic score vs new document with lower semantic score
551        let results = vec![
552            ("old_high".to_string(), 0.9, 0.0),    // Age = 200s, decay ≈ 0.25
553            ("new_low".to_string(), 0.6, 190.0),   // Age = 10s, decay ≈ 0.93
554        ];
555        
556        let decayed = scorer.apply_decay(results);
557        
558        // New document should rank higher despite lower semantic score
559        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        // First point should have score near 1.0
570        assert!(curve.points[0].1 > 0.9);
571        
572        // Last point should have lower score
573        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, // 1 hour old
585            &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, // 2 hours
598            ..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}