Skip to content
Merged
6 changes: 3 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/design/decisions/vocabulary-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,9 @@ Open: #913 (the title words phase 2j left, read as titles though borne as names)
- **The index loads on the first `readings_of` call, not inside `Lexicon.default()` (decided 2026-10-29, Derek).** SUPERSEDES: item 5's "loaded for every caller with `Lexicon.default()`". Proposed by 3a and chosen over loading it with the default lexicon. What it buys depends on the state of `Reading.languages`. Today, and until the follow-up that fills it after 3a and 4a (#942) both merge, no parse reads the index, so no caller pays the import, parsing or not, where loading it with the default lexicon would cost every parsing caller the import for nothing. Once the follow-up fills `Reading.languages`, the first parse will load it, so every parsing caller will pay it once, as item 5 measured, and only a caller who imports nameparser without parsing is spared. Wherever the follow-up calls `readings_of`, the import moves with the call.
- **Measured** 2026-10-29 on `9210f221` plus this change, Python 3.11.17, medians of fresh processes with bytecode cached as an installed wheel has it: importing the index takes 0.56 to 0.68 ms across three runs on a shared machine, about 3% of `import nameparser`'s 18 to 24 ms in the same runs, above the 0.45 ms item 5 measured on a flat draft of 2,140 rows; the first lookup after it, 0.07 ms; compiling the whole index, 2.1 ms; uncached, the import takes about 13 to 18 ms (13 after `import nameparser` in this session, 17 to 19 in review's runs, a figure no wheel install pays). The index holds 2,157 readings over 1,798 words, 106 of them in more than one file, and 3 readings written `default = false`; it is about 95 KB of source. Recompute: `uv run python tools/vocab/build.py`, then with `PYTHONDONTWRITEBYTECODE` unset, time `import nameparser.config._index` after `import nameparser` in fresh processes (the first run writes the bytecode), and count with `from nameparser.config._index import READINGS`.
- Not touched: `Lexicon` and every other pickled type gain no field, so no pickle layout moves, and 3a forces no public name; the configure-by-reading methods stay open.
- **2026-10-29 #754, sub-phase 4a — `Token.reading` IS READ AT ASSEMBLE, FROM EACH TOKEN'S FINAL ROLE AND THE TAGS THE DECIDING STAGES LEFT, ITS SUFFIX AND NAME SOURCES BY THE PREDICATES THAT NAME `Ambiguity.alternative`.** It carries out item 2 of the 2026-10-28 #754 entry above, all but `Reading.languages`, which a follow-up adds and fills from 3a's reading index (`_reading_index.readings_of`, the 2026-10-29 #753 entry above), adding the field with its values because an empty one would say that no file holds the reading.
1. **Where it is filled.** A word's reading is decided in two halves today: its tags say which readings of the word the vocabulary or a shape offers and how its part stands (classify's vocabulary and shape tags, the `joined` mark group and post_rules write, post_rules' unjoined marks, which make `van` in `van Gogh` a name word where `Vincent van Gogh` keeps it a particle), and the stage that gives the word its role says which one was taken. Both halves are final only at assemble, since post_rules still moves words after assign (P1's fold, P6's attachment, the patronymic rotations, `middle_as_family`'s fold), so a reading written at an earlier site would need re-writing at every later move, the stale-mark class `_types._remarked` exists to recompute. Assemble reads the suffix and name readings through `_vocab.suffix_kind` and `_vocab.name_source`, the predicates `suffix_reading` and `name_reading` use for the reading NOT taken, so the two are named by one predicate there. The bound given name's `vocabulary` and the title, connective, particle and initial readings are assemble's own tests, kept out of the shared predicates so that no alternative moves (the first draft put the bound given name in `name_source` and moved `Smith, John Abu`'s from `declared`, which review caught; `_EXPECTED_ALTERNATIVES` holds it now), the initial by classify's `initial` tag where `name_reading` names one by its shape (a one-letter `é` in `John Smith é` reads `name`, the tag never written on it). Neutral, measured 2026-10-29: the committed snapshot is byte-identical, every recorded alternative included.
2. **What each role reads as.** A title: `title` from the vocabulary where a title tag stands, by its shape where rules/H2's period-abbreviation shape claimed it (`Insp.`, `Esq. Smith`), and by position where a title phrase took a word nothing lists (`Parliament` in `Member of Parliament`); its connective `conjunction`. A suffix: `suffix_kind`'s kind and source, save a word no suffix tag marks and the vocabulary does not list, which stands there by position: a word past a second comma (`Foo` in `John Smith, Jr., Foo`), or a continuation of the entry before it (`Jones` in `John Smith PhD Jones` and in `John Smith III Jones` reads `credential`, `position`), save a credential split across a space, whose second word takes its first word's reading whole (`Ph.` `D.`); a connective reads `conjunction` from the vocabulary, as in a title or a name part, whether it joins two entries (`and` in `John Smith, PhD and MD`) or trails them with nothing to join (`John Smith, Jr. and`), as `John Smith and` does on the name side. A name-role word: `initial` by its shape where classify tagged it one, `conjunction` or `particle` from the vocabulary where it joins, and otherwise `name`, `declared` where a derived marker says so, `vocabulary` for a word the vocabulary lists as a bound given name, whether rules/P5 joined it (`Abdul` in `Abdul Aziz Tariq`) or not (`Abdul` as the middle name in `John Abdul Smith`), save one that is also a particle (`abu`), which is P5's given name only where P5 joins it, in the given part and opening its segment past the titles (`Abu Bakar Smith`, `Smith, Abu Bakar`, `Dr. Abu Bakar Smith`), and otherwise a `particle` where it joins, the chain having taken it (`John Abu Smith`, `Smith, John Abu`, and the given part of `Salim Ali abu Bakar Hassan` under FAMILY_FIRST_GIVEN_LAST, where P5 joins nothing past the family it opens with), no tag telling the two joins apart, and for a family word the segmentation split off an unspaced name by `Lexicon.surnames` (`김` in `김민준`, where a spaced `김 민준` reads `position`, the order placing it), and `position` for the rest; a particle or connective with nothing to join reads as the name word it stands as (`van` in `van Gogh`, `name`, `declared`), and so does a particle in a nickname, which has no family to join (`"Van" Smith`).
3. **Not filled**, `None`: a token `ParsedName.replace()` builds, a token `Parser.revise()` harvests, whose role the caller forced, and a token loaded from a pickle written before 3.0 (`ADDED_IN = "3.0.0"`; `tests/v2/data/parsedname_v28.pickle`, made by the released 2.8.0 wheel, loads and equals a fresh parse). `capitalized()` carries each reading through.
4. **Cost**, measured 2026-10-29 with `tools/perf/call_count.py`: the reference row moved from 305 to 306 frames on Python 3.11 and from 286 to 287 on 3.15, one frame for its suffix `III`. Every `Reading` a token can take is built once at import (280 of them, ten kinds by seven roles by four sources) and shared, where a construction per token would cost two frames a word; an untagged word, the commonest, is read in C-level tests with no frame. `_CALL_BASELINE` (307 and 288) is not moved.
5. **Left open.** A listed suffix word that the compiled sets do not split by kind reads `post-nominal`, so `Jr`, `Sr` and `Esq.` read alike: the TOML gives `jr` a `generation` reading, but `SUFFIX_WORDS` holds both kinds and classify tags them alike, so only a vocabulary lookup by kind can tell them apart: 3a's reading index, a new `Lexicon` field being set aside (the 2026-10-28 `Lexicon` surface entry makes the compiled sets internal, and `Lexicon`'s own pickle guard refuses an added field). It is the predicate's own answer, which `Ambiguity.alternative` has given since 2.7 (the snapshot's one post-nominal alternative is `Rinpoche`'s), and a kind left unset would have meant a parsed token with no reading or a kind for "not yet known", so the reading taken agrees with it until 4b, whose `generation` view needs the split, reads a listed word's kind from 3a's index; `tests/v2/pipeline/test_assemble.py::test_a_suffix_word_reads_as_its_vocabulary_kind` holds `Jr` and `Sr` as strict expected failures until then. `ReadingKind.PATRONYMIC` exists and no reading takes it until 4c, whose rules write it. This amends the 2026-10-28 #754 entry's item 9: 4b needs 3a for that kind.
Loading
Loading