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