Skip to main content

khive_pack_memory/
config.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use khive_fusion::FusionStrategy;
6use khive_runtime::RuntimeError;
7
8/// Error returned when `min_score` is outside the accepted dual-scale range.
9#[derive(Debug, Clone)]
10pub enum MinScoreError {
11    /// Value was NaN or Inf.
12    NotFinite,
13    /// Value was finite but outside `[0.0, 100.0]` (or negative).
14    OutOfRange(f64),
15}
16
17impl std::fmt::Display for MinScoreError {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            Self::NotFinite => write!(f, "min_score must be finite"),
21            Self::OutOfRange(v) => write!(
22                f,
23                "min_score {v} out of range: must be 0.0–1.0 (fraction) or 0–100 (percent)"
24            ),
25        }
26    }
27}
28
29impl From<MinScoreError> for RuntimeError {
30    fn from(e: MinScoreError) -> Self {
31        RuntimeError::InvalidInput(e.to_string())
32    }
33}
34
35/// Configuration for the recall scoring pipeline.
36/// All fields have sensible defaults matching current behavior.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(default)]
39pub struct RecallConfig {
40    // --- Fusion weights ---
41    /// Weight of RRF/fusion score. Default 0.70.
42    pub relevance_weight: f64,
43    /// Weight of decay-adjusted salience. Default 0.20.
44    pub salience_weight: f64,
45    /// Weight of pure recency. Default 0.10.
46    pub temporal_weight: f64,
47
48    // --- Reranker weights (ADR-033 §1) ---
49    /// Per-reranker weights, keyed by reranker name. Missing keys → 0.0 (disabled).
50    /// v1 built-in names: "cross_encoder", "salience", "graph_proximity".
51    pub reranker_weights: HashMap<String, f64>,
52
53    // --- Temporal parameters ---
54    /// Days for temporal score to halve. Default 30.0.
55    pub temporal_half_life_days: f64,
56    /// Decay model to apply to salience. Default Exponential.
57    pub decay_model: DecayModel,
58
59    // --- Retrieval parameters ---
60    /// Candidates per retrieval path before fusion = limit × this. Default 20.
61    pub candidate_multiplier: u32,
62    /// Explicit max candidates per retrieval path before fusion. When None,
63    /// candidate_multiplier keeps the legacy behavior.
64    pub candidate_limit: Option<u32>,
65    /// Strategy used to fuse retrieval-source candidate lists. Default RRF k=60.
66    pub fuse_strategy: FusionStrategy,
67    /// Minimum composite score to include in results. Default 0.0.
68    pub min_score: f64,
69    /// Minimum raw salience to include in results. Default 0.0.
70    pub min_salience: f64,
71    /// Include per-component score breakdowns in recall responses. Default false.
72    pub include_breakdown: bool,
73
74    // --- Archive scoring pipeline override ---
75    /// Optional full archive scoring config override. When provided, `handle_recall`
76    /// uses `calculate_score` + all archive pipeline features (MMR, supersedes
77    /// suppression, CJK routing, entity boost) instead of the legacy additive formula.
78    ///
79    /// `ScoringConfig::default()` is used when this is `None` and the caller provides
80    /// entity_names or sets `use_archive_scoring = true`.
81    pub scoring: Option<crate::scoring::ScoringConfig>,
82
83    // --- Brain profile integration (issue #484) ---
84    /// Optional brain profile hint for score boosting.
85    ///
86    /// When set, `handle_recall` fetches the named brain profile's entity
87    /// posteriors and applies a salience multiplier to results whose note id
88    /// or source entity appears in the profile's high-posterior set.
89    ///
90    /// Integration point: after `ranked` is populated, iterate results and
91    /// multiply `rank_score` by `brain_profile_boost` for IDs whose
92    /// Beta posterior mean exceeds `brain_profile_threshold`.
93    pub brain_profile: Option<BrainProfileHint>,
94}
95
96/// Hint for brain-profile-guided score boosting during recall (issue #484).
97///
98/// When a `brain_profile` hint is present in `RecallConfig`, the recall handler
99/// will apply `boost` as a rank-score multiplier to results whose associated
100/// entity posterior mean in the named brain profile exceeds `threshold`.
101///
102/// This ties the memory pack's retrieval scoring to the brain pack's Bayesian
103/// entity posteriors, allowing frequently-recalled or explicitly-marked-useful
104/// entities to surface more prominently.
105///
106/// # Wire shape
107/// ```json
108/// "brain_profile": {"profile_id": "balanced-recall-v1", "boost": 1.3, "threshold": 0.6}
109/// ```
110///
111/// # Integration status (issue #484)
112/// The configuration field is live; the runtime lookup requires a cross-pack
113/// call to `brain.profile` which is not yet wired at the handler level.
114/// TODO(#484): wire brain pack handle lookup into recall scoring loop.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct BrainProfileHint {
117    /// Profile ID to resolve. Passed to `brain.resolve` / `brain.profile`.
118    pub profile_id: String,
119    /// Score multiplier applied to matching results. Default 1.3×.
120    #[serde(default = "BrainProfileHint::default_boost")]
121    pub boost: f64,
122    /// Minimum Beta posterior mean required for a result to receive the boost.
123    /// Posterior mean = alpha / (alpha + beta). Default 0.6 (slightly above
124    /// the uniform prior mean of 0.5).
125    #[serde(default = "BrainProfileHint::default_threshold")]
126    pub threshold: f64,
127}
128
129impl BrainProfileHint {
130    fn default_boost() -> f64 {
131        1.3
132    }
133    fn default_threshold() -> f64 {
134        0.6
135    }
136}
137
138// Tuning artifact: tests/khive-contract/tune/ swept 116 configs but the synthetic corpus
139// produced an identical recall@10 = 0.9333 for every config — i.e. a flat landscape that
140// cannot empirically distinguish these parameters. Defaults below stay at the prior values
141// until a harder corpus (embed-enabled, synonym queries, partial matches) provides signal.
142// See tests/khive-contract/tune/REPORT.md for the analysis.
143//
144// CC-6: Default strategy changed from RRF to Weighted [0.7, 0.3].
145//
146// Under RRF with the default weights (relevance 70%, salience 20%, temporal 10%), a
147// salience=0.3 memory can rank above a salience=0.9 memory when its text/vector rank is
148// marginally better. The Weighted strategy gives full-resolution score values to both
149// retrieval paths, making the salience contribution a meaningful tiebreaker.
150// The RRF strategy remains available via `fusion_strategy="rrf"`.
151impl Default for RecallConfig {
152    fn default() -> Self {
153        Self {
154            relevance_weight: 0.70,
155            salience_weight: 0.20,
156            temporal_weight: 0.10,
157            reranker_weights: HashMap::new(),
158            temporal_half_life_days: 30.0,
159            decay_model: DecayModel::default(),
160            candidate_multiplier: 20,
161            candidate_limit: None,
162            // CC-6: Weighted fusion respects score magnitude, allowing the salience
163            // amplifier to meaningfully differentiate high- vs low-salience memories.
164            // Weights [vector=0.7, text=0.3] match the prior RRF intent: vector
165            // results are weighted higher because embedding search captures semantic
166            // similarity; text results supplement with keyword precision.
167            fuse_strategy: FusionStrategy::Weighted {
168                weights: vec![0.7, 0.3],
169            },
170            min_score: 0.0,
171            min_salience: 0.0,
172            include_breakdown: false,
173            scoring: None,
174            brain_profile: None,
175        }
176    }
177}
178
179impl RecallConfig {
180    /// Validate that the config is internally consistent.
181    ///
182    /// Rejects:
183    /// - Negative weights (base or reranker)
184    /// - All three base weights summing to zero (no scoring signal)
185    /// - Non-positive temporal half-life
186    pub fn validate(&self) -> Result<(), RuntimeError> {
187        if self.relevance_weight < 0.0 {
188            return Err(RuntimeError::InvalidInput(
189                "relevance_weight must be non-negative".to_string(),
190            ));
191        }
192        if self.salience_weight < 0.0 {
193            return Err(RuntimeError::InvalidInput(
194                "salience_weight must be non-negative".to_string(),
195            ));
196        }
197        if self.temporal_weight < 0.0 {
198            return Err(RuntimeError::InvalidInput(
199                "temporal_weight must be non-negative".to_string(),
200            ));
201        }
202        let weight_sum = self.relevance_weight + self.salience_weight + self.temporal_weight;
203        if weight_sum <= 0.0 {
204            return Err(RuntimeError::InvalidInput(
205                "at least one of relevance_weight / salience_weight / temporal_weight must be positive".to_string(),
206            ));
207        }
208        for (name, &weight) in &self.reranker_weights {
209            if weight < 0.0 {
210                return Err(RuntimeError::InvalidInput(format!(
211                    "reranker_weights[{name:?}] must be non-negative"
212                )));
213            }
214        }
215        if self.temporal_half_life_days <= 0.0 {
216            return Err(RuntimeError::InvalidInput(
217                "temporal_half_life_days must be positive".to_string(),
218            ));
219        }
220        if self.candidate_limit == Some(0) {
221            return Err(RuntimeError::InvalidInput(
222                "candidate_limit must be positive when provided".to_string(),
223            ));
224        }
225        if !self.min_score.is_finite() {
226            return Err(RuntimeError::InvalidInput(
227                "min_score must be finite".to_string(),
228            ));
229        }
230        if !self.min_salience.is_finite() {
231            return Err(RuntimeError::InvalidInput(
232                "min_salience must be finite".to_string(),
233            ));
234        }
235        Ok(())
236    }
237}
238
239/// How salience decays over time.
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
241#[serde(rename_all = "snake_case")]
242pub enum DecayModel {
243    /// `salience * exp(-decay_factor * age_days)` — uses the note's own decay_factor directly.
244    ///
245    /// This is the ADR-021 §5 formula. The note's `decay_factor` controls the decay rate;
246    /// `temporal_half_life_days` is used only by the temporal recency score, not here.
247    /// Default `decay_factor=0.01` gives a ~69-day half-life: exp(-0.01 * 69.3) ≈ 0.5.
248    #[default]
249    Exponential,
250    /// `salience / (1 + decay_factor * age_days)`
251    Hyperbolic,
252    /// `salience * half_life / (half_life + age_days)`
253    PowerLaw {
254        /// Override half-life (days) for the power-law model.
255        /// Falls back to RecallConfig.temporal_half_life_days when absent.
256        half_life_days: f64,
257    },
258    /// No decay — salience is used as-is.
259    None,
260}
261
262impl DecayModel {
263    /// Apply decay to a salience value.
264    ///
265    /// - `salience`    — raw salience in [0, 1]
266    /// - `age_days`    — age of the note in days
267    /// - `decay_factor`— per-note decay rate stored on the note (used by Exponential and Hyperbolic)
268    /// - `half_life`   — config half-life, used only by PowerLaw (ignored by Exponential)
269    pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
270        match self {
271            DecayModel::Exponential => {
272                // ADR-021 §5: effective_salience = salience * exp(-decay_factor * age_days)
273                // Uses the note's own decay_factor, not a half-life-derived constant.
274                salience * (-decay_factor * age_days).exp()
275            }
276            DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
277            DecayModel::PowerLaw { half_life_days } => {
278                let hl = *half_life_days;
279                salience * hl / (hl + age_days)
280            }
281            DecayModel::None => salience,
282        }
283    }
284}
285
286/// Per-component score contributions for a single recall result.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ScoreBreakdown {
289    /// Raw RRF fusion score (before weighting).
290    pub relevance: f64,
291    /// Raw salience from the note (before decay).
292    pub salience_raw: f64,
293    /// Salience after applying the decay model.
294    pub salience_decayed: f64,
295    /// Temporal recency score (half-life decay, independent of note's own decay_factor).
296    pub temporal: f64,
297    /// Weighted contributions summing to the total score.
298    pub weighted: WeightedContributions,
299}
300
301impl ScoreBreakdown {
302    /// Total composite score.
303    pub fn total(&self) -> f64 {
304        self.weighted.relevance_contribution
305            + self.weighted.salience_contribution
306            + self.weighted.temporal_contribution
307    }
308}
309
310/// The three weighted components that make up the final score.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct WeightedContributions {
313    pub relevance_contribution: f64,
314    pub salience_contribution: f64,
315    pub temporal_contribution: f64,
316}
317
318// ── Tests ─────────────────────────────────────────────────────────────────────
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    // ── DecayModel ────────────────────────────────────────────────────────────
325
326    #[test]
327    fn exponential_halves_at_decay_factor_half_life() {
328        // ADR-021 §5 formula: salience * exp(-decay_factor * age_days)
329        // Half-life = ln(2) / decay_factor ≈ 69.3 days for decay_factor=0.01
330        let model = DecayModel::Exponential;
331        let salience = 1.0;
332        let decay_factor = 0.01;
333        let half_life_days = std::f64::consts::LN_2 / decay_factor;
334        let result = model.apply(salience, half_life_days, decay_factor, 30.0);
335        let diff = (result - 0.5).abs();
336        assert!(
337            diff < 1e-10,
338            "exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
339        );
340    }
341
342    #[test]
343    fn exponential_full_salience_at_zero_age() {
344        let model = DecayModel::Exponential;
345        let result = model.apply(0.8, 0.0, 0.01, 30.0);
346        let diff = (result - 0.8).abs();
347        assert!(
348            diff < 1e-12,
349            "at age=0 salience should be unchanged, got {result}"
350        );
351    }
352
353    #[test]
354    fn exponential_uses_note_decay_factor_not_half_life() {
355        // Verify the formula uses decay_factor param, not the half_life param.
356        // At age=1 day, decay_factor=1.0 → exp(-1.0) ≈ 0.3679.
357        // If we were using half_life=10 days, exp(-ln2/10) ≈ 0.933.
358        let model = DecayModel::Exponential;
359        let result = model.apply(1.0, 1.0, 1.0, 10.0);
360        let expected = (-1.0f64).exp();
361        assert!(
362            (result - expected).abs() < 1e-12,
363            "expected {expected}, got {result}"
364        );
365    }
366
367    #[test]
368    fn hyperbolic_halves_at_one_over_decay_factor() {
369        // salience / (1 + k * age) = 0.5 when age = 1/k
370        let model = DecayModel::Hyperbolic;
371        let salience = 1.0;
372        let k = 0.05;
373        let age = 1.0 / k; // 20 days
374        let result = model.apply(salience, age, k, 30.0);
375        let diff = (result - 0.5).abs();
376        assert!(
377            diff < 1e-10,
378            "hyperbolic at age=1/k should give 0.5, got {result}"
379        );
380    }
381
382    #[test]
383    fn hyperbolic_full_salience_at_zero_age() {
384        let model = DecayModel::Hyperbolic;
385        let result = model.apply(0.7, 0.0, 0.05, 30.0);
386        let diff = (result - 0.7).abs();
387        assert!(
388            diff < 1e-12,
389            "at age=0 salience should be unchanged, got {result}"
390        );
391    }
392
393    #[test]
394    fn powerlaw_halves_at_half_life() {
395        let hl = 30.0;
396        let model = DecayModel::PowerLaw { half_life_days: hl };
397        let salience = 1.0;
398        // salience * hl / (hl + age) = 0.5 when age = hl
399        let result = model.apply(salience, hl, 0.01, hl);
400        let diff = (result - 0.5).abs();
401        assert!(
402            diff < 1e-10,
403            "power-law should give 0.5 at half-life, got {result}"
404        );
405    }
406
407    #[test]
408    fn decay_none_returns_salience_unchanged() {
409        let model = DecayModel::None;
410        let result = model.apply(0.6, 100.0, 0.99, 30.0);
411        let diff = (result - 0.6).abs();
412        assert!(
413            diff < 1e-12,
414            "None model must not alter salience, got {result}"
415        );
416    }
417
418    // ── RecallConfig ──────────────────────────────────────────────────────────
419
420    #[test]
421    fn default_config_validates() {
422        assert!(RecallConfig::default().validate().is_ok());
423    }
424
425    #[test]
426    fn negative_relevance_weight_fails_validation() {
427        let cfg = RecallConfig {
428            relevance_weight: -0.1,
429            ..RecallConfig::default()
430        };
431        assert!(cfg.validate().is_err());
432    }
433
434    #[test]
435    fn negative_salience_weight_fails_validation() {
436        let cfg = RecallConfig {
437            salience_weight: -1.0,
438            ..RecallConfig::default()
439        };
440        assert!(cfg.validate().is_err());
441    }
442
443    #[test]
444    fn negative_temporal_weight_fails_validation() {
445        let cfg = RecallConfig {
446            temporal_weight: -0.5,
447            ..RecallConfig::default()
448        };
449        assert!(cfg.validate().is_err());
450    }
451
452    #[test]
453    fn all_zero_weights_fails_validation() {
454        let cfg = RecallConfig {
455            relevance_weight: 0.0,
456            salience_weight: 0.0,
457            temporal_weight: 0.0,
458            ..RecallConfig::default()
459        };
460        assert!(cfg.validate().is_err());
461    }
462
463    #[test]
464    fn zero_half_life_fails_validation() {
465        let cfg = RecallConfig {
466            temporal_half_life_days: 0.0,
467            ..RecallConfig::default()
468        };
469        assert!(cfg.validate().is_err());
470    }
471
472    #[test]
473    fn negative_half_life_fails_validation() {
474        let cfg = RecallConfig {
475            temporal_half_life_days: -5.0,
476            ..RecallConfig::default()
477        };
478        assert!(cfg.validate().is_err());
479    }
480
481    #[test]
482    fn non_uniform_weights_validate() {
483        let cfg = RecallConfig {
484            relevance_weight: 0.5,
485            salience_weight: 0.3,
486            temporal_weight: 0.2,
487            ..RecallConfig::default()
488        };
489        assert!(cfg.validate().is_ok());
490    }
491
492    // ── Serde roundtrips ──────────────────────────────────────────────────────
493
494    #[test]
495    fn default_config_roundtrip() {
496        let cfg = RecallConfig::default();
497        let json = serde_json::to_string(&cfg).expect("serialize");
498        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
499        let diff = (cfg.relevance_weight - back.relevance_weight).abs();
500        assert!(diff < 1e-12);
501        assert_eq!(cfg.decay_model, back.decay_model);
502    }
503
504    #[test]
505    fn decay_model_exponential_roundtrip() {
506        let m = DecayModel::Exponential;
507        let json = serde_json::to_string(&m).expect("serialize");
508        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
509        assert_eq!(m, back);
510    }
511
512    #[test]
513    fn decay_model_hyperbolic_roundtrip() {
514        let m = DecayModel::Hyperbolic;
515        let json = serde_json::to_string(&m).expect("serialize");
516        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
517        assert_eq!(m, back);
518    }
519
520    #[test]
521    fn decay_model_powerlaw_roundtrip() {
522        let m = DecayModel::PowerLaw {
523            half_life_days: 14.0,
524        };
525        let json = serde_json::to_string(&m).expect("serialize");
526        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
527        assert_eq!(m, back);
528    }
529
530    #[test]
531    fn decay_model_none_roundtrip() {
532        let m = DecayModel::None;
533        let json = serde_json::to_string(&m).expect("serialize");
534        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
535        assert_eq!(m, back);
536    }
537
538    #[test]
539    fn partial_config_deserializes_with_defaults() {
540        // Only override one field — the rest should default.
541        let json = r#"{"relevance_weight": 0.5}"#;
542        let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
543        // specified field
544        let diff = (cfg.relevance_weight - 0.5).abs();
545        assert!(diff < 1e-12);
546        // unspecified fields keep defaults
547        let diff2 = (cfg.salience_weight - 0.20).abs();
548        assert!(diff2 < 1e-12);
549        assert_eq!(cfg.decay_model, DecayModel::Exponential);
550    }
551
552    // ── RecallConfig new fields ───────────────────────────────────────────────
553
554    #[test]
555    fn new_fields_have_correct_defaults() {
556        let cfg = RecallConfig::default();
557        assert_eq!(cfg.candidate_limit, None);
558        // CC-6: default changed to Weighted [0.7, 0.3] so salience can influence ranking
559        assert!(
560            matches!(
561                cfg.fuse_strategy,
562                FusionStrategy::Weighted { ref weights } if weights == &vec![0.7_f64, 0.3_f64]
563            ),
564            "default fuse_strategy should be Weighted [0.7, 0.3], got {:?}",
565            cfg.fuse_strategy
566        );
567        assert!(!cfg.include_breakdown);
568    }
569
570    #[test]
571    fn candidate_limit_zero_fails_validation() {
572        let cfg = RecallConfig {
573            candidate_limit: Some(0),
574            ..RecallConfig::default()
575        };
576        assert!(cfg.validate().is_err());
577    }
578
579    #[test]
580    fn candidate_limit_some_positive_validates() {
581        let cfg = RecallConfig {
582            candidate_limit: Some(100),
583            ..RecallConfig::default()
584        };
585        assert!(cfg.validate().is_ok());
586    }
587
588    #[test]
589    fn min_score_nan_fails_validation() {
590        let cfg = RecallConfig {
591            min_score: f64::NAN,
592            ..RecallConfig::default()
593        };
594        assert!(cfg.validate().is_err());
595    }
596
597    #[test]
598    fn min_salience_nan_fails_validation() {
599        let cfg = RecallConfig {
600            min_salience: f64::NAN,
601            ..RecallConfig::default()
602        };
603        assert!(cfg.validate().is_err());
604    }
605
606    #[test]
607    fn new_fields_roundtrip() {
608        let cfg = RecallConfig {
609            candidate_limit: Some(50),
610            fuse_strategy: FusionStrategy::Union,
611            include_breakdown: true,
612            ..RecallConfig::default()
613        };
614        let json = serde_json::to_string(&cfg).expect("serialize");
615        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
616        assert_eq!(back.candidate_limit, Some(50));
617        assert_eq!(back.fuse_strategy, FusionStrategy::Union);
618        assert!(back.include_breakdown);
619    }
620
621    #[test]
622    fn partial_config_new_fields_use_defaults() {
623        // Parse JSON that omits all new fields — they should fall back to defaults.
624        let json = r#"{"temporal_weight": 0.15}"#;
625        let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
626        assert_eq!(cfg.candidate_limit, None);
627        // CC-6: default changed to Weighted [0.7, 0.3]
628        assert!(
629            matches!(cfg.fuse_strategy, FusionStrategy::Weighted { .. }),
630            "partial config must deserialize fuse_strategy to Weighted default"
631        );
632        assert!(!cfg.include_breakdown);
633    }
634
635    // ── ScoreBreakdown ────────────────────────────────────────────────────────
636
637    #[test]
638    fn score_breakdown_total_sums_contributions() {
639        let bd = ScoreBreakdown {
640            relevance: 0.5,
641            salience_raw: 0.8,
642            salience_decayed: 0.6,
643            temporal: 0.3,
644            weighted: WeightedContributions {
645                relevance_contribution: 0.35,
646                salience_contribution: 0.12,
647                temporal_contribution: 0.03,
648            },
649        };
650        let expected = 0.35 + 0.12 + 0.03;
651        let diff = (bd.total() - expected).abs();
652        assert!(
653            diff < 1e-12,
654            "total() should sum weighted contributions, got {}",
655            bd.total()
656        );
657    }
658}