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)` — half-life = ln(2)/decay_factor.
468    /// Pack defaults: episodic decay_factor=0.02 (~35d), semantic decay_factor=0.005 (~139d).
469    #[default]
470    Exponential,
471    /// `salience / (1 + decay_factor * age_days)`
472    Hyperbolic,
473    /// `salience * half_life / (half_life + age_days)`
474    PowerLaw {
475        /// Override half-life days for the power-law model.
476        half_life_days: f64,
477    },
478    /// No decay — salience is used as-is.
479    None,
480}
481
482impl DecayModel {
483    /// Apply decay to a salience value given age_days, decay_factor, and config half_life.
484    pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
485        match self {
486            DecayModel::Exponential => {
487                // effective_salience = salience * exp(-decay_factor * age_days)
488                // Uses the note's own decay_factor, not a half-life-derived constant.
489                salience * (-decay_factor * age_days).exp()
490            }
491            DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
492            DecayModel::PowerLaw { half_life_days } => {
493                let hl = *half_life_days;
494                salience * hl / (hl + age_days)
495            }
496            DecayModel::None => salience,
497        }
498    }
499}
500
501/// Per-component score contributions for a single recall result.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct ScoreBreakdown {
504    /// Raw RRF fusion score (before weighting).
505    pub relevance: f64,
506    /// Raw salience from the note (before decay).
507    pub salience_raw: f64,
508    /// Salience after applying the decay model.
509    pub salience_decayed: f64,
510    /// Temporal recency score (half-life decay, independent of note's own decay_factor).
511    pub temporal: f64,
512    /// Weighted contributions summing to the total score.
513    pub weighted: WeightedContributions,
514}
515
516impl ScoreBreakdown {
517    /// Total composite score.
518    pub fn total(&self) -> f64 {
519        self.weighted.relevance_contribution
520            + self.weighted.salience_contribution
521            + self.weighted.temporal_contribution
522    }
523}
524
525/// The three weighted components that make up the final score.
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct WeightedContributions {
528    pub relevance_contribution: f64,
529    pub salience_contribution: f64,
530    pub temporal_contribution: f64,
531}
532
533// ── Tests ─────────────────────────────────────────────────────────────────────
534
535// INLINE TEST JUSTIFICATION: tests exercise private validation methods and
536// DecayModel::apply, which are not accessible from the integration test harness.
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    // ── DecayModel ────────────────────────────────────────────────────────────
542
543    #[test]
544    fn exponential_halves_at_decay_factor_half_life() {
545        // exponential decay: salience * exp(-decay_factor * age_days)
546        // Half-life = ln(2) / decay_factor ≈ 69.3 days for decay_factor=0.01
547        let model = DecayModel::Exponential;
548        let salience = 1.0;
549        let decay_factor = 0.01;
550        let half_life_days = std::f64::consts::LN_2 / decay_factor;
551        let result = model.apply(salience, half_life_days, decay_factor, 30.0);
552        let diff = (result - 0.5).abs();
553        assert!(
554            diff < 1e-10,
555            "exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
556        );
557    }
558
559    #[test]
560    fn exponential_full_salience_at_zero_age() {
561        let model = DecayModel::Exponential;
562        let result = model.apply(0.8, 0.0, 0.01, 30.0);
563        let diff = (result - 0.8).abs();
564        assert!(
565            diff < 1e-12,
566            "at age=0 salience should be unchanged, got {result}"
567        );
568    }
569
570    #[test]
571    fn exponential_uses_note_decay_factor_not_half_life() {
572        // Verify the formula uses decay_factor param, not the half_life param.
573        // At age=1 day, decay_factor=1.0 → exp(-1.0) ≈ 0.3679.
574        // If we were using half_life=10 days, exp(-ln2/10) ≈ 0.933.
575        let model = DecayModel::Exponential;
576        let result = model.apply(1.0, 1.0, 1.0, 10.0);
577        let expected = (-1.0f64).exp();
578        assert!(
579            (result - expected).abs() < 1e-12,
580            "expected {expected}, got {result}"
581        );
582    }
583
584    #[test]
585    fn hyperbolic_halves_at_one_over_decay_factor() {
586        // salience / (1 + k * age) = 0.5 when age = 1/k
587        let model = DecayModel::Hyperbolic;
588        let salience = 1.0;
589        let k = 0.05;
590        let age = 1.0 / k; // 20 days
591        let result = model.apply(salience, age, k, 30.0);
592        let diff = (result - 0.5).abs();
593        assert!(
594            diff < 1e-10,
595            "hyperbolic at age=1/k should give 0.5, got {result}"
596        );
597    }
598
599    #[test]
600    fn hyperbolic_full_salience_at_zero_age() {
601        let model = DecayModel::Hyperbolic;
602        let result = model.apply(0.7, 0.0, 0.05, 30.0);
603        let diff = (result - 0.7).abs();
604        assert!(
605            diff < 1e-12,
606            "at age=0 salience should be unchanged, got {result}"
607        );
608    }
609
610    #[test]
611    fn powerlaw_halves_at_half_life() {
612        let hl = 30.0;
613        let model = DecayModel::PowerLaw { half_life_days: hl };
614        let salience = 1.0;
615        // salience * hl / (hl + age) = 0.5 when age = hl
616        let result = model.apply(salience, hl, 0.01, hl);
617        let diff = (result - 0.5).abs();
618        assert!(
619            diff < 1e-10,
620            "power-law should give 0.5 at half-life, got {result}"
621        );
622    }
623
624    #[test]
625    fn decay_none_returns_salience_unchanged() {
626        let model = DecayModel::None;
627        let result = model.apply(0.6, 100.0, 0.99, 30.0);
628        let diff = (result - 0.6).abs();
629        assert!(
630            diff < 1e-12,
631            "None model must not alter salience, got {result}"
632        );
633    }
634
635    // ── RecallConfig ──────────────────────────────────────────────────────────
636
637    #[test]
638    fn default_config_validates() {
639        assert!(RecallConfig::default().validate().is_ok());
640    }
641
642    #[test]
643    fn negative_relevance_weight_fails_validation() {
644        let cfg = RecallConfig {
645            relevance_weight: -0.1,
646            ..RecallConfig::default()
647        };
648        assert!(cfg.validate().is_err());
649    }
650
651    #[test]
652    fn negative_salience_weight_fails_validation() {
653        let cfg = RecallConfig {
654            salience_weight: -1.0,
655            ..RecallConfig::default()
656        };
657        assert!(cfg.validate().is_err());
658    }
659
660    #[test]
661    fn negative_temporal_weight_fails_validation() {
662        let cfg = RecallConfig {
663            temporal_weight: -0.5,
664            ..RecallConfig::default()
665        };
666        assert!(cfg.validate().is_err());
667    }
668
669    #[test]
670    fn all_zero_weights_fails_validation() {
671        let cfg = RecallConfig {
672            relevance_weight: 0.0,
673            salience_weight: 0.0,
674            temporal_weight: 0.0,
675            ..RecallConfig::default()
676        };
677        assert!(cfg.validate().is_err());
678    }
679
680    #[test]
681    fn zero_half_life_fails_validation() {
682        let cfg = RecallConfig {
683            temporal_half_life_days: 0.0,
684            ..RecallConfig::default()
685        };
686        assert!(cfg.validate().is_err());
687    }
688
689    #[test]
690    fn negative_half_life_fails_validation() {
691        let cfg = RecallConfig {
692            temporal_half_life_days: -5.0,
693            ..RecallConfig::default()
694        };
695        assert!(cfg.validate().is_err());
696    }
697
698    #[test]
699    fn non_uniform_weights_validate() {
700        let cfg = RecallConfig {
701            relevance_weight: 0.5,
702            salience_weight: 0.3,
703            temporal_weight: 0.2,
704            ..RecallConfig::default()
705        };
706        assert!(cfg.validate().is_ok());
707    }
708
709    // ── Serde roundtrips ──────────────────────────────────────────────────────
710
711    #[test]
712    fn default_config_roundtrip() {
713        let cfg = RecallConfig::default();
714        let json = serde_json::to_string(&cfg).expect("serialize");
715        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
716        let diff = (cfg.relevance_weight - back.relevance_weight).abs();
717        assert!(diff < 1e-12);
718        assert_eq!(cfg.decay_model, back.decay_model);
719    }
720
721    #[test]
722    fn decay_model_exponential_roundtrip() {
723        let m = DecayModel::Exponential;
724        let json = serde_json::to_string(&m).expect("serialize");
725        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
726        assert_eq!(m, back);
727    }
728
729    #[test]
730    fn decay_model_hyperbolic_roundtrip() {
731        let m = DecayModel::Hyperbolic;
732        let json = serde_json::to_string(&m).expect("serialize");
733        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
734        assert_eq!(m, back);
735    }
736
737    #[test]
738    fn decay_model_powerlaw_roundtrip() {
739        let m = DecayModel::PowerLaw {
740            half_life_days: 14.0,
741        };
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_none_roundtrip() {
749        let m = DecayModel::None;
750        let json = serde_json::to_string(&m).expect("serialize");
751        let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
752        assert_eq!(m, back);
753    }
754
755    #[test]
756    fn partial_config_deserializes_with_defaults() {
757        // Only override one field — the rest should default.
758        let json = r#"{"relevance_weight": 0.5}"#;
759        let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
760        // specified field
761        let diff = (cfg.relevance_weight - 0.5).abs();
762        assert!(diff < 1e-12);
763        // unspecified fields keep defaults
764        let diff2 = (cfg.salience_weight - 0.20).abs();
765        assert!(diff2 < 1e-12);
766        assert_eq!(cfg.decay_model, DecayModel::Exponential);
767    }
768
769    // ── RecallConfig new fields ───────────────────────────────────────────────
770
771    #[test]
772    fn new_fields_have_correct_defaults() {
773        let cfg = RecallConfig::default();
774        assert_eq!(cfg.candidate_limit, Some(150));
775        // CC-6: default changed to Weighted [0.7, 0.3] so salience can influence ranking
776        assert!(
777            matches!(
778                cfg.fuse_strategy,
779                FusionStrategy::Weighted { ref weights } if weights == &vec![0.7_f64, 0.3_f64]
780            ),
781            "default fuse_strategy should be Weighted [0.7, 0.3], got {:?}",
782            cfg.fuse_strategy
783        );
784        assert!(!cfg.include_breakdown);
785    }
786
787    #[test]
788    fn candidate_limit_zero_fails_validation() {
789        let cfg = RecallConfig {
790            candidate_limit: Some(0),
791            ..RecallConfig::default()
792        };
793        assert!(cfg.validate().is_err());
794    }
795
796    #[test]
797    fn candidate_limit_some_positive_validates() {
798        let cfg = RecallConfig {
799            candidate_limit: Some(100),
800            ..RecallConfig::default()
801        };
802        assert!(cfg.validate().is_ok());
803    }
804
805    #[test]
806    fn min_score_nan_fails_validation() {
807        let cfg = RecallConfig {
808            min_score: f64::NAN,
809            ..RecallConfig::default()
810        };
811        assert!(cfg.validate().is_err());
812    }
813
814    #[test]
815    fn min_salience_nan_fails_validation() {
816        let cfg = RecallConfig {
817            min_salience: f64::NAN,
818            ..RecallConfig::default()
819        };
820        assert!(cfg.validate().is_err());
821    }
822
823    #[test]
824    fn new_fields_roundtrip() {
825        let cfg = RecallConfig {
826            candidate_limit: Some(50),
827            fuse_strategy: FusionStrategy::Union,
828            include_breakdown: true,
829            ..RecallConfig::default()
830        };
831        let json = serde_json::to_string(&cfg).expect("serialize");
832        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
833        assert_eq!(back.candidate_limit, Some(50));
834        assert_eq!(back.fuse_strategy, FusionStrategy::Union);
835        assert!(back.include_breakdown);
836    }
837
838    #[test]
839    fn partial_config_new_fields_use_defaults() {
840        // Parse JSON that omits all new fields — they should fall back to defaults.
841        // With #[serde(default)] on the struct, missing fields use RecallConfig::default(),
842        // so candidate_limit falls back to Some(150), not Option::default() == None.
843        let json = r#"{"temporal_weight": 0.15}"#;
844        let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
845        assert_eq!(cfg.candidate_limit, Some(150));
846        // CC-6: default changed to Weighted [0.7, 0.3]
847        assert!(
848            matches!(cfg.fuse_strategy, FusionStrategy::Weighted { .. }),
849            "partial config must deserialize fuse_strategy to Weighted default"
850        );
851        assert!(!cfg.include_breakdown);
852    }
853
854    // ── ScoreBreakdown ────────────────────────────────────────────────────────
855
856    #[test]
857    fn score_breakdown_total_sums_contributions() {
858        let bd = ScoreBreakdown {
859            relevance: 0.5,
860            salience_raw: 0.8,
861            salience_decayed: 0.6,
862            temporal: 0.3,
863            weighted: WeightedContributions {
864                relevance_contribution: 0.35,
865                salience_contribution: 0.12,
866                temporal_contribution: 0.03,
867            },
868        };
869        let expected = 0.35 + 0.12 + 0.03;
870        let diff = (bd.total() - expected).abs();
871        assert!(
872            diff < 1e-12,
873            "total() should sum weighted contributions, got {}",
874            bd.total()
875        );
876    }
877
878    // ── RecallFtsGatherConfig ─────────────────────────────────────────────────
879
880    #[test]
881    fn fts_gather_default_is_disabled() {
882        let cfg = RecallFtsGatherConfig::default();
883        assert!(!cfg.enabled);
884        assert_eq!(cfg.term_k, 10);
885        assert_eq!(cfg.selection_rule, RecallFtsSelectionRule::Original);
886        assert_eq!(cfg.gather_mode, RecallFtsGatherMode::Ranked);
887        assert!(cfg.gather_limit.is_none());
888        assert_eq!(cfg.gather_cap_multiplier, 4);
889        assert!(cfg.cjk_bypass_ranked);
890    }
891
892    #[test]
893    fn fts_gather_validates_zero_term_k() {
894        let cfg = RecallFtsGatherConfig {
895            term_k: 0,
896            ..RecallFtsGatherConfig::default()
897        };
898        assert!(cfg.validate().is_err());
899    }
900
901    #[test]
902    fn fts_gather_validates_zero_multiplier() {
903        let cfg = RecallFtsGatherConfig {
904            gather_cap_multiplier: 0,
905            ..RecallFtsGatherConfig::default()
906        };
907        assert!(cfg.validate().is_err());
908    }
909
910    #[test]
911    fn fts_gather_validates_zero_gather_limit() {
912        let cfg = RecallFtsGatherConfig {
913            gather_limit: Some(0),
914            ..RecallFtsGatherConfig::default()
915        };
916        assert!(cfg.validate().is_err());
917    }
918
919    #[test]
920    fn fts_gather_effective_gather_limit_uses_multiplier() {
921        let cfg = RecallFtsGatherConfig {
922            gather_cap_multiplier: 4,
923            ..RecallFtsGatherConfig::default()
924        };
925        assert_eq!(cfg.effective_gather_limit(150).unwrap(), 600);
926    }
927
928    #[test]
929    fn fts_gather_effective_gather_limit_explicit_wins() {
930        let cfg = RecallFtsGatherConfig {
931            gather_limit: Some(500),
932            gather_cap_multiplier: 4,
933            ..RecallFtsGatherConfig::default()
934        };
935        assert_eq!(cfg.effective_gather_limit(150).unwrap(), 500);
936    }
937
938    #[test]
939    fn fts_gather_effective_gather_limit_too_small_fails() {
940        let cfg = RecallFtsGatherConfig {
941            gather_limit: Some(100),
942            ..RecallFtsGatherConfig::default()
943        };
944        assert!(cfg.effective_gather_limit(150).is_err());
945    }
946
947    #[test]
948    fn fts_gather_from_env_returns_none_when_no_env_set() {
949        // Ensure none of the relevant vars are set in CI/test environment.
950        // If they are set by a prior test, this test may be flaky; the vars
951        // are prefixed to avoid collisions.
952        if std::env::var("KHIVE_RECALL_FTS_GATHER").is_ok()
953            || std::env::var("KHIVE_RECALL_FTS_TERM_K").is_ok()
954        {
955            return; // skip if env is already configured
956        }
957        let result = RecallFtsGatherConfig::from_env().unwrap();
958        assert!(result.is_none());
959    }
960
961    #[test]
962    fn fts_gather_invalid_gather_value_fails() {
963        // We can't easily set env vars in unit tests without affecting other
964        // tests. This exercises the validation branch directly.
965        // Manually simulate what from_env would produce for an invalid value.
966        let cfg = RecallFtsGatherConfig {
967            term_k: 0,
968            ..RecallFtsGatherConfig::default()
969        };
970        assert!(cfg.validate().is_err(), "term_k=0 must fail validation");
971    }
972
973    #[test]
974    fn fts_gather_from_into_gather_mode() {
975        assert_eq!(
976            TextGatherMode::from(RecallFtsGatherMode::Ranked),
977            TextGatherMode::Ranked
978        );
979        assert_eq!(
980            TextGatherMode::from(RecallFtsGatherMode::Unranked),
981            TextGatherMode::Unranked
982        );
983        assert_eq!(
984            TextGatherMode::from(RecallFtsGatherMode::RankWithinCap),
985            TextGatherMode::RankWithinCap
986        );
987    }
988
989    #[test]
990    fn recall_config_default_has_fts_gather() {
991        let cfg = RecallConfig::default();
992        assert!(!cfg.fts_gather.enabled);
993    }
994
995    #[test]
996    fn recall_config_roundtrip_with_fts_gather() {
997        let cfg = RecallConfig {
998            fts_gather: RecallFtsGatherConfig {
999                enabled: true,
1000                term_k: 5,
1001                selection_rule: RecallFtsSelectionRule::HighestIdf,
1002                gather_mode: RecallFtsGatherMode::RankWithinCap,
1003                gather_limit: Some(600),
1004                gather_cap_multiplier: 4,
1005                cjk_bypass_ranked: true,
1006            },
1007            ..RecallConfig::default()
1008        };
1009        let json = serde_json::to_string(&cfg).expect("serialize");
1010        let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
1011        assert!(back.fts_gather.enabled);
1012        assert_eq!(back.fts_gather.term_k, 5);
1013        assert_eq!(
1014            back.fts_gather.gather_mode,
1015            RecallFtsGatherMode::RankWithinCap
1016        );
1017        assert_eq!(back.fts_gather.gather_limit, Some(600));
1018    }
1019}