use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DecayFunction {
Exponential,
#[cfg_attr(not(test), allow(dead_code))]
Linear,
}
#[derive(Debug, Clone, Copy)]
pub struct DecayConfig {
pub function: DecayFunction,
pub lambda: f64,
pub offset_days: f64,
pub refresh_cap_days: f64,
}
impl Default for DecayConfig {
fn default() -> Self {
Self {
function: DecayFunction::Exponential,
lambda: 1e-6,
offset_days: 0.0,
refresh_cap_days: 30.0,
}
}
}
impl DecayFunction {
#[cfg(test)]
pub fn all() -> impl Iterator<Item = Self> {
[DecayFunction::Exponential, DecayFunction::Linear].into_iter()
}
}
impl DecayConfig {
pub fn new() -> Result<Self, String> {
let config = Self::default();
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), String> {
if self.lambda <= 0.0 {
return Err(format!(
"Invalid lambda: {} (must be positive)",
self.lambda
));
}
match self.function {
DecayFunction::Exponential => {
if self.lambda > 1e-3 {
return Err(format!(
"Exponential decay lambda {} is too large (max: 1e-3)",
self.lambda
));
}
if self.lambda < 1e-10 {
return Err(format!(
"Exponential decay lambda {} is too small (min: 1e-10)",
self.lambda
));
}
}
DecayFunction::Linear => {
if self.lambda > 100.0 {
return Err(format!(
"Linear decay lambda {} is too large (max: 100.0)",
self.lambda
));
}
if self.lambda < 1e-6 {
return Err(format!(
"Linear decay lambda {} is too small to be useful (min: 1e-6)",
self.lambda
));
}
}
}
if self.offset_days < 0.0 {
return Err(format!(
"Invalid offset_days: {} (must be >= 0)",
self.offset_days
));
}
if self.refresh_cap_days < 0.0 {
return Err(format!(
"Invalid refresh_cap_days: {} (must be >= 0)",
self.refresh_cap_days
));
}
Ok(())
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn calculate_decay(&self, created_at: &DateTime<Utc>) -> f64 {
let now = Utc::now();
let age = now.signed_duration_since(*created_at);
let age_seconds = age.num_seconds().max(0) as f64;
if age_seconds.is_nan() || age_seconds.is_infinite() {
return 0.0;
}
let offset_seconds = self.offset_days * 86400.0;
let effective_age = (age_seconds - offset_seconds).max(0.0);
self.apply_function(effective_age)
}
pub fn calculate_decay_with_telemetry(
&self,
created_at: &DateTime<Utc>,
importance: ImportanceLevel,
telemetry: &RetrievalTelemetry,
) -> f64 {
let now = Utc::now();
let age = now.signed_duration_since(*created_at);
let age_seconds = age.num_seconds().max(0) as f64;
if age_seconds.is_nan() || age_seconds.is_infinite() {
return 0.0;
}
let refresh = recency_refresh(
telemetry.retrieval_count,
telemetry.last_retrieved_at,
created_at,
self.refresh_cap_days,
);
let offset_seconds = self.offset_days * 86400.0;
let effective_age = (age_seconds - offset_seconds - refresh).max(0.0);
let scale = importance.scale();
self.apply_function_scaled(effective_age, scale)
}
#[cfg_attr(not(test), allow(dead_code))]
fn apply_function(&self, effective_age: f64) -> f64 {
self.apply_function_scaled(effective_age, 1.0)
}
fn apply_function_scaled(&self, effective_age: f64, scale: f64) -> f64 {
match self.function {
DecayFunction::Exponential => {
let exponent = -self.lambda * scale * effective_age;
if exponent < -700.0 {
return 0.0;
}
if exponent > 700.0 {
return 1.0;
}
exponent.exp()
}
DecayFunction::Linear => {
let decay_rate = self.lambda * scale * effective_age / 86400.0;
(1.0 - decay_rate).clamp(0.0, 1.0)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImportanceLevel {
Low,
#[default]
Medium,
High,
Critical,
}
impl ImportanceLevel {
pub fn parse(s: &str) -> Result<Self, String> {
match s.to_lowercase().as_str() {
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
"critical" => Ok(Self::Critical),
_ => Err(format!(
"Invalid importance '{}'. Must be one of: low, medium, high, critical",
s
)),
}
}
pub fn scale(self) -> f64 {
match self {
Self::Low => 1.0,
Self::Medium => 0.5,
Self::High => 0.25,
Self::Critical => 0.0,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RetrievalTelemetry {
pub retrieval_count: i64,
pub last_retrieved_at: Option<DateTime<Utc>>,
}
impl RetrievalTelemetry {
#[must_use]
pub fn from_stored(retrieval_count: i64, last_retrieved_at: Option<&str>) -> Self {
let last_retrieved_at = last_retrieved_at.and_then(|ts| ts.parse::<DateTime<Utc>>().ok());
Self {
retrieval_count,
last_retrieved_at,
}
}
}
pub fn recency_refresh(
retrieval_count: i64,
last_retrieved_at: Option<DateTime<Utc>>,
created_at: &DateTime<Utc>,
k_days: f64,
) -> f64 {
if retrieval_count <= 0 {
return 0.0;
}
let Some(last) = last_retrieved_at else {
return 0.0;
};
let now = Utc::now();
let age_seconds = now.signed_duration_since(*created_at).num_seconds().max(0) as f64;
let since_last = now.signed_duration_since(last).num_seconds().max(0) as f64;
let cap_seconds = k_days.max(0.0) * 86400.0;
since_last.min(cap_seconds).min(age_seconds)
}
pub fn apply_recency_weight(
similarity: f64,
created_at: &DateTime<Utc>,
recency_weight: f64,
config: &DecayConfig,
importance: ImportanceLevel,
telemetry: &RetrievalTelemetry,
) -> f64 {
if recency_weight <= 0.0 {
return similarity;
}
let decay = config.calculate_decay_with_telemetry(created_at, importance, telemetry);
(1.0 - recency_weight) * similarity + recency_weight * decay
}
pub fn validate_recency_weight(recency_weight: f64) -> Result<(), String> {
if !(0.0..=1.0).contains(&recency_weight) {
return Err(format!(
"Invalid recency weight: {} (must be between 0.0 and 1.0)",
recency_weight
));
}
Ok(())
}
#[cfg(test)]
#[path = "temporal_tests.rs"]
mod temporal_tests;