use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct UserPreferences {
pub category_affinities: HashMap<String, f32>,
pub preferred_duration_ms: Option<i64>,
pub avg_rating_given: f32,
pub preferred_languages: Vec<String>,
}
impl UserPreferences {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_affinity(&mut self, category: impl Into<String>, weight: f32) {
self.category_affinities
.insert(category.into(), weight.clamp(0.0, 1.0));
}
#[must_use]
pub fn affinity_for(&self, category: &str) -> f32 {
self.category_affinities
.get(category)
.copied()
.unwrap_or(0.0)
}
}
#[derive(Debug, Clone)]
pub struct ScoreFactors {
pub category_weight: f32,
pub recency_weight: f32,
pub rating_weight: f32,
pub popularity_weight: f32,
}
impl Default for ScoreFactors {
fn default() -> Self {
Self {
category_weight: 0.4,
recency_weight: 0.2,
rating_weight: 0.3,
popularity_weight: 0.1,
}
}
}
#[derive(Debug, Clone)]
pub struct ContentSignals {
pub categories: Vec<String>,
pub created_at: i64,
pub avg_rating: Option<f32>,
pub view_count: u64,
}
pub struct PersonalizationScore {
factors: ScoreFactors,
now_timestamp: i64,
recency_half_life_secs: f64,
max_view_count: u64,
}
impl PersonalizationScore {
#[must_use]
pub fn new() -> Self {
Self {
factors: ScoreFactors::default(),
now_timestamp: chrono::Utc::now().timestamp(),
recency_half_life_secs: 7.0 * 24.0 * 3600.0, max_view_count: 10_000_000,
}
}
#[must_use]
pub fn with_now(mut self, now: i64) -> Self {
self.now_timestamp = now;
self
}
#[must_use]
pub fn with_factors(mut self, factors: ScoreFactors) -> Self {
self.factors = factors;
self
}
#[must_use]
pub fn with_recency_half_life_days(mut self, days: f64) -> Self {
self.recency_half_life_secs = days * 24.0 * 3600.0;
self
}
#[must_use]
pub fn with_max_view_count(mut self, max: u64) -> Self {
self.max_view_count = max.max(1);
self
}
#[must_use]
pub fn compute(&self, prefs: &UserPreferences, signals: &ContentSignals) -> f32 {
let category_score = self.compute_category_score(prefs, signals);
let recency_score = self.compute_recency_score(signals.created_at);
let rating_score = self.compute_rating_score(signals.avg_rating, prefs.avg_rating_given);
let popularity_score = self.compute_popularity_score(signals.view_count);
let score = self.factors.category_weight * category_score
+ self.factors.recency_weight * recency_score
+ self.factors.rating_weight * rating_score
+ self.factors.popularity_weight * popularity_score;
score.clamp(0.0, 1.0)
}
#[must_use]
pub fn compute_category_score(&self, prefs: &UserPreferences, signals: &ContentSignals) -> f32 {
if signals.categories.is_empty() || prefs.category_affinities.is_empty() {
return 0.0;
}
let total: f32 = signals
.categories
.iter()
.map(|cat| prefs.affinity_for(cat))
.sum();
(total / signals.categories.len() as f32).clamp(0.0, 1.0)
}
#[must_use]
pub fn compute_recency_score(&self, created_at: i64) -> f32 {
let age_secs = (self.now_timestamp - created_at).max(0) as f64;
let lambda = std::f64::consts::LN_2 / self.recency_half_life_secs;
((-lambda * age_secs).exp() as f32).clamp(0.0, 1.0)
}
#[must_use]
pub fn compute_rating_score(&self, avg_rating: Option<f32>, user_avg_rating: f32) -> f32 {
let content_rating = avg_rating.unwrap_or(2.5); let normalized = content_rating / 5.0;
let user_normalized = user_avg_rating / 5.0;
let match_bonus = if content_rating >= user_avg_rating {
0.1
} else {
0.0
};
(normalized * 0.9 + user_normalized * 0.1 + match_bonus).clamp(0.0, 1.0)
}
#[must_use]
pub fn compute_popularity_score(&self, view_count: u64) -> f32 {
if view_count == 0 {
return 0.0;
}
let log_views = (view_count as f64 + 1.0).ln();
let log_max = (self.max_view_count as f64 + 1.0).ln();
(log_views / log_max).clamp(0.0, 1.0) as f32
}
}
impl Default for PersonalizationScore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_prefs() -> UserPreferences {
let mut prefs = UserPreferences::new();
prefs.set_affinity("Action", 0.8);
prefs.set_affinity("Comedy", 0.5);
prefs.avg_rating_given = 3.5;
prefs
}
fn base_signals() -> ContentSignals {
ContentSignals {
categories: vec!["Action".to_string(), "Thriller".to_string()],
created_at: chrono::Utc::now().timestamp() - 3600, avg_rating: Some(4.2),
view_count: 150_000,
}
}
#[test]
fn test_personalization_score_creation() {
let scorer = PersonalizationScore::new();
assert!((scorer.factors.category_weight - 0.4).abs() < f32::EPSILON);
}
#[test]
fn test_compute_score_range() {
let scorer = PersonalizationScore::new();
let prefs = base_prefs();
let signals = base_signals();
let score = scorer.compute(&prefs, &signals);
assert!((0.0..=1.0).contains(&score), "Score {score} out of range");
}
#[test]
fn test_category_score_known_affinity() {
let scorer = PersonalizationScore::new();
let prefs = base_prefs();
let signals = ContentSignals {
categories: vec!["Action".to_string()],
created_at: 0,
avg_rating: None,
view_count: 0,
};
let cat_score = scorer.compute_category_score(&prefs, &signals);
assert!((cat_score - 0.8).abs() < f32::EPSILON);
}
#[test]
fn test_category_score_no_affinity() {
let scorer = PersonalizationScore::new();
let prefs = base_prefs();
let signals = ContentSignals {
categories: vec!["Documentary".to_string()],
created_at: 0,
avg_rating: None,
view_count: 0,
};
let cat_score = scorer.compute_category_score(&prefs, &signals);
assert!((cat_score - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_recency_score_fresh_content() {
let now = chrono::Utc::now().timestamp();
let scorer = PersonalizationScore::new().with_now(now);
let score = scorer.compute_recency_score(now - 60); assert!(
score > 0.99,
"Very recent content should score near 1, got {score}"
);
}
#[test]
fn test_recency_score_old_content() {
let now = chrono::Utc::now().timestamp();
let scorer = PersonalizationScore::new().with_now(now);
let year_ago = now - 365 * 24 * 3600;
let score = scorer.compute_recency_score(year_ago);
assert!(
score < 0.1,
"Year-old content should score low, got {score}"
);
}
#[test]
fn test_rating_score_high_quality() {
let scorer = PersonalizationScore::new();
let score = scorer.compute_rating_score(Some(5.0), 3.0);
assert!(score > 0.9, "5-star content should score high, got {score}");
}
#[test]
fn test_popularity_score_zero_views() {
let scorer = PersonalizationScore::new();
let score = scorer.compute_popularity_score(0);
assert!((score - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_popularity_score_grows_with_views() {
let scorer = PersonalizationScore::new();
let low = scorer.compute_popularity_score(100);
let high = scorer.compute_popularity_score(1_000_000);
assert!(
high > low,
"More views should yield higher popularity score"
);
}
#[test]
fn test_with_custom_factors() {
let factors = ScoreFactors {
category_weight: 1.0,
recency_weight: 0.0,
rating_weight: 0.0,
popularity_weight: 0.0,
};
let scorer = PersonalizationScore::new().with_factors(factors);
let mut prefs = UserPreferences::new();
prefs.set_affinity("Action", 0.7);
let signals = ContentSignals {
categories: vec!["Action".to_string()],
created_at: 0,
avg_rating: None,
view_count: 0,
};
let score = scorer.compute(&prefs, &signals);
assert!(
(score - 0.7).abs() < 0.01,
"Score should be ~0.7, got {score}"
);
}
}