1use crate::{Objective, ObjectiveContext, Selection};
4
5pub struct MaxScoreObjective<T, F>
7where
8 F: Fn(&T) -> f64 + Send + Sync,
9{
10 scorer: F,
11 _phantom: std::marker::PhantomData<T>,
12}
13
14impl<T, F> MaxScoreObjective<T, F>
15where
16 F: Fn(&T) -> f64 + Send + Sync,
17{
18 pub fn new(scorer: F) -> Self {
20 Self {
21 scorer,
22 _phantom: std::marker::PhantomData,
23 }
24 }
25}
26
27impl<T: Send + Sync, F> Objective<T> for MaxScoreObjective<T, F>
28where
29 F: Fn(&T) -> f64 + Send + Sync,
30{
31 fn score(&self, candidate: &T, _context: &ObjectiveContext) -> f64 {
32 (self.scorer)(candidate)
33 }
34
35 fn name(&self) -> &str {
36 "MaxScoreObjective"
37 }
38}
39
40pub struct ThresholdObjective<T, F>
42where
43 F: Fn(&T) -> f64 + Send + Sync,
44{
45 scorer: F,
46 threshold: f64,
47 _phantom: std::marker::PhantomData<T>,
48}
49
50impl<T, F> ThresholdObjective<T, F>
51where
52 F: Fn(&T) -> f64 + Send + Sync,
53{
54 pub fn new(scorer: F, threshold: f64) -> Self {
56 Self {
57 scorer,
58 threshold,
59 _phantom: std::marker::PhantomData,
60 }
61 }
62}
63
64impl<T: Send + Sync, F> Objective<T> for ThresholdObjective<T, F>
65where
66 F: Fn(&T) -> f64 + Send + Sync,
67{
68 fn score(&self, candidate: &T, _context: &ObjectiveContext) -> f64 {
69 (self.scorer)(candidate)
70 }
71
72 fn passes_score(&self, score: f64, context: &ObjectiveContext) -> bool {
73 if !score.is_finite() {
74 return false;
75 }
76 let passes_obj = score >= self.threshold;
77 let passes_ctx = context.min_score.map(|min| score >= min).unwrap_or(true);
78 passes_obj && passes_ctx
79 }
80
81 fn passes(&self, candidate: &T, context: &ObjectiveContext) -> bool {
82 let score = (self.scorer)(candidate);
83 self.passes_score(score, context)
84 }
85
86 fn name(&self) -> &str {
87 "ThresholdObjective"
88 }
89}
90
91pub struct FirstMatchObjective<T, F>
93where
94 F: Fn(&T) -> bool + Send + Sync,
95{
96 predicate: F,
97 _phantom: std::marker::PhantomData<T>,
98}
99
100impl<T, F> FirstMatchObjective<T, F>
101where
102 F: Fn(&T) -> bool + Send + Sync,
103{
104 pub fn new(predicate: F) -> Self {
106 Self {
107 predicate,
108 _phantom: std::marker::PhantomData,
109 }
110 }
111}
112
113impl<T: Send + Sync, F> Objective<T> for FirstMatchObjective<T, F>
114where
115 F: Fn(&T) -> bool + Send + Sync,
116{
117 fn score(&self, candidate: &T, _context: &ObjectiveContext) -> f64 {
118 if (self.predicate)(candidate) {
119 1.0
120 } else {
121 0.0
122 }
123 }
124
125 fn select<'a>(&self, candidates: &'a [T], context: &ObjectiveContext) -> Vec<Selection<&'a T>> {
126 if candidates.is_empty() {
127 return Vec::new();
128 }
129
130 let limit = context
131 .max_candidates
132 .unwrap_or(candidates.len())
133 .min(candidates.len());
134
135 for (i, candidate) in candidates.iter().take(limit).enumerate() {
136 if (self.predicate)(candidate) {
137 if !self.passes_score(1.0, context) {
138 return Vec::new();
139 }
140 return vec![Selection::new(candidate, 1.0, i)
141 .with_considered(i + 1)
142 .with_passed(1)];
143 }
144 }
145
146 Vec::new()
147 }
148
149 fn name(&self) -> &str {
150 "FirstMatchObjective"
151 }
152}
153
154pub trait HasTimestamp {
156 fn timestamp(&self) -> chrono::DateTime<chrono::Utc>;
158}
159
160pub trait HasSalience {
162 fn salience(&self) -> f64;
164}
165
166pub struct RecencyObjective {
168 half_life_seconds: f64,
169}
170
171impl RecencyObjective {
172 const MIN_HALF_LIFE: f64 = 1.0;
173
174 pub fn new(half_life_seconds: f64) -> Self {
176 assert!(
177 half_life_seconds.is_finite() && half_life_seconds > 0.0,
178 "half_life_seconds must be positive and finite, got {half_life_seconds}"
179 );
180 Self {
181 half_life_seconds: half_life_seconds.max(Self::MIN_HALF_LIFE),
182 }
183 }
184
185 pub fn hours(hours: f64) -> Self {
187 Self::new(hours * 3600.0)
188 }
189
190 pub fn days(days: f64) -> Self {
192 Self::new(days * 86400.0)
193 }
194}
195
196impl<T: HasTimestamp + Send + Sync> Objective<T> for RecencyObjective {
197 fn score(&self, candidate: &T, context: &ObjectiveContext) -> f64 {
198 let age_seconds = (context.as_of - candidate.timestamp()).num_seconds().max(0) as f64;
199 0.5f64.powf(age_seconds / self.half_life_seconds)
200 }
201
202 fn name(&self) -> &str {
203 "RecencyObjective"
204 }
205}
206
207pub struct SalienceObjective {
209 min_salience: f64,
210}
211
212impl SalienceObjective {
213 pub fn new() -> Self {
215 Self { min_salience: 0.0 }
216 }
217
218 pub fn with_min(mut self, min: f64) -> Self {
220 self.min_salience = min;
221 self
222 }
223}
224
225impl Default for SalienceObjective {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231impl<T: HasSalience + Send + Sync> Objective<T> for SalienceObjective {
232 fn score(&self, candidate: &T, _context: &ObjectiveContext) -> f64 {
233 let salience = candidate.salience();
234 if salience >= self.min_salience {
235 salience
236 } else {
237 0.0
238 }
239 }
240
241 fn name(&self) -> &str {
242 "SalienceObjective"
243 }
244}
245
246pub struct RelevanceObjective {
248 recency_weight: f64,
249 salience_weight: f64,
250 recency: RecencyObjective,
251}
252
253impl RelevanceObjective {
254 pub fn new(recency_half_life: f64, recency_weight: f64, salience_weight: f64) -> Self {
256 assert!(
257 recency_weight.is_finite() && recency_weight >= 0.0,
258 "recency_weight must be finite and non-negative, got {recency_weight}"
259 );
260 assert!(
261 salience_weight.is_finite() && salience_weight >= 0.0,
262 "salience_weight must be finite and non-negative, got {salience_weight}"
263 );
264 Self {
265 recency_weight,
266 salience_weight,
267 recency: RecencyObjective::new(recency_half_life),
268 }
269 }
270
271 pub fn balanced(recency_half_life: f64) -> Self {
273 Self::new(recency_half_life, 0.5, 0.5)
274 }
275}
276
277impl<T: HasTimestamp + HasSalience + Send + Sync> Objective<T> for RelevanceObjective {
278 fn score(&self, candidate: &T, context: &ObjectiveContext) -> f64 {
279 if let Some(v) = context
281 .extra
282 .get("relevance_score")
283 .and_then(|v| v.as_f64())
284 {
285 return v;
286 }
287
288 let recency_score = self.recency.score(candidate, context);
289 let salience_score = candidate.salience();
290
291 let total_weight = self.recency_weight + self.salience_weight;
292 if total_weight > 0.0 {
293 (self.recency_weight * recency_score + self.salience_weight * salience_score)
294 / total_weight
295 } else {
296 0.0
297 }
298 }
299
300 fn name(&self) -> &str {
301 "RelevanceObjective"
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_max_score_objective() {
311 let objective = MaxScoreObjective::new(|n: &i32| *n as f64);
312
313 let candidates = vec![1, 5, 3, 8, 2];
314 let selection = objective
315 .select(&candidates, &ObjectiveContext::new())
316 .into_iter()
317 .next()
318 .unwrap();
319
320 assert_eq!(*selection.item, 8);
321 }
322
323 #[test]
324 fn test_threshold_objective() {
325 let objective = ThresholdObjective::new(|n: &i32| *n as f64, 5.0);
326
327 assert!(objective.passes(&10, &ObjectiveContext::new()));
328 assert!(!objective.passes(&3, &ObjectiveContext::new()));
329 }
330
331 #[test]
332 fn test_threshold_objective_rejects_infinite_scores() {
333 let objective = ThresholdObjective::new(|_n: &i32| f64::INFINITY, 5.0);
334
335 assert!(!objective.passes(&10, &ObjectiveContext::new()));
336 }
337
338 #[test]
339 fn test_first_match_objective() {
340 let objective = FirstMatchObjective::new(|n: &i32| *n > 5);
341
342 let candidates = vec![1, 3, 7, 9, 2];
343 let selection = objective
344 .select(&candidates, &ObjectiveContext::new())
345 .into_iter()
346 .next()
347 .unwrap();
348
349 assert_eq!(*selection.item, 7);
350 assert_eq!(selection.index, 2);
351 }
352
353 #[test]
354 fn test_first_match_respects_min_score() {
355 let objective = FirstMatchObjective::new(|n: &i32| *n > 5);
356
357 let candidates = vec![7];
358 let context = ObjectiveContext::new().with_min_score(2.0);
359
360 assert!(!objective.passes(&7, &context));
361 assert!(objective.select(&candidates, &context).is_empty());
362 }
363
364 #[test]
365 fn test_first_match_respects_max_candidates() {
366 let objective = FirstMatchObjective::new(|n: &i32| *n > 5);
367
368 let candidates = vec![1, 3, 7, 9, 2];
370 let context = ObjectiveContext::new().with_max_candidates(2);
371 let result = objective.select(&candidates, &context);
372
373 assert!(result.is_empty());
374 }
375
376 #[derive(Clone)]
377 struct TestItem {
378 _value: i32,
379 timestamp: chrono::DateTime<chrono::Utc>,
380 salience: f64,
381 }
382
383 impl HasTimestamp for TestItem {
384 fn timestamp(&self) -> chrono::DateTime<chrono::Utc> {
385 self.timestamp
386 }
387 }
388
389 impl HasSalience for TestItem {
390 fn salience(&self) -> f64 {
391 self.salience
392 }
393 }
394
395 #[test]
396 fn test_recency_objective() {
397 let objective = RecencyObjective::hours(1.0);
398 let now = chrono::Utc::now();
399 let context = ObjectiveContext::at(now);
401
402 let old = now - chrono::Duration::hours(2);
403
404 let new_item = TestItem {
405 _value: 1,
406 timestamp: now,
407 salience: 0.5,
408 };
409 let old_item = TestItem {
410 _value: 2,
411 timestamp: old,
412 salience: 0.5,
413 };
414
415 let new_score = objective.score(&new_item, &context);
416 let old_score = objective.score(&old_item, &context);
417
418 assert!(new_score > old_score);
419 assert!((new_score - 1.0).abs() < 0.1);
420 }
421
422 #[test]
423 fn test_relevance_objective() {
424 let objective = RelevanceObjective::balanced(3600.0);
425 let now = chrono::Utc::now();
426 let context = ObjectiveContext::at(now);
428
429 let item = TestItem {
430 _value: 1,
431 timestamp: now,
432 salience: 0.8,
433 };
434
435 let score = objective.score(&item, &context);
436
437 assert!(score > 0.8 && score < 1.0);
438 }
439
440 #[test]
441 fn test_relevance_uses_context_relevance_score() {
442 let objective = RelevanceObjective::balanced(3600.0);
443 let now = chrono::Utc::now();
444 let context =
446 ObjectiveContext::at(now).with_extra(serde_json::json!({"relevance_score": 0.42}));
447
448 let item = TestItem {
449 _value: 1,
450 timestamp: now,
451 salience: 0.9,
452 };
453
454 let score = objective.score(&item, &context);
456 assert!((score - 0.42).abs() < 1e-9);
457 }
458
459 #[test]
460 #[should_panic(expected = "recency_weight must be finite and non-negative")]
461 fn test_relevance_negative_recency_weight_panics() {
462 RelevanceObjective::new(3600.0, -0.1, 0.5);
463 }
464
465 #[test]
466 #[should_panic(expected = "salience_weight must be finite and non-negative")]
467 fn test_relevance_nan_salience_weight_panics() {
468 RelevanceObjective::new(3600.0, 0.5, f64::NAN);
469 }
470
471 #[test]
472 #[should_panic(expected = "half_life_seconds must be positive and finite")]
473 fn test_recency_zero_half_life_panics() {
474 RecencyObjective::new(0.0);
475 }
476
477 #[test]
478 #[should_panic(expected = "half_life_seconds must be positive and finite")]
479 fn test_recency_negative_half_life_panics() {
480 RecencyObjective::new(-1.0);
481 }
482
483 #[test]
484 #[should_panic(expected = "half_life_seconds must be positive and finite")]
485 fn test_recency_nan_half_life_panics() {
486 RecencyObjective::new(f64::NAN);
487 }
488
489 #[test]
490 fn test_threshold_no_match_below_threshold() {
491 let objective = ThresholdObjective::new(|n: &i32| *n as f64, 10.0);
492
493 let candidates = vec![1, 5, 3];
494 let result = objective.select(&candidates, &ObjectiveContext::new());
495
496 assert!(result.is_empty());
497 }
498
499 #[test]
500 fn test_threshold_selects_best_above() {
501 let objective = ThresholdObjective::new(|n: &i32| *n as f64, 5.0);
502
503 let candidates = vec![1, 10, 3, 15];
504 let selection = objective
505 .select(&candidates, &ObjectiveContext::new())
506 .into_iter()
507 .next()
508 .unwrap();
509
510 assert_eq!(*selection.item, 15);
511 assert_eq!(selection.score, 15.0);
512 assert_eq!(selection.passed, 2);
513 }
514}