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