Pronunciation Fidelity: Correction Layer, Stress Representation, and Contrast Validity¶
Date: 2026-09-12
Status: Design — approved for full implementation ("no half-measures")
Trigger: thyme observed in the app as θaɪm; should be taɪm (homophone of time).
1. Problem¶
Four defects, one root: CMU is an unchallenged authority on the one column every other column is derived from, and our IPA rendering of it is lossy.
1.1 Upstream segmental errors pass through untouched¶
data/cmu/cmudict-0.7b:120991 reads THYME TH AY1 M. English thyme is /taɪm/.
There is one entry, no variant, so no correct pronunciation exists to fall back on.
loaders/cmudict.py transliterates ARPAbet→IPA phone-by-phone and emits it. There is
no pronunciation-correction layer anywhere in the pipeline — data/vocab/ has
root-{deny,fix}.tsv, canonical deny/allow, and image blocklists, but nothing for
phonology.
Verified against upstream cmusphinx/cmudict master (135,166 lines, fetched
2026-09-12): thyme, viscount, and segue are still wrong there. There is no
free upgrade path from CMU.
Clinical consequence. thyme is is_canonical=1 and carries 33 edit-distance-1
pairs, including this row in pairs:
| word1 | word2 | phoneme1 | phoneme2 | position | position_type | pair_type | is_canonical |
|---|---|---|---|---|---|---|---|
| thyme | time | θ | t | 0 | initial | substitution | 1 |
We ship a θ→t minimal pair between two homophones, in initial position, canonical.
A clinician running Contrast Sets for /θ/–/t/ is handed a pair no child can discriminate,
and it looks entirely plausible. thyme also sits in the 1,071-word canonical /θ/ pool
feeding Word Lists.
Confirmed errors found by sampling (all verified against cmudict-0.7b):
| word | CMU | correct | class |
|---|---|---|---|
thyme |
TH AY1 M |
T AY1 M |
silent-letter spelling pronunciation |
thaler |
TH EY1 L ER0 |
T AA1 L ER0 |
loanword spelling pronunciation |
viscount |
V IH1 S K AW0 N T |
V AY1 K AW0 N T |
silent-letter |
laugher |
L AA1 K ER0 |
L AE1 F ER0 |
plain error |
loathing |
L OW1 TH IH0 NG |
L OW1 DH IH0 NG |
θ/ð misfiling |
blithely |
B L AY1 TH L IY0 |
B L AY1 DH L IY0 |
θ/ð misfiling |
furthest |
F ER1 TH AH0 S T |
F ER1 DH AH0 S T |
θ/ð misfiling |
The θ/ð class is the most damaging: /θ/–/ð/ is a core SLP contrast, and these words are currently in the wrong therapy bucket.
1.2 Primary-variant selection is unordered¶
cmudict_to_phono (loaders/cmudict.py:79-88) takes converted[0] as primary. CMU's
variant order is not quality-ordered. segue → S EH1 G (/sɛɡ/, truncated) is primary
while the correct SEGUE(1) S EH1 G W EY0 sits at index 1. 52 pairs derive from the
wrong primary. This is a different root cause from §1.1.
A shape-based rule was tried and rejected (2026-09-12, during execution). The hypothesis: a truncation is always a strict prefix of the full form, whereas a genuine variant differs by substitution, so prefer the longest variant having a shorter strict prefix among its siblings. Measured over all 8,017 multi-variant CMU entries it changed 79 primaries, of which roughly 70 were wrong:
| category | n | examples |
|---|---|---|
| inflectional suffix added | 18 | corps → /kɔɹz/, disability → /dɪsəbɪlɪtiz/, citron → /sɪtɹənz/ |
| different word entirely | ~50 | cache → /kæʃeɪ/ (cachet), corp → /kɔɹpɚeɪʃən/ (corporation), al. → /æləbæmə/ (Alabama) |
| rule selected a third variant | 1 | droste |
The hypothesis is unsound, not mistuned: a truncation is the full form minus a tail and an English plural is the singular plus a tail, so the two are indistinguishable by shape. This is an identifiability problem; no threshold fixes it.
Therefore wrong variant order is a curated correction like any other — segue gets a
pron-fix.tsv row. Discovery of further cases belongs to the §3 detector (WikiPron has
only s ɛ ɡ w e ɪ for segue, so our truncated primary surfaces as a mismatch),
and correction belongs to §4's review. This is the spec's own principle applied to
itself: rules classify, they never decide. test_shape_heuristic_would_break_* in
test_pron_fix.py pins the words the rule broke, so it cannot be quietly reintroduced.
1.3 Stress is computed, then discarded; the ipa column is not IPA¶
loaders/cmudict.py:49-60 builds stress_pattern as a per-phoneme-index array.
pipeline/words.py:108-113 consumes it to construct PhonemeWithStress for the
syllabifier, then drops it. It survives only folded into syllables[].stress.
_build_phonological_record (words.py:106) takes ipa pre-joined from the loader and
ignores the syllable structure it builds four lines later. Result: the ipa column
contains ˈ/ˌ in 0 of 125,756 rows. ablate ships as ʌbleɪt, which is not
faithful IPA for /əˈbleɪt/ — it is an ARPAbet transliteration.
ARPAbet fuses stress to the vowel (a single AH phone; the digit does the work). IPA has
dedicated suprasegmental symbols. Transliterating phone-by-phone into a notation with
those symbols and then not using them leaves the stress information nowhere to go but
the vowel identity.
1.4 ə/ʌ and ɚ/ɝ therefore carry stress, and manufacture false contrasts¶
data/mappings/arpa_to_ipa.json has exactly two stress-conditioned entries out of 84:
| ARPAbet | IPA |
|---|---|
AH, AH0 |
ə |
AH1, AH2 |
ʌ |
ER0 |
ɚ |
ER, ER1, ER2 |
ɝ |
Everything else is stress-invariant (IH0/IH1/IH2 all → ɪ). Audited across all
125,756 entries, zero exceptions:
| nucleus | syllable stress observed | count |
|---|---|---|
| ə | 0 only | 58,263 |
| ɚ | 0 only | 22,330 |
| ʌ | 1 or 2 only | 7,504 |
| ɝ | 1 or 2 only | 4,850 |
Perfect complementary distribution conditioned by stress. Consequence in pairs:
- 86 ə~ʌ substitution pairs (6 canonical)
- 95 ɚ~ɝ substitution pairs (10 canonical)
presented as phonemic contrasts. Examples: advertisers/advertisers' — a "minimal
pair" between a word and its own possessive, differing only in CMU's stress digit on the
same phone; advertiser's/advertisers'; a/uh. These are stress differences wearing
a vowel costume. A clinician asked to contrast /ə/ vs /ʌ/ is being asked to do something
impossible.
1.5 The syllabifier never implemented maximal onset¶
Found 2026-09-12 while executing §1.3: rendering stress made the syllable structure
visible for the first time, and photograph came out /ˈfoʊtəɡˌɹæf/ — the ɡ in the
wrong syllable.
phonology/syllabification.py split intervocalic consonant clusters down the middle
(split_point = len(consonants_between) // 2) while its docstring claimed the maximal
onset principle. Its own comment admitted it: "Simple heuristic: split in middle (can be
improved with phonotactic rules)." Worse, the onset side and the coda side computed that
split independently with the same expression, agreeing only by coincidence.
This is not a display problem. Onset/coda membership feeds:
| consumer | effect |
|---|---|
cv_shape |
acclaim read VC-CVC instead of V-CCVC — and cv_shape is a Word Lists filter |
word_syllables components |
wrong onset/nucleus/coda sequences feed the two-level soft Levenshtein, shifting similarity |
| WCM | cluster and coda counts |
ipa (after §1.3) |
stress mark visibly misplaced |
Scale: 17,621 words (14.01% of the lexicon) change. Note this figure was revised
upward twice during investigation. An initial "3,703 indefensible" count used
obstruent + liquid after an unstressed syllable; both restrictions were wrong for
scoping the fix — the principled rule applies regardless of stress, and
obstruent + liquid wrongly includes /tl/ and /dl/, which are not legal English
onsets (absolutely → æb-sə-lut-li was already correct). Composition:
| subset | n | status |
|---|---|---|
legal obstruent+liquid/glide cluster → onset (acclaim, ablaze, abbreviate) |
~11,600 | unambiguous fix |
/s/+stop → onset (accosted → ə-kɑ-stɪd) |
~4,600 | debatable — see below |
/ŋ/ → coda (singer → sɪŋ-ɚ) |
430 | unambiguous fix, opposite direction |
Fix: onset maximization subject to phonotactic legality. phonology/onsets.py holds
the legal English onset inventory (1-, 2- and 3-consonant) and longest_legal_onset();
syllabify derives one split for both sides of every boundary from it.
Deliberately not done: ambisyllabicity / the lax-vowel constraint. English arguably
closes a stressed syllable containing a lax vowel (happy as /ˈhæp.i/). That is a
transcription-convention choice rather than a phonotactic fact, the existing code already
assigned a lone intervocalic consonant to the onset, and applying it would change far more
words than it fixes. happy therefore stays hæ-pi.
Open question for review: the /s/+stop subset. Dictionaries write /əˈkɒs.tɪd/,
because a stressed syllable attracts the coda; pure maximal onset gives /ə.kɑ.stɪd/. We
now produce the latter for ~4,600 words. Both are defensible for SLP targeting — the /st/
sequence is present either way — but a carve-out keeping /s/+stop split is a one-line
change to _ONSETS_2 if preferred.
2. Decision: keep the inventory at 41¶
Perfect complementary distribution is the classic signature of allophones of one phoneme, which suggested collapsing ə/ʌ and ɚ/ɝ (41→39 phonemes). The learned feature vectors refute this.
From packages/features/outputs/{composites,uncertainty}.csv (26-d Bayesian posteriors
over ECCC + Hillenbrand evidence, 63 segments):
| pair | cos | L2 | median vowel-pair L2 |
|---|---|---|---|
| ə vs ʌ | 0.9413 | 1.8547 | 2.7890 |
| ɚ vs ɝ | 0.9341 | 2.1070 | 2.7890 |
They are closer than median but not among the closest (e/eɪ = 0.46, o/oʊ = 0.65).
The separating feature is back:
back: ə = 0.125 ± 0.098 ʌ = 1.908 ± 0.040
delta = 1.783 pooled SD = 0.106 z = 16.8
ʌ is the 3rd most certain of 58 segments (mean posterior SD 0.058 vs median 0.067). This is evidence, not prior.
Both facts are true and reconcile. Distributionally the pair carries no contrastive load — no minimal pair can distinguish them. Phonetically they are robustly distinct, because unstressed centralization is a real articulatory process: the acoustic difference is the realization of the stress difference.
Therefore: one phoneme, two stress-conditioned allophones, phonetically distinct.
- Keep both symbols in
phonemes,phoneme_dots, and the learned vectors. Soft-Levenshtein should score ə~ʌ as close-but-not-equal; that is correct behaviour. - Represent stress explicitly so it stops riding on vowel identity.
- Fix the contrast layer to know ə~ʌ and ɚ~ɝ are not phonemic oppositions.
This reverses the collapse floated during investigation. Recorded here because a partial implementation would have shipped the wrong conclusion.
3. Decision: WikiPron is a detector, never a source¶
CUNY-CL/wikipron eng_latn_us_broad.tsv: 106,931 rows / 80,995 words. Code Apache-2.0;
data inherits Wiktionary's CC BY-SA (repo README explicitly disclaims the data/
directory). It gets all three of our known errors right:
thyme t a ɪ m
viscount v a ɪ k a ʊ n t
segue s ɛ ɡ w e ɪ (no truncated variant at all)
Why it cannot be ingested¶
| property | our data | WikiPron |
|---|---|---|
| distinct symbols | 41 | 240 (206 outside our inventory) |
| stress marks | in syllables[].stress |
zero, in all 106,931 rows |
| dialect purity | General American | RP leakage: ɒ n=4,267, ɜː n=1,486, iː/uː/ɑː |
| syllabic consonants | none | l̩ n=749, n̩ n=561, m̩ n=126 |
| other | — | ɪ̯ ʊ̯ ə̯ non-syllabic, d͡ʒ t͡ʃ tie bars, x ɛ̃ ʔ ɾ ʍ ɐ ʉ |
An explicit hand-written mapping reduces unmappable rows to 0.2%, but every entry in that
map (ɒ→ɑ? ɜː→ɝ? l̩→əl?) is a phonological judgment propagating into
syllabification, WCM, CV shape, and similarity. And there is no stress to import at all.
License boundary¶
- Detector use ships none of their strings → zero exposure. This is what we do.
- A hand-verified curated correction list is clean: individual pronunciation facts are not copyrightable and a curated list is not a derivative database.
- Bulk-importing the 51,533 words we lack is where share-alike becomes load-bearing on the D1 seed. Out of scope; requires counsel.
Sources rejected¶
| source | reason |
|---|---|
cmusphinx/cmudict master |
verified still wrong on all three probes |
| CELEX2, Unisyn | LDC / academic noncommercial — barred by our own standard |
espeak-ng en_dict |
GPL-3; usable dev-only as a second detector, never bundled |
| ipa-dict, LibriSpeech/Kaldi lexicons | CMU-derived — inherit the same errors, worthless as a second opinion |
| Moby Pronunciator | public domain but 1990s, own ASCII scheme, mediocre |
| Merriam-Webster / Oxford API | authoritative but no redistribution — correct human-review reference, not a bulk source |
| G2P model disagreement | actively wrong for this class: a model trained on CMU predicts TH→θ from spelling, so it agrees with the error. The error is the regular grapheme mapping. |
4. Decision: convention rules classify, they do not gate¶
Comparing raw yields 26.1% disagreement over the 29,462-word overlap — almost all transcription convention. After inventory mapping, 21,041 of 22,290 canonical words agree exactly on vowels (94.4%). CMU is in far better shape than the raw diff suggests.
Rule-family census over 6,033 canonical vowel-slot diffs:
| family | share | tier |
|---|---|---|
| notation noise (ə~ɪ, ɛ~ə, ə~ɚ, syllabic l̩/n̩/m̩, ɚ~əɹ, length marks, tie bars, split diphthongs) | 61.0% | auto-clear |
| UNEXPLAINED | 16.7% (1,006) | review |
| stress disagreement (ə~ʌ / ɚ~ɝ) | 6.8% (412) | see §4.1 |
| lax/tense i~ɪ | 4.8% | review by family |
| cot/caught ɑ~ɔ | 4.0% | review by family |
| north/force ɔ~oʊ | 3.0% | review by family |
| initial æ~ə | 1.8% | review by family |
| bath/father æ~ɑ | 1.1% | review by family |
| lax/tense u~ʊ | 0.6% | review by family |
| strut/lot ʌ~ɑ | 0.1% | review by family |
Consonant axis: 2,144 mismatches (545 canonical), single-substitution census dominated by
z↔s (79), n↔ŋ (52, pre-velar assimilation — a transcription-depth difference), t↔d (16,
flapping), θ↔ð (6, all real errors), w↔ʍ (4, wine/whine merger), θ→t (2: thyme,
thaler).
Rules classify; they never pass/fail. 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-licensed; each gets one bulk review decision per family. That converts ~3,800 word-level decisions into ~14 family decisions plus a genuinely small residue.
θ→t is licensed by no rule and never will be, so thyme cannot hide.
4.1 Stress disagreements are an artifact of our own representation¶
The 412 ə~ʌ / ɚ~ɝ diffs are not segmental disagreements. Because our ə/ʌ split is stress, and WikiPron has no stress marks, these are stress claims in disguise. The fix is to project our side into the comparison form (ʌ→ə, ɝ→ɚ, drop stress) before diffing. They then evaporate — correctly, because they were never segmental.
They are still informative as a stress signal (e.g. ABLATE AH2 B L EY1 T — CMU asserts
secondary stress where /əˈbleɪt/ is standard; CMU over-assigns secondary stress). But that
channel only sees syllables whose nucleus is AH or ER; AE0/1/2 all map to æ, so a
stress error on any other vowel leaves no trace in the IPA string. 412 is a floor on a
subset, not an estimate of CMU's stress error rate. Stress auditing is out of scope
here and recorded as follow-on work.
4.2 The per-family decisions (made 2026-09-14)¶
§4 promised one bulk decision per merger family instead of ~3,800 word-level ones.
Here they are. The decision is not accept/reject but append or nothing, because a
merger means both readings are Standard American English and neither may overwrite
the other — that is pron-variants.tsv, which appends, not pron-fix.tsv, which
replaces. Generator: research/2026-09-12-pronunciation-audit/build_variants.py.
| family | decision | why |
|---|---|---|
| cot/caught ɑ~ɔ | append, merged direction only | add /ɑ/ where we have /ɔ/; never the reverse — see the inclusion rule below |
| north/force ɔ~oʊ | append, merged direction only | add /ɔɹ/; /oʊɹ/ is the regional relic |
| lax/tense i~ɪ, u~ʊ | append, either direction | not regional variation: notation over a contrast absent in that environment (unstressed, or pre-/ɹ/) |
| bath/father æ~ɑ | nothing | regionally localized (it is the eastern New England low-vowel arrangement, which keeps father/bother distinct) |
| initial æ~ə | nothing | æ~ə is reduction, i.e. stress, which §1.4 moved out of vowel identity |
| strut/lot ʌ~ɑ | nothing | 0.1% of diffs, and ʌ is no longer a phoneme (§2) |
The inclusion rule: supralocal, not regional¶
The lexicon is general American, so a variant is admitted when it is supralocal and refused when it is regional. Population is explicitly not the test — the distinction-preserving pronunciations are concentrated in the largest metropolitan corridor in the country, and admitting them on headcount would equally admit r-lessness, which is comparably large and just as regional.
For the low back vowels the merged form is the supralocal one. ANAE Ch. 9.1 puts the merger across Canada, the West, Eastern New England and western Pennsylvania, with a continent-wide age coefficient of 0.43 units per 25 years toward merger; Dinkin (2011), interviewing 119 Upstate New York speakers in 2006–2008, finds the region "trending toward the merger in apparent time, in terms of both phonetic distance between the two phonemes and speakers' explicit judgments." The regions that preserve the distinction are bounded isoglosses: the Inland North core and the Eastern Corridor from Providence through New York City to Baltimore.
Two cautions this rule deliberately does not rest on. First, both surveys are old — ANAE's fieldwork ran 1992–1998 and Dinkin's 2006–2008 — so neither can state a current distribution, and a policy built on their percentages would expire. Supralocal versus regional is the durable distinction. Second, regions are not binary: ANAE's categories are distinct / transitional / merged, and the Midland is dominated by transitional speakers — of 15 Columbus subjects, "all speakers are transitional."
The rule is enforced on the vowel pair, never the detector's family label, which
is unreliable (peridot and solstice are labelled north-force while being ɑ~ɔ).
Pinned by test_shipped_list_adds_only_supralocal_forms.
Two structural rules make the additions trustworthy without a review queue:
- Slot alignment. An addition must be CMU's own primary with one or more vowel
slots swapped — same length, same consonants. Each swapped slot then keeps the
stress digit it already carried, so nothing about stress is reconstructed. The
earlier rule (transfer CMU's digits in vowel order wherever the vowel counts
matched) produced
caronasK AE0 R AH1 N= /kæˈɹʌn/. - No reduced vowel in a differing slot. ə or ɚ on either side means a stress difference, not a merger, and §1.4 put stress somewhere else.
Yield: 919 family-labelled vowel candidates → 119 additions. 578 were rejected because espeak-ng and WikiPron did not agree on the alternative (one crowd-sourced opinion is not enough), 33 failed slot alignment or added the regional form, 16 we already had.
Seven rows came out the other side as corrections, not additions: padre,
rando, yarrow (TRAP/PALM the wrong way round), quadratic, balsamic (wrong
stressed vowel), boombox (/bʊm/ for a GOOSE vowel) and passim (/pæˈsim/, also
mis-stressed). Needing to tell a merger from an error is what found them — the
corrections sweep had skipped the vowel axis entirely. They are in pron-fix.tsv.
5. Why stress matters downstream¶
syllables[].stress has real consumers, which is why nobody noticed the IPA string lost
its marks:
phonology/wcm.py:35-40— the non-initial-stress point of the Word Complexity Measure, a filterable clinical property. A spuriousAH2inflates WCM.str_phono_prob_avg, stress-marked positional probability, stress-matched neighbourhood density — three user-facing properties inconfig/properties.ts.
derived.py:105 already does lstrip("ˈˌ") on the IPA map values — dead code today, but
the design anticipated stress marks.
6. Scope of the correction¶
In scope¶
| # | Change | Root site |
|---|---|---|
| A | data/vocab/pron-fix.tsv + loader application, fail-the-build on bad row |
loaders/cmudict.py |
| B | WikiPron detector + convention classifier + review batch → corrections | new research/ + pipeline/ |
| C | Render ipa with ˈ/ˌ from syllable structure |
pipeline/words.py:106-150 |
| D | Suppress ə~ʌ / ɚ~ɝ as phonemic contrasts (181 pairs) | pipeline/derived.py:325-331 |
| D2 | Explain the suppression instead of returning empty | routes/contrastive.ts, phoneme picker |
| E | Correct CMU's wrong variant order by curation (a shape rule was measured and rejected) | data/vocab/pron-fix.tsv |
| F | Phonotactic onset maximization in the syllabifier | phonology/onsets.py, syllabification.py |
| G | Full reseed + regenerate canonical-derived artifacts | export → R2 → manifest |
Closed decisions (measured 2026-09-14, not deferred)¶
The /s/+stop syllabification stays as it is — pure maximal onset. §1.5 flagged a possible
carve-out keeping /sp st sk/ split, since dictionaries write /əˈkɒs.tɪd/. Measured: 7,625
words currently have /s/+stop starting a syllable, and many are compounds where it
genuinely does:
aerospace ɛ-ɹoʊ-speɪs "space"
.full-stop fʊl-stɑp "stop"
abbotstown æ-bət-staʊn "stown"
'frisco fɹɪ-skoʊ debatable — dictionaries say /ˈfrɪs.koʊ/
A blanket carve-out yields ɛ-ɹoʊs-peɪs, breaking aerospace, full-stop and abbotstown
to fix frisco. The distinction is morphological, not phonotactic, so getting it right
per-word needs morpheme boundaries in the syllabifier — a different capability, not a
one-line change. Consistency is the better trade until that exists.
Ambisyllabicity stays unimplemented. Closing a stressed lax-vowel syllable (happy →
hæp-i) would re-split 37,240 words — 29.6% of the lexicon. That moves cv_shape, which
is a Word Lists filter, plus the word_syllables components feeding similarity, plus WCM —
for a transcription convention on which dictionaries themselves disagree. Large blast radius,
zero correctness gain.
Out of scope (recorded, not done)¶
- Bulk ingestion of WikiPron's 51,533 unseen words (CC BY-SA share-alike on the seed).
- Systematic CMU stress-error audit (the
AH/ERchannel is partial; needs a stress-bearing source). - Collapsing the phoneme inventory to 39 — refuted in §2.
Reseed obligations — checked, and narrower than assumed¶
CLAUDE.md requires regenerating two canonical-derived artifacts after any canonical
change. Verified after the rebuild (2026-09-13): is_canonical did not change, 48,782
before and after, and pipeline/canonical.py contains no reference to phonology at all —
canonicality derives from POS / frequency mass / WordNet. So that obligation does not
trigger here, and the Qwensim edge rebuild is provably unnecessary rather than merely
skipped.
One genuine staleness, recorded rather than fixed: corpus_sentences_index.rarity_score
is a coverage-aware score over phoneme-position, phoneme-bigram and top-50 CV-shape
constraints. §1.5 changed 17,621 words' CV shapes and §1.1 changed 61 words' phonemes, so
the score's constraint basis has shifted.
Why it is not regenerated in this branch:
- It affects ordering only, never correctness.
/api/sentencesranks bymatch_countDESC first, andmatch_countis computed live against the correctedwordsandpairs— so constrained retrieval, which is the clinical path, is already correct. The stale score reorders sentences within amatch_counttier. It does become the primary signal when no include rules are active (routes/sentences.ts:62), i.e. unconstrained browsing. - Re-scoring alone would be semantically inconsistent. The 50K set was selected by a rarity-driven greedy walk; rescoring without re-selecting leaves scores from a different constraint basis than the selection used.
- Re-selecting is not possible locally. The raw corpus is not on disk — only the curated runtime parquets — so it needs the full 236K→50K ingest re-run (spaCy tagging plus the GPT gap-fill, ~$20 and hours).
Correct home for this: the next corpus rebuild, where selection and scoring happen together.
Original reseed obligations (from CLAUDE.md)¶
Correcting phonology changes words, pairs, word_syllables, and phonemes. Per
project policy, after any canonical-affecting change also regenerate:
- data/norms/phonolex_qwensim_edges.tsv via packages/data/scripts/build_qwensim_edges.py
- the 50K sentence set
is_canonical is not itself changed by this work, but pairs.is_canonical rows shift, so
both derived artifacts must be re-verified rather than assumed stable.
7. Acceptance criteria¶
thyme→taɪm;thyme/timeis not a substitution pair; they are homophones.- All seven §1.1 confirmed errors corrected, each with a test.
loathing,blithely,furthestappear in the /ð/ pool, not /θ/.segueprimary issɛɡweɪ.words.ipacarriesˈ/ˌ;ablaterenders/əˈbleɪt/.phonemes_strunchanged.- Zero ə~ʌ and zero ɚ~ɝ rows in
pairswithpair_type='substitution'. 6b. Requesting ə~ʌ or ɚ~ɝ as a contrast returns an explanation, not an empty list — these are stress allophones, not a phonemic opposition. Suppressing the pairs while leaving both symbols selectable in the phoneme picker would be a silent drop, which this project treats as a regression. The route already has a precedent for a named defensive filter (contrastHoldsForDisplayedForms,routes/contrastive.ts:412); this needs the explanatory variant, and the picker must not present the pair as viable. - A malformed or unmatched
pron-fix.tsvrow fails the build, with a test proving it. - Detector + classifier reproducible from a committed script; review decisions committed as data.
- Full test matrix green:
packages/datapytest, workers vitest, frontend type-check + lint + build.
8. Constraints¶
ipais display-only. Verified: every consumer wraps it in slashes (WordProfileContext.tsx:187,LookupTool.tsx:343,615,PackItemCard.tsx:27).similarity.ts'sipa1/ipa2are phoneme-level cache keys, unaffected. Matching runs onphonemes_str.variantPronunciations()(LookupTool.tsx:359) string-compares primary against variant IPA — mark both or neither.- Corrections are exact-surface-form, never propagating to inflections — same rule as the
canonical deny/allow lists. List
thymeandthymesseparately if both are wanted. (thymesis absent from CMU entirely.) - D1: 100 bind params/query, 100 columns/table. No new
wordscolumns are required by this design (stress goes into the existingipastring, not a new column). - Terminology: "feature vectors," never "embeddings."