use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use khive_fusion::FusionStrategy;
use khive_runtime::RuntimeError;
use khive_storage::types::{TextGatherMode, TextSearchOptions};
#[derive(Debug, Clone)]
pub enum MinScoreError {
NotFinite,
OutOfRange(f64),
}
impl std::fmt::Display for MinScoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFinite => write!(f, "min_score must be finite"),
Self::OutOfRange(v) => write!(
f,
"min_score {v} out of range: must be 0.0–1.0 (fraction) or 0–100 (percent)"
),
}
}
}
impl From<MinScoreError> for RuntimeError {
fn from(e: MinScoreError) -> Self {
RuntimeError::InvalidInput(e.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RecallConfig {
pub relevance_weight: f64,
pub salience_weight: f64,
pub temporal_weight: f64,
pub reranker_weights: HashMap<String, f64>,
pub temporal_half_life_days: f64,
pub decay_model: DecayModel,
pub candidate_multiplier: u32,
pub candidate_limit: Option<u32>,
pub fuse_strategy: FusionStrategy,
pub min_score: f64,
pub min_salience: f64,
pub include_breakdown: bool,
pub scoring: Option<crate::scoring::ScoringConfig>,
pub brain_profile: Option<BrainProfileHint>,
pub fts_gather: RecallFtsGatherConfig,
pub ann_overfetch_max_rounds: Option<usize>,
pub ann_ready_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainProfileHint {
pub profile_id: String,
#[serde(default = "BrainProfileHint::default_boost")]
pub boost: f64,
#[serde(default = "BrainProfileHint::default_threshold")]
pub threshold: f64,
}
impl BrainProfileHint {
fn default_boost() -> f64 {
1.3
}
fn default_threshold() -> f64 {
0.6
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RecallFtsSelectionRule {
#[default]
Original,
LowestDf,
HighestIdf,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RecallFtsGatherMode {
#[default]
Ranked,
Unranked,
RankWithinCap,
}
impl From<RecallFtsGatherMode> for TextGatherMode {
fn from(m: RecallFtsGatherMode) -> Self {
match m {
RecallFtsGatherMode::Ranked => TextGatherMode::Ranked,
RecallFtsGatherMode::Unranked => TextGatherMode::Unranked,
RecallFtsGatherMode::RankWithinCap => TextGatherMode::RankWithinCap,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RecallFtsGatherConfig {
pub enabled: bool,
pub term_k: usize,
pub selection_rule: RecallFtsSelectionRule,
pub gather_mode: RecallFtsGatherMode,
pub gather_limit: Option<u32>,
pub gather_cap_multiplier: u32,
pub cjk_bypass_ranked: bool,
}
impl Default for RecallFtsGatherConfig {
fn default() -> Self {
Self {
enabled: false,
term_k: 10,
selection_rule: RecallFtsSelectionRule::Original,
gather_mode: RecallFtsGatherMode::Ranked,
gather_limit: None,
gather_cap_multiplier: 4,
cjk_bypass_ranked: true,
}
}
}
impl RecallFtsGatherConfig {
pub fn from_env() -> Result<Option<Self>, RuntimeError> {
let gather = std::env::var("KHIVE_RECALL_FTS_GATHER").ok();
let term_k = std::env::var("KHIVE_RECALL_FTS_TERM_K").ok();
let selection = std::env::var("KHIVE_RECALL_FTS_SELECTION").ok();
let limit = std::env::var("KHIVE_RECALL_FTS_GATHER_LIMIT").ok();
let multiplier = std::env::var("KHIVE_RECALL_FTS_GATHER_MULTIPLIER").ok();
let cjk_bypass = std::env::var("KHIVE_RECALL_FTS_CJK_BYPASS").ok();
if gather.is_none()
&& term_k.is_none()
&& selection.is_none()
&& limit.is_none()
&& multiplier.is_none()
&& cjk_bypass.is_none()
{
return Ok(None);
}
let mut cfg = RecallFtsGatherConfig {
enabled: true,
..RecallFtsGatherConfig::default()
};
if let Some(g) = gather {
match g.as_str() {
"baseline" => {
cfg.enabled = false;
}
"ranked" => {
cfg.gather_mode = RecallFtsGatherMode::Ranked;
}
"rank_subset" => {
cfg.gather_mode = RecallFtsGatherMode::RankWithinCap;
}
"unranked" => {
cfg.gather_mode = RecallFtsGatherMode::Unranked;
}
other => {
return Err(RuntimeError::InvalidInput(format!(
"KHIVE_RECALL_FTS_GATHER must be baseline|ranked|rank_subset|unranked, got {other:?}"
)));
}
}
}
if let Some(k) = term_k {
let v: usize = k.parse().map_err(|_| {
RuntimeError::InvalidInput(format!(
"KHIVE_RECALL_FTS_TERM_K must be a positive integer 1..10, got {k:?}"
))
})?;
if v == 0 || v > 10 {
return Err(RuntimeError::InvalidInput(format!(
"KHIVE_RECALL_FTS_TERM_K must be 1..10, got {v}"
)));
}
cfg.term_k = v;
}
if let Some(s) = selection {
cfg.selection_rule = match s.as_str() {
"original" => RecallFtsSelectionRule::Original,
"lowest_df" => RecallFtsSelectionRule::LowestDf,
"highest_idf" => RecallFtsSelectionRule::HighestIdf,
other => {
return Err(RuntimeError::InvalidInput(format!(
"KHIVE_RECALL_FTS_SELECTION must be original|lowest_df|highest_idf, got {other:?}"
)));
}
};
}
if let Some(l) = limit {
let v: u32 = l.parse().map_err(|_| {
RuntimeError::InvalidInput(format!(
"KHIVE_RECALL_FTS_GATHER_LIMIT must be a positive integer, got {l:?}"
))
})?;
if v == 0 {
return Err(RuntimeError::InvalidInput(
"KHIVE_RECALL_FTS_GATHER_LIMIT must be > 0".to_string(),
));
}
cfg.gather_limit = Some(v);
}
if let Some(m) = multiplier {
let v: u32 = m.parse().map_err(|_| {
RuntimeError::InvalidInput(format!(
"KHIVE_RECALL_FTS_GATHER_MULTIPLIER must be a positive integer, got {m:?}"
))
})?;
if v == 0 {
return Err(RuntimeError::InvalidInput(
"KHIVE_RECALL_FTS_GATHER_MULTIPLIER must be > 0".to_string(),
));
}
cfg.gather_cap_multiplier = v;
}
if let Some(b) = cjk_bypass {
cfg.cjk_bypass_ranked = match b.as_str() {
"1" | "true" => true,
"0" | "false" => false,
other => {
return Err(RuntimeError::InvalidInput(format!(
"KHIVE_RECALL_FTS_CJK_BYPASS must be 1|0, got {other:?}"
)));
}
};
}
cfg.validate()?;
Ok(Some(cfg))
}
pub fn validate(&self) -> Result<(), RuntimeError> {
if self.term_k == 0 {
return Err(RuntimeError::InvalidInput(
"fts_gather.term_k must be > 0".to_string(),
));
}
if self.gather_cap_multiplier == 0 {
return Err(RuntimeError::InvalidInput(
"fts_gather.gather_cap_multiplier must be > 0".to_string(),
));
}
if let Some(gl) = self.gather_limit {
if gl == 0 {
return Err(RuntimeError::InvalidInput(
"fts_gather.gather_limit must be > 0 when provided".to_string(),
));
}
}
Ok(())
}
pub fn effective_gather_limit(&self, candidate_limit: u32) -> Result<u32, RuntimeError> {
let gl = match self.gather_limit {
Some(explicit) => {
if explicit < candidate_limit {
return Err(RuntimeError::InvalidInput(format!(
"fts_gather.gather_limit ({explicit}) must be >= candidate_limit ({candidate_limit})"
)));
}
explicit
}
None => candidate_limit.saturating_mul(self.gather_cap_multiplier),
};
Ok(gl.max(candidate_limit))
}
pub fn to_search_options(
&self,
candidate_limit: u32,
) -> Result<TextSearchOptions, RuntimeError> {
let gather_limit = self.effective_gather_limit(candidate_limit)?;
Ok(TextSearchOptions {
gather_mode: self.gather_mode.into(),
gather_limit: Some(gather_limit),
})
}
}
impl Default for RecallConfig {
fn default() -> Self {
Self {
relevance_weight: 0.70,
salience_weight: 0.20,
temporal_weight: 0.10,
reranker_weights: HashMap::new(),
temporal_half_life_days: 30.0,
decay_model: DecayModel::default(),
candidate_multiplier: 20,
candidate_limit: Some(150),
fuse_strategy: FusionStrategy::Weighted {
weights: vec![0.7, 0.3],
},
min_score: 0.0,
min_salience: 0.0,
include_breakdown: false,
scoring: None,
brain_profile: None,
fts_gather: RecallFtsGatherConfig::default(),
ann_overfetch_max_rounds: None,
ann_ready_timeout_ms: None,
}
}
}
impl RecallConfig {
pub fn validate(&self) -> Result<(), RuntimeError> {
if !self.relevance_weight.is_finite() || self.relevance_weight < 0.0 {
return Err(RuntimeError::InvalidInput(
"relevance_weight must be a finite non-negative number".to_string(),
));
}
if !self.salience_weight.is_finite() || self.salience_weight < 0.0 {
return Err(RuntimeError::InvalidInput(
"salience_weight must be a finite non-negative number".to_string(),
));
}
if !self.temporal_weight.is_finite() || self.temporal_weight < 0.0 {
return Err(RuntimeError::InvalidInput(
"temporal_weight must be a finite non-negative number".to_string(),
));
}
let weight_sum = self.relevance_weight + self.salience_weight + self.temporal_weight;
if weight_sum <= 0.0 {
return Err(RuntimeError::InvalidInput(
"at least one of relevance_weight / salience_weight / temporal_weight must be positive".to_string(),
));
}
for (name, &weight) in &self.reranker_weights {
if !weight.is_finite() || weight < 0.0 {
return Err(RuntimeError::InvalidInput(format!(
"reranker_weights[{name:?}] must be a finite non-negative number"
)));
}
}
if !self.temporal_half_life_days.is_finite() || self.temporal_half_life_days <= 0.0 {
return Err(RuntimeError::InvalidInput(
"temporal_half_life_days must be a finite positive number".to_string(),
));
}
if let DecayModel::PowerLaw { half_life_days } = self.decay_model {
if !half_life_days.is_finite() || half_life_days <= 0.0 {
return Err(RuntimeError::InvalidInput(
"decay_model.power_law.half_life_days must be a finite positive number"
.to_string(),
));
}
}
if self.candidate_limit == Some(0) {
return Err(RuntimeError::InvalidInput(
"candidate_limit must be positive when provided".to_string(),
));
}
if !self.min_score.is_finite() {
return Err(RuntimeError::InvalidInput(
"min_score must be finite".to_string(),
));
}
if !self.min_salience.is_finite() {
return Err(RuntimeError::InvalidInput(
"min_salience must be finite".to_string(),
));
}
Ok(())
}
pub fn try_from_value(v: serde_json::Value) -> Result<Self, RuntimeError> {
let cfg: Self =
serde_json::from_value(v).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
cfg.validate()?;
Ok(cfg)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DecayModel {
#[default]
Exponential,
Hyperbolic,
PowerLaw {
half_life_days: f64,
},
None,
}
impl DecayModel {
pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
match self {
DecayModel::Exponential => {
salience * (-decay_factor * age_days).exp()
}
DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
DecayModel::PowerLaw { half_life_days } => {
let hl = *half_life_days;
salience * hl / (hl + age_days)
}
DecayModel::None => salience,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreBreakdown {
pub relevance: f64,
pub salience_raw: f64,
pub salience_decayed: f64,
pub temporal: f64,
pub weighted: WeightedContributions,
pub profile_component: f64,
pub entity_posterior_mean: Option<f64>,
}
impl ScoreBreakdown {
pub fn total(&self) -> f64 {
self.weighted.relevance_contribution
+ self.weighted.salience_contribution
+ self.weighted.temporal_contribution
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeightedContributions {
pub relevance_contribution: f64,
pub salience_contribution: f64,
pub temporal_contribution: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exponential_halves_at_decay_factor_half_life() {
let model = DecayModel::Exponential;
let salience = 1.0;
let decay_factor = 0.01;
let half_life_days = std::f64::consts::LN_2 / decay_factor;
let result = model.apply(salience, half_life_days, decay_factor, 30.0);
let diff = (result - 0.5).abs();
assert!(
diff < 1e-10,
"exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
);
}
#[test]
fn exponential_full_salience_at_zero_age() {
let model = DecayModel::Exponential;
let result = model.apply(0.8, 0.0, 0.01, 30.0);
let diff = (result - 0.8).abs();
assert!(
diff < 1e-12,
"at age=0 salience should be unchanged, got {result}"
);
}
#[test]
fn exponential_uses_note_decay_factor_not_half_life() {
let model = DecayModel::Exponential;
let result = model.apply(1.0, 1.0, 1.0, 10.0);
let expected = (-1.0f64).exp();
assert!(
(result - expected).abs() < 1e-12,
"expected {expected}, got {result}"
);
}
#[test]
fn hyperbolic_halves_at_one_over_decay_factor() {
let model = DecayModel::Hyperbolic;
let salience = 1.0;
let k = 0.05;
let age = 1.0 / k; let result = model.apply(salience, age, k, 30.0);
let diff = (result - 0.5).abs();
assert!(
diff < 1e-10,
"hyperbolic at age=1/k should give 0.5, got {result}"
);
}
#[test]
fn hyperbolic_full_salience_at_zero_age() {
let model = DecayModel::Hyperbolic;
let result = model.apply(0.7, 0.0, 0.05, 30.0);
let diff = (result - 0.7).abs();
assert!(
diff < 1e-12,
"at age=0 salience should be unchanged, got {result}"
);
}
#[test]
fn powerlaw_halves_at_half_life() {
let hl = 30.0;
let model = DecayModel::PowerLaw { half_life_days: hl };
let salience = 1.0;
let result = model.apply(salience, hl, 0.01, hl);
let diff = (result - 0.5).abs();
assert!(
diff < 1e-10,
"power-law should give 0.5 at half-life, got {result}"
);
}
#[test]
fn decay_none_returns_salience_unchanged() {
let model = DecayModel::None;
let result = model.apply(0.6, 100.0, 0.99, 30.0);
let diff = (result - 0.6).abs();
assert!(
diff < 1e-12,
"None model must not alter salience, got {result}"
);
}
#[test]
fn default_config_validates() {
assert!(RecallConfig::default().validate().is_ok());
}
#[test]
fn negative_relevance_weight_fails_validation() {
let cfg = RecallConfig {
relevance_weight: -0.1,
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn negative_salience_weight_fails_validation() {
let cfg = RecallConfig {
salience_weight: -1.0,
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn negative_temporal_weight_fails_validation() {
let cfg = RecallConfig {
temporal_weight: -0.5,
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn all_zero_weights_fails_validation() {
let cfg = RecallConfig {
relevance_weight: 0.0,
salience_weight: 0.0,
temporal_weight: 0.0,
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn zero_half_life_fails_validation() {
let cfg = RecallConfig {
temporal_half_life_days: 0.0,
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn negative_half_life_fails_validation() {
let cfg = RecallConfig {
temporal_half_life_days: -5.0,
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn non_uniform_weights_validate() {
let cfg = RecallConfig {
relevance_weight: 0.5,
salience_weight: 0.3,
temporal_weight: 0.2,
..RecallConfig::default()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn default_config_roundtrip() {
let cfg = RecallConfig::default();
let json = serde_json::to_string(&cfg).expect("serialize");
let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
let diff = (cfg.relevance_weight - back.relevance_weight).abs();
assert!(diff < 1e-12);
assert_eq!(cfg.decay_model, back.decay_model);
}
#[test]
fn decay_model_exponential_roundtrip() {
let m = DecayModel::Exponential;
let json = serde_json::to_string(&m).expect("serialize");
let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(m, back);
}
#[test]
fn decay_model_hyperbolic_roundtrip() {
let m = DecayModel::Hyperbolic;
let json = serde_json::to_string(&m).expect("serialize");
let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(m, back);
}
#[test]
fn decay_model_powerlaw_roundtrip() {
let m = DecayModel::PowerLaw {
half_life_days: 14.0,
};
let json = serde_json::to_string(&m).expect("serialize");
let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(m, back);
}
#[test]
fn decay_model_none_roundtrip() {
let m = DecayModel::None;
let json = serde_json::to_string(&m).expect("serialize");
let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(m, back);
}
#[test]
fn partial_config_deserializes_with_defaults() {
let json = r#"{"relevance_weight": 0.5}"#;
let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
let diff = (cfg.relevance_weight - 0.5).abs();
assert!(diff < 1e-12);
let diff2 = (cfg.salience_weight - 0.20).abs();
assert!(diff2 < 1e-12);
assert_eq!(cfg.decay_model, DecayModel::Exponential);
}
#[test]
fn new_fields_have_correct_defaults() {
let cfg = RecallConfig::default();
assert_eq!(cfg.candidate_limit, Some(150));
assert!(
matches!(
cfg.fuse_strategy,
FusionStrategy::Weighted { ref weights } if weights == &vec![0.7_f64, 0.3_f64]
),
"default fuse_strategy should be Weighted [0.7, 0.3], got {:?}",
cfg.fuse_strategy
);
assert!(!cfg.include_breakdown);
}
#[test]
fn candidate_limit_zero_fails_validation() {
let cfg = RecallConfig {
candidate_limit: Some(0),
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn candidate_limit_some_positive_validates() {
let cfg = RecallConfig {
candidate_limit: Some(100),
..RecallConfig::default()
};
assert!(cfg.validate().is_ok());
}
#[test]
fn min_score_nan_fails_validation() {
let cfg = RecallConfig {
min_score: f64::NAN,
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn min_salience_nan_fails_validation() {
let cfg = RecallConfig {
min_salience: f64::NAN,
..RecallConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn new_fields_roundtrip() {
let cfg = RecallConfig {
candidate_limit: Some(50),
fuse_strategy: FusionStrategy::Union,
include_breakdown: true,
..RecallConfig::default()
};
let json = serde_json::to_string(&cfg).expect("serialize");
let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.candidate_limit, Some(50));
assert_eq!(back.fuse_strategy, FusionStrategy::Union);
assert!(back.include_breakdown);
}
#[test]
fn partial_config_new_fields_use_defaults() {
let json = r#"{"temporal_weight": 0.15}"#;
let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
assert_eq!(cfg.candidate_limit, Some(150));
assert!(
matches!(cfg.fuse_strategy, FusionStrategy::Weighted { .. }),
"partial config must deserialize fuse_strategy to Weighted default"
);
assert!(!cfg.include_breakdown);
}
#[test]
fn score_breakdown_total_sums_contributions() {
let bd = ScoreBreakdown {
relevance: 0.5,
salience_raw: 0.8,
salience_decayed: 0.6,
temporal: 0.3,
weighted: WeightedContributions {
relevance_contribution: 0.35,
salience_contribution: 0.12,
temporal_contribution: 0.03,
},
profile_component: 1.0,
entity_posterior_mean: None,
};
let expected = 0.35 + 0.12 + 0.03;
let diff = (bd.total() - expected).abs();
assert!(
diff < 1e-12,
"total() should sum weighted contributions, got {}",
bd.total()
);
}
#[test]
fn fts_gather_default_is_disabled() {
let cfg = RecallFtsGatherConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.term_k, 10);
assert_eq!(cfg.selection_rule, RecallFtsSelectionRule::Original);
assert_eq!(cfg.gather_mode, RecallFtsGatherMode::Ranked);
assert!(cfg.gather_limit.is_none());
assert_eq!(cfg.gather_cap_multiplier, 4);
assert!(cfg.cjk_bypass_ranked);
}
#[test]
fn fts_gather_validates_zero_term_k() {
let cfg = RecallFtsGatherConfig {
term_k: 0,
..RecallFtsGatherConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn fts_gather_validates_zero_multiplier() {
let cfg = RecallFtsGatherConfig {
gather_cap_multiplier: 0,
..RecallFtsGatherConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn fts_gather_validates_zero_gather_limit() {
let cfg = RecallFtsGatherConfig {
gather_limit: Some(0),
..RecallFtsGatherConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn fts_gather_effective_gather_limit_uses_multiplier() {
let cfg = RecallFtsGatherConfig {
gather_cap_multiplier: 4,
..RecallFtsGatherConfig::default()
};
assert_eq!(cfg.effective_gather_limit(150).unwrap(), 600);
}
#[test]
fn fts_gather_effective_gather_limit_explicit_wins() {
let cfg = RecallFtsGatherConfig {
gather_limit: Some(500),
gather_cap_multiplier: 4,
..RecallFtsGatherConfig::default()
};
assert_eq!(cfg.effective_gather_limit(150).unwrap(), 500);
}
#[test]
fn fts_gather_effective_gather_limit_too_small_fails() {
let cfg = RecallFtsGatherConfig {
gather_limit: Some(100),
..RecallFtsGatherConfig::default()
};
assert!(cfg.effective_gather_limit(150).is_err());
}
#[test]
fn fts_gather_from_env_returns_none_when_no_env_set() {
if std::env::var("KHIVE_RECALL_FTS_GATHER").is_ok()
|| std::env::var("KHIVE_RECALL_FTS_TERM_K").is_ok()
{
return; }
let result = RecallFtsGatherConfig::from_env().unwrap();
assert!(result.is_none());
}
#[test]
fn fts_gather_invalid_gather_value_fails() {
let cfg = RecallFtsGatherConfig {
term_k: 0,
..RecallFtsGatherConfig::default()
};
assert!(cfg.validate().is_err(), "term_k=0 must fail validation");
}
#[test]
fn fts_gather_from_into_gather_mode() {
assert_eq!(
TextGatherMode::from(RecallFtsGatherMode::Ranked),
TextGatherMode::Ranked
);
assert_eq!(
TextGatherMode::from(RecallFtsGatherMode::Unranked),
TextGatherMode::Unranked
);
assert_eq!(
TextGatherMode::from(RecallFtsGatherMode::RankWithinCap),
TextGatherMode::RankWithinCap
);
}
#[test]
fn recall_config_default_has_fts_gather() {
let cfg = RecallConfig::default();
assert!(!cfg.fts_gather.enabled);
}
#[test]
fn recall_config_roundtrip_with_fts_gather() {
let cfg = RecallConfig {
fts_gather: RecallFtsGatherConfig {
enabled: true,
term_k: 5,
selection_rule: RecallFtsSelectionRule::HighestIdf,
gather_mode: RecallFtsGatherMode::RankWithinCap,
gather_limit: Some(600),
gather_cap_multiplier: 4,
cjk_bypass_ranked: true,
},
..RecallConfig::default()
};
let json = serde_json::to_string(&cfg).expect("serialize");
let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
assert!(back.fts_gather.enabled);
assert_eq!(back.fts_gather.term_k, 5);
assert_eq!(
back.fts_gather.gather_mode,
RecallFtsGatherMode::RankWithinCap
);
assert_eq!(back.fts_gather.gather_limit, Some(600));
}
}