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