Skip to main content

khive_pack_memory/
config.rs

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