Skip to content

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 pipelinedata/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. segueS 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 othersegue 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 (singersɪŋ-ɚ) 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, //ɑː
syllabic consonants none n=749, n=561, 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?) 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:

  1. 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 caron as K AE0 R AH1 N = /kæˈɹʌn/.
  2. 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 spurious AH2 inflates WCM.
  • str_phono_prob_avg, stress-marked positional probability, stress-matched neighbourhood density — three user-facing properties in config/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 (happyhæ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/ER channel 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/sentences ranks by match_count DESC first, and match_count is computed live against the corrected words and pairs — so constrained retrieval, which is the clinical path, is already correct. The stale score reorders sentences within a match_count tier. 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

  1. thymetaɪm; thyme/time is not a substitution pair; they are homophones.
  2. All seven §1.1 confirmed errors corrected, each with a test.
  3. loathing, blithely, furthest appear in the /ð/ pool, not /θ/.
  4. segue primary is sɛɡweɪ.
  5. words.ipa carries ˈ/ˌ; ablate renders /əˈbleɪt/. phonemes_str unchanged.
  6. Zero ə~ʌ and zero ɚ~ɝ rows in pairs with pair_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.
  7. A malformed or unmatched pron-fix.tsv row fails the build, with a test proving it.
  8. Detector + classifier reproducible from a committed script; review decisions committed as data.
  9. Full test matrix green: packages/data pytest, workers vitest, frontend type-check + lint + build.

8. Constraints

  • ipa is display-only. Verified: every consumer wraps it in slashes (WordProfileContext.tsx:187, LookupTool.tsx:343,615, PackItemCard.tsx:27). similarity.ts's ipa1/ipa2 are phoneme-level cache keys, unaffected. Matching runs on phonemes_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 thyme and thymes separately if both are wanted. (thymes is absent from CMU entirely.)
  • D1: 100 bind params/query, 100 columns/table. No new words columns are required by this design (stress goes into the existing ipa string, not a new column).
  • Terminology: "feature vectors," never "embeddings."