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
90#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct BrainProfileHint {
94 pub profile_id: String,
96 #[serde(default = "BrainProfileHint::default_boost")]
98 pub boost: f64,
99 #[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#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
115#[serde(rename_all = "snake_case")]
116pub enum RecallFtsSelectionRule {
117 #[default]
119 Original,
120 LowestDf,
122 HighestIdf,
124}
125
126#[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#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(default)]
149pub struct RecallFtsGatherConfig {
150 pub enabled: bool,
152 pub term_k: usize,
154 pub selection_rule: RecallFtsSelectionRule,
156 pub gather_mode: RecallFtsGatherMode,
158 pub gather_limit: Option<u32>,
160 pub gather_cap_multiplier: u32,
162 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 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 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 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 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
349impl 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
465#[serde(rename_all = "snake_case")]
466pub enum DecayModel {
467 #[default]
470 Exponential,
471 Hyperbolic,
473 PowerLaw {
475 half_life_days: f64,
477 },
478 None,
480}
481
482impl DecayModel {
483 pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
485 match self {
486 DecayModel::Exponential => {
487 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#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct ScoreBreakdown {
504 pub relevance: f64,
506 pub salience_raw: f64,
508 pub salience_decayed: f64,
510 pub temporal: f64,
512 pub weighted: WeightedContributions,
514}
515
516impl ScoreBreakdown {
517 pub fn total(&self) -> f64 {
519 self.weighted.relevance_contribution
520 + self.weighted.salience_contribution
521 + self.weighted.temporal_contribution
522 }
523}
524
525#[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#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
544 fn exponential_halves_at_decay_factor_half_life() {
545 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 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 let model = DecayModel::Hyperbolic;
588 let salience = 1.0;
589 let k = 0.05;
590 let age = 1.0 / k; 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 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 #[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 #[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 let json = r#"{"relevance_weight": 0.5}"#;
759 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
760 let diff = (cfg.relevance_weight - 0.5).abs();
762 assert!(diff < 1e-12);
763 let diff2 = (cfg.salience_weight - 0.20).abs();
765 assert!(diff2 < 1e-12);
766 assert_eq!(cfg.decay_model, DecayModel::Exponential);
767 }
768
769 #[test]
772 fn new_fields_have_correct_defaults() {
773 let cfg = RecallConfig::default();
774 assert_eq!(cfg.candidate_limit, Some(150));
775 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 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 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 #[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 #[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 if std::env::var("KHIVE_RECALL_FTS_GATHER").is_ok()
953 || std::env::var("KHIVE_RECALL_FTS_TERM_K").is_ok()
954 {
955 return; }
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 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}