Skip to content

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:

  1. use ə's learned vector (simplest; distorts stressed instances)
  2. frequency-weighted merge of the two vectors
  3. 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 AH2 shows 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

  1. arpa_to_ipa maps AH* to exactly one symbol and ER* to exactly one.
  2. phonemes_str contains no ʌ and no ɝ, lexicon-wide.
  3. ipa still reads /kʌp/, /bɝd/, /əˈbaʊt/, /ˈlɛtɚ/ — display unchanged for a reader.
  4. phonemes_stress_str has the same slot count as phonemes_str for every row, and syllable_stress_str one slot per syllable — both asserted over live pipeline output, not a parquet that can be stale.
  5. phonology/allophones.py, config/allophones.ts and the oracle's exception are deleted, and no ə~ʌ or ɚ~ɝ pair exists — structurally, with no suppression rule.
  6. /api/contrastive/minimal-pairs for ə~ʌ returns a phoneme-not-in-inventory response, not the bespoke 422 (the pair is no longer a special case; ʌ is simply not a phoneme).
  7. A pattern query can express "unstressed /ə/" (%|ə0|%) and "primary stress on syllable n" (syllable_stress_str), and both are exposed through /api/property-metadata rather than hardcoded in the frontend.
  8. phonemes table and the IPA keyboard expose 39 symbols, none yielding zero results.
  9. D3's similarity measurement is reported, with the chosen option and its score movement.
  10. Full matrix green; verify.py from the previous branch still passes unchanged.

7. Risks

  • Similarity scores move for any word with an AH/ER vowel — 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_count is max-across-variants (words.py:203), unrelated to this change but in the same file; do not alter it here.