nathanael-fijalkow commited on
Commit
641f13e
·
1 Parent(s): b93075b

first commiiiiiiiiit

Browse files
.gitignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ eggs/
12
+ parts/
13
+ var/
14
+ sdist/
15
+ develop-eggs/
16
+ .installed.cfg
17
+ lib/
18
+ lib64/
19
+
20
+ # Virtual environments
21
+ .env
22
+ .venv
23
+ env/
24
+ venv/
25
+ ENV/
26
+
27
+ # Jupyter
28
+ .ipynb_checkpoints/
29
+ *.ipynb
30
+
31
+ # macOS
32
+ .DS_Store
33
+
34
+ # IDEs
35
+ .vscode/
36
+ .idea/
37
+ *.swp
38
+ *.swo
39
+
40
+ # Logs
41
+ *.log
42
+ log.json
README.md CHANGED
@@ -1,13 +1,73 @@
1
- ---
2
- title: Codenames
3
- emoji: 🐨
4
- colorFrom: purple
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.6.0
8
- app_file: app.py
9
- pinned: false
10
- short_description: Playing codenames with LLMs
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Codenames LLM Challenge
2
+
3
+ A Python framework for students to implement guesser bots for Codenames. The LLM acts as spymaster using embeddings.
4
+
5
+ ## Game Rules
6
+
7
+ **Challenge Mode (Single Team):**
8
+ - Goal: Guess all RED words in minimum rounds
9
+ - Board: 25 words total (9 RED, 8 BLUE, 8 ASSASSIN)
10
+ - Each round: LLM spymaster gives a clue + number
11
+ - Guesser makes up to (number + 1) guesses
12
+ - Round ends if: BLUE word revealed, max guesses reached, or guesser stops
13
+ - Game ends: WIN if all RED found, LOSE if ASSASSIN revealed
14
+
15
+ ## Setup
16
+
17
+ ```bash
18
+ uv venv
19
+ source .venv/bin/activate
20
+ uv pip install -r requirements.txt
21
+ ```
22
+
23
+ **Dictionary:** Fixed list of 420 Codenames words. Clues and board words must be from this dictionary (case-insensitive).
24
+
25
+ **Pre-build Embedding Cache (Recommended):**
26
+
27
+ ```bash
28
+ python -m codenames.cli init-cache
29
+ ```
30
+
31
+ Downloads the embedding model and computes vectors for all 420 words (~30 seconds). Cached for reuse.
32
+
33
+ ## Test Your Guesser
34
+
35
+ Create a Python file with a `guesser` function:
36
+
37
+ ```python
38
+ # my_guesser.py
39
+ def guesser(clue: str, board_state: list[str]) -> str | None:
40
+ """
41
+ Args:
42
+ clue: The spymaster's one-word clue (from dictionary)
43
+ board_state: List of unrevealed words on the board
44
+
45
+ Returns:
46
+ A word to guess from board_state, or None to stop the round
47
+ """
48
+ # Your embedding-based or heuristic logic here
49
+ return board_state[0] # Simple example: always guess first word
50
+ ```
51
+
52
+ Run against LLM spymaster:
53
+
54
+ ```bash
55
+ python -m codenames.cli challenge my_guesser.py --seed 42 --output log.json
56
+ ```
57
+
58
+ **Options:**
59
+ - `--seed`: Random seed for reproducible boards
60
+ - `--model`: Embedding model (default: `sentence-transformers/all-MiniLM-L6-v2`)
61
+ - `--max-rounds`: Maximum rounds before timeout (default: 10)
62
+ - `--output`: Save JSON log with board state, clues, guesses, and result
63
+
64
+ ## Log Format
65
+
66
+ The JSON output contains:
67
+ - `seed`: Random seed used
68
+ - `board_words`: All 25 words on the board
69
+ - `board_roles`: Role for each word (RED/BLUE/ASSASSIN)
70
+ - `rounds`: Array of rounds with clue, number, and guesses
71
+ - `final_state`: Win/loss status and rounds taken
72
+
73
+ Use this data to analyze performance or train ML models.
app.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+
3
+ INTRO_MD = """
4
+ # 🕵️ Codenames LLM Challenge
5
+
6
+ Welcome! This is a coding challenge where you build a **guesser bot** for the board game
7
+ [Codenames](https://en.wikipedia.org/wiki/Codenames_(board_game)).
8
+
9
+ An LLM-powered **spymaster** will give you one-word clues. Your job is to write the
10
+ **guesser** that picks the right words on the board.
11
+
12
+ ---
13
+
14
+ ## How the game works
15
+
16
+ The board has **25 words** split into hidden roles:
17
+
18
+ | Role | Count | Meaning |
19
+ |------|-------|---------|
20
+ | 🟥 RED | 9 | Your team's words — guess these! |
21
+ | 🟦 BLUE | 8 | Opponent words — avoid these |
22
+ | 💀 ASSASSIN | 8 | Instant loss if revealed |
23
+
24
+ Each round:
25
+ 1. The **spymaster** gives a one-word clue + a number (e.g. `"ocean 3"`)
26
+ 2. Your **guesser** can make up to `number + 1` guesses
27
+ 3. A round ends when you reveal a BLUE word, reach the max guesses, or return `None`
28
+ 4. The game ends when **all RED words are found** (win) or the **assassin is revealed** (loss)
29
+
30
+ Your score is based on the number of rounds needed — fewer is better.
31
+
32
+ ---
33
+
34
+ ## Two steps to participate
35
+ """
36
+
37
+ STEP1_MD = """
38
+ ## Step 1 — Clone the repository
39
+
40
+ Open a terminal and run:
41
+
42
+ ```bash
43
+ git clone https://huggingface.co/spaces/LLM-course/codenames
44
+ cd codenames
45
+ ```
46
+
47
+ Then set up the Python environment:
48
+
49
+ ```bash
50
+ uv venv
51
+ source .venv/bin/activate # on Windows: .venv\\Scripts\\activate
52
+ uv pip install -r requirements.txt
53
+ ```
54
+
55
+ *(Optional but recommended)* Pre-build the embedding cache so runs are faster:
56
+
57
+ ```bash
58
+ python -m codenames.cli init-cache
59
+ ```
60
+ """
61
+
62
+ STEP2_MD = """
63
+ ## Step 2 — Implement `my_guesser.py`
64
+
65
+ Open `my_guesser.py` and fill in the `guesser` function:
66
+
67
+ ```python
68
+ # my_guesser.py
69
+ from codenames.embedder import EmbeddingIndex
70
+ from codenames.dictionary import load_dictionary
71
+
72
+ _dictionary = load_dictionary()
73
+ _index = EmbeddingIndex(_dictionary)
74
+
75
+ def guesser(clue: str, board_state: list[str]) -> str | None:
76
+ \"\"\"
77
+ Args:
78
+ clue: The spymaster's one-word clue (always from the 420-word dictionary)
79
+ board_state: List of unrevealed words currently on the board
80
+
81
+ Returns:
82
+ A word from board_state to guess, or None to stop guessing this round.
83
+ \"\"\"
84
+ # TODO: replace this with your smart logic!
85
+ return board_state[0]
86
+ ```
87
+
88
+ ### Tips
89
+ - Use **embedding similarity** between the clue and board words — `_index.vector(word)`
90
+ returns a numpy vector for any dictionary word.
91
+ - Return `None` when you're not confident: it's safer than hitting a BLUE or ASSASSIN word.
92
+ - The clue is always a word from the shared 420-word dictionary (case-insensitive).
93
+
94
+ ### Test your guesser
95
+
96
+ ```bash
97
+ python -m codenames.cli challenge my_guesser.py --seed 42 --output log.json
98
+ ```
99
+
100
+ Options:
101
+ - `--seed` — fix the board for reproducibility
102
+ - `--max-rounds` — limit rounds (default 10)
103
+ - `--output` — save a detailed JSON log with clues, guesses and the final result
104
+
105
+ The JSON log is useful for inspecting mistakes and improving your strategy.
106
+ """
107
+
108
+ RULES_MD = """
109
+ ## Full rules summary
110
+
111
+ - Board: 25 words, 9 RED / 8 BLUE / 8 ASSASSIN (challenge / single-team mode)
112
+ - Clue + number are given each round by the LLM spymaster
113
+ - Guesser may guess up to `number + 1` times per round
114
+ - Revealing a BLUE word ends the round immediately
115
+ - Revealing the ASSASSIN ends the game immediately (loss)
116
+ - **Goal:** reveal all 9 RED words in as few rounds as possible
117
+
118
+ ## Dictionary
119
+
120
+ All clue words and board words are drawn from a **fixed list of 420 Codenames words**
121
+ (see `codenames/data/codenames_dict.txt`). Your guesser receives words from this list
122
+ and must return a word from the current `board_state`.
123
+ """
124
+
125
+ with gr.Blocks(title="Codenames LLM Challenge") as demo:
126
+ gr.Markdown(INTRO_MD)
127
+
128
+ with gr.Tabs():
129
+ with gr.TabItem("🛠️ Step 1 — Clone"):
130
+ gr.Markdown(STEP1_MD)
131
+
132
+ with gr.TabItem("💡 Step 2 — Implement"):
133
+ gr.Markdown(STEP2_MD)
134
+
135
+ with gr.TabItem("📋 Full Rules"):
136
+ gr.Markdown(RULES_MD)
137
+
138
+ if __name__ == "__main__":
139
+ demo.launch()
baseline.json ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "seed": 42,
3
+ "board_words": [
4
+ "Snowman",
5
+ "Carrot",
6
+ "Atlantis",
7
+ "Tube",
8
+ "France",
9
+ "Fan",
10
+ "Dwarf",
11
+ "Chocolate",
12
+ "Trip",
13
+ "Canada",
14
+ "Stock",
15
+ "Pool",
16
+ "Bridge",
17
+ "Row",
18
+ "Mercury",
19
+ "Ball",
20
+ "Back",
21
+ "Buffalo",
22
+ "Drill",
23
+ "England",
24
+ "Penguin",
25
+ "Scientist",
26
+ "Australia",
27
+ "Pyramid",
28
+ "Degree"
29
+ ],
30
+ "board_roles": [
31
+ "ASSASSIN",
32
+ "ASSASSIN",
33
+ "ASSASSIN",
34
+ "BLUE",
35
+ "ASSASSIN",
36
+ "BLUE",
37
+ "BLUE",
38
+ "RED",
39
+ "BLUE",
40
+ "RED",
41
+ "RED",
42
+ "RED",
43
+ "BLUE",
44
+ "RED",
45
+ "BLUE",
46
+ "RED",
47
+ "RED",
48
+ "RED",
49
+ "BLUE",
50
+ "RED",
51
+ "BLUE",
52
+ "ASSASSIN",
53
+ "ASSASSIN",
54
+ "ASSASSIN",
55
+ "ASSASSIN"
56
+ ],
57
+ "rounds": [
58
+ {
59
+ "round": 1,
60
+ "clue": "bat",
61
+ "number": 3,
62
+ "guesses": [
63
+ {
64
+ "word": "Ball",
65
+ "role": "RED"
66
+ },
67
+ {
68
+ "word": "Pool",
69
+ "role": "RED"
70
+ },
71
+ {
72
+ "word": "Drill",
73
+ "role": "BLUE"
74
+ }
75
+ ]
76
+ },
77
+ {
78
+ "round": 2,
79
+ "clue": "bark",
80
+ "number": 3,
81
+ "guesses": [
82
+ {
83
+ "word": "Buffalo",
84
+ "role": "RED"
85
+ },
86
+ {
87
+ "word": "Chocolate",
88
+ "role": "RED"
89
+ },
90
+ {
91
+ "word": "Back",
92
+ "role": "RED"
93
+ },
94
+ {
95
+ "word": "Bridge",
96
+ "role": "BLUE"
97
+ }
98
+ ]
99
+ },
100
+ {
101
+ "round": 3,
102
+ "clue": "mail",
103
+ "number": 1,
104
+ "guesses": [
105
+ {
106
+ "word": "Stock",
107
+ "role": "RED"
108
+ },
109
+ {
110
+ "word": "Canada",
111
+ "role": "RED"
112
+ }
113
+ ]
114
+ },
115
+ {
116
+ "round": 4,
117
+ "clue": "jam",
118
+ "number": 1,
119
+ "guesses": [
120
+ {
121
+ "word": "Row",
122
+ "role": "RED"
123
+ },
124
+ {
125
+ "word": "Trip",
126
+ "role": "BLUE"
127
+ }
128
+ ]
129
+ },
130
+ {
131
+ "round": 5,
132
+ "clue": "well",
133
+ "number": 1,
134
+ "guesses": [
135
+ {
136
+ "word": "Fan",
137
+ "role": "BLUE"
138
+ }
139
+ ]
140
+ },
141
+ {
142
+ "round": 6,
143
+ "clue": "boot",
144
+ "number": 1,
145
+ "guesses": [
146
+ {
147
+ "word": "Tube",
148
+ "role": "BLUE"
149
+ }
150
+ ]
151
+ },
152
+ {
153
+ "round": 7,
154
+ "clue": "boot",
155
+ "number": 1,
156
+ "guesses": [
157
+ {
158
+ "word": "Scientist",
159
+ "role": "ASSASSIN"
160
+ }
161
+ ]
162
+ }
163
+ ],
164
+ "final_state": {
165
+ "won": false,
166
+ "lost": true,
167
+ "rounds_taken": 7
168
+ }
169
+ }
codenames/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __all__ = [
2
+ "models",
3
+ "dictionary",
4
+ "embedder",
5
+ "master",
6
+ "challenge",
7
+ "challenge_runner",
8
+ ]
codenames/challenge.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass, field
5
+ from typing import List, Optional
6
+
7
+ from .models import Role, Card
8
+
9
+
10
+ @dataclass
11
+ class ChallengeBoard:
12
+ """Single-team challenge: RED team vs (BLUE + ASSASSIN). No neutrals."""
13
+
14
+ cards: List[Card] = field(default_factory=list)
15
+ index_by_word: dict = field(default_factory=dict)
16
+
17
+ @staticmethod
18
+ def create(words: List[str], rng: random.Random) -> "ChallengeBoard":
19
+ """Create a board with RED, BLUE, ASSASSIN roles (no NEUTRAL)."""
20
+ if len(words) != 25:
21
+ raise ValueError("Challenge board must have exactly 25 words")
22
+
23
+ # 8 RED, 8 BLUE, 1 ASSASSIN (9 remaining would need adjustment)
24
+ # Standard: 9 RED, 8 BLUE, 8 ASSASSIN? Let's use 9 RED, 8 BLUE, 8 ASSASSIN
25
+ roles = [Role.RED] * 9 + [Role.BLUE] * 8 + [Role.ASSASSIN] * 8
26
+ rng.shuffle(roles)
27
+
28
+ cards = [Card(word=w, role=roles[i]) for i, w in enumerate(words)]
29
+ board = ChallengeBoard(cards=cards)
30
+ board.index_by_word = {c.word.lower(): i for i, c in enumerate(cards)}
31
+ return board
32
+
33
+ def get_card(self, word: str) -> Optional[Card]:
34
+ idx = self.index_by_word.get(word.lower())
35
+ return self.cards[idx] if idx is not None else None
36
+
37
+ def reveal(self, word: str) -> Optional[Role]:
38
+ card = self.get_card(word)
39
+ if card is None or card.revealed:
40
+ return None
41
+ card.revealed = True
42
+ return card.role
43
+
44
+ def red_remaining(self) -> int:
45
+ return sum(1 for c in self.cards if c.role == Role.RED and not c.revealed)
46
+
47
+ def all_red_revealed(self) -> bool:
48
+ return all(c.revealed for c in self.cards if c.role == Role.RED)
49
+
50
+ def as_grid(self) -> List[List[Card]]:
51
+ return [self.cards[i : i + 5] for i in range(0, 25, 5)]
52
+
53
+ def board_view(self, show_key: bool = False) -> List[List[str]]:
54
+ grid = []
55
+ for row in self.as_grid():
56
+ display_row = []
57
+ for c in row:
58
+ label = c.word
59
+ if c.revealed:
60
+ label = f"[{label}]"
61
+ elif show_key:
62
+ label = f"{label}:{c.role.value}"
63
+ display_row.append(label)
64
+ grid.append(display_row)
65
+ return grid
66
+
67
+
68
+ @dataclass
69
+ class ChallengeGame:
70
+ """Single-team challenge: guess all RED words while avoiding BLUE and ASSASSIN."""
71
+
72
+ dictionary: List[str]
73
+ seed: Optional[int] = None
74
+ rng: random.Random = field(init=False)
75
+ board: ChallengeBoard = field(init=False)
76
+ dictionary_set: set = field(init=False)
77
+
78
+ # Turn state
79
+ clue_word: Optional[str] = None
80
+ clue_number: Optional[int] = None
81
+ guesses_made: int = 0
82
+ max_guesses_this_turn: int = 0
83
+ round_number: int = 1
84
+
85
+ # Outcome
86
+ won: bool = False
87
+ lost: bool = False
88
+
89
+ def __post_init__(self):
90
+ self.rng = random.Random(self.seed)
91
+ self.dictionary_set = {w.lower() for w in self.dictionary}
92
+ if len(self.dictionary) < 25:
93
+ raise ValueError("Dictionary must contain at least 25 words")
94
+ words = self.rng.sample(self.dictionary, 25)
95
+ self.board = ChallengeBoard.create(words, self.rng)
96
+
97
+ def give_clue(self, clue_word: str, number: int):
98
+ if self.won or self.lost:
99
+ raise RuntimeError("Game already over")
100
+ if number < 0:
101
+ raise ValueError("Number must be non-negative")
102
+ if clue_word.lower() not in self.dictionary_set:
103
+ raise ValueError("Clue must be a word from the dictionary")
104
+ if clue_word.lower() in self.board.index_by_word:
105
+ raise ValueError("Clue cannot be a word that is on the board")
106
+
107
+ self.clue_word = clue_word
108
+ self.clue_number = number
109
+ self.max_guesses_this_turn = number + 1
110
+ self.guesses_made = 0
111
+
112
+ def guess(self, word: str) -> tuple[Optional[Role], str]:
113
+ if self.won or self.lost:
114
+ return None, "Game over"
115
+ if self.clue_word is None:
116
+ return None, "No clue given yet"
117
+
118
+ card = self.board.get_card(word)
119
+ if card is None:
120
+ return None, f"Word '{word}' is not on the board. Try again."
121
+ if card.revealed:
122
+ return None, f"Word '{word}' is already revealed. Try again."
123
+
124
+ role = self.board.reveal(word)
125
+ self.guesses_made += 1
126
+
127
+ # Check outcomes
128
+ if role == Role.ASSASSIN:
129
+ self.lost = True
130
+ return role, "Revealed ASSASSIN. You lose."
131
+
132
+ if role == Role.BLUE:
133
+ self.end_round()
134
+ return role, "Revealed BLUE. Round ends."
135
+
136
+ # Revealed RED
137
+ if self.board.all_red_revealed():
138
+ self.won = True
139
+ return role, f"Revealed RED. All RED words found! You win in {self.round_number} round(s)."
140
+
141
+ # Can continue if guesses remain
142
+ if self.guesses_made >= self.max_guesses_this_turn:
143
+ self.end_round()
144
+ return role, f"Revealed RED. Max guesses reached. Round ends."
145
+
146
+ return role, f"Revealed RED. You may guess again or 'stop'."
147
+
148
+ def end_round(self):
149
+ self.clue_word = None
150
+ self.clue_number = None
151
+ self.guesses_made = 0
152
+ self.max_guesses_this_turn = 0
153
+ self.round_number += 1
154
+
155
+ def status_text(self) -> str:
156
+ if self.won:
157
+ return f"🏆 YOU WIN in {self.round_number} round(s)! 🏆"
158
+ if self.lost:
159
+ return "💀 YOU LOSE - Assassin revealed. 💀"
160
+
161
+ lines = [
162
+ f"🎯 SPYMASTER (Round {self.round_number})",
163
+ f"RED remaining: {self.board.red_remaining()}",
164
+ ]
165
+
166
+ if self.clue_word is not None:
167
+ lines.append(f"Clue: {self.clue_word.upper()} ({self.clue_number})")
168
+ lines.append(f"{self.max_guesses_this_turn - self.guesses_made} guesses left")
169
+
170
+ return "\n".join(lines)
codenames/challenge_runner.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, Optional
4
+
5
+ from .challenge import ChallengeGame
6
+ from .master import MasterPlayer
7
+
8
+
9
+ def play_challenge(
10
+ guesser_fn: Callable,
11
+ dictionary: list[str],
12
+ seed: Optional[int] = None,
13
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
14
+ max_rounds: int = 10,
15
+ verbose: bool = True,
16
+ ) -> dict:
17
+ """
18
+ Play a challenge game with LLM spymaster and student guesser code.
19
+
20
+ The guesser_fn should have signature:
21
+ guesser_fn(clue: str, board_state: list[str]) -> Optional[str]
22
+ where board_state is a list of unrevealed word strings.
23
+ """
24
+ game = ChallengeGame(dictionary=dictionary, seed=seed)
25
+ master = MasterPlayer(dictionary, model_name=model_name)
26
+
27
+ log = {
28
+ "seed": seed,
29
+ "board_words": [c.word for c in game.board.cards],
30
+ "board_roles": [c.role.value for c in game.board.cards],
31
+ "rounds": [],
32
+ }
33
+
34
+ while not game.won and not game.lost and game.round_number <= max_rounds:
35
+ if verbose:
36
+ print(f"\n{game.status_text()}")
37
+
38
+ # Spymaster suggests clue
39
+ suggestion = master.suggest_clue(game)
40
+ if suggestion is None:
41
+ if verbose:
42
+ print("No clue available.")
43
+ break
44
+
45
+ game.give_clue(suggestion.clue, suggestion.number)
46
+ if verbose:
47
+ print(f"Clue: {suggestion.clue} ({suggestion.number})")
48
+
49
+ round_log = {
50
+ "round": game.round_number,
51
+ "clue": suggestion.clue,
52
+ "number": suggestion.number,
53
+ "guesses": [],
54
+ }
55
+
56
+ # Guesser phase with student code
57
+ while not game.won and not game.lost and game.clue_word is not None:
58
+ unrevealed = [c.word for c in game.board.cards if not c.revealed]
59
+ try:
60
+ guess_word = guesser_fn(game.clue_word, unrevealed)
61
+ except Exception as e:
62
+ if verbose:
63
+ print(f"Guesser error: {e}")
64
+ break
65
+
66
+ if guess_word is None:
67
+ break
68
+
69
+ role, msg = game.guess(guess_word)
70
+ if verbose:
71
+ print(f" Guess: {guess_word} -> {msg}")
72
+
73
+ if role is not None:
74
+ round_log["guesses"].append({"word": guess_word, "role": role.value})
75
+
76
+ log["rounds"].append(round_log)
77
+
78
+ log["final_state"] = {
79
+ "won": game.won,
80
+ "lost": game.lost,
81
+ "rounds_taken": game.round_number - 1 if game.won else game.round_number,
82
+ }
83
+
84
+ if verbose:
85
+ print(f"\nFinal: {game.status_text()}")
86
+
87
+ return log
codenames/cli.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from typing import Optional
5
+
6
+ from .dictionary import load_dictionary
7
+
8
+
9
+
10
+
11
+
12
+ def cmd_init_cache(args):
13
+ """Pre-build embedding cache for the dictionary."""
14
+ from .embedder import load_or_build_embeddings
15
+ words = load_dictionary()
16
+ print(f"Pre-building cache for {len(words)} words with model: {args.model}")
17
+ cache = load_or_build_embeddings(words, model_name=args.model, rebuild=args.rebuild)
18
+ print(f"✓ Cache ready: {cache.vectors.shape}")
19
+
20
+
21
+
22
+
23
+
24
+ def cmd_challenge(args):
25
+ """Play challenge mode with LLM spymaster and student guesser code."""
26
+ words = load_dictionary()
27
+
28
+ # Import student code
29
+ import importlib.util
30
+ spec = importlib.util.spec_from_file_location("student_guesser", args.guesser_code)
31
+ if spec is None or spec.loader is None:
32
+ print(f"Could not load {args.guesser_code}")
33
+ return
34
+ module = importlib.util.module_from_spec(spec)
35
+ spec.loader.exec_module(module)
36
+
37
+ if not hasattr(module, "guesser"):
38
+ print("Student code must define a 'guesser' function with signature: guesser(clue: str, board_state: list[str]) -> Optional[str]")
39
+ return
40
+
41
+ from .challenge_runner import play_challenge
42
+ log = play_challenge(
43
+ module.guesser,
44
+ words,
45
+ seed=args.seed,
46
+ model_name=args.model,
47
+ max_rounds=args.max_rounds,
48
+ verbose=True,
49
+ )
50
+
51
+ if args.output:
52
+ import json
53
+ with open(args.output, "w") as f:
54
+ json.dump(log, f, indent=2)
55
+ print(f"\nLog saved to {args.output}")
56
+
57
+
58
+ def main(argv: Optional[list] = None):
59
+ parser = argparse.ArgumentParser(description="Codenames Challenge CLI")
60
+ sub = parser.add_subparsers(dest="cmd", required=True)
61
+
62
+ p_cache = sub.add_parser("init-cache", help="Pre-build embedding cache for dictionary")
63
+ p_cache.add_argument(
64
+ "--model",
65
+ type=str,
66
+ default="sentence-transformers/all-MiniLM-L6-v2",
67
+ help="SentenceTransformers model name",
68
+ )
69
+ p_cache.add_argument("--rebuild", action="store_true", help="Force rebuild cache even if it exists")
70
+ p_cache.set_defaults(func=cmd_init_cache)
71
+
72
+ p_challenge = sub.add_parser("challenge", help="Challenge mode: LLM spymaster + student guesser")
73
+ p_challenge.add_argument("guesser_code", help="Path to Python file with guesser() function")
74
+ p_challenge.add_argument("--seed", type=int, default=None, help="Random seed")
75
+ p_challenge.add_argument(
76
+ "--model",
77
+ type=str,
78
+ default="sentence-transformers/all-MiniLM-L6-v2",
79
+ help="SentenceTransformers model name",
80
+ )
81
+ p_challenge.add_argument("--max-rounds", type=int, default=10, help="Maximum rounds")
82
+ p_challenge.add_argument("--output", type=str, default=None, help="Save log to JSON file")
83
+ p_challenge.set_defaults(func=cmd_challenge)
84
+
85
+ args = parser.parse_args(argv)
86
+ args.func(args)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
codenames/data/codenames_dict.txt ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Africa
2
+ Agent
3
+ Air
4
+ Alien
5
+ Alps
6
+ Amazon
7
+ Ambulance
8
+ America
9
+ Angel
10
+ Antarctica
11
+ Apple
12
+ Arm
13
+ Atlantis
14
+ Australia
15
+ Aztec
16
+ Back
17
+ Ball
18
+ Band
19
+ Bank
20
+ Bar
21
+ Bark
22
+ Bat
23
+ Battery
24
+ Beach
25
+ Bear
26
+ Beat
27
+ Bed
28
+ Beijing
29
+ Bell
30
+ Belt
31
+ Berlin
32
+ Bermuda
33
+ Berry
34
+ Bill
35
+ Block
36
+ Board
37
+ Bolt
38
+ Bomb
39
+ Bond
40
+ Boom
41
+ Boot
42
+ Bottle
43
+ Bow
44
+ Box
45
+ Bridge
46
+ Brush
47
+ Buck
48
+ Buffalo
49
+ Bug
50
+ Bugle
51
+ Button
52
+ Calf
53
+ Canada
54
+ Cap
55
+ Capital
56
+ Car
57
+ Card
58
+ Carrot
59
+ Casino
60
+ Cast
61
+ Cat
62
+ Cell
63
+ Centaur
64
+ Center
65
+ Chair
66
+ Change
67
+ Charge
68
+ Check
69
+ Chest
70
+ Chick
71
+ China
72
+ Chocolate
73
+ Church
74
+ Circle
75
+ Cliff
76
+ Cloak
77
+ Club
78
+ Code
79
+ Cold
80
+ Comic
81
+ Compound
82
+ Concert
83
+ Conductor
84
+ Contract
85
+ Cook
86
+ Copper
87
+ Cotton
88
+ Court
89
+ Cover
90
+ Crane
91
+ Crash
92
+ Cricket
93
+ Cross
94
+ Crown
95
+ Cycle
96
+ Czech
97
+ Dance
98
+ Date
99
+ Day
100
+ Death
101
+ Deck
102
+ Degree
103
+ Diamond
104
+ Dice
105
+ Dinosaur
106
+ Disease
107
+ Doctor
108
+ Dog
109
+ Draft
110
+ Dragon
111
+ Dress
112
+ Drill
113
+ Drop
114
+ Duck
115
+ Dwarf
116
+ Eagle
117
+ Egypt
118
+ Embassy
119
+ Engine
120
+ England
121
+ Europe
122
+ Eye
123
+ Face
124
+ Fair
125
+ Fall
126
+ Fan
127
+ Fence
128
+ Field
129
+ Fighter
130
+ Figure
131
+ File
132
+ Film
133
+ Fire
134
+ Fish
135
+ Flute
136
+ Fly
137
+ Foot
138
+ Force
139
+ Forest
140
+ Fork
141
+ France
142
+ Game
143
+ Gas
144
+ Genius
145
+ Germany
146
+ Ghost
147
+ Giant
148
+ Glass
149
+ Glove
150
+ Gold
151
+ Grace
152
+ Grass
153
+ Greece
154
+ Green
155
+ Ground
156
+ Ham
157
+ Hand
158
+ Hawk
159
+ Head
160
+ Heart
161
+ Helicopter
162
+ Himalayas
163
+ Hole
164
+ Hollywood
165
+ Honey
166
+ Hood
167
+ Hook
168
+ Horn
169
+ Horse
170
+ Horseshoe
171
+ Hospital
172
+ Hotel
173
+ Ice
174
+ Ice Cream
175
+ India
176
+ Iron
177
+ Ivory
178
+ Jack
179
+ Jam
180
+ Jet
181
+ Jupiter
182
+ Kangaroo
183
+ Ketchup
184
+ Key
185
+ Kid
186
+ King
187
+ Kiwi
188
+ Knife
189
+ Knight
190
+ Lab
191
+ Lap
192
+ Laser
193
+ Lawyer
194
+ Lead
195
+ Leather
196
+ Lemon
197
+ Leprechaun
198
+ Life
199
+ Light
200
+ Limousine
201
+ Line
202
+ Link
203
+ Lion
204
+ Litter
205
+ Loch Ness
206
+ Lock
207
+ Log
208
+ London
209
+ Luck
210
+ Mail
211
+ Mammoth
212
+ Maple
213
+ Marble
214
+ March
215
+ Mass
216
+ Match
217
+ Mercury
218
+ Mexico
219
+ Microscope
220
+ Millionaire
221
+ Mine
222
+ Mint
223
+ Missile
224
+ Model
225
+ Mole
226
+ Moon
227
+ Moscow
228
+ Mount
229
+ Mouse
230
+ Mouth
231
+ Mug
232
+ Nail
233
+ Needle
234
+ Net
235
+ New York
236
+ Night
237
+ Ninja
238
+ Note
239
+ Novel
240
+ Nurse
241
+ Nut
242
+ Octopus
243
+ Oil
244
+ Olive
245
+ Olympus
246
+ Opera
247
+ Orange
248
+ Organ
249
+ Pain
250
+ Palm
251
+ Pan
252
+ Pants
253
+ Paper
254
+ Parachute
255
+ Park
256
+ Part
257
+ Pass
258
+ Paste
259
+ Penguin
260
+ Phoenix
261
+ Piano
262
+ Pie
263
+ Pilot
264
+ Pin
265
+ Pipe
266
+ Pirate
267
+ Pistol
268
+ Pit
269
+ Pitch
270
+ Plane
271
+ Plastic
272
+ Plate
273
+ Platypus
274
+ Play
275
+ Plot
276
+ Point
277
+ Poison
278
+ Pole
279
+ Police
280
+ Pool
281
+ Port
282
+ Post
283
+ Pound
284
+ Press
285
+ Princess
286
+ Pumpkin
287
+ Pupil
288
+ Pyramid
289
+ Queen
290
+ Rabbit
291
+ Racket
292
+ Ray
293
+ Revolution
294
+ Ring
295
+ Robin
296
+ Robot
297
+ Rock
298
+ Rome
299
+ Root
300
+ Rose
301
+ Roulette
302
+ Round
303
+ Row
304
+ Ruler
305
+ Satellite
306
+ Saturn
307
+ Scale
308
+ School
309
+ Scientist
310
+ Scorpion
311
+ Screen
312
+ Scuba Diver
313
+ Seal
314
+ Server
315
+ Shadow
316
+ Shakespeare
317
+ Shark
318
+ Ship
319
+ Shoe
320
+ Shop
321
+ Shot
322
+ Sink
323
+ Skyscraper
324
+ Slip
325
+ Slug
326
+ Smuggler
327
+ Snow
328
+ Snowman
329
+ Sock
330
+ Soldier
331
+ Soul
332
+ Sound
333
+ Space
334
+ Spell
335
+ Spider
336
+ Spike
337
+ Spine
338
+ Spot
339
+ Spring
340
+ Spy
341
+ Square
342
+ Stadium
343
+ Staff
344
+ Star
345
+ State
346
+ Stick
347
+ Stock
348
+ Straw
349
+ Stream
350
+ Strike
351
+ String
352
+ Sub
353
+ Suit
354
+ Superhero
355
+ Swing
356
+ Switch
357
+ Table
358
+ Tablet
359
+ Tag
360
+ Tail
361
+ Tap
362
+ Teacher
363
+ Telescope
364
+ Temple
365
+ Theater
366
+ Thief
367
+ Thumb
368
+ Tick
369
+ Tie
370
+ Time
371
+ Tokyo
372
+ Tooth
373
+ Torch
374
+ Tower
375
+ Track
376
+ Train
377
+ Triangle
378
+ Trip
379
+ Trunk
380
+ Tube
381
+ Turkey
382
+ Undertaker
383
+ Unicorn
384
+ Vacuum
385
+ Van
386
+ Vet
387
+ Wake
388
+ Wall
389
+ War
390
+ Washer
391
+ Washington
392
+ Watch
393
+ Water
394
+ Wave
395
+ Web
396
+ Well
397
+ Whale
398
+ Whip
399
+ Wind
400
+ Witch
401
+ Worm
402
+ Yard
403
+ Beard
404
+ Pleasure
405
+ Orgasm
406
+ Volcano
407
+ Eruption
408
+ Burger
409
+ Hot Dog
410
+ Pizza
411
+ Sausage
412
+ Hands
413
+ Cherry
414
+ Tomato
415
+ Pickle
416
+ Sexy
417
+ Cave
418
+ Volley
419
+ Mold
420
+ Mould
codenames/dictionary.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ WORDS_DIR = Path(__file__).parent / "data"
7
+ WORDS_FILE = WORDS_DIR / "codenames_dict.txt"
8
+
9
+
10
+ def load_dictionary() -> List[str]:
11
+ if not WORDS_FILE.exists():
12
+ raise FileNotFoundError(f"Dictionary file not found at {WORDS_FILE}")
13
+ words: List[str] = []
14
+ with open(WORDS_FILE, "r", encoding="utf-8") as f:
15
+ for line in f:
16
+ w = line.strip()
17
+ if w:
18
+ words.append(w)
19
+ if len(words) < 100:
20
+ raise ValueError("Dictionary seems too small.")
21
+ return words
codenames/embedder.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Dict, Iterable, List, Optional, Sequence
6
+
7
+ import numpy as np
8
+ from sentence_transformers import SentenceTransformer
9
+
10
+ from .dictionary import WORDS_DIR
11
+
12
+
13
+ @dataclass
14
+ class EmbeddingCache:
15
+ words: List[str]
16
+ vectors: np.ndarray # shape (N, D), normalized
17
+
18
+
19
+ def _cache_path(model_name: str) -> Path:
20
+ safe = model_name.replace("/", "_")
21
+ return WORDS_DIR / f"embeddings_{safe}.npz"
22
+
23
+
24
+ def load_or_build_embeddings(
25
+ words: Sequence[str],
26
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
27
+ rebuild: bool = False,
28
+ batch_size: int = 64,
29
+ ) -> EmbeddingCache:
30
+ WORDS_DIR.mkdir(parents=True, exist_ok=True)
31
+ cache_file = _cache_path(model_name)
32
+ lower_words = [w.lower() for w in words]
33
+
34
+ if cache_file.exists() and not rebuild:
35
+ data = np.load(cache_file, allow_pickle=False)
36
+ cached_words = data["words"].tolist()
37
+ vectors = data["vectors"].astype(np.float32)
38
+ norms = np.linalg.norm(vectors, axis=1, keepdims=True)
39
+ norms = np.where(norms == 0, 1.0, norms)
40
+ vectors = vectors / norms
41
+ if cached_words == lower_words:
42
+ print(f"✓ Loaded cached embeddings for {len(lower_words)} words (model: {model_name})")
43
+ return EmbeddingCache(words=lower_words, vectors=vectors)
44
+ else:
45
+ print(f"⚠ Cache mismatch: expected {len(lower_words)} words, found {len(cached_words)}. Rebuilding...")
46
+
47
+ print(f"Building embeddings for {len(lower_words)} words with {model_name}...")
48
+ model = SentenceTransformer(model_name)
49
+ vectors = model.encode(
50
+ lower_words,
51
+ batch_size=batch_size,
52
+ normalize_embeddings=True,
53
+ convert_to_numpy=True,
54
+ ).astype(np.float32)
55
+ np.savez(cache_file, words=np.array(lower_words), vectors=vectors)
56
+ print(f"✓ Cache saved to {cache_file.name}")
57
+ return EmbeddingCache(words=lower_words, vectors=vectors)
58
+
59
+
60
+ class EmbeddingIndex:
61
+ def __init__(
62
+ self,
63
+ words: Sequence[str],
64
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
65
+ rebuild: bool = False,
66
+ ) -> None:
67
+ self.cache = load_or_build_embeddings(words, model_name=model_name, rebuild=rebuild)
68
+ self.lookup: Dict[str, int] = {w: i for i, w in enumerate(self.cache.words)}
69
+
70
+ def vector(self, word: str) -> Optional[np.ndarray]:
71
+ idx = self.lookup.get(word.lower())
72
+ if idx is None:
73
+ return None
74
+ return self.cache.vectors[idx]
75
+
76
+ def vectors_for(self, words: Iterable[str]) -> List[np.ndarray]:
77
+ out: List[np.ndarray] = []
78
+ for w in words:
79
+ v = self.vector(w)
80
+ if v is not None:
81
+ out.append(v)
82
+ return out
83
+
84
+ @property
85
+ def all_words(self) -> List[str]:
86
+ return list(self.cache.words)
codenames/master.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable, List, Optional, Sequence, Tuple
5
+
6
+ import numpy as np
7
+
8
+ from .embedder import EmbeddingIndex
9
+ from .challenge import ChallengeGame
10
+ from .models import Role
11
+
12
+
13
+ @dataclass
14
+ class ClueSuggestion:
15
+ clue: str
16
+ number: int
17
+ score: float
18
+ supports: List[Tuple[str, float]]
19
+ blockers: List[Tuple[str, float]]
20
+
21
+
22
+ class MasterPlayer:
23
+ def __init__(
24
+ self,
25
+ dictionary: Sequence[str],
26
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
27
+ good_top_k: int = 3,
28
+ good_threshold: float = 0.32,
29
+ bad_weight: float = 1.2,
30
+ assassin_weight: float = 3.0,
31
+ ) -> None:
32
+ self.index = EmbeddingIndex(dictionary, model_name=model_name)
33
+ self.dictionary = [w.lower() for w in dictionary]
34
+ self.good_top_k = good_top_k
35
+ self.good_threshold = good_threshold
36
+ self.bad_weight = bad_weight
37
+ self.assassin_weight = assassin_weight
38
+
39
+ def suggest_clue(self, game: ChallengeGame) -> Optional[ClueSuggestion]:
40
+ good_words = [
41
+ c.word.lower()
42
+ for c in game.board.cards
43
+ if not c.revealed and c.role == Role.RED
44
+ ]
45
+ bad_words = [
46
+ c.word.lower()
47
+ for c in game.board.cards
48
+ if not c.revealed and (c.role == Role.BLUE or c.role == Role.ASSASSIN)
49
+ ]
50
+ assassin_words = [c.word.lower() for c in game.board.cards if c.role == Role.ASSASSIN and not c.revealed]
51
+
52
+ board_words = {c.word.lower() for c in game.board.cards}
53
+
54
+ good_vecs = self._vectors_for(good_words)
55
+ bad_vecs = self._vectors_for(bad_words)
56
+ assassin_vecs = self._vectors_for(assassin_words)
57
+
58
+ if not good_vecs:
59
+ return None
60
+
61
+ good_matrix = np.stack(good_vecs)
62
+ bad_matrix = np.stack(bad_vecs) if bad_vecs else None
63
+ assassin_matrix = np.stack(assassin_vecs) if assassin_vecs else None
64
+
65
+ best: Optional[ClueSuggestion] = None
66
+ for clue in self.dictionary:
67
+ if clue in board_words:
68
+ continue
69
+ vec = self.index.vector(clue)
70
+ if vec is None:
71
+ continue
72
+ norm = np.linalg.norm(vec)
73
+ if norm == 0 or not np.isfinite(norm):
74
+ continue
75
+ vec = vec / norm
76
+
77
+ with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
78
+ good_sims = good_matrix @ vec
79
+ sorted_good_idx = np.argsort(-good_sims)
80
+ top_idx = sorted_good_idx[: self.good_top_k]
81
+ top_pairs = [(good_words[i], float(good_sims[i])) for i in top_idx]
82
+ good_score = float(np.sum([good_sims[i] for i in top_idx]))
83
+
84
+ bad_max = 0.0
85
+ bad_pairs: List[Tuple[str, float]] = []
86
+ if bad_matrix is not None:
87
+ bad_sims = bad_matrix @ vec
88
+ max_idx = int(np.argmax(bad_sims)) if bad_sims.size else 0
89
+ bad_max = float(bad_sims[max_idx]) if bad_sims.size else 0.0
90
+ bad_pairs = [(bad_words[max_idx], float(bad_max))]
91
+
92
+ assassin_max = 0.0
93
+ if assassin_matrix is not None:
94
+ ass_sims = assassin_matrix @ vec
95
+ assassin_max = float(np.max(ass_sims)) if ass_sims.size else 0.0
96
+
97
+ score = good_score - self.bad_weight * bad_max - self.assassin_weight * assassin_max
98
+
99
+ guessable = sum(1 for s in good_sims if s >= self.good_threshold)
100
+ number = max(1, min(self.good_top_k, guessable))
101
+
102
+ suggestion = ClueSuggestion(
103
+ clue=clue,
104
+ number=number,
105
+ score=score,
106
+ supports=top_pairs,
107
+ blockers=bad_pairs,
108
+ )
109
+ if best is None or suggestion.score > best.score:
110
+ best = suggestion
111
+ return best
112
+
113
+ def _vectors_for(self, words: Iterable[str]) -> List[np.ndarray]:
114
+ out: List[np.ndarray] = []
115
+ for w in words:
116
+ v = self.index.vector(w)
117
+ if v is not None:
118
+ out.append(v)
119
+ return out
codenames/models.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from dataclasses import dataclass
3
+
4
+
5
+ class Role(Enum):
6
+ RED = "RED"
7
+ BLUE = "BLUE"
8
+ ASSASSIN = "ASSASSIN"
9
+
10
+
11
+ @dataclass
12
+ class Card:
13
+ word: str
14
+ role: Role
15
+ revealed: bool = False
my_guesser.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from codenames.embedder import EmbeddingIndex
6
+ from codenames.dictionary import load_dictionary
7
+
8
+ _dictionary = load_dictionary()
9
+ _index = EmbeddingIndex(_dictionary)
10
+
11
+
12
+ def guesser(clue: str, board_state: list[str]) -> Optional[str]:
13
+ """
14
+ Given a clue word and list of unrevealed board words,
15
+ return a word.
16
+ """
17
+ return board_state[0]
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ wordfreq>=3.0
2
+ numpy>=1.26
3
+ sentence-transformers>=3.0
4
+ colorama>=0.4.6