Skip to main content

khive_pack_memory/
config.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use khive_runtime::{FusionStrategy, RuntimeError};
6
7/// Configuration for the recall scoring pipeline.
8/// All fields have sensible defaults matching current behavior.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct RecallConfig {
12    // --- Fusion weights ---
13    /// Weight of RRF/fusion score. Default 0.70.
14    pub relevance_weight: f64,
15    /// Weight of decay-adjusted salience. Default 0.20.
16    pub importance_weight: f64,
17    /// Weight of pure recency. Default 0.10.
18    pub temporal_weight: f64,
19
20    // --- Reranker weights (ADR-033 §1) ---
21    /// Per-reranker weights, keyed by reranker name. Missing keys → 0.0 (disabled).
22    /// v1 built-in names: "cross_encoder", "salience", "graph_proximity".
23    pub reranker_weights: HashMap<String, f64>,
24    /// Per-reranker config params (e.g., graph_proximity anchors, salience α).
25    pub reranker_params: HashMap<String, serde_json::Value>,
26
27    // --- Temporal parameters ---
28    /// Days for temporal score to halve. Default 30.0.
29    pub temporal_half_life_days: f64,
30    /// Decay model to apply to salience. Default Exponential.
31    pub decay_model: DecayModel,
32
33    // --- Retrieval parameters ---
34    /// Candidates per retrieval path before fusion = limit × this. Default 20.
35    pub candidate_multiplier: u32,
36    /// Explicit max candidates per retrieval path before fusion. When None,
37    /// candidate_multiplier keeps the legacy behavior.
38    pub candidate_limit: Option<u32>,
39    /// Strategy used to fuse retrieval-source candidate lists. Default RRF k=60.
40    pub fuse_strategy: FusionStrategy,
41    /// Minimum composite score to include in results. Default 0.0.
42    pub min_score: f64,
43    /// Minimum raw salience to include in results. Default 0.0.
44    pub min_salience: f64,
45    /// Include per-component score breakdowns in recall responses. Default false.
46    pub include_breakdown: bool,
47
48    // --- Migration behavior (ADR-033 §1, ADR-043) ---
49    /// When true and no active embedding model is configured, fall back to FTS5-only
50    /// candidate retrieval rather than failing. Default true.
51    pub fallback_during_migration: bool,
52}
53
54impl Default for RecallConfig {
55    fn default() -> Self {
56        Self {
57            relevance_weight: 0.70,
58            importance_weight: 0.20,
59            temporal_weight: 0.10,
60            reranker_weights: HashMap::new(),
61            reranker_params: HashMap::new(),
62            temporal_half_life_days: 30.0,
63            decay_model: DecayModel::default(),
64            candidate_multiplier: 20,
65            candidate_limit: None,
66            fuse_strategy: FusionStrategy::default(),
67            min_score: 0.0,
68            min_salience: 0.0,
69            include_breakdown: false,
70            fallback_during_migration: true,
71        }
72    }
73}
74
75impl RecallConfig {
76    /// Validate that the config is internally consistent.
77    ///
78    /// Rejects:
79    /// - Negative weights (base or reranker)
80    /// - All three base weights summing to zero (no scoring signal)
81    /// - Non-positive temporal half-life
82    pub fn validate(&self) -> Result<(), RuntimeError> {
83        if self.relevance_weight < 0.0 {
84            return Err(RuntimeError::InvalidInput(
85                "relevance_weight must be non-negative".to_string(),
86            ));
87        }
88        if self.importance_weight < 0.0 {
89            return Err(RuntimeError::InvalidInput(
90                "importance_weight must be non-negative".to_string(),
91            ));
92        }
93        if self.temporal_weight < 0.0 {
94            return Err(RuntimeError::InvalidInput(
95                "temporal_weight must be non-negative".to_string(),
96            ));
97        }
98        let weight_sum = self.relevance_weight + self.importance_weight + self.temporal_weight;
99        if weight_sum <= 0.0 {
100            return Err(RuntimeError::InvalidInput(
101                "at least one of relevance_weight / importance_weight / temporal_weight must be positive".to_string(),
102            ));
103        }
104        for (name, &weight) in &self.reranker_weights {
105            if weight < 0.0 {
106                return Err(RuntimeError::InvalidInput(format!(
107                    "reranker_weights[{name:?}] must be non-negative"
108                )));
109            }
110        }
111        if self.temporal_half_life_days <= 0.0 {
112            return Err(RuntimeError::InvalidInput(
113                "temporal_half_life_days must be positive".to_string(),
114            ));
115        }
116        if self.candidate_limit == Some(0) {
117            return Err(RuntimeError::InvalidInput(
118                "candidate_limit must be positive when provided".to_string(),
119            ));
120        }
121        if !self.min_score.is_finite() {
122            return Err(RuntimeError::InvalidInput(
123                "min_score must be finite".to_string(),
124            ));
125        }
126        if !self.min_salience.is_finite() {
127            return Err(RuntimeError::InvalidInput(
128                "min_salience must be finite".to_string(),
129            ));
130        }
131        Ok(())
132    }
133}
134
135/// How salience decays over time.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
137#[serde(rename_all = "snake_case")]
138pub enum DecayModel {
139    /// `salience * exp(-decay_factor * age_days)` — uses the note's own decay_factor directly.
140    ///
141    /// This is the ADR-021 §5 formula. The note's `decay_factor` controls the decay rate;
142    /// `temporal_half_life_days` is used only by the temporal recency score, not here.
143    /// Default `decay_factor=0.01` gives a ~69-day half-life: exp(-0.01 * 69.3) ≈ 0.5.
144    #[default]
145    Exponential,
146    /// `salience / (1 + decay_factor * age_days)`
147    Hyperbolic,
148    /// `salience * half_life / (half_life + age_days)`
149    PowerLaw {
150        /// Override half-life (days) for the power-law model.
151        /// Falls back to RecallConfig.temporal_half_life_days when absent.
152        half_life_days: f64,
153    },
154    /// No decay — salience is used as-is.
155    None,
156}
157
158impl DecayModel {
159    /// Apply decay to a salience value.
160    ///
161    /// - `salience`    — raw importance in [0, 1]
162    /// - `age_days`    — age of the note in days
163    /// - `decay_factor`— per-note decay rate stored on the note (used by Exponential and Hyperbolic)
164    /// - `half_life`   — config half-life, used only by PowerLaw (ignored by Exponential)
165    pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
166        match self {
167            DecayModel::Exponential => {
168                // ADR-021 §5: effective_importance = salience * exp(-decay_factor * age_days)
169                // Uses the note's own decay_factor, not a half-life-derived constant.
170                salience * (-decay_factor * age_days).exp()
171            }
172            DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
173            DecayModel::PowerLaw { half_life_days } => {
174                let hl = *half_life_days;
175                salience * hl / (hl + age_days)
176            }
177            DecayModel::None => salience,
178        }
179    }
180}
181
182/// Per-component score contributions for a single recall result.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ScoreBreakdown {
185    /// Raw RRF fusion score (before weighting).
186    pub relevance: f64,
187    /// Raw salience from the note (before decay).
188    pub importance_raw: f64,
189    /// Salience after applying the decay model.
190    pub importance_decayed: f64,
191    /// Temporal recency score (half-life decay, independent of note's own decay_factor).
192    pub temporal: f64,
193    /// Weighted contributions summing to the total score.
194    pub weighted: WeightedContributions,
195}
196
197impl ScoreBreakdown {
198    /// Total composite score.
199    pub fn total(&self) -> f64 {
200        self.weighted.relevance_contribution
201            + self.weighted.importance_contribution
202            + self.weighted.temporal_contribution
203    }
204}
205
206/// The three weighted components that make up the final score.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct WeightedContributions {
209    pub relevance_contribution: f64,
210    pub importance_contribution: f64,
211    pub temporal_contribution: f64,
212}
213
214// ── Tests ─────────────────────────────────────────────────────────────────────
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    // ── DecayModel ────────────────────────────────────────────────────────────
221
222    #[test]
223    fn exponential_halves_at_decay_factor_half_life() {
224        // ADR-021 §5 formula: salience * exp(-decay_factor * age_days)
225        // Half-life = ln(2) / decay_factor ≈ 69.3 days for decay_factor=0.01
226        let model = DecayModel::Exponential;
227        let salience = 1.0;
228        let decay_factor = 0.01;
229        let half_life_days = std::f64::consts::LN_2 / decay_factor;
230        let result = model.apply(salience, half_life_days, decay_factor, 30.0);
231        let diff = (result - 0.5).abs();
232        assert!(
233            diff < 1e-10,
234            "exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
235        );
236    }
237
238    #[test]
239    fn exponential_full_salience_at_zero_age() {
240        let model = DecayModel::Exponential;
241        let result = model.apply(0.8, 0.0, 0.01, 30.0);
242        let diff = (result - 0.8).abs();
243        assert!(
244            diff < 1e-12,
245            "at age=0 salience should be unchanged, got {result}"
246        );
247    }
248
249    #[test]
250    fn exponential_uses_note_decay_factor_not_half_life() {
251        // Verify the formula uses decay_factor param, not the half_life param.
252        // At age=1 day, decay_factor=1.0 → exp(-1.0) ≈ 0.3679.
253        // If we were using half_life=10 days, exp(-ln2/10) ≈ 0.933.
254        let model = DecayModel::Exponential;
255        let result = model.apply(1.0, 1.0, 1.0, 10.0);
256        let expected = (-1.0f64).exp();
257        assert!(
258            (result - expected).abs() < 1e-12,
259            "expected {expected}, got {result}"
260        );
261    }
262
263    #[test]
264    fn hyperbolic_halves_at_one_over_decay_factor() {
265        // salience / (1 + k * age) = 0.5 when age = 1/k
266        let model = DecayModel::Hyperbolic;
267        let salience = 1.0;
268        let k = 0.05;
269        let age = 1.0 / k; // 20 days
270        let result = model.apply(salience, age, k, 30.0);
271        let diff = (result - 0.5).abs();
272        assert!(
273            diff < 1e-10,
274            "hyperbolic at age=1/k should give 0.5, got {result}"
275        );
276    }
277
278    #[test]
279    fn hyperbolic_full_salience_at_zero_age() {
280        let model = DecayModel::Hyperbolic;
281        let result = model.apply(0.7, 0.0, 0.05, 30.0);
282        let diff = (result - 0.7).abs();
283        assert!(
284            diff < 1e-12,
285            "at age=0 salience should be unchanged, got {result}"
286        );
287    }
288
289    #[test]
290    fn powerlaw_halves_at_half_life() {
291        let hl = 30.0;
292        let model = DecayModel::PowerLaw { half_life_days: hl };
293        let salience = 1.0;
294        // salience * hl / (hl + age) = 0.5 when age = hl
295        let result = model.apply(salience, hl, 0.01, hl);
296        let diff = (result - 0.5).abs();
297        assert!(
298            diff < 1e-10,
299            "power-law should give 0.5 at half-life, got {result}"
300        );
301    }
302
303    #[test]
304    fn decay_none_returns_salience_unchanged() {
305        let model = DecayModel::None;
306        let result = model.apply(0.6, 100.0, 0.99, 30.0);
307        let diff = (result - 0.6).abs();
308        assert!(
309            diff < 1e-12,
310            "None model must not alter salience, got {result}"
311        );
312    }
313
314    // ── RecallConfig ──────────────────────────────────────────────────────────
315
316    #[test]
317    fn default_config_validates() {
318        assert!(RecallConfig::default().validate().is_ok());
319    }
320
321    #[test]
322    fn negative_relevance_weight_fails_validation() {
323        let cfg = RecallConfig {
324            relevance_weight: -0.1,
325            ..RecallConfig::default()
326        };
327        assert!(cfg.validate().is_err());
328    }
329
330    #[test]
331    fn negative_importance_weight_fails_validation() {
332        let cfg = RecallConfig {
333            importance_weight: -1.0,
334            ..RecallConfig::default()
335        };
336        assert!(cfg.validate().is_err());
337    }
338
339    #[test]
340    fn negative_temporal_weight_fails_validation() {
341        let cfg = RecallConfig {
342            temporal_weight: -0.5,
343            ..RecallConfig::default()
344        };
345        assert!(cfg.validate().is_err());
346    }
347
348    #[test]
349    fn all_zero_weights_fails_validation() {
350        let cfg = RecallConfig {
351            relevance_weight: 0.0,
352            importance_weight: 0.0,
353            temporal_weight: 0.0,
354            ..RecallConfig::default()
355        };
356        assert!(cfg.validate().is_err());
357    }
358
359    #[test]
360    fn zero_half_life_fails_validation() {
361        let cfg = RecallConfig {
362            temporal_half_life_days: 0.0,
363            ..RecallConfig::default()
364        };
365        assert!(cfg.validate().is_err());
366    }
367
368    #[test]
369    fn negative_half_life_fails_validation() {
370        let cfg = RecallConfig {
371            temporal_half_life_days: -5.0,
372            ..RecallConfig::default()
373        };
374        assert!(cfg.validate().is_err());
375    }
376
377    #[test]
378    fn non_uniform_weights_validate() {
379        let cfg = RecallConfig {
380            relevance_weight: 0.5,
381            importance_weight: 0.3,
382            temporal_weight: 0.2,
383            ..RecallConfig::default()
384        };
385        assert!(cfg.validate().is_ok());
386    }
387
388    // ── Serde roundtrips ──────────────────────────────────────────────────────
389
390    #[test]
391    fn default_config_roundtrip() {
392        let cfg = RecallConfig::default();
393        let json = serde_json::to_string(&cfg).expect("serialize");
394        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
395        let diff = (cfg.relevance_weight - back.relevance_weight).abs();
396        assert!(diff < 1e-12);
397        assert_eq!(cfg.decay_model, back.decay_model);
398    }
399
400    #[test]
401    fn decay_model_exponential_roundtrip() {
402        let m = DecayModel::Exponential;
403        let json = serde_json::to_string(&m).expect("serialize");
404        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
405        assert_eq!(m, back);
406    }
407
408    #[test]
409    fn decay_model_hyperbolic_roundtrip() {
410        let m = DecayModel::Hyperbolic;
411        let json = serde_json::to_string(&m).expect("serialize");
412        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
413        assert_eq!(m, back);
414    }
415
416    #[test]
417    fn decay_model_powerlaw_roundtrip() {
418        let m = DecayModel::PowerLaw {
419            half_life_days: 14.0,
420        };
421        let json = serde_json::to_string(&m).expect("serialize");
422        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
423        assert_eq!(m, back);
424    }
425
426    #[test]
427    fn decay_model_none_roundtrip() {
428        let m = DecayModel::None;
429        let json = serde_json::to_string(&m).expect("serialize");
430        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
431        assert_eq!(m, back);
432    }
433
434    #[test]
435    fn partial_config_deserializes_with_defaults() {
436        // Only override one field — the rest should default.
437        let json = r#"{"relevance_weight": 0.5}"#;
438        let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
439        // specified field
440        let diff = (cfg.relevance_weight - 0.5).abs();
441        assert!(diff < 1e-12);
442        // unspecified fields keep defaults
443        let diff2 = (cfg.importance_weight - 0.20).abs();
444        assert!(diff2 < 1e-12);
445        assert_eq!(cfg.decay_model, DecayModel::Exponential);
446    }
447
448    // ── RecallConfig new fields ───────────────────────────────────────────────
449
450    #[test]
451    fn new_fields_have_correct_defaults() {
452        let cfg = RecallConfig::default();
453        assert_eq!(cfg.candidate_limit, None);
454        assert_eq!(cfg.fuse_strategy, FusionStrategy::Rrf { k: 60 });
455        assert!(!cfg.include_breakdown);
456    }
457
458    #[test]
459    fn candidate_limit_zero_fails_validation() {
460        let cfg = RecallConfig {
461            candidate_limit: Some(0),
462            ..RecallConfig::default()
463        };
464        assert!(cfg.validate().is_err());
465    }
466
467    #[test]
468    fn candidate_limit_some_positive_validates() {
469        let cfg = RecallConfig {
470            candidate_limit: Some(100),
471            ..RecallConfig::default()
472        };
473        assert!(cfg.validate().is_ok());
474    }
475
476    #[test]
477    fn min_score_nan_fails_validation() {
478        let cfg = RecallConfig {
479            min_score: f64::NAN,
480            ..RecallConfig::default()
481        };
482        assert!(cfg.validate().is_err());
483    }
484
485    #[test]
486    fn min_salience_nan_fails_validation() {
487        let cfg = RecallConfig {
488            min_salience: f64::NAN,
489            ..RecallConfig::default()
490        };
491        assert!(cfg.validate().is_err());
492    }
493
494    #[test]
495    fn new_fields_roundtrip() {
496        let cfg = RecallConfig {
497            candidate_limit: Some(50),
498            fuse_strategy: FusionStrategy::Union,
499            include_breakdown: true,
500            ..RecallConfig::default()
501        };
502        let json = serde_json::to_string(&cfg).expect("serialize");
503        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
504        assert_eq!(back.candidate_limit, Some(50));
505        assert_eq!(back.fuse_strategy, FusionStrategy::Union);
506        assert!(back.include_breakdown);
507    }
508
509    #[test]
510    fn partial_config_new_fields_use_defaults() {
511        // Parse JSON that omits all new fields — they should fall back to defaults.
512        let json = r#"{"temporal_weight": 0.15}"#;
513        let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
514        assert_eq!(cfg.candidate_limit, None);
515        assert_eq!(cfg.fuse_strategy, FusionStrategy::Rrf { k: 60 });
516        assert!(!cfg.include_breakdown);
517    }
518
519    // ── ScoreBreakdown ────────────────────────────────────────────────────────
520
521    #[test]
522    fn score_breakdown_total_sums_contributions() {
523        let bd = ScoreBreakdown {
524            relevance: 0.5,
525            importance_raw: 0.8,
526            importance_decayed: 0.6,
527            temporal: 0.3,
528            weighted: WeightedContributions {
529                relevance_contribution: 0.35,
530                importance_contribution: 0.12,
531                temporal_contribution: 0.03,
532            },
533        };
534        let expected = 0.35 + 0.12 + 0.03;
535        let diff = (bd.total() - expected).abs();
536        assert!(
537            diff < 1e-12,
538            "total() should sum weighted contributions, got {}",
539            bd.total()
540        );
541    }
542}