The es pair, and a dictionary that was quietly Spain's

Phase 25. Spanish was never built — the groundwork was all [x] (DreamDict
data, the prompt language, the L1 rule gating, TTS env-discovery), which is
why the plan read as though it had shipped. shippedPairs was the honest
answer: the server had been refusing es on purpose.

The langpack is neutral Latin American, chosen with the user: tú, ustedes,
no vosotros, and the pan-American half of every vocabulary split. A vitest
greps for the peninsular twins the way fr is greped for québécismes —
including coger, which is not merely regional but obscene through most of
Latin America.

The dictionary is the story. Debian's hunspell-es symlinks twenty country
codes to one file, which reads as pan-Hispanic; RLA publishes twenty-four
builds per release, one per country plus a generic es that is the union,
and Debian ships peninsular es_ES. The 58,622-form gap is essentially
voseo, so the first version of this commit underlined vení and tenés as
misspellings and called it a considered gap.

The MUST_ACCEPT list was written to catch exactly that and structurally
could not: it asserted the pan-Hispanic vocabulary, and every RLA variant
carries the full pan-Hispanic vocabulary — only the paradigms are
localised. The REP table cited as the second witness is shared by all
builds too. Two independent-looking proofs, neither able to distinguish
anything, agreeing with each other.

The profile now demands what discriminates, each verified against the build
it targets: voseo rejects es_ES and Debian, vosotros rejects es_MX, and
arepa/chévere/bacán reject es_AR, which has both paradigms and would
otherwise pass. 717,640 forms, 1.74 MB gzipped, 762 ms / 97 MB in a real
nspell. fr and pt-PT rebuild byte-identical from their own upstream debs,
so the shared script still means what it meant.

Shipping the union is fr's call arrived at from the other side: coût and
cout are both correct French, tienes and tenés are both correct Spanish.
The dictionary holds every variety because underlining is all it can do;
the copy picks a register because speaking requires one.

Reviewed by four models at the usual >=2-of-4 threshold, 5 of 27 findings
applied — one catching the bedtime proverb as fr's Qui dort dîne calqued
into Spanish, gloss and all, which is the rule the fr header states. One
below-threshold finding (a missing ¡, seen by 1 of 4 because an absent
opening mark has no closing ! to look wrong against) was applied and turned
into an assertion instead: the suite now rejects any native line that
closes ? or ! without opening one.

piper-es on es_MX-ald-medium, not the es_ES-davefx-medium the plan named —
six of Piper's nine Spanish voices are peninsular, so the obvious pick was
the pt-PT trap through a different door.

go build/vet/test, tsc, vite, vitest 251/251.

Not deployed, not seen in a browser, not read by a native speaker, and no
es account exists.
This commit is contained in:
prosolis
2026-07-28 18:25:59 -07:00
parent 39d4e4770a
commit 9224c44fff
14 changed files with 972 additions and 18 deletions
+114 -5
View File
@@ -4,12 +4,13 @@ import { onPackChange, pack, resetPackForTests, setPackLang, shippedPacks } from
import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT'
import { fr } from './packs/fr'
import { es } from './packs/es'
import type { Pack } from './types'
// Every pack that ships. Shape assertions run over all of them, because the
// point of Phase 19 was that a language is data — and data that only the first
// author's pack satisfies isn't a shape, it's a coincidence.
const PACKS: Pack[] = [zh, ptPT, fr]
const PACKS: Pack[] = [zh, ptPT, fr, es]
// Every string a pack would ever put on screen, and nothing else — field names
// excluded (see the pt-PT grep below for what including them cost). Templates are
@@ -49,8 +50,10 @@ describe('pack selection', () => {
it('falls back rather than blanking on a pair with no pack yet', () => {
// A pair_lang the deployment has no copy for is a deployment that got ahead
// of its translation. She should still get a working editor. (This was 'fr'
// until Phase 24 gave fr a pack; 'es' is the pair still waiting for one.)
setPackLang('es')
// until Phase 24, then 'es' until Phase 25 — every pair PairLang names now
// has a pack, so the stand-in is a regional code Petal has not decided
// about, which is the realistic version of this failure anyway.)
setPackLang('es-ES')
expect(pack()).toBe(zh)
setPackLang('klingon')
expect(pack()).toBe(zh)
@@ -70,7 +73,7 @@ describe('pack selection', () => {
expect(seen).not.toHaveBeenCalled()
// An unshipped pair resolves back to zh, which is also not a change.
setPackLang('es')
setPackLang('es-ES')
expect(seen).not.toHaveBeenCalled()
setPackLang('pt-PT')
@@ -83,7 +86,7 @@ describe('pack selection', () => {
// matching allowlist exists to enforce from the other side.
it('offers exactly the pairs it has copy for', () => {
const codes = shippedPacks().map((p) => p.code)
expect(codes.sort()).toEqual(['fr', 'pt-PT', 'zh'])
expect(codes.sort()).toEqual(['es', 'fr', 'pt-PT', 'zh'])
// Every offered pair names itself, because a writer stranded on the wrong
// pack can only read the label that is in her own language.
for (const p of shippedPacks()) expect(p.nativeName.length).toBeGreaterThan(0)
@@ -364,6 +367,97 @@ describe('the fr pack', () => {
})
})
describe('the es pack', () => {
// Spanish has no regional question in its dictionary at all — hunspell-es
// ships twenty country codes and every one is a symlink to one pan-Hispanic
// word list — so, even more than with French, the entire regional decision
// lives in this file. Neutral Latin American was chosen deliberately, and a
// stray peninsular form is invisible to everyone reviewing the diff.
it('is Latin American, not peninsular', () => {
const text = copyOf(es).toLowerCase()
for (const bad of [
'ordenador', 'vosotros', 'zumo', 'patata', 'coche',
'gafas', 'billete', 'chaval', 'guay',
// The one that is not merely regional: *coger* is an everyday verb in
// Spain and obscene through most of Latin America. A companion in a
// private notebook must never produce it by accident.
'coger',
]) {
expect(text, `peninsular form "${bad}" in the es pack`).not.toMatch(
new RegExp(`\\b${bad}\\b`),
)
}
// And the accent it is read aloud in. Six of Piper's nine Spanish voices
// are es_ES, so the wrong country is the easy default here — the pt-PT
// trap, not the fr non-question.
expect(es.locale).toBe('es-MX')
})
// Spanish opens its questions and exclamations, and the easiest place to
// forget is exactly where a reviewer's eye slides past: inside a Line's
// native half, and inside an interpolated template. Every opening mark in the
// pack is deliberate; a missing one is a typo the type system cannot see.
it('opens every question and exclamation it closes', () => {
const lines: { native: string; where: string }[] = []
const walk = (node: unknown, path: string) => {
if (typeof node === 'function') return walk((node as (...a: unknown[]) => unknown)(1, 'x'), path)
if (!node || typeof node !== 'object') return
const rec = node as Record<string, unknown>
// A Line is the one shape whose `native` is pure Spanish — the flat
// "Spanish · English" strings carry English punctuation too, so they are
// asserted by hand below rather than by rule.
if (typeof rec.native === 'string' && typeof rec.en === 'string') {
lines.push({ native: rec.native, where: path })
return
}
for (const [k, v] of Object.entries(rec)) walk(v, path ? `${path}.${k}` : k)
}
walk(es, '')
expect(lines.length).toBeGreaterThan(40)
for (const { native, where } of lines) {
if (native.includes('?')) expect(native, `${where} closes ? without ¿`).toContain('¿')
if (native.includes('!')) expect(native, `${where} closes ! without ¡`).toContain('¡')
}
// An exclamative opening with Qué/Cómo/Cuánto is the case that slips past a
// reader, because it carries no closing "!" to look wrong against — the
// whole pair is simply absent. The quorum review caught exactly one of
// these ("Qué linda elección de palabra"), and only one reviewer of four
// saw it, which is the argument for asserting it instead of re-reviewing it.
for (const { native, where } of lines) {
expect(native, `${where} opens an exclamative without ¡`).not.toMatch(
/^(Qué|Cómo|Cuánto|Cuánta)\b/,
)
}
// And the two-language strings, by hand: both halves punctuate their own way.
expect(es.garden.promptRecognition).toBe('¿Qué significa? · What does this mean?')
expect(es.garden.promptProduction).toContain('¿Cuál es la palabra en inglés?')
})
it('renders its interpolated lines with the value in place', () => {
expect(es.app.duplicateTitle('Primavera')).toBe('Primavera (copia)')
expect(es.companion.milestone(300).native).toContain('300 palabras')
// Spanish agreement is the pack's business; the call site only ever passes
// a number. `flor`/`flores` is the irregular one — it takes -es, not -s.
expect(es.garden.reviewDue(1)).toContain('1 palabra ·')
expect(es.garden.reviewDue(4)).toContain('4 palabras ·')
expect(es.garden.growing(1)).toContain('1 flor en el jardín')
expect(es.garden.growing(3)).toContain('3 flores en el jardín')
expect(es.journal.kept(1)).toContain('1 cosa que te llevaste')
expect(es.journal.kept(5)).toContain('5 cosas que te llevaste')
expect(es.status.petalsToPolish(1).native).toContain('1 pétalo por pulir')
expect(es.status.petalsToPolish(2).native).toContain('2 pétalos por pulir')
})
it('says the collision line, which this pair meets constantly', () => {
// real, red, once, pie, sin, pan, mayor, sale, ropa — Spanish and English
// collide about as often as French and English do.
expect(es.editor.alsoIn).toBeTruthy()
expect(es.editor.alsoIn).not.toBe(zh.editor.alsoIn)
expect(es.editor.alsoIn).not.toBe(fr.editor.alsoIn)
expect(es.editor.alsoIn).not.toBe(ptPT.editor.alsoIn)
})
})
// False friends are a per-pair dataset rather than copy: the Latin pairs carry
// the traps their writers actually fall into, and the zh pair legitimately has
// none. Both halves of that are worth pinning.
@@ -391,6 +485,21 @@ describe('false friends', () => {
expect(Object.keys(fr.falseFriends).length).toBeGreaterThan(10)
})
it('the es pair carries the ones that cost most', () => {
// "embarrassed" is the reason this feature exists at all: *embarazada* is
// "pregnant", and it is the single false friend most likely to be said out
// loud to a room. "molest" is the other one that has to be here, because
// *molestar* is an everyday word and the English is not.
for (const word of ['embarrassed', 'molest', 'actually', 'realize', 'exit', 'carpet']) {
expect(es.falseFriends[word], word).toBeDefined()
}
// Spanish shares more Latin with English than either of the other Latin
// pairs, so this list is the longest of the four and should stay that way.
expect(Object.keys(es.falseFriends).length).toBeGreaterThan(
Object.keys(fr.falseFriends).length,
)
})
it('is keyed by the lowercase English word, so a lookup can find it', () => {
for (const p of PACKS) {
for (const key of Object.keys(p.falseFriends)) {