"""Stage 5: turn a user state + filters into ranked game recommendations. `recommend(state, filters, played_games, top_n, mode)` is the hot path the HuggingFace app calls per request. It runs three interchangeable ranking modes: - "profile_rl" (default): candidate generator keeps the `candidate_k` games closest to the play history, then the RL policy reranks those. History-anchored; masks the policy's centroid collapse. - "rl_only": policy ranks the entire filtered catalog ("trust the policy"). Exposes the policy's raw behavior (currently weak — see PLAN.md "Known limitations"). - "profile_only": rank purely by cosine to the play-history profile; the policy is not used at all. A content-based baseline / ablation. All modes share: filter → rank → drop played → enrich (year / cover / description from games.csv, optionally refreshed live from IGDB). `state` is supplied by the caller (Stage 6 `state_builder.cold_start_state`); `played_games` must be canonical names (the resolved output of the state builder) so the drop-played step and the profile ranking line up. """ import ast import numpy as np from recommender import artifacts from recommender.candidate_generator import candidates MODES = ("profile_rl", "rl_only", "profile_only") def _policy_action(state: np.ndarray) -> np.ndarray: import torch policy = artifacts.policy() t = torch.from_numpy(np.asarray(state, dtype=np.float32)) if t.ndim == 1: t = t[None, :] with torch.no_grad(): return policy(t).cpu().numpy().ravel() # All GameType fields, grouped by how they're parsed out of games.csv. _STR_FIELDS = ("name", "release", "description") _FLOAT_FIELDS = ( "rawg_rating", "igdb_rating", "hltb_rating", "metacritic_rating", "user_rating", "main_story", "main_extra", "completionist", ) _LIST_FIELDS = ( "platforms", "cover_url", "developers", "publishers", "language_supports", "genres", "keywords", ) def _parse_list(v) -> list: """games.csv stores list columns as str(list); parse back to a clean list.""" if isinstance(v, (list, tuple)): return [x for x in v if x] if v is None or (isinstance(v, float) and np.isnan(v)): return [] try: items = ast.literal_eval(v) if isinstance(v, str) else v except (ValueError, SyntaxError): return [] return [x for x in items if x] if isinstance(items, (list, tuple)) else [] def _display_cover(cover_list) -> str | None: """First usable cover URL: fix protocol-relative URLs, upscale IGDB thumbnails.""" for u in cover_list or []: if u: url = ("https:" + u) if str(u).startswith("//") else str(u) return url.replace("/t_thumb/", "/t_cover_big/") return None def _enrich(name: str, score: float, enrich: bool = False) -> dict: """Build the full GameType record for `name` from games.csv, optionally filling empty fields from the live multi-API merge.""" meta = artifacts.games_metadata() idx = artifacts.index_frame() row = idx.iloc[artifacts.name_to_row()[name]] rec: dict = {"score": score, "release_year": None if np.isnan(row["release_year"]) else int(row["release_year"])} for f in _STR_FIELDS: rec[f] = "" for f in _FLOAT_FIELDS: rec[f] = 0.0 for f in _LIST_FIELDS: rec[f] = [] rec["name"] = name if name in meta.index: m = meta.loc[name] for f in _STR_FIELDS: v = m.get(f) rec[f] = "" if (v is None or (isinstance(v, float) and np.isnan(v))) else str(v) rec["name"] = name # keep the canonical index name for f in _FLOAT_FIELDS: v = m.get(f) try: rec[f] = float(v) if v is not None and not (isinstance(v, float) and np.isnan(v)) else 0.0 except (TypeError, ValueError): rec[f] = 0.0 for f in _LIST_FIELDS: rec[f] = _parse_list(m.get(f)) # Live multi-API fill for whatever games.csv is missing. needs = (not rec["description"]) or (not _display_cover(rec["cover_url"])) or (not rec["genres"]) if enrich and needs: from recommender.enrichment import live_enrich extra = live_enrich(name) for f in _STR_FIELDS: if not rec[f]: rec[f] = extra.get(f) or "" for f in _FLOAT_FIELDS: if not rec[f]: rec[f] = extra.get(f) or 0.0 for f in _LIST_FIELDS: if not rec[f]: rec[f] = extra.get(f) or [] rec["cover_url_display"] = _display_cover(rec["cover_url"]) return rec def recommend( state: np.ndarray, filters: dict, played_games=None, top_n: int = 5, mode: str = "profile_rl", candidate_k: int = 30, enrich: bool = False, profile_prefilter: bool | None = None, ) -> list[dict]: """Return up to `top_n` recommendation dicts for the given state + filters. `mode` is one of MODES. `profile_prefilter` is a legacy alias kept for older callers (True -> "profile_rl", False -> "rl_only"); when given it overrides `mode`. """ if profile_prefilter is not None: mode = "profile_rl" if profile_prefilter else "rl_only" if mode not in MODES: raise ValueError(f"mode must be one of {MODES}, got {mode!r}") played = list(played_games or []) n2r = artifacts.name_to_row() played_rows = [n2r[g] for g in played if g in n2r] E = artifacts.embedding_matrix() En = artifacts.embedding_matrix_normalized() # 1. Candidate set. if mode == "profile_rl" and played_rows: cand_idx = candidates(filters, played_games=played, k=candidate_k) elif mode == "profile_only" and played_rows: cand_idx = candidates(filters, played_games=played, k=None) else: # rl_only, or a profile mode with no usable history -> whole filtered set. cand_idx = candidates(filters, played_games=None, k=None) if len(cand_idx) == 0: return [] # 2-3. Score the candidates. profile_only matches in the full embedding space # (E); policy modes match in the reduced PCA action space (Z) the policy # predicts in. if mode == "profile_only" and played_rows: profile = E[played_rows].mean(axis=0) ref = profile / max(float(np.linalg.norm(profile)), 1e-12) space = En else: action = _policy_action(state) ref = action / max(float(np.linalg.norm(action)), 1e-12) space = artifacts.action_matrix_normalized() sims = space[cand_idx] @ ref order = np.argsort(-sims) # 4-5. Drop played, take top_n, enrich. names = artifacts.index_frame()["name"].values played_set = set(played) results = [] for pos in order: row = int(cand_idx[pos]) name = names[row] if name in played_set: continue results.append(_enrich(name, float(sims[pos]), enrich=enrich)) if len(results) == top_n: break return results