Skip to main content

post_cortex_memory/
scoring.rs

1//! Scoring strategies for semantic search results
2//!
3//! This module provides traits and implementations for adjusting search result scores
4//! based on various factors (temporal decay, popularity, quality, etc.)
5//!
6//! # Overview
7//!
8//! The [`ScoreAdjuster`] trait allows you to compose multiple scoring strategies
9//! without modifying the core search logic.
10
11use chrono::{DateTime, Utc};
12use post_cortex_embeddings::VectorMetadata;
13
14/// Trait for adjusting search result scores based on various factors
15///
16/// This trait enables the Strategy pattern for scoring adjustments, allowing you
17/// to compose multiple scoring strategies (temporal decay, popularity boosts, quality
18/// weights, etc.) without modifying the core search logic.
19///
20/// # Implementing ScoreAdjuster
21///
22/// To create a custom adjuster:
23///
24/// 1. Create a struct with configuration parameters
25/// 2. Implement the `adjust()` method
26/// 3. Return the adjusted score (0.0 to 1.0)
27///
28/// # Example
29///
30/// ```ignore
31/// struct PopularityBoostAdjuster {
32///     boost_factor: f32,
33/// }
34///
35/// impl ScoreAdjuster for PopularityBoostAdjuster {
36///     fn adjust(&self, base_score: f32, metadata: &VectorMetadata) -> f32 {
37///         // Boost score based on content popularity
38///         let popularity = metadata.metadata.get("popularity")
39///             .and_then(|p| p.parse().ok())
40///             .unwrap_or(0.0);
41///         base_score * (1.0 + self.boost_factor * popularity)
42///     }
43/// }
44/// ```
45pub trait ScoreAdjuster: Send + Sync {
46    /// Adjust the base score for a given search result
47    ///
48    /// This method takes a base similarity score (typically 0.0 to 1.0) and
49    /// metadata about the search result, then returns an adjusted score.
50    ///
51    /// # Arguments
52    /// * `base_score` - The initial similarity score (0.0 to 1.0)
53    /// * `metadata` - Metadata about the search result (timestamp, content_type, etc.)
54    ///
55    /// # Returns
56    /// The adjusted score (should typically be in range 0.0 to 1.0)
57    ///
58    /// # Example
59    ///
60    /// ```ignore
61    /// fn adjust(&self, base_score: f32, metadata: &VectorMetadata) -> f32 {
62    ///     if self.lambda <= 0.0 {
63    ///         return base_score;
64    ///     }
65    ///     let days_since = (self.now - metadata.timestamp).num_days().max(0) as f32;
66    ///     let decay_factor = (-self.lambda * days_since / 365.0).exp();
67    ///     base_score * decay_factor
68    /// }
69    /// ```
70    fn adjust(&self, base_score: f32, metadata: &VectorMetadata) -> f32;
71}
72
73/// Temporal decay adjuster - prioritizes recent content using exponential decay
74///
75/// # Formula
76/// `adjusted_score = base_score × e^(-λ × days/365)`
77///
78/// Where:
79/// - λ (lambda) is the recency bias parameter
80/// - days is the age of the content
81///
82/// # Effects
83/// - λ = 0.0: No decay (disabled)
84/// - λ = 0.5: Moderate decay
85/// - λ = 1.0: Aggressive decay
86pub struct TemporalDecayAdjuster {
87    /// Recency bias parameter (λ in the formula)
88    /// Higher values = more aggressive decay of older content
89    lambda: f32,
90
91    /// Reference time for decay calculation
92    /// Content timestamps are compared against this
93    now: DateTime<Utc>,
94}
95
96impl TemporalDecayAdjuster {
97    /// Create a new temporal decay adjuster
98    ///
99    /// # Arguments
100    /// * `lambda` - Recency bias parameter (0.0 to 10.0)
101    /// * `now` - Reference time for decay calculations
102    ///
103    /// # Example
104    /// ```ignore
105    /// use chrono::Utc;
106    /// let adjuster = TemporalDecayAdjuster::new(0.5, Utc::now());
107    /// ```
108    pub fn new(lambda: f32, now: DateTime<Utc>) -> Self {
109        Self { lambda, now }
110    }
111
112    /// Create adjuster with current time
113    pub fn with_current_time(lambda: f32) -> Self {
114        Self::new(lambda, Utc::now())
115    }
116}
117
118impl ScoreAdjuster for TemporalDecayAdjuster {
119    fn adjust(&self, base_score: f32, metadata: &VectorMetadata) -> f32 {
120        // If lambda is 0 or negative, decay is disabled
121        if self.lambda <= 0.0 {
122            return base_score;
123        }
124
125        // Calculate days since content was created
126        let days_since = (self.now - metadata.timestamp).num_days().max(0) as f32;
127
128        // Apply exponential decay formula: e^(-λ × days/365)
129        // This means:
130        // - Content from today: decay_factor = 1.0 (no decay)
131        // - Content from 1 year ago with λ=1.0: decay_factor = e^(-1) ≈ 0.37
132        // - Content from 1 year ago with λ=0.5: decay_factor = e^(-0.5) ≈ 0.61
133        let decay_factor = (-self.lambda * days_since / 365.0).exp();
134
135        // Apply decay to base score
136        base_score * decay_factor
137    }
138}
139
140/// Composite score adjuster - combines multiple adjusters sequentially
141///
142/// This adjuster applies multiple scoring strategies in sequence, with each
143/// adjuster receiving the output of the previous one. This allows you to
144/// compose complex scoring behaviors from simple, reusable components.
145///
146/// # Example
147///
148/// ```ignore
149/// use post_cortex::core::scoring::{CompositeScoreAdjuster, TemporalDecayAdjuster};
150/// use chrono::Utc;
151///
152/// // Combine temporal decay with custom popularity boost
153/// let composite = CompositeScoreAdjuster::new(vec![
154///     Box::new(TemporalDecayAdjuster::new(0.5, Utc::now())),
155///     Box::new(PopularityBoostAdjuster { boost_factor: 0.2 }),
156/// ]);
157///
158/// let adjusted_score = composite.adjust(0.8, &metadata);
159/// // First applies temporal decay, then popularity boost
160/// ```
161pub struct CompositeScoreAdjuster {
162    /// The adjusters to apply, in order
163    adjusters: Vec<Box<dyn ScoreAdjuster>>,
164}
165
166impl CompositeScoreAdjuster {
167    /// Create a new composite adjuster from a list of adjusters
168    ///
169    /// Adjusters are applied in the order they appear in the vector.
170    ///
171    /// # Arguments
172    /// * `adjusters` - Vector of boxed adjusters to apply sequentially
173    ///
174    /// # Example
175    ///
176    /// ```ignore
177    /// let composite = CompositeScoreAdjuster::new(vec![
178    ///     Box::new(TemporalDecayAdjuster::new(0.5, Utc::now())),
179    ///     Box::new(QualityWeightAdjuster::new(0.3)),
180    /// ]);
181    /// ```
182    pub fn new(adjusters: Vec<Box<dyn ScoreAdjuster>>) -> Self {
183        Self { adjusters }
184    }
185
186    /// Create an empty composite adjuster (no adjustments)
187    ///
188    /// This is useful when you want to conditionally add adjusters.
189    pub fn empty() -> Self {
190        Self {
191            adjusters: Vec::new(),
192        }
193    }
194
195    /// Add an adjuster to the composite
196    ///
197    /// # Arguments
198    /// * `adjuster` - The adjuster to add
199    ///
200    /// # Example
201    ///
202    /// ```ignore
203    /// let mut composite = CompositeScoreAdjuster::empty();
204    /// composite.add(Box::new(TemporalDecayAdjuster::new(0.5, Utc::now())));
205    /// composite.add(Box::new(PopularityBoostAdjuster::new(0.2)));
206    /// ```
207    pub fn add(&mut self, adjuster: Box<dyn ScoreAdjuster>) {
208        self.adjusters.push(adjuster);
209    }
210}
211
212impl ScoreAdjuster for CompositeScoreAdjuster {
213    fn adjust(&self, base_score: f32, metadata: &VectorMetadata) -> f32 {
214        // Apply each adjuster in sequence, feeding the output of one
215        // into the input of the next
216        self.adjusters.iter().fold(base_score, |score, adjuster| {
217            adjuster.adjust(score, metadata)
218        })
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use chrono::Duration;
226
227    #[test]
228    fn test_temporal_decay_disabled() {
229        let now = Utc::now();
230        let adjuster = TemporalDecayAdjuster::new(0.0, now);
231
232        let metadata = VectorMetadata {
233            id: "test".to_string(),
234            source: "session123".to_string(),
235            content_type: "qa".to_string(),
236            timestamp: now - Duration::days(365), // 1 year old
237            text: "test content".to_string(),
238            metadata: std::collections::HashMap::new(),
239        };
240
241        let adjusted = adjuster.adjust(0.8, &metadata);
242        assert_eq!(adjusted, 0.8); // No decay when lambda=0
243    }
244
245    #[test]
246    fn test_temporal_decay_recent_content() {
247        let now = Utc::now();
248        let adjuster = TemporalDecayAdjuster::new(1.0, now);
249
250        let metadata = VectorMetadata {
251            id: "test".to_string(),
252            source: "session123".to_string(),
253            content_type: "qa".to_string(),
254            timestamp: now, // Fresh content
255            text: "test content".to_string(),
256            metadata: std::collections::HashMap::new(),
257        };
258
259        let adjusted = adjuster.adjust(0.8, &metadata);
260        assert!((adjusted - 0.8).abs() < 0.01); // No decay for fresh content
261    }
262
263    #[test]
264    fn test_temporal_decay_old_content() {
265        let now = Utc::now();
266        let adjuster = TemporalDecayAdjuster::new(1.0, now);
267
268        let metadata = VectorMetadata {
269            id: "test".to_string(),
270            source: "session123".to_string(),
271            content_type: "qa".to_string(),
272            timestamp: now - Duration::days(365), // 1 year old
273            text: "test content".to_string(),
274            metadata: std::collections::HashMap::new(),
275        };
276
277        let adjusted = adjuster.adjust(0.8, &metadata);
278        // After 1 year with lambda=1.0: decay = e^(-1) ≈ 0.37
279        // So adjusted = 0.8 * 0.37 ≈ 0.29
280        assert!((adjusted - 0.296).abs() < 0.01); // e^(-1) = 0.3679..., 0.8 * 0.3679 = 0.294
281    }
282
283    #[test]
284    fn test_temporal_decay_half_lambda() {
285        let now = Utc::now();
286        let adjuster = TemporalDecayAdjuster::new(0.5, now);
287
288        let metadata = VectorMetadata {
289            id: "test".to_string(),
290            source: "session123".to_string(),
291            content_type: "qa".to_string(),
292            timestamp: now - Duration::days(365), // 1 year old
293            text: "test content".to_string(),
294            metadata: std::collections::HashMap::new(),
295        };
296
297        let adjusted = adjuster.adjust(0.8, &metadata);
298        // After 1 year with lambda=0.5: decay = e^(-0.5) ≈ 0.61
299        // So adjusted = 0.8 * 0.61 ≈ 0.49
300        assert!((adjusted - 0.488).abs() < 0.01); // e^(-0.5) = 0.6065..., 0.8 * 0.6065 = 0.485
301    }
302
303    #[test]
304    fn test_temporal_decay_future_timestamp() {
305        let now = Utc::now();
306        let adjuster = TemporalDecayAdjuster::new(1.0, now);
307
308        let metadata = VectorMetadata {
309            id: "test".to_string(),
310            source: "session123".to_string(),
311            content_type: "qa".to_string(),
312            timestamp: now + Duration::days(30), // Future timestamp
313            text: "test content".to_string(),
314            metadata: std::collections::HashMap::new(),
315        };
316
317        let adjusted = adjuster.adjust(0.8, &metadata);
318        // Future content should get decay_factor > 1.0 (boost)
319        // days_since will be 0 (max(0, -30))
320        assert!((adjusted - 0.8).abs() < 0.01); // No decay for future content
321    }
322
323    // A simple test adjuster that always multiplies by a fixed factor
324    struct BoostAdjuster {
325        factor: f32,
326    }
327
328    impl ScoreAdjuster for BoostAdjuster {
329        fn adjust(&self, base_score: f32, _metadata: &VectorMetadata) -> f32 {
330            base_score * self.factor
331        }
332    }
333
334    #[test]
335    fn test_composite_adjuster_empty() {
336        let composite = CompositeScoreAdjuster::empty();
337
338        let metadata = VectorMetadata {
339            id: "test".to_string(),
340            source: "session123".to_string(),
341            content_type: "qa".to_string(),
342            timestamp: Utc::now(),
343            text: "test content".to_string(),
344            metadata: std::collections::HashMap::new(),
345        };
346
347        let adjusted = composite.adjust(0.8, &metadata);
348        assert_eq!(adjusted, 0.8); // No adjusters, score unchanged
349    }
350
351    #[test]
352    fn test_composite_adjuster_single() {
353        let adjuster = BoostAdjuster { factor: 0.5 };
354        let composite = CompositeScoreAdjuster::new(vec![Box::new(adjuster)]);
355
356        let metadata = VectorMetadata {
357            id: "test".to_string(),
358            source: "session123".to_string(),
359            content_type: "qa".to_string(),
360            timestamp: Utc::now(),
361            text: "test content".to_string(),
362            metadata: std::collections::HashMap::new(),
363        };
364
365        let adjusted = composite.adjust(0.8, &metadata);
366        assert_eq!(adjusted, 0.4); // 0.8 * 0.5
367    }
368
369    #[test]
370    fn test_composite_adjuster_multiple() {
371        let now = Utc::now();
372        let composite = CompositeScoreAdjuster::new(vec![
373            // First adjuster: decay by ~50% (e^(-0.693) ≈ 0.5)
374            Box::new(TemporalDecayAdjuster::new(0.693, now)),
375            // Second adjuster: boost by 2.0x
376            Box::new(BoostAdjuster { factor: 2.0 }),
377        ]);
378
379        let metadata = VectorMetadata {
380            id: "test".to_string(),
381            source: "session123".to_string(),
382            content_type: "qa".to_string(),
383            timestamp: now - Duration::days(365), // 1 year old
384            text: "test content".to_string(),
385            metadata: std::collections::HashMap::new(),
386        };
387
388        let adjusted = composite.adjust(0.8, &metadata);
389        // First: decay from 1 year with lambda=0.693: 0.8 * 0.5 = 0.4
390        // Then: boost by 2.0x: 0.4 * 2.0 = 0.8
391        assert!((adjusted - 0.8).abs() < 0.01);
392    }
393
394    #[test]
395    fn test_composite_adjuster_add() {
396        let mut composite = CompositeScoreAdjuster::empty();
397        composite.add(Box::new(BoostAdjuster { factor: 0.5 }));
398        composite.add(Box::new(BoostAdjuster { factor: 0.5 }));
399
400        let metadata = VectorMetadata {
401            id: "test".to_string(),
402            source: "session123".to_string(),
403            content_type: "qa".to_string(),
404            timestamp: Utc::now(),
405            text: "test content".to_string(),
406            metadata: std::collections::HashMap::new(),
407        };
408
409        let adjusted = composite.adjust(0.8, &metadata);
410        assert_eq!(adjusted, 0.2); // 0.8 * 0.5 * 0.5
411    }
412}