Skip to content
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely o

### Vocabulary (`nameparser/vocab/`) and the configuration layer (`nameparser/config/`)

**The default vocabulary is written per word, in TOML, and compiled.** `nameparser/vocab/<tag>.toml` holds one file per BCP 47 language tag, each `[[word]]` entry a `text` and its readings keyed by kind. What a file means, how an entry is built, the kinds and properties with the set each compiles into, exclusions, bearer counts and the steps for adding a word are in `nameparser/vocab/README.md`, the reference for anyone editing the TOML; `tests/v2/test_vocab.py` holds its kind, property, derived-marker and reserved-name tables to the names `build.py` accepts and the sets it compiles, so read it rather than restating it here. The parser never reads the TOML: `tools/vocab/build.py` runs the build checks that replaced the modules' import-time asserts (its docstring numbers them), turns every reading into an index row with its file's tag, and writes the rows as the committed reading index `nameparser/config/_index.py` (#753, phase 3a) and, compiled, as the committed `nameparser/config/_compiled.py`, frozenset literals one entry per line; the build never reads `_index.py` back. The compiler is `nameparser/_reading_index.py`'s `compile_readings`, with the kind and property tables it reads (`CATEGORY`, `BEHAVIORAL`, `GATED_WORDS`), so the build and phase 3's choice of languages compile alike; the build keeps the checks and loads that module by path, as it loads `_lexicon.py`, and `tests/v2/test_reading_index.py` holds that the index compiled with no choice is `_compiled.py` exactly. `readings_of(text)` there is the run-time reader of the index, every reading of a word with its file's tag, loaded on first call. Each `config/` module re-exports its constants from `_compiled.py` (`TITLES = _compiled.TITLES`) under its autodoc docstring, so the public names and the docs did not move. `tests/v2/test_vocab.py` fails when `_compiled.py` is stale, and `tests/v2/test_reading_index.py` when the index is, each naming the refresh command, and `test_vocab.py` gives every build check a mutation. The model, the phasing and what was declined: decisions/vocabulary-model.
**The default vocabulary is written per word, in TOML, and compiled.** `nameparser/vocab/<tag>.toml` holds one file per BCP 47 language tag, each `[[word]]` entry a `text` and its readings keyed by kind. What a file means, how an entry is built, the kinds and properties with the set each compiles into, exclusions, bearer counts and the steps for adding a word are in `nameparser/vocab/README.md`, the reference for anyone editing the TOML; `tests/v2/test_vocab.py` holds its kind, property, derived-marker and reserved-name tables to the names `build.py` accepts and the sets it compiles, so read it rather than restating it here. The parser never reads the TOML: `tools/vocab/build.py` runs the build checks that replaced the modules' import-time asserts (its docstring numbers them), turns every reading into an index row with its file's tag, and writes the rows as the committed reading index `nameparser/config/_index.py` (#753, phase 3a) and, compiled, as the committed `nameparser/config/_compiled.py`, frozenset literals one entry per line; the build never reads `_index.py` back. The compiler is `nameparser/_reading_index.py`'s `compile_readings`, with the kind and property tables it reads (`CATEGORY`, `BEHAVIORAL`, `GATED_WORDS`), so the build and phase 3's choice of languages compile alike; the build keeps the checks and loads that module by path, as it loads `_lexicon.py`, and `tests/v2/test_reading_index.py` holds that the index compiled with no choice is `_compiled.py` exactly. `readings_of(text)` there is the run-time reader of the index, every reading of a word with its file's tag, loaded on first call; `languages_of(text, categories)` is the reader a parse calls, from assemble, for `Reading.languages` (3.0), so the first parse that reads a word from its `Lexicon`'s vocabulary loads the index. Each `config/` module re-exports its constants from `_compiled.py` (`TITLES = _compiled.TITLES`) under its autodoc docstring, so the public names and the docs did not move. `tests/v2/test_vocab.py` fails when `_compiled.py` is stale, and `tests/v2/test_reading_index.py` when the index is, each naming the refresh command, and `test_vocab.py` gives every build check a mutation. The model, the phasing and what was declined: decisions/vocabulary-model.

The `config/` SETS are frozen since 2.2 (#293): there is no `.add()`/`.remove()` on any of them, so a default word list is changed at runtime by configuring a `Lexicon`, never by editing the constant, and the shipped default is changed by editing the TOML and rebuilding. **Neither dict was frozen, and `CAPITALIZATION_EXCEPTIONS` is not covered by anything else either.** `REGEXES` is a compiled-pattern table rather than vocabulary and was never in #293's scope; `CAPITALIZATION_EXCEPTIONS` is vocabulary-shaped and is a decided, in-scope exemption. So the hazard the freeze closes is still live for it: `CAPITALIZATION_EXCEPTIONS['dclinpsy'] = 'DClinPsy'` after the first parse misses the cached `Lexicon.default()`. Same advice — configure the object (`dataclasses.replace(lexicon, capitalization_exceptions=...)`). `tests/v2/test_contracts.py::test_every_vocabulary_constant_is_frozen` names both dicts as explicit exemptions rather than letting its `isinstance` filter drop them.

Expand All @@ -227,7 +227,7 @@ The `config/` SETS are frozen since 2.2 (#293): there is no `.add()`/`.remove()`
The 2.0 rewrite landed as underscore-private modules beside the v1 code, which 2.0 and then 3.0 deleted. These conventions apply to all of it. The full design record (rationale, settled-decision logs, dated amendments) lives in untracked `docs/superpowers/specs/`; this section is the enforceable subset. **A commit that establishes or amends one of these conventions must update this section in the same commit** — grep-driven staleness sweeps miss paraphrased prose (see Workflow above), so write-time maintenance is the mechanism, audits are the backstop.

- **Module layout**: every module is underscore-private (`_types.py`, `_lexicon.py`, `_policy.py`, `_locale.py`, `_render.py`, `_reading_index.py`, `_pipeline/`, `_parser.py`). The public import surface is exactly `nameparser`, `nameparser.locales` and the vocabulary data modules under `nameparser.config`; `nameparser/__init__.py` holds re-exports and `__all__` only — no logic.
- **Layering is enforced by `tests/v2/test_layering.py`** (exact-module matching; `if TYPE_CHECKING:` imports don't count): `_types` imports nothing internal at module level — its rendering delegates import `_render` at call time; `_lexicon` and `_policy` sit above `_types` independently (`_lexicon` may import `nameparser.config.*` DATA modules only, e.g. `config/maiden_markers.py`); `_reading_index` imports nothing internal at module level and the index, `config/_index.py`, at call time, since `tools/vocab/build.py` loads it by path; `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types`, `_lexicon` (for `Lexicon.default()` and `_normalize`) and `_policy` (for the scripts that write no initials); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers; `_parser` sits on everything except `_render`; `__main__` imports only the public package; locale pack modules (`locales/*.py`) import `_locale`/`_lexicon`/`_policy`/`_types` only (`_types` joined the list in #272 — `locales/ja.py`'s segmenter factory constructs a `Segmentation`), and the `locales/__init__` additionally lazy-imports its packs (PEP 562). Extend the test's `ALLOWED` table when adding a module.
- **Layering is enforced by `tests/v2/test_layering.py`** (exact-module matching; `if TYPE_CHECKING:` imports don't count): `_types` imports nothing internal at module level — its rendering delegates import `_render` at call time; `_lexicon` and `_policy` sit above `_types` independently (`_lexicon` may import `nameparser.config.*` DATA modules only, e.g. `config/maiden_markers.py`); `_reading_index` imports nothing internal at module level and the index, `config/_index.py`, at call time, since `tools/vocab/build.py` loads it by path; `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types`, `_lexicon` (for `Lexicon.default()` and `_normalize`) and `_policy` (for the scripts that write no initials); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers, and `_assemble` also `_reading_index`, for `Reading.languages`; `_parser` sits on everything except `_render`; `__main__` imports only the public package; locale pack modules (`locales/*.py`) import `_locale`/`_lexicon`/`_policy`/`_types` only (`_types` joined the list in #272 — `locales/ja.py`'s segmenter factory constructs a `Segmentation`), and the `locales/__init__` additionally lazy-imports its packs (PEP 562). Extend the test's `ALLOWED` table when adding a module.
- **Canonical field order** — the seven roles in `Role` enum declaration order, defined once and derived everywhere (properties, `as_dict`, reprs, `comparison_key`). Never restate the order literally. `Role` is a `StrEnum`: members compare as their field-name strings, and `tokens_for()` coerces strings via `_coerce_enum`.
- **Method organization**, fixed section order in every class: fields + `__post_init__` validation → alternative constructors → dunders (construction/equality → protocol → operators) → properties → public methods by concern (access → editing → comparison → rendering delegates) → private helpers last, except a helper serving exactly one section may sit at that section's head.
- **Validation is eager and fail-loud**: every `raise` states the offending value, the expected form, and the fix. Exception taxonomy: wrong type — including wrong element type inside a collection, bare `str` where an iterable of strings is expected, or a `Mapping` where a plain iterable is expected — raises `TypeError`; well-typed but unacceptable values raise `ValueError`; failed enum lookups stay `ValueError` for any input (stdlib `EnumType` precedent). **When the message hands the reader code to paste, that code has to survive a type checker, and then RUN** — nameparser ships `py.typed`, and a placeholder can type-check and still raise: the 2.4 release sweep's first fix to the `capitalization_exceptions` hint offered `('key', 'Value')`, which passes mypy and fails #459's mask check, so `test_add_capitalization_exceptions_raises_pointing_at_replace` now executes the offered code. #337's segmenterless warning offered `Policy(segment_scripts=())`, an `arg-type` error, because these fields are annotated with what they STORE rather than everything the constructor accepts. Prefer the `frozenset()` / `()` spellings in messages and docstrings, and pin the offered spelling in a test — the warning tests matched on `ja_segmenter` and never checked the actionable half of the message. **A warning emitted in `Parser.__post_init__` needs `parser_for` to re-emit it from its own frame** (the `catch_warnings(record=True)` block at its return): `__post_init__`'s `stacklevel` is sized for direct `Parser(...)` construction, and through `parser_for`'s extra frame the default one-line rendering attributes the warning to the library's own `return Parser(...)` — the exact call the message tells the user to change becomes invisible. No single stacklevel serves both entry points; a new construction warning gets the re-emission for free, but a new CONSTRUCTION SITE for `Parser` inside this package needs its own re-emission or its callers get library-attributed warnings (#337 review).
Expand Down
2 changes: 1 addition & 1 deletion docs/design/decisions/A1-ambiguity-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Open: none in this file.

- 2026-10-21 #793 — AMENDS #626's list of reports worded where the field is settled, for the comma's credential reading. The listed-member report after a family comma said "read as a credential" of a word the listing form binds as a title (`John, Si.`, `John, Ma.`), so that field was not settled by the wording alone. It now names the role the comma stage binds, "a title" or "a credential", worded at the emitter rather than through `field_tail`, since nothing later moves a word of the part the binding empties. The measurement is decisions/suffix-acronym-collisions' 2026-10-21 (#793) entry.

- 2026-10-26 #752 (phase 2m) — A REPORT CARRIES THE READING IT DID NOT TAKE. decisions/vocabulary-model's 2026-10-23 #752 entry planned it: an `Ambiguity` carries the reading the parse did not take, and a word the credential's run takes by position alone is told from a word with a declared name reading by that reading's source, so it needs no new ambiguity kind. Derek decided on 2026-10-26 to ship it in 2.7 and to hold the reading taken, `Token.reading`, for 3.0.
- 2026-10-26 #752 (phase 2m) (its DECLINED `languages` superseded by 2026-10-29 #754, 4a's follow-up, decisions/vocabulary-model) — A REPORT CARRIES THE READING IT DID NOT TAKE. decisions/vocabulary-model's 2026-10-23 #752 entry planned it: an `Ambiguity` carries the reading the parse did not take, and a word the credential's run takes by position alone is told from a word with a declared name reading by that reading's source, so it needs no new ambiguity kind. Derek decided on 2026-10-26 to ship it in 2.7 and to hold the reading taken, `Token.reading`, for 3.0.
DECIDED: `Ambiguity.alternative: Reading | None`, declared `compare=False`, so two reports and two names compare as before and `ParsedName.comparison_key` is untouched. `Reading` is a frozen, slotted, picklable value of three fields, exported with its two enums: `kind` (`ReadingKind`: credential, generation, agnomen, post-nominal, title, particle, conjunction, initial, name), `role`, and `source` (`ReadingSource`). Its FIELD is the one the word would land in had the call gone the other way with every other word of the name read as the parse read it, which is what a caller moving one word needs. A non-name reading names its field by what it is: the suffix for a credential, a generation, an agnomen or another post-nominal, the title for a title, the family for a particle (rules/P2). A name reading takes the field the positional read gives one more name word in its place: the last name word's under the order that read the part (`JOHN SMITH MA` gives `family`, under FAMILY_FIRST `middle`), the given part's given or middle name after a family comma, the family before one, the maiden name in a marker's clause, and the given name of the listing form where the comma's decision bound the part as a credential run (`John Smith Jr., AD` gives `given`). Where the name piece in front would take the word in, it lands where that piece lands, named at assemble from the piece's final role: a particle chain, which runs to the end of the part's name words (`Juan de la Vega VI` under FAMILY_FIRST gives `given`, the chain's field), a lone particle not opening the part, which stood unchained only for want of a word behind it, and a bound given name opening the part where a family comma or a given-name title licenses its join (`Rahman, Abdul MB ChB` gives `given`, `Sir abdul VI` too); a particle P6 attached is not trailing once the word stands behind it, so it takes nothing in, and a bound join is no chain though `abu` is a particle too (`Smith, Abu Mary VI` gives `middle`). A never-given particle opening the part takes the word into the family by rules/P1's fold, in every order and part (`Dr. mc VI`, `Smith, Dr. de VI` give `family`). O5's lone word names the other primary field from its final role, after any move, and `middle_as_family` folds a middle into the family as it folds the parse's.
DECIDED: four sources, where the RFC's "Readings on the parse" named three. `vocabulary` is a listed word in the reading's category, `declared` a word the vocabulary also declares a name (the derived markers of decisions/vocabulary-model's 2026-10-25 #752 entry, and a given name written short), `shape` a roman numeral, dotted letters or a one-letter initial, and `position` an ordinary word nothing lists, a name only by where it stands: `John Smith PhD Jones` gives `position` for `Jones` where `JOHN SMITH MA` gives `declared` for `MA`, which is the case the plan needed the source for. A shape-admitted member of the ambiguous class carries the class's tag beside the shape tag, so the shape tag decides that it declares no name.
DECIDED: every emitter of every fork kind fills it at its decision site (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE). Two stages run before the field exists, classify's connective-or-initial letter and group's particle chain, and defer it (`_state.Deferred.JOINED` and `UNCHAINED`) for assign to name off the positions it reads: the connective's join takes in the name word on each side, so where those stand in two pieces the joined unit takes the place of the first (`JOSE E MARIA SANTOS` gives `given`), and where both are already inside the letter's own piece the join changes nothing the piece did not hold and the letter lands where its piece lands, named at assemble (`MARIA DA SILVA E SOUZA`, a case row, gives `family`); a join that would leave the name one word is none, rules/P3 keeping a single-letter connective in a three-word name a name word, so there too the letter keeps its own field (`JOHN E SMITH` gives `middle`), where a family comma's surname is the word that lets the given part join (`SMITH, JOHN E MARY` gives `given`); the unchained particle stands as one more unit in front of its piece. One assign cannot place stays unnamed and surfaces as no alternative, in two shapes on 2026-10-26: a connective letter opening its part, which has no word before it to join, so no field could hold the reading (`E SMITH JONES`, a case row since; `e j smith` and `e and e` in the committed corpora), and in the case sweep the chain's report in `Freiherr von Berg Dr. and Ed. Prof.`, under each order, whose chained piece ends in a title a connective's join made. The first was found by the design-docs review; rules/A1 names it. The three kinds that report a shape (comma-structure, unbalanced-delimiter, segmentation) carry none; `order` is reserved and emitted nowhere.
Expand Down
Loading
Loading