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}