Phonemic Stress Separation: one vowel for AH, stress as its own annotation¶
Date: 2026-09-13
Status: Design — D1 and D2 settled during implementation; D3 still gated on measurement
Follows: 2026-09-12-pronunciation-fidelity-design.md (merged as PR #283)
Branch: feature/phonemic-stress-separation
1. The problem the last branch only half-fixed¶
The previous branch rendered stress into the ipa column with ˈ/ˌ. It did not remove
the stress-conditioned vowel mapping. So stress is now encoded twice, and one word shows it:
abduction ipa = æbˈdʌkʃən phonemes_str = |æ|b|d|ʌ|k|ʃ|ə|n|
CMU has a single phone AH. It appears twice in abduction, and becomes two different
phonemes in our data purely because one carried stress digit 1 and the other 0. The
ipa column now also marks that same stress independently. ER behaves identically
(ER0→ɚ, ER1/ER2→ɝ).
The previous branch then suppressed the 181 resulting false contrast pairs. That treats the symptom. The cause is that vowel identity carries stress.
The rule does not compose¶
Three separate places currently have to know that ə~ʌ is not a contrast:
| site | what it has to know |
|---|---|
phonology/allophones.py |
do not emit the substitution pair |
config/allophones.ts |
answer 422 rather than an empty list |
tests/test_derived.py |
the brute-force oracle must model the exception |
Every future consumer of phonemes would need to learn it too. Under collapse, all three
disappear — the pairs cannot be generated, so nothing downstream needs a rule.
It also mis-assigned blame to CMU¶
The previous branch deferred "CMU over-assigns secondary stress," citing
ABLATE AH2 B L EY1 T → /ˌʌˈbleɪt/. Only the spurious ˌ is CMU's fault. The wrong
vowel is ours: AH2→ʌ is our mapping. With one phoneme for AH, ablate becomes
/ˌəˈbleɪt/ — still over-stressed, but segmentally correct.
Half the system already collapsed them¶
ʌ appears nowhere in packages/audio/src. The audio pipeline's ESPEAK_MAP normalizes
every AH-class vowel to ə and never produces ʌ, and there is no reconciliation in
pronunciationScore.ts or audioCanons.ts. So the lexicon offers cup as |k|ʌ|p| while
the transcriber can only ever emit ə.
Unverified: whether this lands as a hard symbol mismatch or a small feature-distance penalty depends on the Fisher-weighted scorer, which has not been traced. Either way the two halves of the system disagree about the inventory, and that is independent evidence for the collapse rather than against it.
2. What this does NOT claim¶
The previous spec's §2 established that ə and ʌ are acoustically distinct — the learned feature vectors separate them on backness at z = 16.8, and ʌ is the 3rd most certain of 58 segments. That finding stands and is not contradicted here.
It answers a different question. ə and ʌ are distinct phonetic realizations; they are not distinct phonemes. Unstressed centralization is real, which is exactly why the allophony exists — and exactly why the distinction is derivable from stress rather than needing to be stored in segment identity.
So: the learned vectors keep both, because similarity and audio care about realization. The phonemic layer keeps one, because contrast and pattern matching care about opposition.
3. Design¶
3.1 ə and ɚ are the phonemic symbols¶
Both directions are defensible on convention — General American inventories are written with /ʌ/ (STRUT) and /ɝ/ (NURSE) as often as with /ə/. The data decides it:
| candidate | words whose phonemes_str must be rewritten |
|---|---|
ʌ / ɝ |
49,069 + 21,689 |
ə / ɚ |
7,417 + 4,842 |
ə/ɚ is a roughly 6× smaller diff, matches the label the audio pipeline already emits, and
keeps the symbol clinicians name aloud ("schwa") in the IPA keyboard.
arpa_to_ipa: AH, AH0, AH1, AH2 -> ə
ER, ER0, ER1, ER2 -> ɚ
Inventory for search and contrast goes 41 → 39.
3.2 Stress becomes annotation, fused onto the nucleus¶
Two new columns on words. phonemes_str itself is untouched.
phonemes_str |æ|b|d|ə|k|ʃ|ə|n| unchanged
phonemes_stress_str |æ0|b|d|ə1|k|ʃ|ə0|n| stress fused onto each nucleus
syllable_stress_str |0|1|0| one slot per syllable
Why phonemes_str is untouched. Every pattern query in the product is a LIKE against
it — %|æ|%. Annotating vowels in place would turn that into |æ1| and silently break every
vowel search in Word Lists, Sentences and Contrast Sets at once.
Why the stress digit is FUSED rather than held in a parallel index-aligned column. This
spec first proposed |2|-|-|1|-|, one slot per phoneme. That is unqueryable. Pattern matching
is LIKE on a pipe-delimited string with no positional indexing, so "the phoneme at index n
carries stress s" would require two independent LIKEs that can match at different
positions — returning words that look right and are not, which is the exact bug class this
line of work exists to remove. Corrected during implementation (2026-09-14) before anything
was built on top of it.
Fused, the query is one clause:
| question | clause |
|---|---|
| unstressed schwa | phonemes_stress_str LIKE '%\|ə0\|%' |
| stressed schwa (the old ʌ) | phonemes_stress_str LIKE '%\|ə1\|%' |
| primary stress on syllable 2 | syllable_stress_str LIKE '\|0\|1\|%' |
Why syllable_stress_str is separate. Stress position is a syllable question, and the
fused phoneme string carries no syllable boundaries. Keeping them apart means each query shape
is one LIKE against the column built for it, rather than one column serving neither well.
3.3 ipa still renders the allophone¶
ipa is display-only (verified in the previous branch: every consumer wraps it in slashes).
It continues to show the phonetically faithful form, derived at render time from stress:
unstressed -> ə / ɚ about /əˈbaʊt/
stressed -> ʌ / ɝ cup /kʌp/ bird /bɝd/
So the reader sees no regression in the transcriptions — only the phonemic layer underneath
changes. ipa_render.py already receives the syllable structure it needs.
3.4 What this unlocks¶
Schwa stays searchable, and becomes more precise: "find words with a schwa" is now
unstressed /ə/ — phonemes_stress_str LIKE '%|ə0|%' — rather than relying on a symbol
that merely implied unstressedness. Without §3.2 the collapse would silently delete that
capability, which this project treats as a regression.
It also makes expressible a query with no current form at all: stress position — "words with primary stress on the second syllable" — a prosody target the product cannot ask for today.
4. Three decisions¶
D1. ə/ɚ as the phonemic symbols. Recommended on the diff-size and audio-alignment
grounds in §3.1. (This revises a verbal recommendation of ʌ/ɝ made before the counts
were measured.)
D2. Stress fused onto the nucleus, plus a separate per-syllable column. Settled during
implementation, not on paper. The originally recommended parallel index-aligned column turned
out to be unqueryable — see §3.2. Annotating phonemes_str in place remains rejected, since it
would break every existing vowel pattern query.
D3. Which vector represents the merged phoneme — MEASURE, do not decide on paper.
phoneme_dots is keyed by IPA symbol and the soft-Levenshtein looks up dots for whatever is
in phonemes_str. With only ə present, ʌ's vector goes unused and every stressed AH
is scored with ə's vector — a systematic distortion of unknown size. Candidates:
- use ə's learned vector (simplest; distorts stressed instances)
- frequency-weighted merge of the two vectors
- make similarity stress-aware and look up the allophone's vector (correct; largest change)
This needs a measured before/after on similarity scores, not a judgement call. The plan must include that experiment and report the score movement before any option is chosen.
5. Scope¶
In scope¶
| # | change | site |
|---|---|---|
| A | AH/ER → one IPA symbol each |
data/mappings/arpa_to_ipa.json |
| B | phonemes_stress_str + syllable_stress_str emitted |
phonology/stress.py, runtime/emit_parquet.py, emit_d1_sql.py |
| C | ipa renders the stressed allophone |
phonology/ipa_render.py |
| D | Delete the three allophone special cases | allophones.py, allophones.ts, test_derived.py |
| E | Stress-aware pattern predicate | lib/wordFilter.ts, config/properties.ts |
| F | Merged-phoneme vector, per D3's measurement | pipeline/derived.py |
| G | Full reseed | export → R2 → manifest |
Out of scope¶
- The variant display guard (5,691 words matching on forms the UI never shows) and bulk variant enrichment (~2,348 dialect alternatives). Both independent; neither queues behind this.
- CMU's genuine stress errors. With the vowel fixed, a spurious
AH2shows up only as an extraˌ. Auditing that needs a stress-bearing external source; WikiPron has none.
Blast radius¶
words whose phonemes_str changes |
12,259 (ʌ 7,417 + ɝ 4,842) |
| substitution pairs touching the four vowels | 67,198 of 642,871 |
| inventory | 41 → 39 |
| special-case sites removed | 3 |
Pair counts will move: pairs that were ə~X and ʌ~X collapse into one relation, and the 181 suppressed pairs vanish structurally rather than by rule.
6. Acceptance criteria¶
arpa_to_ipamapsAH*to exactly one symbol andER*to exactly one.phonemes_strcontains noʌand noɝ, lexicon-wide.ipastill reads/kʌp/,/bɝd/,/əˈbaʊt/,/ˈlɛtɚ/— display unchanged for a reader.phonemes_stress_strhas the same slot count asphonemes_strfor every row, andsyllable_stress_strone slot per syllable — both asserted over live pipeline output, not a parquet that can be stale.phonology/allophones.py,config/allophones.tsand the oracle's exception are deleted, and no ə~ʌ or ɚ~ɝ pair exists — structurally, with no suppression rule./api/contrastive/minimal-pairsfor ə~ʌ returns a phoneme-not-in-inventory response, not the bespoke 422 (the pair is no longer a special case; ʌ is simply not a phoneme).- A pattern query can express "unstressed /ə/" (
%|ə0|%) and "primary stress on syllable n" (syllable_stress_str), and both are exposed through/api/property-metadatarather than hardcoded in the frontend. phonemestable and the IPA keyboard expose 39 symbols, none yielding zero results.- D3's similarity measurement is reported, with the chosen option and its score movement.
- Full matrix green;
verify.pyfrom the previous branch still passes unchanged.
7. Risks¶
- Similarity scores move for any word with an
AH/ERvowel — most of the lexicon. D3's measurement is the gate; shipping without it means shipping an unmeasured change to a ranking users see. - The audio inventory must be reconciled, not assumed. Audio already emits
əonly, so this brings the two halves into agreement — but PHON-150/151 touch the same label set and should be checked before this lands, not after. syllable_countis max-across-variants (words.py:203), unrelated to this change but in the same file; do not alter it here.