From 7a30d383aa6fbe61d8691d8e8b57dec2be141d40 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Mon, 26 Oct 2026 21:43:12 -0700 Subject: [PATCH 1/3] Keep the titles 2.6.0 listed in v1's Constants().titles (#752) Phase 2j moved titles that are also given names (king, mahdi, wing, ...) out of TITLES into the phrase-only TITLES_AMBIGUOUS, and v1's default titles, copied from TITLES, lost them: Constants().titles.remove('king') raised KeyError where 2.6.0 accepted it. Freeze 2.6.0's TITLES (config/_titles_2_6_0.py, generated from the wheel) and add every 2.6.0 title now in TITLES_AMBIGUOUS back to v1's default. The snapshot reads a kept one by the phrase, as Lexicon.default() does, and a removed one as no title, phrase readings included. A word 2.6.0 never listed (dean) still becomes a title everywhere when added. Tests derive the moved words from the compiled sets, so later batches are swept with no edit. decisions/H9, 2026-10-26. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docs/design/decisions/3-0-reevaluations.md | 1 + docs/design/decisions/H9-title-phrase.md | 4 +- docs/release_log.rst | 2 +- nameparser/_config_shim.py | 57 +- nameparser/config/_titles_2_6_0.py | 826 +++++++++++++++++++++ tests/v2/test_config_shim.py | 109 +++ 7 files changed, 981 insertions(+), 20 deletions(-) create mode 100644 nameparser/config/_titles_2_6_0.py diff --git a/AGENTS.md b/AGENTS.md index 0c6232f4d..cf3c5bffa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,7 +245,7 @@ The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These - **Parser owns config-dependent conveniences**: `Parser.matches`/`Parser.capitalized`/`Parser.revise` exist because the `ParsedName` equivalents fall back to DEFAULT config for str/omitted arguments (documented loudly in both docstrings). `revise` harvests tokens from a full sub-parse of each replacement value (tags kept minus `FOLDED_TAG`, roles forced, the R1 entry pass `suffix_entries` re-run over the forced state so a suffix value's entries follow its own commas, ambiguities discarded); the merge tail is shared with `replace()` via `ParsedName._with_field_tokens`. `Parser.capitalized` delegates through `name.capitalized(self.lexicon)` specifically so `_parser` never imports `_render` — keep it that way. - **Per-word vocabulary fields warn on multi-word entries** (`_normset`/`_normpairs` via `_warn_dead_entry`, UserWarning, never a raise — see the given_name_titles Gotcha for why raising is wrong). `given_name_titles` is the one multi-word-matched field and is exempt; `_edit` passes `warn=False` (add() warns once via the new instance's `__post_init__`; remove() stores nothing). The default vocabulary and every locale pack must stay warning-free (`test_default_lexicon_builds_warning_free`, `test_pack_vocabulary_entries_are_single_words`). - **Invariants guard harm, not no-ops**: add a constructor check when violating it produces a *wrong parse*, not when it produces *nothing*. A false positive costs a working configuration; a true positive on an inert condition costs the user nothing, so that trade is never worth taking. `suffix_acronyms_ambiguous ∩ suffix_words` is guarded because the overlap loses a family name; `given_name_titles` is not, because an unreachable entry is simply never consulted (see Gotchas). Before adding one, construct the config it forbids and check what actually breaks. **Between raise and silence sits the construction-time `UserWarning`**, for a gap that is statically decidable, harmless to SOME deliberate caller, and indistinguishable-from-working for everyone else: the segmenterless activation (#337 — `parser_for(locales.JA)` without a segmenter behaved exactly like a working parser minus the feature) warns rather than raises because the inert JA registration is itself a pinned property, and a warning is filterable by the caller who wants exactly that. The message must carry every applicable remedy and no inapplicable one (the `ja_segmenter` hint fires only when a Japanese script is among the dead ones). Test fuzzers that legitimately construct such configs suppress the warning by MESSAGE, never by category — a blanket `UserWarning` ignore would mask the next construction diagnostic (`_quiet_parser` in `tests/v2/test_properties.py` is the pattern). -- **The shim TRANSLATES; it never raises on a config v1 accepted (one decided exception, #459, at the end of this bullet), and never silently changes the parse**: `Constants._snapshot()` is a translation boundary between v1's model and v2's invariants, and every transformation there carries its v1-reachability argument in a comment. These exist today — `first_name_titles` re-folded per word (v1 joins-then-`lc`, v2 normalizes-then-joins), `suffix_acronyms_ambiguous ∩ acronyms` (a provable no-op), `suffix_words − ambiguous` (v1 already accepts the word via the acronym branch, so the addition is inert there), `particles_ambiguous ∪ (bound ∩ particles)` (a pinned deviation, `test_bound_never_given_prefix_deviates_on_two_pieces`), `honorific_tails = GLUED_HONORIFICS ∩ suffix_words` (#308 behavior with no v1 manager of its own, so the one v1 knob that reaches it is deleting the suffix word — which turns the peel off, `test_snapshot_removing_a_honorific_word_turns_the_peel_off`), `conjunctions_ambiguous = CONJUNCTIONS_AMBIGUOUS ∩ conjunctions` (#383/#479 behavior with no v1 manager of its own either, so the one v1 knob that reaches it is deleting the conjunction — which turns the marking off, `test_snapshot_removing_a_conjunction_turns_the_marker_off`), `after_given_titles = AFTER_GIVEN_TITLES ∩ titles` (#835, likewise: deleting the title turns H8 off for it, `test_snapshot_removing_a_title_turns_its_after_given_reading_off`), `suffix_words_ambiguous = SUFFIX_WORDS_AMBIGUOUS ∩ suffix_words` with `agnomens` inside it (#752, likewise: deleting the suffix word turns S11's gate off, `test_snapshot_removing_a_suffix_word_turns_its_gate_off`), `title_modifiers`, `title_styles` and `title_phrases` intersected with the titles (#752 phase 2i, rules/H9, likewise), `titles_ambiguous` passed whole, its words being in no v1 set, except where the caller emptied `titles`, v1's way of turning title detection off, which drops it too (`test_snapshot_cleared_titles_turn_the_title_phrase_off`), `given_name_abbreviations` passed whole (#789: a word a v1 caller adds to `titles` is still a title, the leading peel asking the titles before the shape), and `maiden_delimiters − nickname_delimiters` on the POLICY half of the same method (v1 precedence: a pair in both v1 buckets parses as a nickname, while `Policy` resolves the overlap the other way, so the subtraction is what keeps the facade at v1 behavior, `test_snapshot_overlap_keeps_v1_nickname_precedence`), and `_v1_matchable`, which runs ahead of every other one: an entry v1 could never match — empty after `lc()`, or holding edge whitespace, a whitespace run or any whitespace character other than a single space, since no v1 piece does — is dropped from all nine set fields and from the `capitalization_exceptions` keys and named in one `UserWarning` whose remedy runs (#541; translated, it started matching once `Lexicon` stripped it or tripped a `Lexicon` invariant the set algebra could not see, `test_an_entry_v1_could_not_match_is_dropped_with_one_warning`), except the two whitespace typos in 1.4.0's own shipped `TITLES`, which a restored 1.4 pickle carries and are dropped without the warning. The same filter drops an entry `Lexicon` folds to empty — since 2.3.0's `FULL_STOPS` fold, a lone CJK full stop, which 1.4.0 through 2.2.0 could match on a token made of that stop but which since 2.3.0 can be neither held nor matched, so translating it raised wherever `Lexicon` received it (#582, the one departure from 1.4.0 this filter makes) — and the two cross-set computations `Lexicon` re-checks after its own fold (`suffix_words − ambiguous` and `bound ∩ particles`) compare `_normalize`d spellings, while every other field passes raw as before (an edge full stop, `'ma。'` beside the ambiguous `ma`, had raised `Lexicon`'s gate-bypass check since 2.3.0; folding EVERY field instead was measured to move readings on configs that never raised, `test_an_edge_full_stop_entry_outside_the_two_checks_reads_as_before`). Note that the `maiden_delimiters − nickname_delimiters` one is on the `Policy`, not the `Lexicon` — the roster is per-`_snapshot()`, not per-vocabulary-field, so a sweep that only reads the `Lexicon(...)` call misses it. When a v1 config cannot satisfy a v2 invariant, work out what v1 actually *does* with it — usually nothing — and reproduce that; weakening the invariant or letting the raise through are both wrong. **Test the case the translation decides**, not one where both branches agree: a test using an input v1 parses identically with and without the config pins nothing. A `capitalization_exceptions` value that does not spell its key's letters and digits raises `ValueError` at snapshot rather than being translated (#459), because v1 substituted such a value for the word and 2.4 repair only recases, so there is no v1 behavior to reproduce; pinned by `test_a_mismatched_exception_value_raises_at_the_first_parse`. +- **The shim TRANSLATES; it never raises on a config v1 accepted (one decided exception, #459, at the end of this bullet), and never silently changes the parse**: `Constants._snapshot()` is a translation boundary between v1's model and v2's invariants, and every transformation there carries its v1-reachability argument in a comment. These exist today — `first_name_titles` re-folded per word (v1 joins-then-`lc`, v2 normalizes-then-joins), `suffix_acronyms_ambiguous ∩ acronyms` (a provable no-op), `suffix_words − ambiguous` (v1 already accepts the word via the acronym branch, so the addition is inert there), `particles_ambiguous ∪ (bound ∩ particles)` (a pinned deviation, `test_bound_never_given_prefix_deviates_on_two_pieces`), `honorific_tails = GLUED_HONORIFICS ∩ suffix_words` (#308 behavior with no v1 manager of its own, so the one v1 knob that reaches it is deleting the suffix word — which turns the peel off, `test_snapshot_removing_a_honorific_word_turns_the_peel_off`), `conjunctions_ambiguous = CONJUNCTIONS_AMBIGUOUS ∩ conjunctions` (#383/#479 behavior with no v1 manager of its own either, so the one v1 knob that reaches it is deleting the conjunction — which turns the marking off, `test_snapshot_removing_a_conjunction_turns_the_marker_off`), `after_given_titles = AFTER_GIVEN_TITLES ∩ titles` (#835, likewise: deleting the title turns H8 off for it, `test_snapshot_removing_a_title_turns_its_after_given_reading_off`), `suffix_words_ambiguous = SUFFIX_WORDS_AMBIGUOUS ∩ suffix_words` with `agnomens` inside it (#752, likewise: deleting the suffix word turns S11's gate off, `test_snapshot_removing_a_suffix_word_turns_its_gate_off`), `title_modifiers`, `title_styles` and `title_phrases` intersected with the titles (#752 phase 2i, rules/H9, likewise), `titles_ambiguous` passed whole except where the caller emptied `titles`, v1's way of turning title detection off, which drops it too (`test_snapshot_cleared_titles_turn_the_title_phrase_off`), and except the words 2.6.0 listed as titles (`moved`, from the frozen `config/_titles_2_6_0.py`): v1's default `titles` keeps holding them, so `c.titles.remove('king')` works as on 2.6.0, and the snapshot passes a kept one to `titles_ambiguous` and a removed one to neither (decisions/H9, 2026-10-26; `test_removing_a_moved_title_turns_every_title_reading_off`), `given_name_abbreviations` passed whole (#789: a word a v1 caller adds to `titles` is still a title, the leading peel asking the titles before the shape), and `maiden_delimiters − nickname_delimiters` on the POLICY half of the same method (v1 precedence: a pair in both v1 buckets parses as a nickname, while `Policy` resolves the overlap the other way, so the subtraction is what keeps the facade at v1 behavior, `test_snapshot_overlap_keeps_v1_nickname_precedence`), and `_v1_matchable`, which runs ahead of every other one: an entry v1 could never match — empty after `lc()`, or holding edge whitespace, a whitespace run or any whitespace character other than a single space, since no v1 piece does — is dropped from all nine set fields and from the `capitalization_exceptions` keys and named in one `UserWarning` whose remedy runs (#541; translated, it started matching once `Lexicon` stripped it or tripped a `Lexicon` invariant the set algebra could not see, `test_an_entry_v1_could_not_match_is_dropped_with_one_warning`), except the two whitespace typos in 1.4.0's own shipped `TITLES`, which a restored 1.4 pickle carries and are dropped without the warning. The same filter drops an entry `Lexicon` folds to empty — since 2.3.0's `FULL_STOPS` fold, a lone CJK full stop, which 1.4.0 through 2.2.0 could match on a token made of that stop but which since 2.3.0 can be neither held nor matched, so translating it raised wherever `Lexicon` received it (#582, the one departure from 1.4.0 this filter makes) — and the two cross-set computations `Lexicon` re-checks after its own fold (`suffix_words − ambiguous` and `bound ∩ particles`) compare `_normalize`d spellings, while every other field passes raw as before (an edge full stop, `'ma。'` beside the ambiguous `ma`, had raised `Lexicon`'s gate-bypass check since 2.3.0; folding EVERY field instead was measured to move readings on configs that never raised, `test_an_edge_full_stop_entry_outside_the_two_checks_reads_as_before`). Note that the `maiden_delimiters − nickname_delimiters` one is on the `Policy`, not the `Lexicon` — the roster is per-`_snapshot()`, not per-vocabulary-field, so a sweep that only reads the `Lexicon(...)` call misses it. When a v1 config cannot satisfy a v2 invariant, work out what v1 actually *does* with it — usually nothing — and reproduce that; weakening the invariant or letting the raise through are both wrong. **Test the case the translation decides**, not one where both branches agree: a test using an input v1 parses identically with and without the config pins nothing. A `capitalization_exceptions` value that does not spell its key's letters and digits raises `ValueError` at snapshot rather than being translated (#459), because v1 substituted such a value for the word and 2.4 repair only recases, so there is no v1 behavior to reproduce; pinned by `test_a_mismatched_exception_value_raises_at_the_first_parse`. - **Reprs are bounded**: render which fields deviate from a named baseline and by how much, never contents (`Lexicon(default + titles: +2)`). `PolicyPatch`'s repr shows only set (non-UNSET) fields; `_order_repr` must never raise even on an unvalidated patch's garbage `name_order` (PolicyPatch defers validation to apply time); the sweep test in `tests/v2/test_reprs.py` pins that no config repr leaks the UNSET sentinel. - **Every pipeline stage is one module holding one public function of the same name** (`_tokenize.py`/`tokenize`), with the exceptions named where they stand — `_group.py`'s `marker_run_length`, a shared predicate (mechanisms.md#ONE-PREDICATE-PER-QUESTION), and since #511 `_post_rules.py`'s `suffix_entries`, the R1 entry pass as a function, because `Parser.revise` runs that one pass over a forced-role sub-parse and a stage's tail cannot be called on its own — **and its module docstring declares the contract in three labelled lines**: `Consumes:` what it takes from `ParseState`, `Produces:` what it hands back, `Reads:` which `Policy`/`Lexicon` fields it consults. `Reads:` is the load-bearing one — it makes "which stage do I touch for this feature?" a grep rather than a read-through. The authority for the stage set is `_pipeline/__init__.py`'s `STAGES` (eight, and not public API) with the field-ownership map in `ParseState`'s docstring, pinned by `tests/v2/pipeline/test_state.py`; NOT the rules, which are implementation-free by their own preamble and whose `implemented:` names modules honoring a rule rather than stages. A `_pipeline/` module that is not a stage says so in its first line instead (`_assemble.py`: "Not a stage: …", omitting `Reads:` because it consults neither), so absence of the three lines is a claim about the module rather than an oversight. Provenance: §5 of the 2026-07-11 conventions spec, recorded here 2026-08-16. - **Frames are a budget for hot and scaling paths; everywhere else, prefer the shared predicate.** The parser's cost is counted in Python frames (`sys.setprofile` "call" events — deterministic and load-immune, which is why #475 chose them over wall-clock timing), but one frame is small: measured 2026-09-28 on py3.11, a trivial call costs about 13 ns against 40–80 µs for a whole parse, so one frame per parse is roughly 0.03% of it. What the budget exists to catch is how a cost SCALES and how it ACCUMULATES, so weigh an extra frame by four questions, in order. (1) Does it multiply with the input — per token, per member, per pair? Then it matters, and a hand-inlined fast path can earn its place (the inline tag tests ahead of `_pieces.starts_a_credential_run` in its loops, each a necessary half of the predicate so nothing can drift; `_vocab.run_word_fold` was the measured example until #613 deleted the run test it served). Folding a copy into a shared helper is where such a pre-check gets lost: #617 replaced `_comma._title_chains`, whose loop tested the conjunction tag inline, with a call into group's loop, which asks `is_conj_piece` per piece, and every family-comma parse paid two frames per word of the part (`Smith, John` +2) while the reference row fell -- review caught it; the fix gates the call on the tag walk the caller already makes, and `test_a_comma_part_with_no_free_connective_skips_the_join` holds it. Count a comma name, not only the reference row, when a fold touches the comma path. (2) How many names pay it — every parse, where plain names like `Smith, John` must keep their cost, or only a rare shape? (3) Does it move an enforced guard — the ±2% reference band in `test_benchmark.py` (`_CALL_BASELINE`, decisions/parse-cost) or a scaling ratio? (4) What does the inline alternative cost to maintain? A hand copy of a predicate that can drift is the defect class mechanisms.md#ONE-PREDICATE-PER-QUESTION names, and it compounds; a NARROWER question stated exactly is not a copy and may stay inline on that ground alone. A constant frame on a rare shape that no guard sees loses to readability: #544's cleanup declined `is_title_piece` for one frame on comma credential runs (24 ns a parse, no guard moved), a stricter call than this rule asks for; by this rule the inline test is still right, but because it asks the narrower question (a lone word in both vocabularies), not because of the frame. The converse holds too: the frame counter cannot see C-level work (`x in some_list`, slices, string concatenation), so "no new frames" is not "no new cost" — #553's quadratic was a list membership test inside a loop, invisible to every frame guard. diff --git a/docs/design/decisions/3-0-reevaluations.md b/docs/design/decisions/3-0-reevaluations.md index 15488ce56..0a7d9851e 100644 --- a/docs/design/decisions/3-0-reevaluations.md +++ b/docs/design/decisions/3-0-reevaluations.md @@ -63,3 +63,4 @@ Open: #753 (phase 3, language packs, where the three pack bullets land). - (A) 2026-09-20 #397 — A SECOND INSTANCE OF THE SAME PER-LOCALE QUESTION, cross-referenced rather than parked again. Catalan/Polish `i` joins `conjunctions_ambiguous` beside `e`, so the marked subset now carries two letters and the 2026-08-18 bullet above — which counted six single-letter conjunctions and named the per-word-policy requirement — describes a set of seven, `i` included. Nothing in that bullet's argument moves: the subset IS the per-word policy the bullet asked for, made configurable without packs, and the pack-side half is still what the 2026-09-13 SHOULD A LOCALE PACK BE ABLE TO REMOVE BASE VOCABULARY? (A) bullet above asks (this pointer named only its date until 2026-10-22, which two bullets share), since a `ca` or `pl` caller still has to write `Lexicon.default().remove(conjunctions_ambiguous={"i"})` by hand. See `decisions/P3`'s 2026-09-20 bullet. - (B) 2026-09-23 #459 — THE SHIM RAISES on a v1 config it once accepted: a `capitalization_exceptions` value that does not spell its key's letters and digits raises `ValueError` at the first parse (decisions/R4, 2026-09-23). Cited against the shim's never-raise rule and taken anyway, so it is recorded here: the raise stands on its own — a case mask has no reading for a value that re-spells its word, and the core `Lexicon` raises the same way with no shim involved — and only where it LANDS for a v1 caller, at the first parse rather than at construction, is shim-era. 3.0 has nothing left to decide. - (B) 2026-10-02 #541 — THE SHIM DROPS, with a warning, every v1 `Constants` entry v1 could never match (decisions/R4, 2026-10-02). Cited from 1.4 parity, and it dies with the shim: the core `Lexicon` normalizes edge whitespace away and has no v1 reading to reproduce, so 3.0 has nothing left to decide. +- (B) 2026-10-26 #752 — THE SHIM KEEPS 2.6.0'S TITLE LIST as v1's default `titles`, frozen in `nameparser/config/_titles_2_6_0.py`, so a title a later release made phrase-only (decisions/H9, 2026-10-26, the v1 title list) is still listed and removable. Cited from v1 parity, and it dies with the shim: the core `Lexicon` keeps the two sets apart and has no v1 list to reproduce, so 3.0 deletes the file with the facade and decides nothing. diff --git a/docs/design/decisions/H9-title-phrase.md b/docs/design/decisions/H9-title-phrase.md index f7a0b4cc6..697412259 100644 --- a/docs/design/decisions/H9-title-phrase.md +++ b/docs/design/decisions/H9-title-phrase.md @@ -7,7 +7,7 @@ - `grace` (a style) and `marshal` (a rank) are titles that may be given names, `sergeant` attaches to the word `major` and `major` to `general` (`attaches` naming a word where a kind would reach too far): `His Grace John Smith`, `Field Marshal Bernard Montgomery` and `Sergeant Major John Smith` read the title; `Mr Justice Smith` keeps given 'Justice', no exception behind a salutation — 2026-10-26 #752, phase 2i (Derek). - Its partner is a modifier or a style before it, a title before it that attaches to it or a title after it that it attaches to (`attaches`), or `of` after it; a salutation is never one, nor any other title before it (#853's `Mr Dean Smith`), nor a title after it it does not attach to (`SMITH, DEAN MR.`) — 2026-10-25 #752. - A modifier in the leading run of titles before the first comma is a title only with a head title after it in that run, `of` after it, or in the object of `of` or `the` there; outside that run (ending the name, H5's, or after a comma) it reads as any title does, the given part after a family comma included — 2026-10-25 #752; save a modifier that may be a given name, which the run's test reads in every part (`Lau, Wing Kai` → given 'Wing', `John Smith Wing.` → family 'Wing.') — 2026-10-26 #752, phase 2j, `wing`. -- A title that may be a given name which a caller lists in `titles` is a title wherever it stands, which keeps the 1.4 recipe `c.titles.add("dean")`; a v1 caller who empties `titles` turns the phrase reading off with them, while one who replaces the list keeps it (`c.titles.clear(); c.titles.add('dr')` still reads `Dean of Students` as the title), v1 holding no knob for the set — 2026-10-25 #752. +- A title that may be a given name which a caller lists in `titles` is a title wherever it stands, which keeps the 1.4 recipe `c.titles.add("dean")`; a v1 caller who empties `titles` turns the phrase reading off with them, while one who replaces the list keeps it (`c.titles.clear(); c.titles.add('dr')` still reads `Dean of Students` as the title), v1 holding no knob for the set — 2026-10-25 #752. A title 2.6.0 listed that a later batch made a title only inside a phrase stays in v1's default `titles`, read by the phrase while it is there and as no title once removed, so `c.titles.remove('king')` works as in 2.6.0; listing it in v1 cannot make it a title everywhere, which takes `Lexicon.default().add(titles={'king'})` — 2026-10-26 #752, the v1 title list. ## Log @@ -56,3 +56,5 @@ - `king` WAS THE WORKED EXAMPLE OF A TITLE THAT IS ALSO A SURNAME, in rules H1, H3 to H8, M2, S2 and S8, their decisions Standing lines, AGENTS.md, docs/usage.rst, two code comments and eleven case rows and tests. Each now uses `pope`, chosen because it reads as `king` read on master in every one of those examples (19 inputs checked against `queen`, `bishop`, `pope`, `baron`, `judge`, `sheriff`, `mayor` and `emperor`; only `pope` and `queen` matched all, `pope` being the commoner surname). decisions/v1-xfail-triage's "`king` stays in TITLES" is superseded there. - MEASURED (2026-10-26, against master `957509d1`, py3.11, every local check with `--base`). PSC (1,039,174 scored): 58 records fixed, none broken, 5 changed. fec-individuals 73 moved, all fixes: `AA, KING …` given names (51) and `KING AA, AA` family parts that H6 had peeled (22). ctgov-officials 12, all fixes: officials whose given name is King (`King A. Aa`), and one whose credential master read as the given name (`Aa King, AA Aa` → given 'Aa', family 'King', the credentials suffixes). tse-donors, ukec-individuals, jrc-names and nepal-candidates pairs (0 now alike, 0 apart: both spellings of a person move together), CLDR and GND agreement do not move. The committed corpora move 11 names before the rules examples were rewritten: the three royal rows, `King Curtis` (#348's example, fixed), `King Smith, John` and `Mary Jane King.` (1.4.0's readings), a maiden clause, and four that only lose H4's `title-or-name`, the word no longer being title vocabulary (`Dr. King`, `Dr King Jr`, `Dr. King MD`; `Smith and King` reports `given-or-family` instead). Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local fec-individuals --base ` (and `ctgov-officials`). + +- **2026-10-26 #752, the v1 title list — A v1 `Constants().titles` HOLDS EVERY TITLE 2.6.0 LISTED THAT IS STILL A TITLE OF EITHER KIND.** The review of phase 2j's fifth and sixth batches found that a word a batch moved from `titles` to `titles_ambiguous` also left v1's default `titles`, which the shim copied from TITLES: `Constants().titles.remove('king')` raised `KeyError` and `'king' in Constants().titles` was False, where 2.6.0 accepted the removal and answered True. Measured against the 2.6.0 wheel (`PYTHONSAFEPATH=1`, `nameparser.__file__` asserted on both sides), word by word over all nine v1 set attributes and the `capitalization_exceptions` keys: `titles` alone had lost words (`bard`, `buddha`, `gyani`, `king`, `mahdi`, `siddha`, `wing`), and `suffix_not_acronyms` had gained the eight agnomens and `fils` of phase 2h, a vocabulary addition its release bullet records. `suffix_acronyms_ambiguous` (derived in phase 2a), `given_name_abbreviations` (2k) and the phase 2h sets have no v1 manager that lost a word. The translation: `nameparser/config/_titles_2_6_0.py` freezes 2.6.0's TITLES, generated from the wheel and never edited, and v1's default `titles` is TITLES with every 2.6.0 title now in `titles_ambiguous` added back (`moved`). `Constants._build_snapshot` passes a moved word to `titles_ambiguous` while the caller keeps it, so a default `Constants` still translates to `Lexicon.default()`, and passes it to neither once removed, which turns its modifier, style and phrase readings off with it, as removing any title does: `c.titles.remove('king')` then reads `Hon. King Smith` as title 'Hon.', given 'King', 2.6.0's reading after the same removal. A word 2.6.0 never listed (`dean`, `justice`, `grace`, `major`, `marshal`) is not in `moved`, so `c.titles.add('dean')` still makes it a title everywhere. The gap, accepted because v1's one bit per word cannot carry three readings: adding a moved word in v1 restores the default, the phrase reading, and not 2.6.0's title everywhere, which a caller gets from the 2.0 API (`Lexicon.default().add(titles={'king'})`). Keyed on the compiled sets rather than a hand list, so a later batch that moves another 2.6.0 title (`sultan`, `sheikh`) is covered with no edit, and `tests/v2/test_config_shim.py` sweeps every moved word by the same derivation: membership, set operators, `remove`, `discard`, re-adding, a replaced list, pickle, deepcopy and copy, 37 of its 41 rows failing on master `6ba78d0d`. The default parse does not move: the snapshot, the CLDR, GND and native agreement records and the facade case rows pass unchanged. diff --git a/docs/release_log.rst b/docs/release_log.rst index 1a1ae388d..7db2ea4b8 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -13,7 +13,7 @@ Release Log - **Fix a split Ph. D. in the middle of a name stopping short of the words after it.** ``John Smith Ph. D. Jones`` gives suffix ``Ph. D. Jones``, as ``John Smith PhD Jones`` does, where 1.4.0 through 2.6.0 gave last ``Jones``, suffix ``Ph. D.``; ``John Smith V Ph. D.`` gives last ``V``, as ``John Smith V PhD`` does, where 1.4.0 through 2.6.0 read ``V`` as a suffix. :issue:`827` - **Read the Brazilian agnomens and French fils as suffixes where the name has a word to spare.** ``Pedro Alves Filho`` and ``Alexandre Dumas fils`` give last ``Alves`` and ``Dumas``, suffix ``Filho`` and ``fils``, where 1.4.0 through 2.6.0 gave middle ``Alves`` and ``Dumas``, last ``Filho`` and ``fils``; ``Neto``, ``Netto``, ``Sobrinho`` and the feminines ``Filha``, ``Neta``, ``Sobrinha`` read the same way. Each is a surname too, so a name of two words keeps it as the last name (``Ana Neto``, ``PEDRO FILHO``) and ``Fils`` in Title case in a mixed-case name stays the last name (``Marie Fils``), the parse reports the choice as a ``suffix-or-name`` ambiguity. ``Lexicon.suffix_words_ambiguous`` lists the words and ``Lexicon.agnomens`` the ones read by the word count alone. :issue:`843`, :issue:`752` - **Read a credential that is also a name, written in capitals in a mixed-case name, as settled.** ``John Smith MA`` reports no ``suffix-or-name`` ambiguity, where 2.0.0 through 2.6.0 reported one, and ``Smith, John MA Jones`` gives suffix ``MA Jones``, where 2.0.0 through 2.6.0 gave middle ``MA Jones`` and 1.4.0 middle ``Jones``, suffix ``MA``. A name written in one case still reports (``JOHN SMITH MA``), and with no comma a name word behind the capitals keeps them a name word, since they may be initials (``Maria Ana MA Silva`` keeps last ``Silva``). :issue:`785`, :issue:`752` - - **Read a title that is also a given name as a title only inside a title phrase.** ``Chief Justice John Roberts`` gives title ``Chief Justice``, first ``John``, where 1.4.0 through 2.6.0 gave title ``Chief``, first ``Justice``; ``Field Marshal Bernard Montgomery``, ``Dean of Students Jane Smith`` and ``Major General John Smith`` give the phrase as the title too. Outside a phrase the word is still a name, so ``Dean Smith`` and ``Grace Smith`` give first ``Dean`` and ``Grace``; ``Dean Smith`` now reports a ``title-or-name`` ambiguity unless a comma has marked the given name (``Smith, Dean``), while ``Grace``, a title only in a phrase such as ``His Grace``, reports nothing. Listing the word as a title still makes it one everywhere (``Lexicon.default().add(titles={"dean"})``). :issue:`752`, :issue:`853` + - **Read a title that is also a given name as a title only inside a title phrase.** ``Chief Justice John Roberts`` gives title ``Chief Justice``, first ``John``, where 1.4.0 through 2.6.0 gave title ``Chief``, first ``Justice``; ``Field Marshal Bernard Montgomery``, ``Dean of Students Jane Smith`` and ``Major General John Smith`` give the phrase as the title too. Outside a phrase the word is still a name, so ``Dean Smith`` and ``Grace Smith`` give first ``Dean`` and ``Grace``; ``Dean Smith`` now reports a ``title-or-name`` ambiguity unless a comma has marked the given name (``Smith, Dean``), while ``Grace``, a title only in a phrase such as ``His Grace``, reports nothing. Listing the word as a title still makes it one everywhere (``Lexicon.default().add(titles={"dean"})``). A v1 ``Constants().titles`` still lists ``King``, ``Mahdi`` and the other titles 2.6.0 listed, and removing one reads it as a name everywhere, but adding one there no longer makes it a title outside a phrase; that takes the ``Lexicon`` spelling. :issue:`752`, :issue:`853` - **Fix the given name King reading as a title.** ``King Curtis`` and ``Mr King Smith`` give first ``King``, where 1.4.0 through 2.6.0 gave title ``King`` and ``Mr King``; ``SMITH, KING`` reads the same. A monarch written with the bare title reads it as a first name too (``King Henry`` gives first ``King``, last ``Henry``), where a style keeps it (``His Majesty King Henry``). :issue:`752`, :issue:`348` - **Fix the Nepali given names Buddha, Siddha and Gyani reading as titles.** ``Buddha Bahadur Tamang`` and ``Gyani Maya Gurung`` give first ``Buddha`` and ``Gyani``, middle ``Bahadur`` and ``Maya``, where 1.4.0 through 2.6.0 gave title ``Buddha`` and ``Gyani``, first ``Bahadur`` and ``Maya``; ``Siddha Raj Joshi`` and ``Tamang, Buddha Bahadur`` read the same. ``Guru`` is still a title. The Sikh title written ``Gyani`` reads as a given name too (``Gyani Zail Singh``); its usual spelling ``Giani`` is still a title. :issue:`869`, :issue:`752` - **Fix the given names Mahdi and Bard reading as titles.** ``Mahdi Karimi`` and ``Bard Larsen`` give first ``Mahdi`` and ``Bard``, where 1.4.0 through 2.6.0 gave title ``Mahdi`` and ``Bard`` with no first name; ``Mr Mahdi Karimi`` and ``Karimi, Mahdi`` read the same. Inside a title phrase either is still a title (``Mahdi of Sudan``), but the epithet with its article is not: ``Muhammad Ahmad, the Mahdi`` gives first ``the Mahdi``, last ``Muhammad Ahmad``, where 1.4.0 through 2.6.0 gave title ``the Mahdi``. :issue:`867`, :issue:`752` diff --git a/nameparser/_config_shim.py b/nameparser/_config_shim.py index 925540f05..0b0f4886a 100644 --- a/nameparser/_config_shim.py +++ b/nameparser/_config_shim.py @@ -731,13 +731,19 @@ def _default_vocab() -> dict[str, frozenset[str]]: from nameparser.config.suffixes import ( SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_WORDS, ) - from nameparser.config.titles import GIVEN_NAME_TITLES, TITLES + from nameparser.config._titles_2_6_0 import TITLES_2_6_0 + from nameparser.config.titles import ( + GIVEN_NAME_TITLES, TITLES, TITLES_AMBIGUOUS) return { "prefixes": PARTICLES, "suffix_acronyms": SUFFIX_ACRONYMS, "suffix_not_acronyms": SUFFIX_WORDS, "suffix_acronyms_ambiguous": SUFFIX_ACRONYMS_AMBIGUOUS, - "titles": TITLES, + # 2.6.0's titles, less any word that is no title now: one a + # later release made a title only inside a title phrase + # (rules/H9) stays a v1 title, so remove() and `in` answer as + # they did; _build_snapshot reads it by the phrase + "titles": TITLES | (TITLES_2_6_0 & TITLES_AMBIGUOUS), "first_name_titles": GIVEN_NAME_TITLES, "conjunctions": CONJUNCTIONS, "bound_first_names": BOUND_GIVEN_NAMES, @@ -1050,6 +1056,7 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: from nameparser.config.suffixes import ( AGNOMENS, GLUED_HONORIFICS, SUFFIX_WORDS_AMBIGUOUS) from nameparser.config.surnames import KOREAN_SURNAMES + from nameparser.config._titles_2_6_0 import TITLES_2_6_0 from nameparser.config.titles import ( AFTER_GIVEN_TITLES, TITLE_MODIFIERS, TITLE_PHRASES, TITLE_STYLES, TITLES_AMBIGUOUS) @@ -1075,6 +1082,20 @@ def matchable(field: str) -> frozenset[str]: conjunctions = matchable("conjunctions") bound = matchable("bound_first_names") titles = matchable("titles") + # A title 2.6.0 listed that a later release made a title only + # inside a title phrase (rules/H9) is in v1's default `titles`, + # where 2.6.0 had it, so remove(), discard() and `in` answer as + # they did (decisions/H9, 2026-10-26). Kept, it is read as the + # default reads it, by the phrase; removed, it is no title at + # all, as removing it made it in 2.6.0. The one 2.6.0 reading + # with no v1 spelling left is the word as a title everywhere: + # v1 can say "a title" or "not a title", and "a title" now means + # the phrase reading. A word 2.6.0 never listed (`dean`) is not + # in `moved`, so adding it is still a title everywhere, the 1.4 + # recipe the phrase test honours below. + moved = TITLES_2_6_0 & TITLES_AMBIGUOUS + phrase_only = ((TITLES_AMBIGUOUS - moved) | (moved & titles) + if titles else frozenset()) first_name_titles = matchable("first_name_titles") non_given = matchable("non_first_name_prefixes") ambiguous_acronyms = matchable("suffix_acronyms_ambiguous") & acronyms @@ -1140,7 +1161,7 @@ def matchable(field: str) -> frozenset[str]: # v1 also had, later, at capitalize(). try: lexicon = Lexicon( - titles=titles, + titles=titles - moved, # TRANSLATE, do not filter. The two versions build the same # lookup key differently: v1 joins the raw title run and # then applies lc(), which strips only the whole string's @@ -1249,11 +1270,13 @@ def matchable(field: str) -> frozenset[str]: # likewise no v1 manager: the title phrase (rules/H9) is # 2.7 behavior (#752), so its sets ride in the snapshot # only. A title that may be a given name is not in - # TITLES, so v1's `titles` never held it, and one a v1 - # caller ADDS to `titles` is a title wherever it stands, - # as it was in 1.4 (`c.titles.add("dean")`, the - # documented recipe): the phrase test reads a word of - # titles_ambiguous only where it is not in titles. The + # TITLES, so v1's `titles` holds one only where 2.6.0 + # listed it (`moved`, above, read by the phrase), and one + # 2.6.0 never listed that a v1 caller ADDS to `titles` is + # a title wherever it stands, as it was in 1.4 + # (`c.titles.add("dean")`, the documented recipe): the + # phrase test reads a word of titles_ambiguous only where + # it is not in titles. The # knob that reaches the other three is deleting a title, # which turns its modifier, style or phrase reading off # with it; Lexicon checks none of them, so the @@ -1261,17 +1284,17 @@ def matchable(field: str) -> frozenset[str]: # after_given_titles' is. # ...save where the caller emptied `titles`, v1's way to # turn title detection off (customize.rst). A caller who - # REPLACES the list keeps the phrase reading: v1 has no - # knob for these words, and what a replaced list means - # for words it never held is not something it says - titles_ambiguous=TITLES_AMBIGUOUS if titles else frozenset(), - title_modifiers=TITLE_MODIFIERS & ( - titles | (TITLES_AMBIGUOUS if titles else frozenset())), - title_styles=TITLE_STYLES & ( - titles | (TITLES_AMBIGUOUS if titles else frozenset())), + # REPLACES the list keeps the phrase reading of the words + # 2.6.0 never listed: v1 has no knob for them, and what a + # replaced list means for words it never held is not + # something it says. A moved word reads by whether the + # replaced list holds it, as any v1 title does + titles_ambiguous=phrase_only, + title_modifiers=TITLE_MODIFIERS & (titles | phrase_only), + title_styles=TITLE_STYLES & (titles | phrase_only), title_phrases=frozenset( p for p in TITLE_PHRASES - if all(w in titles or w in TITLES_AMBIGUOUS + if all(w in titles or w in phrase_only for w in p.split(" "))), # TupleManager is dict[str, object] (v1 parity: values were # never statically str-typed); every real entry is a str, diff --git a/nameparser/config/_titles_2_6_0.py b/nameparser/config/_titles_2_6_0.py new file mode 100644 index 000000000..5ecf0de3a --- /dev/null +++ b/nameparser/config/_titles_2_6_0.py @@ -0,0 +1,826 @@ +"""The title list nameparser 2.6.0 shipped, frozen: the v1 default +``Constants().titles`` the facade translates from (decisions/H9, +2026-10-26). Never edited, and deleted with the facade in 3.0. + +A word a later release moved out of TITLES into TITLES_AMBIGUOUS, a title +only inside a title phrase (rules/H9), is still a v1 title, so a v1 +caller can test, iterate and remove it as 2.6.0 let them; +``Constants._build_snapshot`` sends it to ``titles_ambiguous`` while the +caller keeps it. Generated from the 2.6.0 wheel's +``nameparser.config.titles.TITLES``. +""" + +TITLES_2_6_0: frozenset[str] = frozenset({ + '10th', + '1lt', + '1sgt', + '1st', + '1stlt', + '1stsgt', + '2lt', + '2nd', + '2ndlt', + '3rd', + '4th', + '5th', + '6th', + '7th', + '8th', + '9th', + 'a1c', + 'ab', + 'abbess', + 'abbot', + 'abolitionist', + 'academic', + 'acolyte', + 'activist', + 'actor', + 'actress', + 'adept', + 'adjutant', + 'adm', + 'administrator', + 'admiral', + 'advertising', + 'adviser', + 'advocate', + 'air', + 'akhoond', + 'alderman', + 'almoner', + 'ambassador', + 'amn', + 'analytics', + 'anarchist', + 'animator', + 'anthropologist', + 'appellate', + 'apprentice', + 'arbitrator', + 'archbishop', + 'archdeacon', + 'archdruid', + 'archduchess', + 'archduke', + 'archeologist', + 'architect', + 'arhat', + 'army', + 'arranger', + 'assistant', + 'assoc', + 'associate', + 'asst', + 'astronomer', + 'attache', + 'attaché', + 'attorney', + 'aunt', + 'auntie', + 'author', + 'award-winning', + 'ayatollah', + 'baba', + 'bailiff', + 'ballet', + 'bandleader', + 'banker', + 'banner', + 'bapak', + 'bard', + 'baron', + 'baroness', + 'barrister', + 'baseball', + 'batoni', + 'bearer', + 'behavioral', + 'bench', + 'bg', + 'bgen', + 'biblical', + 'bibliographer', + 'binibini', + 'biochemist', + 'biographer', + 'biologist', + 'bishop', + 'blessed', + 'blogger', + 'blues', + 'bodhisattva', + 'bookseller', + 'botanist', + 'bp', + 'brigadier', + 'briggen', + 'british', + 'broadcaster', + 'brother', + 'buddha', + 'burgess', + 'burlesque', + 'business', + 'businessman', + 'businesswoman', + 'bwana', + 'canon', + 'capt', + 'captain', + 'cardinal', + 'cartographer', + 'cartoonist', + 'catholicos', + 'ccmsgt', + 'cdr', + 'celebrity', + 'ceo', + 'cfo', + 'ch.ma', + 'ch.mo', + 'chair', + 'chairs', + 'chancellor', + 'chaplain', + 'charge', + 'chargé', + 'chef', + 'cheikh', + 'chemist', + 'chief', + 'chieftain', + 'choreographer', + 'civil', + 'classical', + 'clergyman', + 'clerk', + 'cllr', + 'cmsaf', + 'cmsgt', + 'co-chair', + 'co-chairs', + 'co-founder', + 'coach', + 'col', + 'collector', + 'colonel', + 'comedian', + 'comedienne', + 'comic', + 'commander', + 'commander-in-chief', + 'commissioner', + 'commodore', + 'composer', + 'compositeur', + 'comptroller', + 'computer', + 'comtesse', + 'conductor', + 'consultant', + 'controller', + 'corporal', + 'corporate', + 'correspondent', + 'councillor', + 'counsel', + 'counselor', + 'count', + 'countess', + 'courtier', + 'cpl', + 'cpo', + 'cpt', + 'credit', + 'criminal', + 'criminologist', + 'critic', + 'csm', + 'curator', + 'customs', + 'cwo-2', + 'cwo-3', + 'cwo-4', + 'cwo-5', + 'cwo2', + 'cwo3', + 'cwo4', + 'cwo5', + 'cyclist', + "d'affaires", + 'dame', + 'dancer', + 'dcn', + 'deacon', + 'delegate', + 'deputy', + 'designated', + 'designer', + 'detective', + 'developer', + 'dhr', + 'dipl.-ing', + 'diplomat', + 'dir', + 'director', + 'discovery', + 'dissident', + 'district', + 'division', + 'doamna', + 'doamnă', + 'docent', + 'docket', + 'doctor', + 'domnișoara', + 'domnul', + 'dott.ssa', + 'doyen', + 'dpty', + 'dr', + 'dra', + 'dramatist', + 'druid', + 'drummer', + 'duchesse', + 'dutchess', + 'ecologist', + 'economist', + 'editor', + 'edler', + 'edmi', + 'edohen', + 'educator', + 'effendi', + 'ekegbian', + 'elerunwon', + 'eminence', + 'emperor', + 'empress', + 'encik', + 'engineer', + 'english', + 'ens', + 'entertainer', + 'entrepreneur', + 'envoy', + 'erzbischof', + 'essayist', + 'evangelist', + 'excellency', + 'excellent', + 'exec', + 'executive', + 'expert', + 'fadm', + 'familie', + 'family', + 'father', + 'federal', + 'fh-prof', + 'field', + 'film', + 'financial', + 'first', + 'flag', + 'flying', + 'foreign', + 'forester', + 'founder', + 'fr', + 'frau', + 'freifrau', + 'freiherr', + 'friar', + 'frk', + 'fru', + 'fräulein', + 'fröken', + 'frøken', + 'frú', + 'fürst', + 'fürsterzbischof', + 'gaf', + 'gen', + 'general', + 'generalissimo', + 'gent.le', + 'gent.ma', + 'gent.mo', + 'gentiluomo', + 'giani', + 'ginang', + 'ginoo', + 'goodman', + 'goodwife', + 'gospodin', + 'gospođa', + 'gospođica', + 'gov', + 'governor', + 'graaf', + 'graf', + 'grafas', + 'grafi', + 'grand', + 'greifi', + 'greve', + 'group', + 'großfürst', + 'gräfin', + 'guitarist', + 'guru', + 'gyani', + 'gysgt', + 'hajji', + 'headman', + 'heir', + 'heiress', + 'her', + 'hereditary', + 'heren', + 'herr', + 'herra', + 'herren', + 'herrn', + 'herzog', + 'high', + 'highness', + 'his', + 'historian', + 'historicus', + 'historien', + 'holiness', + 'hon', + 'honorable', + 'honourable', + 'host', + 'hr', + 'hrabia', + 'härra', + 'ill.ma', + 'ill.mo', + 'illustrator', + 'imam', + 'industrialist', + 'information', + 'instructor', + 'intelligence', + 'intendant', + 'inventor', + 'investigator', + 'investor', + 'journalist', + 'journeyman', + 'judge', + 'judicial', + 'juffrouw', + 'jurist', + 'kalbatoni', + 'keyboardist', + 'king', + "king's", + 'kingdom', + 'knight', + 'knowledge', + 'krahv', + 'kreivi', + 'lady', + 'lama', + 'lamido', + 'law', + 'lawyer', + 'lcdr', + 'lcpl', + 'leader', + 'lecturer', + 'legal', + 'librarian', + 'lieutenant', + 'linguist', + 'literary', + 'lord', + 'lt', + 'ltc', + 'ltcol', + 'ltg', + 'ltgen', + 'ltjg', + 'lyricist', + 'madam', + 'madame', + 'mademoiselle', + 'mag', + 'mag-judge', + 'mag/judge', + 'magistrate', + 'magistrate-judge', + 'magnate', + 'maharajah', + 'maharani', + 'mahdi', + 'maid', + 'maj', + 'majesty', + 'majgen', + 'manager', + 'marcher', + 'marchess', + 'marchioness', + 'marketing', + 'marquess', + 'marquis', + 'marquise', + 'master', + 'mathematician', + 'mathematics', + 'matriarch', + 'mayor', + 'mcpo', + 'mcpoc', + 'mcpon', + 'me', + 'member', + 'memoirist', + 'meneer', + 'merchant', + 'met', + 'metropolitan', + 'mevr', + 'mevrouw', + 'mevrouwe', + 'mg', + 'mgr', + 'mgysgt', + 'military', + 'minister', + 'miss', + 'misses', + 'missionary', + 'mister', + 'mlle', + 'mme', + 'mobster', + 'model', + 'monk', + 'monseigneur', + 'monsieur', + 'monsignor', + 'most', + 'mother', + 'mountaineer', + 'mpco-cg', + 'mr', + 'mrs', + 'ms', + 'msg', + 'msgt', + 'mufti', + 'mullah', + 'municipal', + 'murshid', + 'musician', + 'musicologist', + 'mx', + 'mystery', + 'nanny', + 'narrator', + 'national', + 'naturalist', + 'navy', + 'neiti', + 'neuroscientist', + 'novelist', + 'nurse', + 'obstetritian', + 'officer', + 'opera', + 'operating', + 'ornithologist', + 'owura', + 'painter', + 'paleontologist', + 'paní', + 'pastor', + 'patriarch', + 'pd', + 'pediatrician', + 'personality', + 'petty', + 'pfc', + 'pharaoh', + 'philantropist', + 'philosopher', + 'photographer', + 'physician', + 'physicist', + 'pianist', + 'pilot', + 'pioneer', + 'pir', + 'player', + 'playwright', + 'po1', + 'po2', + 'po3', + 'poet', + 'police', + 'political', + 'politician', + 'ponas', + 'ponia', + 'pope', + 'prefect', + 'preg.ma', + 'preg.mo', + 'preili', + 'prelate', + 'premier', + 'pres', + 'presbyter', + 'president', + 'presiding', + 'priest', + 'priestess', + 'primate', + 'prime', + 'prin', + 'prince', + 'princess', + 'principal', + 'printer', + 'printmaker', + 'prinz', + 'prior', + 'priv.-doz', + 'private', + 'pro', + 'producer', + 'prof', + 'prof.ssa', + 'professor', + 'proua', + 'provost', + 'pslc', + 'psychiatrist', + 'psychologist', + 'publisher', + 'pursuivant', + 'pv2', + 'pvt', + 'queen', + "queen's", + 'ra', + 'rabbi', + 'radio', + 'radm', + 'rangatira', + 'ranger', + 'rdml', + 'rear', + 'rebbe', + 'registrar', + 'reichsgraf', + 'rep', + 'representative', + 'researcher', + 'resident', + 'rev', + 'revenue', + 'reverend', + 'right', + 'risk', + 'ritter', + 'rock', + 'rouva', + 'royal', + 'rt', + 'sailor', + 'saint', + 'sainte', + 'saoshyant', + 'satirist', + 'scholar', + 'schoolmaster', + 'scientist', + 'scpo', + 'screenwriter', + 'secretary', + 'security', + 'seigneur', + 'senator', + 'senhor', + 'senhora', + 'senhorita', + 'senior', + 'senior-judge', + 'sergeant', + 'servant', + 'señor', + 'señora', + 'señores', + 'señorita', + 'señoritas', + 'sfc', + 'sgm', + 'sgt', + 'sgtmaj', + 'sgtmajmc', + 'shaik', + 'shaikh', + 'shayk', + 'shaykh', + 'sheik', + 'sheikh', + 'shekh', + 'sheriff', + 'siddha', + 'sig.na', + 'sig.ra', + 'signor', + 'signora', + 'signore', + 'signorina', + 'singer', + 'singer-songwriter', + 'sir', + 'sister', + 'slečna', + 'sma', + 'smsgt', + 'sn', + 'soccer', + 'social', + 'sociologist', + 'software', + 'soldier', + 'solicitor', + 'soprano', + 'spc', + 'speaker', + 'special', + 'sr', + 'sra', + 'sres', + 'srta', + 'srtas', + 'ssg', + 'ssgt', + 'st', + 'staff', + 'state', + 'states', + 'strategy', + 'subaltern', + 'subedar', + 'suffragist', + 'sultan', + 'sultana', + 'superior', + 'supreme', + 'surgeon', + 'swami', + 'swordbearer', + 'sysselmann', + 'tax', + 'teacher', + 'technical', + 'technologist', + 'television', + 'tenor', + 'theater', + 'theatre', + 'theologian', + 'theorist', + 'timi', + 'tirthankar', + 'translator', + 'travel', + 'treasurer', + 'tsar', + 'tsarina', + 'tsgt', + 'uk', + 'uncle', + 'ungfrú', + 'united', + 'univ.prof', + 'us', + 'vadm', + 'vardapet', + 'vc', + 'venerable', + 'verderer', + 'vicar', + 'vice', + 'viscount', + 'vizier', + 'vocalist', + 'voice', + 'vrouwe', + 'warden', + 'warrant', + 'wing', + 'wo-1', + 'wo1', + 'wo2', + 'wo3', + 'wo4', + 'wo5', + 'woodman', + 'wp', + 'writer', + 'zoologist', + 'δρ', + 'κα', + 'καθ', + 'κος', + 'акад', + 'г-жа', + 'г-н', + 'д-р', + 'пан', + 'пані', + 'проф', + "גב'", + 'גברת', + 'גב׳', + 'ד"ר', + 'ד״ר', + 'הרב', + 'מר', + 'עו"ד', + 'עו״ד', + "פרופ'", + 'פרופסור', + 'פרופ׳', + 'أستاذ', + 'أستاذة', + 'الأستاذ', + 'الأستاذة', + 'الحاج', + 'الحاجة', + 'الدكتور', + 'الدكتورة', + 'الشيخ', + 'الشيخة', + 'دكتور', + 'دكتورة', + 'مهندس', + 'गुरु', + 'जनाब', + 'डा', + 'डॉ', + 'डॉक्टर', + 'न्यायमूर्ति', + 'पं', + 'पंडित', + 'प्रा', + 'प्राध्यापक', + 'प्रो', + 'प्रोफेसर', + 'बाबा', + 'बाबू', + 'महात्मा', + 'महाराजा', + 'मौलाना', + 'श्री', + 'श्रीमती', + 'श्रीमान', + 'श्रीयुत', + 'संत', + 'सरदार', + 'सुश्री', + 'सौ', + 'स्वामी', + 'অধ্যাপক', + 'আলহাজ', + 'আলহাজ্ব', + 'গুরু', + 'জনাব', + 'ড', + 'ডঃ', + 'ডক্টর', + 'ডা', + 'ডাঃ', + 'ডাক্তার', + 'প্রফেসর', + 'বাবা', + 'বিচারপতি', + 'মাওলানা', + 'মি', + 'মিঃ', + 'মিসেস', + 'মুফতি', + 'মো', + 'মোঃ', + 'মোছা', + 'মোছাঃ', + 'মোসা', + 'মোসাঃ', + 'শ্রী', + 'শ্রীমতী', + 'শ্রীল', + 'স্বামী', +}) diff --git a/tests/v2/test_config_shim.py b/tests/v2/test_config_shim.py index bf3fd0571..fc26ccfac 100644 --- a/tests/v2/test_config_shim.py +++ b/tests/v2/test_config_shim.py @@ -14,6 +14,8 @@ _RegexesProxy, _cached_parser, ) from nameparser.config.regexes import EMPTY_REGEX, REGEXES +from nameparser.config._titles_2_6_0 import TITLES_2_6_0 +from nameparser.config.titles import TITLE_MODIFIERS, TITLES, TITLES_AMBIGUOUS from nameparser.util import lc _DATA_DIR = Path(__file__).parent / "data" @@ -1327,6 +1329,113 @@ def test_snapshot_cleared_titles_turn_the_title_phrase_off() -> None: assert HumanName("Dean of Students Jane Smith", constants=c).title == "" +# A title 2.6.0 listed that a later release moved into TITLES_AMBIGUOUS, +# a title only inside a title phrase (rules/H9), is still a v1 title: +# derived from the compiled sets, so a batch that moves another word is +# swept here with no edit (decisions/H9, 2026-10-26). +_MOVED_TITLES = sorted(TITLES_2_6_0 & TITLES_AMBIGUOUS) + + +def _phrase_probe(word: str) -> str: + # a name reading the word as a title by default only through the + # phrase: behind a style, or ahead of a head title for a modifier + if word in TITLE_MODIFIERS: + return f"{word.title()} Commander Smith" + return f"Hon. {word.title()} Smith" + + +def test_the_moved_titles_are_found() -> None: + # an empty parametrize is a skip, not a failure + assert _MOVED_TITLES + + +def test_a_v1_titles_default_holds_every_2_6_0_title_still_read_as_one() -> None: + # membership, iteration and len, against the frozen 2.6.0 list: a + # word still read as a title of either kind stays a v1 title, while + # one taken out of the vocabulary since may leave + kept = TITLES_2_6_0 & (TITLES | TITLES_AMBIGUOUS) + titles = Constants().titles + assert kept <= set(titles) + assert all(w in titles for w in kept) + assert len(titles) == len(TITLES | kept) + # and a word 2.6.0 never listed stays out, so the 1.4 recipe + # `c.titles.add("dean")` still adds it + assert set(titles) - TITLES - TITLES_2_6_0 == set() + + +@pytest.mark.parametrize("word", _MOVED_TITLES) +def test_a_moved_title_reads_by_its_phrase_while_v1_keeps_it(word: str) -> None: + c = Constants() + assert word in c.titles + assert c.titles & {word} == {word} and {word} - c.titles == set() + lexicon, _, _ = c._snapshot() + assert word not in lexicon.titles and word in lexicon.titles_ambiguous + # reachability: the probe reads the word as a title only by the phrase + assert word.title() in HumanName(_phrase_probe(word), constants=c).title + + +@pytest.mark.parametrize("op", ["remove", "discard"]) +@pytest.mark.parametrize("word", _MOVED_TITLES) +def test_removing_a_moved_title_turns_every_title_reading_off( + word: str, op: str) -> None: + # 2.6.0 accepted c.titles.remove(word) and read the word as a name + # everywhere after; the phrase reading goes with it + c = Constants() + getattr(c.titles, op)(word) # must not raise + assert word not in c.titles + lexicon, _, _ = c._snapshot() + assert word not in lexicon.titles + assert word not in lexicon.titles_ambiguous + assert word not in lexicon.title_modifiers | lexicon.title_styles + assert not any(word in p.split(" ") for p in lexicon.title_phrases) + assert word.title() not in HumanName( + _phrase_probe(word), constants=c).title + name = HumanName(f"Hon. {word.title()} Smith", constants=c) + assert (name.title, name.first) == ("Hon.", word.title()) + + +@pytest.mark.parametrize("word", _MOVED_TITLES) +def test_adding_a_moved_title_back_restores_the_default(word: str) -> None: + # re-adding gives the default reading, the phrase-only one; 2.6.0's + # title-everywhere reading has no v1 spelling (decisions/H9) + c = Constants() + c.titles.remove(word) + c.titles.add(word) + assert c._snapshot()[0] == Lexicon.default() + + +@pytest.mark.parametrize("word", _MOVED_TITLES) +def test_a_moved_title_survives_a_pickle_and_a_deepcopy(word: str) -> None: + c = Constants() + for copied in (pickle.loads(pickle.dumps(c)), copy.deepcopy(c), + copy.copy(c)): + assert word in copied.titles + assert copied._snapshot()[0] == Lexicon.default() + c.titles.remove(word) + for copied in (pickle.loads(pickle.dumps(c)), copy.deepcopy(c)): + assert word not in copied.titles + assert word not in copied._snapshot()[0].titles_ambiguous + + +def test_a_replaced_titles_list_holding_a_moved_title_reads_it_by_phrase() -> None: + # Constants(titles=...) replaces the list; a moved word in it is + # read as the default reads it, a title inside a phrase + word = _MOVED_TITLES[0] + c = Constants(titles=["dr", word]) + lexicon, _, _ = c._snapshot() + assert lexicon.titles == {"dr"} + assert word in lexicon.titles_ambiguous + + +def test_a_v1_title_never_listed_by_2_6_0_is_a_title_everywhere() -> None: + # the 1.4 recipe for the phrase-only words 2.6.0 never listed + c = Constants() + assert "dean" not in c.titles + c.titles.add("dean") + assert "dean" in c._snapshot()[0].titles + assert HumanName("Dean Smith", constants=c).title == "Dean" + + def test_snapshot_a_given_name_written_short_listed_as_a_title_is_a_title() -> None: # #789: given_name_abbreviations rides in the snapshot whole, no v1 # manager reaching it; the v1 recipe that lists the word as a title From 9395e63ebdb97292f99783668c619505a7bbfef1 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Mon, 26 Oct 2026 21:49:19 -0700 Subject: [PATCH 2/3] Fold a v1 title's spelling before testing it against the moved titles (#752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A v1 entry Lexicon folds onto a moved title ('king。') skipped the split and read as a title everywhere. Pin the replaced-list Standing sentence too, and correct two counts in decisions/H9 (39 rows; seven agnomens). Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/H9-title-phrase.md | 2 +- nameparser/_config_shim.py | 9 ++++++--- tests/v2/test_config_shim.py | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/design/decisions/H9-title-phrase.md b/docs/design/decisions/H9-title-phrase.md index 697412259..176492b00 100644 --- a/docs/design/decisions/H9-title-phrase.md +++ b/docs/design/decisions/H9-title-phrase.md @@ -57,4 +57,4 @@ - MEASURED (2026-10-26, against master `957509d1`, py3.11, every local check with `--base`). PSC (1,039,174 scored): 58 records fixed, none broken, 5 changed. fec-individuals 73 moved, all fixes: `AA, KING …` given names (51) and `KING AA, AA` family parts that H6 had peeled (22). ctgov-officials 12, all fixes: officials whose given name is King (`King A. Aa`), and one whose credential master read as the given name (`Aa King, AA Aa` → given 'Aa', family 'King', the credentials suffixes). tse-donors, ukec-individuals, jrc-names and nepal-candidates pairs (0 now alike, 0 apart: both spellings of a person move together), CLDR and GND agreement do not move. The committed corpora move 11 names before the rules examples were rewritten: the three royal rows, `King Curtis` (#348's example, fixed), `King Smith, John` and `Mary Jane King.` (1.4.0's readings), a maiden clause, and four that only lose H4's `title-or-name`, the word no longer being title vocabulary (`Dr. King`, `Dr King Jr`, `Dr. King MD`; `Smith and King` reports `given-or-family` instead). Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local fec-individuals --base ` (and `ctgov-officials`). -- **2026-10-26 #752, the v1 title list — A v1 `Constants().titles` HOLDS EVERY TITLE 2.6.0 LISTED THAT IS STILL A TITLE OF EITHER KIND.** The review of phase 2j's fifth and sixth batches found that a word a batch moved from `titles` to `titles_ambiguous` also left v1's default `titles`, which the shim copied from TITLES: `Constants().titles.remove('king')` raised `KeyError` and `'king' in Constants().titles` was False, where 2.6.0 accepted the removal and answered True. Measured against the 2.6.0 wheel (`PYTHONSAFEPATH=1`, `nameparser.__file__` asserted on both sides), word by word over all nine v1 set attributes and the `capitalization_exceptions` keys: `titles` alone had lost words (`bard`, `buddha`, `gyani`, `king`, `mahdi`, `siddha`, `wing`), and `suffix_not_acronyms` had gained the eight agnomens and `fils` of phase 2h, a vocabulary addition its release bullet records. `suffix_acronyms_ambiguous` (derived in phase 2a), `given_name_abbreviations` (2k) and the phase 2h sets have no v1 manager that lost a word. The translation: `nameparser/config/_titles_2_6_0.py` freezes 2.6.0's TITLES, generated from the wheel and never edited, and v1's default `titles` is TITLES with every 2.6.0 title now in `titles_ambiguous` added back (`moved`). `Constants._build_snapshot` passes a moved word to `titles_ambiguous` while the caller keeps it, so a default `Constants` still translates to `Lexicon.default()`, and passes it to neither once removed, which turns its modifier, style and phrase readings off with it, as removing any title does: `c.titles.remove('king')` then reads `Hon. King Smith` as title 'Hon.', given 'King', 2.6.0's reading after the same removal. A word 2.6.0 never listed (`dean`, `justice`, `grace`, `major`, `marshal`) is not in `moved`, so `c.titles.add('dean')` still makes it a title everywhere. The gap, accepted because v1's one bit per word cannot carry three readings: adding a moved word in v1 restores the default, the phrase reading, and not 2.6.0's title everywhere, which a caller gets from the 2.0 API (`Lexicon.default().add(titles={'king'})`). Keyed on the compiled sets rather than a hand list, so a later batch that moves another 2.6.0 title (`sultan`, `sheikh`) is covered with no edit, and `tests/v2/test_config_shim.py` sweeps every moved word by the same derivation: membership, set operators, `remove`, `discard`, re-adding, a replaced list, pickle, deepcopy and copy, 37 of its 41 rows failing on master `6ba78d0d`. The default parse does not move: the snapshot, the CLDR, GND and native agreement records and the facade case rows pass unchanged. +- **2026-10-26 #752, the v1 title list — A v1 `Constants().titles` HOLDS EVERY TITLE 2.6.0 LISTED THAT IS STILL A TITLE OF EITHER KIND.** The review of phase 2j's fifth and sixth batches found that a word a batch moved from `titles` to `titles_ambiguous` also left v1's default `titles`, which the shim copied from TITLES: `Constants().titles.remove('king')` raised `KeyError` and `'king' in Constants().titles` was False, where 2.6.0 accepted the removal and answered True. Measured against the 2.6.0 wheel (`PYTHONSAFEPATH=1`, `nameparser.__file__` asserted on both sides), word by word over all nine v1 set attributes and the `capitalization_exceptions` keys: `titles` alone had lost words (`bard`, `buddha`, `gyani`, `king`, `mahdi`, `siddha`, `wing`), and `suffix_not_acronyms` had gained the seven agnomens and `fils` of phase 2h, a vocabulary addition its release bullet records. `suffix_acronyms_ambiguous` (derived in phase 2a), `given_name_abbreviations` (2k) and the phase 2h sets have no v1 manager that lost a word. The translation: `nameparser/config/_titles_2_6_0.py` freezes 2.6.0's TITLES, generated from the wheel and never edited, and v1's default `titles` is TITLES with every 2.6.0 title now in `titles_ambiguous` added back (`moved`). `Constants._build_snapshot` passes a moved word to `titles_ambiguous` while the caller keeps it, so a default `Constants` still translates to `Lexicon.default()`, and passes it to neither once removed, which turns its modifier, style and phrase readings off with it, as removing any title does: `c.titles.remove('king')` then reads `Hon. King Smith` as title 'Hon.', given 'King', 2.6.0's reading after the same removal. A word 2.6.0 never listed (`dean`, `justice`, `grace`, `major`, `marshal`) is not in `moved`, so `c.titles.add('dean')` still makes it a title everywhere. The gap, accepted because v1's one bit per word cannot carry three readings: adding a moved word in v1 restores the default, the phrase reading, and not 2.6.0's title everywhere, which a caller gets from the 2.0 API (`Lexicon.default().add(titles={'king'})`). Keyed on the compiled sets rather than a hand list, so a later batch that moves another 2.6.0 title (`sultan`, `sheikh`) is covered with no edit, and `tests/v2/test_config_shim.py` sweeps every moved word by the same derivation: membership, set operators, `remove`, `discard`, re-adding, a replaced list, pickle, deepcopy and copy, 37 of its 39 rows failing on master `6ba78d0d` (the two that pass are the non-empty guard and `dean`'s recipe), with two rows added in review, a spelling Lexicon folds onto a moved word and a replaced list. The default parse does not move: the snapshot, the CLDR, GND and native agreement records and the facade case rows pass unchanged. diff --git a/nameparser/_config_shim.py b/nameparser/_config_shim.py index 0b0f4886a..1dbeb73ba 100644 --- a/nameparser/_config_shim.py +++ b/nameparser/_config_shim.py @@ -1092,9 +1092,12 @@ def matchable(field: str) -> frozenset[str]: # v1 can say "a title" or "not a title", and "a title" now means # the phrase reading. A word 2.6.0 never listed (`dean`) is not # in `moved`, so adding it is still a title everywhere, the 1.4 - # recipe the phrase test honours below. + # recipe the phrase test honours below. Compared folded, as the + # two cross-set computations below are (#582), so a spelling + # Lexicon folds onto a moved word (`king。`) is read as it is. moved = TITLES_2_6_0 & TITLES_AMBIGUOUS - phrase_only = ((TITLES_AMBIGUOUS - moved) | (moved & titles) + kept_moved = frozenset(w for w in titles if _normalize(w) in moved) + phrase_only = ((TITLES_AMBIGUOUS - moved) | kept_moved if titles else frozenset()) first_name_titles = matchable("first_name_titles") non_given = matchable("non_first_name_prefixes") @@ -1161,7 +1164,7 @@ def matchable(field: str) -> frozenset[str]: # v1 also had, later, at capitalize(). try: lexicon = Lexicon( - titles=titles - moved, + titles=titles - kept_moved, # TRANSLATE, do not filter. The two versions build the same # lookup key differently: v1 joins the raw title run and # then applies lc(), which strips only the whole string's diff --git a/tests/v2/test_config_shim.py b/tests/v2/test_config_shim.py index fc26ccfac..bbd106c8d 100644 --- a/tests/v2/test_config_shim.py +++ b/tests/v2/test_config_shim.py @@ -1427,6 +1427,27 @@ def test_a_replaced_titles_list_holding_a_moved_title_reads_it_by_phrase() -> No assert word in lexicon.titles_ambiguous +def test_a_moved_title_spelled_with_a_full_stop_reads_by_its_phrase() -> None: + # compared folded (#582): Lexicon folds 'king。' onto 'king', so it + # is read as the listed spelling is, by the phrase + c = Constants() + c.titles.remove("king") + c.titles.add("king。") + assert HumanName("King Curtis", constants=c).first == "King" + assert HumanName("Hon. King Smith", constants=c).title == "Hon. King" + + +def test_a_replaced_titles_list_keeps_the_phrase_reading_of_unlisted_words( +) -> None: + # decisions/H9's Standing line: a replaced list keeps the phrase + # reading of the words 2.6.0 never listed + c = Constants() + c.titles.clear() + c.titles.add("dr") + assert HumanName("Dean of Students Jane Smith", constants=c).title == ( + "Dean of Students") + + def test_a_v1_title_never_listed_by_2_6_0_is_a_title_everywhere() -> None: # the 1.4 recipe for the phrase-only words 2.6.0 never listed c = Constants() From c466fe4208a610fbaf352b6e4b653c972af165b4 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Mon, 26 Oct 2026 21:52:01 -0700 Subject: [PATCH 3/3] Compare the title phrase vocabulary against folded v1 spellings (#752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second review of the fold found it narrowed at the intersections: 'wing。' kept its phrase reading but lost its modifier one, so 'Wing Commander Smith' read no title. Fold all three intersections, and sweep the full-stop spelling over every moved word. Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/H9-title-phrase.md | 2 +- nameparser/_config_shim.py | 11 +++++++---- tests/v2/test_config_shim.py | 18 ++++++++++++------ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/design/decisions/H9-title-phrase.md b/docs/design/decisions/H9-title-phrase.md index 176492b00..a7a081d84 100644 --- a/docs/design/decisions/H9-title-phrase.md +++ b/docs/design/decisions/H9-title-phrase.md @@ -57,4 +57,4 @@ - MEASURED (2026-10-26, against master `957509d1`, py3.11, every local check with `--base`). PSC (1,039,174 scored): 58 records fixed, none broken, 5 changed. fec-individuals 73 moved, all fixes: `AA, KING …` given names (51) and `KING AA, AA` family parts that H6 had peeled (22). ctgov-officials 12, all fixes: officials whose given name is King (`King A. Aa`), and one whose credential master read as the given name (`Aa King, AA Aa` → given 'Aa', family 'King', the credentials suffixes). tse-donors, ukec-individuals, jrc-names and nepal-candidates pairs (0 now alike, 0 apart: both spellings of a person move together), CLDR and GND agreement do not move. The committed corpora move 11 names before the rules examples were rewritten: the three royal rows, `King Curtis` (#348's example, fixed), `King Smith, John` and `Mary Jane King.` (1.4.0's readings), a maiden clause, and four that only lose H4's `title-or-name`, the word no longer being title vocabulary (`Dr. King`, `Dr King Jr`, `Dr. King MD`; `Smith and King` reports `given-or-family` instead). Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local fec-individuals --base ` (and `ctgov-officials`). -- **2026-10-26 #752, the v1 title list — A v1 `Constants().titles` HOLDS EVERY TITLE 2.6.0 LISTED THAT IS STILL A TITLE OF EITHER KIND.** The review of phase 2j's fifth and sixth batches found that a word a batch moved from `titles` to `titles_ambiguous` also left v1's default `titles`, which the shim copied from TITLES: `Constants().titles.remove('king')` raised `KeyError` and `'king' in Constants().titles` was False, where 2.6.0 accepted the removal and answered True. Measured against the 2.6.0 wheel (`PYTHONSAFEPATH=1`, `nameparser.__file__` asserted on both sides), word by word over all nine v1 set attributes and the `capitalization_exceptions` keys: `titles` alone had lost words (`bard`, `buddha`, `gyani`, `king`, `mahdi`, `siddha`, `wing`), and `suffix_not_acronyms` had gained the seven agnomens and `fils` of phase 2h, a vocabulary addition its release bullet records. `suffix_acronyms_ambiguous` (derived in phase 2a), `given_name_abbreviations` (2k) and the phase 2h sets have no v1 manager that lost a word. The translation: `nameparser/config/_titles_2_6_0.py` freezes 2.6.0's TITLES, generated from the wheel and never edited, and v1's default `titles` is TITLES with every 2.6.0 title now in `titles_ambiguous` added back (`moved`). `Constants._build_snapshot` passes a moved word to `titles_ambiguous` while the caller keeps it, so a default `Constants` still translates to `Lexicon.default()`, and passes it to neither once removed, which turns its modifier, style and phrase readings off with it, as removing any title does: `c.titles.remove('king')` then reads `Hon. King Smith` as title 'Hon.', given 'King', 2.6.0's reading after the same removal. A word 2.6.0 never listed (`dean`, `justice`, `grace`, `major`, `marshal`) is not in `moved`, so `c.titles.add('dean')` still makes it a title everywhere. The gap, accepted because v1's one bit per word cannot carry three readings: adding a moved word in v1 restores the default, the phrase reading, and not 2.6.0's title everywhere, which a caller gets from the 2.0 API (`Lexicon.default().add(titles={'king'})`). Keyed on the compiled sets rather than a hand list, so a later batch that moves another 2.6.0 title (`sultan`, `sheikh`) is covered with no edit, and `tests/v2/test_config_shim.py` sweeps every moved word by the same derivation: membership, set operators, `remove`, `discard`, re-adding, a replaced list, pickle, deepcopy and copy, 37 of its 39 rows failing on master `6ba78d0d` (the two that pass are the non-empty guard and `dean`'s recipe), with two rows added in review, a spelling Lexicon folds onto a moved word and a replaced list. The default parse does not move: the snapshot, the CLDR, GND and native agreement records and the facade case rows pass unchanged. +- **2026-10-26 #752, the v1 title list — A v1 `Constants().titles` HOLDS EVERY TITLE 2.6.0 LISTED THAT IS STILL A TITLE OF EITHER KIND.** The review of phase 2j's fifth and sixth batches found that a word a batch moved from `titles` to `titles_ambiguous` also left v1's default `titles`, which the shim copied from TITLES: `Constants().titles.remove('king')` raised `KeyError` and `'king' in Constants().titles` was False, where 2.6.0 accepted the removal and answered True. Measured against the 2.6.0 wheel (`PYTHONSAFEPATH=1`, `nameparser.__file__` asserted on both sides), word by word over all nine v1 set attributes and the `capitalization_exceptions` keys: `titles` alone had lost words (`bard`, `buddha`, `gyani`, `king`, `mahdi`, `siddha`, `wing`), and `suffix_not_acronyms` had gained the seven agnomens and `fils` of phase 2h, a vocabulary addition its release bullet records. `suffix_acronyms_ambiguous` (derived in phase 2a), `given_name_abbreviations` (2k) and the phase 2h sets have no v1 manager that lost a word. The translation: `nameparser/config/_titles_2_6_0.py` freezes 2.6.0's TITLES, generated from the wheel and never edited, and v1's default `titles` is TITLES with every 2.6.0 title now in `titles_ambiguous` added back (`moved`). `Constants._build_snapshot` passes a moved word to `titles_ambiguous` while the caller keeps it, so a default `Constants` still translates to `Lexicon.default()`, and passes it to neither once removed, which turns its modifier, style and phrase readings off with it, as removing any title does: `c.titles.remove('king')` then reads `Hon. King Smith` as title 'Hon.', given 'King', 2.6.0's reading after the same removal. A word 2.6.0 never listed (`dean`, `justice`, `grace`, `major`, `marshal`) is not in `moved`, so `c.titles.add('dean')` still makes it a title everywhere. The gap, accepted because v1's one bit per word cannot carry three readings: adding a moved word in v1 restores the default, the phrase reading, and not 2.6.0's title everywhere, which a caller gets from the 2.0 API (`Lexicon.default().add(titles={'king'})`). Keyed on the compiled sets rather than a hand list, so a later batch that moves another 2.6.0 title (`sultan`, `sheikh`) is covered with no edit, and `tests/v2/test_config_shim.py` sweeps every moved word by the same derivation: membership, set operators, `remove`, `discard`, re-adding, a replaced list, pickle, deepcopy and copy, 37 of its 39 rows failing on master `6ba78d0d` (the two that pass are the non-empty guard and `dean`'s recipe), with rows added in review for a spelling Lexicon folds onto a moved word (`king。`, `wing。`: the moved test and the modifier, style and phrase intersections compare folded spellings, #582's rule, the second review finding that folding the first alone lost `Wing Commander`) and for a replaced list's unlisted words. The default parse does not move: the snapshot, the CLDR, GND and native agreement records and the facade case rows pass unchanged. diff --git a/nameparser/_config_shim.py b/nameparser/_config_shim.py index 1dbeb73ba..6833d86aa 100644 --- a/nameparser/_config_shim.py +++ b/nameparser/_config_shim.py @@ -1099,6 +1099,10 @@ def matchable(field: str) -> frozenset[str]: kept_moved = frozenset(w for w in titles if _normalize(w) in moved) phrase_only = ((TITLES_AMBIGUOUS - moved) | kept_moved if titles else frozenset()) + # ...and the three intersections with the phrase vocabulary read + # the same folded spellings, or a folded moved word kept its + # phrase reading but lost its modifier one (`wing。`) + any_title = frozenset(_normalize(w) for w in titles | phrase_only) first_name_titles = matchable("first_name_titles") non_given = matchable("non_first_name_prefixes") ambiguous_acronyms = matchable("suffix_acronyms_ambiguous") & acronyms @@ -1293,12 +1297,11 @@ def matchable(field: str) -> frozenset[str]: # something it says. A moved word reads by whether the # replaced list holds it, as any v1 title does titles_ambiguous=phrase_only, - title_modifiers=TITLE_MODIFIERS & (titles | phrase_only), - title_styles=TITLE_STYLES & (titles | phrase_only), + title_modifiers=TITLE_MODIFIERS & any_title, + title_styles=TITLE_STYLES & any_title, title_phrases=frozenset( p for p in TITLE_PHRASES - if all(w in titles or w in phrase_only - for w in p.split(" "))), + if all(w in any_title for w in p.split(" "))), # TupleManager is dict[str, object] (v1 parity: values were # never statically str-typed); every real entry is a str, # same assumption _DelimiterManager's sentinel lookup makes. diff --git a/tests/v2/test_config_shim.py b/tests/v2/test_config_shim.py index bbd106c8d..d69c4616d 100644 --- a/tests/v2/test_config_shim.py +++ b/tests/v2/test_config_shim.py @@ -1427,14 +1427,20 @@ def test_a_replaced_titles_list_holding_a_moved_title_reads_it_by_phrase() -> No assert word in lexicon.titles_ambiguous -def test_a_moved_title_spelled_with_a_full_stop_reads_by_its_phrase() -> None: +@pytest.mark.parametrize("word", _MOVED_TITLES) +def test_a_moved_title_spelled_with_a_full_stop_reads_as_the_default( + word: str) -> None: # compared folded (#582): Lexicon folds 'king。' onto 'king', so it - # is read as the listed spelling is, by the phrase + # reads as the listed spelling does, by the phrase, a modifier's + # reading included (`Wing Commander Smith`) c = Constants() - c.titles.remove("king") - c.titles.add("king。") - assert HumanName("King Curtis", constants=c).first == "King" - assert HumanName("Hon. King Smith", constants=c).title == "Hon. King" + c.titles.remove(word) + c.titles.add(f"{word}。") + assert c._snapshot()[0] == Lexicon.default() + assert HumanName(f"{word.title()} Smith", constants=c).first == ( + word.title()) + assert word.title() in HumanName( + _phrase_probe(word), constants=c).title def test_a_replaced_titles_list_keeps_the_phrase_reading_of_unlisted_words(