use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct ScoringWeights {
pub alpha: f64,
pub beta: f64,
pub gamma: f64,
pub delta: f64,
pub decay_rate: f64,
}
impl Default for ScoringWeights {
fn default() -> Self {
Self {
alpha: 0.25,
beta: 0.25,
gamma: 0.3,
delta: 0.2,
decay_rate: 0.01,
}
}
}
pub fn recency_score(created_at: DateTime<Utc>, now: DateTime<Utc>, decay_rate: f64) -> f64 {
let duration = now.signed_duration_since(created_at);
let hours = duration.num_seconds() as f64 / 3600.0;
if hours <= 0.0 {
return 1.0;
}
(-decay_rate * hours).exp()
}
pub fn importance_score(importance: u8) -> f64 {
let clamped = importance.clamp(1, 10);
(clamped as f64 - 1.0) / 9.0
}
pub fn strength_score(strength: f64) -> f64 {
strength.clamp(0.0, 1.0)
}
pub const STRENGTH_DECAY_RATE: f64 = 0.005;
pub fn effective_strength(
strength: f64,
last_accessed: DateTime<Utc>,
now: DateTime<Utc>,
decay_rate: f64,
) -> f64 {
let hours = now
.signed_duration_since(last_accessed)
.num_seconds()
.max(0) as f64
/ 3600.0;
let decayed = strength * (-decay_rate * hours).exp();
decayed.clamp(0.0, 1.0)
}
pub fn composite_score(
weights: &ScoringWeights,
created_at: DateTime<Utc>,
now: DateTime<Utc>,
importance: u8,
relevance: f64,
strength: f64,
) -> f64 {
let r = recency_score(created_at, now, weights.decay_rate);
let i = importance_score(importance);
let rel = relevance.clamp(0.0, 1.0);
let s = strength_score(strength);
weights.alpha * r + weights.beta * i + weights.gamma * rel + weights.delta * s
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn recency_score_now_is_one() {
let now = Utc::now();
let score = recency_score(now, now, 0.01);
assert!((score - 1.0).abs() < f64::EPSILON);
}
#[test]
fn recency_score_decays_over_time() {
let now = Utc::now();
let one_day_ago = now - Duration::hours(24);
let score = recency_score(one_day_ago, now, 0.01);
assert!(score < 1.0);
assert!(score > 0.5);
}
#[test]
fn recency_score_very_old_approaches_zero() {
let now = Utc::now();
let long_ago = now - Duration::hours(10000);
let score = recency_score(long_ago, now, 0.01);
assert!(score < 0.001);
}
#[test]
fn recency_score_negative_duration_clamps() {
let now = Utc::now();
let future = now + Duration::hours(5);
let score = recency_score(future, now, 0.01);
assert!((score - 1.0).abs() < f64::EPSILON);
}
#[test]
fn importance_score_range() {
assert!((importance_score(1) - 0.0).abs() < f64::EPSILON);
assert!((importance_score(10) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn importance_score_midpoint() {
let score = importance_score(5);
assert!((score - 4.0 / 9.0).abs() < f64::EPSILON);
}
#[test]
fn importance_score_clamps_out_of_range() {
assert!((importance_score(0) - 0.0).abs() < f64::EPSILON);
assert!((importance_score(15) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn strength_score_identity() {
assert!((strength_score(0.5) - 0.5).abs() < f64::EPSILON);
assert!((strength_score(1.0) - 1.0).abs() < f64::EPSILON);
assert!((strength_score(0.0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn strength_score_clamps() {
assert!((strength_score(-0.5) - 0.0).abs() < f64::EPSILON);
assert!((strength_score(1.5) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn composite_score_all_max() {
let now = Utc::now();
let weights = ScoringWeights::default();
let score = composite_score(&weights, now, now, 10, 1.0, 1.0);
assert!((score - 1.0).abs() < f64::EPSILON);
}
#[test]
fn composite_score_all_zero() {
let now = Utc::now();
let old = now - Duration::hours(100_000);
let weights = ScoringWeights::default();
let score = composite_score(&weights, old, now, 1, 0.0, 0.0);
assert!(score < 0.01);
}
#[test]
fn composite_score_importance_dominates() {
let now = Utc::now();
let old = now - Duration::hours(100_000);
let weights = ScoringWeights {
alpha: 0.0,
beta: 1.0,
gamma: 0.0,
delta: 0.0,
decay_rate: 0.01,
};
let score = composite_score(&weights, old, now, 10, 0.0, 0.0);
assert!((score - 1.0).abs() < f64::EPSILON);
}
#[test]
fn composite_score_strength_contributes() {
let now = Utc::now();
let weights = ScoringWeights {
alpha: 0.0,
beta: 0.0,
gamma: 0.0,
delta: 1.0,
decay_rate: 0.01,
};
let score = composite_score(&weights, now, now, 1, 0.0, 0.8);
assert!((score - 0.8).abs() < f64::EPSILON);
}
#[test]
fn low_strength_entries_rank_lower() {
let now = Utc::now();
let weights = ScoringWeights::default();
let strong = composite_score(&weights, now, now, 5, 0.5, 1.0);
let weak = composite_score(&weights, now, now, 5, 0.5, 0.1);
assert!(
strong > weak,
"higher strength should yield higher composite score"
);
}
#[test]
fn default_weights_sum_to_one() {
let w = ScoringWeights::default();
assert!((w.alpha + w.beta + w.gamma + w.delta - 1.0).abs() < f64::EPSILON);
}
#[test]
fn effective_strength_no_decay_when_just_accessed() {
let now = Utc::now();
let eff = effective_strength(0.8, now, now, STRENGTH_DECAY_RATE);
assert!((eff - 0.8).abs() < 1e-10);
}
#[test]
fn effective_strength_decays_over_time() {
let now = Utc::now();
let week_ago = now - Duration::hours(7 * 24);
let eff = effective_strength(1.0, week_ago, now, STRENGTH_DECAY_RATE);
assert!(eff < 0.5, "should decay significantly after a week: {eff}");
assert!(eff > 0.3, "should not fully decay after a week: {eff}");
}
#[test]
fn effective_strength_approaches_zero_for_very_old() {
let now = Utc::now();
let month_ago = now - Duration::hours(30 * 24);
let eff = effective_strength(1.0, month_ago, now, STRENGTH_DECAY_RATE);
assert!(eff < 0.05, "should be near zero after a month: {eff}");
}
#[test]
fn effective_strength_clamps_negative_duration() {
let now = Utc::now();
let future = now + Duration::hours(5);
let eff = effective_strength(0.8, future, now, STRENGTH_DECAY_RATE);
assert!((eff - 0.8).abs() < 1e-10);
}
}