Skip to main content

oxirs_embed/application_tasks/
recommendation.rs

1//! Recommendation system evaluation module
2//!
3//! This module provides comprehensive evaluation for recommendation systems using
4//! embedding models, including precision, recall, coverage, diversity, and user
5//! satisfaction metrics.
6
7use super::ApplicationEvalConfig;
8use crate::{EmbeddingModel, Vector};
9use anyhow::{anyhow, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13/// User interaction data
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct UserInteraction {
16    /// User identifier
17    pub user_id: String,
18    /// Item identifier
19    pub item_id: String,
20    /// Interaction type (view, like, purchase, etc.)
21    pub interaction_type: InteractionType,
22    /// Rating (if applicable)
23    pub rating: Option<f64>,
24    /// Timestamp
25    pub timestamp: chrono::DateTime<chrono::Utc>,
26    /// Contextual features
27    pub context: HashMap<String, String>,
28}
29
30/// Types of user interactions
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub enum InteractionType {
33    View,
34    Like,
35    Dislike,
36    Purchase,
37    AddToCart,
38    Share,
39    Comment,
40    Rating,
41}
42
43/// Item metadata
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ItemMetadata {
46    /// Item identifier
47    pub item_id: String,
48    /// Item category
49    pub category: String,
50    /// Item features
51    pub features: HashMap<String, String>,
52    /// Item popularity score
53    pub popularity: f64,
54    /// Item embedding (if available)
55    pub embedding: Option<Vec<f32>>,
56}
57
58/// Recommendation evaluation metrics
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub enum RecommendationMetric {
61    /// Precision at K
62    PrecisionAtK(usize),
63    /// Recall at K
64    RecallAtK(usize),
65    /// F1 score at K
66    F1AtK(usize),
67    /// Mean Average Precision
68    MAP,
69    /// Normalized Discounted Cumulative Gain
70    NDCG(usize),
71    /// Mean Reciprocal Rank
72    MRR,
73    /// Coverage (catalog coverage)
74    Coverage,
75    /// Diversity
76    Diversity,
77    /// Novelty
78    Novelty,
79    /// Serendipity
80    Serendipity,
81}
82
83/// Per-user recommendation results
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct UserRecommendationResults {
86    /// User identifier
87    pub user_id: String,
88    /// Precision scores at different K values
89    pub precision_scores: HashMap<usize, f64>,
90    /// Recall scores at different K values
91    pub recall_scores: HashMap<usize, f64>,
92    /// NDCG scores
93    pub ndcg_scores: HashMap<usize, f64>,
94    /// Personalization score
95    pub personalization_score: f64,
96    /// Full ranked list of recommended item IDs (highest-scored first), kept
97    /// so aggregate metrics needing the whole ranking (MAP, MRR) and
98    /// catalog-level statistics (coverage, diversity, novelty) can be
99    /// computed for real instead of guessed at.
100    pub recommended_items: Vec<String>,
101    /// Ground-truth relevant item IDs for this user (from held-out test
102    /// interactions), used alongside `recommended_items` for MAP/MRR.
103    pub ground_truth: HashSet<String>,
104}
105
106/// Coverage statistics
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct CoverageStats {
109    /// Catalog coverage percentage
110    pub catalog_coverage: f64,
111    /// Number of unique items recommended
112    pub unique_items_recommended: usize,
113    /// Total items in catalog
114    pub total_catalog_items: usize,
115    /// Long-tail coverage
116    pub long_tail_coverage: f64,
117}
118
119/// Diversity analysis
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct DiversityAnalysis {
122    /// Intra-list diversity (average)
123    pub intra_list_diversity: f64,
124    /// Inter-user diversity
125    pub inter_user_diversity: f64,
126    /// Category diversity
127    pub category_diversity: f64,
128    /// Feature diversity
129    pub feature_diversity: f64,
130}
131
132/// A/B test results
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ABTestResults {
135    /// Control group performance
136    pub control_performance: f64,
137    /// Treatment group performance
138    pub treatment_performance: f64,
139    /// Statistical significance
140    pub p_value: f64,
141    /// Effect size
142    pub effect_size: f64,
143    /// Confidence interval
144    pub confidence_interval: (f64, f64),
145}
146
147/// Recommendation evaluation results
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct RecommendationResults {
150    /// Metric scores
151    pub metric_scores: HashMap<String, f64>,
152    /// Per-user results
153    pub per_user_results: HashMap<String, UserRecommendationResults>,
154    /// Coverage statistics
155    pub coverage_stats: CoverageStats,
156    /// Diversity analysis
157    pub diversity_analysis: DiversityAnalysis,
158    /// User satisfaction scores
159    pub user_satisfaction: Option<HashMap<String, f64>>,
160    /// A/B test results (if applicable)
161    pub ab_test_results: Option<ABTestResults>,
162}
163
164/// Recommendation system evaluator
165pub struct RecommendationEvaluator {
166    /// User interaction history
167    user_interactions: HashMap<String, Vec<UserInteraction>>,
168    /// Item catalog
169    item_catalog: HashMap<String, ItemMetadata>,
170    /// Evaluation metrics
171    metrics: Vec<RecommendationMetric>,
172}
173
174impl RecommendationEvaluator {
175    /// Create a new recommendation evaluator
176    pub fn new() -> Self {
177        Self {
178            user_interactions: HashMap::new(),
179            item_catalog: HashMap::new(),
180            metrics: vec![
181                RecommendationMetric::PrecisionAtK(5),
182                RecommendationMetric::PrecisionAtK(10),
183                RecommendationMetric::RecallAtK(5),
184                RecommendationMetric::RecallAtK(10),
185                RecommendationMetric::NDCG(10),
186                RecommendationMetric::MAP,
187                RecommendationMetric::Coverage,
188                RecommendationMetric::Diversity,
189            ],
190        }
191    }
192
193    /// Add user interaction data
194    pub fn add_interaction(&mut self, interaction: UserInteraction) {
195        self.user_interactions
196            .entry(interaction.user_id.clone())
197            .or_default()
198            .push(interaction);
199    }
200
201    /// Add item to catalog
202    pub fn add_item(&mut self, item: ItemMetadata) {
203        self.item_catalog.insert(item.item_id.clone(), item);
204    }
205
206    /// Evaluate recommendation quality
207    pub async fn evaluate(
208        &self,
209        model: &dyn EmbeddingModel,
210        config: &ApplicationEvalConfig,
211    ) -> Result<RecommendationResults> {
212        let mut metric_scores = HashMap::new();
213        let mut per_user_results = HashMap::new();
214
215        // Sample users for evaluation
216        let users_to_evaluate: Vec<_> = self
217            .user_interactions
218            .keys()
219            .take(config.sample_size)
220            .cloned()
221            .collect();
222
223        for user_id in &users_to_evaluate {
224            let user_results = self
225                .evaluate_user_recommendations(user_id, model, config)
226                .await?;
227            per_user_results.insert(user_id.clone(), user_results);
228        }
229
230        // Calculate aggregate metrics
231        for metric in &self.metrics {
232            let score = self.calculate_metric(metric, &per_user_results)?;
233            metric_scores.insert(format!("{metric:?}"), score);
234        }
235
236        // Calculate coverage and diversity
237        let coverage_stats = self.calculate_coverage_stats(&per_user_results)?;
238        let diversity_analysis = self.calculate_diversity_analysis(&per_user_results)?;
239
240        // User satisfaction (if enabled)
241        let user_satisfaction = if config.enable_user_satisfaction {
242            Some(self.simulate_user_satisfaction(&per_user_results)?)
243        } else {
244            None
245        };
246
247        Ok(RecommendationResults {
248            metric_scores,
249            per_user_results,
250            coverage_stats,
251            diversity_analysis,
252            user_satisfaction,
253            ab_test_results: None, // Would be populated in real A/B testing scenarios
254        })
255    }
256
257    /// Evaluate recommendations for a specific user
258    async fn evaluate_user_recommendations(
259        &self,
260        user_id: &str,
261        model: &dyn EmbeddingModel,
262        config: &ApplicationEvalConfig,
263    ) -> Result<UserRecommendationResults> {
264        let user_interactions = self
265            .user_interactions
266            .get(user_id)
267            .expect("user_id should exist in user_interactions");
268
269        // Split interactions into training and test sets
270        let split_point = (user_interactions.len() as f64 * 0.8) as usize;
271        let training_interactions = &user_interactions[..split_point];
272        let test_interactions = &user_interactions[split_point..];
273
274        if test_interactions.is_empty() {
275            return Err(anyhow!("No test interactions for user {}", user_id));
276        }
277
278        // Generate recommendations based on training interactions
279        let recommendations = self
280            .generate_recommendations(
281                user_id,
282                training_interactions,
283                model,
284                config.num_recommendations,
285            )
286            .await?;
287
288        // Extract ground truth items from test interactions
289        let ground_truth: HashSet<String> = test_interactions
290            .iter()
291            .filter(|i| {
292                matches!(
293                    i.interaction_type,
294                    InteractionType::Like | InteractionType::Purchase
295                )
296            })
297            .map(|i| i.item_id.clone())
298            .collect();
299
300        // Calculate precision and recall at different K values
301        let mut precision_scores = HashMap::new();
302        let mut recall_scores = HashMap::new();
303        let mut ndcg_scores = HashMap::new();
304
305        for &k in &[1, 3, 5, 10] {
306            if k <= recommendations.len() {
307                let top_k_recs: HashSet<String> = recommendations
308                    .iter()
309                    .take(k)
310                    .map(|(item_id, _)| item_id.clone())
311                    .collect();
312
313                let tp = top_k_recs.intersection(&ground_truth).count() as f64;
314                let precision = tp / k as f64;
315                let recall = if !ground_truth.is_empty() {
316                    tp / ground_truth.len() as f64
317                } else {
318                    0.0
319                };
320
321                precision_scores.insert(k, precision);
322                recall_scores.insert(k, recall);
323
324                // Calculate NDCG
325                let ndcg = self.calculate_ndcg(&recommendations, &ground_truth, k)?;
326                ndcg_scores.insert(k, ndcg);
327            }
328        }
329
330        // Calculate personalization score
331        let personalization_score =
332            self.calculate_personalization_score(user_id, &recommendations, training_interactions)?;
333
334        Ok(UserRecommendationResults {
335            user_id: user_id.to_string(),
336            precision_scores,
337            recall_scores,
338            ndcg_scores,
339            personalization_score,
340            recommended_items: recommendations
341                .iter()
342                .map(|(item_id, _)| item_id.clone())
343                .collect(),
344            ground_truth,
345        })
346    }
347
348    /// Generate recommendations for a user
349    async fn generate_recommendations(
350        &self,
351        _user_id: &str,
352        interactions: &[UserInteraction],
353        model: &dyn EmbeddingModel,
354        num_recommendations: usize,
355    ) -> Result<Vec<(String, f64)>> {
356        // Create user profile based on interactions
357        let user_profile = self.create_user_profile(interactions, model).await?;
358
359        // Score all items in catalog
360        let mut item_scores = Vec::new();
361        for (item_id, item_metadata) in &self.item_catalog {
362            // Skip items the user has already interacted with
363            if interactions.iter().any(|i| &i.item_id == item_id) {
364                continue;
365            }
366
367            let item_score = self
368                .score_item_for_user(&user_profile, item_metadata, model)
369                .await?;
370            item_scores.push((item_id.clone(), item_score));
371        }
372
373        // Sort by score and return top K
374        item_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
375        item_scores.truncate(num_recommendations);
376
377        Ok(item_scores)
378    }
379
380    /// Create user profile from interactions
381    async fn create_user_profile(
382        &self,
383        interactions: &[UserInteraction],
384        model: &dyn EmbeddingModel,
385    ) -> Result<Vector> {
386        let mut profile_embeddings = Vec::new();
387
388        for interaction in interactions {
389            if let Ok(item_embedding) = model.get_entity_embedding(&interaction.item_id) {
390                // Weight by interaction type
391                let weight = match interaction.interaction_type {
392                    InteractionType::Purchase => 3.0,
393                    InteractionType::Like => 2.0,
394                    InteractionType::View => 1.0,
395                    InteractionType::Dislike => -1.0,
396                    _ => 1.0,
397                };
398
399                // Weight by rating if available
400                let rating_weight = interaction.rating.unwrap_or(1.0);
401                let final_weight = weight * rating_weight;
402
403                let weighted_embedding: Vec<f32> = item_embedding
404                    .values
405                    .iter()
406                    .map(|&x| x * final_weight as f32)
407                    .collect();
408
409                profile_embeddings.push(weighted_embedding);
410            }
411        }
412
413        if profile_embeddings.is_empty() {
414            return Ok(Vector::new(vec![0.0; 100])); // Default empty profile
415        }
416
417        // Average the embeddings
418        let dim = profile_embeddings[0].len();
419        let mut avg_embedding = vec![0.0f32; dim];
420
421        for embedding in &profile_embeddings {
422            for (i, &value) in embedding.iter().enumerate() {
423                avg_embedding[i] += value;
424            }
425        }
426
427        for value in &mut avg_embedding {
428            *value /= profile_embeddings.len() as f32;
429        }
430
431        Ok(Vector::new(avg_embedding))
432    }
433
434    /// Score an item for a user
435    async fn score_item_for_user(
436        &self,
437        user_profile: &Vector,
438        item: &ItemMetadata,
439        model: &dyn EmbeddingModel,
440    ) -> Result<f64> {
441        // Get item embedding
442        let item_embedding = if let Some(ref embedding) = item.embedding {
443            Vector::new(embedding.clone())
444        } else {
445            model.get_entity_embedding(&item.item_id)?
446        };
447
448        // Calculate cosine similarity
449        let similarity = self.cosine_similarity(user_profile, &item_embedding);
450
451        // Add popularity bias (small weight)
452        let popularity_score = item.popularity * 0.1;
453
454        Ok(similarity + popularity_score)
455    }
456
457    /// Calculate cosine similarity between two vectors
458    fn cosine_similarity(&self, v1: &Vector, v2: &Vector) -> f64 {
459        let dot_product: f32 = v1
460            .values
461            .iter()
462            .zip(v2.values.iter())
463            .map(|(a, b)| a * b)
464            .sum();
465        let norm_a: f32 = v1.values.iter().map(|x| x * x).sum::<f32>().sqrt();
466        let norm_b: f32 = v2.values.iter().map(|x| x * x).sum::<f32>().sqrt();
467
468        if norm_a > 0.0 && norm_b > 0.0 {
469            (dot_product / (norm_a * norm_b)) as f64
470        } else {
471            0.0
472        }
473    }
474
475    /// Calculate NDCG score
476    fn calculate_ndcg(
477        &self,
478        recommendations: &[(String, f64)],
479        ground_truth: &HashSet<String>,
480        k: usize,
481    ) -> Result<f64> {
482        if k == 0 || recommendations.is_empty() {
483            return Ok(0.0);
484        }
485
486        let mut dcg = 0.0;
487        for (i, (item_id, _)) in recommendations.iter().take(k).enumerate() {
488            if ground_truth.contains(item_id) {
489                dcg += 1.0 / (i as f64 + 2.0).log2(); // +2 because rank starts from 1
490            }
491        }
492
493        // Calculate ideal DCG
494        let relevant_items = ground_truth.len().min(k);
495        let mut idcg = 0.0;
496        for i in 0..relevant_items {
497            idcg += 1.0 / (i as f64 + 2.0).log2();
498        }
499
500        if idcg > 0.0 {
501            Ok(dcg / idcg)
502        } else {
503            Ok(0.0)
504        }
505    }
506
507    /// Calculate personalization score
508    fn calculate_personalization_score(
509        &self,
510        _user_id: &str,
511        recommendations: &[(String, f64)],
512        user_interactions: &[UserInteraction],
513    ) -> Result<f64> {
514        if recommendations.is_empty() || user_interactions.is_empty() {
515            return Ok(0.0);
516        }
517
518        // Calculate how well recommendations match user's historical preferences
519        let user_categories: HashSet<String> = user_interactions
520            .iter()
521            .filter_map(|i| self.item_catalog.get(&i.item_id))
522            .map(|item| item.category.clone())
523            .collect();
524
525        let recommendation_categories: HashSet<String> = recommendations
526            .iter()
527            .filter_map(|(item_id, _)| self.item_catalog.get(item_id))
528            .map(|item| item.category.clone())
529            .collect();
530
531        if user_categories.is_empty() {
532            return Ok(0.0);
533        }
534
535        let overlap = user_categories
536            .intersection(&recommendation_categories)
537            .count();
538        Ok(overlap as f64 / user_categories.len() as f64)
539    }
540
541    /// Calculate aggregate metric from per-user results
542    fn calculate_metric(
543        &self,
544        metric: &RecommendationMetric,
545        per_user_results: &HashMap<String, UserRecommendationResults>,
546    ) -> Result<f64> {
547        if per_user_results.is_empty() {
548            return Ok(0.0);
549        }
550
551        match metric {
552            RecommendationMetric::PrecisionAtK(k) => {
553                let scores: Vec<f64> = per_user_results
554                    .values()
555                    .filter_map(|r| r.precision_scores.get(k))
556                    .cloned()
557                    .collect();
558                Ok(scores.iter().sum::<f64>() / scores.len() as f64)
559            }
560            RecommendationMetric::RecallAtK(k) => {
561                let scores: Vec<f64> = per_user_results
562                    .values()
563                    .filter_map(|r| r.recall_scores.get(k))
564                    .cloned()
565                    .collect();
566                Ok(scores.iter().sum::<f64>() / scores.len() as f64)
567            }
568            RecommendationMetric::NDCG(k) => {
569                let scores: Vec<f64> = per_user_results
570                    .values()
571                    .filter_map(|r| r.ndcg_scores.get(k))
572                    .cloned()
573                    .collect();
574                Ok(scores.iter().sum::<f64>() / scores.len() as f64)
575            }
576            RecommendationMetric::F1AtK(k) => {
577                let scores: Vec<f64> = per_user_results
578                    .values()
579                    .filter_map(|r| {
580                        let precision = *r.precision_scores.get(k)?;
581                        let recall = *r.recall_scores.get(k)?;
582                        Some(if precision + recall > 0.0 {
583                            2.0 * precision * recall / (precision + recall)
584                        } else {
585                            0.0
586                        })
587                    })
588                    .collect();
589                if scores.is_empty() {
590                    return Err(anyhow!(
591                        "No precision/recall@{k} scores available to compute F1@{k}"
592                    ));
593                }
594                Ok(scores.iter().sum::<f64>() / scores.len() as f64)
595            }
596            RecommendationMetric::MAP => {
597                let scores: Vec<f64> = per_user_results
598                    .values()
599                    .map(|r| Self::average_precision(&r.recommended_items, &r.ground_truth))
600                    .collect();
601                Ok(scores.iter().sum::<f64>() / scores.len() as f64)
602            }
603            RecommendationMetric::MRR => {
604                let scores: Vec<f64> = per_user_results
605                    .values()
606                    .map(|r| Self::reciprocal_rank(&r.recommended_items, &r.ground_truth))
607                    .collect();
608                Ok(scores.iter().sum::<f64>() / scores.len() as f64)
609            }
610            RecommendationMetric::Coverage => Ok(self
611                .calculate_coverage_stats(per_user_results)?
612                .catalog_coverage),
613            RecommendationMetric::Diversity => Ok(self
614                .calculate_diversity_analysis(per_user_results)?
615                .intra_list_diversity),
616            RecommendationMetric::Novelty => {
617                let scores: Vec<f64> = per_user_results
618                    .values()
619                    .map(|r| self.novelty_for_items(&r.recommended_items))
620                    .collect();
621                if scores.is_empty() {
622                    return Ok(0.0);
623                }
624                Ok(scores.iter().sum::<f64>() / scores.len() as f64)
625            }
626            RecommendationMetric::Serendipity => {
627                // Simplified but real serendipity: the fraction of
628                // recommended items that are both relevant (in the user's
629                // ground truth) and unpopular (below the catalog's median
630                // popularity) — i.e. "pleasant surprises" rather than
631                // popularity-driven hits.
632                let median_popularity = self.median_catalog_popularity();
633                let scores: Vec<f64> = per_user_results
634                    .values()
635                    .filter(|r| !r.recommended_items.is_empty())
636                    .map(|r| {
637                        let surprising_hits = r
638                            .recommended_items
639                            .iter()
640                            .filter(|item_id| {
641                                r.ground_truth.contains(*item_id)
642                                    && self
643                                        .item_catalog
644                                        .get(*item_id)
645                                        .is_some_and(|item| item.popularity < median_popularity)
646                            })
647                            .count();
648                        surprising_hits as f64 / r.recommended_items.len() as f64
649                    })
650                    .collect();
651                if scores.is_empty() {
652                    return Ok(0.0);
653                }
654                Ok(scores.iter().sum::<f64>() / scores.len() as f64)
655            }
656        }
657    }
658
659    /// Average precision of a ranked recommendation list against a
660    /// user's ground-truth relevant items.
661    fn average_precision(recommended_items: &[String], ground_truth: &HashSet<String>) -> f64 {
662        if ground_truth.is_empty() {
663            return 0.0;
664        }
665        let mut hits = 0usize;
666        let mut precision_sum = 0.0;
667        for (rank, item_id) in recommended_items.iter().enumerate() {
668            if ground_truth.contains(item_id) {
669                hits += 1;
670                precision_sum += hits as f64 / (rank + 1) as f64;
671            }
672        }
673        precision_sum / ground_truth.len() as f64
674    }
675
676    /// Reciprocal rank of the first relevant item in a ranked recommendation
677    /// list (0.0 if none of the recommended items are relevant).
678    fn reciprocal_rank(recommended_items: &[String], ground_truth: &HashSet<String>) -> f64 {
679        recommended_items
680            .iter()
681            .position(|item_id| ground_truth.contains(item_id))
682            .map(|rank| 1.0 / (rank + 1) as f64)
683            .unwrap_or(0.0)
684    }
685
686    /// Novelty of a recommendation list: the average "unpopularity"
687    /// (`1 - popularity`) of its items, using the catalog's recorded
688    /// popularity scores. Unknown items are skipped.
689    fn novelty_for_items(&self, recommended_items: &[String]) -> f64 {
690        let popularities: Vec<f64> = recommended_items
691            .iter()
692            .filter_map(|item_id| self.item_catalog.get(item_id))
693            .map(|item| item.popularity.clamp(0.0, 1.0))
694            .collect();
695        if popularities.is_empty() {
696            return 0.0;
697        }
698        1.0 - popularities.iter().sum::<f64>() / popularities.len() as f64
699    }
700
701    /// Median popularity across the whole item catalog, used as the
702    /// "unpopular" threshold for serendipity.
703    fn median_catalog_popularity(&self) -> f64 {
704        let mut popularities: Vec<f64> = self
705            .item_catalog
706            .values()
707            .map(|item| item.popularity)
708            .collect();
709        if popularities.is_empty() {
710            return 0.0;
711        }
712        popularities.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
713        let mid = popularities.len() / 2;
714        if popularities.len() % 2 == 0 {
715            (popularities[mid - 1] + popularities[mid]) / 2.0
716        } else {
717            popularities[mid]
718        }
719    }
720
721    /// Calculate catalog coverage statistics from the items actually
722    /// recommended across all evaluated users.
723    fn calculate_coverage_stats(
724        &self,
725        per_user_results: &HashMap<String, UserRecommendationResults>,
726    ) -> Result<CoverageStats> {
727        let total_catalog_items = self.item_catalog.len();
728
729        let recommended_item_set: HashSet<&str> = per_user_results
730            .values()
731            .flat_map(|r| r.recommended_items.iter().map(String::as_str))
732            .collect();
733        let unique_items_recommended = recommended_item_set.len();
734
735        let catalog_coverage = if total_catalog_items > 0 {
736            unique_items_recommended as f64 / total_catalog_items as f64
737        } else {
738            0.0
739        };
740
741        // Long-tail items: those in the catalog's bottom-half by popularity.
742        let median_popularity = self.median_catalog_popularity();
743        let long_tail_items: HashSet<&str> = self
744            .item_catalog
745            .values()
746            .filter(|item| item.popularity < median_popularity)
747            .map(|item| item.item_id.as_str())
748            .collect();
749        let long_tail_coverage = if !long_tail_items.is_empty() {
750            recommended_item_set.intersection(&long_tail_items).count() as f64
751                / long_tail_items.len() as f64
752        } else {
753            0.0
754        };
755
756        Ok(CoverageStats {
757            catalog_coverage,
758            unique_items_recommended,
759            total_catalog_items,
760            long_tail_coverage,
761        })
762    }
763
764    /// Calculate diversity analysis from the items actually recommended
765    /// across all evaluated users, using catalog category/feature metadata
766    /// (and item embeddings when available) as the similarity signal.
767    fn calculate_diversity_analysis(
768        &self,
769        per_user_results: &HashMap<String, UserRecommendationResults>,
770    ) -> Result<DiversityAnalysis> {
771        let non_empty_users: Vec<&UserRecommendationResults> = per_user_results
772            .values()
773            .filter(|r| !r.recommended_items.is_empty())
774            .collect();
775
776        if non_empty_users.is_empty() {
777            return Ok(DiversityAnalysis {
778                intra_list_diversity: 0.0,
779                inter_user_diversity: 0.0,
780                category_diversity: 0.0,
781                feature_diversity: 0.0,
782            });
783        }
784
785        // Intra-list diversity: mean pairwise dissimilarity between items
786        // within each user's own recommendation list, using item embeddings
787        // when available and falling back to "different category" otherwise.
788        let mut intra_scores = Vec::new();
789        // Category diversity: fraction of a user's list that is distinct
790        // categories.
791        let mut category_scores = Vec::new();
792        // Feature diversity: fraction of a user's list that is distinct
793        // (key, value) feature pairs, aggregated across the list.
794        let mut feature_scores = Vec::new();
795
796        for result in &non_empty_users {
797            let items: Vec<&ItemMetadata> = result
798                .recommended_items
799                .iter()
800                .filter_map(|item_id| self.item_catalog.get(item_id))
801                .collect();
802            if items.is_empty() {
803                continue;
804            }
805
806            let mut pair_count = 0usize;
807            let mut dissimilarity_sum = 0.0;
808            for i in 0..items.len() {
809                for j in (i + 1)..items.len() {
810                    let dissimilarity = match (&items[i].embedding, &items[j].embedding) {
811                        (Some(a), Some(b)) => 1.0 - Self::cosine_similarity_slices(a, b),
812                        _ => {
813                            if items[i].category == items[j].category {
814                                0.0
815                            } else {
816                                1.0
817                            }
818                        }
819                    };
820                    dissimilarity_sum += dissimilarity;
821                    pair_count += 1;
822                }
823            }
824            if pair_count > 0 {
825                intra_scores.push(dissimilarity_sum / pair_count as f64);
826            }
827
828            let distinct_categories: HashSet<&str> =
829                items.iter().map(|item| item.category.as_str()).collect();
830            category_scores.push(distinct_categories.len() as f64 / items.len() as f64);
831
832            let distinct_features: HashSet<(&str, &str)> = items
833                .iter()
834                .flat_map(|item| item.features.iter().map(|(k, v)| (k.as_str(), v.as_str())))
835                .collect();
836            let total_feature_pairs: usize = items.iter().map(|item| item.features.len()).sum();
837            if total_feature_pairs > 0 {
838                feature_scores.push(distinct_features.len() as f64 / total_feature_pairs as f64);
839            }
840        }
841
842        // Inter-user diversity: mean pairwise Jaccard *dissimilarity*
843        // between different users' recommended-item sets.
844        let user_item_sets: Vec<HashSet<&str>> = non_empty_users
845            .iter()
846            .map(|r| r.recommended_items.iter().map(String::as_str).collect())
847            .collect();
848        let mut inter_pair_count = 0usize;
849        let mut inter_dissimilarity_sum = 0.0;
850        for i in 0..user_item_sets.len() {
851            for j in (i + 1)..user_item_sets.len() {
852                let union = user_item_sets[i].union(&user_item_sets[j]).count();
853                if union == 0 {
854                    continue;
855                }
856                let intersection = user_item_sets[i].intersection(&user_item_sets[j]).count();
857                let jaccard = intersection as f64 / union as f64;
858                inter_dissimilarity_sum += 1.0 - jaccard;
859                inter_pair_count += 1;
860            }
861        }
862
863        Ok(DiversityAnalysis {
864            intra_list_diversity: Self::mean(&intra_scores),
865            inter_user_diversity: if inter_pair_count > 0 {
866                inter_dissimilarity_sum / inter_pair_count as f64
867            } else {
868                0.0
869            },
870            category_diversity: Self::mean(&category_scores),
871            feature_diversity: Self::mean(&feature_scores),
872        })
873    }
874
875    fn mean(values: &[f64]) -> f64 {
876        if values.is_empty() {
877            0.0
878        } else {
879            values.iter().sum::<f64>() / values.len() as f64
880        }
881    }
882
883    fn cosine_similarity_slices(a: &[f32], b: &[f32]) -> f64 {
884        let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
885        let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
886        let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
887        if norm_a > 0.0 && norm_b > 0.0 {
888            (dot / (norm_a * norm_b)) as f64
889        } else {
890            0.0
891        }
892    }
893
894    /// Simulate user satisfaction scores
895    fn simulate_user_satisfaction(
896        &self,
897        per_user_results: &HashMap<String, UserRecommendationResults>,
898    ) -> Result<HashMap<String, f64>> {
899        let mut satisfaction_scores = HashMap::new();
900
901        for (user_id, results) in per_user_results {
902            // Base satisfaction on precision and personalization
903            let avg_precision = results.precision_scores.get(&5).copied().unwrap_or(0.0);
904            let personalization = results.personalization_score;
905
906            let satisfaction = (avg_precision * 0.7 + personalization * 0.3).clamp(0.0, 1.0);
907
908            satisfaction_scores.insert(user_id.clone(), satisfaction);
909        }
910
911        Ok(satisfaction_scores)
912    }
913}
914
915impl Default for RecommendationEvaluator {
916    fn default() -> Self {
917        Self::new()
918    }
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    fn make_item(id: &str, category: &str, popularity: f64) -> ItemMetadata {
926        ItemMetadata {
927            item_id: id.to_string(),
928            category: category.to_string(),
929            features: HashMap::new(),
930            popularity,
931            embedding: None,
932        }
933    }
934
935    fn make_user_result(recommended: &[&str], ground_truth: &[&str]) -> UserRecommendationResults {
936        UserRecommendationResults {
937            user_id: "u1".to_string(),
938            precision_scores: HashMap::new(),
939            recall_scores: HashMap::new(),
940            ndcg_scores: HashMap::new(),
941            personalization_score: 0.0,
942            recommended_items: recommended.iter().map(|s| s.to_string()).collect(),
943            ground_truth: ground_truth.iter().map(|s| s.to_string()).collect(),
944        }
945    }
946
947    /// Regression test for the P3 finding: MAP/MRR must be computed for
948    /// real from the ranked `recommended_items` list against
949    /// `ground_truth`, instead of a hardcoded 0.5.
950    #[test]
951    fn test_calculate_metric_map_and_mrr_are_real() -> Result<()> {
952        let evaluator = RecommendationEvaluator::new();
953        let mut per_user = HashMap::new();
954        per_user.insert(
955            "u1".to_string(),
956            make_user_result(&["i1", "i2", "i3"], &["i2"]),
957        );
958
959        // i2 (the only relevant item) is at rank 2: AP = (1/2) / 1 = 0.5;
960        // RR = 1/2 = 0.5.
961        let map = evaluator.calculate_metric(&RecommendationMetric::MAP, &per_user)?;
962        assert!((map - 0.5).abs() < 1e-9, "map = {map}");
963
964        let mrr = evaluator.calculate_metric(&RecommendationMetric::MRR, &per_user)?;
965        assert!((mrr - 0.5).abs() < 1e-9, "mrr = {mrr}");
966
967        Ok(())
968    }
969
970    /// Regression test: catalog coverage must be computed from the actual
971    /// items recommended vs. the real catalog size, instead of the
972    /// hardcoded (0.65, 450, 1000, 0.25) placeholder tuple.
973    #[test]
974    fn test_calculate_coverage_stats_reflects_real_catalog() {
975        let mut evaluator = RecommendationEvaluator::new();
976        evaluator.add_item(make_item("i1", "books", 0.9));
977        evaluator.add_item(make_item("i2", "books", 0.1));
978        evaluator.add_item(make_item("i3", "toys", 0.5));
979
980        let mut per_user = HashMap::new();
981        per_user.insert("u1".to_string(), make_user_result(&["i1"], &[]));
982
983        let stats = evaluator
984            .calculate_coverage_stats(&per_user)
985            .expect("should succeed");
986        assert_eq!(stats.total_catalog_items, 3);
987        assert_eq!(stats.unique_items_recommended, 1);
988        assert!(
989            (stats.catalog_coverage - (1.0 / 3.0)).abs() < 1e-9,
990            "catalog_coverage = {}",
991            stats.catalog_coverage
992        );
993    }
994
995    /// Regression test: novelty must be derived from the catalog's actual
996    /// popularity scores instead of being a constant.
997    #[test]
998    fn test_novelty_for_items_uses_real_popularity() {
999        let mut evaluator = RecommendationEvaluator::new();
1000        evaluator.add_item(make_item("popular", "x", 1.0));
1001        evaluator.add_item(make_item("obscure", "x", 0.0));
1002
1003        let popular_novelty = evaluator.novelty_for_items(&["popular".to_string()]);
1004        let obscure_novelty = evaluator.novelty_for_items(&["obscure".to_string()]);
1005
1006        assert!(
1007            (popular_novelty - 0.0).abs() < 1e-9,
1008            "popular_novelty = {popular_novelty}"
1009        );
1010        assert!(
1011            (obscure_novelty - 1.0).abs() < 1e-9,
1012            "obscure_novelty = {obscure_novelty}"
1013        );
1014    }
1015
1016    /// Regression test: diversity analysis must genuinely differ between a
1017    /// single-category recommendation list and a multi-category one,
1018    /// instead of the hardcoded (0.7, 0.8, 0.6, 0.65) placeholder tuple.
1019    #[test]
1020    fn test_calculate_diversity_analysis_varies_with_categories() {
1021        let mut evaluator = RecommendationEvaluator::new();
1022        evaluator.add_item(make_item("i1", "books", 0.5));
1023        evaluator.add_item(make_item("i2", "books", 0.5));
1024        evaluator.add_item(make_item("i3", "toys", 0.5));
1025
1026        let mut same_category = HashMap::new();
1027        same_category.insert("u1".to_string(), make_user_result(&["i1", "i2"], &[]));
1028        let same_category_diversity = evaluator
1029            .calculate_diversity_analysis(&same_category)
1030            .expect("should succeed");
1031
1032        let mut mixed_category = HashMap::new();
1033        mixed_category.insert("u1".to_string(), make_user_result(&["i1", "i3"], &[]));
1034        let mixed_category_diversity = evaluator
1035            .calculate_diversity_analysis(&mixed_category)
1036            .expect("should succeed");
1037
1038        assert_eq!(same_category_diversity.category_diversity, 0.5);
1039        assert_eq!(mixed_category_diversity.category_diversity, 1.0);
1040        assert!(
1041            mixed_category_diversity.intra_list_diversity
1042                > same_category_diversity.intra_list_diversity
1043        );
1044    }
1045}