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;