#[derive(Debug, Clone, Copy, PartialEq)]
#[allow(dead_code)]
pub enum FreshnessDecay {
Exponential,
Linear,
Step,
Logarithmic,
}
impl FreshnessDecay {
#[must_use]
pub fn compute(self, age_days: f64, half_life_days: f64) -> f64 {
if half_life_days <= 0.0 || age_days < 0.0 {
return 0.0;
}
match self {
Self::Exponential => {
let decay_rate = std::f64::consts::LN_2 / half_life_days;
(-decay_rate * age_days).exp()
}
Self::Linear => {
(1.0 - age_days / (2.0 * half_life_days)).max(0.0)
}
Self::Step => {
if age_days <= half_life_days {
1.0
} else {
0.0
}
}
Self::Logarithmic => {
let ratio = age_days / half_life_days;
1.0 / (1.0 + (1.0 + ratio).log2())
}
}
}
}
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub struct ContentAge {
pub published_at_days_ago: u32,
pub last_updated_days_ago: u32,
pub view_velocity: f32,
}
impl ContentAge {
#[must_use]
pub fn new(published_at_days_ago: u32, last_updated_days_ago: u32, view_velocity: f32) -> Self {
Self {
published_at_days_ago,
last_updated_days_ago,
view_velocity: view_velocity.max(0.0),
}
}
#[must_use]
pub fn effective_age_days(&self) -> f64 {
f64::from(self.last_updated_days_ago.min(self.published_at_days_ago))
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FreshnessScorer {
pub half_life_days: f64,
pub max_velocity: f32,
pub velocity_weight: f32,
}
impl FreshnessScorer {
#[must_use]
pub fn new(half_life_days: f64, max_velocity: f32, velocity_weight: f32) -> Self {
Self {
half_life_days: half_life_days.max(0.001),
max_velocity: max_velocity.max(1.0),
velocity_weight: velocity_weight.clamp(0.0, 1.0),
}
}
#[must_use]
pub fn score(&self, age: &ContentAge, decay: FreshnessDecay) -> f32 {
let base = decay.compute(age.effective_age_days(), self.half_life_days) as f32;
let velocity_norm = (age.view_velocity / self.max_velocity).min(1.0);
let boost = velocity_norm * self.velocity_weight;
(base + boost).min(1.0)
}
}
impl Default for FreshnessScorer {
fn default() -> Self {
Self::new(7.0, 1000.0, 0.2)
}
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct BoostSchedule {
pub boosts: Vec<(u32, f32)>,
}
impl BoostSchedule {
#[must_use]
pub fn new(mut boosts: Vec<(u32, f32)>) -> Self {
boosts.sort_by_key(|(d, _)| *d);
Self { boosts }
}
#[must_use]
pub fn factor_for(&self, age_days: u32) -> f32 {
for &(day_offset, factor) in &self.boosts {
if age_days <= day_offset {
return factor;
}
}
1.0
}
#[must_use]
pub fn apply(&self, base_score: f32, age_days: u32) -> f32 {
(base_score * self.factor_for(age_days)).min(1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exponential_decay_zero_age() {
let score = FreshnessDecay::Exponential.compute(0.0, 7.0);
assert!((score - 1.0).abs() < 1e-9);
}
#[test]
fn test_exponential_decay_half_life() {
let score = FreshnessDecay::Exponential.compute(7.0, 7.0);
assert!((score - 0.5).abs() < 1e-6);
}
#[test]
fn test_exponential_decay_decreasing() {
let s0 = FreshnessDecay::Exponential.compute(0.0, 7.0);
let s7 = FreshnessDecay::Exponential.compute(7.0, 7.0);
let s14 = FreshnessDecay::Exponential.compute(14.0, 7.0);
assert!(s0 > s7 && s7 > s14);
}
#[test]
fn test_linear_decay_zero_age() {
let score = FreshnessDecay::Linear.compute(0.0, 7.0);
assert!((score - 1.0).abs() < 1e-9);
}
#[test]
fn test_linear_decay_after_two_half_lives() {
let score = FreshnessDecay::Linear.compute(14.0, 7.0);
assert!(score <= 0.0 + 1e-9);
}
#[test]
fn test_step_decay_within() {
assert!((FreshnessDecay::Step.compute(3.0, 7.0) - 1.0).abs() < 1e-9);
}
#[test]
fn test_step_decay_beyond() {
assert!((FreshnessDecay::Step.compute(8.0, 7.0) - 0.0).abs() < 1e-9);
}
#[test]
fn test_logarithmic_decay_decreasing() {
let s0 = FreshnessDecay::Logarithmic.compute(0.0, 7.0);
let s7 = FreshnessDecay::Logarithmic.compute(7.0, 7.0);
let s70 = FreshnessDecay::Logarithmic.compute(70.0, 7.0);
assert!(s0 > s7 && s7 > s70);
}
#[test]
fn test_content_age_effective_age_uses_minimum() {
let age = ContentAge::new(30, 5, 100.0);
assert_eq!(age.effective_age_days(), 5.0);
}
#[test]
fn test_freshness_scorer_new_content() {
let scorer = FreshnessScorer::default();
let age = ContentAge::new(0, 0, 0.0);
let score = scorer.score(&age, FreshnessDecay::Exponential);
assert!(score > 0.9);
}
#[test]
fn test_freshness_scorer_old_content() {
let scorer = FreshnessScorer::default();
let age = ContentAge::new(365, 365, 0.0);
let score = scorer.score(&age, FreshnessDecay::Exponential);
assert!(score < 0.1);
}
#[test]
fn test_boost_schedule_first_day() {
let schedule = BoostSchedule::new(vec![(1, 2.0), (7, 1.5), (30, 1.1)]);
let factor = schedule.factor_for(0);
assert!((factor - 2.0).abs() < 1e-6);
}
#[test]
fn test_boost_schedule_after_all_windows() {
let schedule = BoostSchedule::new(vec![(1, 2.0), (7, 1.5)]);
assert!((schedule.factor_for(30) - 1.0).abs() < 1e-6);
}
#[test]
fn test_boost_schedule_apply_clamps() {
let schedule = BoostSchedule::new(vec![(7, 3.0)]);
let result = schedule.apply(0.8, 3);
assert!(result <= 1.0);
}
}