use zeph_config::memory::FiveSignalConfig;
#[derive(Debug, Clone, Copy)]
pub struct FiveSignalWeights {
pub w_recency: f64,
pub w_relevance: f64,
pub w_frequency: f64,
pub w_causal: f64,
pub w_novelty: f64,
}
impl FiveSignalWeights {
#[must_use]
pub fn normalized(cfg: &FiveSignalConfig) -> Self {
let _span = tracing::info_span!("memory.five_signal.weights.normalize").entered();
let sum = cfg.w_recency + cfg.w_relevance + cfg.w_frequency + cfg.w_causal + cfg.w_novelty;
if sum.abs() < 1e-12 {
tracing::warn!(
"five_signal: all weights are zero; falling back to (0.5, 0.5, 0.0, 0.0, 0.0)"
);
return Self {
w_recency: 0.5,
w_relevance: 0.5,
w_frequency: 0.0,
w_causal: 0.0,
w_novelty: 0.0,
};
}
if (sum - 1.0).abs() > 1e-9 {
tracing::warn!(
original_sum = %sum,
w_recency = %cfg.w_recency,
w_relevance = %cfg.w_relevance,
w_frequency = %cfg.w_frequency,
w_causal = %cfg.w_causal,
w_novelty = %cfg.w_novelty,
"five_signal: weights do not sum to 1.0; normalizing"
);
}
Self {
w_recency: cfg.w_recency / sum,
w_relevance: cfg.w_relevance / sum,
w_frequency: cfg.w_frequency / sum,
w_causal: cfg.w_causal / sum,
w_novelty: cfg.w_novelty / sum,
}
}
#[must_use]
#[inline]
pub fn is_baseline(&self) -> bool {
self.w_frequency.abs() < 1e-12
&& self.w_causal.abs() < 1e-12
&& self.w_novelty.abs() < 1e-12
}
}
#[cfg(test)]
mod tests {
use super::*;
use tracing_test::traced_test;
#[test]
fn default_config_normalizes_to_fifty_fifty() {
let cfg = FiveSignalConfig::default();
let w = FiveSignalWeights::normalized(&cfg);
assert!((w.w_recency - 0.5).abs() < 1e-9);
assert!((w.w_relevance - 0.5).abs() < 1e-9);
assert!(w.is_baseline());
}
#[test]
fn already_normalized_stays_unchanged() {
let cfg = FiveSignalConfig {
w_recency: 0.35,
w_relevance: 0.35,
w_frequency: 0.15,
w_causal: 0.10,
w_novelty: 0.05,
..FiveSignalConfig::default()
};
let w = FiveSignalWeights::normalized(&cfg);
assert!((w.w_recency - 0.35).abs() < 1e-9);
assert!((w.w_frequency - 0.15).abs() < 1e-9);
assert!(!w.is_baseline());
}
#[test]
fn zero_sum_falls_back_to_fifty_fifty() {
let cfg = FiveSignalConfig {
w_recency: 0.0,
w_relevance: 0.0,
..FiveSignalConfig::default()
};
let w = FiveSignalWeights::normalized(&cfg);
assert!((w.w_recency - 0.5).abs() < 1e-9);
assert!((w.w_relevance - 0.5).abs() < 1e-9);
}
#[traced_test]
#[test]
fn unnormalized_weights_emit_warn() {
let cfg = FiveSignalConfig {
w_recency: 2.0,
w_relevance: 3.0,
..FiveSignalConfig::default()
};
let _w = FiveSignalWeights::normalized(&cfg);
assert!(logs_contain("weights do not sum to 1.0"));
}
#[traced_test]
#[test]
fn zero_sum_emits_warn() {
let cfg = FiveSignalConfig {
w_recency: 0.0,
w_relevance: 0.0,
..FiveSignalConfig::default()
};
let _w = FiveSignalWeights::normalized(&cfg);
assert!(logs_contain("all weights are zero"));
}
#[test]
fn unnormalized_weights_are_scaled() {
let cfg = FiveSignalConfig {
w_recency: 2.0,
w_relevance: 2.0,
w_frequency: 1.0,
w_causal: 0.0,
w_novelty: 0.0,
..FiveSignalConfig::default()
};
let w = FiveSignalWeights::normalized(&cfg);
let total = w.w_recency + w.w_relevance + w.w_frequency + w.w_causal + w.w_novelty;
assert!((total - 1.0).abs() < 1e-9, "sum must be 1.0, got {total}");
assert!((w.w_frequency - 0.2).abs() < 1e-9);
}
}