Spaces:
Running
Running
Root-legality: Wiktionary lexicon + fastText cosine gate (fixes מדע/מדען-type illegal clues)
Browse files- app.py +4 -3
- data/ROOT_LEXICON_NOTICE.md +9 -0
- data/word2root.json +0 -0
- morph.py +40 -0
- probe.py +79 -46
app.py
CHANGED
|
@@ -462,9 +462,10 @@ def coach_check():
|
|
| 462 |
j = request.get_json(force=True)
|
| 463 |
board = board_from(j)
|
| 464 |
clue = j["clue"].strip()
|
| 465 |
-
# Legality
|
| 466 |
-
# (
|
| 467 |
-
|
|
|
|
| 468 |
if not illegal and j.get("use_llm"):
|
| 469 |
illegal = bool(probe.llm_root_conflicts(get_llm(j.get("model")), [clue], board.words))
|
| 470 |
read = _read_clue(board, clue)
|
|
|
|
| 462 |
j = request.get_json(force=True)
|
| 463 |
board = board_from(j)
|
| 464 |
clue = j["clue"].strip()
|
| 465 |
+
# Legality (offline, no LLM): a clue is illegal if it is a board word / an inflection of one
|
| 466 |
+
# (DictaBERT lemma), or shares a root (Wiktionary lexicon) with a board word it is transparent
|
| 467 |
+
# to (fastText cosine). The optional DictaLM root-judge adds extra coverage on opt-in.
|
| 468 |
+
illegal = probe.shares_lemma(clue, board, enc=get_enc(GEO_ENC))
|
| 469 |
if not illegal and j.get("use_llm"):
|
| 470 |
illegal = bool(probe.llm_root_conflicts(get_llm(j.get("model")), [clue], board.words))
|
| 471 |
read = _read_clue(board, clue)
|
data/ROOT_LEXICON_NOTICE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hebrew root lexicon — `word2root.json`
|
| 2 |
+
|
| 3 |
+
Maps Hebrew surface words → triliteral root(s), powering the shared-root half of the
|
| 4 |
+
clue-legality check. Derived from the **Hebrew Wiktionary** extract at
|
| 5 |
+
[kaikki.org](https://kaikki.org/dictionary/Hebrew/) (via
|
| 6 |
+
[wiktextract](https://github.com/tatuylonen/wiktextract)).
|
| 7 |
+
|
| 8 |
+
Wiktionary content is dual-licensed **CC BY-SA 3.0** and **GFDL 1.3**; this derived dataset
|
| 9 |
+
is redistributed under the same terms. Attribution: *Wiktionary contributors, via kaikki.org.*
|
data/word2root.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
morph.py
CHANGED
|
@@ -12,7 +12,12 @@ First load downloads the model (~mins); afterwards it is HF-cached and loads off
|
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
import threading
|
|
|
|
| 16 |
|
| 17 |
_LEX_ID = "dicta-il/dictabert-lex"
|
| 18 |
_MORPH_ID = "dicta-il/dictabert-morph"
|
|
@@ -113,3 +118,38 @@ def root_sig(word: str) -> str:
|
|
| 113 |
if len(s) > 3 and s[-1] in "נתה": # agentive/feminine ending: פחדן→פחד, שומרת→שומר
|
| 114 |
s = s[:-1]
|
| 115 |
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
import functools
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
import threading
|
| 20 |
+
import unicodedata
|
| 21 |
|
| 22 |
_LEX_ID = "dicta-il/dictabert-lex"
|
| 23 |
_MORPH_ID = "dicta-il/dictabert-morph"
|
|
|
|
| 118 |
if len(s) > 3 and s[-1] in "נתה": # agentive/feminine ending: פחדן→פחד, שומרת→שומר
|
| 119 |
s = s[:-1]
|
| 120 |
return s
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# --------------------------------------------------------------------------- #
|
| 124 |
+
# Root lexicon — authoritative shared-root signal (Wiktionary/kaikki-derived)
|
| 125 |
+
# --------------------------------------------------------------------------- #
|
| 126 |
+
# `roots()` looks a surface word up in data/word2root.json (see data/ROOT_LEXICON_NOTICE.md).
|
| 127 |
+
# It is the primary shared-root source for clue legality; `root_sig` above stays as the
|
| 128 |
+
# fallback for words the lexicon does not cover.
|
| 129 |
+
|
| 130 |
+
_ROOT_LEXICON_PATH = os.path.join(os.path.dirname(__file__), "data", "word2root.json")
|
| 131 |
+
_NIQQUD = re.compile(r"[֑-ׇ]") # cantillation + niqqud range
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _norm_lookup(word: str) -> str:
|
| 135 |
+
"""Normalise a surface word to the lexicon's key form: NFC, niqqud stripped, maqaf/hyphen
|
| 136 |
+
removed. Final letters are left intact (correct standalone spelling), matching the keys."""
|
| 137 |
+
w = _NIQQUD.sub("", unicodedata.normalize("NFC", word)).strip()
|
| 138 |
+
return w.replace("־", "").replace("-", "")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@functools.lru_cache(maxsize=1)
|
| 142 |
+
def _root_lexicon() -> dict:
|
| 143 |
+
"""Surface word -> list of triliteral roots, loaded once from data/word2root.json.
|
| 144 |
+
Empty dict if the file is absent, so callers transparently fall back to root_sig."""
|
| 145 |
+
try:
|
| 146 |
+
with open(_ROOT_LEXICON_PATH, encoding="utf-8") as f:
|
| 147 |
+
return json.load(f)
|
| 148 |
+
except FileNotFoundError:
|
| 149 |
+
return {}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def roots(word: str) -> set[str]:
|
| 153 |
+
"""Triliteral root(s) of a surface Hebrew word per the vendored Wiktionary lexicon.
|
| 154 |
+
Returns an empty set for out-of-lexicon words — the caller then falls back to root_sig."""
|
| 155 |
+
return set(_root_lexicon().get(_norm_lookup(word), ()))
|
probe.py
CHANGED
|
@@ -424,16 +424,7 @@ def encoder_spymaster(enc, board: Board, clue_vocab, clue_emb=None, vocab_lemmas
|
|
| 424 |
form). Pass precomputed `clue_emb` (aligned with clue_vocab) to skip re-embedding, and
|
| 425 |
`vocab_freq` (FREQ scores in [0,1] aligned with clue_vocab) to enable the FREQ term.
|
| 426 |
"""
|
| 427 |
-
bw = board
|
| 428 |
-
if vocab_lemmas is None:
|
| 429 |
-
vocab_lemmas = morph.lemmas(clue_vocab)
|
| 430 |
-
mask = legal_vocab_mask(clue_vocab, vocab_lemmas, forbidden_lemmas(board),
|
| 431 |
-
board_root_sigs(board)) # no shared lemma or shoresh
|
| 432 |
-
keep = [i for i, k in enumerate(mask) if k]
|
| 433 |
-
cand = [clue_vocab[i] for i in keep]
|
| 434 |
-
C = enc.embed(cand) if clue_emb is None else clue_emb[keep]
|
| 435 |
-
|
| 436 |
-
B = enc.embed(bw) # (25, d)
|
| 437 |
adj = C @ B.T # (V, 25) cosine to every board word
|
| 438 |
adj = adj - adj.mean(1, keepdims=True) # centre per clue over the board
|
| 439 |
|
|
@@ -468,15 +459,8 @@ def encoder_clue_candidates(enc, board: Board, clue_vocab, clue_emb=None, vocab_
|
|
| 468 |
If `targets` (a subset of the team words) is given, every candidate is scored to
|
| 469 |
connect *all* of them (the "I want a clue for these specific words" path); otherwise
|
| 470 |
the score auto-selects the best-m team words per candidate."""
|
| 471 |
-
bw = board
|
| 472 |
-
|
| 473 |
-
vocab_lemmas = morph.lemmas(clue_vocab)
|
| 474 |
-
mask = legal_vocab_mask(clue_vocab, vocab_lemmas, forbidden_lemmas(board),
|
| 475 |
-
board_root_sigs(board))
|
| 476 |
-
keep = [i for i, k in enumerate(mask) if k]
|
| 477 |
-
cand = [clue_vocab[i] for i in keep]
|
| 478 |
-
C = enc.embed(cand) if clue_emb is None else clue_emb[keep]
|
| 479 |
-
B = enc.embed(bw); adj = C @ B.T; adj = adj - adj.mean(1, keepdims=True)
|
| 480 |
roles = np.array([board.role[w] for w in bw])
|
| 481 |
is_opp, is_neu, is_as = roles == "opp", roles == "neutral", roles == "assassin"
|
| 482 |
def tmax(mask): return np.clip(adj[:, mask].max(1), 0, None) if mask.any() else np.zeros(len(cand))
|
|
@@ -619,10 +603,15 @@ def llm_guess_ranking(llm: HebrewLLM, board: Board, clue: str) -> list[str]:
|
|
| 619 |
# --------------------------------------------------------------------------- #
|
| 620 |
# Legality (Codenames clue rules)
|
| 621 |
# --------------------------------------------------------------------------- #
|
| 622 |
-
# A clue
|
| 623 |
-
#
|
| 624 |
-
#
|
| 625 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
|
| 627 |
|
| 628 |
def forbidden_lemmas(board: "Board") -> set[str]:
|
|
@@ -630,18 +619,11 @@ def forbidden_lemmas(board: "Board") -> set[str]:
|
|
| 630 |
return set(board.words) | set(morph.lemmas(board.words))
|
| 631 |
|
| 632 |
|
| 633 |
-
def board_root_sigs(board: "Board") -> set[str]:
|
| 634 |
-
"""Shoresh signatures of the board words — a clue sharing one is a same-root derivative
|
| 635 |
-
(e.g. קסם next to קוסם) and therefore illegal. Length<2 sigs are dropped as too coarse."""
|
| 636 |
-
return {s for s in (morph.root_sig(lem) for lem in morph.lemmas(board.words)) if len(s) >= 2}
|
| 637 |
-
|
| 638 |
-
|
| 639 |
def _root_conflict(sig: str, board_sigs) -> bool:
|
| 640 |
-
"""
|
| 641 |
-
always conflict; for roots of 3+ letters,
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
so short unrelated roots don't collide (אש vs ראש)."""
|
| 645 |
if not sig:
|
| 646 |
return False
|
| 647 |
for bs in board_sigs:
|
|
@@ -652,26 +634,77 @@ def _root_conflict(sig: str, board_sigs) -> bool:
|
|
| 652 |
return False
|
| 653 |
|
| 654 |
|
| 655 |
-
def
|
| 656 |
-
"""Per
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
out = []
|
| 659 |
-
for
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
return out
|
| 665 |
|
| 666 |
|
| 667 |
-
def
|
| 668 |
-
"""
|
| 669 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
forbidden = forbidden_lemmas(board)
|
| 671 |
lem = morph.lemma(clue)
|
| 672 |
if clue in forbidden or lem in forbidden:
|
| 673 |
return True
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
|
| 677 |
_ROOT_SYS = (
|
|
|
|
| 424 |
form). Pass precomputed `clue_emb` (aligned with clue_vocab) to skip re-embedding, and
|
| 425 |
`vocab_freq` (FREQ scores in [0,1] aligned with clue_vocab) to enable the FREQ term.
|
| 426 |
"""
|
| 427 |
+
bw, B, cand, keep, C = _legal_candidates(enc, board, clue_vocab, clue_emb, vocab_lemmas)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
adj = C @ B.T # (V, 25) cosine to every board word
|
| 429 |
adj = adj - adj.mean(1, keepdims=True) # centre per clue over the board
|
| 430 |
|
|
|
|
| 459 |
If `targets` (a subset of the team words) is given, every candidate is scored to
|
| 460 |
connect *all* of them (the "I want a clue for these specific words" path); otherwise
|
| 461 |
the score auto-selects the best-m team words per candidate."""
|
| 462 |
+
bw, B, cand, keep, C = _legal_candidates(enc, board, clue_vocab, clue_emb, vocab_lemmas)
|
| 463 |
+
adj = C @ B.T; adj = adj - adj.mean(1, keepdims=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
roles = np.array([board.role[w] for w in bw])
|
| 465 |
is_opp, is_neu, is_as = roles == "opp", roles == "neutral", roles == "assassin"
|
| 466 |
def tmax(mask): return np.clip(adj[:, mask].max(1), 0, None) if mask.any() else np.zeros(len(cand))
|
|
|
|
| 603 |
# --------------------------------------------------------------------------- #
|
| 604 |
# Legality (Codenames clue rules)
|
| 605 |
# --------------------------------------------------------------------------- #
|
| 606 |
+
# A clue is illegal iff it is a board word / an inflection of one (same lemma), OR it shares a
|
| 607 |
+
# root with a board word AND is semantically transparent to it (clue↔word cosine >= THETA).
|
| 608 |
+
# Root sharing is decided by the Wiktionary lexicon (morph.roots); words the lexicon does not
|
| 609 |
+
# cover fall back to the coarse root_sig heuristic. The cosine gate keeps opaque etymological
|
| 610 |
+
# cognates legal (מלחמה next to לחם) and neutralises both root_sig's false positives (אש/ראש)
|
| 611 |
+
# and lexicon homograph noise. Encoders return L2-normalised vectors, so a clue↔board dot
|
| 612 |
+
# product is exactly the cosine the gate needs; THETA was calibrated on fastText.
|
| 613 |
+
|
| 614 |
+
ROOT_TRANSPARENCY_THETA = 0.30
|
| 615 |
|
| 616 |
|
| 617 |
def forbidden_lemmas(board: "Board") -> set[str]:
|
|
|
|
| 619 |
return set(board.words) | set(morph.lemmas(board.words))
|
| 620 |
|
| 621 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
def _root_conflict(sig: str, board_sigs) -> bool:
|
| 623 |
+
"""Coarse shoresh-signature collision, used only as the fallback when the lexicon does not
|
| 624 |
+
cover one of the words. Equal signatures always conflict; for roots of 3+ letters,
|
| 625 |
+
containment in either direction also conflicts (כלב/כלבלב, ספר/ספרון). For 2-letter
|
| 626 |
+
skeletons only exact equality counts, so short unrelated roots don't collide (אש vs ראש)."""
|
|
|
|
| 627 |
if not sig:
|
| 628 |
return False
|
| 629 |
for bs in board_sigs:
|
|
|
|
| 634 |
return False
|
| 635 |
|
| 636 |
|
| 637 |
+
def _board_root_signals(board: "Board"):
|
| 638 |
+
"""Per board word, the pair (lexicon root set, root_sig fallback string). The root set
|
| 639 |
+
unions the word's and its lemma's lexicon roots; the sig backs the OOV fallback compare."""
|
| 640 |
+
lems = morph.lemmas(board.words)
|
| 641 |
+
return [(morph.roots(w) | morph.roots(lem), morph.root_sig(lem))
|
| 642 |
+
for w, lem in zip(board.words, lems)]
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def _shares_root(cand_roots, cand_sig, board_roots, board_sig) -> bool:
|
| 646 |
+
"""Shared-root test for one (clue, board word) pair: authoritative lexicon-set intersection
|
| 647 |
+
when both sides are covered, else the coarse root_sig conflict."""
|
| 648 |
+
if cand_roots and board_roots:
|
| 649 |
+
return bool(cand_roots & board_roots)
|
| 650 |
+
return _root_conflict(cand_sig, {board_sig} if len(board_sig) >= 2 else set())
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def legal_vocab_mask(clue_vocab, vocab_lemmas, board, cos, theta: float = ROOT_TRANSPARENCY_THETA) -> list[bool]:
|
| 654 |
+
"""Per-candidate legality over a whole clue vocabulary. `cos` is the (V, n_board) clue↔board
|
| 655 |
+
cosine matrix (= C @ B.T for L2-normalised encoders). A candidate is illegal if it (or its
|
| 656 |
+
lemma) is a board word/lemma, or if it shares a root with a board word it is transparent to
|
| 657 |
+
(cosine >= theta). Root work runs only for candidates transparent to some board word."""
|
| 658 |
+
forbidden = forbidden_lemmas(board)
|
| 659 |
+
signals = _board_root_signals(board)
|
| 660 |
out = []
|
| 661 |
+
for i, (c, clem) in enumerate(zip(clue_vocab, vocab_lemmas)):
|
| 662 |
+
if c in forbidden or clem in forbidden:
|
| 663 |
+
out.append(False)
|
| 664 |
+
continue
|
| 665 |
+
hot = np.where(cos[i] >= theta)[0] # board words this clue is transparent to
|
| 666 |
+
if len(hot) == 0:
|
| 667 |
+
out.append(True)
|
| 668 |
+
continue
|
| 669 |
+
crs = morph.roots(c) | morph.roots(clem)
|
| 670 |
+
csig = morph.root_sig(clem)
|
| 671 |
+
out.append(not any(_shares_root(crs, csig, *signals[j]) for j in hot))
|
| 672 |
return out
|
| 673 |
|
| 674 |
|
| 675 |
+
def _legal_candidates(enc, board: "Board", clue_vocab, clue_emb=None, vocab_lemmas=None):
|
| 676 |
+
"""Embed the vocab + board, drop illegal clues (composite root + cosine gate), and return
|
| 677 |
+
(board_words, B, kept_candidates, keep_indices, C_kept). Encoders return L2-normalised
|
| 678 |
+
vectors, so C @ B.T is the cosine used by both the legality gate and the scorer."""
|
| 679 |
+
bw = board.words
|
| 680 |
+
if vocab_lemmas is None:
|
| 681 |
+
vocab_lemmas = morph.lemmas(clue_vocab)
|
| 682 |
+
Cfull = enc.embed(clue_vocab) if clue_emb is None else clue_emb
|
| 683 |
+
B = enc.embed(bw)
|
| 684 |
+
mask = legal_vocab_mask(clue_vocab, vocab_lemmas, board, Cfull @ B.T)
|
| 685 |
+
keep = [i for i, k in enumerate(mask) if k]
|
| 686 |
+
cand = [clue_vocab[i] for i in keep]
|
| 687 |
+
return bw, B, cand, keep, Cfull[keep]
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def shares_lemma(clue: str, board: "Board", enc=None, theta: float = ROOT_TRANSPARENCY_THETA) -> bool:
|
| 691 |
+
"""Single-clue legality (the coach 'is my clue legal?' check). Illegal if the clue/its lemma
|
| 692 |
+
is a board word/lemma, or it shares a root with a board word it is transparent to. Without an
|
| 693 |
+
encoder the transparency gate cannot run, so any shared root is treated as illegal (strict)."""
|
| 694 |
forbidden = forbidden_lemmas(board)
|
| 695 |
lem = morph.lemma(clue)
|
| 696 |
if clue in forbidden or lem in forbidden:
|
| 697 |
return True
|
| 698 |
+
crs = morph.roots(clue) | morph.roots(lem)
|
| 699 |
+
csig = morph.root_sig(lem)
|
| 700 |
+
shared = [j for j, sig in enumerate(_board_root_signals(board))
|
| 701 |
+
if _shares_root(crs, csig, *sig)]
|
| 702 |
+
if not shared:
|
| 703 |
+
return False
|
| 704 |
+
if enc is None:
|
| 705 |
+
return True
|
| 706 |
+
cvec = enc.embed([clue])[0]
|
| 707 |
+
return bool((enc.embed([board.words[j] for j in shared]) @ cvec >= theta).any())
|
| 708 |
|
| 709 |
|
| 710 |
_ROOT_SYS = (
|