shmulc commited on
Commit
098a36d
·
verified ·
1 Parent(s): 003291c

Root-legality: Wiktionary lexicon + fastText cosine gate (fixes מדע/מדען-type illegal clues)

Browse files
Files changed (5) hide show
  1. app.py +4 -3
  2. data/ROOT_LEXICON_NOTICE.md +9 -0
  3. data/word2root.json +0 -0
  4. morph.py +40 -0
  5. 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 is lemma-based (DictaBERT) by default no LLM. The stricter shoresh/root gate
466
- # (DictaLM) runs only when the client opts in, so the checker works fully offline.
467
- illegal = probe.shares_lemma(clue, board)
 
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.words
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.words
472
- if vocab_lemmas is None:
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 may not be a board word, an inflection/attached-particle form of one, or
623
- # share its root. Lemma equality (DictaBERT, via morph.py) is the deterministic
624
- # core no letter lists. The stricter shoresh/derivative case that lemma equality
625
- # cannot see (תוכנה / תוכנית: same root, different lemma) is judged by the LLM.
 
 
 
 
 
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
- """Whether a clue's shoresh signature collides with any board word's. Equal signatures
641
- always conflict; for roots of 3+ letters, containment in either direction also conflicts
642
- this catches reduplication/diminutive and affix derivations that bare equality misses
643
- (כלב/כלבלב, חתול/חתלתול, ספר/ספרון). For 2-letter skeletons only exact equality counts,
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 legal_vocab_mask(clue_vocab, vocab_lemmas, forbidden, forbidden_roots=None) -> list[bool]:
656
- """Per-word legality over a clue vocabulary: not a board word/lemma, and (when
657
- `forbidden_roots` is given) not a same-root derivative of one."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  out = []
659
- for w, lem in zip(clue_vocab, vocab_lemmas):
660
- ok = w not in forbidden and lem not in forbidden
661
- if ok and forbidden_roots and _root_conflict(morph.root_sig(lem), forbidden_roots):
662
- ok = False
663
- out.append(ok)
 
 
 
 
 
 
664
  return out
665
 
666
 
667
- def shares_lemma(clue: str, board: "Board") -> bool:
668
- """Single-clue legality: clue (or its lemma) coincides with a board word/lemma, or
669
- shares a shoresh with one (the קסם/קוסם case lemma equality cannot catch)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
  forbidden = forbidden_lemmas(board)
671
  lem = morph.lemma(clue)
672
  if clue in forbidden or lem in forbidden:
673
  return True
674
- return _root_conflict(morph.root_sig(lem), board_root_sigs(board))
 
 
 
 
 
 
 
 
 
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 = (