mathly / docs /scoring-dialects-plan.md
3v324v23's picture
Add unit tests for pronunciation rules parsing and detection
0af696f
|
Raw History Blame Contribute Delete
13.4 kB

Plan: dialect acceptance and Hebrew-speaker rules in the scorer

Status: implemented 2026-10-15, except where "Implementation notes" at the end says otherwise. Source rules: pronunciation-rules.md.

Two layers on top of today's scoring path:

  • Layer A, dialect acceptance: decides what counts as correct. Runs inside GOP: every correct realisation of a letter is pooled into one class, and members of the class never count as competitors.
  • Layer B, Hebrew-speaker rules: decides how strictly to judge real errors. Runs after GOP: per-pair margins, severity, feedback, exemptions.

The native baseline and the teacher calibration floors stay where they are, but are keyed on the abstract symbol instead of the sound actually said.

Final order of the pipeline:

  1. Vocab.merge pools spelling variants (exists)
  2. Layer A: class columns → alignment → GOP against the class
  3. Layer B exemptions (6.3)
  4. Native baseline normalisation (exists, keyed on the abstract symbol)
  5. Layer B margins, severity, feedback
  6. Teacher DB floors (exists, last, manual override)

Phase 0: Rule loader (app/rules.py, new)

  • Pull the YAML blocks out of pronunciation-rules.md (fenced ```yaml, split on ---). Load them into:
    • archiphonemes, dialects_enabled, optional_realisations_enabled
    • scoring (margins, floors)
    • checks (target, heard, severity, feedback), collected from every rule block
    • heard_labels
  • Pass every symbol through base() when loading, so ʁ is stored as ɣ, χ as x, and so on. Warn about rules that become undetectable:
    • target and heard end up the same sound after base();
    • a heard symbol is in the same accepted set as its target;
    • a symbol is missing from the model vocab.
  • Add pyyaml to requirements.txt. It's already installed through huggingface_hub, but it should be listed explicitly.
  • Consider moving the rules file to docs/ as the doc itself suggests. The student sections are served from static/assets/, so either keep it there or copy it at build time.
  • Tests: every block parses; every heard: symbol has a label in section 8; the undetectable-rule check fires on a deliberately bad rule.

Phase 1: Layer A core, with Q only

1a. Converting the phrases at load time, not in the source files

app/workbook.py has ~930 ق and app/grammar.py has more, so rewriting the IPA by hand isn't realistic.

  • Add archify(ar, ipa) in a new app/archi.py. For each word whose Arabic contains ق, replace the matching ʔ/q/k with Q.
    • Match by consonant order. word_letters in app/translit.py already lines up ق with its phoneme, so reuse that logic.
  • Ambiguous words (both ء and ق, or ك next to ق) need an explicit marker written in the phrase. Add a test that fails on any ق word it can't resolve.
  • After conversion, merge duplicate variants. Today's separate q and k variants (e.g. i l q u d s) become the same as the ʔ one.
  • Run it inside PHRASES / workbook_phrases() so every consumer sees Q, including references and tokenisation at startup in load_scorer.

1b. Vocab classes (app/scorer.py, Vocab)

  • Vocab(token_to_id, classes=rules.archiphonemes) creates one virtual id per abstract symbol, numbered after the real vocabulary (V, V+1, …). It also keeps members[vid], the real representative ids of the accepted realisations.
  • merge() returns logp with extra columns: column vid = logaddexp over its members.
  • resolve("Q") returns [vid]. i2t[vid] = "Q".
  • Anything that ranges over "real" symbols has to ignore virtual columns: greedy_decode, _heard_hebrew, the heatmap's "heard" track, and edit_distance for the unrelated check. Easiest is a vocab.real(logp) view of logp[:, :V].

1c. GOP (app/align.py, score_tokens)

  • score_tokens(..., accept: list[set[int]] | None). For a class token, mask all its members from the competitor search, and always mask every virtual column.
  • Add TokenScore.realised: the member with the highest peak inside the window (None for plain tokens).
  • The alignment itself doesn't change. It already runs on the virtual column.

1d. Results and display

  • Each phoneme entry gets realised. heard comes from the competitor as before, so an accepted realisation never shows up as "heard".
  • _heard_hebrew: when the frame is inside a class token's span and the greedy symbol is one of its members, write the realised letter (כּ, א or ק) uncoloured.
  • to_hebrew / word_letters: Q is written ק in the answer (today's ق rule already does this).
  • app/tips.py: add Q to NO_DELETION_TIP (6.3 #2). Remove the ʔ>k qaf tip.

1e. Baseline and calibrations

  • References need no change. Reference.sounds now holds (w, "Q"), so the baseline is the same whatever realisation the TTS voice used. The cache is in memory and rebuilds on restart.
  • Calibration lookup in score_logp: try (archi, heard) first, then fall back to (realised target, heard) so existing DB rows still apply. Add a teacher-panel note or a one-off SQL to re-key ʔ rows that came from ق phrases.

1f. Tests (tests/test_scoring.py)

  • Rewrite test_k_for_urban_qaf_picks_closest_variant_and_tips: k for Q scores ≥ 90, has no tip, and realised == "k".
  • New tests:
    • q for Q is accepted;
    • Q dropped between vowels is not flagged;
    • ʔ from ء said as k is flagged (e.g. اهلا → kahla);
    • a reference voiced with q doesn't penalise a learner who says ʔ;
    • archify on a sample of workbook phrases, plus the ambiguity test.

Phase 2: The other five abstract symbols

  • Extend archify: ك→K, ث→TH, ذ→DH, ظ→ZH, ج→J.
  • Per-word override syntax for learned words, e.g. TH{t,s} (ثقافة) or a plain t to lock it. Parse it in tokenize, which creates a one-off class for that position.
  • Vocab gaps: if θ, ð, ðˤ or tʃ aren't in the model, the class quietly shrinks. Log it, and expose it in /api/health under rules (section 7 of the rules doc).
  • Add tʃ (צ'), θ (ת'), ð (ד״) to CONSONANTS in app/translit.py so rural realisations can be written in the heard line.
  • Add θ, ð, tʃ, ðˤ, dʒ to the test vocab SYMS. Tests for each class, plus one mixed-dialect sentence that isn't penalised.

Phase 3: Positional classes and diphthongs

  • E for a final ة/ه vowel → {a, e, i}. Derived automatically from the Arabic ending (word_letters already detects it).
  • O for the vowel of a pronoun ending → {o, u}. This one needs an explicit marker in the phrase, because it can't be told reliably from the Arabic.
  • EY / OW: expand into extra variants (eː | a j, oː | a w) inside archify, before variants are merged. _fit already picks the best variant. These can't be a per-token class, because one token stands against two.
  • near_vowels stays for other positions until the vowel-length decision is made (see "Open decisions").
  • Sounds that natives also drop (ʔ at the start or between vowels, il- after a vowel) are not Layer A: an optional token would need a separate alignment with and without it. They stay as Layer B exemptions.

Phase 4: Layer B, the rule engine

4a. Competitor set

  • Limit competitors to the Arabic sounds plus the Hebrew-interference sounds (every heard: symbol in checks). Report any other winner as competitor=None, other=True ("not clear").
  • Watch out: this lowers comp_peak in the deletion test (deleted_comp). Re-check the deletion tests.

4b. Flagging by margin

  • In score_logp, flag a sound when score < flag_below and the competitor is outside the target's accepted set and −gop ≥ margin(target, heard). gop is already log P(target) − log P(competitor).
  • The issues list keeps its threshold (issue_below) but also respects the margin.

4c. Feedback

  • tip_for looks up checks[(target, heard)] first, then falls back to TIPS. Keep the key format expected>heard so the front end doesn't change.
  • Sort tips by severity, then by score. max_tips comes from the rules file.

4d. Exemptions (6.3)

  • Move NO_DELETION_TIP, elided_article and near_vowels into a predicate registry in rules.py: (name, applies(toks, i, ar_words, sc) -> bool, action).
  • Add the doc's cases 1–3, 4 (il- not assimilated) and 12 (mixed_dialect as info only).

4e. Letters that can only be heard, never expected

  • A HEARD_ONLY table in translit.py: p→פּ, v→ב/ו, ts→צ. Include it in HEBREW_SOUNDS so _heard_hebrew stops dropping these sounds. Today a student who says pinjaan sees the first letter missing instead of פּ.
  • sound_name / letter_reason labels come from heard_labels.
  • Tests: f→p (פִנְג'אן), w→v, b→v, sˤ→ts, r→ʁ. Emphatic just under the margin is not flagged, just over it is.

Phase 5: Teacher-facing parts

  • dialects_enabled / optional_realisations_enabled as teacher settings, stored wherever teacher settings live today. Pass them into Scorer.score per request, so different classes can differ.
  • The taught dialect only changes hints and info, never what's accepted.
  • Teacher panel: show realised and mixed_dialect in the diagnostics view. Show abstract symbols by their Hebrew letter, never as "Q".
  • /api/health: a rules field listing each rule and whether it can be detected.

Phase 6: Rollout check

  • If recordings are stored, add a scripts/rescore.py that compares old and new scores per phrase. Review the biggest changes before switching over, especially ק phrases and emphatics.
  • Ship Phase 1 on its own: it fixes ק/א and tests the class mechanism end to end.

Open decisions (outside this plan)

  1. Vowel length: the doc's vowel_length checks (severity 1, margin 1.1) vs. today's near_vowel_floor, which forgives length completely.
  2. Sentence formula: the doc's mean(word_min) and severity weights vs. today's 0.5/0.5 blend. Keep it behind a config flag until decided.
  3. Bedouin g for Q: on or off by default.
  4. Extra sounds: inserted sounds aren't detected, since forced alignment only scores expected sounds.

Main risks

  • The virtual columns change the logp width. Anything that indexes logp by a real id is fine, but anything that iterates over all columns (heatmap, greedy) must use the real-only view.
  • archify mismatches in the workbook. That's why the ambiguity test fails the build instead of guessing.
  • Old calibration rows keyed on ʔ until they're re-keyed. That's why the lookup falls back to the old key.

Implementation notes

Code: app/rules.py (loader, detectability, exemptions), app/archi.py (symbols, letter alignment, diphthong variants), app/phones.py (base(), moved out of the scorer), app/scorer.py / app/align.py (classes, GOP, margins, tips), scripts/rescore.py. Tests: tests/test_rules.py and the "dialects and Hebrew-speaker rules" section of tests/test_scoring.py.

Where it differs from the plan above:

  • archify runs inside the scorer, not on PHRASES. Scorer._variants converts each variant when scoring (cached). Display, TTS, transliteration and the verb game keep the plain IPA. Symbols replace phonemes one for one, so positions in the IPA still line up.
  • Token format is SYMBOL:written (Q:ʔ), or SYMBOL{a,b} for a hand-written set. A phrase that writes a symbol itself is left alone.
  • Ambiguity: across the bank (~11.6k variants) no ق is unresolved. 5 × ث merged with ت into one geminate (بعثت) and 1 × ج (جوز) stay plain phonemes; only an unresolved ق fails the test.
  • Learned-only realisations (TH as s, DH as z) are accepted where the phrase writes them, with no override syntax needed.
  • Missing model symbols: a class member the model lacks uses the same FALLBACKS stand-in as a plain phoneme. With the base model, ZH is {dˤ, z}, so "ظ heard as z" shows as accepted (undetectable) in /api/health → rules.
  • Competitor naming (4a) keeps the GOP against the strongest symbol of any kind, so scores and deletions don't change. Only the name is withheld (other) when that symbol isn't Arabic or a Hebrew-interference sound.
  • Tips: max_tips is now 3 (from the rules file), sorted by severity then score. Every tip carries severity.
  • Results gain phonemes[].sound (Q), phonemes[].realised (k), and dialect: urban|rural or mixed_dialect: true. None of them affect the score.
  • Teacher view: the expected/heard phone rows compare an accepted realisation with what was said, and show the dialect.

Not done:

  • Per-teacher dialect settings (Phase 5). The accepted set comes from the rules file for everyone (dialects_enabled, optional_realisations_enabled). Since the taught dialect was only meant to change hints, nothing in scoring depends on it yet.
  • Re-keying old calibration rows. The lookup falls back to the written phoneme, so old rows keep working. Rows proposed from the page are still keyed on phonemes[].ipa.
  • The open decisions above (vowel length, sentence formula, Bedouin g, insertions) are unchanged. Vowel-length rules show as undetectable in /api/health.