Skip to main content

oximedia_recommend/
lib.rs

1//! Content recommendation and discovery engine for `OxiMedia`.
2//!
3//! `oximedia-recommend` provides comprehensive recommendation capabilities for media platforms,
4//! including content-based filtering, collaborative filtering, hybrid approaches, and
5//! advanced personalization features.
6//!
7//! # Features
8//!
9//! - **Content-based Filtering**: Recommend similar content based on features
10//! - **Collaborative Filtering**: User behavior-based recommendations
11//! - **Hybrid Approach**: Combine multiple recommendation methods
12//! - **Similarity Metrics**: Calculate content similarity using various metrics
13//! - **User Profiles**: Build and manage user preference profiles
14//! - **View History**: Track and analyze viewing patterns
15//! - **Rating System**: Handle explicit and implicit ratings
16//! - **Trending Detection**: Identify trending content in real-time
17//! - **Personalization**: Context-aware personalized recommendations
18//! - **Diversity**: Ensure recommendation diversity and avoid filter bubbles
19//! - **Freshness**: Balance popular and new content
20//!
21//! # Modules
22//!
23//! - [`content`]: Content-based filtering and similarity
24//! - [`collaborative`]: Collaborative filtering algorithms
25//! - [`hybrid`]: Hybrid recommendation approaches
26//! - [`profile`]: User profile management
27//! - [`history`]: View history tracking and analysis
28//! - [`rating`]: Rating system (explicit and implicit)
29//! - [`trending`]: Trending content detection
30//! - [`personalize`]: Personalization engine
31//! - [`diversity`]: Diversity enforcement
32//! - [`freshness`]: Fresh content promotion
33//! - [`rank`]: Ranking and scoring
34//! - [`explain`]: Recommendation explanations
35//!
36//! # Example
37//!
38//! ```
39//! use oximedia_recommend::{RecommendationEngine, RecommendationRequest};
40//! use uuid::Uuid;
41//!
42//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
43//! // Create a recommendation engine
44//! let engine = RecommendationEngine::new();
45//!
46//! // Get recommendations for a user
47//! let user_id = Uuid::new_v4();
48//! let request = RecommendationRequest {
49//!     user_id,
50//!     limit: 10,
51//!     ..Default::default()
52//! };
53//!
54//! // let recommendations = engine.recommend(&request)?;
55//! # Ok(())
56//! # }
57//! ```
58
59#![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
131// Re-export commonly used items
132pub 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
140/// Main recommendation engine coordinating all recommendation capabilities
141pub struct RecommendationEngine {
142    /// Content-based recommender
143    content_recommender: content::similarity::ContentRecommender,
144    /// Collaborative filtering engine
145    collaborative_engine: collaborative::matrix::CollaborativeEngine,
146    /// Hybrid combiner
147    hybrid_combiner: hybrid::combine::HybridCombiner,
148    /// User profile manager
149    profile_manager: profile::user::UserProfileManager,
150    /// View history tracker
151    history_tracker: history::track::HistoryTracker,
152    /// Rating manager
153    rating_manager: rating::explicit::RatingManager,
154    /// Trending detector
155    trending_detector: trending::detect::TrendingDetector,
156    /// Personalization engine
157    personalization_engine: personalize::engine::PersonalizationEngine,
158    /// Diversity enforcer
159    diversity_enforcer: diversity::ensure::DiversityEnforcer,
160    /// Freshness balancer
161    freshness_balancer: freshness::balance::FreshnessBalancer,
162    /// Optional rate limiter (None = no rate limiting)
163    rate_limiter: Option<rate_limit::RecommendationRateLimiter>,
164}
165
166/// Recommendation request configuration
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct RecommendationRequest {
169    /// User ID to get recommendations for
170    pub user_id: Uuid,
171    /// Number of recommendations to return
172    pub limit: usize,
173    /// Content ID to base recommendations on (optional)
174    pub content_id: Option<Uuid>,
175    /// Recommendation strategy to use
176    pub strategy: RecommendationStrategy,
177    /// Context information
178    pub context: RecommendationContext,
179    /// Diversity settings
180    pub diversity: DiversitySettings,
181    /// Include explanations
182    pub include_explanations: bool,
183}
184
185/// Recommendation strategy
186#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
187pub enum RecommendationStrategy {
188    /// Content-based filtering only
189    ContentBased,
190    /// Collaborative filtering only
191    Collaborative,
192    /// Hybrid approach (combines multiple methods)
193    Hybrid,
194    /// Personalized recommendations
195    Personalized,
196    /// Trending content
197    Trending,
198}
199
200/// Context information for recommendations
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct RecommendationContext {
203    /// Current time (unix timestamp)
204    pub timestamp: Option<i64>,
205    /// Device type
206    pub device: Option<String>,
207    /// Location
208    pub location: Option<String>,
209    /// Session ID
210    pub session_id: Option<Uuid>,
211    /// Time of day
212    pub time_of_day: Option<TimeOfDay>,
213    /// Day of week
214    pub day_of_week: Option<u8>,
215}
216
217/// Time of day categories
218#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
219pub enum TimeOfDay {
220    /// Morning (6am-12pm)
221    Morning,
222    /// Afternoon (12pm-6pm)
223    Afternoon,
224    /// Evening (6pm-10pm)
225    Evening,
226    /// Night (10pm-6am)
227    Night,
228}
229
230/// Diversity settings for recommendations
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct DiversitySettings {
233    /// Enable diversity enforcement
234    pub enabled: bool,
235    /// Minimum category diversity (0.0-1.0)
236    pub category_diversity: f32,
237    /// Include novel/surprising items
238    pub include_serendipity: bool,
239    /// Serendipity weight (0.0-1.0)
240    pub serendipity_weight: f32,
241}
242
243/// Recommendation result item
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct Recommendation {
246    /// Content ID
247    pub content_id: Uuid,
248    /// Recommendation score (0.0-1.0)
249    pub score: f32,
250    /// Rank in recommendation list
251    pub rank: usize,
252    /// Reasons for recommendation
253    pub reasons: Vec<RecommendationReason>,
254    /// Content metadata
255    pub metadata: ContentMetadata,
256    /// Explanation (if requested)
257    pub explanation: Option<String>,
258}
259
260/// Reason for recommendation
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub enum RecommendationReason {
263    /// Similar to content the user liked
264    SimilarToLiked {
265        /// ID of the similar content that the user liked
266        content_id: Uuid,
267        /// Similarity score (0-1)
268        similarity: f32,
269    },
270    /// Users similar to you also liked this
271    CollaborativeFiltering {
272        /// Confidence score (0-1)
273        confidence: f32,
274    },
275    /// Trending in your area/globally
276    Trending {
277        /// Trending score indicating popularity momentum
278        trending_score: f32,
279    },
280    /// Matches your interests
281    MatchesProfile {
282        /// Categories that matched the user profile
283        categories: Vec<String>,
284    },
285    /// New/fresh content
286    FreshContent {
287        /// Number of days since publication
288        published_days_ago: u32,
289    },
290    /// Popular content
291    Popular {
292        /// Total view count
293        view_count: u64,
294    },
295    /// Completes a series/collection
296    ContinueWatching {
297        /// Watch progress (0-1)
298        progress: f32,
299    },
300}
301
302/// Content metadata for recommendations
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct ContentMetadata {
305    /// Content title
306    pub title: String,
307    /// Content description
308    pub description: Option<String>,
309    /// Categories/genres
310    pub categories: Vec<String>,
311    /// Duration (milliseconds)
312    pub duration_ms: Option<i64>,
313    /// Thumbnail URL
314    pub thumbnail_url: Option<String>,
315    /// Created timestamp
316    pub created_at: i64,
317    /// Average rating
318    pub avg_rating: Option<f32>,
319    /// View count
320    pub view_count: u64,
321}
322
323/// Recommendation results
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct RecommendationResults {
326    /// User ID
327    pub user_id: Uuid,
328    /// Recommended items
329    pub recommendations: Vec<Recommendation>,
330    /// Total candidates evaluated
331    pub total_candidates: usize,
332    /// Processing time (milliseconds)
333    pub processing_time_ms: u64,
334    /// Strategy used
335    pub strategy: RecommendationStrategy,
336}
337
338impl RecommendationEngine {
339    /// Create a new recommendation engine with no rate limiting.
340    #[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    /// Create a new recommendation engine with rate limiting enabled.
358    ///
359    /// `config` controls per-user and global token-bucket parameters.
360    /// `now` is the initial Unix timestamp (seconds) used to seed the buckets.
361    #[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    /// Enable (or replace) the rate limiter on an existing engine.
380    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    /// Disable rate limiting on this engine.
385    pub fn disable_rate_limiter(&mut self) {
386        self.rate_limiter = None;
387    }
388
389    /// Returns `true` if rate limiting is currently enabled.
390    #[must_use]
391    pub fn has_rate_limiter(&self) -> bool {
392        self.rate_limiter.is_some()
393    }
394
395    /// Query how many tokens remain for a user without consuming any.
396    ///
397    /// Returns `None` if rate limiting is disabled or if the user has no bucket yet.
398    #[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    /// Query how many global tokens remain without consuming any.
406    ///
407    /// Returns `None` if rate limiting is disabled.
408    #[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    /// Get recommendations for a user
416    ///
417    /// For the `Hybrid` strategy, all sub-strategies are evaluated in parallel
418    /// via rayon.  The resulting candidate lists are merged and deduplicated by
419    /// content ID, taking the maximum score for any item that appeared in
420    /// multiple strategy outputs.
421    ///
422    /// If a rate limiter is configured, the request is checked against both the
423    /// per-user and global token buckets.  When the limit is exceeded a
424    /// [`RecommendError::RateLimited`] error is returned immediately.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if recommendation generation fails or the caller is rate-limited.
429    pub fn recommend(
430        &mut self,
431        request: &RecommendationRequest,
432    ) -> RecommendResult<RecommendationResults> {
433        use std::collections::HashMap;
434
435        // Check rate limit before doing any work
436        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        // Get candidates based on strategy.
454        // For Hybrid, all strategies are evaluated in parallel with rayon.
455        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        // Deduplicate merged candidates: keep highest score per content_id
470        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        // Apply diversity if enabled
492        let diverse_candidates = if request.diversity.enabled {
493            self.diversity_enforcer
494                .enforce_diversity(candidates, &request.diversity)?
495        } else {
496            candidates
497        };
498
499        // Apply freshness balancing
500        let balanced_candidates = self.freshness_balancer.balance(diverse_candidates)?;
501
502        // Rank and score
503        let mut ranked = self.rank_recommendations(balanced_candidates)?;
504
505        // Limit results
506        ranked.truncate(request.limit);
507
508        // Add explanations if requested
509        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    /// Get content-based recommendations
526    fn get_content_based_recommendations(
527        &self,
528        request: &RecommendationRequest,
529    ) -> RecommendResult<Vec<Recommendation>> {
530        self.content_recommender.recommend(request)
531    }
532
533    /// Get collaborative filtering recommendations
534    fn get_collaborative_recommendations(
535        &self,
536        request: &RecommendationRequest,
537    ) -> RecommendResult<Vec<Recommendation>> {
538        self.collaborative_engine.recommend(request)
539    }
540
541    /// Get hybrid recommendations (single-threaded, delegates to HybridCombiner)
542    fn get_hybrid_recommendations(
543        &self,
544        request: &RecommendationRequest,
545    ) -> RecommendResult<Vec<Recommendation>> {
546        self.hybrid_combiner.recommend(request)
547    }
548
549    /// Evaluate all non-Hybrid strategies in parallel via rayon and merge results.
550    ///
551    /// Each strategy is run concurrently; any strategy that fails is silently
552    /// dropped so that a single sub-strategy error never blocks results from
553    /// the others.
554    fn get_hybrid_parallel(
555        &self,
556        request: &RecommendationRequest,
557    ) -> RecommendResult<Vec<Recommendation>> {
558        use rayon::prelude::*;
559
560        // List of strategy labels to evaluate in parallel.
561        // We exclude Hybrid itself to avoid recursion.
562        let strategies: &[RecommendationStrategy] = &[
563            RecommendationStrategy::ContentBased,
564            RecommendationStrategy::Collaborative,
565            RecommendationStrategy::Personalized,
566            RecommendationStrategy::Trending,
567        ];
568
569        // Evaluate strategies in parallel; collect successful results.
570        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        // Also get the HybridCombiner result (which has its own weighting logic)
591        let combiner_result = self.hybrid_combiner.recommend(request).unwrap_or_default();
592
593        // Merge all candidate lists
594        let mut merged: Vec<Recommendation> = parallel_results.into_iter().flatten().collect();
595        merged.extend(combiner_result);
596        Ok(merged)
597    }
598
599    /// Get personalized recommendations
600    fn get_personalized_recommendations(
601        &self,
602        request: &RecommendationRequest,
603    ) -> RecommendResult<Vec<Recommendation>> {
604        self.personalization_engine.recommend(request)
605    }
606
607    /// Get trending recommendations
608    fn get_trending_recommendations(
609        &self,
610        request: &RecommendationRequest,
611    ) -> RecommendResult<Vec<Recommendation>> {
612        self.trending_detector.get_trending(request.limit)
613    }
614
615    /// Rank recommendations
616    fn rank_recommendations(
617        &self,
618        candidates: Vec<Recommendation>,
619    ) -> RecommendResult<Vec<Recommendation>> {
620        rank::score::rank_recommendations(candidates)
621    }
622
623    /// Add explanations to recommendations
624    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    /// Record a user view event
633    ///
634    /// # Errors
635    ///
636    /// Returns an error if recording fails
637    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        // Record in history
645        self.history_tracker
646            .record_view(user_id, content_id, watch_time_ms, completed)?;
647
648        // Update user profile
649        self.profile_manager
650            .update_from_view(user_id, content_id, watch_time_ms, completed)?;
651
652        // Update implicit rating
653        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    /// Record an explicit rating
664    ///
665    /// # Errors
666    ///
667    /// Returns an error if recording fails
668    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    /// Update trending scores
682    ///
683    /// # Errors
684    ///
685    /// Returns an error if update fails
686    pub fn update_trending(&mut self) -> RecommendResult<()> {
687        self.trending_detector.update_scores()
688    }
689
690    /// Get user profile
691    ///
692    /// # Errors
693    ///
694    /// Returns an error if retrieval fails
695    pub fn get_user_profile(&self, user_id: Uuid) -> RecommendResult<profile::user::UserProfile> {
696        self.profile_manager.get_profile(user_id)
697    }
698
699    /// Get similar users
700    ///
701    /// # Errors
702    ///
703    /// Returns an error if retrieval fails
704    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        // Hybrid should run in parallel without panicking and return Ok
785        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    // ─────────────────────────────────────────────────────────────────────────
814    // Rate limiter integration tests
815    // ─────────────────────────────────────────────────────────────────────────
816
817    #[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        // Should succeed (not rate-limited)
844        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, // no refill
853            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        // First two should be allowed
864        assert!(engine.recommend(&request).is_ok());
865        assert!(engine.recommend(&request).is_ok());
866        // Third should be rate-limited
867        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        // Should have consumed 1 token from a 10-token bucket
910        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        // Both get one request each
958        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        // Both are now exhausted
962        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}