#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::similar_names)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::too_many_arguments)]
#![allow(clippy::too_many_lines)]
#![allow(clippy::missing_errors_doc)]
#![allow(dead_code)]
pub mod ab_test;
pub mod als;
pub mod bandits;
pub mod batch_recommend;
pub mod calibration;
pub mod cold_start;
pub mod collab_filter;
pub mod collaborative;
pub mod content;
pub mod content_based;
pub mod content_filter;
pub mod context_signal;
pub mod contextual_bandits;
pub mod cross_domain;
pub mod decay_model;
pub mod dense_linalg;
pub mod diversity;
pub mod diversity_rerank;
pub mod embargo;
pub mod error;
pub mod evaluation;
pub mod explain;
pub mod exploration_policy;
pub mod fairness;
pub mod feature_store;
pub mod federated;
pub mod feedback_signal;
pub mod freshness;
pub mod genre_affinity;
pub mod history;
pub mod hybrid;
pub mod impression_tracker;
pub mod item_similarity;
pub mod knowledge_graph;
pub mod lsh;
pub mod multi_objective;
pub mod novelty;
pub mod personalize;
pub mod playlist_generator;
pub mod popularity_bias;
pub mod profile;
pub mod rank;
pub mod ranking;
pub mod rate_limit;
pub mod rating;
pub mod recommendation_score;
pub mod score_cache;
pub mod sequence_model;
pub mod session;
pub mod session_recommend;
pub mod svd_pp;
pub mod trending;
pub mod trending_detection;
pub mod user_profile;
pub mod user_segment;
pub mod watch_history;
#[cfg(feature = "onnx")]
pub mod ml;
pub use error::{RecommendError, RecommendResult};
#[cfg(feature = "onnx")]
pub use ml::{rank_by_similarity, ContentEmbedding, EmbeddingExtractor};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub struct RecommendationEngine {
content_recommender: content::similarity::ContentRecommender,
collaborative_engine: collaborative::matrix::CollaborativeEngine,
hybrid_combiner: hybrid::combine::HybridCombiner,
profile_manager: profile::user::UserProfileManager,
history_tracker: history::track::HistoryTracker,
rating_manager: rating::explicit::RatingManager,
trending_detector: trending::detect::TrendingDetector,
personalization_engine: personalize::engine::PersonalizationEngine,
diversity_enforcer: diversity::ensure::DiversityEnforcer,
freshness_balancer: freshness::balance::FreshnessBalancer,
rate_limiter: Option<rate_limit::RecommendationRateLimiter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendationRequest {
pub user_id: Uuid,
pub limit: usize,
pub content_id: Option<Uuid>,
pub strategy: RecommendationStrategy,
pub context: RecommendationContext,
pub diversity: DiversitySettings,
pub include_explanations: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum RecommendationStrategy {
ContentBased,
Collaborative,
Hybrid,
Personalized,
Trending,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecommendationContext {
pub timestamp: Option<i64>,
pub device: Option<String>,
pub location: Option<String>,
pub session_id: Option<Uuid>,
pub time_of_day: Option<TimeOfDay>,
pub day_of_week: Option<u8>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum TimeOfDay {
Morning,
Afternoon,
Evening,
Night,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiversitySettings {
pub enabled: bool,
pub category_diversity: f32,
pub include_serendipity: bool,
pub serendipity_weight: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
pub content_id: Uuid,
pub score: f32,
pub rank: usize,
pub reasons: Vec<RecommendationReason>,
pub metadata: ContentMetadata,
pub explanation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RecommendationReason {
SimilarToLiked {
content_id: Uuid,
similarity: f32,
},
CollaborativeFiltering {
confidence: f32,
},
Trending {
trending_score: f32,
},
MatchesProfile {
categories: Vec<String>,
},
FreshContent {
published_days_ago: u32,
},
Popular {
view_count: u64,
},
ContinueWatching {
progress: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentMetadata {
pub title: String,
pub description: Option<String>,
pub categories: Vec<String>,
pub duration_ms: Option<i64>,
pub thumbnail_url: Option<String>,
pub created_at: i64,
pub avg_rating: Option<f32>,
pub view_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendationResults {
pub user_id: Uuid,
pub recommendations: Vec<Recommendation>,
pub total_candidates: usize,
pub processing_time_ms: u64,
pub strategy: RecommendationStrategy,
}
impl RecommendationEngine {
#[must_use]
pub fn new() -> Self {
Self {
content_recommender: content::similarity::ContentRecommender::new(),
collaborative_engine: collaborative::matrix::CollaborativeEngine::new(),
hybrid_combiner: hybrid::combine::HybridCombiner::new(),
profile_manager: profile::user::UserProfileManager::new(),
history_tracker: history::track::HistoryTracker::new(),
rating_manager: rating::explicit::RatingManager::new(),
trending_detector: trending::detect::TrendingDetector::new(),
personalization_engine: personalize::engine::PersonalizationEngine::new(),
diversity_enforcer: diversity::ensure::DiversityEnforcer::new(),
freshness_balancer: freshness::balance::FreshnessBalancer::new(0.3, 30),
rate_limiter: None,
}
}
#[must_use]
pub fn with_rate_limiter(config: rate_limit::RateLimitConfig, now: i64) -> Self {
let limiter = rate_limit::RecommendationRateLimiter::new(config, now);
Self {
content_recommender: content::similarity::ContentRecommender::new(),
collaborative_engine: collaborative::matrix::CollaborativeEngine::new(),
hybrid_combiner: hybrid::combine::HybridCombiner::new(),
profile_manager: profile::user::UserProfileManager::new(),
history_tracker: history::track::HistoryTracker::new(),
rating_manager: rating::explicit::RatingManager::new(),
trending_detector: trending::detect::TrendingDetector::new(),
personalization_engine: personalize::engine::PersonalizationEngine::new(),
diversity_enforcer: diversity::ensure::DiversityEnforcer::new(),
freshness_balancer: freshness::balance::FreshnessBalancer::new(0.3, 30),
rate_limiter: Some(limiter),
}
}
pub fn set_rate_limiter(&mut self, config: rate_limit::RateLimitConfig, now: i64) {
self.rate_limiter = Some(rate_limit::RecommendationRateLimiter::new(config, now));
}
pub fn disable_rate_limiter(&mut self) {
self.rate_limiter = None;
}
#[must_use]
pub fn has_rate_limiter(&self) -> bool {
self.rate_limiter.is_some()
}
#[must_use]
pub fn user_available_tokens(&self, user_id: &str) -> Option<f64> {
self.rate_limiter
.as_ref()
.and_then(|rl| rl.user_available_tokens(user_id))
}
#[must_use]
pub fn global_available_tokens(&self) -> Option<f64> {
self.rate_limiter
.as_ref()
.map(|rl| rl.global_available_tokens())
}
pub fn recommend(
&mut self,
request: &RecommendationRequest,
) -> RecommendResult<RecommendationResults> {
use std::collections::HashMap;
if let Some(ref mut rl) = self.rate_limiter {
let user_key = request.user_id.to_string();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let decision = rl.check_and_consume(&user_key, now);
if !decision.is_allowed() {
return Err(RecommendError::RateLimited(format!(
"User {} exceeded rate limit: {decision:?}",
request.user_id
)));
}
}
let start = std::time::Instant::now();
let candidates = match request.strategy {
RecommendationStrategy::ContentBased => {
self.get_content_based_recommendations(request)?
}
RecommendationStrategy::Collaborative => {
self.get_collaborative_recommendations(request)?
}
RecommendationStrategy::Hybrid => self.get_hybrid_parallel(request)?,
RecommendationStrategy::Personalized => {
self.get_personalized_recommendations(request)?
}
RecommendationStrategy::Trending => self.get_trending_recommendations(request)?,
};
let candidates = {
let mut best: HashMap<uuid::Uuid, Recommendation> = HashMap::new();
for rec in candidates {
let entry = best.entry(rec.content_id);
entry
.and_modify(|existing| {
if rec.score > existing.score {
*existing = rec.clone();
}
})
.or_insert(rec);
}
let mut deduped: Vec<Recommendation> = best.into_values().collect();
deduped.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
deduped
};
let diverse_candidates = if request.diversity.enabled {
self.diversity_enforcer
.enforce_diversity(candidates, &request.diversity)?
} else {
candidates
};
let balanced_candidates = self.freshness_balancer.balance(diverse_candidates)?;
let mut ranked = self.rank_recommendations(balanced_candidates)?;
ranked.truncate(request.limit);
if request.include_explanations {
self.add_explanations(&mut ranked)?;
}
let processing_time_ms = start.elapsed().as_millis() as u64;
let total_candidates = ranked.len();
Ok(RecommendationResults {
user_id: request.user_id,
recommendations: ranked,
total_candidates,
processing_time_ms,
strategy: request.strategy,
})
}
fn get_content_based_recommendations(
&self,
request: &RecommendationRequest,
) -> RecommendResult<Vec<Recommendation>> {
self.content_recommender.recommend(request)
}
fn get_collaborative_recommendations(
&self,
request: &RecommendationRequest,
) -> RecommendResult<Vec<Recommendation>> {
self.collaborative_engine.recommend(request)
}
fn get_hybrid_recommendations(
&self,
request: &RecommendationRequest,
) -> RecommendResult<Vec<Recommendation>> {
self.hybrid_combiner.recommend(request)
}
fn get_hybrid_parallel(
&self,
request: &RecommendationRequest,
) -> RecommendResult<Vec<Recommendation>> {
use rayon::prelude::*;
let strategies: &[RecommendationStrategy] = &[
RecommendationStrategy::ContentBased,
RecommendationStrategy::Collaborative,
RecommendationStrategy::Personalized,
RecommendationStrategy::Trending,
];
let parallel_results: Vec<Vec<Recommendation>> = strategies
.par_iter()
.filter_map(|strategy| {
let result = match strategy {
RecommendationStrategy::ContentBased => {
self.get_content_based_recommendations(request)
}
RecommendationStrategy::Collaborative => {
self.get_collaborative_recommendations(request)
}
RecommendationStrategy::Personalized => {
self.get_personalized_recommendations(request)
}
RecommendationStrategy::Trending => self.get_trending_recommendations(request),
RecommendationStrategy::Hybrid => return None,
};
result.ok()
})
.collect();
let combiner_result = self.hybrid_combiner.recommend(request).unwrap_or_default();
let mut merged: Vec<Recommendation> = parallel_results.into_iter().flatten().collect();
merged.extend(combiner_result);
Ok(merged)
}
fn get_personalized_recommendations(
&self,
request: &RecommendationRequest,
) -> RecommendResult<Vec<Recommendation>> {
self.personalization_engine.recommend(request)
}
fn get_trending_recommendations(
&self,
request: &RecommendationRequest,
) -> RecommendResult<Vec<Recommendation>> {
self.trending_detector.get_trending(request.limit)
}
fn rank_recommendations(
&self,
candidates: Vec<Recommendation>,
) -> RecommendResult<Vec<Recommendation>> {
rank::score::rank_recommendations(candidates)
}
fn add_explanations(&self, recommendations: &mut [Recommendation]) -> RecommendResult<()> {
for rec in recommendations {
let explanation = explain::generate::generate_explanation(rec)?;
rec.explanation = Some(explanation);
}
Ok(())
}
pub fn record_view(
&mut self,
user_id: Uuid,
content_id: Uuid,
watch_time_ms: i64,
completed: bool,
) -> RecommendResult<()> {
self.history_tracker
.record_view(user_id, content_id, watch_time_ms, completed)?;
self.profile_manager
.update_from_view(user_id, content_id, watch_time_ms, completed)?;
self.rating_manager.update_implicit_rating(
user_id,
content_id,
watch_time_ms,
completed,
)?;
Ok(())
}
pub fn record_rating(
&mut self,
user_id: Uuid,
content_id: Uuid,
rating: f32,
) -> RecommendResult<()> {
self.rating_manager
.record_rating(user_id, content_id, rating)?;
self.profile_manager
.update_from_rating(user_id, content_id, rating)?;
Ok(())
}
pub fn update_trending(&mut self) -> RecommendResult<()> {
self.trending_detector.update_scores()
}
pub fn get_user_profile(&self, user_id: Uuid) -> RecommendResult<profile::user::UserProfile> {
self.profile_manager.get_profile(user_id)
}
pub fn get_similar_users(&self, user_id: Uuid, limit: usize) -> RecommendResult<Vec<Uuid>> {
self.profile_manager.get_similar_users(user_id, limit)
}
}
impl Default for RecommendationEngine {
fn default() -> Self {
Self::new()
}
}
impl Default for RecommendationRequest {
fn default() -> Self {
Self {
user_id: Uuid::new_v4(),
limit: 10,
content_id: None,
strategy: RecommendationStrategy::Hybrid,
context: RecommendationContext::default(),
diversity: DiversitySettings::default(),
include_explanations: false,
}
}
}
impl Default for DiversitySettings {
fn default() -> Self {
Self {
enabled: true,
category_diversity: 0.3,
include_serendipity: true,
serendipity_weight: 0.1,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recommendation_engine_creation() {
let engine = RecommendationEngine::new();
assert!(std::mem::size_of_val(&engine) > 0);
}
#[test]
fn test_recommendation_request_default() {
let request = RecommendationRequest::default();
assert_eq!(request.limit, 10);
assert!(matches!(request.strategy, RecommendationStrategy::Hybrid));
}
#[test]
fn test_diversity_settings_default() {
let settings = DiversitySettings::default();
assert!(settings.enabled);
assert!((settings.category_diversity - 0.3).abs() < f32::EPSILON);
}
#[test]
fn test_recommendation_strategy_variants() {
let strategies = [
RecommendationStrategy::ContentBased,
RecommendationStrategy::Collaborative,
RecommendationStrategy::Hybrid,
RecommendationStrategy::Personalized,
RecommendationStrategy::Trending,
];
assert_eq!(strategies.len(), 5);
}
#[test]
fn test_recommend_hybrid_parallel_succeeds() {
let mut engine = RecommendationEngine::new();
let request = RecommendationRequest {
strategy: RecommendationStrategy::Hybrid,
limit: 10,
..Default::default()
};
let result = engine.recommend(&request);
assert!(result.is_ok());
assert!(matches!(
result.expect("hybrid recommend should succeed").strategy,
RecommendationStrategy::Hybrid
));
}
#[test]
fn test_recommend_all_strategies_run() {
let mut engine = RecommendationEngine::new();
for strategy in [
RecommendationStrategy::ContentBased,
RecommendationStrategy::Collaborative,
RecommendationStrategy::Hybrid,
RecommendationStrategy::Personalized,
RecommendationStrategy::Trending,
] {
let request = RecommendationRequest {
strategy,
limit: 5,
..Default::default()
};
let result = engine.recommend(&request);
assert!(result.is_ok(), "strategy {strategy:?} failed");
}
}
#[test]
fn test_engine_no_rate_limiter_by_default() {
let engine = RecommendationEngine::new();
assert!(!engine.has_rate_limiter());
assert!(engine.global_available_tokens().is_none());
}
#[test]
fn test_engine_with_rate_limiter_enabled() {
let config = rate_limit::RateLimitConfig::default();
let engine = RecommendationEngine::with_rate_limiter(config, 0);
assert!(engine.has_rate_limiter());
assert!(engine.global_available_tokens().is_some());
}
#[test]
fn test_engine_rate_limiter_allows_under_limit() {
let config = rate_limit::RateLimitConfig {
per_user_capacity: 10.0,
per_user_refill_rate: 1.0,
global_capacity: 100.0,
global_refill_rate: 10.0,
tokens_per_request: 1.0,
};
let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
let request = RecommendationRequest::default();
let result = engine.recommend(&request);
assert!(result.is_ok(), "should be allowed: {result:?}");
}
#[test]
fn test_engine_rate_limiter_rejects_when_exhausted() {
let config = rate_limit::RateLimitConfig {
per_user_capacity: 2.0,
per_user_refill_rate: 0.0, global_capacity: 1000.0,
global_refill_rate: 100.0,
tokens_per_request: 1.0,
};
let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
let user_id = uuid::Uuid::new_v4();
let request = RecommendationRequest {
user_id,
..Default::default()
};
assert!(engine.recommend(&request).is_ok());
assert!(engine.recommend(&request).is_ok());
let result = engine.recommend(&request);
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(err_str.contains("Rate limited") || err_str.contains("rate limit"));
}
#[test]
fn test_engine_set_rate_limiter() {
let mut engine = RecommendationEngine::new();
assert!(!engine.has_rate_limiter());
let config = rate_limit::RateLimitConfig::default();
engine.set_rate_limiter(config, 0);
assert!(engine.has_rate_limiter());
}
#[test]
fn test_engine_disable_rate_limiter() {
let config = rate_limit::RateLimitConfig::default();
let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
assert!(engine.has_rate_limiter());
engine.disable_rate_limiter();
assert!(!engine.has_rate_limiter());
}
#[test]
fn test_engine_user_available_tokens_after_request() {
let config = rate_limit::RateLimitConfig {
per_user_capacity: 10.0,
per_user_refill_rate: 1.0,
global_capacity: 1000.0,
global_refill_rate: 100.0,
tokens_per_request: 1.0,
};
let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
let user_id = uuid::Uuid::new_v4();
let request = RecommendationRequest {
user_id,
..Default::default()
};
engine.recommend(&request).ok();
let tokens = engine.user_available_tokens(&user_id.to_string());
assert!(tokens.is_some());
let t = tokens.expect("should have bucket");
assert!((t - 9.0).abs() < f64::EPSILON, "expected 9.0 but got {t}");
}
#[test]
fn test_engine_global_tokens_decrease_per_request() {
let config = rate_limit::RateLimitConfig {
per_user_capacity: 100.0,
per_user_refill_rate: 10.0,
global_capacity: 50.0,
global_refill_rate: 0.0,
tokens_per_request: 1.0,
};
let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
let before = engine
.global_available_tokens()
.expect("should have global");
engine.recommend(&RecommendationRequest::default()).ok();
let after = engine
.global_available_tokens()
.expect("should have global");
assert!((before - after - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_engine_multiple_users_independent_limits() {
let config = rate_limit::RateLimitConfig {
per_user_capacity: 1.0,
per_user_refill_rate: 0.0,
global_capacity: 1000.0,
global_refill_rate: 100.0,
tokens_per_request: 1.0,
};
let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
let user_a = uuid::Uuid::new_v4();
let user_b = uuid::Uuid::new_v4();
let req_a = RecommendationRequest {
user_id: user_a,
..Default::default()
};
let req_b = RecommendationRequest {
user_id: user_b,
..Default::default()
};
assert!(engine.recommend(&req_a).is_ok(), "user_a first request");
assert!(engine.recommend(&req_b).is_ok(), "user_b first request");
assert!(
engine.recommend(&req_a).is_err(),
"user_a should be rate-limited"
);
assert!(
engine.recommend(&req_b).is_err(),
"user_b should be rate-limited"
);
}
}