use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use khive_fusion::FusionStrategy;
use khive_runtime::RuntimeError;
#[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>,
}
#[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
}
}
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: None,
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,
}
}
}
impl RecallConfig {
pub fn validate(&self) -> Result<(), RuntimeError> {
if self.relevance_weight < 0.0 {
return Err(RuntimeError::InvalidInput(
"relevance_weight must be non-negative".to_string(),
));
}
if self.salience_weight < 0.0 {
return Err(RuntimeError::InvalidInput(
"salience_weight must be non-negative".to_string(),
));
}
if self.temporal_weight < 0.0 {
return Err(RuntimeError::InvalidInput(
"temporal_weight must be non-negative".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 < 0.0 {
return Err(RuntimeError::InvalidInput(format!(
"reranker_weights[{name:?}] must be non-negative"
)));
}
}
if self.temporal_half_life_days <= 0.0 {
return Err(RuntimeError::InvalidInput(
"temporal_half_life_days must be positive".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(())
}
}
#[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,
}
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, None);
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, None);
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,
},
};
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()
);
}
}