1#![forbid(unsafe_code)]
60#![warn(missing_docs)]
61#![allow(clippy::module_name_repetitions)]
62#![allow(clippy::similar_names)]
63#![allow(clippy::cast_possible_truncation)]
64#![allow(clippy::cast_sign_loss)]
65#![allow(clippy::cast_precision_loss)]
66#![allow(clippy::too_many_arguments)]
67#![allow(clippy::too_many_lines)]
68#![allow(clippy::missing_errors_doc)]
69#![allow(dead_code)]
70
71pub mod ab_test;
72pub mod als;
73pub mod bandits;
74pub mod batch_recommend;
75pub mod calibration;
76pub mod cold_start;
77pub mod collab_filter;
78pub mod collaborative;
79pub mod content;
80pub mod content_based;
81pub mod content_filter;
82pub mod context_signal;
83pub mod contextual_bandits;
84pub mod cross_domain;
85pub mod decay_model;
86pub mod dense_linalg;
87pub mod diversity;
88pub mod diversity_rerank;
89pub mod embargo;
90pub mod error;
91pub mod evaluation;
92pub mod explain;
93pub mod exploration_policy;
94pub mod fairness;
95pub mod feature_store;
96pub mod federated;
97pub mod feedback_signal;
98pub mod freshness;
99pub mod genre_affinity;
100pub mod history;
101pub mod hybrid;
102pub mod impression_tracker;
103pub mod item_similarity;
104pub mod knowledge_graph;
105pub mod lsh;
106pub mod multi_objective;
107pub mod novelty;
108pub mod personalize;
109pub mod playlist_generator;
110pub mod popularity_bias;
111pub mod profile;
112pub mod rank;
113pub mod ranking;
114pub mod rate_limit;
115pub mod rating;
116pub mod recommendation_score;
117pub mod score_cache;
118pub mod sequence_model;
119pub mod session;
120pub mod session_recommend;
121pub mod svd_pp;
122pub mod trending;
123pub mod trending_detection;
124pub mod user_profile;
125pub mod user_segment;
126pub mod watch_history;
127
128#[cfg(feature = "onnx")]
129pub mod ml;
130
131pub use error::{RecommendError, RecommendResult};
133
134#[cfg(feature = "onnx")]
135pub use ml::{rank_by_similarity, ContentEmbedding, EmbeddingExtractor};
136
137use serde::{Deserialize, Serialize};
138use uuid::Uuid;
139
140pub struct RecommendationEngine {
142 content_recommender: content::similarity::ContentRecommender,
144 collaborative_engine: collaborative::matrix::CollaborativeEngine,
146 hybrid_combiner: hybrid::combine::HybridCombiner,
148 profile_manager: profile::user::UserProfileManager,
150 history_tracker: history::track::HistoryTracker,
152 rating_manager: rating::explicit::RatingManager,
154 trending_detector: trending::detect::TrendingDetector,
156 personalization_engine: personalize::engine::PersonalizationEngine,
158 diversity_enforcer: diversity::ensure::DiversityEnforcer,
160 freshness_balancer: freshness::balance::FreshnessBalancer,
162 rate_limiter: Option<rate_limit::RecommendationRateLimiter>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct RecommendationRequest {
169 pub user_id: Uuid,
171 pub limit: usize,
173 pub content_id: Option<Uuid>,
175 pub strategy: RecommendationStrategy,
177 pub context: RecommendationContext,
179 pub diversity: DiversitySettings,
181 pub include_explanations: bool,
183}
184
185#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
187pub enum RecommendationStrategy {
188 ContentBased,
190 Collaborative,
192 Hybrid,
194 Personalized,
196 Trending,
198}
199
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct RecommendationContext {
203 pub timestamp: Option<i64>,
205 pub device: Option<String>,
207 pub location: Option<String>,
209 pub session_id: Option<Uuid>,
211 pub time_of_day: Option<TimeOfDay>,
213 pub day_of_week: Option<u8>,
215}
216
217#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
219pub enum TimeOfDay {
220 Morning,
222 Afternoon,
224 Evening,
226 Night,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct DiversitySettings {
233 pub enabled: bool,
235 pub category_diversity: f32,
237 pub include_serendipity: bool,
239 pub serendipity_weight: f32,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct Recommendation {
246 pub content_id: Uuid,
248 pub score: f32,
250 pub rank: usize,
252 pub reasons: Vec<RecommendationReason>,
254 pub metadata: ContentMetadata,
256 pub explanation: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
262pub enum RecommendationReason {
263 SimilarToLiked {
265 content_id: Uuid,
267 similarity: f32,
269 },
270 CollaborativeFiltering {
272 confidence: f32,
274 },
275 Trending {
277 trending_score: f32,
279 },
280 MatchesProfile {
282 categories: Vec<String>,
284 },
285 FreshContent {
287 published_days_ago: u32,
289 },
290 Popular {
292 view_count: u64,
294 },
295 ContinueWatching {
297 progress: f32,
299 },
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct ContentMetadata {
305 pub title: String,
307 pub description: Option<String>,
309 pub categories: Vec<String>,
311 pub duration_ms: Option<i64>,
313 pub thumbnail_url: Option<String>,
315 pub created_at: i64,
317 pub avg_rating: Option<f32>,
319 pub view_count: u64,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct RecommendationResults {
326 pub user_id: Uuid,
328 pub recommendations: Vec<Recommendation>,
330 pub total_candidates: usize,
332 pub processing_time_ms: u64,
334 pub strategy: RecommendationStrategy,
336}
337
338impl RecommendationEngine {
339 #[must_use]
341 pub fn new() -> Self {
342 Self {
343 content_recommender: content::similarity::ContentRecommender::new(),
344 collaborative_engine: collaborative::matrix::CollaborativeEngine::new(),
345 hybrid_combiner: hybrid::combine::HybridCombiner::new(),
346 profile_manager: profile::user::UserProfileManager::new(),
347 history_tracker: history::track::HistoryTracker::new(),
348 rating_manager: rating::explicit::RatingManager::new(),
349 trending_detector: trending::detect::TrendingDetector::new(),
350 personalization_engine: personalize::engine::PersonalizationEngine::new(),
351 diversity_enforcer: diversity::ensure::DiversityEnforcer::new(),
352 freshness_balancer: freshness::balance::FreshnessBalancer::new(0.3, 30),
353 rate_limiter: None,
354 }
355 }
356
357 #[must_use]
362 pub fn with_rate_limiter(config: rate_limit::RateLimitConfig, now: i64) -> Self {
363 let limiter = rate_limit::RecommendationRateLimiter::new(config, now);
364 Self {
365 content_recommender: content::similarity::ContentRecommender::new(),
366 collaborative_engine: collaborative::matrix::CollaborativeEngine::new(),
367 hybrid_combiner: hybrid::combine::HybridCombiner::new(),
368 profile_manager: profile::user::UserProfileManager::new(),
369 history_tracker: history::track::HistoryTracker::new(),
370 rating_manager: rating::explicit::RatingManager::new(),
371 trending_detector: trending::detect::TrendingDetector::new(),
372 personalization_engine: personalize::engine::PersonalizationEngine::new(),
373 diversity_enforcer: diversity::ensure::DiversityEnforcer::new(),
374 freshness_balancer: freshness::balance::FreshnessBalancer::new(0.3, 30),
375 rate_limiter: Some(limiter),
376 }
377 }
378
379 pub fn set_rate_limiter(&mut self, config: rate_limit::RateLimitConfig, now: i64) {
381 self.rate_limiter = Some(rate_limit::RecommendationRateLimiter::new(config, now));
382 }
383
384 pub fn disable_rate_limiter(&mut self) {
386 self.rate_limiter = None;
387 }
388
389 #[must_use]
391 pub fn has_rate_limiter(&self) -> bool {
392 self.rate_limiter.is_some()
393 }
394
395 #[must_use]
399 pub fn user_available_tokens(&self, user_id: &str) -> Option<f64> {
400 self.rate_limiter
401 .as_ref()
402 .and_then(|rl| rl.user_available_tokens(user_id))
403 }
404
405 #[must_use]
409 pub fn global_available_tokens(&self) -> Option<f64> {
410 self.rate_limiter
411 .as_ref()
412 .map(|rl| rl.global_available_tokens())
413 }
414
415 pub fn recommend(
430 &mut self,
431 request: &RecommendationRequest,
432 ) -> RecommendResult<RecommendationResults> {
433 use std::collections::HashMap;
434
435 if let Some(ref mut rl) = self.rate_limiter {
437 let user_key = request.user_id.to_string();
438 let now = std::time::SystemTime::now()
439 .duration_since(std::time::UNIX_EPOCH)
440 .unwrap_or_default()
441 .as_secs() as i64;
442 let decision = rl.check_and_consume(&user_key, now);
443 if !decision.is_allowed() {
444 return Err(RecommendError::RateLimited(format!(
445 "User {} exceeded rate limit: {decision:?}",
446 request.user_id
447 )));
448 }
449 }
450
451 let start = std::time::Instant::now();
452
453 let candidates = match request.strategy {
456 RecommendationStrategy::ContentBased => {
457 self.get_content_based_recommendations(request)?
458 }
459 RecommendationStrategy::Collaborative => {
460 self.get_collaborative_recommendations(request)?
461 }
462 RecommendationStrategy::Hybrid => self.get_hybrid_parallel(request)?,
463 RecommendationStrategy::Personalized => {
464 self.get_personalized_recommendations(request)?
465 }
466 RecommendationStrategy::Trending => self.get_trending_recommendations(request)?,
467 };
468
469 let candidates = {
471 let mut best: HashMap<uuid::Uuid, Recommendation> = HashMap::new();
472 for rec in candidates {
473 let entry = best.entry(rec.content_id);
474 entry
475 .and_modify(|existing| {
476 if rec.score > existing.score {
477 *existing = rec.clone();
478 }
479 })
480 .or_insert(rec);
481 }
482 let mut deduped: Vec<Recommendation> = best.into_values().collect();
483 deduped.sort_by(|a, b| {
484 b.score
485 .partial_cmp(&a.score)
486 .unwrap_or(std::cmp::Ordering::Equal)
487 });
488 deduped
489 };
490
491 let diverse_candidates = if request.diversity.enabled {
493 self.diversity_enforcer
494 .enforce_diversity(candidates, &request.diversity)?
495 } else {
496 candidates
497 };
498
499 let balanced_candidates = self.freshness_balancer.balance(diverse_candidates)?;
501
502 let mut ranked = self.rank_recommendations(balanced_candidates)?;
504
505 ranked.truncate(request.limit);
507
508 if request.include_explanations {
510 self.add_explanations(&mut ranked)?;
511 }
512
513 let processing_time_ms = start.elapsed().as_millis() as u64;
514
515 let total_candidates = ranked.len();
516 Ok(RecommendationResults {
517 user_id: request.user_id,
518 recommendations: ranked,
519 total_candidates,
520 processing_time_ms,
521 strategy: request.strategy,
522 })
523 }
524
525 fn get_content_based_recommendations(
527 &self,
528 request: &RecommendationRequest,
529 ) -> RecommendResult<Vec<Recommendation>> {
530 self.content_recommender.recommend(request)
531 }
532
533 fn get_collaborative_recommendations(
535 &self,
536 request: &RecommendationRequest,
537 ) -> RecommendResult<Vec<Recommendation>> {
538 self.collaborative_engine.recommend(request)
539 }
540
541 fn get_hybrid_recommendations(
543 &self,
544 request: &RecommendationRequest,
545 ) -> RecommendResult<Vec<Recommendation>> {
546 self.hybrid_combiner.recommend(request)
547 }
548
549 fn get_hybrid_parallel(
555 &self,
556 request: &RecommendationRequest,
557 ) -> RecommendResult<Vec<Recommendation>> {
558 use rayon::prelude::*;
559
560 let strategies: &[RecommendationStrategy] = &[
563 RecommendationStrategy::ContentBased,
564 RecommendationStrategy::Collaborative,
565 RecommendationStrategy::Personalized,
566 RecommendationStrategy::Trending,
567 ];
568
569 let parallel_results: Vec<Vec<Recommendation>> = strategies
571 .par_iter()
572 .filter_map(|strategy| {
573 let result = match strategy {
574 RecommendationStrategy::ContentBased => {
575 self.get_content_based_recommendations(request)
576 }
577 RecommendationStrategy::Collaborative => {
578 self.get_collaborative_recommendations(request)
579 }
580 RecommendationStrategy::Personalized => {
581 self.get_personalized_recommendations(request)
582 }
583 RecommendationStrategy::Trending => self.get_trending_recommendations(request),
584 RecommendationStrategy::Hybrid => return None,
585 };
586 result.ok()
587 })
588 .collect();
589
590 let combiner_result = self.hybrid_combiner.recommend(request).unwrap_or_default();
592
593 let mut merged: Vec<Recommendation> = parallel_results.into_iter().flatten().collect();
595 merged.extend(combiner_result);
596 Ok(merged)
597 }
598
599 fn get_personalized_recommendations(
601 &self,
602 request: &RecommendationRequest,
603 ) -> RecommendResult<Vec<Recommendation>> {
604 self.personalization_engine.recommend(request)
605 }
606
607 fn get_trending_recommendations(
609 &self,
610 request: &RecommendationRequest,
611 ) -> RecommendResult<Vec<Recommendation>> {
612 self.trending_detector.get_trending(request.limit)
613 }
614
615 fn rank_recommendations(
617 &self,
618 candidates: Vec<Recommendation>,
619 ) -> RecommendResult<Vec<Recommendation>> {
620 rank::score::rank_recommendations(candidates)
621 }
622
623 fn add_explanations(&self, recommendations: &mut [Recommendation]) -> RecommendResult<()> {
625 for rec in recommendations {
626 let explanation = explain::generate::generate_explanation(rec)?;
627 rec.explanation = Some(explanation);
628 }
629 Ok(())
630 }
631
632 pub fn record_view(
638 &mut self,
639 user_id: Uuid,
640 content_id: Uuid,
641 watch_time_ms: i64,
642 completed: bool,
643 ) -> RecommendResult<()> {
644 self.history_tracker
646 .record_view(user_id, content_id, watch_time_ms, completed)?;
647
648 self.profile_manager
650 .update_from_view(user_id, content_id, watch_time_ms, completed)?;
651
652 self.rating_manager.update_implicit_rating(
654 user_id,
655 content_id,
656 watch_time_ms,
657 completed,
658 )?;
659
660 Ok(())
661 }
662
663 pub fn record_rating(
669 &mut self,
670 user_id: Uuid,
671 content_id: Uuid,
672 rating: f32,
673 ) -> RecommendResult<()> {
674 self.rating_manager
675 .record_rating(user_id, content_id, rating)?;
676 self.profile_manager
677 .update_from_rating(user_id, content_id, rating)?;
678 Ok(())
679 }
680
681 pub fn update_trending(&mut self) -> RecommendResult<()> {
687 self.trending_detector.update_scores()
688 }
689
690 pub fn get_user_profile(&self, user_id: Uuid) -> RecommendResult<profile::user::UserProfile> {
696 self.profile_manager.get_profile(user_id)
697 }
698
699 pub fn get_similar_users(&self, user_id: Uuid, limit: usize) -> RecommendResult<Vec<Uuid>> {
705 self.profile_manager.get_similar_users(user_id, limit)
706 }
707}
708
709impl Default for RecommendationEngine {
710 fn default() -> Self {
711 Self::new()
712 }
713}
714
715impl Default for RecommendationRequest {
716 fn default() -> Self {
717 Self {
718 user_id: Uuid::new_v4(),
719 limit: 10,
720 content_id: None,
721 strategy: RecommendationStrategy::Hybrid,
722 context: RecommendationContext::default(),
723 diversity: DiversitySettings::default(),
724 include_explanations: false,
725 }
726 }
727}
728
729impl Default for DiversitySettings {
730 fn default() -> Self {
731 Self {
732 enabled: true,
733 category_diversity: 0.3,
734 include_serendipity: true,
735 serendipity_weight: 0.1,
736 }
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn test_recommendation_engine_creation() {
746 let engine = RecommendationEngine::new();
747 assert!(std::mem::size_of_val(&engine) > 0);
748 }
749
750 #[test]
751 fn test_recommendation_request_default() {
752 let request = RecommendationRequest::default();
753 assert_eq!(request.limit, 10);
754 assert!(matches!(request.strategy, RecommendationStrategy::Hybrid));
755 }
756
757 #[test]
758 fn test_diversity_settings_default() {
759 let settings = DiversitySettings::default();
760 assert!(settings.enabled);
761 assert!((settings.category_diversity - 0.3).abs() < f32::EPSILON);
762 }
763
764 #[test]
765 fn test_recommendation_strategy_variants() {
766 let strategies = [
767 RecommendationStrategy::ContentBased,
768 RecommendationStrategy::Collaborative,
769 RecommendationStrategy::Hybrid,
770 RecommendationStrategy::Personalized,
771 RecommendationStrategy::Trending,
772 ];
773 assert_eq!(strategies.len(), 5);
774 }
775
776 #[test]
777 fn test_recommend_hybrid_parallel_succeeds() {
778 let mut engine = RecommendationEngine::new();
779 let request = RecommendationRequest {
780 strategy: RecommendationStrategy::Hybrid,
781 limit: 10,
782 ..Default::default()
783 };
784 let result = engine.recommend(&request);
786 assert!(result.is_ok());
787 assert!(matches!(
788 result.expect("hybrid recommend should succeed").strategy,
789 RecommendationStrategy::Hybrid
790 ));
791 }
792
793 #[test]
794 fn test_recommend_all_strategies_run() {
795 let mut engine = RecommendationEngine::new();
796 for strategy in [
797 RecommendationStrategy::ContentBased,
798 RecommendationStrategy::Collaborative,
799 RecommendationStrategy::Hybrid,
800 RecommendationStrategy::Personalized,
801 RecommendationStrategy::Trending,
802 ] {
803 let request = RecommendationRequest {
804 strategy,
805 limit: 5,
806 ..Default::default()
807 };
808 let result = engine.recommend(&request);
809 assert!(result.is_ok(), "strategy {strategy:?} failed");
810 }
811 }
812
813 #[test]
818 fn test_engine_no_rate_limiter_by_default() {
819 let engine = RecommendationEngine::new();
820 assert!(!engine.has_rate_limiter());
821 assert!(engine.global_available_tokens().is_none());
822 }
823
824 #[test]
825 fn test_engine_with_rate_limiter_enabled() {
826 let config = rate_limit::RateLimitConfig::default();
827 let engine = RecommendationEngine::with_rate_limiter(config, 0);
828 assert!(engine.has_rate_limiter());
829 assert!(engine.global_available_tokens().is_some());
830 }
831
832 #[test]
833 fn test_engine_rate_limiter_allows_under_limit() {
834 let config = rate_limit::RateLimitConfig {
835 per_user_capacity: 10.0,
836 per_user_refill_rate: 1.0,
837 global_capacity: 100.0,
838 global_refill_rate: 10.0,
839 tokens_per_request: 1.0,
840 };
841 let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
842 let request = RecommendationRequest::default();
843 let result = engine.recommend(&request);
845 assert!(result.is_ok(), "should be allowed: {result:?}");
846 }
847
848 #[test]
849 fn test_engine_rate_limiter_rejects_when_exhausted() {
850 let config = rate_limit::RateLimitConfig {
851 per_user_capacity: 2.0,
852 per_user_refill_rate: 0.0, global_capacity: 1000.0,
854 global_refill_rate: 100.0,
855 tokens_per_request: 1.0,
856 };
857 let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
858 let user_id = uuid::Uuid::new_v4();
859 let request = RecommendationRequest {
860 user_id,
861 ..Default::default()
862 };
863 assert!(engine.recommend(&request).is_ok());
865 assert!(engine.recommend(&request).is_ok());
866 let result = engine.recommend(&request);
868 assert!(result.is_err());
869 let err_str = result.unwrap_err().to_string();
870 assert!(err_str.contains("Rate limited") || err_str.contains("rate limit"));
871 }
872
873 #[test]
874 fn test_engine_set_rate_limiter() {
875 let mut engine = RecommendationEngine::new();
876 assert!(!engine.has_rate_limiter());
877 let config = rate_limit::RateLimitConfig::default();
878 engine.set_rate_limiter(config, 0);
879 assert!(engine.has_rate_limiter());
880 }
881
882 #[test]
883 fn test_engine_disable_rate_limiter() {
884 let config = rate_limit::RateLimitConfig::default();
885 let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
886 assert!(engine.has_rate_limiter());
887 engine.disable_rate_limiter();
888 assert!(!engine.has_rate_limiter());
889 }
890
891 #[test]
892 fn test_engine_user_available_tokens_after_request() {
893 let config = rate_limit::RateLimitConfig {
894 per_user_capacity: 10.0,
895 per_user_refill_rate: 1.0,
896 global_capacity: 1000.0,
897 global_refill_rate: 100.0,
898 tokens_per_request: 1.0,
899 };
900 let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
901 let user_id = uuid::Uuid::new_v4();
902 let request = RecommendationRequest {
903 user_id,
904 ..Default::default()
905 };
906 engine.recommend(&request).ok();
907 let tokens = engine.user_available_tokens(&user_id.to_string());
908 assert!(tokens.is_some());
909 let t = tokens.expect("should have bucket");
911 assert!((t - 9.0).abs() < f64::EPSILON, "expected 9.0 but got {t}");
912 }
913
914 #[test]
915 fn test_engine_global_tokens_decrease_per_request() {
916 let config = rate_limit::RateLimitConfig {
917 per_user_capacity: 100.0,
918 per_user_refill_rate: 10.0,
919 global_capacity: 50.0,
920 global_refill_rate: 0.0,
921 tokens_per_request: 1.0,
922 };
923 let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
924 let before = engine
925 .global_available_tokens()
926 .expect("should have global");
927 engine.recommend(&RecommendationRequest::default()).ok();
928 let after = engine
929 .global_available_tokens()
930 .expect("should have global");
931 assert!((before - after - 1.0).abs() < f64::EPSILON);
932 }
933
934 #[test]
935 fn test_engine_multiple_users_independent_limits() {
936 let config = rate_limit::RateLimitConfig {
937 per_user_capacity: 1.0,
938 per_user_refill_rate: 0.0,
939 global_capacity: 1000.0,
940 global_refill_rate: 100.0,
941 tokens_per_request: 1.0,
942 };
943 let mut engine = RecommendationEngine::with_rate_limiter(config, 0);
944
945 let user_a = uuid::Uuid::new_v4();
946 let user_b = uuid::Uuid::new_v4();
947
948 let req_a = RecommendationRequest {
949 user_id: user_a,
950 ..Default::default()
951 };
952 let req_b = RecommendationRequest {
953 user_id: user_b,
954 ..Default::default()
955 };
956
957 assert!(engine.recommend(&req_a).is_ok(), "user_a first request");
959 assert!(engine.recommend(&req_b).is_ok(), "user_b first request");
960
961 assert!(
963 engine.recommend(&req_a).is_err(),
964 "user_a should be rate-limited"
965 );
966 assert!(
967 engine.recommend(&req_b).is_err(),
968 "user_b should be rate-limited"
969 );
970 }
971}