Skip to content

35. A letter has two readings, and a row's requires says which one it gets

Context

The 2026-09-02 MCP language sweep's Findings 1 and 2 are one cause with two faces.

fold_diacritics defaults to true. The text side folded and the parameter side did not, so a parameter was compared against text it could never equal: vowel="ä" reported found: "a" against expected: "ä" in German, vowels="äö" the same, consonant="ß" compared a two-character expected against a one-character got, and a ß in an acrostic target was a "letter" no unit could match. Seven rows were wrong under default parameters — the values no golden fixture supplies, which is what the sweep found out about the fixtures rather than about the rows.

One of the seven broke the project's thesis. slenderizing._produce kept a character when fold_diacritics(ch).lower() != params.deleted; ß folds to ss, which never equals one letter, so ß always survived a deletion _check then required. Measured: apply gave Die traße war groß. and its own checker scored that 0.412 with seven violations, under defaults, in the one row the safety net could not reach — slenderizing is in PARAMETER_GATED and its golden file pins lang: en at file level, so it was unexercised twice over.

The second face is not a fold at all. pack.vowels() was being asked several different questions and answering all of them with one flat frozenset:

pack vowels() folded folded, less y/ÿ
en aeiou (5) aeiou aeiou
de aeiouäöü (8) aeiou aeiou
fr aeiouyàâäèéêëîïôöùûüÿ (21) aeiouy aeiou

supervocalic publishes "each of the five vowels exactly once" and read that set, so it demanded German's eight and French's twenty-one against folded text that could never contain an umlaut: unsatisfiable in both languages, permanently. English was correct only because its readings coincide.

And the sweep filed French monoconsonantal("yoyo") — consonants: 0, satisfied — as a defect. It is not one, and saying why is the substance of D2. Both y in yoyo are /j/, but a flat set cannot express a letter whose class depends on where it stands, and every language here has such letters: English y w u i o (myth, cwm, quick, onion), German y i (Physik, Familie), French y i u ou (stylo, pied, huit, oui). rhythm has two syllables and no vowel letter at all.

The candidate rule — a vowel letter standing before another vowel letter is a consonant — scored 30 of 34 on the probe cases, with three of the four misses being probe error and the genuine miss German Quelle, where qu is /kv/. It is a good rule. Applied to these rows it is measurably wrong:

en onion    vowel letters oio  → oo   UNIVOCALIC in o
en quick    vowel letters ui   → i    UNIVOCALIC in i
en million  vowel letters iio  → o    UNIVOCALIC in o
fr oui / huit / lui / nuit     → i    UNIVOCALIC in i

No Word Ways editor accepts onion as an o-univocalic. en.py had already reached the same conclusion and encoded it: _VOWELS = frozenset("aeiou") for letter-play, _VOWEL_GROUP = re.compile(r"[aeiouy]+") for syllable counting. The two readings were already separate; nothing named the separation, so nothing stopped a row taking the wrong one.

Decision

D1. vowels() is split by the question asked, and only two of the three questions are answered so far. Reading its call sites finds three:

method returns consumers
vowels() every character written as a vowel, accented forms included word_ladder._alphabet, univocalic, bivocalic, monoconsonantal, source_compare (homoconsonantism, homovocalism) and spoonerism._letter_onset — six call sites across seven rows, two questions, see Consequences
vowel_inventory() the base vowel letters the language names supervocalic alone
a contextual classifier one class per character, phonetically spoonerism alone — not built, see D5

vowels() keeps its name and its meaning because of the first of those call sites: word_ladder._alphabet asks no question about vowelhood at all — it wants the diacritic variants a ladder step may be — and renaming the method would make that site read as a classification it is not. The other five sites, serving six rows, do ask a classification question, and get the orthographic answer, which is the one D2 says they are entitled to. So this split takes one of the three readings out of vowels() and leaves two behind it; the Consequences say what that costs.

vowel_inventory() lives on LanguagePack with a BasePack default that folds the written set and removes the ambiguous letters (_AMBIGUOUS = frozenset("yÿ")). All three packs answer aeiou and none overrides. The fold is what does the work — not the order of the two steps, which was claimed to be load-bearing during implementation and measured false: folding y/ÿ never produces a base vowel, so reversing the steps gives the identical result. What matters is that the fold happens at all. Without it the inventory is German's eight and French's nineteen (twenty-one written vowel letters, less the two ambiguous ones), against a definition that says five.

D2. A row's requires list licenses which reading of a letter it may take. The split is by row, not by language.

univocalic, bivocalic, monoconsonantal and supervocalic are family: letter, sourced to Bombaugh (1867) and Word Ways, and declare requires: [tokens, fold_diacritics, alphabet] — no phonemes. homoconsonantism and homovocalism, the two source_compare rows, declare the same. A glide rule in any of them is a row making a pronunciation judgement while advertising that it works on letters alone, which is the defect class ADR 0030 fixed twice in one day. The measurement above is what it would cost: seven ordinary words silently become univocalics.

spoonerism is the one consumer that declares phonemes, and its _letter_onset already calls itself "a written stand-in for the phonetic boundary it cannot reconstruct". That is where the contextual reading belongs, whenever it is built.

Rejected: one contextual rule in BasePack for all three languages. It measured correct linguistically and flipped zero golden cases — but zero flips is evidence, not proof, and correctness about phonetics is not a licence for rows defined on orthography.

tests/test_reading_boundary.py pins the boundary by name rather than by implementation: onion, quick, million, oui, huit, nuit and lui must not be univocalics, and the four rows must not gain phonemes without this decision being revisited. It holds whether or not the phonetic reading exists.

D3. The parameter side folds the way the text side folds. core/text.py gains fold_letter(ch, pack, *, fold), the parameter-side twin of letter_spans, applied at the seven rows this decision covers — univocalic, bivocalic, monoconsonantal, slenderizing, and the acrostic family. Not at every comparison of a parameter against folded text in the library; the rows that still do not fold are listed under Known remaining, and this decision does not reach them. fold_diacritics: false stops being the workaround it was for these seven.

A target that is a phrase rather than a single letter is flattened, so a ß claims the two units its two letters need: acrostic, telestich by inheritance, and double_acrostic, which kept its own copy of the expression and had to be fixed separately — which is why the sweep's two acrostic rows are three. The flatten itself is fold_target(value, pack, *, fold) beside fold_letter, and all three sites call it: leaving three copies of the expression while Alternatives considered rejects per-checker folding as "five checkers, five chances to differ" would have been the rejected alternative written in smaller print.

D4. A single-letter parameter that folds to several characters is refused, by name. single_letter() raises InvalidParams naming the field, the value, what it folded to, and fold_diacritics: false as the way to use it as one letter. These callers compare letter against letter, and a two-letter expected against a one-character got is not a comparison a violation can report honestly. This replaces slenderizing's degenerate_output advising allow_identity=true, which named neither the cause nor a remedy and would have returned the untouched text as a slenderizing.

Matching the folded sequence instead was rejected: it turns a per-letter comparison into a per-sequence one in four checkers and makes found and expected differ in length in every violation they emit.

D5. The contextual classifier is deferred, and this record does not describe it as built. The design for it stands — one class per alphabetic character of a single unfolded word, since context is orthographic and does not cross a word boundary; French ou marked consonant on both characters because a per-character return cannot say "these two are jointly one glide". None of it is implemented. spoonerism._letter_onset still splits on flat vowels() membership, so French yoyo still has an empty onset and English myth is still entirely onset. That is a known-wrong approximation that continues to ship, and D2 is the reason it is the only place the fix would be allowed to land.

D6. slenderizing's _produce decides per source character by looking at every letter that character folds to. D3 applied to a generator, and stated at that precision because the weaker version of it was wrong. Folding deleted is necessary and not sufficient: the first fix dropped a source character whenever any letter it folds to was deleted, which agrees with _check only when a fold is uniform. ß → ss is uniform; œ → oe is not, and apply("Le cœur et la sœur", lang="fr", deleted="e") returned L cur t la sur, which its own checker scored 0.167, unsatisfied — the German defect one language over, in the fix for it. The rule is three-way:

the character's folded letters _produce emits
none is deleted the source character, untouched — case and the ligature survive
all are deleted nothing; this is what takes ß out with s
some are the survivors, folded, since no single character spells them

œ under deleted="o" is the sharper case: the old code raised DegenerateOutput, an honest refusal, and the first fix turned that into text failing its own checker — a regression, not a shortfall. Both now round-trip at 1.0 (L cour t la sour, Le ceur et la seur), pinned by French tests and a golden case beside the German ones.

Known remaining, FIXED 2026-09-03

Five more rows declared fold_diacritics and still compared an unfolded parameter against folded text. They were out of scope in the branch that shipped this ADR — that work fixed the seven the sweep filed — but an unqualified "every such comparison" would have been false, so they were named here and fixed the same day, once folded, using the same single_letter helper as the seven above. A sixth row not named in the original finding, serial_lipogram, was fixed alongside lipogram for the reason given below. Reproduced on this branch before the fix, at default parameters:

row call verdict before the fix
lipogram ("Rätsel", lang="de", forbidden="ä") satisfied=True, score 1.0
pangrammatic_lipogram a German pangram containing ä, forbidden="ä" satisfied=True, score 1.0
tautogram ("Ähre Ähnlich Ärmel", lang="de", initial="ä") three wrong_initial, score 0.0
homoteleuton ("café passé", lang="fr", final="é") two wrong_final, score 0.0
abecedarian ("ähnlich bald cool", lang="de", start="ä") satisfied=True — and vacuous

lipogram was the serious one, with pangrammatic_lipogram the same shape behind it: a lipogram is a claim of absence, so an unfolded forbidden inverted the verdict rather than refusing it. The text plainly contained the letter and the row said it did not. The other three failed closed — annoying, honest. abecedarian failed neither way: "ä" was not in alphabet(), alphabet.index was guarded, so first fell back to 0 and start="ä" was silently start="a"; both spellings returned the identical report, which is what made it vacuous rather than wrong. A sharper example than the table's own, found while fixing it: start="ö" folds to "o" (index 14, not 0), so the fallback disagreed with the correct answer on every line rather than agreeing by coincidence — a genuine wrong verdict, not merely a vacuous one.

beau_present, belle_absente and letter_bank were checked and are fine — they fold their parameter as text, through letter_spans, so they never had the seam.

The fix was not only a fold, as anticipated here. lipogram's fix reaches pangrammatic_lipogram (same fold, same helper) but not serial_lipogram, which has its own comparison and its own bug: it refused start="ä" outright with an InvalidParams, even under default folding — stricter than the fold its own text-side comparison already performs. Folding start before the alphabet-membership check fixes it. abecedarian needed the decision this section flagged as open: what happens when a folded start is still not a letter of the row's own flat alphabet(), which is only reachable with fold_diacritics=False on a genuinely accented letter. The decision taken is the one serial_lipogram already modelled — refuse by name by raising InvalidParams, rather than silently defaulting to index 0 as before. Ten golden cases were added, one per fixed row plus a negative for four of them, all in the language the original defect was reproduced in (tests/test_folding_seam.py, eval --all 498 → 508).

Consequences

German supervocalic is satisfiable for the first time, and its catalogue row still says languages: ["en"]. No editorial languages value moved here, deliberately — whether a row should now claim a language it can be satisfied in is an editorial decision and not a side effect of a bug fix. So the catalogue continues to understate what these rows do, and runs_in (ADR 0029) is the only place a caller can see it.

French monoconsonantal("yoyo") still reports consonants: 0, and English still reports 2 for the same text. Under French orthographic convention y is a vowel letter, so zero is the consistent answer for a row defined on letters — consistent, not obviously right. A caller who reads yoyo as /jojo/ will think the row is broken, will find the sweep filed it as a defect, and will find this record saying it is not. The en/fr divergence on identical input is a convention difference this decision declines to erase. Whether a text with zero consonants should satisfy monoconsonantal at all is a separate verdict question, still unsettled.

A rule that measured correct was refused, and the cost is that the packs stay wrong about phonetics. The glide rule was right in all three languages and broke no golden case. It is not here. Every consumer that would genuinely benefit — the syllable path already has its own reading, spoonerism has none — waits on D5, and until then spoonerism publishes an onset it knows is sometimes wrong. Deferring a measured improvement to keep a boundary clean is a real price and it is being paid on purpose.

ß and œ cannot be single-letter parameters at all while folding is on. What used to be an unsatisfiable report is now a raised InvalidParams, which is a harder failure than callers previously met: a script that passed consonant="ß" and read satisfied now gets an exception instead of false. The message names the remedy, and the remedy works, which is the whole difference from the error it replaces.

slenderizing.apply(..., fold_diacritics=false) now refuses where it used to return text. The old _produce ignored the parameter and always folded, so apply("Bäh", lang="de", deleted="a", fold_diacritics=False) returned "Bh" — deleting a letter the caller had just said not to fold ä into. It now raises DegenerateOutput, because with folding off ä is ä, the text contains no a, and the slenderizing of it is the text itself. That is the answer _check gives, so the two agree for the first time; it is still a behaviour change nobody asked for, arriving in a bug fix, and a caller reading fold_diacritics: false as the safe setting will meet it.

A mixed fold also costs case. D6's third row — some of a character's folded letters deleted, some not — emits the survivors folded, so a ligature that opens a word loses its case along with the deleted member: apply("Œuvre et Sœur", lang="fr", deleted="e") returns "ouvr t Sour", the leading Œ becoming a lowercase o. The output still satisfies its own checker, so this is a cost to record, not a bug to fix.

With folding off, an accented vowel is inert in supervocalic. The inventory is always the folded aeiou, while ä stays ä in the text — so it neither supplies the a the row requires nor counts as a repeat of one. That is the intended reading of a definition that says five, and it is also a rule nobody would guess from the outside; tests/test_vowel_inventory.py measures both halves so it cannot drift silently.

vowels() still answers two questions, not one. D1 split off the inventory and leaves univocalic, bivocalic, monoconsonantal, the two source_compare rows and spoonerism._letter_onset reading the written set to decide which letters in a text are vowels, beside word_ladder._alphabet, which asks something else entirely. Both readings are the ones those rows are entitled to under D2, so both are correct — but the method's name still covers two questions, and the next person to add a consumer has only this record to tell them which one they are getting.

denckring eval --all goes from 487 to 498 passing cases and denckring status does not move, staying 155 · 130 · 121 · 121 · 25 and 0 instruments catalogued. This chapter adds no catalogue row and implements no procedure; it fixes eight that were already counted as implemented, which is precisely the kind of change the coverage line cannot see.

Alternatives considered

Folding the parameter inside each checker. Five checkers, five chances to differ, and the two acrostic rows had already proved the point by diverging with one copy each of the same expression. fold_letter and fold_target are two functions on the seam that already owns folding — fold_target because the first pass at this decision left the flatten copied three times, which is the rejected alternative at one third the scale.

Leaving fold_diacritics: false as the documented workaround. It is what the sweep found callers doing, and it is a per-call instruction to disable a default that exists because it is right. It also cannot fix slenderizing, whose generator ignored the parameter entirely.

Giving supervocalic a literal "aeiou". It would have fixed the row and put a fact about which letters a language names into a procedure module, which is the placement ADR 0030 and ADR 0034 D1 both moved away from. A fourth pack with a sixth vowel letter would then be wrong in a file nobody would think to look in.