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]
469 Exponential,
470 Hyperbolic,
472 PowerLaw {
474 half_life_days: f64,
476 },
477 None,
479}
480
481impl DecayModel {
482 pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
484 match self {
485 DecayModel::Exponential => {
486 salience * (-decay_factor * age_days).exp()
489 }
490 DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
491 DecayModel::PowerLaw { half_life_days } => {
492 let hl = *half_life_days;
493 salience * hl / (hl + age_days)
494 }
495 DecayModel::None => salience,
496 }
497 }
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct ScoreBreakdown {
503 pub relevance: f64,
505 pub salience_raw: f64,
507 pub salience_decayed: f64,
509 pub temporal: f64,
511 pub weighted: WeightedContributions,
513}
514
515impl ScoreBreakdown {
516 pub fn total(&self) -> f64 {
518 self.weighted.relevance_contribution
519 + self.weighted.salience_contribution
520 + self.weighted.temporal_contribution
521 }
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct WeightedContributions {
527 pub relevance_contribution: f64,
528 pub salience_contribution: f64,
529 pub temporal_contribution: f64,
530}
531
532#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
543 fn exponential_halves_at_decay_factor_half_life() {
544 let model = DecayModel::Exponential;
547 let salience = 1.0;
548 let decay_factor = 0.01;
549 let half_life_days = std::f64::consts::LN_2 / decay_factor;
550 let result = model.apply(salience, half_life_days, decay_factor, 30.0);
551 let diff = (result - 0.5).abs();
552 assert!(
553 diff < 1e-10,
554 "exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
555 );
556 }
557
558 #[test]
559 fn exponential_full_salience_at_zero_age() {
560 let model = DecayModel::Exponential;
561 let result = model.apply(0.8, 0.0, 0.01, 30.0);
562 let diff = (result - 0.8).abs();
563 assert!(
564 diff < 1e-12,
565 "at age=0 salience should be unchanged, got {result}"
566 );
567 }
568
569 #[test]
570 fn exponential_uses_note_decay_factor_not_half_life() {
571 let model = DecayModel::Exponential;
575 let result = model.apply(1.0, 1.0, 1.0, 10.0);
576 let expected = (-1.0f64).exp();
577 assert!(
578 (result - expected).abs() < 1e-12,
579 "expected {expected}, got {result}"
580 );
581 }
582
583 #[test]
584 fn hyperbolic_halves_at_one_over_decay_factor() {
585 let model = DecayModel::Hyperbolic;
587 let salience = 1.0;
588 let k = 0.05;
589 let age = 1.0 / k; let result = model.apply(salience, age, k, 30.0);
591 let diff = (result - 0.5).abs();
592 assert!(
593 diff < 1e-10,
594 "hyperbolic at age=1/k should give 0.5, got {result}"
595 );
596 }
597
598 #[test]
599 fn hyperbolic_full_salience_at_zero_age() {
600 let model = DecayModel::Hyperbolic;
601 let result = model.apply(0.7, 0.0, 0.05, 30.0);
602 let diff = (result - 0.7).abs();
603 assert!(
604 diff < 1e-12,
605 "at age=0 salience should be unchanged, got {result}"
606 );
607 }
608
609 #[test]
610 fn powerlaw_halves_at_half_life() {
611 let hl = 30.0;
612 let model = DecayModel::PowerLaw { half_life_days: hl };
613 let salience = 1.0;
614 let result = model.apply(salience, hl, 0.01, hl);
616 let diff = (result - 0.5).abs();
617 assert!(
618 diff < 1e-10,
619 "power-law should give 0.5 at half-life, got {result}"
620 );
621 }
622
623 #[test]
624 fn decay_none_returns_salience_unchanged() {
625 let model = DecayModel::None;
626 let result = model.apply(0.6, 100.0, 0.99, 30.0);
627 let diff = (result - 0.6).abs();
628 assert!(
629 diff < 1e-12,
630 "None model must not alter salience, got {result}"
631 );
632 }
633
634 #[test]
637 fn default_config_validates() {
638 assert!(RecallConfig::default().validate().is_ok());
639 }
640
641 #[test]
642 fn negative_relevance_weight_fails_validation() {
643 let cfg = RecallConfig {
644 relevance_weight: -0.1,
645 ..RecallConfig::default()
646 };
647 assert!(cfg.validate().is_err());
648 }
649
650 #[test]
651 fn negative_salience_weight_fails_validation() {
652 let cfg = RecallConfig {
653 salience_weight: -1.0,
654 ..RecallConfig::default()
655 };
656 assert!(cfg.validate().is_err());
657 }
658
659 #[test]
660 fn negative_temporal_weight_fails_validation() {
661 let cfg = RecallConfig {
662 temporal_weight: -0.5,
663 ..RecallConfig::default()
664 };
665 assert!(cfg.validate().is_err());
666 }
667
668 #[test]
669 fn all_zero_weights_fails_validation() {
670 let cfg = RecallConfig {
671 relevance_weight: 0.0,
672 salience_weight: 0.0,
673 temporal_weight: 0.0,
674 ..RecallConfig::default()
675 };
676 assert!(cfg.validate().is_err());
677 }
678
679 #[test]
680 fn zero_half_life_fails_validation() {
681 let cfg = RecallConfig {
682 temporal_half_life_days: 0.0,
683 ..RecallConfig::default()
684 };
685 assert!(cfg.validate().is_err());
686 }
687
688 #[test]
689 fn negative_half_life_fails_validation() {
690 let cfg = RecallConfig {
691 temporal_half_life_days: -5.0,
692 ..RecallConfig::default()
693 };
694 assert!(cfg.validate().is_err());
695 }
696
697 #[test]
698 fn non_uniform_weights_validate() {
699 let cfg = RecallConfig {
700 relevance_weight: 0.5,
701 salience_weight: 0.3,
702 temporal_weight: 0.2,
703 ..RecallConfig::default()
704 };
705 assert!(cfg.validate().is_ok());
706 }
707
708 #[test]
711 fn default_config_roundtrip() {
712 let cfg = RecallConfig::default();
713 let json = serde_json::to_string(&cfg).expect("serialize");
714 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
715 let diff = (cfg.relevance_weight - back.relevance_weight).abs();
716 assert!(diff < 1e-12);
717 assert_eq!(cfg.decay_model, back.decay_model);
718 }
719
720 #[test]
721 fn decay_model_exponential_roundtrip() {
722 let m = DecayModel::Exponential;
723 let json = serde_json::to_string(&m).expect("serialize");
724 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
725 assert_eq!(m, back);
726 }
727
728 #[test]
729 fn decay_model_hyperbolic_roundtrip() {
730 let m = DecayModel::Hyperbolic;
731 let json = serde_json::to_string(&m).expect("serialize");
732 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
733 assert_eq!(m, back);
734 }
735
736 #[test]
737 fn decay_model_powerlaw_roundtrip() {
738 let m = DecayModel::PowerLaw {
739 half_life_days: 14.0,
740 };
741 let json = serde_json::to_string(&m).expect("serialize");
742 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
743 assert_eq!(m, back);
744 }
745
746 #[test]
747 fn decay_model_none_roundtrip() {
748 let m = DecayModel::None;
749 let json = serde_json::to_string(&m).expect("serialize");
750 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
751 assert_eq!(m, back);
752 }
753
754 #[test]
755 fn partial_config_deserializes_with_defaults() {
756 let json = r#"{"relevance_weight": 0.5}"#;
758 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
759 let diff = (cfg.relevance_weight - 0.5).abs();
761 assert!(diff < 1e-12);
762 let diff2 = (cfg.salience_weight - 0.20).abs();
764 assert!(diff2 < 1e-12);
765 assert_eq!(cfg.decay_model, DecayModel::Exponential);
766 }
767
768 #[test]
771 fn new_fields_have_correct_defaults() {
772 let cfg = RecallConfig::default();
773 assert_eq!(cfg.candidate_limit, Some(150));
774 assert!(
776 matches!(
777 cfg.fuse_strategy,
778 FusionStrategy::Weighted { ref weights } if weights == &vec![0.7_f64, 0.3_f64]
779 ),
780 "default fuse_strategy should be Weighted [0.7, 0.3], got {:?}",
781 cfg.fuse_strategy
782 );
783 assert!(!cfg.include_breakdown);
784 }
785
786 #[test]
787 fn candidate_limit_zero_fails_validation() {
788 let cfg = RecallConfig {
789 candidate_limit: Some(0),
790 ..RecallConfig::default()
791 };
792 assert!(cfg.validate().is_err());
793 }
794
795 #[test]
796 fn candidate_limit_some_positive_validates() {
797 let cfg = RecallConfig {
798 candidate_limit: Some(100),
799 ..RecallConfig::default()
800 };
801 assert!(cfg.validate().is_ok());
802 }
803
804 #[test]
805 fn min_score_nan_fails_validation() {
806 let cfg = RecallConfig {
807 min_score: f64::NAN,
808 ..RecallConfig::default()
809 };
810 assert!(cfg.validate().is_err());
811 }
812
813 #[test]
814 fn min_salience_nan_fails_validation() {
815 let cfg = RecallConfig {
816 min_salience: f64::NAN,
817 ..RecallConfig::default()
818 };
819 assert!(cfg.validate().is_err());
820 }
821
822 #[test]
823 fn new_fields_roundtrip() {
824 let cfg = RecallConfig {
825 candidate_limit: Some(50),
826 fuse_strategy: FusionStrategy::Union,
827 include_breakdown: true,
828 ..RecallConfig::default()
829 };
830 let json = serde_json::to_string(&cfg).expect("serialize");
831 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
832 assert_eq!(back.candidate_limit, Some(50));
833 assert_eq!(back.fuse_strategy, FusionStrategy::Union);
834 assert!(back.include_breakdown);
835 }
836
837 #[test]
838 fn partial_config_new_fields_use_defaults() {
839 let json = r#"{"temporal_weight": 0.15}"#;
843 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
844 assert_eq!(cfg.candidate_limit, Some(150));
845 assert!(
847 matches!(cfg.fuse_strategy, FusionStrategy::Weighted { .. }),
848 "partial config must deserialize fuse_strategy to Weighted default"
849 );
850 assert!(!cfg.include_breakdown);
851 }
852
853 #[test]
856 fn score_breakdown_total_sums_contributions() {
857 let bd = ScoreBreakdown {
858 relevance: 0.5,
859 salience_raw: 0.8,
860 salience_decayed: 0.6,
861 temporal: 0.3,
862 weighted: WeightedContributions {
863 relevance_contribution: 0.35,
864 salience_contribution: 0.12,
865 temporal_contribution: 0.03,
866 },
867 };
868 let expected = 0.35 + 0.12 + 0.03;
869 let diff = (bd.total() - expected).abs();
870 assert!(
871 diff < 1e-12,
872 "total() should sum weighted contributions, got {}",
873 bd.total()
874 );
875 }
876
877 #[test]
880 fn fts_gather_default_is_disabled() {
881 let cfg = RecallFtsGatherConfig::default();
882 assert!(!cfg.enabled);
883 assert_eq!(cfg.term_k, 10);
884 assert_eq!(cfg.selection_rule, RecallFtsSelectionRule::Original);
885 assert_eq!(cfg.gather_mode, RecallFtsGatherMode::Ranked);
886 assert!(cfg.gather_limit.is_none());
887 assert_eq!(cfg.gather_cap_multiplier, 4);
888 assert!(cfg.cjk_bypass_ranked);
889 }
890
891 #[test]
892 fn fts_gather_validates_zero_term_k() {
893 let cfg = RecallFtsGatherConfig {
894 term_k: 0,
895 ..RecallFtsGatherConfig::default()
896 };
897 assert!(cfg.validate().is_err());
898 }
899
900 #[test]
901 fn fts_gather_validates_zero_multiplier() {
902 let cfg = RecallFtsGatherConfig {
903 gather_cap_multiplier: 0,
904 ..RecallFtsGatherConfig::default()
905 };
906 assert!(cfg.validate().is_err());
907 }
908
909 #[test]
910 fn fts_gather_validates_zero_gather_limit() {
911 let cfg = RecallFtsGatherConfig {
912 gather_limit: Some(0),
913 ..RecallFtsGatherConfig::default()
914 };
915 assert!(cfg.validate().is_err());
916 }
917
918 #[test]
919 fn fts_gather_effective_gather_limit_uses_multiplier() {
920 let cfg = RecallFtsGatherConfig {
921 gather_cap_multiplier: 4,
922 ..RecallFtsGatherConfig::default()
923 };
924 assert_eq!(cfg.effective_gather_limit(150).unwrap(), 600);
925 }
926
927 #[test]
928 fn fts_gather_effective_gather_limit_explicit_wins() {
929 let cfg = RecallFtsGatherConfig {
930 gather_limit: Some(500),
931 gather_cap_multiplier: 4,
932 ..RecallFtsGatherConfig::default()
933 };
934 assert_eq!(cfg.effective_gather_limit(150).unwrap(), 500);
935 }
936
937 #[test]
938 fn fts_gather_effective_gather_limit_too_small_fails() {
939 let cfg = RecallFtsGatherConfig {
940 gather_limit: Some(100),
941 ..RecallFtsGatherConfig::default()
942 };
943 assert!(cfg.effective_gather_limit(150).is_err());
944 }
945
946 #[test]
947 fn fts_gather_from_env_returns_none_when_no_env_set() {
948 if std::env::var("KHIVE_RECALL_FTS_GATHER").is_ok()
952 || std::env::var("KHIVE_RECALL_FTS_TERM_K").is_ok()
953 {
954 return; }
956 let result = RecallFtsGatherConfig::from_env().unwrap();
957 assert!(result.is_none());
958 }
959
960 #[test]
961 fn fts_gather_invalid_gather_value_fails() {
962 let cfg = RecallFtsGatherConfig {
966 term_k: 0,
967 ..RecallFtsGatherConfig::default()
968 };
969 assert!(cfg.validate().is_err(), "term_k=0 must fail validation");
970 }
971
972 #[test]
973 fn fts_gather_from_into_gather_mode() {
974 assert_eq!(
975 TextGatherMode::from(RecallFtsGatherMode::Ranked),
976 TextGatherMode::Ranked
977 );
978 assert_eq!(
979 TextGatherMode::from(RecallFtsGatherMode::Unranked),
980 TextGatherMode::Unranked
981 );
982 assert_eq!(
983 TextGatherMode::from(RecallFtsGatherMode::RankWithinCap),
984 TextGatherMode::RankWithinCap
985 );
986 }
987
988 #[test]
989 fn recall_config_default_has_fts_gather() {
990 let cfg = RecallConfig::default();
991 assert!(!cfg.fts_gather.enabled);
992 }
993
994 #[test]
995 fn recall_config_roundtrip_with_fts_gather() {
996 let cfg = RecallConfig {
997 fts_gather: RecallFtsGatherConfig {
998 enabled: true,
999 term_k: 5,
1000 selection_rule: RecallFtsSelectionRule::HighestIdf,
1001 gather_mode: RecallFtsGatherMode::RankWithinCap,
1002 gather_limit: Some(600),
1003 gather_cap_multiplier: 4,
1004 cjk_bypass_ranked: true,
1005 },
1006 ..RecallConfig::default()
1007 };
1008 let json = serde_json::to_string(&cfg).expect("serialize");
1009 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
1010 assert!(back.fts_gather.enabled);
1011 assert_eq!(back.fts_gather.term_k, 5);
1012 assert_eq!(
1013 back.fts_gather.gather_mode,
1014 RecallFtsGatherMode::RankWithinCap
1015 );
1016 assert_eq!(back.fts_gather.gather_limit, Some(600));
1017 }
1018}