Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/interscript/ml/nikud_ipa.py
Original file line number Diff line number Diff line change
@@ -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))
97 changes: 97 additions & 0 deletions tests/test_nikud_ipa.py
Original file line number Diff line number Diff line change
@@ -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"
Loading