1use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8use khive_fusion::FusionStrategy;
9use khive_runtime::RuntimeError;
10use khive_storage::types::{TextGatherMode, TextSearchOptions};
11
12#[derive(Debug, Clone)]
14pub enum MinScoreError {
15 NotFinite,
17 OutOfRange(f64),
19}
20
21impl std::fmt::Display for MinScoreError {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 Self::NotFinite => write!(f, "min_score must be finite"),
25 Self::OutOfRange(v) => write!(
26 f,
27 "min_score {v} out of range: must be 0.0–1.0 (fraction) or 0–100 (percent)"
28 ),
29 }
30 }
31}
32
33impl From<MinScoreError> for RuntimeError {
34 fn from(e: MinScoreError) -> Self {
35 RuntimeError::InvalidInput(e.to_string())
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(default)]
43pub struct RecallConfig {
44 pub relevance_weight: f64,
47 pub salience_weight: f64,
49 pub temporal_weight: f64,
51
52 pub reranker_weights: HashMap<String, f64>,
56
57 pub temporal_half_life_days: f64,
60 pub decay_model: DecayModel,
62
63 pub candidate_multiplier: u32,
66 pub candidate_limit: Option<u32>,
69 pub fuse_strategy: FusionStrategy,
71 pub min_score: f64,
73 pub min_salience: f64,
75 pub include_breakdown: bool,
77
78 pub scoring: Option<crate::scoring::ScoringConfig>,
81
82 pub brain_profile: Option<BrainProfileHint>,
85
86 pub fts_gather: RecallFtsGatherConfig,
89
90 pub ann_overfetch_max_rounds: Option<usize>,
97
98 pub ann_ready_timeout_ms: Option<u64>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct BrainProfileHint {
112 pub profile_id: String,
114 #[serde(default = "BrainProfileHint::default_boost")]
116 pub boost: f64,
117 #[serde(default = "BrainProfileHint::default_threshold")]
119 pub threshold: f64,
120}
121
122impl BrainProfileHint {
123 fn default_boost() -> f64 {
124 1.3
125 }
126 fn default_threshold() -> f64 {
127 0.6
128 }
129}
130
131#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "snake_case")]
134pub enum RecallFtsSelectionRule {
135 #[default]
137 Original,
138 LowestDf,
140 HighestIdf,
142}
143
144#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "snake_case")]
147pub enum RecallFtsGatherMode {
148 #[default]
149 Ranked,
150 Unranked,
151 RankWithinCap,
152}
153
154impl From<RecallFtsGatherMode> for TextGatherMode {
155 fn from(m: RecallFtsGatherMode) -> Self {
156 match m {
157 RecallFtsGatherMode::Ranked => TextGatherMode::Ranked,
158 RecallFtsGatherMode::Unranked => TextGatherMode::Unranked,
159 RecallFtsGatherMode::RankWithinCap => TextGatherMode::RankWithinCap,
160 }
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(default)]
167pub struct RecallFtsGatherConfig {
168 pub enabled: bool,
170 pub term_k: usize,
172 pub selection_rule: RecallFtsSelectionRule,
174 pub gather_mode: RecallFtsGatherMode,
176 pub gather_limit: Option<u32>,
178 pub gather_cap_multiplier: u32,
180 pub cjk_bypass_ranked: bool,
182}
183
184impl Default for RecallFtsGatherConfig {
185 fn default() -> Self {
186 Self {
187 enabled: false,
188 term_k: 10,
189 selection_rule: RecallFtsSelectionRule::Original,
190 gather_mode: RecallFtsGatherMode::Ranked,
191 gather_limit: None,
192 gather_cap_multiplier: 4,
193 cjk_bypass_ranked: true,
194 }
195 }
196}
197
198impl RecallFtsGatherConfig {
199 pub fn from_env() -> Result<Option<Self>, RuntimeError> {
201 let gather = std::env::var("KHIVE_RECALL_FTS_GATHER").ok();
202 let term_k = std::env::var("KHIVE_RECALL_FTS_TERM_K").ok();
203 let selection = std::env::var("KHIVE_RECALL_FTS_SELECTION").ok();
204 let limit = std::env::var("KHIVE_RECALL_FTS_GATHER_LIMIT").ok();
205 let multiplier = std::env::var("KHIVE_RECALL_FTS_GATHER_MULTIPLIER").ok();
206 let cjk_bypass = std::env::var("KHIVE_RECALL_FTS_CJK_BYPASS").ok();
207
208 if gather.is_none()
209 && term_k.is_none()
210 && selection.is_none()
211 && limit.is_none()
212 && multiplier.is_none()
213 && cjk_bypass.is_none()
214 {
215 return Ok(None);
216 }
217
218 let mut cfg = RecallFtsGatherConfig {
219 enabled: true,
220 ..RecallFtsGatherConfig::default()
221 };
222
223 if let Some(g) = gather {
224 match g.as_str() {
225 "baseline" => {
226 cfg.enabled = false;
227 }
228 "ranked" => {
229 cfg.gather_mode = RecallFtsGatherMode::Ranked;
230 }
231 "rank_subset" => {
232 cfg.gather_mode = RecallFtsGatherMode::RankWithinCap;
233 }
234 "unranked" => {
235 cfg.gather_mode = RecallFtsGatherMode::Unranked;
236 }
237 other => {
238 return Err(RuntimeError::InvalidInput(format!(
239 "KHIVE_RECALL_FTS_GATHER must be baseline|ranked|rank_subset|unranked, got {other:?}"
240 )));
241 }
242 }
243 }
244
245 if let Some(k) = term_k {
246 let v: usize = k.parse().map_err(|_| {
247 RuntimeError::InvalidInput(format!(
248 "KHIVE_RECALL_FTS_TERM_K must be a positive integer 1..10, got {k:?}"
249 ))
250 })?;
251 if v == 0 || v > 10 {
252 return Err(RuntimeError::InvalidInput(format!(
253 "KHIVE_RECALL_FTS_TERM_K must be 1..10, got {v}"
254 )));
255 }
256 cfg.term_k = v;
257 }
258
259 if let Some(s) = selection {
260 cfg.selection_rule = match s.as_str() {
261 "original" => RecallFtsSelectionRule::Original,
262 "lowest_df" => RecallFtsSelectionRule::LowestDf,
263 "highest_idf" => RecallFtsSelectionRule::HighestIdf,
264 other => {
265 return Err(RuntimeError::InvalidInput(format!(
266 "KHIVE_RECALL_FTS_SELECTION must be original|lowest_df|highest_idf, got {other:?}"
267 )));
268 }
269 };
270 }
271
272 if let Some(l) = limit {
273 let v: u32 = l.parse().map_err(|_| {
274 RuntimeError::InvalidInput(format!(
275 "KHIVE_RECALL_FTS_GATHER_LIMIT must be a positive integer, got {l:?}"
276 ))
277 })?;
278 if v == 0 {
279 return Err(RuntimeError::InvalidInput(
280 "KHIVE_RECALL_FTS_GATHER_LIMIT must be > 0".to_string(),
281 ));
282 }
283 cfg.gather_limit = Some(v);
284 }
285
286 if let Some(m) = multiplier {
287 let v: u32 = m.parse().map_err(|_| {
288 RuntimeError::InvalidInput(format!(
289 "KHIVE_RECALL_FTS_GATHER_MULTIPLIER must be a positive integer, got {m:?}"
290 ))
291 })?;
292 if v == 0 {
293 return Err(RuntimeError::InvalidInput(
294 "KHIVE_RECALL_FTS_GATHER_MULTIPLIER must be > 0".to_string(),
295 ));
296 }
297 cfg.gather_cap_multiplier = v;
298 }
299
300 if let Some(b) = cjk_bypass {
301 cfg.cjk_bypass_ranked = match b.as_str() {
302 "1" | "true" => true,
303 "0" | "false" => false,
304 other => {
305 return Err(RuntimeError::InvalidInput(format!(
306 "KHIVE_RECALL_FTS_CJK_BYPASS must be 1|0, got {other:?}"
307 )));
308 }
309 };
310 }
311
312 cfg.validate()?;
313 Ok(Some(cfg))
314 }
315
316 pub fn validate(&self) -> Result<(), RuntimeError> {
318 if self.term_k == 0 {
319 return Err(RuntimeError::InvalidInput(
320 "fts_gather.term_k must be > 0".to_string(),
321 ));
322 }
323 if self.gather_cap_multiplier == 0 {
324 return Err(RuntimeError::InvalidInput(
325 "fts_gather.gather_cap_multiplier must be > 0".to_string(),
326 ));
327 }
328 if let Some(gl) = self.gather_limit {
329 if gl == 0 {
330 return Err(RuntimeError::InvalidInput(
331 "fts_gather.gather_limit must be > 0 when provided".to_string(),
332 ));
333 }
334 }
335 Ok(())
336 }
337
338 pub fn effective_gather_limit(&self, candidate_limit: u32) -> Result<u32, RuntimeError> {
340 let gl = match self.gather_limit {
341 Some(explicit) => {
342 if explicit < candidate_limit {
343 return Err(RuntimeError::InvalidInput(format!(
344 "fts_gather.gather_limit ({explicit}) must be >= candidate_limit ({candidate_limit})"
345 )));
346 }
347 explicit
348 }
349 None => candidate_limit.saturating_mul(self.gather_cap_multiplier),
350 };
351 Ok(gl.max(candidate_limit))
352 }
353
354 pub fn to_search_options(
356 &self,
357 candidate_limit: u32,
358 ) -> Result<TextSearchOptions, RuntimeError> {
359 let gather_limit = self.effective_gather_limit(candidate_limit)?;
360 Ok(TextSearchOptions {
361 gather_mode: self.gather_mode.into(),
362 gather_limit: Some(gather_limit),
363 })
364 }
365}
366
367impl Default for RecallConfig {
381 fn default() -> Self {
382 Self {
383 relevance_weight: 0.70,
384 salience_weight: 0.20,
385 temporal_weight: 0.10,
386 reranker_weights: HashMap::new(),
387 temporal_half_life_days: 30.0,
388 decay_model: DecayModel::default(),
389 candidate_multiplier: 20,
390 candidate_limit: Some(150),
391 fuse_strategy: FusionStrategy::Weighted {
397 weights: vec![0.7, 0.3],
398 },
399 min_score: 0.0,
400 min_salience: 0.0,
401 include_breakdown: false,
402 scoring: None,
403 brain_profile: None,
404 fts_gather: RecallFtsGatherConfig::default(),
405 ann_overfetch_max_rounds: None,
406 ann_ready_timeout_ms: None,
407 }
408 }
409}
410
411impl RecallConfig {
412 pub fn validate(&self) -> Result<(), RuntimeError> {
414 if !self.relevance_weight.is_finite() || self.relevance_weight < 0.0 {
415 return Err(RuntimeError::InvalidInput(
416 "relevance_weight must be a finite non-negative number".to_string(),
417 ));
418 }
419 if !self.salience_weight.is_finite() || self.salience_weight < 0.0 {
420 return Err(RuntimeError::InvalidInput(
421 "salience_weight must be a finite non-negative number".to_string(),
422 ));
423 }
424 if !self.temporal_weight.is_finite() || self.temporal_weight < 0.0 {
425 return Err(RuntimeError::InvalidInput(
426 "temporal_weight must be a finite non-negative number".to_string(),
427 ));
428 }
429 let weight_sum = self.relevance_weight + self.salience_weight + self.temporal_weight;
430 if weight_sum <= 0.0 {
431 return Err(RuntimeError::InvalidInput(
432 "at least one of relevance_weight / salience_weight / temporal_weight must be positive".to_string(),
433 ));
434 }
435 for (name, &weight) in &self.reranker_weights {
436 if !weight.is_finite() || weight < 0.0 {
437 return Err(RuntimeError::InvalidInput(format!(
438 "reranker_weights[{name:?}] must be a finite non-negative number"
439 )));
440 }
441 }
442 if !self.temporal_half_life_days.is_finite() || self.temporal_half_life_days <= 0.0 {
443 return Err(RuntimeError::InvalidInput(
444 "temporal_half_life_days must be a finite positive number".to_string(),
445 ));
446 }
447 if let DecayModel::PowerLaw { half_life_days } = self.decay_model {
449 if !half_life_days.is_finite() || half_life_days <= 0.0 {
450 return Err(RuntimeError::InvalidInput(
451 "decay_model.power_law.half_life_days must be a finite positive number"
452 .to_string(),
453 ));
454 }
455 }
456 if self.candidate_limit == Some(0) {
457 return Err(RuntimeError::InvalidInput(
458 "candidate_limit must be positive when provided".to_string(),
459 ));
460 }
461 if !self.min_score.is_finite() {
462 return Err(RuntimeError::InvalidInput(
463 "min_score must be finite".to_string(),
464 ));
465 }
466 if !self.min_salience.is_finite() {
467 return Err(RuntimeError::InvalidInput(
468 "min_salience must be finite".to_string(),
469 ));
470 }
471 Ok(())
472 }
473
474 pub fn try_from_value(v: serde_json::Value) -> Result<Self, RuntimeError> {
476 let cfg: Self =
477 serde_json::from_value(v).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
478 cfg.validate()?;
479 Ok(cfg)
480 }
481}
482
483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
485#[serde(rename_all = "snake_case")]
486pub enum DecayModel {
487 #[default]
490 Exponential,
491 Hyperbolic,
493 PowerLaw {
495 half_life_days: f64,
497 },
498 None,
500}
501
502impl DecayModel {
503 pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
505 match self {
506 DecayModel::Exponential => {
507 salience * (-decay_factor * age_days).exp()
510 }
511 DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
512 DecayModel::PowerLaw { half_life_days } => {
513 let hl = *half_life_days;
514 salience * hl / (hl + age_days)
515 }
516 DecayModel::None => salience,
517 }
518 }
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct ScoreBreakdown {
524 pub relevance: f64,
526 pub salience_raw: f64,
528 pub salience_decayed: f64,
530 pub temporal: f64,
532 pub weighted: WeightedContributions,
534 pub profile_component: f64,
540 pub entity_posterior_mean: Option<f64>,
551}
552
553impl ScoreBreakdown {
554 pub fn total(&self) -> f64 {
556 self.weighted.relevance_contribution
557 + self.weighted.salience_contribution
558 + self.weighted.temporal_contribution
559 }
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct WeightedContributions {
565 pub relevance_contribution: f64,
566 pub salience_contribution: f64,
567 pub temporal_contribution: f64,
568}
569
570#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
581 fn exponential_halves_at_decay_factor_half_life() {
582 let model = DecayModel::Exponential;
585 let salience = 1.0;
586 let decay_factor = 0.01;
587 let half_life_days = std::f64::consts::LN_2 / decay_factor;
588 let result = model.apply(salience, half_life_days, decay_factor, 30.0);
589 let diff = (result - 0.5).abs();
590 assert!(
591 diff < 1e-10,
592 "exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
593 );
594 }
595
596 #[test]
597 fn exponential_full_salience_at_zero_age() {
598 let model = DecayModel::Exponential;
599 let result = model.apply(0.8, 0.0, 0.01, 30.0);
600 let diff = (result - 0.8).abs();
601 assert!(
602 diff < 1e-12,
603 "at age=0 salience should be unchanged, got {result}"
604 );
605 }
606
607 #[test]
608 fn exponential_uses_note_decay_factor_not_half_life() {
609 let model = DecayModel::Exponential;
613 let result = model.apply(1.0, 1.0, 1.0, 10.0);
614 let expected = (-1.0f64).exp();
615 assert!(
616 (result - expected).abs() < 1e-12,
617 "expected {expected}, got {result}"
618 );
619 }
620
621 #[test]
622 fn hyperbolic_halves_at_one_over_decay_factor() {
623 let model = DecayModel::Hyperbolic;
625 let salience = 1.0;
626 let k = 0.05;
627 let age = 1.0 / k; let result = model.apply(salience, age, k, 30.0);
629 let diff = (result - 0.5).abs();
630 assert!(
631 diff < 1e-10,
632 "hyperbolic at age=1/k should give 0.5, got {result}"
633 );
634 }
635
636 #[test]
637 fn hyperbolic_full_salience_at_zero_age() {
638 let model = DecayModel::Hyperbolic;
639 let result = model.apply(0.7, 0.0, 0.05, 30.0);
640 let diff = (result - 0.7).abs();
641 assert!(
642 diff < 1e-12,
643 "at age=0 salience should be unchanged, got {result}"
644 );
645 }
646
647 #[test]
648 fn powerlaw_halves_at_half_life() {
649 let hl = 30.0;
650 let model = DecayModel::PowerLaw { half_life_days: hl };
651 let salience = 1.0;
652 let result = model.apply(salience, hl, 0.01, hl);
654 let diff = (result - 0.5).abs();
655 assert!(
656 diff < 1e-10,
657 "power-law should give 0.5 at half-life, got {result}"
658 );
659 }
660
661 #[test]
662 fn decay_none_returns_salience_unchanged() {
663 let model = DecayModel::None;
664 let result = model.apply(0.6, 100.0, 0.99, 30.0);
665 let diff = (result - 0.6).abs();
666 assert!(
667 diff < 1e-12,
668 "None model must not alter salience, got {result}"
669 );
670 }
671
672 #[test]
675 fn default_config_validates() {
676 assert!(RecallConfig::default().validate().is_ok());
677 }
678
679 #[test]
680 fn negative_relevance_weight_fails_validation() {
681 let cfg = RecallConfig {
682 relevance_weight: -0.1,
683 ..RecallConfig::default()
684 };
685 assert!(cfg.validate().is_err());
686 }
687
688 #[test]
689 fn negative_salience_weight_fails_validation() {
690 let cfg = RecallConfig {
691 salience_weight: -1.0,
692 ..RecallConfig::default()
693 };
694 assert!(cfg.validate().is_err());
695 }
696
697 #[test]
698 fn negative_temporal_weight_fails_validation() {
699 let cfg = RecallConfig {
700 temporal_weight: -0.5,
701 ..RecallConfig::default()
702 };
703 assert!(cfg.validate().is_err());
704 }
705
706 #[test]
707 fn all_zero_weights_fails_validation() {
708 let cfg = RecallConfig {
709 relevance_weight: 0.0,
710 salience_weight: 0.0,
711 temporal_weight: 0.0,
712 ..RecallConfig::default()
713 };
714 assert!(cfg.validate().is_err());
715 }
716
717 #[test]
718 fn zero_half_life_fails_validation() {
719 let cfg = RecallConfig {
720 temporal_half_life_days: 0.0,
721 ..RecallConfig::default()
722 };
723 assert!(cfg.validate().is_err());
724 }
725
726 #[test]
727 fn negative_half_life_fails_validation() {
728 let cfg = RecallConfig {
729 temporal_half_life_days: -5.0,
730 ..RecallConfig::default()
731 };
732 assert!(cfg.validate().is_err());
733 }
734
735 #[test]
736 fn non_uniform_weights_validate() {
737 let cfg = RecallConfig {
738 relevance_weight: 0.5,
739 salience_weight: 0.3,
740 temporal_weight: 0.2,
741 ..RecallConfig::default()
742 };
743 assert!(cfg.validate().is_ok());
744 }
745
746 #[test]
749 fn default_config_roundtrip() {
750 let cfg = RecallConfig::default();
751 let json = serde_json::to_string(&cfg).expect("serialize");
752 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
753 let diff = (cfg.relevance_weight - back.relevance_weight).abs();
754 assert!(diff < 1e-12);
755 assert_eq!(cfg.decay_model, back.decay_model);
756 }
757
758 #[test]
759 fn decay_model_exponential_roundtrip() {
760 let m = DecayModel::Exponential;
761 let json = serde_json::to_string(&m).expect("serialize");
762 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
763 assert_eq!(m, back);
764 }
765
766 #[test]
767 fn decay_model_hyperbolic_roundtrip() {
768 let m = DecayModel::Hyperbolic;
769 let json = serde_json::to_string(&m).expect("serialize");
770 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
771 assert_eq!(m, back);
772 }
773
774 #[test]
775 fn decay_model_powerlaw_roundtrip() {
776 let m = DecayModel::PowerLaw {
777 half_life_days: 14.0,
778 };
779 let json = serde_json::to_string(&m).expect("serialize");
780 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
781 assert_eq!(m, back);
782 }
783
784 #[test]
785 fn decay_model_none_roundtrip() {
786 let m = DecayModel::None;
787 let json = serde_json::to_string(&m).expect("serialize");
788 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
789 assert_eq!(m, back);
790 }
791
792 #[test]
793 fn partial_config_deserializes_with_defaults() {
794 let json = r#"{"relevance_weight": 0.5}"#;
796 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
797 let diff = (cfg.relevance_weight - 0.5).abs();
799 assert!(diff < 1e-12);
800 let diff2 = (cfg.salience_weight - 0.20).abs();
802 assert!(diff2 < 1e-12);
803 assert_eq!(cfg.decay_model, DecayModel::Exponential);
804 }
805
806 #[test]
809 fn new_fields_have_correct_defaults() {
810 let cfg = RecallConfig::default();
811 assert_eq!(cfg.candidate_limit, Some(150));
812 assert!(
814 matches!(
815 cfg.fuse_strategy,
816 FusionStrategy::Weighted { ref weights } if weights == &vec![0.7_f64, 0.3_f64]
817 ),
818 "default fuse_strategy should be Weighted [0.7, 0.3], got {:?}",
819 cfg.fuse_strategy
820 );
821 assert!(!cfg.include_breakdown);
822 }
823
824 #[test]
825 fn candidate_limit_zero_fails_validation() {
826 let cfg = RecallConfig {
827 candidate_limit: Some(0),
828 ..RecallConfig::default()
829 };
830 assert!(cfg.validate().is_err());
831 }
832
833 #[test]
834 fn candidate_limit_some_positive_validates() {
835 let cfg = RecallConfig {
836 candidate_limit: Some(100),
837 ..RecallConfig::default()
838 };
839 assert!(cfg.validate().is_ok());
840 }
841
842 #[test]
843 fn min_score_nan_fails_validation() {
844 let cfg = RecallConfig {
845 min_score: f64::NAN,
846 ..RecallConfig::default()
847 };
848 assert!(cfg.validate().is_err());
849 }
850
851 #[test]
852 fn min_salience_nan_fails_validation() {
853 let cfg = RecallConfig {
854 min_salience: f64::NAN,
855 ..RecallConfig::default()
856 };
857 assert!(cfg.validate().is_err());
858 }
859
860 #[test]
861 fn new_fields_roundtrip() {
862 let cfg = RecallConfig {
863 candidate_limit: Some(50),
864 fuse_strategy: FusionStrategy::Union,
865 include_breakdown: true,
866 ..RecallConfig::default()
867 };
868 let json = serde_json::to_string(&cfg).expect("serialize");
869 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
870 assert_eq!(back.candidate_limit, Some(50));
871 assert_eq!(back.fuse_strategy, FusionStrategy::Union);
872 assert!(back.include_breakdown);
873 }
874
875 #[test]
876 fn partial_config_new_fields_use_defaults() {
877 let json = r#"{"temporal_weight": 0.15}"#;
881 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
882 assert_eq!(cfg.candidate_limit, Some(150));
883 assert!(
885 matches!(cfg.fuse_strategy, FusionStrategy::Weighted { .. }),
886 "partial config must deserialize fuse_strategy to Weighted default"
887 );
888 assert!(!cfg.include_breakdown);
889 }
890
891 #[test]
894 fn score_breakdown_total_sums_contributions() {
895 let bd = ScoreBreakdown {
896 relevance: 0.5,
897 salience_raw: 0.8,
898 salience_decayed: 0.6,
899 temporal: 0.3,
900 weighted: WeightedContributions {
901 relevance_contribution: 0.35,
902 salience_contribution: 0.12,
903 temporal_contribution: 0.03,
904 },
905 profile_component: 1.0,
906 entity_posterior_mean: None,
907 };
908 let expected = 0.35 + 0.12 + 0.03;
909 let diff = (bd.total() - expected).abs();
910 assert!(
911 diff < 1e-12,
912 "total() should sum weighted contributions, got {}",
913 bd.total()
914 );
915 }
916
917 #[test]
920 fn fts_gather_default_is_disabled() {
921 let cfg = RecallFtsGatherConfig::default();
922 assert!(!cfg.enabled);
923 assert_eq!(cfg.term_k, 10);
924 assert_eq!(cfg.selection_rule, RecallFtsSelectionRule::Original);
925 assert_eq!(cfg.gather_mode, RecallFtsGatherMode::Ranked);
926 assert!(cfg.gather_limit.is_none());
927 assert_eq!(cfg.gather_cap_multiplier, 4);
928 assert!(cfg.cjk_bypass_ranked);
929 }
930
931 #[test]
932 fn fts_gather_validates_zero_term_k() {
933 let cfg = RecallFtsGatherConfig {
934 term_k: 0,
935 ..RecallFtsGatherConfig::default()
936 };
937 assert!(cfg.validate().is_err());
938 }
939
940 #[test]
941 fn fts_gather_validates_zero_multiplier() {
942 let cfg = RecallFtsGatherConfig {
943 gather_cap_multiplier: 0,
944 ..RecallFtsGatherConfig::default()
945 };
946 assert!(cfg.validate().is_err());
947 }
948
949 #[test]
950 fn fts_gather_validates_zero_gather_limit() {
951 let cfg = RecallFtsGatherConfig {
952 gather_limit: Some(0),
953 ..RecallFtsGatherConfig::default()
954 };
955 assert!(cfg.validate().is_err());
956 }
957
958 #[test]
959 fn fts_gather_effective_gather_limit_uses_multiplier() {
960 let cfg = RecallFtsGatherConfig {
961 gather_cap_multiplier: 4,
962 ..RecallFtsGatherConfig::default()
963 };
964 assert_eq!(cfg.effective_gather_limit(150).unwrap(), 600);
965 }
966
967 #[test]
968 fn fts_gather_effective_gather_limit_explicit_wins() {
969 let cfg = RecallFtsGatherConfig {
970 gather_limit: Some(500),
971 gather_cap_multiplier: 4,
972 ..RecallFtsGatherConfig::default()
973 };
974 assert_eq!(cfg.effective_gather_limit(150).unwrap(), 500);
975 }
976
977 #[test]
978 fn fts_gather_effective_gather_limit_too_small_fails() {
979 let cfg = RecallFtsGatherConfig {
980 gather_limit: Some(100),
981 ..RecallFtsGatherConfig::default()
982 };
983 assert!(cfg.effective_gather_limit(150).is_err());
984 }
985
986 #[test]
987 fn fts_gather_from_env_returns_none_when_no_env_set() {
988 if std::env::var("KHIVE_RECALL_FTS_GATHER").is_ok()
992 || std::env::var("KHIVE_RECALL_FTS_TERM_K").is_ok()
993 {
994 return; }
996 let result = RecallFtsGatherConfig::from_env().unwrap();
997 assert!(result.is_none());
998 }
999
1000 #[test]
1001 fn fts_gather_invalid_gather_value_fails() {
1002 let cfg = RecallFtsGatherConfig {
1006 term_k: 0,
1007 ..RecallFtsGatherConfig::default()
1008 };
1009 assert!(cfg.validate().is_err(), "term_k=0 must fail validation");
1010 }
1011
1012 #[test]
1013 fn fts_gather_from_into_gather_mode() {
1014 assert_eq!(
1015 TextGatherMode::from(RecallFtsGatherMode::Ranked),
1016 TextGatherMode::Ranked
1017 );
1018 assert_eq!(
1019 TextGatherMode::from(RecallFtsGatherMode::Unranked),
1020 TextGatherMode::Unranked
1021 );
1022 assert_eq!(
1023 TextGatherMode::from(RecallFtsGatherMode::RankWithinCap),
1024 TextGatherMode::RankWithinCap
1025 );
1026 }
1027
1028 #[test]
1029 fn recall_config_default_has_fts_gather() {
1030 let cfg = RecallConfig::default();
1031 assert!(!cfg.fts_gather.enabled);
1032 }
1033
1034 #[test]
1035 fn recall_config_roundtrip_with_fts_gather() {
1036 let cfg = RecallConfig {
1037 fts_gather: RecallFtsGatherConfig {
1038 enabled: true,
1039 term_k: 5,
1040 selection_rule: RecallFtsSelectionRule::HighestIdf,
1041 gather_mode: RecallFtsGatherMode::RankWithinCap,
1042 gather_limit: Some(600),
1043 gather_cap_multiplier: 4,
1044 cjk_bypass_ranked: true,
1045 },
1046 ..RecallConfig::default()
1047 };
1048 let json = serde_json::to_string(&cfg).expect("serialize");
1049 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
1050 assert!(back.fts_gather.enabled);
1051 assert_eq!(back.fts_gather.term_k, 5);
1052 assert_eq!(
1053 back.fts_gather.gather_mode,
1054 RecallFtsGatherMode::RankWithinCap
1055 );
1056 assert_eq!(back.fts_gather.gather_limit, Some(600));
1057 }
1058}