Skip to main content

khive_pack_memory/
config.rs

1//! Recall configuration types — scoring weights, decay models, and FTS gather options.
2//! See `crates/khive-pack-memory/docs/api/configuration.md` for the full policy reference.
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8use khive_fusion::FusionStrategy;
9use khive_runtime::RuntimeError;
10use khive_storage::types::{TextGatherMode, TextSearchOptions};
11
12/// Error returned when `min_score` is outside the accepted dual-scale range.
13#[derive(Debug, Clone)]
14pub enum MinScoreError {
15    /// Value was NaN or Inf.
16    NotFinite,
17    /// Value was finite but outside `[0.0, 100.0]` (or negative).
18    OutOfRange(f64),
19}
20
21impl std::fmt::Display for MinScoreError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Self::NotFinite => write!(f, "min_score must be finite"),
25            Self::OutOfRange(v) => write!(
26                f,
27                "min_score {v} out of range: must be 0.0–1.0 (fraction) or 0–100 (percent)"
28            ),
29        }
30    }
31}
32
33impl From<MinScoreError> for RuntimeError {
34    fn from(e: MinScoreError) -> Self {
35        RuntimeError::InvalidInput(e.to_string())
36    }
37}
38
39/// Configuration for the recall scoring pipeline.
40/// All fields have sensible defaults matching current behavior.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(default)]
43pub struct RecallConfig {
44    // --- Fusion weights ---
45    /// Weight of RRF/fusion score. Default 0.70.
46    pub relevance_weight: f64,
47    /// Weight of decay-adjusted salience. Default 0.20.
48    pub salience_weight: f64,
49    /// Weight of pure recency. Default 0.10.
50    pub temporal_weight: f64,
51
52    // --- Reranker weights ---
53    /// Per-reranker weights, keyed by reranker name. Missing keys → 0.0 (disabled).
54    /// v1 built-in names: "cross_encoder", "salience", "graph_proximity".
55    pub reranker_weights: HashMap<String, f64>,
56
57    // --- Temporal parameters ---
58    /// Days for temporal score to halve. Default 30.0.
59    pub temporal_half_life_days: f64,
60    /// Decay model to apply to salience. Default Exponential.
61    pub decay_model: DecayModel,
62
63    // --- Retrieval parameters ---
64    /// Candidates per retrieval path before fusion = limit × this. Default 20.
65    pub candidate_multiplier: u32,
66    /// Explicit max candidates per retrieval path before fusion. When None,
67    /// candidate_multiplier keeps the legacy behavior.
68    pub candidate_limit: Option<u32>,
69    /// Strategy used to fuse retrieval lists. Default weighted `[vector=0.7, text=0.3]`.
70    pub fuse_strategy: FusionStrategy,
71    /// Minimum composite score to include in results. Default 0.0.
72    pub min_score: f64,
73    /// Minimum raw salience to include in results. Default 0.0.
74    pub min_salience: f64,
75    /// Include per-component score breakdowns in recall responses. Default false.
76    pub include_breakdown: bool,
77
78    // --- Archive scoring pipeline override ---
79    /// Optional archive scoring config; enables MMR, supersedes suppression, CJK routing, entity boost.
80    pub scoring: Option<crate::scoring::ScoringConfig>,
81
82    // --- Brain profile integration ---
83    /// Optional brain profile hint for post-recall score boosting.
84    pub brain_profile: Option<BrainProfileHint>,
85
86    // --- FTS candidate-gather optimization ---
87    /// Controls the two-stage FTS gather path (default: disabled, existing behavior).
88    pub fts_gather: RecallFtsGatherConfig,
89
90    // --- ANN over-fetch retry ---
91    /// Maximum rounds for the ANN namespace over-fetch retry loop. Round 1 is
92    /// the initial over-fetch; rounds 2–N double the fetch window until enough
93    /// visible-namespace candidates are found or the corpus is exhausted. When
94    /// `None`, falls back to the `ANN_OVERFETCH_MAX_ROUNDS` env var (default 3).
95    /// Pass `Some(1)` to disable widening entirely.
96    pub ann_overfetch_max_rounds: Option<usize>,
97
98    /// Bounded wait (milliseconds) for a cold-miss `ensure_ann_for_model` call
99    /// on the recall path before degrading that model's contribution to
100    /// FTS-only (#836). When `None`, falls back to the
101    /// `KHIVE_MEMORY_ANN_READY_TIMEOUT_MS` env var (default 8000ms — see
102    /// `handlers::common::ann_ready_timeout_ms`).
103    ///
104    /// See `crates/khive-pack-memory/docs/api/configuration.md` for the operational rationale.
105    pub ann_ready_timeout_ms: Option<u64>,
106}
107
108/// Brain-profile hint for score boosting during recall.
109/// Applies `boost` multiplier to results whose profile posterior mean exceeds `threshold`.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct BrainProfileHint {
112    /// Profile ID to resolve. Passed to `brain.resolve` / `brain.profile`.
113    pub profile_id: String,
114    /// Score multiplier applied to matching results. Default 1.3×.
115    #[serde(default = "BrainProfileHint::default_boost")]
116    pub boost: f64,
117    /// Minimum Beta posterior mean required for a result to receive the boost. Default 0.6.
118    #[serde(default = "BrainProfileHint::default_threshold")]
119    pub threshold: f64,
120}
121
122impl BrainProfileHint {
123    fn default_boost() -> f64 {
124        1.3
125    }
126    fn default_threshold() -> f64 {
127        0.6
128    }
129}
130
131/// Term selection rule for FTS candidate gather.
132#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "snake_case")]
134pub enum RecallFtsSelectionRule {
135    /// Keep query terms in original order (current behavior).
136    #[default]
137    Original,
138    /// Pick K terms with the lowest document frequency (most selective).
139    LowestDf,
140    /// Pick K terms with the highest IDF (same as lowest DF with Robertson-Walker formula).
141    HighestIdf,
142}
143
144/// Pack-level alias for the DB gather mode enum, serializable identically.
145#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "snake_case")]
147pub enum RecallFtsGatherMode {
148    #[default]
149    Ranked,
150    Unranked,
151    RankWithinCap,
152}
153
154impl From<RecallFtsGatherMode> for TextGatherMode {
155    fn from(m: RecallFtsGatherMode) -> Self {
156        match m {
157            RecallFtsGatherMode::Ranked => TextGatherMode::Ranked,
158            RecallFtsGatherMode::Unranked => TextGatherMode::Unranked,
159            RecallFtsGatherMode::RankWithinCap => TextGatherMode::RankWithinCap,
160        }
161    }
162}
163
164/// Configuration for the FTS candidate-gather optimization (default: disabled, existing behavior).
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(default)]
167pub struct RecallFtsGatherConfig {
168    /// Enable the candidate-gather optimization. Default false = existing behavior.
169    pub enabled: bool,
170    /// Max query terms to send to FTS after selection. Default 10 (existing fanout limit).
171    pub term_k: usize,
172    /// How to select the K terms. Default original (existing order).
173    pub selection_rule: RecallFtsSelectionRule,
174    /// How the DB gathers candidates. Default ranked (existing behavior).
175    pub gather_mode: RecallFtsGatherMode,
176    /// Row cap for RankWithinCap gather. When None, uses candidate_limit * gather_cap_multiplier.
177    pub gather_limit: Option<u32>,
178    /// Multiplier for gather_limit when gather_limit is None. Default 4.
179    pub gather_cap_multiplier: u32,
180    /// When true, CJK queries bypass term selection and use the existing ranked all-term path.
181    pub cjk_bypass_ranked: bool,
182}
183
184impl Default for RecallFtsGatherConfig {
185    fn default() -> Self {
186        Self {
187            enabled: false,
188            term_k: 10,
189            selection_rule: RecallFtsSelectionRule::Original,
190            gather_mode: RecallFtsGatherMode::Ranked,
191            gather_limit: None,
192            gather_cap_multiplier: 4,
193            cjk_bypass_ranked: true,
194        }
195    }
196}
197
198impl RecallFtsGatherConfig {
199    /// Parse gather config from env vars. Returns `None` when none are set, `Err` on malformed values.
200    pub fn from_env() -> Result<Option<Self>, RuntimeError> {
201        let gather = std::env::var("KHIVE_RECALL_FTS_GATHER").ok();
202        let term_k = std::env::var("KHIVE_RECALL_FTS_TERM_K").ok();
203        let selection = std::env::var("KHIVE_RECALL_FTS_SELECTION").ok();
204        let limit = std::env::var("KHIVE_RECALL_FTS_GATHER_LIMIT").ok();
205        let multiplier = std::env::var("KHIVE_RECALL_FTS_GATHER_MULTIPLIER").ok();
206        let cjk_bypass = std::env::var("KHIVE_RECALL_FTS_CJK_BYPASS").ok();
207
208        if gather.is_none()
209            && term_k.is_none()
210            && selection.is_none()
211            && limit.is_none()
212            && multiplier.is_none()
213            && cjk_bypass.is_none()
214        {
215            return Ok(None);
216        }
217
218        let mut cfg = RecallFtsGatherConfig {
219            enabled: true,
220            ..RecallFtsGatherConfig::default()
221        };
222
223        if let Some(g) = gather {
224            match g.as_str() {
225                "baseline" => {
226                    cfg.enabled = false;
227                }
228                "ranked" => {
229                    cfg.gather_mode = RecallFtsGatherMode::Ranked;
230                }
231                "rank_subset" => {
232                    cfg.gather_mode = RecallFtsGatherMode::RankWithinCap;
233                }
234                "unranked" => {
235                    cfg.gather_mode = RecallFtsGatherMode::Unranked;
236                }
237                other => {
238                    return Err(RuntimeError::InvalidInput(format!(
239                        "KHIVE_RECALL_FTS_GATHER must be baseline|ranked|rank_subset|unranked, got {other:?}"
240                    )));
241                }
242            }
243        }
244
245        if let Some(k) = term_k {
246            let v: usize = k.parse().map_err(|_| {
247                RuntimeError::InvalidInput(format!(
248                    "KHIVE_RECALL_FTS_TERM_K must be a positive integer 1..10, got {k:?}"
249                ))
250            })?;
251            if v == 0 || v > 10 {
252                return Err(RuntimeError::InvalidInput(format!(
253                    "KHIVE_RECALL_FTS_TERM_K must be 1..10, got {v}"
254                )));
255            }
256            cfg.term_k = v;
257        }
258
259        if let Some(s) = selection {
260            cfg.selection_rule = match s.as_str() {
261                "original" => RecallFtsSelectionRule::Original,
262                "lowest_df" => RecallFtsSelectionRule::LowestDf,
263                "highest_idf" => RecallFtsSelectionRule::HighestIdf,
264                other => {
265                    return Err(RuntimeError::InvalidInput(format!(
266                        "KHIVE_RECALL_FTS_SELECTION must be original|lowest_df|highest_idf, got {other:?}"
267                    )));
268                }
269            };
270        }
271
272        if let Some(l) = limit {
273            let v: u32 = l.parse().map_err(|_| {
274                RuntimeError::InvalidInput(format!(
275                    "KHIVE_RECALL_FTS_GATHER_LIMIT must be a positive integer, got {l:?}"
276                ))
277            })?;
278            if v == 0 {
279                return Err(RuntimeError::InvalidInput(
280                    "KHIVE_RECALL_FTS_GATHER_LIMIT must be > 0".to_string(),
281                ));
282            }
283            cfg.gather_limit = Some(v);
284        }
285
286        if let Some(m) = multiplier {
287            let v: u32 = m.parse().map_err(|_| {
288                RuntimeError::InvalidInput(format!(
289                    "KHIVE_RECALL_FTS_GATHER_MULTIPLIER must be a positive integer, got {m:?}"
290                ))
291            })?;
292            if v == 0 {
293                return Err(RuntimeError::InvalidInput(
294                    "KHIVE_RECALL_FTS_GATHER_MULTIPLIER must be > 0".to_string(),
295                ));
296            }
297            cfg.gather_cap_multiplier = v;
298        }
299
300        if let Some(b) = cjk_bypass {
301            cfg.cjk_bypass_ranked = match b.as_str() {
302                "1" | "true" => true,
303                "0" | "false" => false,
304                other => {
305                    return Err(RuntimeError::InvalidInput(format!(
306                        "KHIVE_RECALL_FTS_CJK_BYPASS must be 1|0, got {other:?}"
307                    )));
308                }
309            };
310        }
311
312        cfg.validate()?;
313        Ok(Some(cfg))
314    }
315
316    /// Validate the config for internal consistency.
317    pub fn validate(&self) -> Result<(), RuntimeError> {
318        if self.term_k == 0 {
319            return Err(RuntimeError::InvalidInput(
320                "fts_gather.term_k must be > 0".to_string(),
321            ));
322        }
323        if self.gather_cap_multiplier == 0 {
324            return Err(RuntimeError::InvalidInput(
325                "fts_gather.gather_cap_multiplier must be > 0".to_string(),
326            ));
327        }
328        if let Some(gl) = self.gather_limit {
329            if gl == 0 {
330                return Err(RuntimeError::InvalidInput(
331                    "fts_gather.gather_limit must be > 0 when provided".to_string(),
332                ));
333            }
334        }
335        Ok(())
336    }
337
338    /// Compute the effective gather_limit for a given candidate_limit.
339    pub fn effective_gather_limit(&self, candidate_limit: u32) -> Result<u32, RuntimeError> {
340        let gl = match self.gather_limit {
341            Some(explicit) => {
342                if explicit < candidate_limit {
343                    return Err(RuntimeError::InvalidInput(format!(
344                        "fts_gather.gather_limit ({explicit}) must be >= candidate_limit ({candidate_limit})"
345                    )));
346                }
347                explicit
348            }
349            None => candidate_limit.saturating_mul(self.gather_cap_multiplier),
350        };
351        Ok(gl.max(candidate_limit))
352    }
353
354    /// Convert to DB-level `TextSearchOptions` for a given candidate_limit.
355    pub fn to_search_options(
356        &self,
357        candidate_limit: u32,
358    ) -> Result<TextSearchOptions, RuntimeError> {
359        let gather_limit = self.effective_gather_limit(candidate_limit)?;
360        Ok(TextSearchOptions {
361            gather_mode: self.gather_mode.into(),
362            gather_limit: Some(gather_limit),
363        })
364    }
365}
366
367// Tuning artifact: tests/khive-contract/tune/ swept 116 configs but the synthetic corpus
368// produced an identical recall@10 = 0.9333 for every config — i.e. a flat landscape that
369// cannot empirically distinguish these parameters. Defaults below stay at the prior values
370// until a harder corpus (embed-enabled, synonym queries, partial matches) provides signal.
371// See tests/khive-contract/tune/REPORT.md for the analysis.
372//
373// CC-6: Default strategy changed from RRF to Weighted [0.7, 0.3].
374//
375// Under RRF with the default weights (relevance 70%, salience 20%, temporal 10%), a
376// salience=0.3 memory can rank above a salience=0.9 memory when its text/vector rank is
377// marginally better. The Weighted strategy gives full-resolution score values to both
378// retrieval paths, making the salience contribution a meaningful tiebreaker.
379// The RRF strategy remains available via `fusion_strategy="rrf"`.
380impl Default for RecallConfig {
381    fn default() -> Self {
382        Self {
383            relevance_weight: 0.70,
384            salience_weight: 0.20,
385            temporal_weight: 0.10,
386            reranker_weights: HashMap::new(),
387            temporal_half_life_days: 30.0,
388            decay_model: DecayModel::default(),
389            candidate_multiplier: 20,
390            candidate_limit: Some(150),
391            // CC-6: Weighted fusion respects score magnitude, allowing the salience
392            // amplifier to meaningfully differentiate high- vs low-salience memories.
393            // Weights [vector=0.7, text=0.3] match the prior RRF intent: vector
394            // results are weighted higher because embedding search captures semantic
395            // similarity; text results supplement with keyword precision.
396            fuse_strategy: FusionStrategy::Weighted {
397                weights: vec![0.7, 0.3],
398            },
399            min_score: 0.0,
400            min_salience: 0.0,
401            include_breakdown: false,
402            scoring: None,
403            brain_profile: None,
404            fts_gather: RecallFtsGatherConfig::default(),
405            ann_overfetch_max_rounds: None,
406            ann_ready_timeout_ms: None,
407        }
408    }
409}
410
411impl RecallConfig {
412    /// Validate config consistency: non-negative weights, positive weight sum, positive half-life.
413    pub fn validate(&self) -> Result<(), RuntimeError> {
414        if !self.relevance_weight.is_finite() || self.relevance_weight < 0.0 {
415            return Err(RuntimeError::InvalidInput(
416                "relevance_weight must be a finite non-negative number".to_string(),
417            ));
418        }
419        if !self.salience_weight.is_finite() || self.salience_weight < 0.0 {
420            return Err(RuntimeError::InvalidInput(
421                "salience_weight must be a finite non-negative number".to_string(),
422            ));
423        }
424        if !self.temporal_weight.is_finite() || self.temporal_weight < 0.0 {
425            return Err(RuntimeError::InvalidInput(
426                "temporal_weight must be a finite non-negative number".to_string(),
427            ));
428        }
429        let weight_sum = self.relevance_weight + self.salience_weight + self.temporal_weight;
430        if weight_sum <= 0.0 {
431            return Err(RuntimeError::InvalidInput(
432                "at least one of relevance_weight / salience_weight / temporal_weight must be positive".to_string(),
433            ));
434        }
435        for (name, &weight) in &self.reranker_weights {
436            if !weight.is_finite() || weight < 0.0 {
437                return Err(RuntimeError::InvalidInput(format!(
438                    "reranker_weights[{name:?}] must be a finite non-negative number"
439                )));
440            }
441        }
442        if !self.temporal_half_life_days.is_finite() || self.temporal_half_life_days <= 0.0 {
443            return Err(RuntimeError::InvalidInput(
444                "temporal_half_life_days must be a finite positive number".to_string(),
445            ));
446        }
447        // Validate PowerLaw half_life_days if that decay model is active.
448        if let DecayModel::PowerLaw { half_life_days } = self.decay_model {
449            if !half_life_days.is_finite() || half_life_days <= 0.0 {
450                return Err(RuntimeError::InvalidInput(
451                    "decay_model.power_law.half_life_days must be a finite positive number"
452                        .to_string(),
453                ));
454            }
455        }
456        if self.candidate_limit == Some(0) {
457            return Err(RuntimeError::InvalidInput(
458                "candidate_limit must be positive when provided".to_string(),
459            ));
460        }
461        if !self.min_score.is_finite() {
462            return Err(RuntimeError::InvalidInput(
463                "min_score must be finite".to_string(),
464            ));
465        }
466        if !self.min_salience.is_finite() {
467            return Err(RuntimeError::InvalidInput(
468                "min_salience must be finite".to_string(),
469            ));
470        }
471        Ok(())
472    }
473
474    /// Deserialize from a JSON value and validate in one step.
475    pub fn try_from_value(v: serde_json::Value) -> Result<Self, RuntimeError> {
476        let cfg: Self =
477            serde_json::from_value(v).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
478        cfg.validate()?;
479        Ok(cfg)
480    }
481}
482
483/// How salience decays over time.
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
485#[serde(rename_all = "snake_case")]
486pub enum DecayModel {
487    /// `salience * exp(-decay_factor * age_days)` — half-life = ln(2)/decay_factor.
488    /// Pack defaults: episodic decay_factor=0.02 (~35d), semantic decay_factor=0.005 (~139d).
489    #[default]
490    Exponential,
491    /// `salience / (1 + decay_factor * age_days)`
492    Hyperbolic,
493    /// `salience * half_life / (half_life + age_days)`
494    PowerLaw {
495        /// Override half-life days for the power-law model.
496        half_life_days: f64,
497    },
498    /// No decay — salience is used as-is.
499    None,
500}
501
502impl DecayModel {
503    /// Apply decay to a salience value given age_days, decay_factor, and config half_life.
504    pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
505        match self {
506            DecayModel::Exponential => {
507                // effective_salience = salience * exp(-decay_factor * age_days)
508                // Uses the note's own decay_factor, not a half-life-derived constant.
509                salience * (-decay_factor * age_days).exp()
510            }
511            DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
512            DecayModel::PowerLaw { half_life_days } => {
513                let hl = *half_life_days;
514                salience * hl / (hl + age_days)
515            }
516            DecayModel::None => salience,
517        }
518    }
519}
520
521/// Per-component score contributions for a single recall result.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct ScoreBreakdown {
524    /// Raw RRF fusion score (before weighting).
525    pub relevance: f64,
526    /// Raw salience from the note (before decay).
527    pub salience_raw: f64,
528    /// Salience after applying the decay model.
529    pub salience_decayed: f64,
530    /// Temporal recency score (half-life decay, independent of note's own decay_factor).
531    pub temporal: f64,
532    /// Weighted contributions summing to the total score.
533    pub weighted: WeightedContributions,
534    /// ADR-104 §3: multiplicative contribution of serve-time profile-weight
535    /// projection relative to configured-default weights for this hit —
536    /// `score_with_projected_weights / score_with_default_weights`. `1.0`
537    /// when no profile served the request (component 1 never ran) or the
538    /// default-weight score is ~0 (div-by-zero guard).
539    pub profile_component: f64,
540    /// ADR-104 §3: the serving profile's per-entity Beta posterior mean for
541    /// this hit's UUID. `None` when no profile served the request, or the
542    /// profile holds no posterior for this UUID beyond the uninformative
543    /// prior. As of Stage B (§2), this value is not just reported: it is
544    /// the input to the bounded per-entity term
545    /// `clamp(1 + 0.3 * (entity_posterior_mean - 0.5), 0.85, 1.15)` that
546    /// multiplies the response's `rank_score` — so a value here other than
547    /// the neutral case (`None`, or a mean of exactly 0.5) means this hit's
548    /// `rank_score` was moved by up to +/-15% relative to the same
549    /// candidate under a profile with no posterior for it.
550    pub entity_posterior_mean: Option<f64>,
551}
552
553impl ScoreBreakdown {
554    /// Total composite score.
555    pub fn total(&self) -> f64 {
556        self.weighted.relevance_contribution
557            + self.weighted.salience_contribution
558            + self.weighted.temporal_contribution
559    }
560}
561
562/// The three weighted components that make up the final score.
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct WeightedContributions {
565    pub relevance_contribution: f64,
566    pub salience_contribution: f64,
567    pub temporal_contribution: f64,
568}
569
570// ── Tests ─────────────────────────────────────────────────────────────────────
571
572// INLINE TEST JUSTIFICATION: tests exercise private validation methods and
573// DecayModel::apply, which are not accessible from the integration test harness.
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    // ── DecayModel ────────────────────────────────────────────────────────────
579
580    #[test]
581    fn exponential_halves_at_decay_factor_half_life() {
582        // exponential decay: salience * exp(-decay_factor * age_days)
583        // Half-life = ln(2) / decay_factor ≈ 69.3 days for decay_factor=0.01
584        let model = DecayModel::Exponential;
585        let salience = 1.0;
586        let decay_factor = 0.01;
587        let half_life_days = std::f64::consts::LN_2 / decay_factor;
588        let result = model.apply(salience, half_life_days, decay_factor, 30.0);
589        let diff = (result - 0.5).abs();
590        assert!(
591            diff < 1e-10,
592            "exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
593        );
594    }
595
596    #[test]
597    fn exponential_full_salience_at_zero_age() {
598        let model = DecayModel::Exponential;
599        let result = model.apply(0.8, 0.0, 0.01, 30.0);
600        let diff = (result - 0.8).abs();
601        assert!(
602            diff < 1e-12,
603            "at age=0 salience should be unchanged, got {result}"
604        );
605    }
606
607    #[test]
608    fn exponential_uses_note_decay_factor_not_half_life() {
609        // Verify the formula uses decay_factor param, not the half_life param.
610        // At age=1 day, decay_factor=1.0 → exp(-1.0) ≈ 0.3679.
611        // If we were using half_life=10 days, exp(-ln2/10) ≈ 0.933.
612        let model = DecayModel::Exponential;
613        let result = model.apply(1.0, 1.0, 1.0, 10.0);
614        let expected = (-1.0f64).exp();
615        assert!(
616            (result - expected).abs() < 1e-12,
617            "expected {expected}, got {result}"
618        );
619    }
620
621    #[test]
622    fn hyperbolic_halves_at_one_over_decay_factor() {
623        // salience / (1 + k * age) = 0.5 when age = 1/k
624        let model = DecayModel::Hyperbolic;
625        let salience = 1.0;
626        let k = 0.05;
627        let age = 1.0 / k; // 20 days
628        let result = model.apply(salience, age, k, 30.0);
629        let diff = (result - 0.5).abs();
630        assert!(
631            diff < 1e-10,
632            "hyperbolic at age=1/k should give 0.5, got {result}"
633        );
634    }
635
636    #[test]
637    fn hyperbolic_full_salience_at_zero_age() {
638        let model = DecayModel::Hyperbolic;
639        let result = model.apply(0.7, 0.0, 0.05, 30.0);
640        let diff = (result - 0.7).abs();
641        assert!(
642            diff < 1e-12,
643            "at age=0 salience should be unchanged, got {result}"
644        );
645    }
646
647    #[test]
648    fn powerlaw_halves_at_half_life() {
649        let hl = 30.0;
650        let model = DecayModel::PowerLaw { half_life_days: hl };
651        let salience = 1.0;
652        // salience * hl / (hl + age) = 0.5 when age = hl
653        let result = model.apply(salience, hl, 0.01, hl);
654        let diff = (result - 0.5).abs();
655        assert!(
656            diff < 1e-10,
657            "power-law should give 0.5 at half-life, got {result}"
658        );
659    }
660
661    #[test]
662    fn decay_none_returns_salience_unchanged() {
663        let model = DecayModel::None;
664        let result = model.apply(0.6, 100.0, 0.99, 30.0);
665        let diff = (result - 0.6).abs();
666        assert!(
667            diff < 1e-12,
668            "None model must not alter salience, got {result}"
669        );
670    }
671
672    // ── RecallConfig ──────────────────────────────────────────────────────────
673
674    #[test]
675    fn default_config_validates() {
676        assert!(RecallConfig::default().validate().is_ok());
677    }
678
679    #[test]
680    fn negative_relevance_weight_fails_validation() {
681        let cfg = RecallConfig {
682            relevance_weight: -0.1,
683            ..RecallConfig::default()
684        };
685        assert!(cfg.validate().is_err());
686    }
687
688    #[test]
689    fn negative_salience_weight_fails_validation() {
690        let cfg = RecallConfig {
691            salience_weight: -1.0,
692            ..RecallConfig::default()
693        };
694        assert!(cfg.validate().is_err());
695    }
696
697    #[test]
698    fn negative_temporal_weight_fails_validation() {
699        let cfg = RecallConfig {
700            temporal_weight: -0.5,
701            ..RecallConfig::default()
702        };
703        assert!(cfg.validate().is_err());
704    }
705
706    #[test]
707    fn all_zero_weights_fails_validation() {
708        let cfg = RecallConfig {
709            relevance_weight: 0.0,
710            salience_weight: 0.0,
711            temporal_weight: 0.0,
712            ..RecallConfig::default()
713        };
714        assert!(cfg.validate().is_err());
715    }
716
717    #[test]
718    fn zero_half_life_fails_validation() {
719        let cfg = RecallConfig {
720            temporal_half_life_days: 0.0,
721            ..RecallConfig::default()
722        };
723        assert!(cfg.validate().is_err());
724    }
725
726    #[test]
727    fn negative_half_life_fails_validation() {
728        let cfg = RecallConfig {
729            temporal_half_life_days: -5.0,
730            ..RecallConfig::default()
731        };
732        assert!(cfg.validate().is_err());
733    }
734
735    #[test]
736    fn non_uniform_weights_validate() {
737        let cfg = RecallConfig {
738            relevance_weight: 0.5,
739            salience_weight: 0.3,
740            temporal_weight: 0.2,
741            ..RecallConfig::default()
742        };
743        assert!(cfg.validate().is_ok());
744    }
745
746    // ── Serde roundtrips ──────────────────────────────────────────────────────
747
748    #[test]
749    fn default_config_roundtrip() {
750        let cfg = RecallConfig::default();
751        let json = serde_json::to_string(&cfg).expect("serialize");
752        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
753        let diff = (cfg.relevance_weight - back.relevance_weight).abs();
754        assert!(diff < 1e-12);
755        assert_eq!(cfg.decay_model, back.decay_model);
756    }
757
758    #[test]
759    fn decay_model_exponential_roundtrip() {
760        let m = DecayModel::Exponential;
761        let json = serde_json::to_string(&m).expect("serialize");
762        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
763        assert_eq!(m, back);
764    }
765
766    #[test]
767    fn decay_model_hyperbolic_roundtrip() {
768        let m = DecayModel::Hyperbolic;
769        let json = serde_json::to_string(&m).expect("serialize");
770        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
771        assert_eq!(m, back);
772    }
773
774    #[test]
775    fn decay_model_powerlaw_roundtrip() {
776        let m = DecayModel::PowerLaw {
777            half_life_days: 14.0,
778        };
779        let json = serde_json::to_string(&m).expect("serialize");
780        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
781        assert_eq!(m, back);
782    }
783
784    #[test]
785    fn decay_model_none_roundtrip() {
786        let m = DecayModel::None;
787        let json = serde_json::to_string(&m).expect("serialize");
788        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
789        assert_eq!(m, back);
790    }
791
792    #[test]
793    fn partial_config_deserializes_with_defaults() {
794        // Only override one field — the rest should default.
795        let json = r#"{"relevance_weight": 0.5}"#;
796        let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
797        // specified field
798        let diff = (cfg.relevance_weight - 0.5).abs();
799        assert!(diff < 1e-12);
800        // unspecified fields keep defaults
801        let diff2 = (cfg.salience_weight - 0.20).abs();
802        assert!(diff2 < 1e-12);
803        assert_eq!(cfg.decay_model, DecayModel::Exponential);
804    }
805
806    // ── RecallConfig new fields ───────────────────────────────────────────────
807
808    #[test]
809    fn new_fields_have_correct_defaults() {
810        let cfg = RecallConfig::default();
811        assert_eq!(cfg.candidate_limit, Some(150));
812        // CC-6: default changed to Weighted [0.7, 0.3] so salience can influence ranking
813        assert!(
814            matches!(
815                cfg.fuse_strategy,
816                FusionStrategy::Weighted { ref weights } if weights == &vec![0.7_f64, 0.3_f64]
817            ),
818            "default fuse_strategy should be Weighted [0.7, 0.3], got {:?}",
819            cfg.fuse_strategy
820        );
821        assert!(!cfg.include_breakdown);
822    }
823
824    #[test]
825    fn candidate_limit_zero_fails_validation() {
826        let cfg = RecallConfig {
827            candidate_limit: Some(0),
828            ..RecallConfig::default()
829        };
830        assert!(cfg.validate().is_err());
831    }
832
833    #[test]
834    fn candidate_limit_some_positive_validates() {
835        let cfg = RecallConfig {
836            candidate_limit: Some(100),
837            ..RecallConfig::default()
838        };
839        assert!(cfg.validate().is_ok());
840    }
841
842    #[test]
843    fn min_score_nan_fails_validation() {
844        let cfg = RecallConfig {
845            min_score: f64::NAN,
846            ..RecallConfig::default()
847        };
848        assert!(cfg.validate().is_err());
849    }
850
851    #[test]
852    fn min_salience_nan_fails_validation() {
853        let cfg = RecallConfig {
854            min_salience: f64::NAN,
855            ..RecallConfig::default()
856        };
857        assert!(cfg.validate().is_err());
858    }
859
860    #[test]
861    fn new_fields_roundtrip() {
862        let cfg = RecallConfig {
863            candidate_limit: Some(50),
864            fuse_strategy: FusionStrategy::Union,
865            include_breakdown: true,
866            ..RecallConfig::default()
867        };
868        let json = serde_json::to_string(&cfg).expect("serialize");
869        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
870        assert_eq!(back.candidate_limit, Some(50));
871        assert_eq!(back.fuse_strategy, FusionStrategy::Union);
872        assert!(back.include_breakdown);
873    }
874
875    #[test]
876    fn partial_config_new_fields_use_defaults() {
877        // Parse JSON that omits all new fields — they should fall back to defaults.
878        // With #[serde(default)] on the struct, missing fields use RecallConfig::default(),
879        // so candidate_limit falls back to Some(150), not Option::default() == None.
880        let json = r#"{"temporal_weight": 0.15}"#;
881        let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
882        assert_eq!(cfg.candidate_limit, Some(150));
883        // CC-6: default changed to Weighted [0.7, 0.3]
884        assert!(
885            matches!(cfg.fuse_strategy, FusionStrategy::Weighted { .. }),
886            "partial config must deserialize fuse_strategy to Weighted default"
887        );
888        assert!(!cfg.include_breakdown);
889    }
890
891    // ── ScoreBreakdown ────────────────────────────────────────────────────────
892
893    #[test]
894    fn score_breakdown_total_sums_contributions() {
895        let bd = ScoreBreakdown {
896            relevance: 0.5,
897            salience_raw: 0.8,
898            salience_decayed: 0.6,
899            temporal: 0.3,
900            weighted: WeightedContributions {
901                relevance_contribution: 0.35,
902                salience_contribution: 0.12,
903                temporal_contribution: 0.03,
904            },
905            profile_component: 1.0,
906            entity_posterior_mean: None,
907        };
908        let expected = 0.35 + 0.12 + 0.03;
909        let diff = (bd.total() - expected).abs();
910        assert!(
911            diff < 1e-12,
912            "total() should sum weighted contributions, got {}",
913            bd.total()
914        );
915    }
916
917    // ── RecallFtsGatherConfig ─────────────────────────────────────────────────
918
919    #[test]
920    fn fts_gather_default_is_disabled() {
921        let cfg = RecallFtsGatherConfig::default();
922        assert!(!cfg.enabled);
923        assert_eq!(cfg.term_k, 10);
924        assert_eq!(cfg.selection_rule, RecallFtsSelectionRule::Original);
925        assert_eq!(cfg.gather_mode, RecallFtsGatherMode::Ranked);
926        assert!(cfg.gather_limit.is_none());
927        assert_eq!(cfg.gather_cap_multiplier, 4);
928        assert!(cfg.cjk_bypass_ranked);
929    }
930
931    #[test]
932    fn fts_gather_validates_zero_term_k() {
933        let cfg = RecallFtsGatherConfig {
934            term_k: 0,
935            ..RecallFtsGatherConfig::default()
936        };
937        assert!(cfg.validate().is_err());
938    }
939
940    #[test]
941    fn fts_gather_validates_zero_multiplier() {
942        let cfg = RecallFtsGatherConfig {
943            gather_cap_multiplier: 0,
944            ..RecallFtsGatherConfig::default()
945        };
946        assert!(cfg.validate().is_err());
947    }
948
949    #[test]
950    fn fts_gather_validates_zero_gather_limit() {
951        let cfg = RecallFtsGatherConfig {
952            gather_limit: Some(0),
953            ..RecallFtsGatherConfig::default()
954        };
955        assert!(cfg.validate().is_err());
956    }
957
958    #[test]
959    fn fts_gather_effective_gather_limit_uses_multiplier() {
960        let cfg = RecallFtsGatherConfig {
961            gather_cap_multiplier: 4,
962            ..RecallFtsGatherConfig::default()
963        };
964        assert_eq!(cfg.effective_gather_limit(150).unwrap(), 600);
965    }
966
967    #[test]
968    fn fts_gather_effective_gather_limit_explicit_wins() {
969        let cfg = RecallFtsGatherConfig {
970            gather_limit: Some(500),
971            gather_cap_multiplier: 4,
972            ..RecallFtsGatherConfig::default()
973        };
974        assert_eq!(cfg.effective_gather_limit(150).unwrap(), 500);
975    }
976
977    #[test]
978    fn fts_gather_effective_gather_limit_too_small_fails() {
979        let cfg = RecallFtsGatherConfig {
980            gather_limit: Some(100),
981            ..RecallFtsGatherConfig::default()
982        };
983        assert!(cfg.effective_gather_limit(150).is_err());
984    }
985
986    #[test]
987    fn fts_gather_from_env_returns_none_when_no_env_set() {
988        // Ensure none of the relevant vars are set in CI/test environment.
989        // If they are set by a prior test, this test may be flaky; the vars
990        // are prefixed to avoid collisions.
991        if std::env::var("KHIVE_RECALL_FTS_GATHER").is_ok()
992            || std::env::var("KHIVE_RECALL_FTS_TERM_K").is_ok()
993        {
994            return; // skip if env is already configured
995        }
996        let result = RecallFtsGatherConfig::from_env().unwrap();
997        assert!(result.is_none());
998    }
999
1000    #[test]
1001    fn fts_gather_invalid_gather_value_fails() {
1002        // We can't easily set env vars in unit tests without affecting other
1003        // tests. This exercises the validation branch directly.
1004        // Manually simulate what from_env would produce for an invalid value.
1005        let cfg = RecallFtsGatherConfig {
1006            term_k: 0,
1007            ..RecallFtsGatherConfig::default()
1008        };
1009        assert!(cfg.validate().is_err(), "term_k=0 must fail validation");
1010    }
1011
1012    #[test]
1013    fn fts_gather_from_into_gather_mode() {
1014        assert_eq!(
1015            TextGatherMode::from(RecallFtsGatherMode::Ranked),
1016            TextGatherMode::Ranked
1017        );
1018        assert_eq!(
1019            TextGatherMode::from(RecallFtsGatherMode::Unranked),
1020            TextGatherMode::Unranked
1021        );
1022        assert_eq!(
1023            TextGatherMode::from(RecallFtsGatherMode::RankWithinCap),
1024            TextGatherMode::RankWithinCap
1025        );
1026    }
1027
1028    #[test]
1029    fn recall_config_default_has_fts_gather() {
1030        let cfg = RecallConfig::default();
1031        assert!(!cfg.fts_gather.enabled);
1032    }
1033
1034    #[test]
1035    fn recall_config_roundtrip_with_fts_gather() {
1036        let cfg = RecallConfig {
1037            fts_gather: RecallFtsGatherConfig {
1038                enabled: true,
1039                term_k: 5,
1040                selection_rule: RecallFtsSelectionRule::HighestIdf,
1041                gather_mode: RecallFtsGatherMode::RankWithinCap,
1042                gather_limit: Some(600),
1043                gather_cap_multiplier: 4,
1044                cjk_bypass_ranked: true,
1045            },
1046            ..RecallConfig::default()
1047        };
1048        let json = serde_json::to_string(&cfg).expect("serialize");
1049        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
1050        assert!(back.fts_gather.enabled);
1051        assert_eq!(back.fts_gather.term_k, 5);
1052        assert_eq!(
1053            back.fts_gather.gather_mode,
1054            RecallFtsGatherMode::RankWithinCap
1055        );
1056        assert_eq!(back.fts_gather.gather_limit, Some(600));
1057    }
1058}