Skip to content

Read the vocabulary sets through Lexicon._sets (#754, 4i-a) - #989

Merged
derek73 merged 2 commits into
masterfrom
feat/v3-4i-a-lexicon-sets
Nov 2, 2026
Merged

derek73 merged 2 commits into
masterfrom
feat/v3-4i-a-lexicon-sets

Conversation

@derek73

@derek73 derek73 commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Sub-phase 4i-a of #754's Lexicon surface, as decided in #988. This is internal plumbing only: the parse reads the vocabulary sets through one internal object instead of Lexicon's public fields, and the three tools that read a lexicon's sets move to the same object. Nothing public changes and no parse moves.

What changed

  • _lexicon._LexiconSets is a frozen, slotted object with one frozenset per _VOCAB_FIELDS entry:
    • the 20 fields that 4i-b makes internal;
    • surnames and given_names, which stay public. They are included because they sit in the same roster loops (_vocab.in_any_wordlist, claimed_as_non_name, _assemble._listed_whole), so each loop reads one object.
  • The _sets slot. Each Lexicon builds a _LexiconSets in __post_init__, from its normalized fields. It lives in a slot _sets inherited from a plain base class (_HoldsSets):
    • A dataclass field, even one built init=False, would change dataclasses.fields(Lexicon), the pickled state and the 2.8 pickles' layout check.
    • The slot is not compared, hashed or pickled. A load, dataclasses.replace and | rebuild it.
  • The readers moved. Every library module but _lexicon (the pipeline stages, _render, _parser) now reads lexicon._sets.titles where it read lexicon.titles. So do tools/name_usage.py, tools/es_given_names.py and tools/differential/local_agreement.py. None of the tools moved to the index:
    • local_agreement reads a pack parser's lexicon, which the shipped index does not describe.
    • name_usage reads suffix_acronyms_ambiguous, a marker the compile derives and no index row states.
  • Reads moved, re-counted with the 2026-11-01 recipe:
    • The recipe's 111 library reads in 12 files break down as:
      • 8 belong to _lexicon itself and stay;
      • 2 are TailRead.titles reads, not reads of a lexicon;
      • 101 moved.
    • Also moved: 5 reads of surnames and given_names, and 5 roster getattrs.
    • In the tools, 16 of the 17 reads moved. The 17th is local_corpora's counter, which is not a lexicon.
  • Reads: lines. They still name a set as Lexicon.<field>. AGENTS.md now says the stages read it through _sets, and the release checklist's grep becomes \b(policy|lexicon|lex)(\._sets)?\.[a-z][a-z_]*. It keeps printing the field name and no longer prints a bare _sets. A note says that _classify's blanket Reads: line covers twenty fields: the 17 the grep lists in the module, and the rest read through _vocab.
  • Field order. _LexiconSets is built by keyword. tests/v2/test_lexicon.py holds its field names to _VOCAB_FIELDS, in order, and holds each attribute to be the field's own set.

The guard

tests/v2/test_layering.py walks every library module but _lexicon, plus every file under tools/ (40 files), and refuses a vocabulary field read off a lexicon. It runs one test per _VOCAB_FIELDS entry.

  • In the library:
    • A read passes only if it goes through <x>._sets, or through a name sets that every binding in the module assigns from a _sets.
    • sets bound any other way fails: an annotated assignment, a walrus, unpacking, a for target, a parameter or a with target.
    • TailRead's read.titles is named as not a lexicon.
    • A getattr fails if its object is a lexicon or an alias of one, or if it names a field as a constant off anything but a _sets.
  • In the tools, whose counters reuse the field names, a read fails if it is taken off lexicon, lex, <x>.lexicon, Lexicon.default(), or a name some binding gives one of those (an assignment, a walrus, a Lexicon annotation). This includes the AGENTS.md recipe's L = Parser().lexicon.
  • Not followed, and stated in the check's docstring:
    • a lexicon handed through a container, a call's return, or an attribute other than .lexicon;
    • aliases per scope rather than per module.
  • Negative controls:
    • A synthetic read in each of 9 spellings (5 library, 4 tool) must be refused, for every field.
    • Each binding form of sets and of an alias has its own row, in the library and in the tools.
    • _UNGUARDED_READS records what the check refuses on today's sources with ._sets stripped out:
      • 114 library reads: 106 attribute loads, 5 roster getattrs (3 of them through the sets that stripping makes a lexicon) and 3 sets = bindings;
      • 16 tool reads.
  • Mutation check: on the built tree, changing one lex._sets.titles_ambiguous in _classify back to lex.titles_ambiguous failed exactly [titles_ambiguous].

Frames

Measured on Python 3.11.17 with the benchmark's counter (50 names, four-digit counter), b55d4d33 against this branch:

row before after
reference Dr. Juan{i:04d} de la Vega III (tools/perf/call_count.py --against b55d4d33) 311.82 311.82
family comma Smith{i:04d}, John 168.0 168.0
de la Vega{i:04d}, Juan Q., Jr. 334.16 334.16
John Smith{i:04d} 158.0 158.0

There is no new frame per token and no new frame per parse. Wall clock over 6,000 names stayed within the run-to-run spread (96 to 98 µs per name on both trees). _CALL_BASELINE is unchanged.

How 4i-b plugs in

4i-b changes how _sets is filled, and nothing that reads it:

  • Filling. _sets is built from the lexicon's readings, compiled by compile_readings, instead of from its public fields.
  • The default stays lazy. The default's _sets comes from nameparser/config/_compiled.py, as _default_lexicon reads it today, so parse("John Smith") still loads no index.
  • Derived markers. The markers the views will read (a listed suffix word's kind, the patronymic particles) join _LexiconSets as attributes. _vocab.suffix_kind and classify's particle kind then read them there instead of the index.
  • The slot. Since dataclasses.fields(Lexicon) changes in 4i-b anyway, _sets may become a private field then, and the base class can go.
  • _parser._apply_choice reads _sets but still writes the public fields through dataclasses.replace. 4i-b has to give it the readings to write until 4i-c retires it.
  • What 4i-b also breaks, outside what the guard holds:
    • the tests' public-field reads;
    • AGENTS.md's recipe for the titles in no suffix set, L.titles - L.suffix_acronyms - L.suffix_words on L = Parser().lexicon. 4i-b rewrites it over readings_of or _sets.

The same plan is recorded in the new decisions/vocabulary-model entry (2026-11-02 #754, sub-phase 4i-a), and the RFC's 4i-a row is marked landed.

A design-docs review re-measured every number in the decisions entry and reproduced them. It raised three low findings, all fixed before the first push: a roster wording, the AGENTS.md scope sentence, and a missing tool-side lex control row.

An independent review of the PR found no blocking defect. Its five small items are fixed in a second commit:

  • the keyword build and the field-order test;
  • the widened guard;
  • the tuned grep;
  • AGENTS.md's "Adding a curated sub-set" paragraph, which said _VOCAB_FIELDS is read by two rosters. There are three, _assemble._listed_whole among them, and _LISTED_FIELDS leaves four fields out;
  • the test count below.

Verification

  • uv run pytest: 34,457 passed, 29 skipped, without namedivider. The run includes tests/v2/test_snapshot.py, unchanged, and test_cldr_agreement.py, no move.
  • uv run pytest -n0 -m serial: 42 passed.
  • uv run mypy, uv run ruff check and tools/vocab/build.py --check are clean.
  • The suite also passes on Python 3.15, and without namedivider-python installed.
  • No public change: dataclasses.fields(Lexicon) is unchanged (24 fields), reprs are untouched, and tests/v2/data/lexicon_v28.pickle still loads.
  • Layering is unchanged; ALLOWED was not extended.

🤖 Generated with Claude Code

The pipeline, _render, _parser and the three tools that read a
lexicon's sets now read them through one internal object,
_lexicon._LexiconSets, held in a slot inherited from a plain base so
that dataclasses.fields(Lexicon), equality, reprs and pickles stay
as they were. A membership test stays a C-level attribute read: the
frame counts are identical on the reference row, a family comma and
two more rows. tests/v2/test_layering.py refuses a vocabulary field
read off a lexicon, per field, with synthetic and recorded negative
controls. Groundwork for 4i-b, which fills the object from readings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derek73 derek73 added tests design vocabulary Per-word vocabulary model and language packs labels Nov 2, 2026
@derek73 derek73 self-assigned this Nov 2, 2026
Build _LexiconSets by keyword and hold its field names to
_VOCAB_FIELDS. The guard now follows aliases (getattr through one, a
tool's `L = Parser().lexicon`), exempts `sets` only where every binding
assigns it from a `_sets`, and scans every file under tools/; its
recorded control moves to 114 library reads. The checklist grep stops
printing a bare `_sets`, and the curated sub-set paragraph names the
three rosters _VOCAB_FIELDS feeds.

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

Labels

design tests vocabulary Per-word vocabulary model and language packs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant