Skip to content

Add Locale.languages: a choice of languages selects the pool (#753, 3b) - #945

Merged
derek73 merged 5 commits into
masterfrom
feat/3b-language-choice
Oct 30, 2026
Merged

derek73 merged 5 commits into
masterfrom
feat/3b-language-choice

Conversation

@derek73

@derek73 derek73 commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Phase 3b of #753: a choice of languages selects which of the default vocabulary's readings compile. No pack ships here (3d is the first). A caller who chooses no language parses exactly as before.

The API as shipped

  • Locale.languages, a frozenset[str] of BCP 47 tags, empty by default (decided in Phase 3: language packs that prefer or restrict their language #753, item 1). Each tag is stored in BCP 47's canonical case (PT → pt, pt-br → pt-BR), so equal choices compare and hash equal. It is marked ADDED_IN = "3.0.0", so a Locale pickled before this field loads choosing nothing. It is validated by shape: a bare string, bytes or a mapping raise TypeError, and a malformed tag raises ValueError. It is compared, hashed and shown in the repr: by its tags up to three (Locale('pt': languages pt)), by its size past that (Locale('xx': languages: 4)). Reprs are bounded (AGENTS.md); a pack chooses one or two languages, so the tags are what a reader needs, and a long choice is not a pack's.
  • parser_for unions the packs' languages into one choice and applies it to the base lexicon before any fragment is unioned, so the order of the packs doesn't matter. It's applied as a delta against the default's compile. It adds what the choice turns on and removes what the choice drops from the base, a caller's own listing of a dropped word included: parser_for(EN, base=Parser(lexicon=Lexicon.empty().add(particles={'do','van'}))) keeps only van. Entries the choice doesn't touch stay. How a choice combines with a base is decided (below). A tag that no file holds (or mul/und) raises ValueError, wrapped with the pack's code (rules/D2, construction raises, parse never does).
  • When a choice drops a word from a base set, the word also leaves that set's marker subsets, including a caller's own mark. Review found that Lexicon.default().add(particles_ambiguous={'vd'}) under en raised. An error the Lexicon still raises while applying the choice is wrapped and names the languages chosen (rules/D2, construction raises, parse never does; recorded in decisions/D2).
  • No other public name. Lexicon and Parser gain no field. The selection lives in the private _reading_index (selected, choose, compile_choice, changed_words).
pt = Locale(code="pt", lexicon=Lexicon.empty(), languages=frozenset({"pt"}))
parser_for(pt).parse("ANA SILVA E SOUZA").family   # 'SILVA E SOUZA' (default: 'SOUZA')

Semantics (_reading_index.choose)

  • Turning on: a chosen file's default = false readings join the pool.
  • Prefer: on a word where a chosen file holds a vocabulary reading, other files' vocabulary readings leave, except those in mul.toml (every language's) and und.toml (no known language's). I chose to exempt und as well as mul. It holds only name readings today, so this moves nothing. Name readings never leave and never settle anything (decision 15).
  • The shape clash: a chosen file's conjunction drops may_be_initial (decision 20). Under pt, a one-case e reads exactly as a lowercase e in mixed case does, in every comma shape and under all three name_orders.
  • Tags: a tag selects its own file and its ancestors' files, matched case-insensitively (pt-br selects pt-BR and pt). A region with no file selects its language's file (pt-PT selects pt). No macrolanguage table is read.

Measured (each tag chosen alone, against the default's readings)

Committed corpora, snapshot.py --languages TAG, entries moved (names held only by corpus_cldr.jsonl in brackets):

tag moved words changed
pt 38 (1) do, e, sr
pt-BR 38 (1) same as pt
en 54 (7) abd, do, ra, sr, vd
de 49 (0) i, ra
it 14 (0) e
cs 11 (2) st
es 3 (1) h, sr
ar 2 (1) abd
fr 0 none

Local corpora (--local SOURCE --base . --languages TAG):

corpus tag moved of
tse-donors pt 4,824 854,531
tse-donors en 14,559 854,531
ctgov-officials en 327 536,981
psc-individuals en 209 1,040,708 filed names
ukec-individuals en 0 15,214
nepal-candidates ne 0 126,981
  • tse-donors under pt: 4,751 of the moves are names holding a bare e (LIMA E SOUZA becomes the family).
  • tse-donors under en: all but one hold do, which loses its particle.
  • ctgov-officials under en: 223 are Abd names, whose Arabic bound given name leaves for the English credential.

Choosing en alone is the costliest choice, since English holds the credentials.

No-choice proof: test_snapshot.py passes unchanged. measure.py --base <master bd02d68f> reports no move in any committed or local check. A subprocess test shows that building a parser without a choice never loads the reading index. Since #944, a parse loads the index anyway, for Reading.languages, so the test now holds only what 3b owns. After merging master (#944), the snapshot test still passes.

The derived deviation surface is changed_words. test_non_interference_each_choice (pt, en, cs, de, pt+en) requires the default's reading for every corpus name that holds none of those words. It has a negative control.

Decided

A choice is one call's. This was decided 2026-10-29 and is recorded in decisions/vocabulary-model's chained-choices entry. A Parser records no choice, so a choice chained through base= applies on top of the base's choice; it is not combined with it:

  • parser_for(EN, base=parser_for(PT)) reads Maria do Carmo as family Carmo and John Smith DO as family DO.
  • parser_for(PT, EN), one call, is how both languages are chosen, and it keeps both readings (do Carmo, suffix DO).
  • A choice also drops the caller's own base entries that it removes: parser_for(EN, base=Parser(lexicon=Lexicon(particles=frozenset({'do','van'}), ...))) keeps only van.

Two tests in tests/v2/test_parser.py pin both behaviors, so a later change has to be deliberate. docs/locales.rst ("Choosing in one call", with doctests), the parser_for docstring, mechanisms.md and the RFC all state it. Still open, and decided together with the configure-by-reading method names: a Parser.languages field recording the choice, so that chained choices combine. It would be pickle-safe, because Parser's pickle guard honors ADDED_IN.

Reading.languages does not follow a choice. It names the files that hold the reading on by default, whatever choice built the parser, as #944 defined the field. That decision supersedes #944's expectation that 3b would change languages_of's filter. Two readings show the cost:

  • Under pt, the one-case E in ANA SILVA E SOUZA reads conjunction and lists ('it', 'pt'), although prefer dropped it's reading.
  • Under cs, the st. in Jiří Dienstbier st. reads generation and lists (), although cs turned that reading on.

A parse can't know its choice, because a Parser records none (decision above). A field that followed the choice therefore waits on Parser.languages. test_reading_languages_name_the_default_files_under_a_choice pins both readings.

Open questions

  1. Turning on also prefers. de turns on its generation i, so Catalan's and Polish's conjunction i leave under de (32 committed Catalan names move). This follows the rule as agreed. Whether de's i should be a reading at all is the shipping pack's question.

  2. For the pack PRs, left as is:

    • es turns on suffix H (Perez, Juan H → suffix H, unreported).
    • de's i (Carod i Rovira, Josep → suffix i).
    • The marker cleanup in _apply_choice handles only words that leave a base set. A word a choice joins to a marker set while its base set is unchanged is not reconciled; no shipped choice does that.

Docs amended (same-PR rule)

  • RFC Phase 3 status
  • decisions/vocabulary-model (the 3b entry)
  • mechanisms.md#LOCALE-PACKS-PURE-DATA: the add-only contract becomes selection
  • decisions/3-0-reevaluations, P8 (single-letter connective against the initial it spells), D2, deviates-registry
  • AGENTS.md (layering, vocabulary)
  • docs/locales.rst "Choosing languages" (doctests)
  • release log

Refs #753.

🤖 Generated with Claude Code

A Locale carries a choice of BCP 47 tags. parser_for unions the packs'
choices and applies it to the base lexicon as a delta against the
default's compile: a chosen file's default = false readings join; on a
word a chosen file holds a vocabulary reading of, other files'
vocabulary readings leave, save mul.toml's; a chosen conjunction drops
may_be_initial. Name readings never leave. A tag selects its own file
and its ancestors', case-insensitively. A pack choosing no language
leaves the vocabulary as it is, and never loads the reading index.

The deviation surface of a choice is derived (changed_words) and the
non-interference gate reads it; snapshot.py takes --languages.
mechanisms.md#LOCALE-PACKS-PURE-DATA's add-only contract becomes
selection, with the decisions, RFC, AGENTS.md, locales.rst and release
log amended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derek73 derek73 added this to the 3.0 milestone Oct 29, 2026
@derek73 derek73 added enhancement vocabulary Per-word vocabulary model and language packs labels Oct 29, 2026
@derek73 derek73 self-assigned this Oct 29, 2026
derek73 and others added 4 commits October 29, 2026 14:45
- Locale stores each tag in BCP 47's canonical case, so equal choices
  compare and hash equal; the repr lists at most three tags.
- languages has its own mapping message, its offered fix pinned.
- Prefer leaves und.toml's readings as it leaves mul.toml's; und holds
  only name readings today, so no parse moves.
- snapshot.py refuses --since with --languages.
- The case-mask branch of the delta is tested with a stand-in compile.
- The docs say exactly what a choice does to a caller's base entries,
  the three ways a tag is checked, and what stays under a choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A choice chained through base= applies on top of the base's rather
than unioning with it, and a choice drops the caller's own base
entries it removes; one parser_for call choosing both languages is
how both readings are kept. Recorded as a decisions entry with the
Parser.languages option left open with the configure-by-reading
method names, stated in locales.rst (doctests), the parser_for
docstring, mechanisms.md and the RFC, and pinned by two tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oice

# Conflicts:
#	AGENTS.md
#	docs/design/decisions/vocabulary-model.md
#	nameparser/_reading_index.py
#	tests/v2/test_layering.py
#	tests/v2/test_types.py
After the merge with the Reading.languages follow-up: the field keeps
naming the files that hold a reading on by default, whatever choice
built the parser, since a Parser records no choice; a field that
followed the choice waits with Parser.languages. Recorded in the 3b
decisions entry (superseding the follow-up's expectation that 3b would
change languages_of's filter) and pinned by a test. The no-choice
index test now holds what 3b owns: building a parser without a choice
loads no index, a parse loading it since the follow-up. Tests cover
the canonical case of numeric and variant subtags and a choice that
changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derek73
derek73 merged commit b27dadd into master Oct 30, 2026
12 checks passed
@derek73
derek73 deleted the feat/3b-language-choice branch October 30, 2026 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement vocabulary Per-word vocabulary model and language packs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant