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;
13use khive_score::Decay;
14
15/// Pre-computed retrieval signals for a single candidate entity.
16///
17/// All fields are `Option` — a missing signal scores 0.0. The runtime layer
18/// is responsible for populating whichever fields are available before handing
19/// the slice to an objective.
20#[derive(Debug, Clone)]
21pub struct RetrievalCandidate {
22    /// Stable entity UUID.
23    pub id: Uuid,
24    /// Cosine similarity to the query vector (0.0–1.0).
25    pub vector_score: Option<f64>,
26    /// BM25/FTS relevance score (0.0–1.0 normalised, or raw rank score).
27    pub text_score: Option<f64>,
28    /// Hop distance from the nearest anchor node (0 = anchor itself).
29    pub graph_distance: Option<u32>,
30    /// Pre-fused RRF score from `FusionStrategy::Rrf`.
31    pub rrf_score: Option<f64>,
32}
33
34impl HasId for RetrievalCandidate {
35    #[inline]
36    fn id(&self) -> Uuid {
37        self.id
38    }
39}
40
41// ── VectorSimilarityObjective ────────────────────────────────────────────────
42
43/// Scores a candidate by cosine similarity to the query vector.
44///
45/// Returns `vector_score` unchanged, or 0.0 when the field is absent.
46pub struct VectorSimilarityObjective;
47
48impl Objective<RetrievalCandidate> for VectorSimilarityObjective {
49    #[inline]
50    fn score(&self, candidate: &RetrievalCandidate, _context: &ObjectiveContext) -> f64 {
51        candidate.vector_score.unwrap_or(0.0)
52    }
53
54    fn name(&self) -> &str {
55        "VectorSimilarityObjective"
56    }
57}
58
59// ── TextRelevanceObjective ───────────────────────────────────────────────────
60
61/// Scores a candidate by BM25/FTS relevance.
62///
63/// Returns `text_score` unchanged, or 0.0 when the field is absent.
64pub struct TextRelevanceObjective;
65
66impl Objective<RetrievalCandidate> for TextRelevanceObjective {
67    #[inline]
68    fn score(&self, candidate: &RetrievalCandidate, _context: &ObjectiveContext) -> f64 {
69        candidate.text_score.unwrap_or(0.0)
70    }
71
72    fn name(&self) -> &str {
73        "TextRelevanceObjective"
74    }
75}
76
77// ── GraphProximityObjective ──────────────────────────────────────────────────
78
79/// Scores a candidate by graph proximity to anchor nodes.
80///
81/// Score formula (linear decay):
82///
83/// ```text
84/// d ≤ max_distance → score = 1.0 − (d as f64 / max_distance as f64)
85/// d > max_distance → score = 0.0
86/// missing          → score = 0.0
87/// ```
88///
89/// Direct anchor hits (d = 0) score 1.0. The boundary `d == max_distance`
90/// scores 0.0; anything beyond also scores 0.0.
91pub struct GraphProximityObjective {
92    /// Maximum hop distance to consider. Candidates beyond this score 0.0.
93    pub max_distance: u32,
94}
95
96impl Objective<RetrievalCandidate> for GraphProximityObjective {
97    fn score(&self, candidate: &RetrievalCandidate, _context: &ObjectiveContext) -> f64 {
98        let d = match candidate.graph_distance {
99            Some(d) => d,
100            None => return 0.0,
101        };
102        if self.max_distance == 0 || d >= self.max_distance {
103            return 0.0;
104        }
105        1.0 - (d as f64 / self.max_distance as f64)
106    }
107
108    fn name(&self) -> &str {
109        "GraphProximityObjective"
110    }
111}
112
113// ── RrfFusionObjective ───────────────────────────────────────────────────────
114
115/// Scores a candidate by its pre-computed RRF fusion score.
116///
117/// Returns `rrf_score` unchanged, or 0.0 when the field is absent.
118/// Implements `Objective` for both `RetrievalCandidate` and `NoteCandidate`
119/// so the same objective can be used in the general retrieval pipeline
120/// and the memory recall pipeline.
121pub struct RrfFusionObjective;
122
123impl Objective<RetrievalCandidate> for RrfFusionObjective {
124    #[inline]
125    fn score(&self, candidate: &RetrievalCandidate, _context: &ObjectiveContext) -> f64 {
126        candidate.rrf_score.unwrap_or(0.0)
127    }
128
129    fn name(&self) -> &str {
130        "RrfFusionObjective"
131    }
132}
133
134impl Objective<NoteCandidate> for RrfFusionObjective {
135    #[inline]
136    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
137        candidate.rrf_score.unwrap_or(0.0)
138    }
139
140    fn name(&self) -> &str {
141        "RrfFusionObjective"
142    }
143}
144
145// ── Memory-Recall Objectives ──────────────────────────────────────────────────
146
147/// Pre-computed signals for a single memory note candidate.
148///
149/// Used by the recall pipeline to score and rank candidates via
150/// `AmplifiedDecayAwareSalienceObjective`, `TemporalRecencyObjective`, and
151/// `RerankerObjective` without any IO. The runtime layer populates this struct
152/// from stored notes before handing the slice to the pipeline.
153#[derive(Debug, Clone)]
154pub struct NoteCandidate {
155    /// Stable note UUID.
156    pub id: Uuid,
157    /// Pre-fused RRF score from the retrieval stage (0.0–1.0).
158    pub rrf_score: Option<f64>,
159    /// Raw salience stored on the note (0.0–1.0).
160    pub salience: f64,
161    /// Per-note exponential decay rate (>= 0.0).
162    pub decay_factor: f64,
163    /// Age of the note in days at query time.
164    pub age_days: f64,
165    /// Salience after applying the configured `DecayModel` (pre-computed by the caller).
166    ///
167    /// The caller must set this to `DecayModel::apply(salience, age_days, decay_factor, half_life)`
168    /// so that objectives respect the configured decay model variant rather than
169    /// always applying exponential decay. When not set, defaults to 0.0.
170    pub effective_salience: f64,
171    /// Per-reranker scores populated by the rerank stage.
172    /// Keyed by reranker name (e.g. "cross_encoder", "salience", "graph_proximity").
173    pub rerank_scores: HashMap<String, f64>,
174}
175
176impl HasId for NoteCandidate {
177    #[inline]
178    fn id(&self) -> Uuid {
179        self.id
180    }
181}
182
183// ── DecayAwareSalienceObjective ──────────────────────────────────────────────
184
185#[inline]
186fn exponential_decay(value: f64, rate: f64, age_days: f64) -> f64 {
187    // Preserve unchecked public candidates outside the shared decay domain.
188    match Decay::exponential(rate).and_then(|decay| decay.apply(value, age_days)) {
189        Ok(score) if age_days >= 0.0 => score,
190        _ => value * (-rate * age_days).exp(),
191    }
192}
193
194/// Scores a `NoteCandidate` by salience with configurable temporal decay.
195///
196/// Uses the fixed exponential rate supplied at construction:
197/// `salience * exp(-decay_rate * age_days)`. It does not read the candidate's
198/// per-note `decay_factor`. The memory recall pipeline applies its configured
199/// `DecayModel` before scoring and uses `AmplifiedDecayAwareSalienceObjective`
200/// over the resulting `effective_salience` instead.
201///
202/// This objective remains useful for callers that want one decay policy across
203/// every candidate in a `WeightedObjective` composition.
204pub struct DecayAwareSalienceObjective {
205    decay_rate: f64,
206}
207
208impl DecayAwareSalienceObjective {
209    /// Create a new objective with the given exponential decay rate. Panics if
210    /// `decay_rate` is negative or non-finite.
211    ///
212    /// `decay_rate = 0.01` gives a ~69-day half-life (default for memory notes).
213    pub fn new(decay_rate: f64) -> Self {
214        assert!(
215            decay_rate.is_finite() && decay_rate >= 0.0,
216            "decay_rate must be finite and non-negative, got {decay_rate}"
217        );
218        Self { decay_rate }
219    }
220
221    /// Default memory decay rate: 0.01 (~69-day half-life).
222    pub fn default_memory() -> Self {
223        Self::new(0.01)
224    }
225}
226
227impl Objective<NoteCandidate> for DecayAwareSalienceObjective {
228    #[inline]
229    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
230        exponential_decay(candidate.salience, self.decay_rate, candidate.age_days)
231    }
232
233    fn name(&self) -> &str {
234        "DecayAwareSalienceObjective"
235    }
236}
237
238// ── AmplifiedDecayAwareSalienceObjective ─────────────────────────────────────
239
240/// Scores a `NoteCandidate` by salience with exponential decay and a non-linear
241/// amplification exponent applied after decay.
242///
243/// Formula: `(salience * exp(-decay_factor * age_days)) ^ alpha`
244///
245/// With `alpha > 1.0`, high-salience memories rank more clearly above low-salience
246/// ones when relevance is similar. At `alpha = 1.5` (the memory-recall default),
247/// salience 0.9 → 0.854 and salience 0.3 → 0.164 — a ~5.2× spread vs the ~3× linear
248/// spread. Keep `alpha ≤ 2.0`; values above 2 compress near-zero salience toward 0.
249///
250/// Used by the memory recall pipeline to make salience a meaningful tiebreaker
251/// without dominating relevance at the default weight of 0.20.
252pub struct AmplifiedDecayAwareSalienceObjective {
253    /// Power applied to the decayed salience value. Must be > 0.
254    pub alpha: f64,
255}
256
257impl AmplifiedDecayAwareSalienceObjective {
258    /// Create with the given amplification exponent.
259    pub fn new(alpha: f64) -> Self {
260        Self { alpha }
261    }
262
263    /// Default memory alpha from the memory recall handler: 1.5.
264    pub fn default_memory() -> Self {
265        Self::new(1.5)
266    }
267}
268
269impl Objective<NoteCandidate> for AmplifiedDecayAwareSalienceObjective {
270    #[inline]
271    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
272        // effective_salience is pre-computed via the caller's DecayModel, so this
273        // works for all decay model variants, not just exponential.
274        candidate.effective_salience.powf(self.alpha)
275    }
276
277    fn name(&self) -> &str {
278        "AmplifiedDecayAwareSalienceObjective"
279    }
280}
281
282// ── TemporalRecencyObjective ─────────────────────────────────────────────────
283
284/// Scores a `NoteCandidate` by pure temporal recency with a configurable half-life.
285///
286/// Formula: `exp(-ln(2) / half_life_days * age_days)`
287///
288/// At `age_days = 0` → score 1.0 (brand new note).
289/// At `age_days = half_life_days` → score 0.5.
290///
291/// Complements `DecayAwareSalienceObjective`: this signal rewards freshness
292/// independently of the note's own decay rate.
293pub struct TemporalRecencyObjective {
294    /// Number of days for the recency score to halve. Must be > 0.
295    pub half_life_days: f64,
296}
297
298impl TemporalRecencyObjective {
299    /// Create with the default temporal half-life of 30 days.
300    pub fn default_memory() -> Self {
301        Self {
302            half_life_days: 30.0,
303        }
304    }
305}
306
307impl Objective<NoteCandidate> for TemporalRecencyObjective {
308    #[inline]
309    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
310        let k = std::f64::consts::LN_2 / self.half_life_days.max(f64::EPSILON);
311        exponential_decay(1.0, k, candidate.age_days)
312    }
313
314    fn name(&self) -> &str {
315        "TemporalRecencyObjective"
316    }
317}
318
319// ── RerankerObjective ────────────────────────────────────────────────────────
320
321/// Scores a `NoteCandidate` using a named reranker's pre-computed score.
322///
323/// Looks up `candidate.rerank_scores[reranker_name]`. Returns 0.0 when the
324/// reranker was not run (key absent) — callers should gate on
325/// `RecallConfig.reranker_weights[name] > 0.0` before including this objective
326/// in a `WeightedObjective` composition.
327pub struct RerankerObjective {
328    /// Name of the reranker to look up in `candidate.rerank_scores`.
329    pub reranker_name: String,
330}
331
332impl RerankerObjective {
333    /// Create a new objective for the named reranker.
334    pub fn new(name: impl Into<String>) -> Self {
335        Self {
336            reranker_name: name.into(),
337        }
338    }
339}
340
341impl Objective<NoteCandidate> for RerankerObjective {
342    #[inline]
343    fn score(&self, candidate: &NoteCandidate, _context: &ObjectiveContext) -> f64 {
344        candidate
345            .rerank_scores
346            .get(&self.reranker_name)
347            .copied()
348            .unwrap_or(0.0)
349    }
350
351    fn name(&self) -> &str {
352        "RerankerObjective"
353    }
354}
355
356// ── MemoryRecallPipeline ──────────────────────────────────────────────────────
357
358/// Composable scoring pipeline for memory recall candidates.
359///
360/// Wraps a `WeightedObjective<NoteCandidate>` with the three standard memory
361/// scoring components (RRF relevance, amplified salience, temporal recency)
362/// weighted by the recall config parameters. Pack code uses this type to avoid
363/// a direct dependency on `khive-fold`.
364pub struct MemoryRecallPipeline {
365    pipeline: khive_fold::WeightedObjective<NoteCandidate>,
366}
367
368impl MemoryRecallPipeline {
369    /// Build a pipeline from explicit component weights and temporal half-life.
370    ///
371    /// `relevance_weight`, `salience_weight`, `temporal_weight` correspond to
372    /// `RecallConfig`'s three weight fields. `half_life_days` drives
373    /// `TemporalRecencyObjective`. `salience_alpha` is the amplification exponent
374    /// for `AmplifiedDecayAwareSalienceObjective` (default 1.5).
375    pub fn new(
376        relevance_weight: f64,
377        salience_weight: f64,
378        temporal_weight: f64,
379        half_life_days: f64,
380        salience_alpha: f64,
381    ) -> Self {
382        use khive_fold::WeightedObjective;
383        let pipeline = WeightedObjective::<NoteCandidate>::new()
384            .add(Box::new(RrfFusionObjective), relevance_weight)
385            .add(
386                Box::new(AmplifiedDecayAwareSalienceObjective::new(salience_alpha)),
387                salience_weight,
388            )
389            .add(
390                Box::new(TemporalRecencyObjective { half_life_days }),
391                temporal_weight,
392            );
393        Self { pipeline }
394    }
395
396    /// Build a pipeline using the standard memory recall defaults.
397    ///
398    /// Weights: relevance=0.70, salience=0.20, temporal=0.10; half_life=30 days; alpha=1.5.
399    pub fn default_memory() -> Self {
400        Self::new(0.70, 0.20, 0.10, 30.0, 1.5)
401    }
402
403    /// Score a `NoteCandidate` through the pipeline.
404    ///
405    /// The result is in [0.0, 1.0]. The `NoteCandidate.rrf_score` field should
406    /// carry the pre-normalized relevance (output of `normalize_relevance` / `RrfFusionObjective`).
407    pub fn score(&self, candidate: &NoteCandidate) -> f64 {
408        let ctx = ObjectiveContext::new();
409        use khive_fold::objective::Objective;
410        self.pipeline.score(candidate, &ctx).clamp(0.0, 1.0)
411    }
412}
413
414// ────────────────────────────────────────────────────────────────────────────
415
416// Kept inline: these tests exercise internal NoteCandidate fields that would
417// otherwise need to be made pub just to reach them from tests/.
418#[cfg(test)]
419#[path = "objectives_tests.rs"]
420mod tests;