File size: 46,634 Bytes
852d295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
"""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)