diff --git a/bot/bot.py b/bot/bot.py index 9dcd742..8b6f1cf 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -200,12 +200,12 @@ async def guess(self, ctx: commands.Context, word: str = "") -> None: await ctx.send(f"🎉 The word '{word}' was already found by {found_by}!") elif result.already_cited: if result.entry.score is not None: - pct = round(result.entry.score * 100) + pct = math.floor(result.entry.score * 100) await ctx.send(f"'{word}' has already been suggested ({pct}% similarity).") else: await ctx.send(f"'{word}' has already been suggested.") elif result.entry.score is not None: - pct = round(result.entry.score * 100) + pct = math.floor(result.entry.score * 100) await ctx.send(f"'{word}': {pct}% similarity") else: await ctx.send(f"'{word}' is not in the vocabulary.") @@ -276,7 +276,7 @@ async def hint(self, ctx: commands.Context) -> None: return parts = [ - f"{i + 1}. {e.raw_word} ({round((e.score or 0.0) * 100)}%)" + f"{i + 1}. {e.raw_word} ({math.floor((e.score or 0.0) * 100)}%)" for i, e in enumerate(top) ] await ctx.send("Top guesses: " + " | ".join(parts)) @@ -303,7 +303,7 @@ async def status(self, ctx: commands.Context) -> None: top = self._game_state.top_guesses(1) if top: best = top[0] - pct = round((best.score or 0.0) * 100) + pct = math.floor((best.score or 0.0) * 100) await ctx.send( f"Game in progress. {attempts} attempt(s). " f"Best guess: '{best.raw_word}' ({pct}%)." diff --git a/game/engine.py b/game/engine.py index bbbe985..b51edb8 100644 --- a/game/engine.py +++ b/game/engine.py @@ -79,23 +79,39 @@ def similarity(self, word_a: str, word_b: str) -> float | None: def score_guess(self, guess: str, target: str) -> float | None: """Score a player's guess against the target word. - Returns ``1.0`` for an exact (cleaned) match, or the cosine similarity - for a non-exact guess. Returns ``None`` when either word is missing - from the vocabulary. + Returns ``1.0`` for an exact (cleaned) match, or a **percentile rank** + in ``[0, 1)`` for a non-exact guess. Returns ``None`` when either + word is missing from the vocabulary. + + The percentile rank expresses what fraction of the vocabulary is *less + similar* to *target* than *guess* is. For example, a score of + ``0.99`` means the guess is closer to the target than 99 % of all + words in the model. Args: guess: The word submitted by the player. target: The secret target word. Returns: - A float in ``[0, 1]`` (after clamping), or ``None``. + A float in ``[0, 1]``, or ``None``. """ if clean_word(guess) == clean_word(target): return 1.0 - sim = self.similarity(guess, target) - if sim is None: + if self._model is None: + raise RuntimeError("Model not loaded. Call load() first.") + key_guess = self._cleaned_key_map.get(clean_word(guess)) + key_target = self._cleaned_key_map.get(clean_word(target)) + if key_guess is None or key_target is None: + return None + rank = self._model.rank(key_target, key_guess) + # effective_vocab excludes the target word itself, matching how + # gensim's closer_than() (used internally by rank()) omits key1. + # Guard against degenerate single-word vocabularies where no ranking + # is meaningful and division by zero would occur. + effective_vocab = len(self._model.key_to_index) - 1 + if effective_vocab <= 0: return None - return max(0.0, min(1.0, sim)) + return max(0.0, min(1.0, (effective_vocab - rank) / effective_vocab)) class GameEngine: diff --git a/overlay/static/index.html b/overlay/static/index.html index 7d8e69e..a95ee91 100644 --- a/overlay/static/index.html +++ b/overlay/static/index.html @@ -192,7 +192,7 @@ const elAttempts = document.getElementById('attempt-count'); function pct(score) { - return score != null ? Math.round(score * 100) : 0; + return score != null ? Math.floor(score * 100) : 0; } function barColor(score) { diff --git a/tests/test_commands.py b/tests/test_commands.py index 1a9bb85..ae2df39 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -383,11 +383,37 @@ async def test_guess_near_match_shows_similarity(self): message = ctx.send.call_args[0][0] assert "%" in message - async def test_guess_percentage_uses_rounding_not_truncation(self): - """Chat must show the same rounded percentage as the overlay (Math.round).""" + async def test_guess_percentage_uses_floor_not_round(self): + """Chat must use floor() so that near-100% percentiles never display as 100%. - class _RoundingScorer: - """Returns 0.475 so that truncation gives 47% but rounding gives 48%.""" + A scorer returning 0.998 gives round()=100 but floor()=99; floor must be used + so that 100% is reserved exclusively for the exact-match win message. + """ + + class _NearHundredScorer: + """Returns 0.998 so that round() gives 100% but floor() gives 99%.""" + + def score_guess(self, guess: str, target: str) -> float | None: + from game.word_utils import clean_word + if clean_word(guess) == clean_word(target): + return 1.0 + return 0.998 + + bot = _make_bot(cooldown=0) + bot._game_state = GameState(scorer=_NearHundredScorer()) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = _make_ctx() + ctx.author.name = "alice" + await _guess_fn(bot, ctx, "chien") + message = ctx.send.call_args[0][0] + assert "99%" in message, f"Expected 99% (floor), got: {message}" + assert "100%" not in message, f"100% must be reserved for exact matches, got: {message}" + + async def test_guess_percentage_uses_floor_truncation(self): + """Floor truncates toward zero: 0.475 * 100 = 47, not 48.""" + + class _FloorScorer: + """Returns 0.475 so that floor gives 47% but round gives 48%.""" def score_guess(self, guess: str, target: str) -> float | None: from game.word_utils import clean_word @@ -396,13 +422,13 @@ def score_guess(self, guess: str, target: str) -> float | None: return 0.475 bot = _make_bot(cooldown=0) - bot._game_state = GameState(scorer=_RoundingScorer()) + bot._game_state = GameState(scorer=_FloorScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) ctx = _make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "chien") message = ctx.send.call_args[0][0] - assert "48%" in message, f"Expected 48% (rounded), got: {message}" + assert "47%" in message, f"Expected 47% (floor), got: {message}" async def test_guess_unknown_word_reports_vocabulary_miss(self): bot = _make_bot(cooldown=0) diff --git a/tests/test_engine.py b/tests/test_engine.py index fe1ca0a..13e7d8f 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -82,6 +82,21 @@ def test_score_is_between_zero_and_one(self): assert score is not None assert 0.0 <= score <= 1.0 + def test_score_is_percentile_rank(self): + """score_guess returns a percentile rank, not raw cosine similarity. + + With 4 words in the test vocabulary (chat, chien, maison, voiture), + effective_vocab = 3 (excluding the target 'chat' itself). chien is + the closest non-target word (rank 1/3 → score 2/3) and maison is + less similar (rank 2/3 → score 1/3), so chien must outrank maison. + """ + engine = _make_engine() + score_chien = engine.score_guess("chien", "chat") # rank 1/3 → 2/3 + score_maison = engine.score_guess("maison", "chat") # rank 2/3 → 1/3 + assert score_chien is not None + assert score_maison is not None + assert score_chien > score_maison + def test_unknown_word_returns_none(self): engine = _make_engine() assert engine.score_guess("inconnu", "chat") is None