game_manager_lib\services/
cf_aggregator.rs

1//! Módulo de agregação de recomendações baseado em Collaborative Filtering (CF)
2//!
3//! Carrega um índice CF pré-computado a partir de um arquivo JSON
4//! e agrega scores de recomendação baseados na biblioteca do usuário.
5//! Esses scores são ponderados pela importância do jogo na biblioteca do usuário.
6//! O resultado é um mapa de scores CF por jogo candidato.
7
8use serde::Deserialize;
9use std::collections::HashMap;
10
11use once_cell::sync::OnceCell;
12
13use crate::services::recommendation::{calculate_game_weight, GameWithDetails};
14
15// Incluir JSON em compile-time
16const COLLABORATIVE_INDEX_JSON: &str = include_str!("../../data/collaborative_index.json");
17
18// === Estruturas de leitura do JSON ===
19
20/// Estrutura bruta do índice CF lido do JSON
21#[derive(Debug, Deserialize)]
22struct CollaborativeIndexRaw {
23    index: HashMap<String, Vec<Similar>>,
24}
25
26#[derive(Debug, Deserialize, Clone)]
27pub struct Similar {
28    pub app_id: u32,
29    pub score: f32,
30}
31
32/// Índice CF final usado pelo backend
33/// steam_app_id -> similares
34pub type CFIndex = HashMap<u32, Vec<Similar>>;
35
36/// Cache global do índice CF
37static CF_INDEX: OnceCell<CFIndex> = OnceCell::new();
38
39// === Inicialização / Cache ===
40
41/// Inicializa o índice CF no startup do app
42///
43/// Deve ser chamado UMA vez (ex: AppState)
44pub fn init_cf_index() -> anyhow::Result<()> {
45    let raw: CollaborativeIndexRaw = serde_json::from_str(COLLABORATIVE_INDEX_JSON)?;
46
47    let index: CFIndex = raw
48        .index
49        .into_iter()
50        .filter_map(|(k, v)| k.parse::<u32>().ok().map(|id| (id, v)))
51        .collect();
52
53    let total_games = index.len();
54    let total_pairs: usize = index.values().map(|v| v.len()).sum();
55
56    CF_INDEX
57        .set(index)
58        .map_err(|_| anyhow::anyhow!("CF_INDEX já foi inicializado"))?;
59
60    log::info!(
61        "[CF] Índice carregado com sucesso | jogos={} pares={} média={:.2}",
62        total_games,
63        total_pairs,
64        total_pairs as f32 / total_games.max(1) as f32
65    );
66
67    Ok(())
68}
69
70/// Acesso seguro ao índice CF
71#[inline]
72fn get_cf_index() -> Option<&'static CFIndex> {
73    CF_INDEX.get()
74}
75
76// === Agregação de candidatos CF ===
77
78/// Agrega candidatos CF a partir da biblioteca do usuário
79///
80/// Retorna:
81/// - scores CF por steam_app_id
82/// - métricas para logging
83pub fn build_cf_candidates(user_games: &[GameWithDetails]) -> (HashMap<u32, f32>, CFStats) {
84    let mut scores: HashMap<u32, f32> = HashMap::new();
85
86    let Some(cf_index) = get_cf_index() else {
87        return (scores, CFStats::empty(user_games.len()));
88    };
89
90    let mut games_with_steam_id = 0;
91    let mut games_with_cf_match = 0;
92
93    for game in user_games {
94        let Some(steam_id) = game.steam_app_id else {
95            continue;
96        };
97        games_with_steam_id += 1;
98
99        let Some(similars) = cf_index.get(&steam_id) else {
100            continue;
101        };
102        games_with_cf_match += 1;
103
104        let source_weight = calculate_game_weight(&game.game);
105
106        for similar in similars {
107            if similar.score <= 0.0 {
108                continue;
109            }
110
111            *scores.entry(similar.app_id).or_insert(0.0) += similar.score * source_weight;
112        }
113    }
114
115    let stats = CFStats {
116        total_games: user_games.len(),
117        games_with_steam_id,
118        games_with_cf_match,
119    };
120
121    stats.log();
122
123    (scores, stats)
124}
125
126// === Métricas / Logging ===
127
128#[derive(Debug, Clone)]
129pub struct CFStats {
130    pub total_games: usize,
131    pub games_with_steam_id: usize,
132    pub games_with_cf_match: usize,
133}
134
135/// Métricas de cobertura do CF na biblioteca do usuário
136impl CFStats {
137    fn empty(total: usize) -> Self {
138        Self {
139            total_games: total,
140            games_with_steam_id: 0,
141            games_with_cf_match: 0,
142        }
143    }
144
145    pub fn log(&self) {
146        if self.total_games == 0 {
147            log::info!("[CF] Biblioteca vazia");
148            return;
149        }
150
151        let pct_steam = self.games_with_steam_id as f32 / self.total_games as f32 * 100.0;
152        let pct_cf = self.games_with_cf_match as f32 / self.total_games as f32 * 100.0;
153        let pct_fallback = 100.0 - pct_cf;
154
155        log::info!(
156            "[CF] Jogos na biblioteca={} | SteamID={:.1}% | CF ativo={:.1}% | Fallback CB={:.1}%",
157            self.total_games,
158            pct_steam,
159            pct_cf,
160            pct_fallback
161        );
162    }
163}