"""Stage 4: narrow the catalog to a shortlist of candidate games. `candidates(filters, played_games=None, k=500)` returns row indices into the embedding matrix E, filtered by the user's constraints and (optionally) reranked by cosine similarity to the games they've already played. Stage 5 then runs the policy over this shortlist. Filter semantics — **lenient on missing data**: a game is excluded only when its metadata is present AND contradicts the filter. This matters because language data covers only ~6% of the catalog and release year ~83%, so strict matching on missing fields would empty the result set. year_min / year_max : release_year within [min, max]; unknown year passes. platforms : list of platform names; a game passes if any of its platforms case-insensitively matches any requested one (substring either direction, so "PC" matches "PC (Microsoft Windows)"). No platform data -> passes. language : a single language substring; a game passes if any of its languages contains it. No language data -> passes. """ import numpy as np from sklearn.metrics.pairwise import cosine_similarity from recommender import artifacts def _platform_ok(game_platforms, requested_lower) -> bool: if not game_platforms: return True # lenient on missing gp = [str(p).lower() for p in game_platforms] # Match if the requested label is a substring of a game platform label. # One-directional only: "pc" matches "pc (microsoft windows)" and "mac" # matches "macos", but a game labeled just "playstation" (PS1) must NOT # match a "playstation 5" request — the reverse direction caused that. return any(req in g for req in requested_lower for g in gp) def _language_ok(game_langs, requested_lower) -> bool: if not game_langs: return True # lenient on missing return any(requested_lower in str(lang).lower() for lang in game_langs) def candidates(filters: dict, played_games=None, k: int | None = 500) -> np.ndarray: """Return â‰Īk row indices into E matching `filters`. If `played_games` (canonical names) is given, the matches are reranked by cosine to the mean embedding of those games before truncation; otherwise they're returned in catalog (row) order for the policy to rerank in Stage 5. """ idx = artifacts.index_frame() mask = np.ones(len(idx), dtype=bool) # Year range — strict on present years, lenient on NaN. year = idx["release_year"].to_numpy(dtype=float) if filters.get("year_min") is not None: mask &= np.isnan(year) | (year >= filters["year_min"]) if filters.get("year_max") is not None: mask &= np.isnan(year) | (year <= filters["year_max"]) # Platforms. req_platforms = filters.get("platforms") or [] if req_platforms: req_lower = [str(p).lower() for p in req_platforms] mask &= idx["platforms"].apply(lambda gp: _platform_ok(gp, req_lower)).to_numpy() # Language. lang = filters.get("language") if lang: lang_lower = str(lang).lower() mask &= idx["language_supports"].apply(lambda gl: _language_ok(gl, lang_lower)).to_numpy() cand_idx = np.where(mask)[0] # Optional cosine rerank against the played-games mean profile. if played_games: n2r = artifacts.name_to_row() rows = [n2r[g] for g in played_games if g in n2r] if rows and len(cand_idx) > 0: E = artifacts.embedding_matrix() profile = E[rows].mean(axis=0, keepdims=True) sims = cosine_similarity(profile, E[cand_idx])[0] cand_idx = cand_idx[np.argsort(-sims)] return cand_idx if k is None else cand_idx[:k]