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