shmulc's picture
deploy via make deploy
852d295 verified
Raw
History Blame Contribute Delete
46.6 kB
"""Hebrew Codenames as a probe for cross-model semantic alignment.
The game is a measurement instrument: a one-word clue transduces a *target set*
into a *guess*. We put two semantic systems on that channel โ€”
- modern Hebrew ENCODERS (the geometry: cosine over word embeddings), and
- a Hebrew LLM (the intent: clue-giving / guessing in natural language) โ€”
and read off where their notions of "what this clue points at" agree and diverge.
NOTE on what this measures (Koyyalagunta et al. 2021, critiquing Kim et al. 2019):
agreement between a clue-giver and a guesser that share an embedding is trivially
high; cross-system agreement measures *cooperation / alignment*, NOT clue quality.
So our headline number is an alignment score โ€” with the LLM standing in as the
"human-like intent" reference (Kumar et al. 2021: distributional cosine
systematically under-predicts human word association). The divergences are the finding.
Two directions:
LLM -> Encoder : LLM gives a clue + names its targets; does the encoder's
nearest-neighbour guess recover them? (intent recovery)
Encoder -> LLM : encoder picks the best-scoring clue; does the LLM rank its
intended targets on top? (geometry legibility)
Headline scalar: per clue, Spearman rho between the encoder's cosine ordering of
the 25 board words and the LLM's ordering, averaged over rounds.
"""
from __future__ import annotations
import json
import os
import random
import re
from dataclasses import dataclass, field
import numpy as np
from . import DATA_DIR, morph
from .deck_he import DECK
DATA = DATA_DIR
# --------------------------------------------------------------------------- #
# The bench
# --------------------------------------------------------------------------- #
ENCODERS = {
# Static subword vectors โ€” the literature-recommended baseline for Hebrew
# (morphology/OOV); often competitive with contextual encoders for bare-word
# association. Handles OOV via subwords.
"fasttext": dict(kind="fasttext", path=os.path.join(DATA, "cc.he.300.fp16.bin")),
# Concatenated L2-normalized blend of fastText and ConceptNet Numberbatch.
"blend_0.5_0.5": dict(kind="blend", w_ft=0.5, w_nb=0.5),
"blend_0.7_0.3": dict(kind="blend", w_ft=0.7, w_nb=0.3),
# Hebrew-native, newest Dicta encoder (needs transformers<5).
"neodictabert": dict(kind="st", model_id="dicta-il/neodictabert-bilingual-embed"),
# 2025 multilingual SOTA-small.
"embeddinggemma": dict(kind="st", model_id="google/embeddinggemma-300m"),
"qwen3-embed": dict(kind="st", model_id="Qwen/Qwen3-Embedding-0.6B"),
}
# DictaLM 3.0 (2026-05) via MLX. Swap to the 12B for the quality run.
LLM_FAST = "ssdataanalysis/DictaLM-3.0-1.7B-Instruct-mlx-8Bit"
LLM_BIG = "ssdataanalysis/DictaLM-3.0-Nemotron-12B-Instruct-mlx-8Bit"
# Standard Codenames split: 25 words, 9 / 8 / 7 / 1.
N_BOARD, N_MY, N_OPP, N_NEUTRAL, N_ASSASSIN = 25, 9, 8, 7, 1
# --------------------------------------------------------------------------- #
# Encoders
# --------------------------------------------------------------------------- #
def _device():
import torch
if torch.backends.mps.is_available():
return "mps"
if torch.cuda.is_available():
return "cuda"
return "cpu"
class Encoder:
"""Embeds bare Hebrew words to L2-normalised vectors (cosine == dot).
Loads via sentence-transformers when possible; otherwise a raw AutoModel
with mean pooling over the last hidden state.
"""
def __init__(self, model_id: str):
self.model_id = model_id
self._st = None
self._tok = self._model = None
dev = _device()
try:
from sentence_transformers import SentenceTransformer
self._st = SentenceTransformer(model_id, device=dev, trust_remote_code=True)
except Exception:
from transformers import AutoModel, AutoTokenizer
self._tok = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
self._model = AutoModel.from_pretrained(model_id, trust_remote_code=True).to(dev).eval()
self._dev = dev
def embed(self, words) -> np.ndarray:
words = list(words)
if self._st is not None:
V = self._st.encode(
words, normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=False
)
return np.nan_to_num(V, nan=0.0, posinf=0.0, neginf=0.0)
import torch
out = []
with torch.no_grad():
for i in range(0, len(words), 64):
batch = words[i : i + 64]
enc = self._tok(batch, padding=True, truncation=True, return_tensors="pt").to(
self._dev
)
hs = self._model(**enc).last_hidden_state
mask = enc["attention_mask"].unsqueeze(-1).float()
mean = (hs * mask).sum(1) / mask.sum(1).clamp(min=1e-9)
mean = torch.nn.functional.normalize(mean, p=2, dim=1)
out.append(mean.cpu().numpy())
return np.nan_to_num(np.vstack(out), nan=0.0, posinf=0.0, neginf=0.0)
class FastTextEncoder:
"""Static fastText subword vectors (OOV-safe). L2-normalised."""
def __init__(self, path: str):
import fasttext
self.model_id = os.path.basename(path)
self._m = fasttext.load_model(path)
def embed(self, words) -> np.ndarray:
words = list(words)
if not words:
return np.zeros((0, self._m.get_dimension()), np.float32)
V = np.stack([self._m.get_word_vector(w) for w in words]).astype(np.float32)
V /= np.linalg.norm(V, axis=1, keepdims=True) + 1e-9
return V
class CompressedFastTextEncoder:
"""A compress-fasttext model (pruned vocab/ngrams + fp16). Same geometry as the full
cc.he.300.bin (validated loss-free) at ~20x smaller โ€” keeps subword OOV. L2-normalised."""
def __init__(self, path: str):
import compress_fasttext
self.model_id = os.path.basename(path)
self._m = compress_fasttext.models.CompressedFastTextKeyedVectors.load(path)
def embed(self, words) -> np.ndarray:
words = list(words)
if not words:
return np.zeros((0, self._m.vector_size), np.float32)
V = np.stack([self._m[w] for w in words]).astype(np.float32)
V /= np.linalg.norm(V, axis=1, keepdims=True) + 1e-9
return V
def make_encoder(key: str):
if key == "numberbatch" or key.startswith("blend_"):
from .exp_encoders import make_exp_encoder
return make_exp_encoder(key)
cfg = ENCODERS[key]
if cfg["kind"] == "fasttext":
# The compressed fp16 model (geometrically loss-free vs the full cc.he.300.bin, ~250 MB
# incl. its .vectors.npy sidecar) is the standard model everywhere โ€” the full 7 GB model
# is not required. FASTTEXT_COMPRESSED can point at a different compressed model; a full
# .bin can still be loaded explicitly via cfg["path"] + FastTextEncoder if one is present.
comp = os.environ.get("FASTTEXT_COMPRESSED", cfg["path"])
if comp.endswith(".bin") and "fp16" not in comp and os.path.exists(comp):
return FastTextEncoder(comp)
return CompressedFastTextEncoder(comp)
return Encoder(cfg["model_id"])
# --------------------------------------------------------------------------- #
# Clue vocabulary (large, frequency-filtered โ€” clues are NOT drawn from the deck)
# --------------------------------------------------------------------------- #
_HEB_LETTERS = re.compile(r"[ื-ืช]+$") # letters incl. final forms, no niqqud/punct
def load_clue_vocab(n: int = 12000, min_len: int = 2, max_len: int = 12, path: str | None = None):
"""Top-n Hebrew words from a frequency list (FrequencyWords `word count` format).
The only filters are validity, not tuning: pure Hebrew letters (no digits/punct)
and a sane length. No stopword list / frequency-band skip โ€” broadly-similar common
words are suppressed by the per-clue mean-centering in `encoder_spymaster`, not by
hand-maintained lists."""
path = path or os.path.join(DATA, "he_freq_50k.txt")
out, seen = [], set()
with open(path, encoding="utf-8") as f:
for line in f:
w = line.split(" ")[0].strip()
if w in seen or not (min_len <= len(w) <= max_len) or not _HEB_LETTERS.match(w):
continue
seen.add(w)
out.append(w)
if len(out) >= n:
break
return out
def load_clue_vocab_content(n: int = 1500, source_n: int = 6000, cache: str | None = None):
"""A clue vocabulary of **content-word lemmas**: take the frequency list, keep only
content POS (noun/adj/verb/proper โ€” drops prepositions, pronouns, conjunctions, adverbs
via DictaBERT-morph), reduce each to its lemma and de-duplicate (so ื‘ืชื™/ื‘ื‘ื™ืช/ื‘ื™ืช collapse
to ื‘ื™ืช). Principled clue-quality filter โ€” no stopword list. Cached to disk (computed once)."""
cache = cache or os.path.join(DATA, f"clue_vocab_content_{n}.json")
if os.path.exists(cache):
with open(cache, encoding="utf-8") as f:
return json.load(f)
raw = load_clue_vocab(source_n, min_len=2)
parts = morph.pos(raw)
lems = morph.lemmas(raw)
out, seen = [], set()
for _w, p, lem in zip(raw, parts, lems, strict=False):
if p not in morph.CONTENT_POS or not _HEB_LETTERS.match(lem) or len(lem) < 2:
continue
if lem in seen:
continue
seen.add(lem)
out.append(lem) # the lemma is the clue word
if len(out) >= n:
break
with open(cache, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False)
return out
# --------------------------------------------------------------------------- #
# DETECT-style frequency (Koyyalagunta et al. 2021): a clue should be a *mid*-frequency
# word โ€” neither obscure (rare โ†’ bad clue) nor over-common (generic / conversational โ†’
# bad clue). We apply it twice: at vocab-build time (keep only the mid band) and as a
# soft term in the scoring function. Replaces the old "take the most frequent content
# words" pool, whose top is dominated by dialogue verbs (ืจื•ืฆื” / ื™ื•ื“ืข / ื—ื•ืฉื‘).
# --------------------------------------------------------------------------- #
_FREQ: dict[str, int] | None = None
def load_freqs(path: str | None = None) -> dict[str, int]:
"""Surface-form -> corpus count from the frequency list (loaded once)."""
global _FREQ
if _FREQ is None:
path = path or os.path.join(DATA, "he_freq_50k.txt")
d: dict[str, int] = {}
with open(path, encoding="utf-8") as f:
for line in f:
p = line.split()
if len(p) >= 2 and _HEB_LETTERS.match(p[0]):
d.setdefault(p[0], int(p[1]))
_FREQ = d
return _FREQ
def content_lemma_master(source_n: int = 14000, cache: str | None = None):
"""All content-word lemmas within the top `source_n` of the frequency list, each as
[lemma, count, pos], sorted by count desc. The DictaBERT POS+lemma pass runs once and
is cached; clue-vocab bands (by frequency and/or POS) are sliced from this cheaply."""
cache = cache or os.path.join(DATA, f"content_master_v2_{source_n}.json")
if os.path.exists(cache):
return json.load(open(cache, encoding="utf-8"))
raw, cnt = [], {}
with open(os.path.join(DATA, "he_freq_50k.txt"), encoding="utf-8") as f:
for line in f:
p = line.split()
if len(p) >= 2 and _HEB_LETTERS.match(p[0]) and 2 <= len(p[0]) <= 12:
if p[0] not in cnt:
raw.append(p[0])
cnt[p[0]] = int(p[1])
if len(raw) >= source_n:
break
parts = morph.pos(raw)
lems = morph.lemmas(raw)
best: dict[str, tuple[int, str]] = {}
for w, p, lem in zip(raw, parts, lems, strict=False):
if p not in morph.CONTENT_POS or not _HEB_LETTERS.match(lem) or len(lem) < 2:
continue
c = cnt[w]
if c > best.get(lem, (0, ""))[0]:
best[lem] = (c, p)
data = sorted(([lem, c, p] for lem, (c, p) in best.items()), key=lambda r: -r[1])
json.dump(data, open(cache, "w", encoding="utf-8"), ensure_ascii=False)
return data
_BLOCKLIST: set[str] | None = None
def load_blocklist() -> set[str]:
global _BLOCKLIST
if _BLOCKLIST is None:
block = set()
path = os.path.join(DATA, "blocklist_he.txt")
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
for line in f:
w = line.strip()
if w and not w.startswith("#"):
block.add(w)
_BLOCKLIST = block
return _BLOCKLIST
def is_malformed(word: str) -> bool:
# 1. Reject words with 3 or more of the same character in a row
for i in range(len(word) - 2):
if word[i] == word[i + 1] == word[i + 2]:
return True
# 2. Reject words with final letters (ืš, ื, ืŸ, ืฃ, ืฅ) in middle positions (non-final)
finals = set("ืšืืŸืฃืฅ")
for i in range(len(word) - 1):
if word[i] in finals:
return True
# 3. Reject words with non-final letters (ื›, ืž, ื , ืค, ืฆ) at the end of the word
non_finals = set("ื›ืžื ืคืฆ")
if word[-1] in non_finals:
return True
return False
def clue_vocab_band(
n: int = 1800,
lo: int = 200,
hi: int = 60000,
pos: set[str] | None = None,
source_n: int = 14000,
min_len: int = 3,
mode: str | None = None,
filter_malformed: bool = True,
filter_blocklist: bool = True,
):
"""Clue vocab from a frequency BAND of content lemmas: drop over-common conversational
words (count > hi) and obscure words (count < lo). `pos` optionally restricts the part
of speech (e.g. {'NOUN','ADJ'} โ€” nouns/adjectives make cleaner clues than verbs and
avoid the subtitle proper-name noise). `min_len` drops 1โ€“2 letter tokens, which in the
frequency list are mostly fragments / mislabeled function words (ืขื•, ืชืจ, ืžื”) rather than
real clue words. Returns (words, counts)."""
if mode is not None:
source_n = 30000
if mode == "conservative":
pos = {"NOUN", "ADJ"}
lo, hi = 1000, 80000
elif mode == "broad":
pos = {"NOUN", "ADJ", "PROPN"}
lo, hi = 300, 100000
elif mode == "experimental":
pos = {"NOUN", "ADJ", "PROPN", "VERB"}
lo, hi = 100, 150000
else:
raise ValueError(f"Unknown vocabulary mode: {mode}")
data = content_lemma_master(source_n)
block = load_blocklist() if filter_blocklist else set()
band = []
for w, c, p in data:
if not (lo <= c <= hi):
continue
if len(w) < min_len:
continue
if pos is not None and p not in pos:
continue
if filter_blocklist and w in block:
continue
if filter_malformed and is_malformed(w):
continue
band.append((w, c))
if len(band) >= n:
break
return [w for w, _ in band], np.array([c for _, c in band], dtype=np.float32)
def freq_scores(counts, lo: float = 200.0, hi: float = 60000.0, margin: float = 2.0) -> np.ndarray:
"""DETECT-FREQ preference in [0,1]: ~1 inside the mid-frequency band [lo, hi], with a
soft log-linear decay over `margin` log-units for words that are too rare or too common.
`counts` is an array of corpus counts aligned to a clue vocabulary."""
c = np.asarray(counts, dtype=np.float64)
x = np.log(np.clip(c, 1.0, None))
lo_l, hi_l = np.log(lo), np.log(hi)
below = np.clip(1.0 - (lo_l - x) / margin, 0.0, 1.0)
above = np.clip(1.0 - (x - hi_l) / margin, 0.0, 1.0)
s = np.where(x < lo_l, below, np.where(x > hi_l, above, 1.0))
return np.where(c <= 0, 0.0, s).astype(np.float32)
# The assassin costs the game outright, an opponent word only a turn, so the listener-danger
# term weighs probability mass on the assassin more than on a rival word.
_SOFT_ASSASSIN_WEIGHT = 2.0
def _board_softmax(sim: np.ndarray, tau: float) -> np.ndarray:
"""Row-wise softmax of board similarities: L0(word | clue), a literal-listener
distribution over the 25 board words for each candidate clue (rows). `tau` is the
temperature (lower = peakier). Invariant to the per-clue mean-centring of `sim`, since a
per-row shift cancels in the softmax."""
z = sim / max(tau, 1e-6)
z = z - z.max(1, keepdims=True)
e = np.exp(z)
return e / e.sum(1, keepdims=True)
def _listener_danger(
adj: np.ndarray, is_as: np.ndarray, is_opp: np.ndarray, tau: float
) -> np.ndarray:
"""Probability mass a literal listener puts on danger words, per candidate clue: the
softmax share landing on the assassin (weighted) plus the share on opponent words. Unlike
the hinge penalties (which read absolute centred similarity), this is scale-invariant and
accounts for board competition โ€” a clue near the assassin is safe if team/neutral words
draw more of the listener's mass, and risky if they don't."""
L0 = _board_softmax(adj, tau)
as_mass = L0[:, is_as].sum(1) if is_as.any() else 0.0
opp_mass = L0[:, is_opp].sum(1) if is_opp.any() else 0.0
return _SOFT_ASSASSIN_WEIGHT * as_mass + opp_mass
# --------------------------------------------------------------------------- #
# Board
# --------------------------------------------------------------------------- #
@dataclass
class Board:
words: list[str]
role: dict[str, str] # word -> my | opp | neutral | assassin
def of(self, r: str) -> list[str]:
return [w for w in self.words if self.role[w] == r]
@property
def my(self):
return self.of("my")
@property
def assassin(self):
a = self.of("assassin")
return a[0] if a else "" # tolerate a board the user marked without an assassin
@property
def avoid(self):
return [w for w in self.words if self.role[w] != "my"]
def sample_board(rng: random.Random) -> Board:
words = rng.sample(DECK, N_BOARD)
roles = ["my"] * N_MY + ["opp"] * N_OPP + ["neutral"] * N_NEUTRAL + ["assassin"] * N_ASSASSIN
rng.shuffle(roles)
return Board(words=words, role=dict(zip(words, roles, strict=False)))
# --------------------------------------------------------------------------- #
# Encoder spymaster + ranking
# --------------------------------------------------------------------------- #
def encoder_rank(enc, board: Board, clue: str):
"""Rank all board words by cosine to the clue. Returns (ordered_words, sims_dict)."""
W = enc.embed(board.words)
c = enc.embed([clue])[0]
sims = W @ c
order = np.argsort(-sims)
return [board.words[i] for i in order], {
board.words[i]: float(sims[i]) for i in range(len(board.words))
}
def cohesion_keep(enc, words, floor: float = 0.24, pin=frozenset(), mode: str = "any"):
"""Greedy intra-cluster cohesion filter. Keep the head (strongest) word, then keep each
later word only if it coheres (cosine >= floor) with the already-kept set โ€” or is pinned.
Enforces that a clue names a *cluster*: every counted word must cohere with the others,
not merely with the clue. Catches a passenger like radioโ†’milk that the clueโ†”word
similarity alone lets through (milk is close-ish to 'radio' but far from voice/journalist).
`words` must be in similarity order (strongest first).
`mode` sets what "coheres with the kept set" means:
"any" โ€” link to *any* kept word (handles transitive aโ†’bโ†’c chains, but a noise pair can
attach to each other via one borderline link, e.g. foodโ†’{beauty,freedom}),
"head" โ€” link to the *head* (strongest) word (kills noise sub-clusters, but can drop a
legitimate chain tail that relates to a sibling more than to the head)."""
if len(words) <= 1:
return list(words)
V = enc.embed(list(words))
V = V / (np.linalg.norm(V, axis=1, keepdims=True) + 1e-9)
S = V @ V.T
kept = [0]
for i in range(1, len(words)):
link = S[i, 0] if mode == "head" else max(S[i, j] for j in kept)
if words[i] in pin or link >= floor:
kept.append(i)
return [words[i] for i in kept]
def served_count(
read,
keep_rel: float = 0.66,
pin=frozenset(),
enc=None,
cohesion_floor: float | None = None,
cohesion_mode: str = "any",
cliff: float = 0.5,
):
"""The words a clue should *claim* and light up, from a board reading.
`read` = list of {word, role, sim} ordered by sim desc (an encoder's reading of the clue).
Two stages:
1. Walk the *safe run* (team words reached before any enemy word) and keep each next word
while it stays strong: above `keep_rel`ร— the top target AND no sharp cliff (< cliffร— the
previous kept word). A pinned word is always kept. This adapts the count to how many
words are genuinely clustered โ€” a tight trio stays 3, "1 strong + noise tail" shrinks.
2. Cohesion trim (when `enc` + `cohesion_floor` given): drop any kept word that doesn't
cohere with the rest of the cluster (see `cohesion_keep`).
Returns the kept word list (the served `intended`)."""
safe = []
for r in read:
if r["role"] == "my":
safe.append(r["word"])
else:
break
if not safe:
return []
simmap = {r["word"]: r["sim"] for r in read}
top = simmap[safe[0]]
kept = [safe[0]]
prev = top
for w in safe[1:]:
s = simmap[w]
if w in pin:
kept.append(w)
prev = s
continue
if s < top * keep_rel or s < prev * cliff:
break
kept.append(w)
prev = s
if enc is not None and cohesion_floor is not None and len(kept) > 1:
kept = cohesion_keep(enc, kept, floor=cohesion_floor, pin=pin, mode=cohesion_mode)
return kept
@dataclass
class Clue:
word: str
count: int
intended: list[str]
margin: float # the scoring-function value g(c, I)
assassin_sim: float = field(default=float("nan"))
reason: str = "" # one-line rationale (hybrid / LLM picks)
def encoder_spymaster(
enc,
board: Board,
clue_vocab,
clue_emb=None,
vocab_lemmas=None,
lam_opp: float = 1.0,
lam_neu: float = 0.3,
lam_a: float = 2.0,
lam_f: float = 0.0,
vocab_freq=None,
m: int = 2,
lam_soft: float = 0.0,
soft_tau: float = 0.1,
) -> Clue:
"""Pick the clue maximising a tiered Codenames scoring function:
g(c) = sum_{top-m team} s'(c,b)
- lam_a * max(0, s'(c, assassin)) # the black card โ€” avoid hardest
- lam_opp * max(0, max_opp s'(c,r)) # rival team โ€” avoid strongly
- lam_neu * max(0, max_neut s'(c,r)) # bystanders โ€” avoid mildly
+ lam_f * FREQ(c) # DETECT-FREQ: prefer mid-frequency
- lam_soft * P_danger(c) # listener mass on assassin+opp words
where s'(c,w) = cos(c,w) - mean_b cos(c,b) is the similarity centred per clue over
the 25 board words (anisotropy / DETECT-style correction so broadly-similar common
words don't win). P_danger(c) is the softmax share (temperature `soft_tau`) a literal
listener puts on danger words โ€” a board-competition-aware complement to the absolute
hinge penalties (see `_listener_danger`); set lam_soft=0 to disable. Clues come from
`clue_vocab`, never the board (no shared surface form). Pass precomputed `clue_emb`
(aligned with clue_vocab) to skip re-embedding, and `vocab_freq` (FREQ scores in [0,1]
aligned with clue_vocab) to enable the FREQ term.
"""
bw, B, cand, keep, C = _legal_candidates(enc, board, clue_vocab, clue_emb, vocab_lemmas)
adj = C @ B.T # (V, 25) cosine to every board word
adj = adj - adj.mean(1, keepdims=True) # centre per clue over the board
roles = np.array([board.role[w] for w in bw])
is_my, is_opp = roles == "my", roles == "opp"
is_neu, is_as = roles == "neutral", roles == "assassin"
def tier_max(mask):
return np.clip(adj[:, mask].max(1), 0, None) if mask.any() else np.zeros(len(cand))
adj_my = adj[:, is_my]
m = min(m, adj_my.shape[1])
sorted_my = np.sort(adj_my, axis=1)[:, ::-1]
if m >= 2:
top_my = sorted_my[:, :m].mean(1) + 1.0 * sorted_my[:, m - 1]
elif m == 1:
top_my = sorted_my[:, 0]
else:
top_my = np.full(len(cand), -99.0, dtype=np.float32)
g = top_my - lam_a * tier_max(is_as) - lam_opp * tier_max(is_opp) - lam_neu * tier_max(is_neu)
if vocab_freq is not None and lam_f:
g = g + lam_f * np.asarray(vocab_freq, dtype=np.float32)[keep]
if lam_soft:
g = g - lam_soft * _listener_danger(adj, is_as, is_opp, soft_tau)
bi = int(np.nanargmax(g))
my_words = [w for w, mm in zip(bw, is_my, strict=False) if mm]
order = np.argsort(-adj_my[bi])[:m]
return Clue(
word=cand[bi],
count=m,
intended=[my_words[j] for j in order],
margin=float(g[bi]),
assassin_sim=float(adj[bi, is_as][0]) if is_as.any() else float("nan"),
)
def encoder_clue_candidates(
enc,
board: Board,
clue_vocab,
clue_emb=None,
vocab_lemmas=None,
n: int = 10,
targets: list[str] | None = None,
lam_opp: float = 1.0,
lam_neu: float = 0.3,
lam_a: float = 2.0,
lam_f: float = 0.0,
vocab_freq=None,
m: int = 2,
safe_margin: float = 0.0,
lam_soft: float = 0.0,
soft_tau: float = 0.1,
lam_div: float = 0.0,
):
"""Top-n legal clue candidates, each with the team words it *safely* connects.
A team word counts toward a clue only if it is safe โ€” its mean-centred similarity to the
clue beats every enemy/neutral/assassin word by `safe_margin`. The team term is the sum of
the top-m safe words, so the score credits only words a guesser would reach before any danger
word (a stretched m-th word an opponent outranks no longer inflates it), and the returned
`intended`/`count` are exactly those safe words. Larger `safe_margin` = more conservative
(the risk dial). `targets` forces a clue for a chosen team subset (the "clue for these words"
path): there all targets are scored, and safety only informs the tiered penalties.
`lam_soft` adds a listener-competition penalty: the score drops by lam_soft times the
softmax share (temperature `soft_tau`) a literal listener would put on the assassin
(weighted) and opponent words (see `_listener_danger`). It complements the absolute hinge
penalties with a scale-invariant, whole-board view; set lam_soft=0 to disable.
`lam_div` diversifies the returned shortlist (MMR-style): when >0 and not a fixed-target
request, candidates are picked greedily by `score - lam_div * max_jaccard(intended, already
picked)`, so near-duplicate clues for the same target pair don't crowd out other legal
combinations. lam_div=0 restores the plain top-n-by-score behaviour."""
bw, B, cand, keep, C = _legal_candidates(enc, board, clue_vocab, clue_emb, vocab_lemmas)
adj = C @ B.T
adj = adj - adj.mean(1, keepdims=True)
roles = np.array([board.role[w] for w in bw])
is_my = roles == "my"
is_opp, is_neu, is_as = roles == "opp", roles == "neutral", roles == "assassin"
def tmax(mask):
return np.clip(adj[:, mask].max(1), 0, None) if mask.any() else np.zeros(len(cand))
enemy_ceiling = adj[:, ~is_my].max(1) if (~is_my).any() else np.full(len(cand), -1e9)
fixed = bool(targets)
my_words = (
[w for w in targets if w in bw]
if fixed
else [w for w, mm in zip(bw, is_my, strict=False) if mm]
)
my_cols = [bw.index(w) for w in my_words]
adj_my = adj[:, my_cols] if my_cols else np.zeros((len(cand), 0), np.float32)
safe = adj_my > (enemy_ceiling[:, None] + safe_margin) # beats every enemy word by margin
if fixed:
g_team = adj_my.sum(1) # honour the user's chosen targets
else: # mean + minimum of the top-k *safe* team words (k <= m)
safe_counts = safe.sum(1)
sorted_safe = np.sort(np.where(safe, adj_my, -9.0), 1)[:, ::-1]
g_team = np.zeros(len(cand), dtype=np.float32)
for k_val in range(1, m + 1):
mask = (safe_counts == k_val) if k_val < m else (safe_counts >= k_val)
if not mask.any():
continue
if k_val >= 2:
g_team[mask] = (
sorted_safe[mask, :k_val].mean(1) + 1.0 * sorted_safe[mask, k_val - 1]
)
else:
g_team[mask] = sorted_safe[mask, 0] - 0.5
g_team[safe_counts == 0] = -99.0
g = g_team - lam_a * tmax(is_as) - lam_opp * tmax(is_opp) - lam_neu * tmax(is_neu)
if vocab_freq is not None and lam_f:
g = g + lam_f * np.asarray(vocab_freq, dtype=np.float32)[keep]
if lam_soft:
g = g - lam_soft * _listener_danger(adj, is_as, is_opp, soft_tau)
def intended_of(bi) -> list[str]:
if fixed:
return my_words
order = [j for j in np.argsort(-adj_my[bi]) if safe[bi, j]][:m]
if not order and adj_my.shape[1]:
order = [int(np.argmax(adj_my[bi]))] # nothing clears the bar: best single word
return [my_words[j] for j in order]
ranked = list(np.argsort(-g))
if lam_div and not fixed:
# MMR: from a pool of the strongest candidates, greedily pick the n that trade off score
# against overlap with the target sets already chosen, so the shortlist spans distinct
# team-word combinations instead of collapsing onto one tight pair.
pool = ranked[: max(n * 20, 200)]
pool_sets = {bi: frozenset(intended_of(bi)) for bi in pool}
selected, chosen_sets, remaining = [], [], list(pool)
while len(selected) < n and remaining:
best_bi, best_val = remaining[0], -1e18
for bi in remaining:
s = pool_sets[bi]
ov = max((len(s & t) / len(s | t) for t in chosen_sets if (s | t)), default=0.0)
val = float(g[bi]) - lam_div * ov
if val > best_val:
best_val, best_bi = val, bi
selected.append(best_bi)
chosen_sets.append(pool_sets[best_bi])
remaining.remove(best_bi)
else:
selected = ranked[:n]
out = []
for bi in selected:
tg = intended_of(bi)
out.append({"word": cand[int(bi)], "intended": tg, "count": len(tg), "score": float(g[bi])})
return out
# --------------------------------------------------------------------------- #
# Hebrew LLM (DictaLM 3.0 via MLX)
# --------------------------------------------------------------------------- #
class HebrewLLM:
def __init__(self, model_id: str = LLM_FAST):
from mlx_lm import load
self.model_id = model_id
self.model, self.tok = load(model_id)
def chat(self, system: str, user: str, max_tokens: int = 256) -> str:
from mlx_lm import generate
msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
prompt = self.tok.apply_chat_template(msgs, add_generation_prompt=True)
try:
return generate(
self.model, self.tok, prompt=prompt, max_tokens=max_tokens, verbose=False
)
except TypeError:
return generate(self.model, self.tok, prompt, max_tokens=max_tokens, verbose=False)
_SPY_SYS = (
"ืืชื” ืจื‘ ืžืจื’ืœื™ื ื‘ืžืฉื—ืง 'ืฉื ืงื•ื“' ื‘ืขื‘ืจื™ืช. ืืชื” ืจื•ืื” ืืช ืžื™ืœื•ืช ื”ืฆื•ื•ืช ืฉืœืš, ืžื™ืœื•ืช ื”ื™ืจื™ื‘, "
"ืžื™ืœื™ื ื ื™ื˜ืจืœื™ื•ืช, ื•ืžื™ืœืช ื”ืžืชื ืงืฉ ืฉืืกื•ืจ ื‘ืฉื•ื ืื•ืคืŸ ืœืจืžื•ื– ืขืœื™ื”. ืชืŸ ืจืžื– ืฉืœ ืžื™ืœื” ืื—ืช "
"(ืœื ืื—ืช ืžื”ืžื™ืœื™ื ืขืœ ื”ืœื•ื—) ืฉืžืงืฉืจืช ื›ืžื” ืฉื™ื•ืชืจ ืžืžื™ืœื•ืช ื”ืฆื•ื•ืช ืฉืœืš, ื•ืจื—ื•ืงื” ืžื”ืฉืืจ ื•ื‘ืžื™ื•ื—ื“ ืžื”ืžืชื ืงืฉ."
)
_SPY_FMT = (
"ืขื ื” ื‘ื“ื™ื•ืง ื‘ืคื•ืจืžื˜ ื”ื–ื” ื•ื‘ืœื™ ืฉื•ื ื˜ืงืกื˜ ื ื•ืกืฃ:\n"
"ืจืžื–: <ืžื™ืœื” ืื—ืช>\n"
"ืžืกืคืจ: <ื›ืžื” ืžื™ืœื™ื>\n"
"ืžื™ืœื™ื: <ื”ืžื™ืœื™ื ืžื”ืฆื•ื•ืช ืฉืœืš ืฉื”ืจืžื– ืžืชืืจ, ืžื•ืคืจื“ื•ืช ื‘ืคืกื™ืง>"
)
def llm_spymaster(llm: HebrewLLM, board: Board) -> Clue | None:
def block(label, ws):
return f"{label}: " + ", ".join(ws)
user = (
block("ื”ืฆื•ื•ืช ืฉืœื™", board.my)
+ "\n"
+ block("ื”ื™ืจื™ื‘", board.of("opp"))
+ "\n"
+ block("ื ื™ื˜ืจืœื™", board.of("neutral"))
+ "\n"
+ f"ื”ืžืชื ืงืฉ (ืืกื•ืจ!): {board.assassin}\n\n"
+ _SPY_FMT
)
txt = llm.chat(_SPY_SYS, user, max_tokens=120)
clue = _grab(r"ืจืžื–:\s*([^\n,]+)", txt)
cnt = _grab(r"ืžืกืคืจ:\s*(\d+)", txt)
words_line = _grab(r"ืžื™ืœื™ื:\s*(.+)", txt)
if not clue:
return None
clue = clue.strip().split()[0]
if shares_lemma(clue, board): # illegal: clue is a board word or a form of one
return None
intended = []
if words_line:
for tok in re.split(r"[,ึพ\-/]| ื•", words_line):
w = _match_board(tok, board.my)
if w and w not in intended:
intended.append(w)
return Clue(
word=clue,
count=int(cnt) if cnt else len(intended) or 2,
intended=intended,
margin=float("nan"),
)
_PICK_SYS = (
"ืืชื” ืจื‘ ืžืจื’ืœื™ื ื‘ืžืฉื—ืง 'ืฉื ืงื•ื“'. ืงื™ื‘ืœืช ืจืฉื™ืžืช ืจืžื–ื™ื ืžื•ืขืžื“ื™ื, ื›ืœ ืื—ื“ ืขื ืžื™ืœื•ืช ื”ืฆื•ื•ืช ืฉื”ื•ื ืžืชืืจ. "
"ื‘ื—ืจ ืืช ื”ืจืžื– ื”ื˜ื•ื‘, ื”ื‘ื˜ื•ื— ื•ื”ื˜ื‘ืขื™ ื‘ื™ื•ืชืจ โ€” ืฉืžืงืฉืจ ื›ืžื” ืฉื™ื•ืชืจ ืžืžื™ืœื•ืช ื”ืฆื•ื•ืช ื‘ืœื™ ืœืจืžื•ื– ืขืœ ื”ืžืชื ืงืฉ ืื• ืขืœ ื”ื™ืจื™ื‘. "
"ืขื ื” ื‘ืคื•ืจืžื˜ ื”ื–ื” ื‘ืœื‘ื“:\nืจืžื–: <ื”ืžื™ืœื” ืžื”ืจืฉื™ืžื”>\nืžืกืคืจ: <ื›ืžื” ืžื™ืœื™ื>\nืกื™ื‘ื”: <ืžืฉืคื˜ ืงืฆืจ ืื—ื“ ืžื“ื•ืข ื–ื” ื”ืจืžื– ื”ื˜ื•ื‘ ื‘ื™ื•ืชืจ>"
)
def llm_pick_clue(llm: HebrewLLM, board: Board, candidates) -> Clue:
"""Hybrid spymaster: the LLM picks the best clue out of a geometry-vetted shortlist."""
lines = "\n".join(
f"{i + 1}. {c['word']} โ†’ {', '.join(c['intended'])}" for i, c in enumerate(candidates)
)
user = (
f"ื”ืฆื•ื•ืช ืฉืœื™: {', '.join(board.my)}\nื”ืžืชื ืงืฉ (ืืกื•ืจ!): {board.assassin}\n\n"
f"ืžื•ืขืžื“ื™ื:\n{lines}\n\nื‘ื—ืจ ืจืžื– ืื—ื“ ืžื”ืจืฉื™ืžื”."
)
txt = llm.chat(_PICK_SYS, user, max_tokens=120)
word = _grab(r"ืจืžื–:\s*([^\n,]+)", txt)
cnt = _grab(r"ืžืกืคืจ:\s*(\d+)", txt)
reason = _grab(r"ืกื™ื‘ื”:\s*(.+)", txt) or ""
chosen = None
if word:
word = word.strip().split()[0]
for c in candidates:
if c["word"] == word or word in c["word"] or c["word"] in word:
chosen = c
break
chosen = chosen or candidates[0]
return Clue(
word=chosen["word"],
count=int(cnt) if cnt else chosen["count"],
intended=chosen["intended"],
margin=chosen.get("score", float("nan")),
reason=reason,
)
_GUESS_SYS = (
"ืืชื” ืฉื—ืงืŸ ื‘ืžืฉื—ืง 'ืฉื ืงื•ื“' ื‘ืขื‘ืจื™ืช. ืงื™ื‘ืœืช ืจืžื– ืฉืœ ืžื™ืœื” ืื—ืช ื•ืจืฉื™ืžืช ืžื™ืœื™ื ืขืœ ื”ืœื•ื—. "
"ื“ืจื’ ืืช ื›ืœ ืžื™ืœื•ืช ื”ืœื•ื— ืžื”ืงืฉื•ืจื” ื‘ื™ื•ืชืจ ืœืจืžื– ื•ืขื“ ื”ืคื—ื•ืช ืงืฉื•ืจื”."
)
def llm_guess_ranking(llm: HebrewLLM, board: Board, clue: str) -> list[str]:
"""Full ranking of the 25 board words by the LLM, given the clue."""
user = (
f"ื”ืจืžื–: {clue}\n"
f"ืžื™ืœื•ืช ื”ืœื•ื—: {', '.join(board.words)}\n\n"
"ื”ื—ื–ืจ ืืช ื›ืœ ืžื™ืœื•ืช ื”ืœื•ื— ืžืกื•ื“ืจื•ืช ืžื”ืงืฉื•ืจื” ื‘ื™ื•ืชืจ ืœืจืžื– ืขื“ ื”ืคื—ื•ืช ืงืฉื•ืจื”, "
"ืžื•ืคืจื“ื•ืช ื‘ืคืกื™ืง, ื‘ืœื™ ืžืกืคื•ืจ ื•ื‘ืœื™ ื˜ืงืกื˜ ื ื•ืกืฃ."
)
txt = llm.chat(_GUESS_SYS, user, max_tokens=400)
ranked, seen = [], set()
for tok in re.split(r"[,\nึพ]| ื•", txt):
w = _match_board(tok, board.words)
if w and w not in seen:
ranked.append(w)
seen.add(w)
for w in board.words: # append any the model dropped
if w not in seen:
ranked.append(w)
return ranked
# --------------------------------------------------------------------------- #
# Legality (Codenames clue rules)
# --------------------------------------------------------------------------- #
# A clue is illegal iff it is a board word / an inflection of one (same lemma), OR it shares a
# root with a board word AND is semantically transparent to it (clueโ†”word cosine >= THETA).
# Root sharing is decided by the Wiktionary lexicon (morph.roots); words the lexicon does not
# cover fall back to the coarse root_sig heuristic. The cosine gate keeps opaque etymological
# cognates legal (ืžืœื—ืžื” next to ืœื—ื) and neutralises both root_sig's false positives (ืืฉ/ืจืืฉ)
# and lexicon homograph noise. Encoders return L2-normalised vectors, so a clueโ†”board dot
# product is exactly the cosine the gate needs; THETA was calibrated on fastText.
ROOT_TRANSPARENCY_THETA = 0.30
def forbidden_lemmas(board: Board, lemmas=None) -> set[str]:
"""The board words plus their lemmas โ€” a clue equal to any of these is illegal. Pass
precomputed `lemmas` (aligned with board.words) to avoid re-lemmatising the board."""
lems = morph.lemmas(board.words) if lemmas is None else lemmas
return set(board.words) | set(lems)
def _root_conflict(sig: str, board_sigs) -> bool:
"""Coarse shoresh-signature collision, used only as the fallback when the lexicon does not
cover one of the words. Equal signatures always conflict; for roots of 3+ letters,
containment in either direction also conflicts (ื›ืœื‘/ื›ืœื‘ืœื‘, ืกืคืจ/ืกืคืจื•ืŸ). For 2-letter
skeletons only exact equality counts, so short unrelated roots don't collide (ืืฉ vs ืจืืฉ)."""
if not sig:
return False
for bs in board_sigs:
if sig == bs:
return True
if min(len(sig), len(bs)) >= 3 and (bs in sig or sig in bs):
return True
return False
def _board_root_signals(board: Board, lemmas=None):
"""Per board word, the pair (lexicon root set, root_sig fallback string). The root set
unions the word's and its lemma's lexicon roots; the sig backs the OOV fallback compare.
Pass precomputed `lemmas` (aligned with board.words) to avoid re-lemmatising the board."""
lems = morph.lemmas(board.words) if lemmas is None else lemmas
return [
(morph.roots(w) | morph.roots(lem), morph.root_sig(lem))
for w, lem in zip(board.words, lems, strict=False)
]
def _normalize_root(r: str) -> str:
if len(r) == 3:
if r[-1] in ("ื”", "ื™", "ื•"):
return r[:-1]
if r[-1] == r[-2]:
return r[:-1]
return r
def _shares_root(cand_roots, cand_sig, board_roots, board_sig) -> bool:
"""Shared-root test for one (clue, board word) pair: authoritative lexicon-set intersection
when both sides are covered, else the coarse root_sig conflict.
Weak/geminate roots are normalized to prevent false negatives from lexicographical mismatches."""
if cand_roots and board_roots:
cand_norm = {_normalize_root(r) for r in cand_roots}
board_norm = {_normalize_root(r) for r in board_roots}
return bool(cand_norm & board_norm)
return _root_conflict(cand_sig, {board_sig} if len(board_sig) >= 2 else set())
def legal_vocab_mask(
clue_vocab, vocab_lemmas, board, cos, theta: float = ROOT_TRANSPARENCY_THETA
) -> list[bool]:
"""Per-candidate legality over a whole clue vocabulary. `cos` is the (V, n_board) clueโ†”board
cosine matrix (= C @ B.T for L2-normalised encoders). A candidate is illegal if it (or its
lemma) is a board word/lemma, or if it shares a root with a board word it is transparent to
(cosine >= theta). Root work runs only for candidates transparent to some board word."""
board_lems = morph.lemmas(board.words) # lemmatise the board once
forbidden = forbidden_lemmas(board, board_lems)
signals = _board_root_signals(board, board_lems)
hotmask = cos >= theta # (V, n_board) transparent pairs
out = []
for i, (c, clem) in enumerate(zip(clue_vocab, vocab_lemmas, strict=False)):
if c in forbidden or clem in forbidden:
out.append(False)
continue
hot = np.flatnonzero(hotmask[i]) # board words this clue is transparent to
if hot.size == 0:
out.append(True)
continue
crs = morph.roots(c) | morph.roots(clem)
csig = morph.root_sig(clem)
out.append(not any(_shares_root(crs, csig, *signals[j]) for j in hot))
return out
_LEGAL_KEEP_CACHE: dict = {} # (encoder, vocab size, board words) -> legal keep indices
_LEGAL_KEEP_CACHE_MAX = 64
def _legal_candidates(enc, board: Board, clue_vocab, clue_emb=None, vocab_lemmas=None):
"""Embed the vocab + board, drop illegal clues (composite root + cosine gate), and return
(board_words, B, kept_candidates, keep_indices, C_kept). Encoders return L2-normalised
vectors, so C @ B.T is the cosine used by both the legality gate and the scorer.
Legality depends only on the board words (given a fixed vocab + encoder), so the kept-index
set is cached per board โ€” repeated risk/focus toggles on one board skip the lemma+mask pass."""
bw = board.words
Cfull = enc.embed(clue_vocab) if clue_emb is None else clue_emb
B = enc.embed(bw)
key = (getattr(enc, "model_id", ""), len(clue_vocab), tuple(bw))
keep = _LEGAL_KEEP_CACHE.get(key)
if keep is None:
if vocab_lemmas is None:
vocab_lemmas = morph.lemmas(clue_vocab)
mask = legal_vocab_mask(clue_vocab, vocab_lemmas, board, Cfull @ B.T)
keep = [i for i, k in enumerate(mask) if k]
if len(_LEGAL_KEEP_CACHE) >= _LEGAL_KEEP_CACHE_MAX:
_LEGAL_KEEP_CACHE.clear()
_LEGAL_KEEP_CACHE[key] = keep
cand = [clue_vocab[i] for i in keep]
return bw, B, cand, keep, Cfull[keep]
def shares_lemma(clue: str, board: Board, enc=None, theta: float = ROOT_TRANSPARENCY_THETA) -> bool:
"""Single-clue legality (the coach 'is my clue legal?' check). Illegal if the clue/its lemma
is a board word/lemma, or it shares a root with a board word it is transparent to. Without an
encoder the transparency gate cannot run, so any shared root is treated as illegal (strict)."""
board_lems = morph.lemmas(board.words) # lemmatise the board once
forbidden = forbidden_lemmas(board, board_lems)
lem = morph.lemma(clue)
if clue in forbidden or lem in forbidden:
return True
crs = morph.roots(clue) | morph.roots(lem)
csig = morph.root_sig(lem)
shared = [
j
for j, sig in enumerate(_board_root_signals(board, board_lems))
if _shares_root(crs, csig, *sig)
]
if not shared:
return False
if enc is None:
return True
cvec = enc.embed([clue])[0]
return bool((enc.embed([board.words[j] for j in shared]) @ cvec >= theta).any())
_ROOT_SYS = (
"ืืชื” ืžื•ืžื—ื” ืœืžื•ืจืคื•ืœื•ื’ื™ื” ืฉืœ ื”ืขื‘ืจื™ืช. ื”ื”ื›ืจืขื” ืžื•ืจืคื•ืœื•ื’ื™ืช ื‘ืœื‘ื“ โ€” ืœืคื™ ืฉื•ืจืฉ ืžืฉื•ืชืฃ ืื• ืฆื•ืจื” "
"ื ื˜ื•ื™ื”/ื ื’ื–ืจืช โ€” ื•ืœื ืœืคื™ ืงืฉืจ ื‘ืžืฉืžืขื•ืช. ืจืžื– ืคืกื•ืœ ืจืง ืื ื™ืฉ ืœื• ืื•ืชื• ืฉื•ืจืฉ ื›ืžื• ืžื™ืœืช ืœื•ื—, ืื• ืฉื”ื•ื "
"ื ื˜ื™ื™ื”/ื ื’ื–ืจืช ืฉืœื”. ื“ื•ื’ืžืื•ืช ืœืคืกื•ืœ: 'ืชื•ื›ื ื™ืช' ืœื™ื“ 'ืชื•ื›ื ื”', 'ืกืคืจื™ื™ื”' ืœื™ื“ 'ืกืคืจ', 'ืจื›ื‘' ืœื™ื“ 'ืจื›ื‘ืช', "
"'ื›ืœื‘ื”' ืœื™ื“ 'ื›ืœื‘'. "
"ื“ื•ื’ืžืื•ืช ืœืชืงื™ืŸ (ืงืฉืจ ืžืฉืžืขื•ืช ื‘ืœื‘ื“, ืฉื•ืจืฉ ืฉื•ื ื”): 'ืื•ืจื•ืช' ืœื™ื“ 'ืืฉ', 'ืขื™ืชื•ืŸ' ืœื™ื“ 'ืกืคืจ'. "
"ื”ื—ื–ืจ ืืš ื•ืจืง ืืช ืžืกืคืจื™ ื”ืžื•ืขืžื“ื™ื ื”ืคืกื•ืœื™ื ืžื•ืคืจื“ื™ื ื‘ืคืกื™ืง, ืื• ืืช ื”ืžื™ืœื” 'ืื™ืŸ' ืื ื›ื•ืœื ืชืงื™ื ื™ื."
)
def llm_root_conflicts(llm: HebrewLLM, candidate_words, board_words) -> set[str]:
"""Shoresh/derivative gate: ask the Hebrew LLM which candidates share a root with a
board word โ€” real morphological knowledge for the case lemma equality cannot catch."""
cw = list(candidate_words)
if not cw:
return set()
lines = "\n".join(f"{i + 1}. {w}" for i, w in enumerate(cw))
user = f"ืžื™ืœื•ืช ื”ืœื•ื—: {', '.join(board_words)}\n\nืžื•ืขืžื“ื™ื:\n{lines}\n\nืื™ืœื• ืžื•ืขืžื“ื™ื ืคืกื•ืœื™ื?"
txt = llm.chat(_ROOT_SYS, user, max_tokens=80)
bad = set()
for m in re.findall(r"\d+", txt):
i = int(m) - 1
if 0 <= i < len(cw):
bad.add(cw[i])
return bad
# --------------------------------------------------------------------------- #
# Parsing helpers
# --------------------------------------------------------------------------- #
def _grab(pat: str, text: str):
m = re.search(pat, text)
return m.group(1).strip() if m else None
def _match_board(token: str, candidates: list[str]):
"""Map a noisy LLM token to a board word: exact, then substring either way."""
t = re.sub(r"[^ึ-ืฟ]", "", token).strip()
if not t:
return None
if t in candidates:
return t
for c in candidates:
if t == c.replace(" ", ""):
return c
for c in candidates:
if (t in c) or (c in t):
return c
return None
# --------------------------------------------------------------------------- #
# Metrics
# --------------------------------------------------------------------------- #
def spearman(order_a: list[str], order_b: list[str]) -> float:
"""Spearman rho between two orderings of the same item set."""
from scipy.stats import spearmanr
rank_a = {w: i for i, w in enumerate(order_a)}
rank_b = {w: i for i, w in enumerate(order_b)}
items = list(order_a)
rho, _ = spearmanr([rank_a[w] for w in items], [rank_b[w] for w in items])
return float(rho)
def recovery_at_k(order: list[str], intended: list[str], k: int) -> float:
if not intended:
return float("nan")
return len(set(order[:k]) & set(intended)) / len(intended)