Skip to content

Pronunciation Fidelity Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make PhonoLex's phonology faithful — correct CMU's segmental errors at the loader, render stress into the IPA column, and stop shipping stress allophones as phonemic contrasts.

Architecture: Four root-level fixes in the build pipeline plus one no-silent-drop guard in the API. A curated pron-fix.tsv (mirroring root_corrections.py) overrides CMU pronunciations before any derived column is computed. ipa is re-rendered from the syllable structure the pipeline already builds, so stress lives in dedicated IPA symbols instead of riding on vowel identity. A stress-allophone predicate suppresses ə~ʌ / ɚ~ɝ substitution pairs at generation. WikiPron serves as a detector only — it is never ingested.

Tech Stack: Python 3.12 + Polars (packages/data), pytest; Hono + TypeScript + D1 (packages/web/workers), vitest with cloudflare:test; React + MUI (packages/web/frontend).

Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md

Global Constraints

  • Terminology: "feature vectors," never "embeddings."
  • IPA is canonical: ASCII g → IPA ɡ (U+0261). Use to_ipa() / normalize_phoneme().
  • Corrections are exact-surface-form. Never propagate to inflections. List thyme and thymes separately if both are wanted (thymes is absent from CMU).
  • A curated list that silently matches nothing is the worst outcome available. Every malformed or unmatched row fails the build with a ValueError naming file and line number. Same rule as root_corrections.py.
  • ipa is display-only. Consumers wrap it in slashes. Matching runs on phonemes_str, which this work must leave byte-identical except where a pronunciation was actually corrected.
  • Inventory stays at 41 phonemes. Do not collapse ə/ʌ or ɚ/ɝ — refuted in spec §2 (z = 16.8 on backness).
  • Never license a dialect merger automatically. Convention rules classify; they never pass/fail.
  • D1: 100 bind params per query (batch by 80), 100 columns per table. This work adds no columns.
  • Branch: feature branch off develop, PR targets develop. Never commit to develop directly.
  • Don't commit d1-seed.sql, data/runtime/*.parquet, or scripts/d1-chunks/ — all gitignored.

Task 1: pron-fix.tsv correction layer

Corrects CMU pronunciations at the loader, before any derived column exists. This is the fix for spec §1.1.

Files: - Create: packages/data/src/phonolex_data/loaders/pron_fix.py (not pipeline/loaders/ cannot import from pipeline/: pipeline/__init__ pulls in pipeline.edges, which imports loaders, so a pipeline import inside a loader makes import phonolex_data.loaders.cmudict circular. Found during execution; the test suite passed anyway because pytest happened to warm sys.modules first.) - Create: data/vocab/pron-fix.tsv - Modify: packages/data/src/phonolex_data/loaders/cmudict.py (add apply_corrections to cmudict_to_phono) - Test: packages/data/tests/test_pron_fix.py

Interfaces: - Produces: apply_pron_fix(cmu: dict[str, list[list[str]]], path: Path | None = None) -> int — mutates cmu in place, returns the number of words corrected. Raises ValueError on a malformed row, an unknown word, or an invalid ARPAbet symbol. - Produces: cmudict_to_phono(cmu=None, arpa_map=None, apply_corrections: bool = True) — the pipeline entry point, now correction-aware. - Consumed by: Task 3 (stress rendering sees corrected phonology), Task 7 (writes reviewed corrections into the same TSV).

Semantics: a word's rows in pron-fix.tsv replace its entire CMU variant list, in file order. One row = one pronunciation; the first row for a word becomes the primary. This matters for thyme: θaɪm must not survive as a variant, because it is not a real variant of thyme.

  • [ ] Step 1: Write the failing test
# packages/data/tests/test_pron_fix.py
"""Curated CMU pronunciation corrections (spec 2026-09-12 §1.1)."""
from pathlib import Path

import pytest

from phonolex_data.loaders.pron_fix import apply_pron_fix


def _tsv(tmp_path: Path, body: str) -> Path:
    p = tmp_path / "pron-fix.tsv"
    p.write_text("word\tarpabet\n" + body, encoding="utf-8")
    return p


def test_replaces_entire_variant_list(tmp_path):
    cmu = {"thyme": [["TH", "AY1", "M"]]}
    n = apply_pron_fix(cmu, _tsv(tmp_path, "thyme\tT AY1 M\n"))
    assert n == 1
    assert cmu["thyme"] == [["T", "AY1", "M"]]


def test_multiple_rows_become_ordered_variants(tmp_path):
    cmu = {"segue": [["S", "EH1", "G"]]}
    apply_pron_fix(cmu, _tsv(tmp_path, "segue\tS EH1 G W EY0\nsegue\tS EH1 G W EY1\n"))
    assert cmu["segue"] == [["S", "EH1", "G", "W", "EY0"], ["S", "EH1", "G", "W", "EY1"]]


def test_unknown_word_fails_the_build(tmp_path):
    cmu = {"thyme": [["TH", "AY1", "M"]]}
    with pytest.raises(ValueError, match="not in the CMU dictionary"):
        apply_pron_fix(cmu, _tsv(tmp_path, "notaword\tT AY1 M\n"))


def test_invalid_arpabet_symbol_fails_the_build(tmp_path):
    cmu = {"thyme": [["TH", "AY1", "M"]]}
    with pytest.raises(ValueError, match="not a valid ARPAbet symbol"):
        apply_pron_fix(cmu, _tsv(tmp_path, "thyme\tT QQ1 M\n"))


def test_wrong_column_count_fails_the_build(tmp_path):
    cmu = {"thyme": [["TH", "AY1", "M"]]}
    with pytest.raises(ValueError, match="expected 'word<TAB>arpabet'"):
        apply_pron_fix(cmu, _tsv(tmp_path, "thyme\n"))


def test_trailing_comment_is_not_silently_swallowed(tmp_path):
    """root-deny shipped with exactly this bug: a trailing `#` parsed into the value."""
    cmu = {"thyme": [["TH", "AY1", "M"]]}
    with pytest.raises(ValueError, match="not a valid ARPAbet symbol"):
        apply_pron_fix(cmu, _tsv(tmp_path, "thyme\tT AY1 M  # silent-letter\n"))


def test_missing_file_is_a_noop(tmp_path):
    cmu = {"thyme": [["TH", "AY1", "M"]]}
    assert apply_pron_fix(cmu, tmp_path / "absent.tsv") == 0
    assert cmu["thyme"] == [["TH", "AY1", "M"]]
  • [ ] Step 2: Run test to verify it fails

Run: uv run python -m pytest packages/data/tests/test_pron_fix.py -v Expected: FAIL — ModuleNotFoundError: No module named 'phonolex_data.pipeline.pron_fix'

  • [ ] Step 3: Write the implementation
# packages/data/src/phonolex_data/loaders/pron_fix.py
"""Curated corrections to CMU pronunciations (spec 2026-09-12 §1.1).

    data/vocab/pron-fix.tsv    word<TAB>ARPAbet

CMU is otherwise an unchallenged authority on the one column every other column
is derived from, and it carries real errors: `THYME TH AY1 M` (should be
`T AY1 M`), `VISCOUNT V IH1 S K AW0 N T`, plus a θ/ð misfiling class
(`LOATHING`, `BLITHELY`, `FURTHEST`) that puts words in the wrong therapy
bucket. Upstream `cmusphinx/cmudict` master was checked on 2026-09-12 and is
still wrong on all of these, so there is no free upgrade path.

Corrections are held in ARPAbet rather than IPA deliberately: the file then
reads as a patch against cmudict and flows through the existing
`arpa_to_ipa` + stress-digit machinery unchanged, so a correction cannot
accidentally bypass normalization.

A word's rows REPLACE its entire variant list, in file order (first row =
primary). Replacement rather than merge matters for `thyme`: /θaɪm/ must not
survive as a variant, because it is not a variant — it is an error.

**A bad row fails the build rather than being skipped.** A curated list that
quietly matches nothing is the worst outcome available: the build reports
success, the data is unchanged, and nobody looks again. `root-deny.tsv` shipped
with exactly that bug (it stripped whole-line `#` comments but not trailing
ones, so the comment parsed into the value and matched nothing).

Corrections are exact-surface-form and never propagate to inflections — the
same rule as the canonical deny/allow lists.
"""
from __future__ import annotations

from pathlib import Path

from phonolex_data.loaders._helpers import get_data_dir
from phonolex_data.mappings import load_arpa_to_ipa

_HEADER = "word\tarpabet"


def _valid_arpabet() -> set[str]:
    """Every ARPAbet symbol the IPA map knows, stress digits stripped."""
    return {k.rstrip("012") for k in load_arpa_to_ipa()}


def _read_rows(path: Path, cmu: dict[str, list[list[str]]]) -> dict[str, list[list[str]]]:
    """Parse word<TAB>ARPAbet, validating every row. Returns {word: [variant, ...]}."""
    valid = _valid_arpabet()
    out: dict[str, list[list[str]]] = {}
    for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw.strip()
        if not line or line.startswith("#") or line == _HEADER:
            continue
        parts = line.split("\t")
        if len(parts) != 2:
            raise ValueError(
                f"{path.name}:{lineno}: expected 'word<TAB>arpabet', "
                f"got {len(parts)} column(s): {line!r}"
            )
        word, arpa = parts[0].strip().lower(), parts[1].strip()
        if not word or not arpa:
            raise ValueError(f"{path.name}:{lineno}: empty word or pronunciation")
        if word not in cmu:
            raise ValueError(
                f"{path.name}:{lineno}: '{word}' is not in the CMU dictionary"
            )
        phones = arpa.split()
        for ph in phones:
            if ph.rstrip("012") not in valid:
                raise ValueError(
                    f"{path.name}:{lineno}: {ph!r} is not a valid ARPAbet symbol"
                )
        out.setdefault(word, []).append(phones)
    return out


def apply_pron_fix(
    cmu: dict[str, list[list[str]]], path: Path | None = None
) -> int:
    """Replace CMU pronunciations from the curated TSV. Returns words corrected."""
    p = path or (get_data_dir() / "vocab" / "pron-fix.tsv")
    if not p.exists():
        return 0
    fixes = _read_rows(p, cmu)
    for word, variants in fixes.items():
        cmu[word] = variants
    return len(fixes)
  • [ ] Step 4: Run test to verify it passes

Run: uv run python -m pytest packages/data/tests/test_pron_fix.py -v Expected: PASS (7 tests)

  • [ ] Step 5: Create the seed correction file
# data/vocab/pron-fix.tsv
# Curated corrections to CMU pronunciations. See
# docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §1.1
#
# word<TAB>ARPAbet. A word's rows REPLACE its entire CMU variant list, in
# order (first row = primary). Exact surface form only — never propagates to
# inflections. A bad row fails the build.
#
# Reference for every entry below: Merriam-Webster, cross-checked against
# Wiktionary (en-US). Each was verified wrong in data/cmu/cmudict-0.7b AND in
# cmusphinx/cmudict master as of 2026-09-12.
word    arpabet
# -- silent-letter / loanword spelling pronunciations --
thyme   T AY1 M
thaler  T AA1 L ER0
viscount    V AY1 K AW0 N T
# -- plain segmental errors --
laugher L AE1 F ER0
# -- θ/ð misfiling: these are /ð/, CMU has TH. /θ/-/ð/ is a core SLP contrast,
#    so these words were in the wrong therapy bucket.
loathing    L OW1 DH IH0 NG
blithely    B L AY1 DH L IY0
furthest    F ER1 DH AH0 S T
  • [ ] Step 6: Wire it into the loader

In packages/data/src/phonolex_data/loaders/cmudict.py, add the import and thread the flag through cmudict_to_phono:

from phonolex_data.loaders.pron_fix import apply_pron_fix

Change the signature and body:

def cmudict_to_phono(
    cmu: dict[str, list[list[str]]] | None = None,
    arpa_map: dict[str, str] | None = None,
    apply_corrections: bool = True,
) -> dict[str, dict[str, Any]]:
    ...
    if cmu is None:
        cmu = load_cmudict()
    if arpa_map is None:
        arpa_map = load_arpa_to_ipa()
    # Correct CMU's errors before anything is derived from them (spec §1.1).
    # Tests injecting a fixture `cmu` pass apply_corrections=False.
    if apply_corrections:
        apply_pron_fix(cmu)

Extend the docstring to say corrections are applied.

  • [ ] Step 7: Add the end-to-end test
# append to packages/data/tests/test_pron_fix.py
from phonolex_data.loaders.cmudict import cmudict_to_phono, load_cmudict


def test_thyme_is_corrected_end_to_end():
    """The headline bug: thyme must be /taɪm/, a homophone of time."""
    phono = cmudict_to_phono()
    assert phono["thyme"]["ipa"] == "taɪm"
    assert phono["thyme"]["ipa"] == phono["time"]["ipa"]
    # /θaɪm/ must not survive as a variant — it is an error, not a variant.
    assert all(v["ipa"] == "taɪm" for v in phono["thyme"]["variants"])


def test_theta_eth_misfilings_are_corrected():
    phono = cmudict_to_phono()
    for w in ("loathing", "blithely", "furthest"):
        assert "ð" in phono[w]["phonemes"], f"{w} should contain ð"
        assert "θ" not in phono[w]["phonemes"], f"{w} should not contain θ"


def test_corrections_can_be_disabled_for_fixtures():
    raw = {"thyme": [["TH", "AY1", "M"]]}
    phono = cmudict_to_phono(cmu=raw, apply_corrections=False)
    assert phono["thyme"]["ipa"] == "θaɪm"
  • [ ] Step 8: Run the full data suite

Run: uv run python -m pytest packages/data/tests/test_pron_fix.py packages/data/tests/test_pipeline.py -v Expected: PASS. If test_pipeline.py asserts on any corrected word, update the expectation and note it in the commit body.

  • [ ] Step 9: Commit
git add packages/data/src/phonolex_data/loaders/pron_fix.py \
        packages/data/tests/test_pron_fix.py \
        data/vocab/pron-fix.tsv \
        packages/data/src/phonolex_data/loaders/cmudict.py
git commit -m "feat(phonology): curated CMU pronunciation corrections (pron-fix.tsv)

CMU is wrong on thyme (TH AY1 M), thaler, viscount, laugher, and misfiles
loathing/blithely/furthest as /θ/ when they are /ð/ — a core SLP contrast, so
those words sat in the wrong therapy bucket. Upstream cmusphinx/cmudict master
still carries all of them, so there is no upgrade path.

Corrections live in ARPAbet so the file reads as a cmudict patch and flows
through the existing normalization. A word's rows replace its whole variant
list: /θaɪm/ must not survive as a 'variant' of thyme. A malformed row, unknown
word, or invalid ARPAbet symbol fails the build.

Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §1.1"

Task 2: Correct CMU's wrong variant order (COMPLETE — approach reversed)

Fixes spec §1.2 — segue shipped as /sɛɡ/ because CMU's variant order is not quality-ordered and we take index 0.

Executed 2026-09-12. The planned shape heuristic was implemented, measured, and reverted. _select_primary ("prefer the longest variant that has a shorter strict prefix") changed 79 of 8,017 multi-variant primaries and ~70 were wrong: 18 added an inflectional suffix (corps→/kɔɹz/, disability→/dɪsəbɪlɪtiz/), ~50 selected a different word (cache→/kæʃeɪ/ cachet, corp→/kɔɹpɚeɪʃən/ corporation, al.Alabama). Unsound rather than mistuned: a truncation is the full form minus a tail and a plural is the singular plus a tail, so shape cannot separate them.

Delivered instead: segue corrected via data/vocab/pron-fix.tsv (the Task 1 mechanism), converted[0] retained with a comment recording why no heuristic belongs there, and test_shape_heuristic_would_break_inflections / test_shape_heuristic_would_break_distinct_words pinning the words the rule broke. Discovery of further truncated primaries is Task 6's job; correction is Task 7's.

Acceptance criterion §7.4 (segue primary is sɛɡweɪ) is met.


Task 3: Render stress into the ipa column

Fixes spec §1.3 — ipa carries ˈ/ˌ in 0 of 125,756 rows, so ablate ships as ʌbleɪt instead of /əˈbleɪt/.

Files: - Create: packages/data/src/phonolex_data/phonology/ipa_render.py - Modify: packages/data/src/phonolex_data/pipeline/words.py:106-150 (_build_phonological_record) - Test: packages/data/tests/test_ipa_render.py

Interfaces: - Produces: render_ipa(syllables: list[dict]) -> str — takes the same syllable dicts _build_phonological_record already builds ({"onset": [str], "nucleus": str, "coda": [str], "stress": int | None}) and returns a stress-marked IPA string. - Consumed by: _build_phonological_record, which currently passes the loader's pre-joined ipa through untouched.

Convention: ˈ for primary (stress 1), ˌ for secondary (stress 2), nothing otherwise. Monosyllables get no mark — standard IPA practice, and it keeps thyme as taɪm.

  • [ ] Step 1: Write the failing test
# packages/data/tests/test_ipa_render.py
"""Stress-marked IPA rendering (spec 2026-09-12 §1.3)."""
from phonolex_data.phonology.ipa_render import render_ipa


def _syl(onset, nucleus, coda, stress):
    return {"onset": list(onset), "nucleus": nucleus, "coda": list(coda), "stress": stress}


def test_monosyllable_gets_no_stress_mark():
    assert render_ipa([_syl(["t"], "aɪ", ["m"], 1)]) == "taɪm"


def test_primary_stress_marked_on_polysyllable():
    # ablate: ə-ˈbleɪt
    syls = [_syl([], "ə", [], 0), _syl(["b", "l"], "eɪ", ["t"], 1)]
    assert render_ipa(syls) == "əˈbleɪt"


def test_secondary_stress_uses_the_secondary_mark():
    # abductee: ˌæb-dʌk-ˈti
    syls = [_syl([], "æ", ["b"], 2), _syl(["d"], "ʌ", ["k"], 0), _syl(["t"], "i", [], 1)]
    assert render_ipa(syls) == "ˌæbdʌkˈti"


def test_unstressed_syllables_get_nothing():
    syls = [_syl(["ð"], "ə", [], 0), _syl(["b"], "ʌ", ["t"], 1)]
    assert render_ipa(syls) == "ðəˈbʌt"


def test_none_stress_is_treated_as_unstressed():
    syls = [_syl(["k"], "ə", [], None), _syl(["t"], "i", [], 1)]
    assert render_ipa(syls) == "kəˈti"


def test_empty_input_is_empty_string():
    assert render_ipa([]) == ""


def test_segments_are_concatenated_in_order():
    syls = [_syl(["s", "t"], "ɹ", ["ɛ", "ŋ"], 1)]
    assert render_ipa(syls) == "stɹɛŋ"
  • [ ] Step 2: Run test to verify it fails

Run: uv run python -m pytest packages/data/tests/test_ipa_render.py -v Expected: FAIL — ModuleNotFoundError: No module named 'phonolex_data.phonology.ipa_render'

  • [ ] Step 3: Write the implementation
# packages/data/src/phonolex_data/phonology/ipa_render.py
"""Render a stress-marked IPA string from syllable structure (spec §1.3).

ARPAbet fuses stress to the vowel: there is a single `AH` phone and the stress
digit does the work (`AH0`→ə, `AH1`/`AH2`→ʌ). IPA instead has dedicated
suprasegmental symbols. Transliterating phone-by-phone into IPA and then not
using those symbols left the stress information nowhere to go but the vowel
identity — which is how ə/ʌ and ɚ/ɝ came to carry stress, and how 181 false
"phonemic contrast" pairs got manufactured (see `phonology.allophones`).

Stress is a property of the SYLLABLE, not of a segment. The per-index
`stress_pattern` the loader builds is itself the ARPAbet-ism — a stress value
hanging off a segment because that is where the digit happened to sit. So
render from the syllable structure the pipeline already has.

Monosyllables carry no mark, per standard IPA practice.
"""
from __future__ import annotations

from typing import Any

PRIMARY = "ˈ"
SECONDARY = "ˌ"


def render_ipa(syllables: list[dict[str, Any]]) -> str:
    """Concatenate syllables into IPA, prefixing stress marks.

    Args:
        syllables: dicts with 'onset' (list[str]), 'nucleus' (str),
            'coda' (list[str]) and 'stress' (1 primary, 2 secondary,
            0 or None unstressed) — the shape `_build_phonological_record`
            already builds for the `syllables` column.
    """
    if not syllables:
        return ""
    mark_at_all = len(syllables) > 1
    parts: list[str] = []
    for syl in syllables:
        stress = syl.get("stress")
        if mark_at_all and stress == 1:
            parts.append(PRIMARY)
        elif mark_at_all and stress == 2:
            parts.append(SECONDARY)
        parts.append("".join(syl.get("onset") or []))
        parts.append(str(syl.get("nucleus") or ""))
        parts.append("".join(syl.get("coda") or []))
    return "".join(parts)
  • [ ] Step 4: Run test to verify it passes

Run: uv run python -m pytest packages/data/tests/test_ipa_render.py -v Expected: PASS (7 tests)

  • [ ] Step 5: Wire it into the record builder

In packages/data/src/phonolex_data/pipeline/words.py, add the import:

from phonolex_data.phonology.ipa_render import render_ipa

In _build_phonological_record, delete the ipa = phono_data.get("ipa", "") line at the top and instead derive it after syllables is built:

    phonemes = [normalize_phoneme(p) for p in phonemes_raw]

    # `ipa` is rendered from the syllable structure so stress lives in the IPA
    # suprasegmental symbols (ˈ/ˌ) rather than riding on vowel identity
    # (spec §1.3). The loader's pre-joined `ipa` ignored stress entirely.
    ipa = render_ipa(syllables)

    wcm = compute_wcm(phonemes, syllables)

ipa is already passed to WordRecord(... ipa=ipa ...), so no further change is needed there.

  • [ ] Step 6: Add the pipeline-level test
# append to packages/data/tests/test_ipa_render.py
from phonolex_data.loaders.cmudict import cmudict_to_phono
from phonolex_data.pipeline.words import _build_phonological_record


def test_pipeline_emits_stress_marked_ipa():
    phono = cmudict_to_phono()
    rec = _build_phonological_record({**phono["ablate"], "word": "ablate"})
    assert rec.ipa == "əˈbleɪt", rec.ipa


def test_pipeline_leaves_monosyllables_unmarked():
    phono = cmudict_to_phono()
    rec = _build_phonological_record({**phono["thyme"], "word": "thyme"})
    assert rec.ipa == "taɪm"


def test_phonemes_str_is_unaffected_by_stress_marks():
    """Matching runs on phonemes_str — it must stay free of suprasegmentals."""
    phono = cmudict_to_phono()
    rec = _build_phonological_record({**phono["ablate"], "word": "ablate"})
    assert "ˈ" not in "".join(rec.phonemes)
    assert "ˌ" not in "".join(rec.phonemes)

Note: ablate's CMU entry is AH2 B L EY1 T, which renders ˌʌbˈleɪt before any stress correction. Task 3 does not fix CMU's over-assigned secondary stress (out of scope per spec §4.1). If this test fails with ˌʌbˈleɪt, that is the correct current behaviour — change the assertion to "ˌʌbˈleɪt" and add a comment pointing at spec §4.1. Do not add a stress correction here.

  • [ ] Step 7: Run the suite

Run: uv run python -m pytest packages/data/tests/test_ipa_render.py packages/data/tests/test_pipeline.py packages/data/tests/test_cv_shape.py -v Expected: PASS

  • [ ] Step 8: Commit
git add packages/data/src/phonolex_data/phonology/ipa_render.py \
        packages/data/tests/test_ipa_render.py \
        packages/data/src/phonolex_data/pipeline/words.py
git commit -m "feat(phonology): render stress into the ipa column

The ipa column carried ˈ/ˌ in 0 of 125,756 rows: _build_phonological_record
took the loader's pre-joined string and ignored the syllable structure it
builds four lines later. ablate shipped as ʌbleɪt, which is not faithful IPA.

ARPAbet fuses stress to the vowel (one AH phone, digit does the work); IPA has
dedicated suprasegmentals. Transliterating and then not using them left stress
nowhere to go but vowel identity — the root of the ə/ʌ contrast problem.

Stress is a syllable property, so render from syllables. Monosyllables
unmarked, per IPA practice. phonemes_str (the matching column) untouched.

Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §1.3"

Task 4: Suppress ə~ʌ / ɚ~ɝ as phonemic contrasts

Fixes spec §1.4 — 86 ə~ʌ and 95 ɚ~ɝ substitution pairs ship as phonemic contrasts, including advertisers/advertisers'.

Files: - Create: packages/data/src/phonolex_data/phonology/allophones.py - Modify: packages/data/src/phonolex_data/pipeline/derived.py (import + skip in the substitution loop at 325-331) - Test: packages/data/tests/test_allophones.py

Interfaces: - Produces: STRESS_ALLOPHONES: frozenset[frozenset[str]] and is_stress_allophone_pair(p1: str, p2: str) -> bool. - Consumed by: derived.py's _compute_minimal_pairs; mirrored in TypeScript by Task 5.

  • [ ] Step 1: Write the failing test
# packages/data/tests/test_allophones.py
"""Stress allophones are not phonemic oppositions (spec 2026-09-12 §1.4)."""
from phonolex_data.phonology.allophones import (
    STRESS_ALLOPHONES,
    is_stress_allophone_pair,
)


def test_schwa_and_wedge_are_allophones():
    assert is_stress_allophone_pair("ə", "ʌ")
    assert is_stress_allophone_pair("ʌ", "ə")


def test_rhotic_pair_are_allophones():
    assert is_stress_allophone_pair("ɚ", "ɝ")
    assert is_stress_allophone_pair("ɝ", "ɚ")


def test_real_contrasts_are_not_suppressed():
    for a, b in [("θ", "t"), ("θ", "ð"), ("ə", "ɪ"), ("ɑ", "ɔ"), ("ʌ", "ɑ"), ("ɚ", "ə")]:
        assert not is_stress_allophone_pair(a, b), f"{a}~{b} is a real contrast"


def test_identical_phonemes_are_not_a_pair():
    assert not is_stress_allophone_pair("ə", "ə")


def test_exactly_two_allophone_sets():
    """Guard against scope creep — the audit found exactly these two."""
    assert STRESS_ALLOPHONES == frozenset({
        frozenset({"ə", "ʌ"}),
        frozenset({"ɚ", "ɝ"}),
    })
  • [ ] Step 2: Run test to verify it fails

Run: uv run python -m pytest packages/data/tests/test_allophones.py -v Expected: FAIL — ModuleNotFoundError: No module named 'phonolex_data.phonology.allophones'

  • [ ] Step 3: Write the implementation
# packages/data/src/phonolex_data/phonology/allophones.py
"""Stress-conditioned allophones in the CMU encoding (spec §1.4).

`data/mappings/arpa_to_ipa.json` has exactly two stress-conditioned entries out
of 84: `AH0`→ə vs `AH1`/`AH2`→ʌ, and `ER0`→ɚ vs `ER1`/`ER2`→ɝ. Everything else
is stress-invariant (`IH0`/`IH1`/`IH2` all → ɪ).

Audited across all 125,756 entries, with zero exceptions:

    ə  appears only at syllable stress 0   (58,263)
    ɚ  appears only at syllable stress 0   (22,330)
    ʌ  appears only at syllable stress 1/2  (7,504)
    ɝ  appears only at syllable stress 1/2  (4,850)

Perfect complementary distribution: these carry no contrastive load, so no
minimal pair can distinguish them. Emitting them as substitution pairs produced
86 ə~ʌ and 95 ɚ~ɝ false contrasts — including `advertisers`/`advertisers'`, a
"minimal pair" between a word and its own possessive, differing only in CMU's
stress digit on the same phone. A clinician asked to contrast /ə/ vs /ʌ/ is
being asked to do something impossible.

They are NOT merged in the inventory. The learned feature vectors separate ə
from ʌ on backness by z = 16.8 (ə 0.125±0.098, ʌ 1.908±0.040), and ʌ is the 3rd
most certain of 58 segments — that is evidence, not prior. Both facts reconcile:
the acoustic difference IS the realization of the stress difference, because
unstressed centralization is real. So: one phoneme, two stress-conditioned
allophones, phonetically distinct. Similarity SHOULD score them close-but-not-
equal; the contrast layer must not offer them as an opposition.
"""
from __future__ import annotations

#: Phoneme pairs that are stress allophones, not phonemic oppositions.
STRESS_ALLOPHONES: frozenset[frozenset[str]] = frozenset({
    frozenset({"ə", "ʌ"}),
    frozenset({"ɚ", "ɝ"}),
})


def is_stress_allophone_pair(p1: str, p2: str) -> bool:
    """True if p1 and p2 are stress allophones of one phoneme."""
    if p1 == p2:
        return False
    return frozenset({p1, p2}) in STRESS_ALLOPHONES
  • [ ] Step 4: Run test to verify it passes

Run: uv run python -m pytest packages/data/tests/test_allophones.py -v Expected: PASS (5 tests)

  • [ ] Step 5: Wire it into pair generation

In packages/data/src/phonolex_data/pipeline/derived.py, add the import:

from phonolex_data.phonology.allophones import is_stress_allophone_pair

In _compute_minimal_pairs, immediately after ph1, ph2 = phoneme_of[pa_], phoneme_of[pb_]:

                        ph1, ph2 = phoneme_of[pa_], phoneme_of[pb_]
                        # ə~ʌ and ɚ~ɝ are stress allophones, not a phonemic
                        # opposition — emitting them manufactured 181 false
                        # contrasts (spec §1.4).
                        if is_stress_allophone_pair(ph1, ph2):
                            continue

Extend _compute_minimal_pairs's docstring with a line noting the exclusion.

  • [ ] Step 6: Add the generation-level test
# append to packages/data/tests/test_allophones.py
from phonolex_data.pipeline.derived import _compute_minimal_pairs


def test_allophone_pairs_are_not_emitted():
    """A minimal fixture whose only distance-1 relation is ə~ʌ."""
    words = {
        "aaa": type("R", (), {"phonemes": ["k", "ə", "t"], "word": "aaa"})(),
        "bbb": type("R", (), {"phonemes": ["k", "ʌ", "t"], "word": "bbb"})(),
        "ccc": type("R", (), {"phonemes": ["k", "ɪ", "t"], "word": "ccc"})(),
    }
    rows = _compute_minimal_pairs(words, {})
    subs = [(r[0], r[1], r[2], r[3]) for r in rows if r[8] == "substitution"]
    assert not any({r[2], r[3]} == {"ə", "ʌ"} for r in subs), subs
    # the real ə~ɪ and ʌ~ɪ contrasts survive
    assert any({r[2], r[3]} == {"ə", "ɪ"} for r in subs), subs
    assert any({r[2], r[3]} == {"ʌ", "ɪ"} for r in subs), subs

Check _compute_minimal_pairs's actual signature before running — adapt the fixture shape to whatever it takes (it reads .phonemes off word records and a pair_distances mapping). If the real signature differs, match it exactly rather than changing the production code to suit the test.

  • [ ] Step 7: Run the suite

Run: uv run python -m pytest packages/data/tests/test_allophones.py packages/data/tests/test_derived.py -v Expected: PASS

  • [ ] Step 8: Commit
git add packages/data/src/phonolex_data/phonology/allophones.py \
        packages/data/tests/test_allophones.py \
        packages/data/src/phonolex_data/pipeline/derived.py
git commit -m "fix(pairs): stop emitting stress allophones as phonemic contrasts

arpa_to_ipa has exactly two stress-conditioned entries: AH0→ə vs AH1/AH2→ʌ,
ER0→ɚ vs ER1/ER2→ɝ. Audited over 125,756 entries with zero exceptions, ə/ɚ
occur only at stress 0 and ʌ/ɝ only at stress 1-2 — perfect complementary
distribution, so they carry no contrastive load.

We shipped 86 ə~ʌ and 95 ɚ~ɝ substitution pairs anyway, including
advertisers/advertisers': a 'minimal pair' between a word and its own
possessive, differing only in a stress digit.

Not merged in the inventory — the learned vectors separate ə from ʌ on backness
at z=16.8, and ʌ is the 3rd most certain of 58 segments. One phoneme, two
stress-conditioned allophones, phonetically distinct: similarity should see
them as close, the contrast layer must not offer them as an opposition.

Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §1.4"

Task 5: Explain the suppression instead of returning empty

Fixes spec §6b. Task 4 removes 181 pairs, but the phoneme picker still offers ə and ʌ — so a user selecting them would get a silent empty result. This project treats a silent drop as a regression.

Files: - Create: packages/web/workers/src/config/allophones.ts - Modify: packages/web/workers/src/routes/contrastive.ts (top of the /minimal-pairs handler, line ~329) - Test: packages/web/workers/src/__tests__/allophoneContrast.test.ts

Interfaces: - Produces: STRESS_ALLOPHONES: ReadonlyArray<readonly [string, string]>, isStressAllophonePair(a: string, b: string): boolean, and the 422 response body { error: 'not_a_contrast', phoneme1, phoneme2, detail: string }. - Mirrors: phonolex_data.phonology.allophones (Task 4). The two must stay in sync — the TS file's comment must name the Python module, in the same way scripts/config.py mirrors config/properties.ts.

  • [ ] Step 1: Write the failing test
// packages/web/workers/src/__tests__/allophoneContrast.test.ts
import { describe, it, expect } from 'vitest';
import { SELF } from 'cloudflare:test';

describe('/api/contrastive/minimal-pairs — stress allophones', () => {
  it('explains ə~ʌ instead of returning an empty list', async () => {
    const res = await SELF.fetch('https://x/api/contrastive/minimal-pairs', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ phoneme1: 'ə', phoneme2: 'ʌ' }),
    });
    expect(res.status).toBe(422);
    const body = await res.json();
    expect(body.error).toBe('not_a_contrast');
    expect(body.detail).toMatch(/stress/i);
  });

  it('explains ɚ~ɝ in either order', async () => {
    for (const [a, b] of [['ɚ', 'ɝ'], ['ɝ', 'ɚ']]) {
      const res = await SELF.fetch('https://x/api/contrastive/minimal-pairs', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ phoneme1: a, phoneme2: b }),
      });
      expect(res.status).toBe(422);
    }
  });

  it('still serves a real contrast', async () => {
    const res = await SELF.fetch('https://x/api/contrastive/minimal-pairs', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ phoneme1: 'θ', phoneme2: 't' }),
    });
    expect(res.status).toBe(200);
    expect(Array.isArray(await res.json())).toBe(true);
  });

  it('does not block ə~ɪ, which is a real contrast', async () => {
    const res = await SELF.fetch('https://x/api/contrastive/minimal-pairs', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ phoneme1: 'ə', phoneme2: 'ɪ' }),
    });
    expect(res.status).toBe(200);
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: cd packages/web/workers && npx vitest run src/__tests__/allophoneContrast.test.ts Expected: FAIL — first case returns 200 with []

  • [ ] Step 3: Write the config module
// packages/web/workers/src/config/allophones.ts
/**
 * Stress-conditioned allophones in the CMU encoding.
 *
 * MIRROR of `phonolex_data.phonology.allophones` — keep the two in sync, the
 * same way `scripts/config.py` mirrors `config/properties.ts`.
 *
 * `arpa_to_ipa` has exactly two stress-conditioned entries out of 84: AH0→ə
 * vs AH1/AH2→ʌ, and ER0→ɚ vs ER1/ER2→ɝ. Across all 125,756 entries ə/ɚ occur
 * only at syllable stress 0 and ʌ/ɝ only at stress 1-2, with zero exceptions,
 * so these pairs carry no contrastive load and no minimal pair can distinguish
 * them. The build no longer emits them as substitution pairs.
 *
 * They are NOT merged in the inventory — the learned feature vectors separate
 * ə from ʌ on backness at z = 16.8. One phoneme, two stress-conditioned
 * allophones, phonetically distinct.
 *
 * Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §1.4
 */
export const STRESS_ALLOPHONES: ReadonlyArray<readonly [string, string]> = [
  ['ə', 'ʌ'],
  ['ɚ', 'ɝ'],
] as const;

export function isStressAllophonePair(a: string, b: string): boolean {
  if (a === b) return false;
  return STRESS_ALLOPHONES.some(([x, y]) => (a === x && b === y) || (a === y && b === x));
}

/** Explanatory body for a request that names a stress-allophone "contrast". */
export function notAContrastBody(phoneme1: string, phoneme2: string) {
  return {
    error: 'not_a_contrast' as const,
    phoneme1,
    phoneme2,
    detail:
      `/${phoneme1}/ and /${phoneme2}/ are not a phonemic contrast in English — ` +
      `they are the same vowel realized differently depending on stress ` +
      `(/${phoneme2}/ stressed, /${phoneme1}/ unstressed). No minimal pair can ` +
      `distinguish them, so there is nothing to contrast.`,
  };
}
  • [ ] Step 4: Wire it into the route

In packages/web/workers/src/routes/contrastive.ts, add the import near the other config imports:

import { isStressAllophonePair, notAContrastBody } from '../config/allophones';

At the top of the /minimal-pairs handler (line ~329), immediately after the request body is parsed and phoneme1/phoneme2 are in scope:

  // Stress allophones are not a phonemic opposition, and the build no longer
  // emits pairs for them. Say so rather than returning [] — a silent empty
  // result is a regression, not an answer. See config/allophones.ts.
  if (isStressAllophonePair(body.phoneme1, body.phoneme2)) {
    return c.json(notAContrastBody(body.phoneme1, body.phoneme2), 422);
  }

Place this after any existing normalization of the incoming phonemes (normalizePhoneme) so g/ɡ-style variance cannot slip past — check the handler and match its existing order.

  • [ ] Step 5: Run test to verify it passes

Run: cd packages/web/workers && npx vitest run src/__tests__/allophoneContrast.test.ts Expected: PASS (4 tests)

  • [ ] Step 6: Surface it in the frontend

Find the Contrast Sets error path that handles a non-200 from /minimal-pairs:

grep -rn "minimal-pairs" packages/web/frontend/src --include="*.ts" --include="*.tsx"

Render detail from a 422 as an inline explanatory message in the results area (not a toast — the user needs it next to the selection they made). Follow the existing empty/error state pattern in the Contrast Sets tool rather than inventing a new one. Copy rules: see the frontend-copy skill — no em-dash-heavy AI phrasing, plain clinical register.

  • [ ] Step 7: Run the frontend checks

Run:

cd packages/web/frontend && npm run build && npx tsc --noEmit && npm run lint
Expected: all pass

  • [ ] Step 8: Commit
git add packages/web/workers/src/config/allophones.ts \
        packages/web/workers/src/__tests__/allophoneContrast.test.ts \
        packages/web/workers/src/routes/contrastive.ts \
        packages/web/frontend/src
git commit -m "feat(contrastive): explain stress-allophone 'contrasts' instead of returning []

Task 4 stopped emitting the 181 ə~ʌ / ɚ~ɝ pairs, but the phoneme picker still
offers both symbols — so selecting them would have returned an empty list with
no explanation. A silent drop is a regression, not an answer.

/minimal-pairs now answers 422 not_a_contrast with a clinician-readable reason:
same vowel, different stress, no minimal pair can distinguish them. Real
contrasts including ə~ɪ are unaffected.

config/allophones.ts mirrors phonolex_data.phonology.allophones — keep in sync.
Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §6b"

Task 6: WikiPron detector + convention classifier

Builds the reproducible sweep that finds the rest of CMU's segmental errors (spec §3, §4). Detector only — WikiPron data is never ingested and never committed.

Files: - Create: research/2026-09-12-pronunciation-audit/fetch_wikipron.py - Create: research/2026-09-12-pronunciation-audit/classify_diffs.py - Create: research/2026-09-12-pronunciation-audit/README.md - Create: research/2026-09-12-pronunciation-audit/.gitignore (contents: *.tsv, out/)

Interfaces: - Produces: out/candidates.tsv with columns word is_canonical ours_ipa wikipron_ipa axis family verdict where axis ∈ {consonant, vowel}, family is a rule-family name or UNEXPLAINED, and verdict is blank for a human to fill. - Consumed by: Task 7 (review), which turns accepted rows into pron-fix.tsv entries.

Method (from the workup, reproduce exactly): 1. Map WikiPron's 240 symbols into our 41-phoneme inventory with an explicit table. 0.2% of rows are unmappable and are dropped with a logged count. 2. Project our side down to the comparison form before diffing: ʌ→ə, ɝ→ɚ. These are stress claims, not segmental disagreements (spec §4.1) — comparing them raw manufactures 412 phantom diffs. 3. Classify each aligned slot diff into a rule family. Auto-clear the notation tier. Never auto-clear a dialect merger. 4. Emit everything not notation-cleared as a candidate.

  • [ ] Step 1: Write the fetch script
# research/2026-09-12-pronunciation-audit/fetch_wikipron.py
"""Fetch WikiPron's en-US broad lexicon. Detector input only — never ingested.

Code is Apache-2.0; the data/ directory inherits Wiktionary's CC BY-SA. We ship
none of these strings: they are used only to generate a candidate list a human
reviews against Merriam-Webster. The resulting curated corrections in
pron-fix.tsv are our own hand-verified facts, not a derivative database.

The TSV is gitignored. Re-run this script to reproduce.
"""
from __future__ import annotations

import urllib.request
from pathlib import Path

URL = (
    "https://raw.githubusercontent.com/CUNY-CL/wikipron/master/"
    "data/scrape/tsv/eng_latn_us_broad.tsv"
)
OUT = Path(__file__).parent / "eng_latn_us_broad.tsv"


def main() -> None:
    if OUT.exists():
        print(f"already present: {OUT} ({OUT.stat().st_size:,} bytes)")
        return
    print(f"fetching {URL}")
    urllib.request.urlretrieve(URL, OUT)
    n = sum(1 for _ in OUT.open(encoding="utf-8"))
    print(f"wrote {OUT} ({n:,} rows)")


if __name__ == "__main__":
    main()
  • [ ] Step 2: Write the classifier
# research/2026-09-12-pronunciation-audit/classify_diffs.py
"""Classify our-vs-WikiPron pronunciation diffs into convention families.

Rules CLASSIFY, they never gate. Every convention licensed to reduce noise
blinds the detector in that dimension. The notation tier is free — nobody
contrasts ə vs ɪ in an unstressed syllable. The dialect mergers ARE clinical
targets sitting in 179,160 vowel substitution pairs, so no merger family is
auto-cleared; each gets one bulk review decision.

θ→t is licensed by no rule and never will be, so `thyme` cannot hide.

Usage:
    uv run python research/2026-09-12-pronunciation-audit/classify_diffs.py
"""
from __future__ import annotations

import collections
import csv
import unicodedata
from pathlib import Path

import polars as pl

HERE = Path(__file__).parent
WIKIPRON = HERE / "eng_latn_us_broad.tsv"
WORDS = Path(__file__).resolve().parents[2] / "data" / "runtime" / "words.parquet"
OUT = HERE / "out" / "candidates.tsv"

VOWELS = set("iɪeɛæɑɔoʊuʌəɚɝ") | {"aɪ", "aʊ", "eɪ", "oʊ", "ɔɪ"}

# Explicit WikiPron -> our inventory map. Every entry is a phonological
# judgment; keep them visible rather than inferring.
VMAP = {
    "iː": "i", "i": "i", "ɪ": "ɪ", "ɪ̯": "ɪ", "e": "eɪ", "ɛ": "ɛ", "ɛː": "ɛ",
    "æ": "æ", "a": "ɑ", "aː": "ɑ", "ɑ": "ɑ", "ɑː": "ɑ", "ɒ": "ɑ", "ɔ": "ɔ",
    "ɔː": "ɔ", "o": "oʊ", "oː": "oʊ", "ʊ": "ʊ", "ʊ̯": "ʊ", "u": "u", "uː": "u",
    "ʉ": "u", "ʌ": "ʌ", "ə": "ə", "ə̯": "ə", "ɐ": "ə", "ɜ": "ɝ", "ɜː": "ɝ",
    "ɚ": "ɚ", "ɝ": "ɝ", "ɨ": "ɪ", "ᵻ": "ɪ", "y": "i", "ø": "oʊ", "œ": "ɛ",
}
CMAP = {
    "d͡ʒ": "dʒ", "dʒ": "dʒ", "t͡ʃ": "tʃ", "tʃ": "tʃ", "r": "ɹ", "ɹ": "ɹ",
    "g": "ɡ", "ɡ": "ɡ", "ɫ": "l", "l": "l", "ʔ": "t", "ɾ": "t", "ʍ": "w",
    "x": "k", "c": "k", "ç": "h", "ɲ": "n", "ʝ": "j",
}
SYLLABIC = {"l̩": ("ə", "l"), "n̩": ("ə", "n"), "m̩": ("ə", "m"), "ŋ̍": ("ə", "ŋ")}
DIPH = {
    ("ɑ", "ɪ"): "aɪ", ("ɑ", "ʊ"): "aʊ", ("eɪ", "ɪ"): "eɪ",
    ("oʊ", "ʊ"): "oʊ", ("ɔ", "ɪ"): "ɔɪ", ("ə", "ʊ"): "oʊ",
}

# Tier 1 — notation only. Free to clear: no clinician contrasts these.
NOTATION = [{"ə", "ɪ"}, {"ɪ", "ʌ"}, {"ə", "ɚ"}, {"ɪ", "ɚ"}, {"ɛ", "ə"}, {"ɛ", "ɪ"}]
# Tier 2 — dialect mergers. NEVER auto-cleared; one bulk decision each.
MERGERS = {
    frozenset({"ɑ", "ɔ"}): "merger:cot-caught",
    frozenset({"ɔ", "oʊ"}): "merger:north-force",
    frozenset({"i", "ɪ"}): "merger:lax-tense-i",
    frozenset({"u", "ʊ"}): "merger:lax-tense-u",
    frozenset({"æ", "ɑ"}): "merger:bath-father",
    frozenset({"æ", "ə"}): "merger:initial-a",
    frozenset({"ʌ", "ɑ"}): "merger:strut-lot",
}


def to_ours(pron: str, inventory: set[str]) -> list[str] | None:
    """Map a space-delimited WikiPron transcription into our inventory."""
    out: list[str] = []
    for tok in pron.split():
        t = tok.replace("ˈ", "").replace("ˌ", "")
        if t in SYLLABIC:
            out.extend(SYLLABIC[t]); continue
        if t in VMAP:
            out.append(VMAP[t]); continue
        if t in CMAP:
            out.append(CMAP[t]); continue
        base = "".join(
            c for c in unicodedata.normalize("NFD", t) if not unicodedata.combining(c)
        ).replace("ː", "")
        if base in VMAP:
            out.append(VMAP[base]); continue
        if base in CMAP:
            out.append(CMAP[base]); continue
        if base in inventory:
            out.append(base); continue
        return None
    glued: list[str] = []
    i = 0
    while i < len(out):
        if i + 1 < len(out) and (out[i], out[i + 1]) in DIPH:
            glued.append(DIPH[(out[i], out[i + 1])]); i += 2
        else:
            glued.append(out[i]); i += 1
    return glued


def comparison_form(phones: list[str]) -> list[str]:
    """Project our phonemes down to WikiPron's stress-free convention.

    ʌ→ə and ɝ→ɚ. Our ə/ʌ split IS stress (spec §1.4), and WikiPron has no
    stress marks, so leaving them distinct manufactures 412 phantom diffs that
    are stress claims rather than segmental disagreements (spec §4.1).
    """
    return ["ə" if p == "ʌ" else "ɚ" if p == "ɝ" else p for p in phones]


def main() -> None:
    words = pl.read_parquet(WORDS).select(["word", "phonemes", "ipa", "is_canonical"])
    inventory = set(words["phonemes"].explode().unique().drop_nulls().to_list())

    wp: dict[str, set[tuple[str, ...]]] = collections.defaultdict(set)
    unmappable = total = 0
    with WIKIPRON.open(encoding="utf-8") as f:
        for line in f:
            if "\t" not in line:
                continue
            total += 1
            w, pron = line.rstrip("\n").split("\t", 1)
            m = to_ours(pron, inventory)
            if m is None:
                unmappable += 1
            else:
                wp[w].add(tuple(m))
    print(f"wikipron rows {total:,}  unmappable {unmappable:,} "
          f"({100 * unmappable / total:.1f}%)  usable words {len(wp):,}")

    OUT.parent.mkdir(parents=True, exist_ok=True)
    fams: collections.Counter[str] = collections.Counter()
    n_rows = 0
    with OUT.open("w", newline="", encoding="utf-8") as fh:
        w_ = csv.writer(fh, delimiter="\t")
        w_.writerow(["word", "is_canonical", "ours_ipa", "wikipron_ipa",
                     "axis", "family", "verdict"])
        for row in words.iter_rows(named=True):
            word = row["word"]
            if word not in wp:
                continue
            ours = comparison_form(row["phonemes"])
            cands = [comparison_form(list(c)) for c in wp[word]]

            cons_ours = [p for p in ours if p not in VOWELS]
            if not any(cons_ours == [p for p in c if p not in VOWELS] for c in cands):
                fams["consonant-mismatch"] += 1
                n_rows += 1
                w_.writerow([word, int(row["is_canonical"]), row["ipa"],
                             "|".join("".join(c) for c in cands),
                             "consonant", "UNEXPLAINED", ""])
                continue

            vo = [p for p in ours if p in VOWELS]
            best: list[tuple[str, str]] | None = None
            for c in cands:
                vc = [p for p in c if p in VOWELS]
                if len(vc) != len(vo):
                    continue
                d = [(a, b) for a, b in zip(vo, vc) if a != b]
                if best is None or len(d) < len(best):
                    best = d
            if not best:
                continue
            labels = set()
            for a, b in best:
                pair = {a, b}
                if pair in NOTATION:
                    labels.add("notation")
                elif frozenset(pair) in MERGERS:
                    labels.add(MERGERS[frozenset(pair)])
                else:
                    labels.add("UNEXPLAINED")
            if labels == {"notation"}:
                fams["notation (auto-cleared)"] += 1
                continue
            family = ",".join(sorted(labels))
            fams[family] += 1
            n_rows += 1
            w_.writerow([word, int(row["is_canonical"]), row["ipa"],
                         "|".join("".join(c) for c in cands),
                         "vowel", family, ""])

    print(f"\nwrote {n_rows:,} candidates to {OUT}")
    for name, count in fams.most_common():
        print(f"  {name:<34} {count:>6}")


if __name__ == "__main__":
    main()
  • [ ] Step 3: Run the sweep

Run:

uv run python research/2026-09-12-pronunciation-audit/fetch_wikipron.py
uv run python research/2026-09-12-pronunciation-audit/classify_diffs.py
Expected: unmappable ≈ 0.2%; notation auto-cleared is the largest bucket; out/candidates.tsv written. Sanity-check that thyme is absent (Task 1 already corrected it) and that a known-good word like able is absent (pure notation).

  • [ ] Step 4: Write the README

Record in research/2026-09-12-pronunciation-audit/README.md: the license posture (detector only, nothing ingested, TSV gitignored), the exact counts this run produced, the tier policy (notation auto-cleared / mergers never auto-cleared), and the review protocol — Merriam-Webster is the adjudicating reference, WikiPron only nominates. Note the measured precision from the workup: of ~11 hand-checked candidates, 8 were real CMU errors and 3 were WikiPron noise (cephalosporin, dogmatically, unnerve).

  • [ ] Step 5: Commit (scripts and README only — never the TSV or out/)
git add research/2026-09-12-pronunciation-audit/
git status --short research/2026-09-12-pronunciation-audit/  # verify no *.tsv staged
git commit -m "research(phonology): WikiPron-based pronunciation error detector

Detector only — WikiPron data is never ingested (CC BY-SA would become
load-bearing on the D1 seed) and never committed. It nominates; Merriam-Webster
adjudicates.

Key method points: an explicit 240->41 symbol map (0.2% unmappable), and our
side is projected DOWN to the stress-free comparison form (ʌ→ə, ɝ→ɚ) before
diffing — our ə/ʌ split is stress, so comparing raw manufactures 412 phantom
diffs. Rules classify, never gate: notation tier auto-clears, dialect mergers
never do, because they are clinical targets in 179,160 vowel pairs.

Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §3-4"

Task 7: Review the candidates and extend pron-fix.tsv

This task has a mandatory human gate. It follows the project's batch→review→approve cadence: post a batch, get a decision, apply it. Do not self-approve corrections.

Files: - Modify: data/vocab/pron-fix.tsv - Create: research/2026-09-12-pronunciation-audit/out/decisions.tsv (committed — the audit trail) - Test: packages/data/tests/test_pron_fix.py

Interfaces: - Consumes: out/candidates.tsv from Task 6. - Produces: additional pron-fix.tsv rows, validated by Task 1's loader (an unknown word or bad symbol fails the build).

  • [ ] Step 1: Decide the merger families first

Present each merger:* family to the user as one decision covering the whole family, with 5 example words and the family's count. Seven families, seven decisions. Do not review merger candidates word-by-word until the family decision is made, and do not decide any of them yourself.

  • [ ] Step 2: Batch the UNEXPLAINED residue for review

Split the UNEXPLAINED rows into batches of 100, canonical first (canonical words reach Word Lists, Contrast Sets, and similarity; non-canonical only reach sentence retrieval and exact lookup). For each candidate, look the word up in Merriam-Webster and record one of: accept (CMU wrong, correction verified), reject (CMU right, WikiPron noise), or variation (both attested, no correction).

  • [ ] Step 3: Post each batch and wait

Post the batch as a table: word | our IPA | WikiPron | MW pronunciation | proposed verdict. Wait for the user's decision before applying. Per project cadence, post a comment per batch on the tracking Jira issue rather than editing the list piecemeal.

  • [ ] Step 4: Record decisions

Append every reviewed row to out/decisions.tsv with columns word verdict reference noteincluding rejects, so a later run does not re-litigate settled candidates.

  • [ ] Step 5: Apply accepted corrections

Add one pron-fix.tsv row per accepted correction, in ARPAbet, grouped under a comment naming the batch. Remember: exact surface form only. If loathe is corrected, loathes/loathed need their own rows.

  • [ ] Step 6: Add a regression test per accepted correction class
# append to packages/data/tests/test_pron_fix.py
import csv
from pathlib import Path

_DECISIONS = (
    Path(__file__).resolve().parents[3]
    / "research" / "2026-09-12-pronunciation-audit" / "out" / "decisions.tsv"
)


def test_every_accepted_decision_is_in_pron_fix():
    """A decision that never reached pron-fix.tsv is a silently lost correction."""
    if not _DECISIONS.exists():
        return
    accepted = {
        r["word"]
        for r in csv.DictReader(_DECISIONS.open(encoding="utf-8"), delimiter="\t")
        if r["verdict"] == "accept"
    }
    fix_path = Path(__file__).resolve().parents[3] / "data" / "vocab" / "pron-fix.tsv"
    listed = {
        line.split("\t")[0].strip().lower()
        for line in fix_path.read_text(encoding="utf-8").splitlines()
        if line.strip() and not line.startswith("#") and "\t" in line
    }
    assert accepted <= listed, f"accepted but not corrected: {sorted(accepted - listed)}"
  • [ ] Step 7: Run the suite and commit
uv run python -m pytest packages/data/tests/test_pron_fix.py -v
git add data/vocab/pron-fix.tsv research/2026-09-12-pronunciation-audit/out/decisions.tsv \
        packages/data/tests/test_pron_fix.py
git commit -m "data(phonology): apply reviewed pronunciation corrections (batch <N>)

<N> accepted, <M> rejected as WikiPron noise, <K> logged as free variation.
Merriam-Webster adjudicated every accept. Rejects are recorded in
decisions.tsv so a later sweep does not re-litigate them.

Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §4"

Task 8: Rebuild, verify, reseed

Files: - Modify: packages/web/workers/d1-seed.manifest.json - Create: research/2026-09-12-pronunciation-audit/out/verification.md

Interfaces: - Consumes: every prior task. - Produces: a verified seed on R2 plus the committed manifest pointer CI fetches.

  • [ ] Step 1: Full local rebuild

uv run python packages/data/scripts/build_runtime_parquet.py
Expected: completes without a ValueError from pron_fix (a bad row fails the build by design).

  • [ ] Step 2: Verify every acceptance criterion against the rebuilt parquets
uv run python - <<'PY'
import polars as pl
w = pl.read_parquet('data/runtime/words.parquet')
p = pl.read_parquet('data/runtime/pairs.parquet')
ok = True
def check(label, cond, extra=''):
    global ok
    ok = ok and bool(cond)
    print(('PASS' if cond else 'FAIL'), label, extra)

g = lambda word, col: w.filter(pl.col('word') == word)[col][0]
check('thyme is taɪm', g('thyme', 'ipa') == 'taɪm', g('thyme', 'ipa'))
check('thyme == time', g('thyme', 'ipa') == g('time', 'ipa'))
check('segue is sɛɡweɪ', g('segue', 'ipa') == 'sɛɡweɪ', g('segue', 'ipa'))
for word in ('loathing', 'blithely', 'furthest'):
    check(f'{word} has ð', 'ð' in g(word, 'phonemes'))
    check(f'{word} lacks θ', 'θ' not in g(word, 'phonemes'))
check('ipa carries stress marks', w.filter(pl.col('ipa').str.contains('ˈ|ˌ')).height > 0,
      f"{w.filter(pl.col('ipa').str.contains('ˈ|ˌ')).height:,} rows")
check('phonemes_str has no suprasegmentals',
      w.filter(pl.col('phonemes_str').str.contains('ˈ|ˌ')).height == 0)
sub = p.filter(pl.col('pair_type') == 'substitution')
for a, b in (('ə', 'ʌ'), ('ɚ', 'ɝ')):
    n = sub.filter(((pl.col('phoneme1') == a) & (pl.col('phoneme2') == b)) |
                   ((pl.col('phoneme1') == b) & (pl.col('phoneme2') == a))).height
    check(f'no {a}~{b} substitution pairs', n == 0, f'found {n}')
n = sub.filter(((pl.col('word1') == 'thyme') & (pl.col('word2') == 'time')) |
               ((pl.col('word1') == 'time') & (pl.col('word2') == 'thyme'))).height
check('thyme/time is not a substitution pair', n == 0, f'found {n}')
print('\nALL PASS' if ok else '\nFAILURES ABOVE')
PY

All must PASS. A failure here means an earlier task is incomplete — return to it rather than adjusting this check.

  • [ ] Step 3: Regenerate canonical-derived artifacts

Phonology changes shift pairs.is_canonical rows, so per CLAUDE.md both canonical-derived artifacts must be regenerated, not assumed stable:

uv run python packages/data/scripts/build_qwensim_edges.py

Then re-select the 50K sentence set per its documented procedure. Record before/after counts for both in out/verification.md.

  • [ ] Step 4: Emit and apply the seed locally
uv run python packages/web/workers/scripts/export-to-d1.py
uv run python packages/web/workers/scripts/chunk-seed-sql.py
cd packages/web/workers && for f in scripts/d1-chunks/chunk_*.sql; do \
  npx wrangler d1 execute phonolex --local --file "$f"; done
  • [ ] Step 5: Run the full test matrix

cd packages/web/workers && npm test && npx tsc --noEmit
cd ../frontend && npm run build && npx tsc --noEmit && npm run lint
cd ../../.. && uv run python -m pytest packages/data/tests/ \
  --ignore=packages/data/tests/test_datasets.py \
  --ignore=packages/data/tests/test_new_loaders.py
Expected: all green. These are the exact checks CI runs.

  • [ ] Step 6: Upload the seed and commit the manifest
uv run --with boto3 python packages/web/workers/scripts/upload-seed-to-r2.py
git add packages/web/workers/d1-seed.manifest.json \
        research/2026-09-12-pronunciation-audit/out/verification.md
git commit -m "chore(data): reseed for pronunciation fidelity corrections

Rebuild carrying: pron-fix corrections, quality-ordered primaries,
stress-marked ipa, and the removal of 181 stress-allophone pairs.
Qwensim edges and the 50K sentence set regenerated (counts in
verification.md) since pairs.is_canonical rows shifted.

Verification output: research/2026-09-12-pronunciation-audit/out/verification.md
Spec: docs/superpowers/specs/2026-09-12-pronunciation-fidelity-design.md §6-7"
  • [ ] Step 7: Open the PR against develop

Include in the body: the seven confirmed CMU errors with before/after, the stress-rendering change with ablate as the example, the 181 removed pairs with advertisers/advertisers' named, the Task 7 review counts, and the §2 note that the inventory deliberately stays at 41 with the z = 16.8 evidence. Flag the two out-of-scope follow-ons (bulk WikiPron ingestion; systematic stress audit).

  • [ ] Step 8: Build the diagram artifact

Per the project's per-implementation habit, publish an artifact page diagramming the changed pipeline: loaders/cmudict.py (pron-fix + primary selection) → pipeline/words.py (stress rendering) → pipeline/derived.py (allophone suppression) → routes/contrastive.ts (explanatory guard), marking changed / unchanged / next components. Load the artifact-design skill before writing it.

  • [ ] Step 9: Verify the staging deploy

After merge to develop, confirm the reseed applied and smoke-test on staging: look up thyme (expect /taɪm/), ablate (expect a stress mark), request the ə~ʌ contrast (expect the 422 explanation), and request θ~t (expect results without thyme/time). Note the D1 seed import flake: a wrangler poll timeout may need gh run rerun --failed once.


Self-Review

Spec coverage: §1.1→Tasks 1, 6, 7. §1.2→Task 2. §1.3→Task 3. §1.4→Task 4. §2 (inventory stays 41)→enforced by Task 4's test_exactly_two_allophone_sets and documented in allophones.py. §3 (detector not source)→Task 6, license posture in the module docstring and README. §4 (rules classify)→Task 6 tiering + Task 7 Step 1. §4.1 (stress artifact)→comparison_form() in Task 6. §5 (downstream)→Task 8 Step 2 checks phonemes_str purity; WCM is untouched because syllables[].stress is unchanged. §6 A–F→Tasks 1,6/7,3,4,2,8. §6b→Task 5. §7 criteria 1–9→Task 8 Step 2 (1–6), Task 5 (6b), Task 1 Step 1 (7), Task 6 (8), Task 8 Step 5 (9). §8 constraints→Global Constraints.

Placeholder scan: No TBDs. Two steps deliberately require reading before editing (Task 4 Step 6 _compute_minimal_pairs signature, Task 5 Step 6 frontend error path) and say so explicitly with the grep to run — that is instruction, not a placeholder. Task 3 Step 6 pre-empts the ablate secondary-stress outcome with the correct alternative assertion rather than leaving it open.

Type consistency: apply_pron_fix(cmu, path) -> int used consistently in Tasks 1 and 8. render_ipa(syllables: list[dict]) -> str — Task 3 passes the syllables dicts, matching the test fixtures. is_stress_allophone_pair(p1, p2) -> bool (Python) / isStressAllophonePair(a, b) (TS) are named per each language's convention and each file's comment names the other as its mirror. comparison_form is local to Task 6. STRESS_ALLOPHONES is frozenset[frozenset[str]] in Python and ReadonlyArray<readonly [string, string]> in TS — deliberately different shapes for idiomatic membership tests in each language, both asserted in tests.