Spaces:
Running
Running
| """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 | |
| # --------------------------------------------------------------------------- # | |
| 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] | |
| def my(self): | |
| return self.of("my") | |
| def assassin(self): | |
| a = self.of("assassin") | |
| return a[0] if a else "" # tolerate a board the user marked without an assassin | |
| 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 | |
| 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) | |