1use super::ApplicationEvalConfig;
8use crate::{EmbeddingModel, Vector};
9use anyhow::{anyhow, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct UserInteraction {
16 pub user_id: String,
18 pub item_id: String,
20 pub interaction_type: InteractionType,
22 pub rating: Option<f64>,
24 pub timestamp: chrono::DateTime<chrono::Utc>,
26 pub context: HashMap<String, String>,
28}
29
30#[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#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ItemMetadata {
46 pub item_id: String,
48 pub category: String,
50 pub features: HashMap<String, String>,
52 pub popularity: f64,
54 pub embedding: Option<Vec<f32>>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub enum RecommendationMetric {
61 PrecisionAtK(usize),
63 RecallAtK(usize),
65 F1AtK(usize),
67 MAP,
69 NDCG(usize),
71 MRR,
73 Coverage,
75 Diversity,
77 Novelty,
79 Serendipity,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct UserRecommendationResults {
86 pub user_id: String,
88 pub precision_scores: HashMap<usize, f64>,
90 pub recall_scores: HashMap<usize, f64>,
92 pub ndcg_scores: HashMap<usize, f64>,
94 pub personalization_score: f64,
96 pub recommended_items: Vec<String>,
101 pub ground_truth: HashSet<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct CoverageStats {
109 pub catalog_coverage: f64,
111 pub unique_items_recommended: usize,
113 pub total_catalog_items: usize,
115 pub long_tail_coverage: f64,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct DiversityAnalysis {
122 pub intra_list_diversity: f64,
124 pub inter_user_diversity: f64,
126 pub category_diversity: f64,
128 pub feature_diversity: f64,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ABTestResults {
135 pub control_performance: f64,
137 pub treatment_performance: f64,
139 pub p_value: f64,
141 pub effect_size: f64,
143 pub confidence_interval: (f64, f64),
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct RecommendationResults {
150 pub metric_scores: HashMap<String, f64>,
152 pub per_user_results: HashMap<String, UserRecommendationResults>,
154 pub coverage_stats: CoverageStats,
156 pub diversity_analysis: DiversityAnalysis,
158 pub user_satisfaction: Option<HashMap<String, f64>>,
160 pub ab_test_results: Option<ABTestResults>,
162}
163
164pub struct RecommendationEvaluator {
166 user_interactions: HashMap<String, Vec<UserInteraction>>,
168 item_catalog: HashMap<String, ItemMetadata>,
170 metrics: Vec<RecommendationMetric>,
172}
173
174impl RecommendationEvaluator {
175 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 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 pub fn add_item(&mut self, item: ItemMetadata) {
203 self.item_catalog.insert(item.item_id.clone(), item);
204 }
205
206 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 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 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 let coverage_stats = self.calculate_coverage_stats(&per_user_results)?;
238 let diversity_analysis = self.calculate_diversity_analysis(&per_user_results)?;
239
240 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, })
255 }
256
257 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 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 let recommendations = self
280 .generate_recommendations(
281 user_id,
282 training_interactions,
283 model,
284 config.num_recommendations,
285 )
286 .await?;
287
288 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 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 let ndcg = self.calculate_ndcg(&recommendations, &ground_truth, k)?;
326 ndcg_scores.insert(k, ndcg);
327 }
328 }
329
330 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 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 let user_profile = self.create_user_profile(interactions, model).await?;
358
359 let mut item_scores = Vec::new();
361 for (item_id, item_metadata) in &self.item_catalog {
362 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 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 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 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 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])); }
416
417 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 async fn score_item_for_user(
436 &self,
437 user_profile: &Vector,
438 item: &ItemMetadata,
439 model: &dyn EmbeddingModel,
440 ) -> Result<f64> {
441 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 let similarity = self.cosine_similarity(user_profile, &item_embedding);
450
451 let popularity_score = item.popularity * 0.1;
453
454 Ok(similarity + popularity_score)
455 }
456
457 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 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(); }
491 }
492
493 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 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 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 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 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 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 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 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 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 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 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 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 let mut intra_scores = Vec::new();
789 let mut category_scores = Vec::new();
792 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 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 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 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 #[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 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 #[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 #[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 #[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}