File size: 23,102 Bytes
0af696f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35394c4
0af696f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269a3c8
 
 
 
0af696f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269a3c8
0af696f
269a3c8
 
 
 
 
0af696f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269a3c8
0af696f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35394c4
 
0af696f
35394c4
 
 
 
0af696f
 
1c2ce5a
 
 
 
 
 
 
 
 
 
0af696f
 
 
 
 
 
 
 
 
 
94873fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0af696f
 
 
 
1c2ce5a
0af696f
 
94873fd
 
 
0af696f
269a3c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2ce5a
 
 
269a3c8
 
 
 
94873fd
 
 
 
 
 
 
 
 
269a3c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Pronunciation rules for Hebrew-speaking learners, read from the yaml blocks of pronunciation-rules.md.

The document is written for two audiences: its student sections are served as a guide, and its scorer
sections (the ```yaml blocks) say what the scorer accepts and how it judges errors:

  archiphonemes   letters said differently in the urban and the rural dialect, and their correct realisations
  checks          per rule: (target, heard) -> severity and the Hebrew feedback
  scoring         margins per (target, heard) pair, max_tips, ...
  heard_labels    the Hebrew name of every heard symbol

Every symbol is stored by its base() name (phones.py), the names the scorer compares in, so a rule written
with ʁ matches a model that heard ɣ. check_detectable() tells which rules a model can catch at all.
"""
from __future__ import annotations

import re
import math
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Callable

import yaml

from .phones import base

RULES_PATH = Path(__file__).resolve().parent.parent / "static" / "assets" / "pronunciation-rules.md"

# Realisations correct only in learned words (ثقافة as saqaafe): accepted where the phrase writes them, not elsewhere.
LEARNED_ONLY = {"TH": {"s"}, "DH": {"z"}}
# Classes set by position, not by letter (section 6.3 #5 and #6): the vowel of a final ة, the vowel of a pronoun ending.
POSITIONAL = {"E": ["a", "e", "i"], "O": ["o", "u"]}


def norm(symbol: str) -> str:
    """A rule symbol in the scorer's names. "_" (nothing heard) and the abstract symbols stay as they are."""
    return symbol if symbol == "_" or symbol.isupper() else base(symbol)


@dataclass(frozen=True)
class Check:
    rule: str
    target: str
    heard: str
    severity: int
    feedback: str


@dataclass
class Rules:
    archiphonemes: dict[str, dict[str, list[str]]]
    dialects: list[str]
    optional: bool
    scoring: dict
    checks: dict[tuple[str, str], Check]
    titles: dict[str, str]                       # rule id -> title
    heard_labels: dict[str, str]
    margins: dict[tuple[str, str], float] = field(default_factory=dict)
    # a vowel heard between two consonants of a word (3.10): rule, severity, min_peak, min_frames, score_cap, feedback
    insertion: dict | None = None
    # short vowels a rule flags although short vowels are not flagged (6.3 #7): vowel -> rule id (3.11: u)
    flag_vowels: dict[str, str] = field(default_factory=dict)

    def members(self, symbol: str, written: str | None = None) -> list[str]:
        """The correct realisations of an abstract symbol, in base() names, first the dialects' own.

        A learned-only realisation (TH as s) counts only when the phrase writes it (`written`)."""
        if symbol in POSITIONAL:
            return list(POSITIONAL[symbol])
        spec = self.archiphonemes.get(symbol)
        if spec is None:
            return []
        out: list[str] = []
        keys = [*self.dialects, "also_ok", *(["optional"] if self.optional else [])]
        for key in keys:
            for ph in spec.get(key, []):
                ph = base(ph)
                if ph not in out and (ph not in LEARNED_ONLY.get(symbol, ()) or ph == written):
                    out.append(ph)
        return out

    def dialects_of(self, symbol: str, phone: str) -> set[str]:
        """The dialects in which `phone` is a correct realisation of `symbol` (empty: in none, or not dialectal)."""
        spec = self.archiphonemes.get(symbol, {})
        return {d for d in ("urban", "rural") if phone in {base(p) for p in spec.get(d, [])}}

    def check(self, targets: str | list[str], heard: str | None) -> Check | None:
        """The rule for hearing `heard` instead of the target: the first of `targets` that has one."""
        if not heard:
            return None
        for t in [targets] if isinstance(targets, str) else targets:
            c = self.checks.get((norm(t), norm(heard)))
            if c is not None:
                return c
        return None

    def margin(self, targets: str | list[str], heard: str | None) -> float:
        """How much likelier than the target `heard` must be before it is a mistake (natural log). The first
        of `targets` (the abstract symbol, then the written phoneme) with a margin of its own decides."""
        default = float(self.scoring.get("margin_default", 0.0))
        for t in [targets] if isinstance(targets, str) else targets:
            m = self.margins.get((norm(t), norm(heard or "_")))
            if m is not None:
                return m
        return default

    def label(self, heard: str | None) -> str | None:
        """The Hebrew name of a heard symbol, as section 8 gives it."""
        if heard is None:
            return None
        return self.heard_labels.get(heard) or self.heard_labels.get(norm(heard))

    def interference(self) -> set[str]:
        """Every symbol some rule expects to hear instead of the target: the Hebrew sounds worth naming."""
        return {h for (_, h) in self.checks if h != "_"}


def _blocks(text: str) -> list[dict]:
    out = []
    for block in re.findall(r"```yaml\n(.*?)```", text, re.S):
        for doc in re.split(r"^---$", block, flags=re.M):
            if doc.strip():
                out.append(yaml.safe_load(doc))
    return out


def parse(text: str) -> Rules:
    archi, dialects, optional, scoring, labels = {}, ["urban", "rural"], False, {}, {}
    checks: dict[tuple[str, str], Check] = {}
    titles: dict[str, str] = {}
    insertion, flag_vowels = None, {}
    for doc in _blocks(text):
        if "insertion" in doc:
            titles[doc["id"]] = doc.get("title", doc["id"])
            insertion = {"rule": doc["id"], **doc["insertion"]}
        for v in doc.get("flag_vowels", []):
            flag_vowels[str(v)] = doc["id"]
        if "archiphonemes" in doc:
            archi = {k: {d: list(v.get(d, [])) for d in ("urban", "rural", "also_ok", "optional")}
                     for k, v in doc["archiphonemes"].items()}
            dialects = list(doc.get("dialects_enabled", dialects))
            optional = bool(doc.get("optional_realisations_enabled", False))
        if "scoring" in doc:
            scoring = dict(doc["scoring"])
        if "heard_labels" in doc:
            labels = {str(k): str(v) for k, v in doc["heard_labels"].items()}
        if "checks" in doc:
            titles[doc["id"]] = doc.get("title", doc["id"])
            for c in doc["checks"]:
                for heard in c["heard"]:
                    key = (norm(str(c["target"])), norm(str(heard)))
                    # two spellings of one sound (ʁ and ɣ) meet here: the first rule written wins
                    checks.setdefault(key, Check(doc["id"], key[0], key[1], int(c["severity"]), str(c["feedback"])))
    rules = Rules(archi, dialects, optional, scoring, checks, titles, labels, insertion=insertion, flag_vowels=flag_vowels)
    for m in scoring.get("margins", []):
        # base() drops vowel length, so a margin for long vowels would land on quality pairs (a / i) it was not meant for
        for t in (t for t in m["target"] if "ː" not in str(t)):
            for h in m["heard"]:
                rules.margins[(norm(str(t)), norm(str(h)))] = float(m["margin"])
    return rules


@lru_cache(maxsize=1)
def load(path: str = str(RULES_PATH)) -> Rules:
    return parse(Path(path).read_text(encoding="utf-8"))


def undetectable(check: Check, rules: Rules, in_model: Callable[[str], bool],
                 heard_as: Callable[[str], object] | None = None) -> str | None:
    """Why the model cannot catch this mistake, or None if it can.

    `heard_as` names the model symbol a sound is heard as (stand-ins included: ðˤ as z); two sounds heard
    as the same symbol cannot be told apart."""
    same = (lambda a, b: a == b or (heard_as(a) is not None and heard_as(a) == heard_as(b))) if heard_as \
        else (lambda a, b: a == b)
    if same(check.target, check.heard):
        return "same_sound"                 # the two collapse to one sound (aː / a: length is not modelled)
    accepted = rules.members(check.target) if check.target.isupper() else [check.target]
    if any(same(check.heard, p) for p in accepted):
        return "accepted"                   # the 'mistake' is a correct realisation of the target
    if not any(in_model(p) for p in accepted):
        return "target_not_in_model"
    if check.heard != "_" and not in_model(check.heard):
        return "heard_not_in_model"
    return None


def check_detectable(rules: Rules, in_model: Callable[[str], bool],
                     heard_as: Callable[[str], object] | None = None) -> dict[str, dict]:
    """Per rule: whether at least one of its checks can fire, and why each of the others cannot."""
    out: dict[str, dict] = {}
    for c in rules.checks.values():
        r = out.setdefault(c.rule, {"title": rules.titles.get(c.rule, c.rule), "detectable": False, "missing": []})
        why = undetectable(c, rules, in_model, heard_as)
        if why is None:
            r["detectable"] = True
        else:
            r["missing"].append({"target": c.target, "heard": c.heard, "why": why})
    return out


# ---------- exemptions (section 6.3): what the model hears as a mistake but is ordinary speech ----------

VOWELS = {"a", "i", "u", "e", "o", "aː", "iː", "uː", "eː", "oː"}
_VOWEL_GROUPS = [set("iɪeɛ"), set("uʊoɔ"), set("aæɐɑəɛe")]  # a raised towards e is ordinary Palestinian speech


def near_vowels(expected: str, heard: str | None) -> bool:
    """Same vowel with another length, or the neighbouring quality (i/e, u/o, a/ə, a/e)."""
    if expected not in VOWELS or not heard:
        return False
    e, h = expected.rstrip("ː"), heard.rstrip("ː")
    return any(e in g and h in g for g in _VOWEL_GROUPS)


def elided_article(toks, i: int, ar_words: list[str]) -> bool:
    """Is token i the vowel of the article ال right after a word that ends in a vowel?

    There it is not said (على الطّاولة is ʕ a l a | tˤ tˤ aː w l e), so its absence is not an error.
    """
    t = toks[i]
    return (i > 0 and t.phoneme in VOWELS and toks[i - 1].word == t.word - 1 and toks[i - 1].phoneme in VOWELS
            and t.word < len(ar_words) and ar_words[t.word].startswith("ال"))


# A glottal stop that is simply not there is how natives say it too (انا, اهلا; ق between vowels): #1, #2.
# A glottal stop replaced by another consonant still counts, unless the class accepts it (ق said as k).
# At utterance onset, though, one weak competitor frame is commonly a breath or microphone transient. Treat that
# as an omission regardless of the phone the multilingual model names; two frames (~40 ms) remain a substitution.
def _glottal_dropped(toks, i, sc, heard, ar_words):
    if toks[i].sound not in ("ʔ", "Q"):
        return False
    transient_onset = i == 0 and sc.peak < math.log(0.05) and sc.comp_frames < 2
    return sc.deleted or transient_onset


# Rule 31: the same onset transient in front of any other first consonant. The multilingual model hears a
# word-initial stop (طاولتنا: closure silence, then a short burst) weakly, and a single breath or microphone frame
# above deleted_comp then outscores it under an arbitrary name (tˤ heard as j). The target must still be there
# (not deleted, peak at least deleted_peak); an omission, or a competitor held two frames (~40 ms), still counts.
def _onset_transient(toks, i, sc, heard, ar_words):
    t = toks[i]
    return (i == 0 and t.phoneme not in VOWELS and t.sound not in ("ʔ", "Q") and not sc.deleted
            and sc.comp_frames == 1 and sc.peak >= math.log(0.05))


def _article_vowel_elided(toks, i, sc, heard, ar_words):     # #3
    return sc.deleted and elided_article(toks, i, ar_words)


# Neighbouring vowel qualities and vowel length vary between speakers and towns (il / el),
# and the model confuses them on its own: not worth marking a word down for (#7).
def _near_vowel(toks, i, sc, heard, ar_words):
    return near_vowels(toks[i].phoneme, heard)


# Short vowels are deliberately not judged by the learner policy, except u after a consonant inside the same word
# (rule 3.11).  Their acoustic score must follow that decision: an unflagged 1/100 must not collapse the word score.
def _unmarked_short_vowel(toks, i, sc, heard, ar_words):
    t = toks[i]
    checked_u = (t.phoneme == "u" and i > 0 and toks[i - 1].word == t.word
                 and toks[i - 1].phoneme not in VOWELS)
    return t.phoneme in {"a", "i", "u", "e", "o"} and not checked_u


# Rule 28: a native final /b/ can lose its acoustic voicing before a voiceless consonant across a word boundary.
# The multilingual CTC then strongly labels that closure /p/ (ħaliib tˤaare), although Arabic has no phonemic /p/.
# Keep this narrow: a learner's /p/ inside a word, or before a voiced sound, must not be hidden.
_VOICELESS_ONSETS = {"t", "tˤ", "k", "q", "ʔ", "s", "sˤ", "ʃ", "f", "x", "ħ", "h", "θ"}


def _final_b_devoicing(toks, i, sc, heard, ar_words):
    if base(toks[i].phoneme) != "b" or base(heard or "") != "p" or i + 1 >= len(toks):
        return False
    following = toks[i + 1]
    return following.word == toks[i].word + 1 and base(following.phoneme) in _VOICELESS_ONSETS


# Rule 29: in connected speech, a word-final short vowel flows into a vowel-initial next word.  The next word is
# commonly represented with an initial ʔ token, so accept either V or ʔ+V as the hiatus context.  CTC may assign the
# transition to only one side and call the final vowel deleted even in native speech (على إيدك).
def _word_final_vowel_linking(toks, i, sc, heard, ar_words):
    t = toks[i]
    if not sc.deleted or t.phoneme not in {"a", "i", "u", "e", "o"} or i + 1 >= len(toks):
        return False
    following = toks[i + 1]
    if following.word != t.word + 1:
        return False
    if following.phoneme in VOWELS:
        return True
    return (following.sound in {"ʔ", "Q"} and i + 2 < len(toks)
            and toks[i + 2].word == following.word and toks[i + 2].phoneme in VOWELS)


# (name, applies(tokens, index, TokenScore, heard symbol, Arabic words), score it gets: None = full marks,
# else the name of the ScorerConfig floor). The first that applies wins.
EXEMPTIONS = [
    ("glottal_dropped", _glottal_dropped, None),
    ("onset_transient", _onset_transient, "onset_transient_floor"),
    ("article_vowel_elided", _article_vowel_elided, None),
    ("near_vowel", _near_vowel, "near_vowel_floor"),
    ("final_b_devoicing", _final_b_devoicing, None),
    ("word_final_vowel_linking", _word_final_vowel_linking, "near_vowel_floor"),
    ("unmarked_short_vowel", _unmarked_short_vowel, "near_vowel_floor"),
]


# ---------- the rules a teacher can switch off (ניטור): every behaviour taken from pronunciation-rules.md ----------

@dataclass(frozen=True)
class Toggle:
    id: str
    group: str        # "accept": what counts as correct · "judge": how a mistake is judged · "feedback": what is said
    section: str      # where pronunciation-rules.md has it
    title: str
    description: str  # what the scorer does while the rule is on; off is the behaviour before the document


def _toggles() -> list[Toggle]:
    letters = [
        ("Q", "ق", "ʔ (עירוני), k (כפרי) או q", "אַהְוֶה, כַּהְוֶה וקַהְוֶה נכונות שלושתן."),
        ("K", "ك", "k או tʃ (כפרי)", "כַּלְבּ וצ'ַלְבּ נכונות שתיהן."),
        ("TH", "ث", "t או θ (כפרי), ו־s במילים ספרותיות כשהמשפט כותב s", "תְלַאתֶה ות'לַאת'ֶה נכונות שתיהן."),
        ("DH", "ذ", "d או ð (כפרי), ו־z במילים ספרותיות כשהמשפט כותב z", "דַהַבּ וד״ַהַבּ נכונות שתיהן."),
        ("ZH", "ظ", "dˤ, zˤ או ðˤ", "ד'ֻהְר וט'ֻהְר נכונות שתיהן."),
        ("J", "ج", "dʒ או ʒ", "ג' כמו בג'ינס או ז' כמו בז'קט."),
    ]
    out = [Toggle(f"dialect_{s}", "accept", "5", f"{ar}: {said}",
                  f"כל המימושים נכונים, וההגה נמדד מול סכום ההסתברויות שלהם (6.1). {ex} "
                  f"כבוי: נבדק רק המימוש שכתוב במשפט.") for s, ar, said, ex in letters]
    out += [
        Toggle("final_vowel", "accept", "6.3 #5", "תנועה סופית של ة: a, e או i",
               "אַהְוֶה, אַהְוַה ואַהְוִי נכונות. כבוי: נבדקת התנועה שכתובה במשפט."),
        Toggle("diphthong", "accept", "6.3 #8", "eː גם כ־aj, ו־oː גם כ־aw",
               "בֵּית ובַּיְת נכונות שתיהן (נבדק כווריאנט נוסף). כבוי: רק התנועה הארוכה."),
        Toggle("glottal_dropped", "judge", "6.3 #1–2", "עצירה סותמית שהושמטה אינה טעות",
               "ʔ (ء, ו־ق עירונית) שלא נשמעה מקבלת ציון מלא, כמו אצל דוברים ילידיים (אַנַא ← אנה)."),
        Toggle("onset_transient", "judge", "6.3 #31", "קפיצה של פריים אחד לפני העיצור הראשון",
               "עיצור שפותח את המשפט, שנשמע אך חלש, ומתחרה שהופיע בפריים אחד בלבד (נשימה או רעש מיקרופון) מקבל "
               "לפחות 90 ולא מסומן. השמטה, או מתחרה בשני פריימים ומעלה, עדיין נבדקים."),
        Toggle("article_vowel_elided", "judge", "6.3 #3", "תנועת ה־ال אחרי תנועה",
               "ה־i של ال שלא נאמרה אחרי מילה שמסתיימת בתנועה (עַלַא לְ...) אינה טעות."),
        Toggle("near_vowel", "judge", "6.3 #7", "תנועות קרובות",
               "תנועה שנשמעה כשכנתה (i/e, u/o, a/e) או באורך אחר מקבלת לפחות 90."),
        Toggle("unmarked_short_vowel", "judge", "6.3 #30", "תנועה קצרה שאינה נבדקת",
               "תנועה קצרה שהמדיניות אינה מסמנת מקבלת לפחות 90, כדי שלא תפיל את ציון המילה. "
               "u אחרי עיצור בתוך אותה מילה עדיין נבדקת לפי כלל הקובוץ."),
        Toggle("final_b_devoicing", "judge", "6.3 #28", "b סופית לפני עיצור אטום",
               "b בסוף מילה שהמודל שמע כ־p לפני עיצור אטום במילה הבאה מקבלת ציון מלא; "
               "באמצע מילה או לפני עיצור קולי אין פטור."),
        Toggle("word_final_vowel_linking", "judge", "6.3 #29", "תנועה סופית שמתחברת לתנועה הבאה",
               "תנועה קצרה בסוף מילה שהמודל רואה כמחוקה לפני מילה שמתחילה בתנועה מקבלת לפחות 90; "
               "באמצע מילה או לפני עיצור אין פטור."),
        Toggle("margins", "judge", "6.2", "מרווח ביטחון לזוגות שהמודל מבלבל",
               "עיצור מודגש נחשב שגוי רק אם הרגיל סביר ממנו פי 2 בערך, ħ מול h פי 1.5, "
               "ו־h נחשבת שנבלעה רק אם היא חלשה פי 2 מסף ההשמטה. "
               "כבוי: כל מתחרה שמנצח מסמן טעות."),
        Toggle("unnamed_sounds", "judge", "7", "צליל שאינו בערבית או בעברית לא נקרא בשמו",
               "כשהמודל הרב־לשוני שומע צליל זר (נקישה, צליל רטרופלקסי) הוא מדווח כ'לא ברור'. כבוי: נאמר שם הסמל."),
        Toggle("tips_by_severity", "feedback", "6.2", "סדר הטיפים לפי חומרה, עד 3",
               "טעות שמשנה משמעות (ח, ע) מוצגת לפני מבטא, ואחריה הציון הנמוך. כבוי: לפי הציון בלבד, עד 4."),
    ]
    own = {
        "cluster": "תנועה שהמודל שומע בבירור בין שני עיצורים של אותה מילה (katiir במקום ktiir) מורידה את העיצור השני "
                   "לכל היותר ל־60, עם טיפ בחומרה 1. לא בצרור שפותח את המשפט, ולא ליד w או j. כבוי: תנועה שנוספה לא נבדקת.",
        "kubutz": "u אחרי עיצור היא התנועה הקצרה היחידה שמסומנת: כשנשמטה, או נשמעה כ־e, i או שווא. "
                  "כ־o היא עדיין תנועה קרובה. כבוי: כמו שאר התנועות הקצרות, לא מסומנת.",
    }
    for rule_id, title, section in (("pharyngeal_h", "ח לועית", "3.1"), ("ayin", "ע לועית", "3.2"),
                                    ("emphatics", "עיצורים מודגשים", "3.3"), ("r_tap", "ר מתגלגלת", "3.4"),
                                    ("ghain", "ע' היא ה־ר העברית", "3.5"), ("h", "ה נהגית תמיד", "3.6"),
                                    ("w", "ו היא w", "3.7"), ("b", "ב היא תמיד b", "3.7"), ("f", "פ היא תמיד f", "3.7"),
                                    ("vowel_length", "תנועה ארוכה", "3.8"), ("jim", "ג' ולא g", "3.9"),
                                    ("cluster", "שני עיצורים צמודים", "3.10"), ("kubutz", "קובוץ ברור", "3.11")):
        out.append(Toggle(f"rule_{rule_id}", "judge" if rule_id in own else "feedback", section, title,
                          own.get(rule_id) or
                          f"הטיפ והחומרה (בסוגריים) מהמסמך כשנשמע: {_confusions(rule_id)}. "
                          "כבוי: טיפ כללי בחומרה בינונית; ההחלטה אם לסמן לא משתנה."))
    return out


def _confusions(rule_id: str) -> str:
    """'ח ← ח עברית (3), ה (2), נבלעת (2)': the substitutions a section-3 rule speaks for, in Hebrew labels."""
    rules = load()
    by_target: dict[str, list[str]] = {}
    for c in rules.checks.values():
        if c.rule == rule_id:
            heard = rules.label(c.heard) or c.heard
            item = f"{'נבלעת' if c.heard == '_' else heard} ({c.severity})"
            if item not in by_target.setdefault(c.target, []):
                by_target[c.target].append(item)
    return "; ".join(f"{_LETTER.get(t, t)} ← {', '.join(items)}" for t, items in by_target.items())


# a target sound by the letter of the transliteration (section 1), for the teacher's list
_LETTER = {"ħ": "ח", "ʕ": "ע", "sˤ": "צ", "tˤ": "ט", "dˤ": "ד'", "ZH": "ט'", "r": "ר", "ɣ": "ע'", "h": "ה", "w": "ו",
           "b": "ב", "f": "פ", "J": "ג'", "a": "אַא", "i": "ִי", "u": "וּ", "e": "ֵי", "o": "וֹ"}


TOGGLES = _toggles()
TOGGLE_IDS = frozenset(t.id for t in TOGGLES)