diff --git a/src/interscript/ml/nikud_ipa.py b/src/interscript/ml/nikud_ipa.py new file mode 100644 index 0000000..4e23f71 --- /dev/null +++ b/src/interscript/ml/nikud_ipa.py @@ -0,0 +1,109 @@ +"""nikud->IPA: deterministic Hebrew phonemization rules (WO07). + +Standard Israeli reading over letter x nikud-cluster pairs. Cluster +order is the corpus convention (as written); shin/sin dots carry their +own meaning. Documented limitations: no stress marks, no spoken-norm +variation (that is the ReNikud audio-supervised territory), no teamim. +Qamats-qatan is lexicon-resolved; overrides beat rules. +""" + +from __future__ import annotations + +NIKUD = set("ְֱֲֳִֵֶַָֹֺֻּֽֿׁׂ") +DAGESH = "ּ" + +_LETTERS = { + "א": "", "ב": "v", "בּ": "b", "ג": "g", "ד": "d", "ה": "h", + "ו": "v", "ז": "z", "ח": "x", "ט": "t", "י": "j", + "כ": "x", "כּ": "k", "ך": "x", "ל": "l", "מ": "m", "ם": "m", + "נ": "n", "ן": "n", "ס": "s", "ע": "", "פ": "f", "פּ": "p", + "ף": "f", "צ": "t͡s", "ץ": "t͡s", "ק": "k", "ר": "ʁ", "שׁ": "ʃ", + "שׂ": "s", "ש": "ʃ", "ת": "t", "תּ": "t", +} +_VOWELS = { + "ַ": "a", "ָ": "a", "ֲ": "a", "ֱ": "e", "ֳ": "o", "ִ": "i", + "ֵ": "e", "ֶ": "e", "ֹ": "o", "ֻ": "u", "ְ": "", +} +_LEXICON = { + "כָּל": "kol", # qamats qatan + "נָא": "na", +} +_FINAL_FORMS = set("ךםןףץ") +_VOWEL_IPA = set("aeiouə") + + +def _parse(text: str) -> list[tuple[str, str]]: + units: list[tuple[str, str]] = [] + for ch in text: + if ch in NIKUD and ch not in "ׁׂ": + if units: + units[-1] = (units[-1][0], units[-1][1] + ch) + else: + units.append(("\x00", ch)) + continue + if units and units[-1][0] in ("ש", "שׁ", "שׂ") and ch in "ׁׂ": + units[-1] = ("ש" + ch, units[-1][1]) + continue + units.append((ch, "")) + return units + + +def nikud_to_ipa(text: str) -> str: + if text in _LEXICON: + return _LEXICON[text] + out: list[str] = [] + units = _parse(text) + for i, (letter, cluster) in enumerate(units): + last = i == len(units) - 1 + vowels = "".join(_VOWELS.get(c, "") for c in cluster if c in _VOWELS) + + if letter == "\x00": # leading marks: emit the vowel only + out.append(vowels) + continue + + if letter == "ו" and cluster: + if DAGESH in cluster: # shuruk + out.append("u") + continue + if "ֹ" in cluster: # holam on vav + out.append("o") + continue + + if letter == "י": + prev = out[-1] if out else "" + if "ִ" in cluster: + out.append("i" if prev and prev[-1] in _VOWEL_IPA else "ji") + continue + if not cluster and prev and prev[-1] in _VOWEL_IPA: + continue # bare yod after a vowel: silent lengthener + if "ֵ" in cluster: + out.append(vowels) # yod as tsere-mater + continue + + if letter in ("א", "ע"): + out.append(vowels) # silent guttural, vowel carries + continue + + if letter == "ה" and last: + out.append(vowels) # final heh is silent + continue + + # begadkefat: the dagesh spelling is the hard-stop key + if DAGESH in cluster: + base = _LETTERS.get(letter + DAGESH, _LETTERS.get(letter, "")) + else: + base = _LETTERS.get(letter, "") + if not base and letter not in _LETTERS and not cluster: + out.append(letter) # non-Hebrew: pass through (spaces, punct) + continue + if not vowels and "ְ" in cluster: + # sheva: word-initial onset = ə, otherwise silent + if not out and letter not in _FINAL_FORMS: + vowels = "ə" + out.append(base + vowels) + return "".join(out) + + +def plane_to_ipa(model, text: str) -> str: + """Chain a plane model's nikud restoration into the IPA rules.""" + return nikud_to_ipa(model.translate(text)) diff --git a/tests/test_nikud_ipa.py b/tests/test_nikud_ipa.py new file mode 100644 index 0000000..4955007 --- /dev/null +++ b/tests/test_nikud_ipa.py @@ -0,0 +1,97 @@ +"""WO07: nikud->IPA rule layer — one test per rule/special case.""" + +from __future__ import annotations + +from interscript.ml.nikud_ipa import nikud_to_ipa, plane_to_ipa + + +class TestConsonants: + def test_begadkefat_dagesh_hard(self): + assert nikud_to_ipa("בּ") == "b" + assert nikud_to_ipa("כּ") == "k" + assert nikud_to_ipa("פּ") == "p" + + def test_begadkefat_soft(self): + assert nikud_to_ipa("בָ") == "va" + assert nikud_to_ipa("כָ") == "xa" + assert nikud_to_ipa("פָ") == "fa" + + def test_final_forms_spirantize(self): + assert nikud_to_ipa("ךְ") == "x" + assert nikud_to_ipa("ף") == "f" + + def test_shin_sin_dots(self): + assert nikud_to_ipa("שֶׁ") == "ʃe" + assert nikud_to_ipa("שֶׂ") == "se" + assert nikud_to_ipa("שְ") == "ʃə" # dotless = shin; initial sheva = ə + + def test_plain_stops_and_fricatives(self): + assert nikud_to_ipa("גַּ") == "ga" + assert nikud_to_ipa("דִּ") == "di" + assert nikud_to_ipa("תַּ") == "ta" + assert nikud_to_ipa("זֶה") == "ze" + assert nikud_to_ipa("חַ") == "xa" + assert nikud_to_ipa("צָ") == "t͡sa" + assert nikud_to_ipa("קוֹ") == "ko" + assert nikud_to_ipa("רִי") == "ʁi" + + def test_matres_lectionis(self): + assert nikud_to_ipa("וּ") == "u" # shuruk: vav+dagesh + assert nikud_to_ipa("וֹ") == "o" # holam on vav + assert nikud_to_ipa("יִ") == "ji" # onset yod reads consonantal + + def test_yod_after_vowel_is_silent_lengthener(self): + assert nikud_to_ipa("רִי") == "ʁi" + + def test_gutturals_silent_with_vowel(self): + assert nikud_to_ipa("אָב") == "av" + assert nikud_to_ipa("עִיר") == "iʁ" + + def test_final_heh_silent(self): + assert nikud_to_ipa("סֻכָּה") == "suka" + + def test_sheva(self): + assert nikud_to_ipa("בְּנָיָה") == "bənaja" # initial sheva = ə + assert nikud_to_ipa("מַלְכָּה") == "malka" # non-initial sheva silent + + +class TestVowels: + def test_core_vowels(self): + assert nikud_to_ipa("דָּג") == "dag" + assert nikud_to_ipa("דִּין") == "din" + assert nikud_to_ipa("דֶּרֶךְ") == "deʁex" + assert nikud_to_ipa("טוּב") == "tuv" + + def test_hataf(self): + assert nikud_to_ipa("חֲנֻכָּה") == "xanuka" + assert nikud_to_ipa("אֱמֶת") == "emet" + + def test_qamats_qatan_via_lexicon(self): + # כָּל is the canonical qamats-qatan word: /kol/ + assert nikud_to_ipa("כָּל") == "kol" + + +class TestComposition: + def test_plane_to_ipa_chains(self): + class M: + def translate(self, text, preserve_diacritics=False): + return "שָׁלוֹם" + + assert plane_to_ipa(M(), "שלום") == "ʃalom" + + def test_deterministic(self): + assert nikud_to_ipa("שָׁלוֹם") == "ʃalom" + assert nikud_to_ipa("שָׁלוֹם") == nikud_to_ipa("שָׁלוֹם") + + +class TestCoverage: + def test_every_real_cluster_has_rule_or_override(self): + samples = ["בְּ", "שָׁ", "שִׂי", "צַ", "ךְ", "ף", "וּ", "יִ", "אֲ", "תּוֹ"] + for s in samples: + assert isinstance(nikud_to_ipa(s), str) + + +class TestPassThrough: + def test_spaces_and_latin_survive(self): + assert nikud_to_ipa("שָׁלוֹם עֲלֵיכֶם") == "ʃalom alexem" + assert nikud_to_ipa("abc") == "abc"