diff --git a/config.py b/config.py index acffa4e..16fde77 100644 --- a/config.py +++ b/config.py @@ -13,9 +13,20 @@ def _require(name: str) -> str: return value +def validate() -> None: + """Validate all required environment variables. + + Must be called once at application startup, before any Twitch + connection is attempted. Raises RuntimeError if any required + variable is missing. + """ + global TWITCH_CHANNEL + TWITCH_CHANNEL = _require("TWITCH_CHANNEL") + + # Optional: kept for users who still want to supply a token manually. TWITCH_TOKEN: str | None = os.getenv("TWITCH_TOKEN") -TWITCH_CHANNEL: str = _require("TWITCH_CHANNEL") +TWITCH_CHANNEL: str = os.getenv("TWITCH_CHANNEL", "") COMMAND_PREFIX: str = os.getenv("COMMAND_PREFIX", "!sx") COOLDOWN: int = int(os.getenv("COOLDOWN", "5")) DIFFICULTY: str = os.getenv("DIFFICULTY", "easy") diff --git a/game/engine.py b/game/engine.py index b51edb8..bdfbf0b 100644 --- a/game/engine.py +++ b/game/engine.py @@ -1,15 +1,14 @@ """Game engine: state management and guess scoring.""" -import os +import math import pathlib from gensim.models import KeyedVectors +import config from game.word_utils import build_cleaned_key_map, clean_word -_DEFAULT_MODEL_PATH = os.getenv( - "MODEL_PATH", "models/frWac_no_postag_no_phrase_700_skip_cut50.bin" -) +_DEFAULT_MODEL_PATH: str = config.MODEL_PATH class SemanticEngine: @@ -25,10 +24,14 @@ class SemanticEngine: path when the variable is unset. """ - def __init__(self, model_path: str | pathlib.Path | None = None) -> None: + def __init__( + self, + model_path: str | pathlib.Path | None = None, + ) -> None: self._model_path = str(model_path or _DEFAULT_MODEL_PATH) self._model: KeyedVectors | None = None self._cleaned_key_map: dict[str, str] = {} + self._vocab_size: int | None = None # ------------------------------------------------------------------ # Model management @@ -44,6 +47,7 @@ def load(self) -> None: self._model_path, binary=True, unicode_errors="ignore" ) self._cleaned_key_map = build_cleaned_key_map(self._model.key_to_index) + self._vocab_size = len(self._model.key_to_index) @property def is_loaded(self) -> bool: @@ -79,39 +83,45 @@ 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 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. + Returns ``1.0`` for an exact (cleaned) match, or a **logarithmic rank + score** in ``(0, 0.99]`` for a non-exact in-vocabulary guess. + Returns ``None`` when either word is missing from the vocabulary. + + Formula: ``0.99 * log((V+9) / (rank+9)) / log((V+9) / 10)`` where V + is the vocabulary size. The offset of 9 ensures the step from rank 1 + to rank 2 is ≤ 1 percentage point (no integer % gaps) for any + V ≥ 123 000. ``1.0`` is reserved exclusively for exact matches. + + Score distribution (frWac, V ≈ 150 000): + rank 1 → 99 % + rank 2 → 98 % + rank 3 → 97 % + rank 10 → 92 % + rank 100 → 74 % + rank 1 000 → 51 % + rank 10 000 → 27 % + rank 149 999 → 0.0001 % (always > 0) Args: guess: The word submitted by the player. target: The secret target word. Returns: - A float in ``[0, 1]``, or ``None``. + A float in ``(0, 0.99]``, or ``None`` if either word is OOV. """ - if clean_word(guess) == clean_word(target): + clean_guess = clean_word(guess) + clean_target = clean_word(target) + if clean_guess == clean_target: return 1.0 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)) + key_guess = self._cleaned_key_map.get(clean_guess) + key_target = self._cleaned_key_map.get(clean_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, (effective_vocab - rank) / effective_vocab)) + vocab_size = self._vocab_size or len(self._model.key_to_index) + return 0.99 * math.log((vocab_size + 9) / (rank + 9)) / math.log((vocab_size + 9) / 10) class GameEngine: diff --git a/main.py b/main.py index bdd6076..6715341 100644 --- a/main.py +++ b/main.py @@ -53,6 +53,7 @@ def _resolve_token() -> str: def main() -> None: """Start the Twitch bot, and optionally the overlay server.""" + config.validate() if len(sys.argv) > 1 and sys.argv[1] == "auth-login": # CLI mode: force a new login flow and exit. if not config.TWITCH_CLIENT_ID or not config.TWITCH_CLIENT_SECRET: diff --git a/overlay/static/index.html b/overlay/static/index.html index 7a8fd61..f2fb20a 100644 --- a/overlay/static/index.html +++ b/overlay/static/index.html @@ -82,7 +82,7 @@ #gauge-bar { height: 100%; width: 0%; - background: linear-gradient(90deg, #2d7d46, #c9a227, #c0392b); + background: linear-gradient(90deg, #1a5c8a 0%, #2d7d46 60%, #c9a227 90%, #c0392b 100%); border-radius: 3px; transition: width 0.6s ease; } diff --git a/tests/test_engine.py b/tests/test_engine.py index 13e7d8f..4a972a8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -32,6 +32,7 @@ def _make_engine() -> SemanticEngine: engine._model_path = "" engine._model = kv engine._cleaned_key_map = {w: w for w in words} + engine._vocab_size = len(kv.key_to_index) # 4 return engine @@ -48,11 +49,16 @@ def test_is_loaded_after_injecting_model(self): engine = _make_engine() assert engine.is_loaded - def test_score_guess_raises_when_not_loaded(self): + def test_similarity_raises_when_not_loaded(self): engine = SemanticEngine(model_path="/nonexistent/path.bin") with pytest.raises(RuntimeError, match="not loaded"): engine.similarity("chat", "chien") + def test_score_guess_raises_when_not_loaded(self): + engine = SemanticEngine(model_path="/nonexistent/path.bin") + with pytest.raises(RuntimeError, match="not loaded"): + engine.score_guess("chat", "chien") + # --------------------------------------------------------------------------- # SemanticEngine – similarity @@ -71,9 +77,11 @@ def test_similar_words_return_high_score(self): def test_unrelated_words_return_low_score(self): engine = _make_engine() - score = engine.score_guess("maison", "chat") - assert score is not None - assert score < 0.5 + score_close = engine.score_guess("chien", "chat") + score_unrelated = engine.score_guess("maison", "chat") + assert score_close is not None + assert score_unrelated is not None + assert score_unrelated < score_close def test_score_is_between_zero_and_one(self): engine = _make_engine() @@ -82,25 +90,36 @@ 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. + def test_score_is_log_rank(self): + """score_guess uses formula E: 0.99*log((V+9)/(r+9))/log((V+9)/10). - 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. + With vocab_size=4, chien (rank 1) scores exactly 0.99 and maison + (rank 2) scores less, so chien must strictly 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 + score_chien = engine.score_guess("chien", "chat") # rank 1 → 0.99 + score_maison = engine.score_guess("maison", "chat") # rank 2 → lower assert score_chien is not None assert score_maison is not None assert score_chien > score_maison + def test_all_vocab_words_score_above_zero(self): + """Every in-vocabulary word scores strictly > 0.""" + engine = _make_engine() + for word in ["chien", "maison", "voiture"]: + score = engine.score_guess(word, "chat") + assert score is not None + assert score > 0.0, f"{word!r} scored 0" + def test_unknown_word_returns_none(self): engine = _make_engine() assert engine.score_guess("inconnu", "chat") is None + def test_similarity_unknown_word_returns_none(self): + engine = _make_engine() + assert engine.similarity("inconnu", "chat") is None + assert engine.similarity("chat", "inconnu") is None + def test_similarity_is_symmetric(self): engine = _make_engine() assert engine.similarity("chat", "chien") == pytest.approx( @@ -133,8 +152,9 @@ def test_exact_match_returns_one(self): def test_unrelated_word_returns_low_score(self): ge = GameEngine("chat", semantic_engine=_make_engine()) - score = ge.score_guess("maison") - assert score < 0.5 + score_close = ge.score_guess("chien") + score_unrelated = ge.score_guess("maison") + assert score_unrelated < score_close def test_score_is_between_zero_and_one(self): ge = GameEngine("chat", semantic_engine=_make_engine())