rust_filesearch/px/
frecency.rs

1//! Frecency calculation for project ranking
2//!
3//! Implements a Firefox-style frecency algorithm that combines
4//! frequency (access count) and recency (time since last access)
5//! to intelligently rank projects.
6
7use chrono::{DateTime, Duration, Utc};
8
9/// Calculate frecency score for a project
10///
11/// Combines frequency (how often accessed) and recency (how recently accessed)
12/// into a single score for ranking projects.
13///
14/// Formula:
15/// - Frequency component: ln(access_count + 1) * 10.0
16/// - Recency component: time-decay buckets (100 pts for recent, 10 pts for old)
17/// - Final score: frequency + recency
18///
19/// # Arguments
20/// * `access_count` - Number of times project has been accessed
21/// * `last_accessed` - When the project was last accessed (None if never)
22///
23/// # Returns
24/// A score where higher values indicate more relevant projects
25pub fn calculate_frecency(access_count: u32, last_accessed: Option<DateTime<Utc>>) -> f64 {
26    // Frequency component: logarithmic scaling prevents very high counts from dominating
27    // Adding 1 before ln ensures ln(0+1) = 0 for never-accessed projects
28    let frequency_score = ((access_count + 1) as f64).ln() * 10.0;
29
30    // Recency component: time-decay based on age
31    let recency_score = if let Some(last_access) = last_accessed {
32        let now = Utc::now();
33        let age = now.signed_duration_since(last_access);
34        recency_weight(age)
35    } else {
36        0.0 // Never accessed
37    };
38
39    frequency_score + recency_score
40}
41
42/// Calculate recency weight based on time since last access
43///
44/// Uses time buckets similar to Firefox's frecency algorithm:
45/// - 0-4 days: 100 points (very recent)
46/// - 5-14 days: 70 points (recent)
47/// - 15-31 days: 50 points (this month)
48/// - 32-90 days: 30 points (this quarter)
49/// - 90+ days: 10 points (old)
50///
51/// This creates a gentle decay curve that keeps recently-used projects
52/// highly ranked while not completely forgetting older projects.
53fn recency_weight(age: Duration) -> f64 {
54    let days = age.num_days();
55
56    match days {
57        0..=4 => 100.0,   // Within 4 days - highly relevant
58        5..=14 => 70.0,   // Within 2 weeks - still recent
59        15..=31 => 50.0,  // Within month - relevant
60        32..=90 => 30.0,  // Within 3 months - somewhat relevant
61        _ => 10.0,        // Older - less relevant but not forgotten
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use chrono::Duration;
69
70    #[test]
71    fn test_calculate_frecency_never_accessed() {
72        let score = calculate_frecency(0, None);
73        // ln(1) * 10 + 0 = 0
74        assert_eq!(score, 0.0);
75    }
76
77    #[test]
78    fn test_calculate_frecency_accessed_today() {
79        let now = Utc::now();
80        let score = calculate_frecency(5, Some(now));
81
82        // ln(6) * 10 + 100
83        let expected = (6.0_f64).ln() * 10.0 + 100.0;
84        assert!((score - expected).abs() < 0.001);
85    }
86
87    #[test]
88    fn test_calculate_frecency_accessed_week_ago() {
89        let week_ago = Utc::now() - Duration::days(7);
90        let score = calculate_frecency(3, Some(week_ago));
91
92        // ln(4) * 10 + 70
93        let expected = (4.0_f64).ln() * 10.0 + 70.0;
94        assert!((score - expected).abs() < 0.001);
95    }
96
97    #[test]
98    fn test_calculate_frecency_accessed_month_ago() {
99        let month_ago = Utc::now() - Duration::days(20);
100        let score = calculate_frecency(10, Some(month_ago));
101
102        // ln(11) * 10 + 50
103        let expected = (11.0_f64).ln() * 10.0 + 50.0;
104        assert!((score - expected).abs() < 0.001);
105    }
106
107    #[test]
108    fn test_calculate_frecency_accessed_long_ago() {
109        let long_ago = Utc::now() - Duration::days(100);
110        let score = calculate_frecency(2, Some(long_ago));
111
112        // ln(3) * 10 + 10
113        let expected = (3.0_f64).ln() * 10.0 + 10.0;
114        assert!((score - expected).abs() < 0.001);
115    }
116
117    #[test]
118    fn test_recency_weight() {
119        assert_eq!(recency_weight(Duration::days(0)), 100.0);
120        assert_eq!(recency_weight(Duration::days(2)), 100.0);
121        assert_eq!(recency_weight(Duration::days(4)), 100.0);
122        assert_eq!(recency_weight(Duration::days(5)), 70.0);
123        assert_eq!(recency_weight(Duration::days(10)), 70.0);
124        assert_eq!(recency_weight(Duration::days(20)), 50.0);
125        assert_eq!(recency_weight(Duration::days(60)), 30.0);
126        assert_eq!(recency_weight(Duration::days(100)), 10.0);
127    }
128
129    #[test]
130    fn test_frecency_favors_recent_over_frequent() {
131        let recent_low_count = calculate_frecency(2, Some(Utc::now()));
132        let old_high_count = calculate_frecency(20, Some(Utc::now() - Duration::days(100)));
133
134        // Recent project with low count should score higher than
135        // old project with high count (demonstrates recency bias)
136        assert!(recent_low_count > old_high_count);
137    }
138
139    #[test]
140    fn test_frecency_frequency_still_matters() {
141        let recent_high = calculate_frecency(20, Some(Utc::now()));
142        let recent_low = calculate_frecency(2, Some(Utc::now()));
143
144        // With same recency, higher frequency should win
145        assert!(recent_high > recent_low);
146    }
147}