File size: 10,477 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
"""Hebrew morphology via DictaBERT โ€” a real model from the Dicta lab, replacing
hand-rolled particle/suffix stem heuristics.

`lemmas()` uses `dicta-il/dictabert-lex` (lemmatization): it strips attached
particles and reduces inflections to the lexeme ('ื”ืืฉ'->'ืืฉ', 'ืžื™ืœื™ื'->'ืžื™ืœื”',
'ื”ืชื•ื›ื ื™ืช'->'ืชื•ื›ื ื™ืช'), so legality can be defined as *shared lemma* rather than a
letter-list approximation. Same-root-but-different-lemma cases (ืชื•ื›ื ื” / ืชื•ื›ื ื™ืช)
are a stricter shoresh rule handled separately by the LLM root-judge in probe.py.

First load downloads the model (~mins); afterwards it is HF-cached and loads offline.
"""

from __future__ import annotations

import functools
import json
import os
import re
import threading
import unicodedata

from . import DATA_DIR

_LEX_ID = "dicta-il/dictabert-lex"
_MORPH_ID = "dicta-il/dictabert-morph"
_lock = threading.Lock()
_tok = _model = None
_mtok = _mmodel = None

CONTENT_POS = {"NOUN", "PROPN", "ADJ", "VERB"}  # keep as clue words; drop ADP/PRON/DET/CONJ/ADV/NUM
_BATCH = 512  # DictaBERT.predict holds the whole list in memory at once โ€” chunk to bound it


def _predict(model, tok, words):
    """model.predict over `words` in bounded batches (avoids OOM on large vocabularies)."""
    out = []
    for i in range(0, len(words), _BATCH):
        out.extend(model.predict(words[i : i + _BATCH], tok))
    return out


def _load():
    global _tok, _model
    if _model is None:
        with _lock:
            if _model is None:
                from transformers import AutoModel, AutoTokenizer

                _tok = AutoTokenizer.from_pretrained(_LEX_ID, local_files_only=True)
                _model = AutoModel.from_pretrained(
                    _LEX_ID, trust_remote_code=True, local_files_only=True
                ).eval()
    return _tok, _model


def _load_morph():
    global _mtok, _mmodel
    if _mmodel is None:
        with _lock:
            if _mmodel is None:
                from transformers import AutoModel, AutoTokenizer

                _mtok = AutoTokenizer.from_pretrained(_MORPH_ID, local_files_only=True)
                _mmodel = AutoModel.from_pretrained(
                    _MORPH_ID, trust_remote_code=True, local_files_only=True
                ).eval()
    return _mtok, _mmodel


def pos(words) -> list[str]:
    """Coarse UD part-of-speech per (isolated) word via DictaBERT-morph. For a word with
    attached particles, the content head's POS wins (so 'ื‘ื‘ื™ืช' reads as NOUN, not ADP)."""
    words = list(words)
    if not words:
        return []
    tok, model = _load_morph()
    out = _predict(model, tok, words)
    res = []
    for item in out:
        toks = (item or {}).get("tokens") or []
        ps = [t.get("pos") for t in toks if t.get("pos")]
        head = next((p for p in ps if p in CONTENT_POS), ps[-1] if ps else "X")
        res.append(head)
    return res


def lemmas(words) -> list[str]:
    """Lemma of each (isolated) Hebrew word, aligned with `words`. Falls back to the
    surface form when the model returns nothing."""
    words = list(words)
    if not words:
        return []
    tok, model = _load()
    preds = _predict(model, tok, words)  # each word treated as its own sentence
    out = []
    for w, pred in zip(words, preds, strict=False):
        lem = None
        if pred:
            # pred is a list of (token, lemma) for the word's piece(s)
            first = pred[0]
            lem = first[1] if isinstance(first, (list, tuple)) and len(first) > 1 else None
        out.append(lem if lem and lem != "[BLANK]" else w)
    return out


@functools.lru_cache(maxsize=1024)
def lemma(word: str) -> str:
    return lemmas([word])[0]


_FINALS = str.maketrans("ืšืืŸืฃืฅ", "ื›ืžื ืคืฆ")


_CUSTOM_ROOTS = {
    "ืคืจื—ื—": {"ืคืจื—ื—"},
    "ืคืจื—ื—ื™ื": {"ืคืจื—ื—"},
}


def _get_lex_roots(w: str, lex: dict) -> set[str]:
    if w in _CUSTOM_ROOTS:
        return set(_CUSTOM_ROOTS[w])
    # Exact spelling is authoritative, final-letter variant is fallback
    roots_found = set(lex.get(w, ()))
    if not roots_found:
        roots_found.update(lex.get(w.translate(_FINALS), ()))
    return roots_found


def root_sig(word: str) -> str:
    """A coarse consonantal *shoresh* signature for shared-root legality. Normalise final
    forms, drop the matres lectionis (ื• / ื™), and strip a trailing ื” / ืช (nominal/feminine
    ending). Two words whose signatures are equal almost always share a root โ€” ืงื•ืกื/ืงืกื,
    ืจื›ื‘ืช/ืจื›ื‘, ืฉื•ืžืจ/ืฉืžื™ืจื”, ืชื•ื›ื ื”/ืชื•ื›ื ื™ืช โ€” which plain lemma equality cannot see.

    Apply it to a *lemma* (so attached particles and inflection are already gone). It is a
    morphologically motivated approximation, tuned to over-reject rather than ever let a
    derivative through; residual same-root pairs with a different skeleton are caught by the
    DictaLM root-judge (`probe.llm_root_conflicts`)."""
    s = word.translate(_FINALS).replace("ื•", "").replace("ื™", "")
    if len(s) >= 4 and s[0] in "ืžื”ื ":  # servile prefix: present-participle ืž-, hif'il ื”-, nif'al ื -
        s = s[1:]  # ืžืคื—ื“โ†’ืคื—ื“, ื”ืคื—ื™ื“โ†’ื”ืคื—ื“โ†’ืคื—ื“, ื ืคื—ื“โ†’ืคื—ื“
    if len(s) > 3 and s[-1] in "ื ืชื”":  # agentive/feminine ending: ืคื—ื“ืŸโ†’ืคื—ื“, ืฉื•ืžืจืชโ†’ืฉื•ืžืจ
        s = s[:-1]
    return s


# --------------------------------------------------------------------------- #
# Root lexicon โ€” authoritative shared-root signal (Wiktionary/kaikki-derived)
# --------------------------------------------------------------------------- #
# `roots()` looks a surface word up in data/word2root.json (see data/ROOT_LEXICON_NOTICE.md).
# It is the primary shared-root source for clue legality; `root_sig` above stays as the
# fallback for words the lexicon does not cover.

_ROOT_LEXICON_PATH = os.path.join(DATA_DIR, "word2root.json")
_NIQQUD = re.compile(r"[ึ‘-ื‡]")  # cantillation + niqqud range
_PUNCT = re.compile(r"[ืณืณ'\"โ€œโ€โ€˜โ€™`]")
_FINAL_FORMS = str.maketrans("ืšืืŸืฃืฅ", "ื›ืžื ืคืฆ")


def _norm_lookup(word: str) -> str:
    """Normalise a surface word to the lexicon's key form: NFC, niqqud stripped, maqaf/hyphen
    removed. Final letters are left intact (correct standalone spelling), matching the keys."""
    w = _NIQQUD.sub("", unicodedata.normalize("NFC", word)).strip()
    return _PUNCT.sub("", w).replace("ึพ", "").replace("-", "")


@functools.lru_cache(maxsize=1)
def _root_lexicon() -> dict:
    """Surface word -> list of triliteral roots, loaded once from data/word2root.json.
    Empty dict if the file is absent, so callers transparently fall back to root_sig."""
    try:
        with open(_ROOT_LEXICON_PATH, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return {}


@functools.lru_cache(maxsize=4096)
def roots(word: str) -> set[str]:
    """Triliteral root(s) of a surface Hebrew word per the vendored Wiktionary lexicon.
    Handles prefixes, suffixes, plurals, construct forms, and inflections using
    lexicon-based decomposition and lemmatization fallbacks."""
    lex = _root_lexicon()
    if not lex:
        return set()

    norm = _norm_lookup(word)
    if not norm:
        return set()

    # Tier 1: Direct lookup
    res = _get_lex_roots(norm, lex)
    if res:
        return res

    # Tier 2: Systematic prefix/suffix stripping based on lexicon validation
    # This avoids loading/running DictaBERT for simple prefix/suffix inflections
    PREFIXES = [
        "ื•ื‘",
        "ื•ื›",
        "ื•ืœ",
        "ื•ืž",
        "ื•ื”",
        "ื•ืฉ",
        "ืฉื‘",
        "ืฉื”",
        "ืฉื›",
        "ืฉืœ",
        "ืฉืž",
        "ื•",
        "ืฉ",
        "ื”",
        "ื‘",
        "ื›",
        "ืœ",
        "ืž",
    ]
    SUFFIXES = [
        "ื™ื”ื",
        "ื™ื”ืŸ",
        "ื™ื›ื",
        "ื™ื›ืŸ",
        "ื™ื ื•",
        "ื™ื”",
        "ื™ื•",
        "ื™ืš",
        "ื™ื™",
        "ื”ื",
        "ื”ืŸ",
        "ื›ื",
        "ื›ืŸ",
        "ื ื•",
        "ื•ืช",
        "ื™ื",
        "ื”",
        "ืช",
        "ื™",
        "ื•",
        "ืš",
    ]

    candidates = set()

    # Try stripping prefixes only
    for p in PREFIXES:
        if norm.startswith(p) and len(norm) - len(p) >= 2:
            stem = norm[len(p) :]
            stem_roots = _get_lex_roots(stem, lex)
            if stem_roots:
                candidates.update(stem_roots)

    # Try stripping suffixes only
    for s in SUFFIXES:
        if norm.endswith(s) and len(norm) - len(s) >= 2:
            stem = norm[: -len(s)]
            stem_roots = _get_lex_roots(stem, lex)
            if stem_roots:
                candidates.update(stem_roots)

    # Try stripping both prefixes and suffixes
    for p in PREFIXES:
        for s in SUFFIXES:
            if norm.startswith(p) and norm.endswith(s) and len(norm) - len(p) - len(s) >= 2:
                stem = norm[len(p) : -len(s)]
                stem_roots = _get_lex_roots(stem, lex)
                if stem_roots:
                    candidates.update(stem_roots)

    if candidates:
        return candidates

    # Tier 3: Lemmatizer fallback
    try:
        lem = lemma(word)
        norm_lem = _norm_lookup(lem)
        res = _get_lex_roots(norm_lem, lex)
        if res:
            return res
        # Try stripping on the lemma itself
        for p in PREFIXES:
            if norm_lem.startswith(p) and len(norm_lem) - len(p) >= 2:
                stem = norm_lem[len(p) :]
                stem_roots = _get_lex_roots(stem, lex)
                if stem_roots:
                    candidates.update(stem_roots)
        for s in SUFFIXES:
            if norm_lem.endswith(s) and len(norm_lem) - len(s) >= 2:
                stem = norm_lem[: -len(s)]
                stem_roots = _get_lex_roots(stem, lex)
                if stem_roots:
                    candidates.update(stem_roots)
        for p in PREFIXES:
            for s in SUFFIXES:
                if (
                    norm_lem.startswith(p)
                    and norm_lem.endswith(s)
                    and len(norm_lem) - len(p) - len(s) >= 2
                ):
                    stem = norm_lem[len(p) : -len(s)]
                    stem_roots = _get_lex_roots(stem, lex)
                    if stem_roots:
                        candidates.update(stem_roots)
    except Exception:
        # DictaBERT might fail or not be loaded
        pass

    return candidates