Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -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))
Expand All @@ -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}%)."
Expand Down
30 changes: 23 additions & 7 deletions game/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion overlay/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
38 changes: 32 additions & 6 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading