Skip to main content

khive_fold/objective/
builtin.rs

1//! Built-in objective functions
2
3use crate::{Objective, ObjectiveContext, Selection};
4
5/// Selects candidate with highest score.
6pub 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    /// Create a new max score objective
19    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
40/// Passes candidates above a threshold.
41pub 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    /// Create a new threshold objective
55    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
91/// Returns first candidate that passes predicate.
92pub 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    /// Create a new first match objective
105    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
154/// Trait for items with timestamps
155pub trait HasTimestamp {
156    /// Returns the timestamp of this item
157    fn timestamp(&self) -> chrono::DateTime<chrono::Utc>;
158}
159
160/// Trait for items with salience
161pub trait HasSalience {
162    /// Returns the salience value of this item (0.0 to 1.0)
163    fn salience(&self) -> f64;
164}
165
166/// Scores by recency (newer = higher score).
167pub struct RecencyObjective {
168    half_life_seconds: f64,
169}
170
171impl RecencyObjective {
172    const MIN_HALF_LIFE: f64 = 1.0;
173
174    /// Create a new recency objective. Panics if `half_life_seconds` is not positive and finite.
175    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    /// Create with hour half-life. Panics if `hours` is not positive and finite.
186    pub fn hours(hours: f64) -> Self {
187        Self::new(hours * 3600.0)
188    }
189
190    /// Create with day half-life. Panics if `days` is not positive and finite.
191    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
207/// Scores by salience field.
208pub struct SalienceObjective {
209    min_salience: f64,
210}
211
212impl SalienceObjective {
213    /// Create a new salience objective
214    pub fn new() -> Self {
215        Self { min_salience: 0.0 }
216    }
217
218    /// Set minimum salience
219    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
246/// Combines recency and salience.
247pub struct RelevanceObjective {
248    recency_weight: f64,
249    salience_weight: f64,
250    recency: RecencyObjective,
251}
252
253impl RelevanceObjective {
254    /// Create a new relevance objective. Panics if either weight is negative or non-finite.
255    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    /// Create with equal weights (0.5 each). Panics if `recency_half_life` is not positive and finite.
272    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 context carries a named relevance score, use it directly.
280        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        // Match is at index 2 (value 7), but max_candidates=2 limits scan to indices 0..1.
369        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        // Pass current time explicitly — ObjectiveContext::new() defaults to the Unix epoch.
400        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        // Pass current time explicitly — ObjectiveContext::new() defaults to the Unix epoch.
427        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        // Pass current time explicitly — ObjectiveContext::new() defaults to the Unix epoch.
445        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        // The context relevance_score should override the recency+salience fusion.
455        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}