use chrono::{DateTime, Duration, Utc};
use crate::error::CupelError;
use crate::model::ContextItem;
use crate::scorer::Scorer;
pub trait TimeProvider: Send + Sync {
fn now(&self) -> DateTime<Utc>;
}
pub struct SystemTimeProvider;
impl TimeProvider for SystemTimeProvider {
fn now(&self) -> DateTime<Utc> {
Utc::now()
}
}
pub enum DecayCurve {
Exponential(Duration),
Window(Duration),
Step(Vec<(Duration, f64)>),
}
impl DecayCurve {
pub fn exponential(half_life: Duration) -> Result<Self, CupelError> {
if half_life <= Duration::zero() {
return Err(CupelError::ScorerConfig(
"halfLife must be strictly positive".to_string(),
));
}
Ok(Self::Exponential(half_life))
}
pub fn window(max_age: Duration) -> Result<Self, CupelError> {
if max_age <= Duration::zero() {
return Err(CupelError::ScorerConfig(
"maxAge must be strictly positive".to_string(),
));
}
Ok(Self::Window(max_age))
}
pub fn step(windows: Vec<(Duration, f64)>) -> Result<Self, CupelError> {
if windows.is_empty() {
return Err(CupelError::ScorerConfig(
"windows must be non-empty".to_string(),
));
}
for (age, _score) in &windows {
if *age <= Duration::zero() {
return Err(CupelError::ScorerConfig(
"windows: each maxAge must be strictly positive".to_string(),
));
}
}
Ok(Self::Step(windows))
}
fn compute(&self, age: Duration) -> f64 {
match self {
DecayCurve::Exponential(half_life) => {
let age_secs = age.num_milliseconds() as f64 / 1_000.0;
let h_secs = half_life.num_milliseconds() as f64 / 1_000.0;
2_f64.powf(-(age_secs / h_secs))
}
DecayCurve::Window(max_age) => {
if age < *max_age {
1.0
} else {
0.0
}
}
DecayCurve::Step(windows) => {
for (max_age, score) in windows {
if age < *max_age {
return *score;
}
}
windows.last().map(|(_, s)| *s).unwrap_or(0.0)
}
}
}
}
pub struct DecayScorer {
provider: Box<dyn TimeProvider + Send + Sync>,
curve: DecayCurve,
null_timestamp_score: f64,
}
impl DecayScorer {
pub fn new(
provider: Box<dyn TimeProvider + Send + Sync>,
curve: DecayCurve,
null_timestamp_score: f64,
) -> Result<Self, CupelError> {
if !(0.0..=1.0).contains(&null_timestamp_score) {
return Err(CupelError::ScorerConfig(
"nullTimestampScore must be in [0.0, 1.0]".to_string(),
));
}
Ok(Self {
provider,
curve,
null_timestamp_score,
})
}
}
impl Scorer for DecayScorer {
fn score(&self, item: &ContextItem, _all_items: &[ContextItem]) -> f64 {
let ts = match item.timestamp() {
Some(ts) => ts,
None => return self.null_timestamp_score,
};
let now = self.provider.now();
let raw_age = now - ts;
let age = raw_age.max(Duration::zero());
self.curve.compute(age)
}
}