Skip to main content

khive_runtime/
objectives.rs

1//! Retrieval Objective implementations for khive-runtime.
2//!
3//! Domain-specific objectives that operate on pre-computed retrieval signals.
4//! Pure math: no IO, no async. The runtime layer materialises the signal data
5//! and feeds it in via the candidate struct.
6
7use std::collections::HashMap;
8
9use uuid::Uuid;
10
11use khive_fold::objective::{Objective, ObjectiveContext};
12use khive_fold::ordering::HasId;
13
14/// Pre-computed retrieval signals for a single candidate entity.
15///
16/// All fields are `Option` — a missing signal scores 0.0. The runtime layer
17/// is responsible for populating whichever fields are available before handing
18/// the slice to an objective.
19#[derive(Debug, Clone)]
20pub struct RetrievalCandidate {
21    /// Stable entity UUID.
22    pub id: Uuid,
23    /// Cosine similarity to the query vector (0.0–1.0).
24    pub vector_score: Option<f64>,
25    /// BM25/FTS relevance score (0.0–1.0 normalised, or raw rank score).
26    pub text_score: Option<f64>,
27    /// Hop distance from the nearest anchor node (0 = anchor itself).
28    pub graph_distance: Option<u32>,
29    /// Pre-fused RRF score from `FusionStrategy::Rrf`.
30    pub rrf_score: Option<f64>,
31}
32
33impl HasId for RetrievalCandidate {
34    #[inline]
35    fn id(&self) -> Uuid {
36        self.id
37    }
38}
39
40// ── VectorSimilarityObjective ────────────────────────────────────────────────
41
42/// Scores a candidate by cosine similarity to the query vector.
43///
44/// Returns `vector_score` unchanged, or 0.0 when the field is absent.
45pub struct VectorSimilarityObjective;
46
47impl Objective<RetrievalCandidate> for VectorSimilarityObjective {
48    #[inline]
49    fn score(&self, candidate: &RetrievalCandidate, _context: &ObjectiveContext) -> f64 {
50        candidate.vector_score.unwrap_or(0.0)
51    }
52
53    fn name(&self) -> &str {
54        "VectorSimilarityObjective"
55    }
56}
57
58// ── TextRelevanceObjective ───────────────────────────────────────────────────
59
60/// Scores a candidate by BM25/FTS relevance.
61///
62/// Returns `text_score` unchanged, or 0.0 when the field is absent.
63pub struct TextRelevanceObjective;
64
65impl Objective<RetrievalCandidate> for TextRelevanceObjective {
66    #[inline]
67    fn score(&self, candidate: &RetrievalCandidate, _context: &ObjectiveContext) -> f64 {
68        candidate.text_score.unwrap_or(0.0)
69    }
70
71    fn name(&self) -> &str {
72        "TextRelevanceObjective"
73    }
74}
75
76// ── GraphProximityObjective ──────────────────────────────────────────────────
77
78/// Scores a candidate by graph proximity to anchor nodes.
79///
80/// Score formula (linear decay):
81///
82/// ```text
83/// d ≤ max_distance → score = 1.0 − (d as f64 / max_distance as f64)
84/// d > max_distance → score = 0.0
85/// missing          → score = 0.0
86/// ```
87///
88/// Direct anchor hits (d = 0) score 1.0. The boundary `d == max_distance`
89/// scores 0.0; anything beyond also scores 0.0.
90pub struct GraphProximityObjective {
91    /// Maximum hop distance to consider. Candidates beyond this score 0.0.
92    pub max_distance: u32,
93}
94
95impl Objective<RetrievalCandidate> for GraphProximityObjective {
96    fn score(&self, candidate: &RetrievalCandidate, _context: &ObjectiveContext) -> f64 {
97        let d = match candidate.graph_distance {
98            Some(d) => d,
99            None => return 0.0,
100        };
101        if self.max_distance == 0 || d >= self.max_distance {
102            return 0.0;
103        }
104        1.0 - (d as f64 / self.max_distance as f64)
105    }
106
107    fn name(&self) -> &str {
108        "GraphProximityObjective"
109    }
110}
111
112// ── RrfFusionObjective ───────────────────────────────────────────────────────
113
114/// Scores a candidate by its pre-computed RRF fusion score.
115///
116/// Returns `rrf_score` unchanged, or 0.0 when the field is absent.
117/// Implements `Objective` for both `RetrievalCandidate` and `NoteCandidate`
118/// so the same objective can be used in the general retrieval pipeline
119/// and the memory recall pipeline.
120pub struct RrfFusionObjective;
121
122impl Objective<RetrievalCandidate> for RrfFusionObjective {
123    #[inline]
124    fn score(&self, candidate: &RetrievalCandidate, _context: &ObjectiveContext) -> f64 {
125        candidate.rrf_score.unwrap_or(0.0)
126    }
127
128    fn name(&self) -> &str {
129        "RrfFusionObjective"
130    }
131}
132
133impl Objective<NoteCandidate> for RrfFusionObjective {
134    #[inline]
135    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
136        candidate.rrf_score.unwrap_or(0.0)
137    }
138
139    fn name(&self) -> &str {
140        "RrfFusionObjective"
141    }
142}
143
144// ── Memory-Recall Objectives ──────────────────────────────────────────────────
145
146/// Pre-computed signals for a single memory note candidate.
147///
148/// Used by the recall pipeline's `ComposePipeline` to score and rank candidates
149/// via `DecayAwareSalienceObjective`, `TemporalRecencyObjective`, and
150/// `RerankerObjective` without any IO. The runtime layer populates this struct
151/// from stored notes before handing the slice to the pipeline.
152#[derive(Debug, Clone)]
153pub struct NoteCandidate {
154    /// Stable note UUID.
155    pub id: Uuid,
156    /// Pre-fused RRF score from the retrieval stage (0.0–1.0).
157    pub rrf_score: Option<f64>,
158    /// Raw salience stored on the note (0.0–1.0).
159    pub salience: f64,
160    /// Per-note exponential decay rate (>= 0.0).
161    pub decay_factor: f64,
162    /// Age of the note in days at query time.
163    pub age_days: f64,
164    /// Salience after applying the configured `DecayModel` (pre-computed by the caller).
165    ///
166    /// The caller must set this to `DecayModel::apply(salience, age_days, decay_factor, half_life)`
167    /// so that objectives respect the configured decay model variant rather than
168    /// always applying exponential decay. When not set, defaults to 0.0.
169    pub effective_salience: f64,
170    /// Per-reranker scores populated by the rerank stage.
171    /// Keyed by reranker name (e.g. "cross_encoder", "salience", "graph_proximity").
172    pub rerank_scores: HashMap<String, f64>,
173}
174
175impl HasId for NoteCandidate {
176    #[inline]
177    fn id(&self) -> Uuid {
178        self.id
179    }
180}
181
182// ── DecayAwareSalienceObjective ──────────────────────────────────────────────
183
184/// Scores a `NoteCandidate` by salience with configurable temporal decay.
185///
186/// The decay formula is determined by the configured `DecayModel` (injected at
187/// construction time). The default `DecayModel::Exponential` uses the note's own
188/// `decay_factor`: `salience * exp(-decay_factor * age_days)`.
189///
190/// This objective participates in `WeightedObjective` composition alongside
191/// `RrfFusionObjective` and `TemporalRecencyObjective` to form the full recall
192/// scoring pipeline.
193pub struct DecayAwareSalienceObjective {
194    /// Exponential decay rate k (>= 0.0). Score = `salience * exp(-k * age_days)`.
195    /// Corresponds to the per-note `decay_factor` parameter stored on memory notes.
196    pub decay_rate: f64,
197}
198
199impl DecayAwareSalienceObjective {
200    /// Create a new objective with the given exponential decay rate.
201    ///
202    /// `decay_rate = 0.01` gives a ~69-day half-life (default for memory notes).
203    pub fn new(decay_rate: f64) -> Self {
204        Self { decay_rate }
205    }
206
207    /// Default memory decay rate: 0.01 (~69-day half-life).
208    pub fn default_memory() -> Self {
209        Self::new(0.01)
210    }
211}
212
213impl Objective<NoteCandidate> for DecayAwareSalienceObjective {
214    #[inline]
215    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
216        candidate.salience * (-candidate.decay_factor * candidate.age_days).exp()
217    }
218
219    fn name(&self) -> &str {
220        "DecayAwareSalienceObjective"
221    }
222}
223
224// ── AmplifiedDecayAwareSalienceObjective ─────────────────────────────────────
225
226/// Scores a `NoteCandidate` by salience with exponential decay and a non-linear
227/// amplification exponent applied after decay.
228///
229/// Formula: `(salience * exp(-decay_factor * age_days)) ^ alpha`
230///
231/// With `alpha > 1.0`, high-salience memories rank more clearly above low-salience
232/// ones when relevance is similar. At `alpha = 1.5` (the memory-recall default),
233/// salience 0.9 → 0.854 and salience 0.3 → 0.164 — a ~5.2× spread vs the ~3× linear
234/// spread. Keep `alpha ≤ 2.0`; values above 2 compress near-zero salience toward 0.
235///
236/// Used by the memory recall pipeline to make salience a meaningful tiebreaker
237/// without dominating relevance at the default weight of 0.20.
238pub struct AmplifiedDecayAwareSalienceObjective {
239    /// Power applied to the decayed salience value. Must be > 0.
240    pub alpha: f64,
241}
242
243impl AmplifiedDecayAwareSalienceObjective {
244    /// Create with the given amplification exponent.
245    pub fn new(alpha: f64) -> Self {
246        Self { alpha }
247    }
248
249    /// Default memory alpha from the memory recall handler: 1.5.
250    pub fn default_memory() -> Self {
251        Self::new(1.5)
252    }
253}
254
255impl Objective<NoteCandidate> for AmplifiedDecayAwareSalienceObjective {
256    #[inline]
257    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
258        // effective_salience is pre-computed via the caller's DecayModel, so this
259        // works for all decay model variants, not just exponential.
260        candidate.effective_salience.powf(self.alpha)
261    }
262
263    fn name(&self) -> &str {
264        "AmplifiedDecayAwareSalienceObjective"
265    }
266}
267
268// ── TemporalRecencyObjective ─────────────────────────────────────────────────
269
270/// Scores a `NoteCandidate` by pure temporal recency with a configurable half-life.
271///
272/// Formula: `exp(-ln(2) / half_life_days * age_days)`
273///
274/// At `age_days = 0` → score 1.0 (brand new note).
275/// At `age_days = half_life_days` → score 0.5.
276///
277/// Complements `DecayAwareSalienceObjective`: this signal rewards freshness
278/// independently of the note's own decay rate.
279pub struct TemporalRecencyObjective {
280    /// Number of days for the recency score to halve. Must be > 0.
281    pub half_life_days: f64,
282}
283
284impl TemporalRecencyObjective {
285    /// Create with the default temporal half-life of 30 days.
286    pub fn default_memory() -> Self {
287        Self {
288            half_life_days: 30.0,
289        }
290    }
291}
292
293impl Objective<NoteCandidate> for TemporalRecencyObjective {
294    #[inline]
295    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
296        let k = std::f64::consts::LN_2 / self.half_life_days.max(f64::EPSILON);
297        (-k * candidate.age_days).exp()
298    }
299
300    fn name(&self) -> &str {
301        "TemporalRecencyObjective"
302    }
303}
304
305// ── RerankerObjective ────────────────────────────────────────────────────────
306
307/// Scores a `NoteCandidate` using a named reranker's pre-computed score.
308///
309/// Looks up `candidate.rerank_scores[reranker_name]`. Returns 0.0 when the
310/// reranker was not run (key absent) — callers should gate on
311/// `RecallConfig.reranker_weights[name] > 0.0` before including this objective
312/// in a `WeightedObjective` composition.
313pub struct RerankerObjective {
314    /// Name of the reranker to look up in `candidate.rerank_scores`.
315    pub reranker_name: String,
316}
317
318impl RerankerObjective {
319    /// Create a new objective for the named reranker.
320    pub fn new(name: impl Into<String>) -> Self {
321        Self {
322            reranker_name: name.into(),
323        }
324    }
325}
326
327impl Objective<NoteCandidate> for RerankerObjective {
328    #[inline]
329    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
330        candidate
331            .rerank_scores
332            .get(&self.reranker_name)
333            .copied()
334            .unwrap_or(0.0)
335    }
336
337    fn name(&self) -> &str {
338        "RerankerObjective"
339    }
340}
341
342// ── MemoryRecallPipeline ──────────────────────────────────────────────────────
343
344/// Composable scoring pipeline for memory recall candidates.
345///
346/// Wraps a `WeightedObjective<NoteCandidate>` with the three standard memory
347/// scoring components (RRF relevance, amplified salience, temporal recency)
348/// weighted by the recall config parameters. Pack code uses this type to avoid
349/// a direct dependency on `khive-fold`.
350pub struct MemoryRecallPipeline {
351    pipeline: khive_fold::WeightedObjective<NoteCandidate>,
352}
353
354impl MemoryRecallPipeline {
355    /// Build a pipeline from explicit component weights and temporal half-life.
356    ///
357    /// `relevance_weight`, `salience_weight`, `temporal_weight` correspond to
358    /// `RecallConfig`'s three weight fields. `half_life_days` drives
359    /// `TemporalRecencyObjective`. `salience_alpha` is the amplification exponent
360    /// for `AmplifiedDecayAwareSalienceObjective` (default 1.5).
361    pub fn new(
362        relevance_weight: f64,
363        salience_weight: f64,
364        temporal_weight: f64,
365        half_life_days: f64,
366        salience_alpha: f64,
367    ) -> Self {
368        use khive_fold::WeightedObjective;
369        let pipeline = WeightedObjective::<NoteCandidate>::new()
370            .add(Box::new(RrfFusionObjective), relevance_weight)
371            .add(
372                Box::new(AmplifiedDecayAwareSalienceObjective::new(salience_alpha)),
373                salience_weight,
374            )
375            .add(
376                Box::new(TemporalRecencyObjective { half_life_days }),
377                temporal_weight,
378            );
379        Self { pipeline }
380    }
381
382    /// Build a pipeline using the standard memory recall defaults.
383    ///
384    /// Weights: relevance=0.70, salience=0.20, temporal=0.10; half_life=30 days; alpha=1.5.
385    pub fn default_memory() -> Self {
386        Self::new(0.70, 0.20, 0.10, 30.0, 1.5)
387    }
388
389    /// Score a `NoteCandidate` through the pipeline.
390    ///
391    /// The result is in [0.0, 1.0]. The `NoteCandidate.rrf_score` field should
392    /// carry the pre-normalized relevance (output of `normalize_relevance` / `RrfFusionObjective`).
393    pub fn score(&self, candidate: &NoteCandidate) -> f64 {
394        let ctx = ObjectiveContext::new();
395        use khive_fold::objective::Objective;
396        self.pipeline.score(candidate, &ctx).clamp(0.0, 1.0)
397    }
398}
399
400// ────────────────────────────────────────────────────────────────────────────
401
402// Kept inline: these tests exercise internal NoteCandidate fields that would
403// otherwise need to be made pub just to reach them from tests/.
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use khive_fold::objective::{Objective, ObjectiveContext};
408    use khive_fold::WeightedObjective;
409    use uuid::Uuid;
410
411    fn ctx() -> ObjectiveContext {
412        ObjectiveContext::new()
413    }
414
415    fn candidate(
416        vector: Option<f64>,
417        text: Option<f64>,
418        dist: Option<u32>,
419        rrf: Option<f64>,
420    ) -> RetrievalCandidate {
421        RetrievalCandidate {
422            id: Uuid::new_v4(),
423            vector_score: vector,
424            text_score: text,
425            graph_distance: dist,
426            rrf_score: rrf,
427        }
428    }
429
430    fn note_candidate(
431        rrf: Option<f64>,
432        salience: f64,
433        decay_factor: f64,
434        age_days: f64,
435    ) -> NoteCandidate {
436        // Mirrors the caller-side DecayModel::apply() default (Exponential) for test data.
437        let effective_salience = salience * (-decay_factor * age_days).exp();
438        NoteCandidate {
439            id: Uuid::new_v4(),
440            rrf_score: rrf,
441            salience,
442            decay_factor,
443            age_days,
444            effective_salience,
445            rerank_scores: HashMap::new(),
446        }
447    }
448
449    // ── VectorSimilarityObjective ────────────────────────────────────────
450
451    #[test]
452    fn vector_present_returns_signal() {
453        let c = candidate(Some(0.85), None, None, None);
454        let score = VectorSimilarityObjective.score(&c, &ctx());
455        assert!((score - 0.85).abs() < 1e-12);
456    }
457
458    #[test]
459    fn vector_absent_returns_zero() {
460        let c = candidate(None, None, None, None);
461        assert_eq!(VectorSimilarityObjective.score(&c, &ctx()), 0.0);
462    }
463
464    #[test]
465    fn vector_zero_score_returns_zero() {
466        let c = candidate(Some(0.0), None, None, None);
467        assert_eq!(VectorSimilarityObjective.score(&c, &ctx()), 0.0);
468    }
469
470    // ── TextRelevanceObjective ───────────────────────────────────────────
471
472    #[test]
473    fn text_present_returns_signal() {
474        let c = candidate(None, Some(0.6), None, None);
475        let score = TextRelevanceObjective.score(&c, &ctx());
476        assert!((score - 0.6).abs() < 1e-12);
477    }
478
479    #[test]
480    fn text_absent_returns_zero() {
481        let c = candidate(None, None, None, None);
482        assert_eq!(TextRelevanceObjective.score(&c, &ctx()), 0.0);
483    }
484
485    // ── GraphProximityObjective ──────────────────────────────────────────
486
487    #[test]
488    fn graph_anchor_hit_scores_one() {
489        let c = candidate(None, None, Some(0), None);
490        let obj = GraphProximityObjective { max_distance: 3 };
491        assert!((obj.score(&c, &ctx()) - 1.0).abs() < 1e-12);
492    }
493
494    #[test]
495    fn graph_midpoint_scores_half() {
496        let c = candidate(None, None, Some(1), None);
497        let obj = GraphProximityObjective { max_distance: 2 };
498        assert!((obj.score(&c, &ctx()) - 0.5).abs() < 1e-12);
499    }
500
501    #[test]
502    fn graph_at_boundary_scores_zero() {
503        let c = candidate(None, None, Some(3), None);
504        let obj = GraphProximityObjective { max_distance: 3 };
505        assert_eq!(obj.score(&c, &ctx()), 0.0);
506    }
507
508    #[test]
509    fn graph_beyond_boundary_scores_zero() {
510        let c = candidate(None, None, Some(10), None);
511        let obj = GraphProximityObjective { max_distance: 3 };
512        assert_eq!(obj.score(&c, &ctx()), 0.0);
513    }
514
515    #[test]
516    fn graph_absent_scores_zero() {
517        let c = candidate(None, None, None, None);
518        let obj = GraphProximityObjective { max_distance: 3 };
519        assert_eq!(obj.score(&c, &ctx()), 0.0);
520    }
521
522    #[test]
523    fn graph_max_distance_zero_always_scores_zero() {
524        // Guards the divide-by-zero case: max_distance=0 must not panic.
525        let c = candidate(None, None, Some(0), None);
526        let obj = GraphProximityObjective { max_distance: 0 };
527        assert_eq!(obj.score(&c, &ctx()), 0.0);
528    }
529
530    // ── RrfFusionObjective ───────────────────────────────────────────────
531
532    #[test]
533    fn rrf_present_returns_signal() {
534        let c = candidate(None, None, None, Some(0.0327));
535        let score = RrfFusionObjective.score(&c, &ctx());
536        assert!((score - 0.0327).abs() < 1e-12);
537    }
538
539    #[test]
540    fn rrf_absent_returns_zero() {
541        let c = candidate(None, None, None, None);
542        assert_eq!(RrfFusionObjective.score(&c, &ctx()), 0.0);
543    }
544
545    // ── WeightedObjective composition ───────────────────────────────────
546
547    #[test]
548    fn weighted_composition_vector_and_text() {
549        let c = candidate(Some(0.8), Some(0.6), None, None);
550
551        let obj = WeightedObjective::<RetrievalCandidate>::new()
552            .add(Box::new(VectorSimilarityObjective), 0.5)
553            .add(Box::new(TextRelevanceObjective), 0.5);
554
555        let score = obj.score(&c, &ctx());
556        // Weights here already sum to 1.0, so normalization is a no-op.
557        assert!((score - 0.7).abs() < 1e-12);
558    }
559
560    #[test]
561    fn weighted_composition_with_graph() {
562        let c = candidate(Some(1.0), Some(0.0), Some(1), None);
563
564        let obj = WeightedObjective::<RetrievalCandidate>::new()
565            .add(Box::new(VectorSimilarityObjective), 0.4)
566            .add(Box::new(TextRelevanceObjective), 0.3)
567            .add(Box::new(GraphProximityObjective { max_distance: 4 }), 0.3);
568
569        let score = obj.score(&c, &ctx());
570        assert!((score - 0.625).abs() < 1e-12);
571    }
572
573    #[test]
574    fn weighted_all_absent_returns_zero() {
575        let c = candidate(None, None, None, None);
576
577        let obj = WeightedObjective::<RetrievalCandidate>::new()
578            .add(Box::new(VectorSimilarityObjective), 0.5)
579            .add(Box::new(TextRelevanceObjective), 0.5);
580
581        // 0.0 * 0.5 + 0.0 * 0.5 = 0.0
582        assert_eq!(obj.score(&c, &ctx()), 0.0);
583    }
584
585    // ── HasId ────────────────────────────────────────────────────────────
586
587    #[test]
588    fn has_id_returns_candidate_uuid() {
589        let id = Uuid::new_v4();
590        let c = RetrievalCandidate {
591            id,
592            vector_score: None,
593            text_score: None,
594            graph_distance: None,
595            rrf_score: None,
596        };
597        assert_eq!(c.id(), id);
598    }
599
600    // ── select_top via DeterministicObjective ────────────────────────────
601
602    #[test]
603    fn select_top_orders_by_vector_score() {
604        use khive_fold::DeterministicObjective;
605
606        let candidates = vec![
607            candidate(Some(0.3), None, None, None),
608            candidate(Some(0.9), None, None, None),
609            candidate(Some(0.6), None, None, None),
610        ];
611
612        let top = VectorSimilarityObjective.select_top_deterministic(&candidates, 2, &ctx());
613
614        assert_eq!(top.len(), 2);
615        assert!((top[0].score - 0.9).abs() < 1e-12);
616        assert!((top[1].score - 0.6).abs() < 1e-12);
617    }
618
619    // ── NoteCandidate: HasId ─────────────────────────────────────────────
620
621    #[test]
622    fn note_candidate_has_id_returns_uuid() {
623        let id = Uuid::new_v4();
624        let c = NoteCandidate {
625            id,
626            rrf_score: None,
627            salience: 0.5,
628            decay_factor: 0.01,
629            age_days: 0.0,
630            effective_salience: 0.5,
631            rerank_scores: HashMap::new(),
632        };
633        assert_eq!(c.id(), id);
634    }
635
636    // ── DecayAwareSalienceObjective ──────────────────────────────────────
637
638    #[test]
639    fn decay_aware_zero_age_returns_full_salience() {
640        let obj = DecayAwareSalienceObjective::new(0.01);
641        let c = note_candidate(None, 0.8, 0.01, 0.0);
642        let score = obj.score(&c, &ctx());
643        assert!((score - 0.8).abs() < 1e-12, "got {score}");
644    }
645
646    #[test]
647    fn decay_aware_uses_note_decay_factor_not_field() {
648        // Scoring uses the note's own decay_factor, not the objective's field.
649        let obj = DecayAwareSalienceObjective::new(0.99); // obj.decay_rate ignored
650        let c = note_candidate(None, 1.0, 0.01, 100.0);
651        let score = obj.score(&c, &ctx());
652        let expected = (-0.01_f64 * 100.0).exp();
653        assert!(
654            (score - expected).abs() < 1e-12,
655            "got {score}, expected {expected}"
656        );
657    }
658
659    #[test]
660    fn decay_aware_high_decay_reduces_score_faster() {
661        let obj = DecayAwareSalienceObjective::new(0.0);
662        let slow = note_candidate(None, 1.0, 0.001, 100.0);
663        let fast = note_candidate(None, 1.0, 0.1, 100.0);
664        let score_slow = obj.score(&slow, &ctx());
665        let score_fast = obj.score(&fast, &ctx());
666        assert!(
667            score_slow > score_fast,
668            "slow decay should score higher: {score_slow} vs {score_fast}"
669        );
670    }
671
672    // ── TemporalRecencyObjective ─────────────────────────────────────────
673
674    #[test]
675    fn temporal_score_one_at_zero_age() {
676        let obj = TemporalRecencyObjective {
677            half_life_days: 30.0,
678        };
679        let c = note_candidate(None, 0.5, 0.01, 0.0);
680        let score = obj.score(&c, &ctx());
681        assert!((score - 1.0).abs() < 1e-12, "got {score}");
682    }
683
684    #[test]
685    fn temporal_score_half_at_half_life() {
686        let half_life = 30.0;
687        let obj = TemporalRecencyObjective {
688            half_life_days: half_life,
689        };
690        let c = note_candidate(None, 0.5, 0.01, half_life);
691        let score = obj.score(&c, &ctx());
692        assert!(
693            (score - 0.5).abs() < 1e-10,
694            "expected 0.5 at half_life, got {score}"
695        );
696    }
697
698    #[test]
699    fn temporal_score_decreases_with_age() {
700        let obj = TemporalRecencyObjective {
701            half_life_days: 30.0,
702        };
703        let young = note_candidate(None, 1.0, 0.01, 10.0);
704        let old = note_candidate(None, 1.0, 0.01, 100.0);
705        let score_young = obj.score(&young, &ctx());
706        let score_old = obj.score(&old, &ctx());
707        assert!(
708            score_young > score_old,
709            "younger note should score higher: {score_young} vs {score_old}"
710        );
711    }
712
713    // ── RerankerObjective ────────────────────────────────────────────────
714
715    #[test]
716    fn reranker_returns_named_score() {
717        let mut c = note_candidate(None, 0.5, 0.01, 0.0);
718        c.rerank_scores.insert("cross_encoder".to_string(), 0.9);
719        let obj = RerankerObjective::new("cross_encoder");
720        let score = obj.score(&c, &ctx());
721        assert!((score - 0.9).abs() < 1e-12, "got {score}");
722    }
723
724    #[test]
725    fn reranker_absent_key_returns_zero() {
726        let c = note_candidate(None, 0.5, 0.01, 0.0);
727        let obj = RerankerObjective::new("cross_encoder");
728        let score = obj.score(&c, &ctx());
729        assert_eq!(score, 0.0);
730    }
731
732    #[test]
733    fn reranker_different_keys_independent() {
734        let mut c = note_candidate(None, 0.5, 0.01, 0.0);
735        c.rerank_scores.insert("salience".to_string(), 0.7);
736        let obj_ce = RerankerObjective::new("cross_encoder");
737        let obj_sal = RerankerObjective::new("salience");
738        assert_eq!(obj_ce.score(&c, &ctx()), 0.0);
739        assert!((obj_sal.score(&c, &ctx()) - 0.7).abs() < 1e-12);
740    }
741
742    // ── Weighted composition of memory objectives ────────────────────────
743
744    #[test]
745    fn memory_pipeline_weighted_composition() {
746        // Verifies WeightedObjective reproduces the same formula MemoryRecallPipeline builds.
747        let c = NoteCandidate {
748            id: Uuid::new_v4(),
749            rrf_score: Some(0.5),
750            salience: 0.8,
751            decay_factor: 0.01,
752            age_days: 0.0,
753            effective_salience: 0.8,
754            rerank_scores: HashMap::new(),
755        };
756        let pipeline = WeightedObjective::<NoteCandidate>::new()
757            .add(Box::new(RrfFusionObjective), 0.70)
758            .add(Box::new(DecayAwareSalienceObjective::new(0.0)), 0.20)
759            .add(
760                Box::new(TemporalRecencyObjective {
761                    half_life_days: 30.0,
762                }),
763                0.10,
764            );
765        let score = pipeline.score(&c, &ctx());
766        assert!((score - 0.61).abs() < 1e-10, "got {score}");
767    }
768}