Skip to content

manifests: the consonant classes are inventories, not copies — 14 engines - #750

Merged
christopherthompson81 merged 2 commits into
mainfrom
core-ipa-consonants
Aug 8, 2026
Merged

manifests: the consonant classes are inventories, not copies — 14 engines#750
christopherthompson81 merged 2 commits into
mainfrom
core-ipa-consonants

Conversation

@christopherthompson81

Copy link
Copy Markdown
Owner

The third residual from #748. The plan said "consolidate the 33 consonant classes into core/ipa.ts". The triage says don't — and that's the main content of this PR.

Why not

No two of them are the same list. voiceless is six different sets, nasal four, velar five — they share a class name and nothing else:

class engines members
voiceless 6 hungarian p t k f s ʃ t͡s t͡ʃ c x · icelandic p t k s θ f h c · luxembourgish p t k s ʃ f χ t͡s t͡ʃ · occitan p t k f s · romanian (obstruent, both voicings) · tibetan (Wylie tokens, not IPA)
velar 5 ancientgreek ɡ k kʰ ks · galician k ɡ · latin k ɡ kʷ ɡʷ kʰ · totontepecmixe k ɡ ɣ · mindong (romanization finals)
sonorant 5 chuvash l r · irish ɾˠ ɾʲ l̪ˠ lʲ · latin l ɫ r · scottishgaelic l̪ˠ lʲ rˠ ɾʲ · tibetan (Wylie)
nasal 4 albanian m n ɲ ŋ · chuvash n m ŋ j · totontepecmixe m n · turkmen m n ŋ

That contrast with the vowels is the whole point: 31 engines held near-identical copies of one universal alphabet, so sharing was obviously right. Here each list is the language's own inventory.

And the narrowness is load-bearing, which is what settles it rather than taste:

  • totontepecmixe NASAL is {m, n} — but the engine does emit ŋ. Post-nasal voicing fires after ⟨m n⟩ only.
  • occitan VOICELESS_PH excludes ʃ — but the engine does emit it. Intervocalic ⟨s⟩→[z] depends on the exclusion.

A universal class changes both. "The IPA vowel letters" is a fact about the notation; "the voiceless obstruents of this language" is a fact about the language.

What this PR does instead

Moves them to their manifests (#746's rule), each carrying the rule it serves and why it is narrow. core/ipa.ts stays a vowel-class module.

14 engines: albanian, ancientgreek, chuvash, dutch, galician, hungarian, irish, luxembourgish, occitan, romanian, santali, scottishgaelic, totontepecmixe, turkmen.

Irish and Scottish Gaelic are the clearest illustration — their liquids carry the broad ⟨ˠ⟩ / slender ⟨ʲ⟩ marks, so "the liquids" is four phones in a way no shared class could express. Galician already had nasals in its manifest; its velars now sits beside it.

Left alone, deliberately

Verification

Pure data relocation: 19 rewritten sets compared against main, 0 differ (the 20th, Scottish Gaelic's, checked by hand — the script binds DEF/MANIFEST, not that file's M). npm run ci green — 228 files / 3135 tests, typecheck, fence.

docs/ipa_classes_investigation.md Run 8 has the triage.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr

christopherthompson81 and others added 2 commits August 7, 2026 22:17
…ines

The follow-up item assumed these were the vowel problem again. They are not: no two of the 33 are the
same list. "voiceless" is six different sets, "nasal" four, "velar" five — they share a class NAME and
nothing else, because each is that language's own inventory.

The narrowness is load-bearing, which is what settles it. totontepecmixe's NASAL is {m n} while the
engine DOES emit ŋ — post-nasal voicing fires after m/n only. occitan's VOICELESS_PH excludes ʃ while
the engine DOES emit it — intervocalic s→z depends on that. A universal class would change both.

So they go to their MANIFESTS (#746's rule), not to core/ipa.ts, each with the rule it serves and why
it is narrow. core/ipa.ts stays a vowel-class module. Irish and Scottish Gaelic are the clearest case:
their liquids carry the broad ⟨ˠ⟩ / slender ⟨ʲ⟩ marks, so "the liquids" is four phones in a way no
shared class could express.

Left alone: tibetan's Wylie classes (#741 kept the stack grammar with the parser), the VOICELESS sets
in bulgarian/maltese/macedonian (derived from each manifest's DEVOICE map, already single-sourced), and
icelandic (its files are in flight in #749).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr
The premise of this PR is that the manifest comment becomes the authoritative explanation of the class,
so a comment that is merely plausible is the defect, not a nitpick. All six were wrong on the facts:

- santali "stops" was DEAD (STOP bound, never read) and its stated reason was false — "checked" maps
  ɡ→kʼ and k→kʼ identically, so a checked voiced stop does NOT surface differently. Dropped the key;
  voicedStops now says the real reason, that only a final VOICED stop is checked at all.
- galician velars read backwards: the set is tested against the phone AFTER the nasal (cinco→siŋko),
  and word-final ⟨n⟩ velarizes through a separate no-following-phone branch.
- albanian claimed the sibilant cluster was "the one place" a rising onset is not required; the nasal
  branch right beside it is a second exception, and it reads the very key being introduced.
- scottishgaelic pointed at "the preceding rule" — the consumer is preaspiration.
- dutch: the tables it referred to are below, not above, and its "must list exactly what the tables
  emit" invariant was already false (ʒ is emitted via voicedFinal and deliberately absent). Acting on
  the invariant would have changed seam degemination, so it now says NOT to.
- and I orphaned 12 header comments from their tables again, the same way as in #746. Put back, and
  checked with the same script that verified that sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr
@christopherthompson81
christopherthompson81 merged commit c118b7a into main Aug 8, 2026
2 checks passed
@christopherthompson81
christopherthompson81 deleted the core-ipa-consonants branch August 8, 2026 04:24
christopherthompson81 added a commit that referenced this pull request Aug 8, 2026
Same orphaning as #746 and #750 — my spellingVowels block landed between the digraph comment and the
digraph table. Caught by the check-headers script, which I ran on the follow-up branch rather than this
one; running it before pushing is the habit that keeps failing to stick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr
christopherthompson81 added a commit that referenced this pull request Aug 8, 2026
* manifests: the four orthographic sets #748 reverted

They were named like IPA classes (VOWELS, R_VOWEL) but scan SPELLING, which is why pointing them at
core/ipa.ts broke Quechua. #748 reverted them with a comment; this puts them where #746 says they
belong, with the trap recorded beside the data:

  quechua  spellingVowels — the -yuq/-niyuq suffix reads the last LETTER; ⟨y⟩ is absent on purpose,
           being a consonant letter in Quechua but the IPA vowel
  luo      spellingVowels — the gi-/g- elision reads the first letter of the next number word
  balochi  romanVowels    — the ROMAN scan, distinct from the Arabic-script vowelLetters beside it
  fula     latinVowels    — the letters the Adlam lengthener doubles in adlamToLatin's output

quechua gains a manifest.ts for the single parse (quechua.ts imports numbers.ts, so a back-import would
cycle) — the abkhaz/georgian pattern. luo and fula already had one; balochi.ts already had DEF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr

* quechua: put the digraphs header back on its own table

Same orphaning as #746 and #750 — my spellingVowels block landed between the digraph comment and the
digraph table. Caught by the check-headers script, which I ran on the follow-up branch rather than this
one; running it before pushing is the habit that keeps failing to stick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
christopherthompson81 added a commit that referenced this pull request Aug 8, 2026
…ass vacuously

Review caught the same orphaning as #746/#750/#751 — my three phone classes landed between the
Context-free consonants comment and the consonants table. Fourth time.

The check script DOES catch it. I ran it before committing, when git diff main...HEAD is empty, so it
compared nothing and printed ok. A checker that reports success having checked nothing is the exact
defect this session kept finding in the codebase, now in the tool meant to prevent it.

Promoted from a scratchpad file to tools/check-manifest-headers.mjs, where comparing zero files EXITS 2
with an explanation instead of passing — the rule this repo already applies to its own gates
(manifest-script's count assertion, cyrillic-confusables' expect(size).toBeGreaterThan(5)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr
christopherthompson81 added a commit that referenced this pull request Aug 8, 2026
)

* latin: the three function-local phone classes move to the manifest

The last of #750's exclusions. VELAR, MUTA and LIQUID were declared INSIDE the scan, so they were also
being rebuilt on every word — they are per-word constants, and Latin's own inventories besides: the
labiovelars ⟨kʷ ɡʷ⟩ and the Greek-loan aspirates ⟨pʰ tʰ kʰ⟩ are members, so no shared class could stand
in for them.

The manifest now records what each is FOR, including the one that is not obvious: mutae is wider than
velars because muta cum liquida is about obstruent+liquid, not place — it is what makes volucris
antepenultimate ˈwɔɫʊkrɪs rather than *wɔˈɫukrɪs.

Pure move: la referee identical at 41399/44907 (92.2%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr

* latin: put the consonants header back, and make the check refuse to pass vacuously

Review caught the same orphaning as #746/#750/#751 — my three phone classes landed between the
Context-free consonants comment and the consonants table. Fourth time.

The check script DOES catch it. I ran it before committing, when git diff main...HEAD is empty, so it
compared nothing and printed ok. A checker that reports success having checked nothing is the exact
defect this session kept finding in the codebase, now in the tool meant to prevent it.

Promoted from a scratchpad file to tools/check-manifest-headers.mjs, where comparing zero files EXITS 2
with an explanation instead of passing — the rule this repo already applies to its own gates
(manifest-script's count assertion, cyrillic-confusables' expect(size).toBeGreaterThan(5)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Lc3WnUgogC7okV7n53vjr

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
christopherthompson81 added a commit that referenced this pull request Aug 26, 2026
Running the gate from the previous commit over a wider window turned up the
exact defect it was written for, sitting in main.

amharic.jsonc: the four-line "THE SHARED SYMBOL TIER's data" header ended up
above the ORDINALS block. `ordinals` and `words` were inserted between that
header and the `symbolTier` key it describes, so a reader following the header
reached the ordinals table and `symbolTier` — 60 lines further down — carried
no header at all. This is #746/#750/#751/#755 verbatim. The header is moved
back onto its key; nothing else changed, and am stays 200/200.

Two further hardenings the wide run exposed, both of the script's own stated
"refuses to pass vacuously" invariant:

- `git show` wrote "exists on disk, but not in <base>" to the terminal for
  every file when the base predates the data-tree move. Piped, so it reaches
  the catch instead of the console.
- That catch means "no version at base", which is usually a new manifest but is
  EVERY file across a tree move — and the script then printed "ok" having
  compared nothing, which is precisely the vacuous pass its docstring says let
  #755 ship. Comparisons are now counted: an "ok" states how many files it
  actually compared, and zero-compared exits 2 like the empty-diff branch.

Verified all three ways: passes clean on a normal base (35 changed manifests,
35 compared), exits 2 on a pre-move base, and still flags a synthetic key
inserted between vowelSigns' header and its table.

Full gate 79 languages / 15,800 rows / 0 differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuH4pRrB1PAEiG8CCLVR5S
christopherthompson81 added a commit that referenced this pull request Aug 26, 2026
* bho: revive the manifest-header gate, and file the अ/ʌ split

Follow-up to #1025, from its own review.

⚠ tools/check-manifest-headers.mjs HAS BEEN DEAD SINCE THE DATA MOVE. It globs
`src/languages/**/*.jsonc`; all 184 manifests live under `data/languages/`.
The glob matches nothing, so every run takes the "refuses to pass vacuously"
branch and exits 2 — loud, so nobody was misled by a false pass, but the guard
against a header detached from its key has had zero coverage since #769. That
matters here specifically: #1025 was largely a rewrite of four top-level key
headers in a manifest.

Reviving it exposed a second defect in the same script. It fired on #1025's
extended `independentVowels` header — a caveat APPENDED below the original
line, still directly above its own key. The script's existing exemption only
covers a header replaced outright ("old text gone from the file entirely"), so
an extension looks exactly like an orphaning to the `normalized.includes` test.
In a real orphaning the old text has moved to a DIFFERENT key, so `now` cannot
contain it — that is the discriminator, and it is now the guard. Verified both
ways: the gate passes the extended header, and still catches a synthetic
insertion between `vowelSigns`' header and its table.

FILED, NOT FIXED — ONE PHONEME, TWO SYMBOLS. ⟨अ⟩ ships [ʌ] while
`inherentVowel` ships [ə], and the latter's own comment calls [ʌ] the same
sound, "folded". अञ्जन reads ʌnd͡ʒən: both symbols, one word. `provenance`
compounds it — the declared inventory /i e ɛ a ʌ ɔ o u/ contains ʌ and not ə,
so neither value matches the file's own statement of the system. Not cosmetic:
ʌ is in 99 of the 200 golden rows, ə in all 200. Every non-derived Devanagari
sibling writes ə (hi/mr/awa/mai/hne); only magahi.jsonc, derived from this
file, copies the ʌ. The direction is genuinely unobvious — folding ʌ→ə matches
the inherent vowel and the siblings, folding ə→ʌ matches the declared
inventory — and the referee that decides it is not in the repo.

Also recorded: `provenance` ends "Single published source → ." — a generator
dropped the confidence grade after the arrow, and magahi.jsonc has the same
wound. Marked as truncated rather than reconstructed; guessing a confidence
grade is worse than showing one is missing.

csharp/STATUS.md is deliberately untouched — four ports are in flight and the
brief reserves that file.

Comment- and string-only: bho 200/200, full gate 79 languages / 15,800 rows /
0 differ, 5,397 vitest, 693 dotnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuH4pRrB1PAEiG8CCLVR5S

* tools: the revived header gate finds a live orphaning in amharic.jsonc

Running the gate from the previous commit over a wider window turned up the
exact defect it was written for, sitting in main.

amharic.jsonc: the four-line "THE SHARED SYMBOL TIER's data" header ended up
above the ORDINALS block. `ordinals` and `words` were inserted between that
header and the `symbolTier` key it describes, so a reader following the header
reached the ordinals table and `symbolTier` — 60 lines further down — carried
no header at all. This is #746/#750/#751/#755 verbatim. The header is moved
back onto its key; nothing else changed, and am stays 200/200.

Two further hardenings the wide run exposed, both of the script's own stated
"refuses to pass vacuously" invariant:

- `git show` wrote "exists on disk, but not in <base>" to the terminal for
  every file when the base predates the data-tree move. Piped, so it reaches
  the catch instead of the console.
- That catch means "no version at base", which is usually a new manifest but is
  EVERY file across a tree move — and the script then printed "ok" having
  compared nothing, which is precisely the vacuous pass its docstring says let
  #755 ship. Comparisons are now counted: an "ok" states how many files it
  actually compared, and zero-compared exits 2 like the empty-diff branch.

Verified all three ways: passes clean on a normal base (35 changed manifests,
35 compared), exits 2 on a pre-move base, and still flags a synthetic key
inserted between vowelSigns' header and its table.

Full gate 79 languages / 15,800 rows / 0 differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuH4pRrB1PAEiG8CCLVR5S

* tools: decide the vacuous-pass case before anything prints "ok"

Self-review of the previous commit: the zero-compared guard ran AFTER the
console.log, so the pre-move-base run printed "ok — 184 changed manifest(s), 0
compared…" and only then errored. A gate that says ok on its way to exit 2 is
worse than one that just says ok. Moved above the log, and the message now
names the actual cause and the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuH4pRrB1PAEiG8CCLVR5S

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant