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