Skip to content
Merged
13 changes: 12 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
60 changes: 35 additions & 25 deletions game/engine.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion overlay/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
48 changes: 34 additions & 14 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def _make_engine() -> SemanticEngine:
engine._model_path = "<in-memory>"
engine._model = kv
engine._cleaned_key_map = {w: w for w in words}
engine._vocab_size = len(kv.key_to_index) # 4
return engine


Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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/32/3
score_maison = engine.score_guess("maison", "chat") # rank 2/31/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(
Expand Down Expand Up @@ -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())
Expand Down
Loading