From c44d92e3cd7318c9916206bb808aa18e60ba771f Mon Sep 17 00:00:00 2001 From: IMGillusion Date: Thu, 10 Sep 2026 12:45:55 +0800 Subject: [PATCH 1/4] fix(#459): repair one-case suffix acronyms to all-caps Gated on the SUFFIX role; exceptions map consulted first so md/phd keep their special casing. R4 example added; corpus regenerated. --- docs/design/rules.md | 1 + nameparser/_render.py | 10 ++++++++ tests/test_capitalization.py | 33 +++++++++++++++++++++++++++ tools/differential/corpus_rules.jsonl | 1 + 4 files changed, 45 insertions(+) diff --git a/docs/design/rules.md b/docs/design/rules.md index ba455a15..e60a8fe4 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -1676,6 +1676,7 @@ R4. Rationale: case repair is a display concern, applied only on "ANH DO" → capitalized="Anh Do" "anh van do" → capitalized="Anh Van Do" "john smith phd" → capitalized="John Smith Ph.D." + "john smith mba" → capitalized="John Smith MBA" "juan de la vega" → capitalized="Juan de la Vega" · boundary Accepted: the clause reaches a part the parser read. A field spliced in as raw text after the parse carries no reading of its diff --git a/nameparser/_render.py b/nameparser/_render.py index bf3786ca..6952af19 100644 --- a/nameparser/_render.py +++ b/nameparser/_render.py @@ -247,6 +247,16 @@ def _cap_word(word: str, role: Role, tags: frozenset[str], exception = lex.capitalization_exceptions_map.get(key) if exception is not None: return exception + # A credential acronym the exceptions map doesn't carry (mba, jd, + # qc, mp, ...) is an initialism, not a word to title-case. The map + # only special-cases the five that need non-all-caps spelling + # (md, phd, ii, iii, iv); every other suffix_acronyms entry is + # all-caps by definition, so a one-case name repairs to the + # acronym's caps instead of 'Mba' (#459). Gated on the SUFFIX role + # so a word that is a family name only happens to be in the + # vocabulary (anh van DO) still repairs as an ordinary name word. + if role is Role.SUFFIX and normalized.replace(".", "") in lex.suffix_acronyms: + return word.upper() if _MAC.match(word): return _MAC.sub( lambda m: m.group(1).capitalize() + m.group(2).capitalize(), diff --git a/tests/test_capitalization.py b/tests/test_capitalization.py index 4c8f0576..0ba500a2 100644 --- a/tests/test_capitalization.py +++ b/tests/test_capitalization.py @@ -110,6 +110,39 @@ def test_capitalize_suffix_acronym_with_dots(self) -> None: hn.capitalize() self.assertEqual(hn.suffix, 'M.D.') + # A credential acronym the exceptions map doesn't carry is an + # initialism, so a one-case suffix repairs to all-caps instead of + # title-case (issue #459). + def test_capitalize_suffix_acronym_is_all_caps(self) -> None: + for src, expect in [ + ('JOHN SMITH MBA', 'John Smith MBA'), + ('john smith jd', 'John Smith JD'), + ('JOSE LUIS CPA', 'Jose Luis CPA'), + ('john smith pmp', 'John Smith PMP'), + ]: + hn = HumanName(src) + hn.capitalize() + self.m(str(hn), expect, hn) + + # The exceptions map's five keep their special casing; the new + # all-caps path must not shadow them (#459). + def test_capitalize_exceptions_still_win_over_acronyms(self) -> None: + for src, expect in [ + ('john smith md', 'John Smith M.D.'), + ('john smith phd', 'John Smith Ph.D.'), + ]: + hn = HumanName(src) + hn.capitalize() + self.m(str(hn), expect, hn) + + # A word in the acronym vocabulary that parses as a family name + # still repairs as an ordinary name word, not an acronym (#459). + def test_capitalize_family_name_in_acronym_vocab_stays_title_case(self) -> None: + hn = HumanName('anh van do') + hn.capitalize() + self.m(str(hn), 'Anh Van Do', hn) + + # Leaving already-capitalized names alone def test_no_change_to_mixed_chase(self) -> None: hn = HumanName('Shirley Maclaine') diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index b30b894f..79c7a7c1 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -247,6 +247,7 @@ "de la Vega y Santos Juan" "de los Santos" "ibn Awf abdul Rahman" +"john smith mba" "john smith phd" "juan de la vega" "juan mcdonald" From 460a5258dcf849f344e805c61a34bacbcf0e25f4 Mon Sep 17 00:00:00 2001 From: IMGillusion Date: Sun, 13 Sep 2026 22:20:20 +0800 Subject: [PATCH 2/4] fix(#459): address review -- drop ph, swap dphil demo, amend rules/decisions/release-log, correct the comment - docs/customize.rst: dds->dphil demo (dds now handled by default); phd parenthetical -> Ph.D. - config/suffixes.py: ph left suffix_acronyms (read all-caps on the default path; 13 force movers back to Ph. D.) - rules.md R4: all-caps clause sentence + john smith jr boundary line; regenerated corpus_rules.jsonl - decisions.md R4: #459 bullet (scope, reach 5/11-71, recompute recipe, accepted costs) - release_log.rst 2.3.0: behavior bullet - _render.py comment: bsc/msc mixed-case + ii/iii/iv are suffix_words; test blank line --- docs/customize.rst | 14 +++++++------- docs/design/decisions.md | 4 ++++ docs/design/rules.md | 9 ++++++++- docs/release_log.rst | 2 ++ nameparser/_render.py | 17 +++++++++++------ nameparser/config/suffixes.py | 1 - tests/test_capitalization.py | 1 - tools/differential/corpus_rules.jsonl | 1 + 8 files changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/customize.rst b/docs/customize.rst index b3231724..ad20b402 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -157,7 +157,7 @@ Fixing the case of a particular word ``capitalization_exceptions`` is the one pair-valued field — each entry maps a lowercase key to its exact-cased replacement (``"phd"`` → -``"PhD"``), so it isn't a fit for ``add()``/``remove()``. Change it with +``"Ph.D."``), so it isn't a fit for ``add()``/``remove()``. Change it with ``dataclasses.replace()`` instead, and pass the result to ``capitalized()``: @@ -165,17 +165,17 @@ maps a lowercase key to its exact-cased replacement (``"phd"`` → >>> import dataclasses >>> from nameparser import parse - >>> str(parse("jane smith dds").capitalized()) - 'Jane Smith Dds' + >>> str(parse("jane smith dphil").capitalized()) + 'Jane Smith Dphil' >>> default = Lexicon.default() >>> lex = dataclasses.replace( ... default, ... capitalization_exceptions=tuple(default.capitalization_exceptions) - ... + (("dds", "DDS"),)) - >>> str(parse("jane smith dds").capitalized(lex)) - 'Jane Smith DDS' + ... + (("dphil", "DPhil"),)) + >>> str(parse("jane smith dphil").capitalized(lex)) + 'Jane Smith DPhil' -Note the ``tuple(...) + ...``: assigning a bare ``(("dds", "DDS"),)`` +Note the ``tuple(...) + ...``: assigning a bare ``(("dphil", "DPhil"),)`` would *replace* the default exceptions rather than extend them, so ``"phd"`` and the rest would stop being fixed. diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 3718efab..caa94c58 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -1170,6 +1170,10 @@ R3's earlier history is under `decisions.md#R2`, which this entry does not repea - 2026-08-29 — WHY THE BOUNDARY WENT UNNOTICED UNTIL #407, which is where a future reader should look for it. For an ALL-PARTICLE part the other three tag-driven views give the same answer through `replace()` and `revise()` alike: measured over `de la`, `van der`, `do`, `de` and `van de la`, all five agree on `family_particles=''`, on a `family_base` holding the whole part, and on initials from every word. They converge because an UNTAGGED part and a MARKED all-particle part reach the same place by different routes — untagged, no word is recognized as a particle; marked, none is ACTING as one — and all three views only ask which words are particles. Case repair is the one view that asks a second question, since it must also decide whether to lowercase, so it is where the two routes first come apart. The mirror case confirms the reading: on a MIXED part the convergence is the other way round — `de la vega` and `van der berg` diverge in all three views between `replace()` and `revise()` (`replace()` reports particles `''` and base `'de la vega'` where `revise()` reports `'de la'` and `'vega'`) and AGREE on case repair, R4's all-particle clause not reaching them. So before #407 the distinction was invisible on exactly the shape the clause is about, and visible only on shapes the clause does not govern. +- 2026-09-13 #459 — DECIDED: a credential acronym the exceptions map does not carry is an initialism, so a single-case word the parse put in the SUFFIX role from `suffix_acronyms` repairs to its all-caps spelling rather than a title-cased one (the clause in `_render._cap_word`, after the exceptions-map lookup and before the Mac/Mc rule). The scope is narrow on purpose. The exceptions map is consulted first, so the five entries that need a non-all-caps spelling (`md` → M.D., `phd` → Ph.D., and the roman numerals) keep theirs and the clause never touches them. The repair is gated on the SUFFIX role, so a word that is in the acronym vocabulary but parsed as an ordinary name word (`anh van do` → `Anh Van Do`) still repairs as that name word -- the gate is the whole reason the fix is safe on surnames that share a spelling with a credential. The wider design the issue proposed (letter masks, `md` leaving the map, the given-role half of `QC MP`) stays on the rescoped #459; this clause is the narrow part #459 already accepts, not a re-litigation of those. +Reach, population first: measured on the PR head over the differential corpora (1143 names), 122 names carry acronym vocabulary in a suffix token and 11 of those are written in a single case, so the honest reach is 5 of the 11 eligible on the default `capitalized()` path and 71 under `force=True`. Recompute by swapping the pre-change `_cap_word` -- the one without the clause, `git show d37b8ec:nameparser/_render.py` -- in for the changed one in a single process, then diffing `capitalized()` and `force=True` over the deduped corpora (the 1143-name population is the differential corpora at the released baselines). +Accepted costs, deferred to the rescoped #459 rather than relitigated here: the all-caps default reaches words conventionally written mixed-case -- `bsc`/`msc` read `BSC`/`MSC` under `force=True`, and `Dr. med. univ. Margit Popp, MSc` is a corpus name that reads `MSC` -- which the letter-mask design #459 defers is meant to recover; `ii`/`iii`/`iv` are `suffix_words` rather than acronyms and need the exceptions map precisely because the clause would not see them there; and because the ambiguous five (`ba`, `do`, `ed`, `jd`, `ma`) are in `suffix_acronyms`, the clause moves WHICH parse triggers the repair rather than preventing it -- `john smith ed` → `John Smith ED`, `john smith ba` → `BA`, and `smith, ms.` → `MS.` on the default path. That is the same #342/#454-class cost #459 already accepts, and the alternative (reading classify's `vocab:suffix` tag) was measured and costs `jd` → `Jd`, so the role gate is the right instrument. + ### R5 — the case-repair gate - 2026-08-29 (#407 arc) — EXTRACTION, not a decision: the parser is untouched. The two halves of R5 have separate provenance, and conflating them is easy enough that the first draft of this entry did. The REFUSAL — repair skips any name already carrying more than one case — is older than the git history: `git log -S "name == name.upper() or name == name.lower()" --reverse` bottoms out at 45a1539 (2011-02-03), the initial import from svn, where `capitalize()` already opens with that guard and a bare `return`. (A path-filtered search answers 280895b instead, the same-day commit that moved the module into `nameparser/`; the code did not change there.) The OVERRIDE is bf1e0a5, 2016-06-02, which did not add the refusal but wrapped it — `if not (name == name.upper() or ...)` became `if not force and not (...)` — and shipped in 0.4.0 (June 2, 2016; its own release-log line is docs/release_log.rst under that heading). So R5's statement as a whole holds from 0.4.0 on. rules.md had never said any of it, though bf1e0a5's diff shows the API docstring already did: "It will not adjust the case of names entered in mixed case" was there before that commit edited around it. The gap was rules.md's alone. Evidence, measured on the released 1.4.0 wheel — the last v1 release and one of the differential baselines, so a natural thing to measure against and not a release that introduced anything here — and re-measured on this branch today, facade and core agreeing: `HumanName('Shirley Maclaine').capitalize()` leaves `'Shirley Maclaine'` — mixed case, wrong, and kept — while the same name under `force=True` gives `'Shirley MacLaine'`; `HumanName('Juan McDonald').capitalize()` leaves `'Juan McDonald'`. rules.md's own preamble classifies behavior in this position as "pinned-but-undocumented — an extraction gap to close, not a specification", which is why the fix lands in the document rather than in `_render.py`. diff --git a/docs/design/rules.md b/docs/design/rules.md index e60a8fe4..74430151 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -1670,13 +1670,20 @@ R4. Rationale: case repair is a display concern, applied only on conventions rather than by the bearer's. A spelling written in a single case is repaired even where its bearer meant it, because nothing in the text marks it as a choice; where the text does - mark one, R5 defers to it. + mark one, R5 defers to it. A credential acronym the exceptions map + does not carry is an initialism, so a single-case word the parse + put in the suffix role from the acronym vocabulary repairs to its + all-caps spelling rather than a title-cased one; a word in that + vocabulary that parsed as an ordinary name word repairs as that + name word, and a suffix word that is not an acronym -- the + generational `jr`, `sr` -- keeps its title case. "juan mcdonald" → capitalized="Juan McDonald" "Juan McDonald" → capitalized_forced="Juan McDonald" "ANH DO" → capitalized="Anh Do" "anh van do" → capitalized="Anh Van Do" "john smith phd" → capitalized="John Smith Ph.D." "john smith mba" → capitalized="John Smith MBA" + "john smith jr" → capitalized="John Smith Jr" · boundary "juan de la vega" → capitalized="Juan de la Vega" · boundary Accepted: the clause reaches a part the parser read. A field spliced in as raw text after the parse carries no reading of its diff --git a/docs/release_log.rst b/docs/release_log.rst index bff433aa..51765f99 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -6,6 +6,8 @@ Release Log **Behavior Changes** + - **Repair a credential acronym the case-repair exceptions map does not carry to all-caps instead of title-casing it.** ``HumanName("JOHN SMITH MBA").capitalize()`` gives ``John Smith MBA`` where every release since 1.4.0 gave ``John Smith Mba``; ``john smith jd`` gives ``John Smith JD``. The repair is keyed on the word having parsed in the suffix role from the acronym vocabulary, so a word that is an ordinary name merely sharing a spelling with an acronym is untouched, and the exceptions map still wins first -- ``john smith md`` gives ``John Smith M.D.`` and ``john smith phd`` gives ``John Smith Ph.D.`` as before, and the generational ``jr`` is unaffected (``john smith jr`` gives ``John Smith Jr``). The given-name half of a mixed run is unchanged, so ``QC MP`` gives ``Qc MP`` with the ``QC`` (given role) still title-cased and only the ``MP`` (suffix role) repaired. Five names move in the differential corpora on the default ``capitalize()`` path (seventy-one under ``force=True``); no other field view moves. See the ``R4`` entry of ``docs/design/decisions.md`` (#459) + - **Fix HumanName.initials() dropping a middle- or family-group initial that is also a one-letter conjunction.** ``HumanName("Scott E. Werner").initials()`` gives ``S. E. W.`` again where 2.0.0 through 2.2.0 gave ``S. W.``; ``Juan Y. Garcia`` and a bare ASCII capital ``John E Smith`` likewise. v1 excluded initial-shaped words from its conjunction test and the 2.0 facade had not; ``parse(...).initials()`` was already right and is unchanged. A bare lowercase ``john e smith`` still reads the ``e`` as the connective. See the ``R3`` entry of ``docs/design/decisions.md`` (closes #462) - **Record a 2.0.0 change to HumanName.initials() that no release note had classified:** since 2.0.0 the facade initials each WORD of a name part, where 1.4.0 initialed a joined run as one group -- ``HumanName("Juan Velasquez y Garcia").initials()`` is ``J. V. G.`` and was ``J. V G.``; ``Abdul Salam Hassan`` is ``A. S. H.`` and was ``A S. H.``. Nothing changes in 2.3.0; the differential gate now compares ``initials()`` (#484) and this is what it found. See the ``differential-ledger, the initials view`` entry of ``docs/design/decisions.md`` diff --git a/nameparser/_render.py b/nameparser/_render.py index 6952af19..77976a09 100644 --- a/nameparser/_render.py +++ b/nameparser/_render.py @@ -248,12 +248,17 @@ def _cap_word(word: str, role: Role, tags: frozenset[str], if exception is not None: return exception # A credential acronym the exceptions map doesn't carry (mba, jd, - # qc, mp, ...) is an initialism, not a word to title-case. The map - # only special-cases the five that need non-all-caps spelling - # (md, phd, ii, iii, iv); every other suffix_acronyms entry is - # all-caps by definition, so a one-case name repairs to the - # acronym's caps instead of 'Mba' (#459). Gated on the SUFFIX role - # so a word that is a family name only happens to be in the + # qc, mp, ...) is an initialism, not a word to title-case: a one- + # case name repairs to the acronym's caps instead of 'Mba' (#459). + # The exceptions map is consulted first and holds the entries that + # spell differently -- md -> M.D. and phd -> Ph.D. (the generational + # ii/iii/iv are suffix_words, not acronyms, and ride the map because + # str.capitalize() would give 'Ii'). The all-caps default is the + # right call for an initialism; its cost is that an acronym + # conventionally written mixed-case (bsc, msc) reads all-caps here + # (BSc -> BSC under force) rather than mixed, which the letter-mask + # design deferred to #459 is meant to recover. Gated on the SUFFIX + # role so a word that is a family name only happens to be in the # vocabulary (anh van DO) still repairs as an ordinary name word. if role is Role.SUFFIX and normalized.replace(".", "") in lex.suffix_acronyms: return word.upper() diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 6ce0be9d..417cead5 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -814,7 +814,6 @@ 'pfmp', 'pg', 'pgmp', - 'ph', 'pharmd', 'phc', 'phd', diff --git a/tests/test_capitalization.py b/tests/test_capitalization.py index 0ba500a2..2abf27c8 100644 --- a/tests/test_capitalization.py +++ b/tests/test_capitalization.py @@ -142,7 +142,6 @@ def test_capitalize_family_name_in_acronym_vocab_stays_title_case(self) -> None: hn.capitalize() self.m(str(hn), 'Anh Van Do', hn) - # Leaving already-capitalized names alone def test_no_change_to_mixed_chase(self) -> None: hn = HumanName('Shirley Maclaine') diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 79c7a7c1..9f70f698 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -247,6 +247,7 @@ "de la Vega y Santos Juan" "de los Santos" "ibn Awf abdul Rahman" +"john smith jr" "john smith mba" "john smith phd" "juan de la vega" From 9eec6b42803ae40623f0bb261461889e10e3dd0f Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 22 Sep 2026 00:52:08 -0700 Subject: [PATCH 3/4] docs(#459): the R4 record carries the merged tree's numbers, and the ph removal is on the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer fix-up on PR #521 after the merge from master, the eight items of the 2026-09-16 review comment: - decisions.md R4: the reach is remeasured on the merged tree with the recipe's comparator named (master's `_cap_word` at 23e52dc6), and the two earlier snapshots are explained rather than replaced -- the `ph` removal took 13 forced movers back to `Ph. D.`, and the rows PRs #530/#532/#534 added account for the rest of the drift. The exceptions map's job is stated as the spellings `str.capitalize()` gets wrong, not "non-all-caps" (II/III/IV are all-caps); the precedence over Mac/Mc and the role-not-tag reach of the gate are recorded. - decisions.md: an `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's, so a wordlist sweep does not put the fragment back. - rules.md R4: the non-acronym clause is qualified by the exceptions map (`john smith ii` -> `II`), the acronym repair's precedence over the Mac/Mc convention is stated and pinned by a `john smith mcse` row (`McSe` without it), and the Accepted paragraph names which clause it is about and states the acronym repair's contrast on a spliced suffix. - release_log.rst: the bullet moves from the shipped 2.3.0 section to 2.4.0 with the current digits, and `ph` leaving the set gets its own bullet in the rai/cha shape. - corpus_rules.jsonl regenerated (three new R4 rows). Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 11 ++++++++--- docs/design/rules.md | 22 ++++++++++++++-------- docs/release_log.rst | 6 ++++-- tools/differential/corpus_rules.jsonl | 1 + 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index d2c84630..28f7557e 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -579,6 +579,10 @@ Excluded (SUFFIX_ACRONYMS — esq, removed 2026-09-08, #316/#489 bundle): - esq is OUT of SUFFIX_ACRONYMS and must not be put back by a sweep that finds "Esq." parsing and assumes the acronym set is what carries it. The SUFFIX_WORDS membership carries every single-token spelling — Esq, Esq., ESQ, esq — and stops being inert with the acronym entry gone. What the acronym entry uniquely covered is the multi-dot spelling, so "John Smith E.S.Q." reads family E.S.Q. where every release since 1.4.0 read suffix; that is the whole of the cost and it is a spelling nobody writes. The criterion is #suffix-acronym-collisions', asked of the WORD rather than of the frequency: does the entry describe the word or the machinery. Esquire is a contraction, not an initialism, so the initialism set was never its home; it arrived in the 2019-12-11 bulk Wikipedia post-nominal import (af5bdab, #93) and was never reviewed. This SUPERSEDES the dual-membership entry that stood here from the 2.2 cycle, which called the acronym membership load-bearing and the word membership inert and concluded that the singleton "is why the two sets cannot carry a disjointness assert". The assert now exists — `not (SUFFIX_ACRONYMS & SUFFIX_WORDS)` in suffixes.py's guard block — and it is what the removal buys: the two sets normalize differently (the word test strips edge periods, the acronym test strips all of them), so a word in both is matched by two rules and which one fired is unreadable from outside. AGENTS.md's esq gotcha, the two defending comment blocks in suffixes.py, the `# NOT asserted:` note and the `suffix_acronym_multidot_spelling` case row all retire with it; a row pinning `John Smith E.S.Q.` → family replaces the last of those. Measured over the corpus glob as it stood at the change (1123 distinct names, 1263 rows; the docs commit that follows adds this bundle's rules.md examples and makes it 1136 and 1284): exactly one moves, `John Smith E.S.Q.` itself, and it diffs at every baseline. Classified a behavior change, a 2.x parity break, on all four ledgers. +Excluded (SUFFIX_ACRONYMS — ph, removed 2026-09-13, #459/#521): + +- ph is OUT of SUFFIX_ACRONYMS and must not be put back by a sweep that finds "Ph. D." parsing and assumes the acronym set is what carries it. The split spelling is merged by group's own rule (the `PH`/`D` regexes of `_pipeline/_vocab.py`, v1's fix_phd; see #phd-merge), which never consults acronym membership, and the single-token spellings `phd`/`Ph.D.` are the `phd` entry's. The fragment arrived in the 2019-12-11 bulk Wikipedia post-nominal import (af5bdab, #93) and was never reviewed; what it uniquely did was let the merged `Ph. D.` token pass the acronym test on its first piece, which nothing read until #459's all-caps repair did — with `ph` in the set that repair read `john smith ph. d.` as `John Smith PH. D.` on the default path, not only under force. Measured at the removal (2026-09-11, 1143 corpus names): 0 names move on any role field at any baseline; under `capitalized()` 0 move and the 13 `PH. D.` shapes that the repair had moved under `force=True` go back to `Ph. D.`; `john smith phd` and `ph.d.` still give `Ph.D.`. The criterion is #suffix-acronym-collisions', asked of the WORD: the entry described the machinery, not a credential anyone writes alone. + ### suffix-acronym-collisions — the trailing-position collision class, decided (2026-09-07, #342/#454) Closes #342 (a wordlist question) and #454 (a rules.md question) together, because they are the same question asked of two different words and answering one without the other would leave the criterion half-stated. No parser code moves in either. Two existing forks carry the whole thing and both were confirmed on the pre-bundle tree with a throwaway override before any wordlist was edited: `Parser(lexicon=Lexicon.default().remove(suffix_acronyms={"rai","cha"}).add(suffix_acronyms_ambiguous={"ba"})).parse(text)`. @@ -1325,9 +1329,10 @@ R3's earlier history is under `decisions.md#R2`, which this entry does not repea - 2026-08-29 — WHY THE BOUNDARY WENT UNNOTICED UNTIL #407, which is where a future reader should look for it. For an ALL-PARTICLE part the other three tag-driven views give the same answer through `replace()` and `revise()` alike: measured over `de la`, `van der`, `do`, `de` and `van de la`, all five agree on `family_particles=''`, on a `family_base` holding the whole part, and on initials from every word. They converge because an UNTAGGED part and a MARKED all-particle part reach the same place by different routes — untagged, no word is recognized as a particle; marked, none is ACTING as one — and all three views only ask which words are particles. Case repair is the one view that asks a second question, since it must also decide whether to lowercase, so it is where the two routes first come apart. The mirror case confirms the reading: on a MIXED part the convergence is the other way round — `de la vega` and `van der berg` diverge in all three views between `replace()` and `revise()` (`replace()` reports particles `''` and base `'de la vega'` where `revise()` reports `'de la'` and `'vega'`) and AGREE on case repair, R4's all-particle clause not reaching them. So before #407 the distinction was invisible on exactly the shape the clause is about, and visible only on shapes the clause does not govern. -- 2026-09-13 #459 — DECIDED: a credential acronym the exceptions map does not carry is an initialism, so a single-case word the parse put in the SUFFIX role from `suffix_acronyms` repairs to its all-caps spelling rather than a title-cased one (the clause in `_render._cap_word`, after the exceptions-map lookup and before the Mac/Mc rule). The scope is narrow on purpose. The exceptions map is consulted first, so the five entries that need a non-all-caps spelling (`md` → M.D., `phd` → Ph.D., and the roman numerals) keep theirs and the clause never touches them. The repair is gated on the SUFFIX role, so a word that is in the acronym vocabulary but parsed as an ordinary name word (`anh van do` → `Anh Van Do`) still repairs as that name word -- the gate is the whole reason the fix is safe on surnames that share a spelling with a credential. The wider design the issue proposed (letter masks, `md` leaving the map, the given-role half of `QC MP`) stays on the rescoped #459; this clause is the narrow part #459 already accepts, not a re-litigation of those. -Reach, population first: measured on the PR head over the differential corpora (1143 names), 122 names carry acronym vocabulary in a suffix token and 11 of those are written in a single case, so the honest reach is 5 of the 11 eligible on the default `capitalized()` path and 71 under `force=True`. Recompute by swapping the pre-change `_cap_word` -- the one without the clause, `git show d37b8ec:nameparser/_render.py` -- in for the changed one in a single process, then diffing `capitalized()` and `force=True` over the deduped corpora (the 1143-name population is the differential corpora at the released baselines). -Accepted costs, deferred to the rescoped #459 rather than relitigated here: the all-caps default reaches words conventionally written mixed-case -- `bsc`/`msc` read `BSC`/`MSC` under `force=True`, and `Dr. med. univ. Margit Popp, MSc` is a corpus name that reads `MSC` -- which the letter-mask design #459 defers is meant to recover; `ii`/`iii`/`iv` are `suffix_words` rather than acronyms and need the exceptions map precisely because the clause would not see them there; and because the ambiguous five (`ba`, `do`, `ed`, `jd`, `ma`) are in `suffix_acronyms`, the clause moves WHICH parse triggers the repair rather than preventing it -- `john smith ed` → `John Smith ED`, `john smith ba` → `BA`, and `smith, ms.` → `MS.` on the default path. That is the same #342/#454-class cost #459 already accepts, and the alternative (reading classify's `vocab:suffix` tag) was measured and costs `jd` → `Jd`, so the role gate is the right instrument. +- 2026-09-13 #459 (landed 2026-09-22, PR #521) — DECIDED: a credential acronym the exceptions map does not carry is an initialism, so a single-case word the parse put in the SUFFIX role from `suffix_acronyms` repairs to its all-caps spelling rather than a title-cased one (the clause in `_render._cap_word`, after the exceptions-map lookup and before the Mac/Mc rule — the order is load-bearing, since `mcse` is in the acronym set and matches the Mac/Mc shape, and reads `MCSE` only because the acronym clause is asked first; rules.md#R4 states the precedence and pins it with a row). The scope is narrow on purpose. The exceptions map is consulted first, so its five entries — the spellings `str.capitalize()` gets wrong, `md` → M.D. and `phd` → Ph.D. because they are punctuated, and `ii`/`iii`/`iv` → II/III/IV because a numeral is written all-caps and they are `suffix_words` the acronym clause would never see — keep theirs and the clause never touches them. The repair is gated on the SUFFIX role, so a word that is in the acronym vocabulary but parsed as an ordinary name word (`anh van do` → `Anh Van Do`) still repairs as that name word -- the gate is the whole reason the fix is safe on surnames that share a spelling with a credential. Because it is the ROLE and not a parse tag, the gate also reaches a suffix spliced in as raw text: `parse("john smith").replace(suffix="mba").capitalized()` gives `MBA` like the parsed name does, which is the contrast R4's Accepted paragraph draws against the all-particle clause. The same PR removed `ph` from `suffix_acronyms`; see `Excluded (SUFFIX_ACRONYMS — ph ...)`, beside the esq block, for why. The wider design the issue proposed (letter masks, `md` leaving the map, the given-role half of `QC MP`) stays on the rescoped #459; this clause is the narrow part #459 already accepts, not a re-litigation of those. +Reach, population first, measured 2026-09-22 on the PR branch merged with master at 23e52dc6, over the deduped corpus glob (1338 distinct names): 178 names carry acronym vocabulary in a suffix token and 30 of those are written in a single case, and 22 move on the default `capitalized()` path, 119 under `force=True`. Recompute by exec'ing the pre-change `_cap_word` -- `git show 23e52dc6:nameparser/_render.py`, the master this branch merged, which carries #397/#461's generation gate but not this clause -- into `nameparser._render`'s namespace and swapping it in for the changed one in a single process, then diffing `str(capitalized(parse(n), None, force=...))` over the glob. The digits have moved twice and neither move is the clause's: the PR's first head (2026-09-11, `ph` still in the set, 1143 names) measured 5 default and 71 forced; the commit that dropped `ph` took the 13 `PH. D.` forced movers back to `Ph. D.` and left 111 in the population, 5 default and 58 forced; and the merge brought in the corpus rows of PR #530 (#289/#516), PR #532 (#531) and PR #534 (#533), whose single-case trailing `MA` credentials (`DOE, JOHN MA`, `jane doe nee smith ma`, `JOHN SMITH, MA`, the CJK `田中 太郎, MA` family; their mixed-case twins hold still under R5) account for 15 of the 17 further default movers; the other two are `STEVEN HARDMAN, MD, DO, DDS` and `john smith mcse`, the row this PR's own rules.md amendment adds (`McSe` under the pre-change function, which is the precedence claim above measured). The ratio is the stable claim: every single-case name with an acronym suffix the map does not carry moves, and nothing else does. +Accepted costs, deferred to the rescoped #459 rather than relitigated here: the all-caps default reaches words conventionally written mixed-case -- `bsc`/`msc` read `BSC`/`MSC` under `force=True`, and `Dr. med. univ. Margit Popp, MSc` is a corpus name that reads `MSC` under `force=True` only, its mixed case holding it back on the default path under R5 -- which the letter-mask design #459 defers is meant to recover; `ii`/`iii`/`iv` are `suffix_words` rather than acronyms and need the exceptions map precisely because the clause would not see them there; and because the ambiguous five (`ba`, `do`, `ed`, `jd`, `ma`) are in `suffix_acronyms`, the clause moves WHICH parse triggers the repair rather than preventing it -- `john smith ed` → `John Smith ED`, `john smith ba` → `BA`, and `smith, ms.` → `MS.` on the default path. That is the same #342/#454-class cost #459 already accepts, and the alternative (reading classify's `vocab:suffix` tag) was measured and costs `jd` → `Jd`, because `jd` carries `vocab:suffix-ambiguous` and not `vocab:suffix`, so the role gate is the right instrument. + - 2026-09-20 #461/#397 — THE GROUNDING MOVED, AND THEN A DEFECT THE GROUNDING EXPOSED. R4's sentence rested on R3 by name ("the carve-out R3 states for initials"), and R3's carve-out is conditional now while R4's is not, so the cross-reference is CUT and replaced by this rule's own reason: a connective that initials because it joins nothing is still not written the way a name is written. Measured 2026-09-20, plain and forced, over `juan y`, `john and jane smith`, `duke of edinburgh`, `juan de y`, `juan y garcia`, `JUAN Y GARCIA`, `josep carod i rovira`, `JOSEP CAROD I ROVIRA` and — under `add(particles={"y"})` — `Anh y Van`: all nine are byte-identical to the same call at the parent, re-measured after the repair below and still byte-identical, and `tests/test_capitalization.py` is green. WHAT THE PLAIN CALL DOES, stated over a population rather than over nine names, because "capitalized() does not move" was drafted for this bullet and is false as written. Swept 2026-09-20 over the 1558 non-empty corpus-union-cases names under eight configurations against the parent commit, `capitalized()` moves on SIX distinct names and every one of them is a name whose ROLES moved under the join — it renders different fields rather than treating a word differently. The correct statement is therefore: where only `initials()` moves, `capitalized()` is byte-identical; where the roles move, it follows them. WHAT THE FORCED CALL DID, AND THE DEFECT THAT WAS FOUND WRITING THIS RECORD. The sweep above was run for the PLAIN claim and turned up a second answer: `capitalized(force=True)` moved on ten further names whose roles, initials and plain repair all stayed put, every one a lower-case `i` — `parse("Carod i")` forced gave "Carod i" where 1.4.0 and 2.3.0 gave "Carod I", and so did "John Quincy Smith i", "Josep Carod i", "Lluis Carod i", "Josep Lluis Carod i III", "Josep Lluis Carod i V", "Carod y de Rovira i", "Rovira, Josep Carod i Jr.", "Carod i Rovira" and "Josep i Rovira". Nine of those were a DEFECT and are REPAIRED here; the remaining two are the rule. diff --git a/docs/design/rules.md b/docs/design/rules.md index 23f47082..876c6ae4 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -2176,26 +2176,32 @@ R4. Rationale: case repair is a display concern, applied only on mark one, R5 defers to it. A credential acronym the exceptions map does not carry is an initialism, so a single-case word the parse put in the suffix role from the acronym vocabulary repairs to its - all-caps spelling rather than a title-cased one; a word in that - vocabulary that parsed as an ordinary name word repairs as that - name word, and a suffix word that is not an acronym -- the - generational `jr`, `sr` -- keeps its title case. + all-caps spelling rather than a title-cased one, and that repair + outranks the Mac/Mc convention where a word fits both (MCSE, not + McSe); a word in that vocabulary that parsed as an ordinary name + word repairs as that name word, and a suffix word that is neither + an acronym nor an exceptions-map entry -- the generational `jr`, + `sr` -- keeps its title case. "juan mcdonald" → capitalized="Juan McDonald" "Juan McDonald" → capitalized_forced="Juan McDonald" "ANH DO" → capitalized="Anh Do" "anh van do" → capitalized="Anh Van Do" "john smith phd" → capitalized="John Smith Ph.D." "john smith mba" → capitalized="John Smith MBA" + "john smith mcse" → capitalized="John Smith MCSE" "john smith jr" → capitalized="John Smith Jr" · boundary "John Quincy Smith i" → capitalized_forced="John Quincy Smith I" "Carod i" → capitalized_forced="Carod I" "Smith, John, and" → capitalized_forced="John Smith and" "Doe, Jane, and Jr." → capitalized_forced="Jane Doe and Jr." "juan de la vega" → capitalized="Juan de la Vega" · boundary - Accepted: the clause reaches a part the parser read. A field - spliced in as raw text after the parse carries no reading of its - own, so a family set that way to "de la" stays lowercase where - those same two words parsed from a name are repaired to "De La". + Accepted: the all-particle clause reaches a part the parser read. + A field spliced in as raw text after the parse carries no reading + of its own, so a family set that way to "de la" stays lowercase + where those same two words parsed from a name are repaired to "De + La". The acronym repair is the contrast: it asks the role and the + vocabulary and not a reading, so a suffix spliced in as "mba" is + repaired to "MBA" exactly as a parsed one is. That is the boundary between splicing text into a field and revising a field through the parser — revise() classifies the value, so the repair follows it — rather than a gap between them. diff --git a/docs/release_log.rst b/docs/release_log.rst index b0e2b5aa..e5cf30b3 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -32,6 +32,10 @@ Release Log - **Add AmbiguityKind.CONJUNCTION_OR_INITIAL, reported when a one-letter connective in a name written wholly in one case is read as an initial:** ``parse("jose e maria santos").ambiguities`` and ``parse("JOSE E MARIA SANTOS").ambiguities`` both name it, and ``detail`` names the letter. That is the call the behavior change above had to make. A letter outside the marked set reports nothing, its reading not being in doubt, so ``JUAN GARCIA Y LOPEZ`` is silent; so is every mixed-case name, where the writing decided it. See the ``P3`` entry of ``docs/design/decisions.md`` (#383, #479) + - **Repair a credential acronym the case-repair exceptions map does not carry to all-caps instead of title-casing it.** ``HumanName("JOHN SMITH MBA").capitalize()`` gives ``John Smith MBA`` where every release since 1.4.0 gave ``John Smith Mba``; ``john smith jd`` gives ``John Smith JD``. The repair is keyed on the word having parsed in the suffix role from the acronym vocabulary, so a word that is an ordinary name merely sharing a spelling with an acronym is untouched, and the exceptions map still wins first -- ``john smith md`` gives ``John Smith M.D.`` and ``john smith phd`` gives ``John Smith Ph.D.`` as before, and the generational ``jr`` is unaffected (``john smith jr`` gives ``John Smith Jr``). The given-name half of a mixed run is unchanged, so ``QC MP`` gives ``Qc MP`` with the ``QC`` (given role) still title-cased and only the ``MP`` (suffix role) repaired. Twenty-two names move in the differential corpora on the default ``capitalize()`` path and 119 under ``force=True``, every one a single-case name with an acronym suffix the map does not carry; no role field moves. See the ``R4`` entry of ``docs/design/decisions.md`` (#459) + + - **Remove ph from the default post-nominal acronyms.** The fragment existed only so the merged ``Ph. D.`` token could pass the acronym test on its first piece, and the repair above would have read ``john smith ph. d.`` as ``John Smith PH. D.``; the parser merges the split spelling by its own rule, so ``HumanName("John Smith Ph. D.")`` still gives suffix ``Ph. D.``, ``john smith ph. d.`` capitalizes to ``John Smith Ph. D.``, and ``phd``/``Ph.D.`` are unchanged. No name in the differential corpora moves on any field. See the ``Excluded (SUFFIX_ACRONYMS -- ph)`` entry of ``docs/design/decisions.md`` (#459) + * 2.3.0 - September 12, 2026 nameparser 2.3 is parsing fixes and new honorific vocabulary; @@ -53,8 +57,6 @@ Release Log **Behavior Changes** - - **Repair a credential acronym the case-repair exceptions map does not carry to all-caps instead of title-casing it.** ``HumanName("JOHN SMITH MBA").capitalize()`` gives ``John Smith MBA`` where every release since 1.4.0 gave ``John Smith Mba``; ``john smith jd`` gives ``John Smith JD``. The repair is keyed on the word having parsed in the suffix role from the acronym vocabulary, so a word that is an ordinary name merely sharing a spelling with an acronym is untouched, and the exceptions map still wins first -- ``john smith md`` gives ``John Smith M.D.`` and ``john smith phd`` gives ``John Smith Ph.D.`` as before, and the generational ``jr`` is unaffected (``john smith jr`` gives ``John Smith Jr``). The given-name half of a mixed run is unchanged, so ``QC MP`` gives ``Qc MP`` with the ``QC`` (given role) still title-cased and only the ``MP`` (suffix role) repaired. Five names move in the differential corpora on the default ``capitalize()`` path (seventy-one under ``force=True``); no other field view moves. See the ``R4`` entry of ``docs/design/decisions.md`` (#459) - - **Fix HumanName.initials() dropping a middle- or family-group initial that is also a one-letter conjunction.** ``HumanName("Scott E. Werner").initials()`` gives ``S. E. W.`` again where 2.0.0 through 2.2.0 gave ``S. W.``; ``Juan Y. Garcia`` and a bare ASCII capital ``John E Smith`` likewise. v1 excluded initial-shaped words from its conjunction test and the 2.0 facade had not; ``parse(...).initials()`` was already right and is unchanged. A bare lowercase ``john e smith`` still reads the ``e`` as the connective. See the ``R3`` entry of ``docs/design/decisions.md`` (closes #462) - **Record a 2.0.0 change to HumanName.initials() that no release note had classified:** since 2.0.0 the facade initials each WORD of a name part, where 1.4.0 initialed a joined run as one group -- ``HumanName("Juan Velasquez y Garcia").initials()`` is ``J. V. G.`` and was ``J. V G.``; ``Abdul Salam Hassan`` is ``A. S. H.`` and was ``A S. H.``. Nothing changes in 2.3.0; the differential gate now compares ``initials()`` (#484) and this is what it found. See the ``differential-ledger, the initials view`` entry of ``docs/design/decisions.md`` diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 7f1ada52..4f6745ad 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -328,6 +328,7 @@ "john e smith" "john smith jr" "john smith mba" +"john smith mcse" "john smith phd" "john smith x.y.z." "john van der berg ma" From 16b66c60641b0517b5db196bc6a6966d3dbc67b0 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 22 Sep 2026 00:56:20 -0700 Subject: [PATCH 4/4] test(#459): the ph removal's parity break on a bare trailing Ph. is pinned and classified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs review of the fix-up found what the removal's measurement could not: `ph` also carried a bare trailing `Ph.` -- no `D.` behind it -- as a credential on every release since 1.4.0, and with the entry gone `John Smith Ph.` reads middle Smith, family Ph. The corpus had no name of that shape, so "0 role movers" was true and blind, the case AGENTS.md warns of. - tests/v2/cases.py: a shape-1 row carries `John Smith Ph.` at the positional reading, classified fix(#459), beside the esq rows the same criterion decided. - corpus_shapes.jsonl regenerated with it. - all five ledgers: a `change(suffix-acronym-collisions) ph leaves the acronym set` rule, literal and exactly as wide as the diff; the gate explains that one name at every baseline with nothing unexplained. - test_ledger_guards.py: the rule's corpus claim recorded per ledger, and its negative controls (`Ph. D.` in both positions, `phd`, `Ph.D.`). - decisions.md `Excluded (SUFFIX_ACRONYMS — ph)` and the 2.4.0 release-log bullet state the cost, the classification, and the add-back for a caller who wants the old reading. Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 2 +- docs/release_log.rst | 2 +- tests/v2/cases.py | 19 +++++++++++ tests/v2/test_ledger_guards.py | 35 ++++++++++++++++++++ tools/differential/corpus_shapes.jsonl | 1 + tools/differential/expected_since_1.4.0.toml | 29 ++++++++++++++++ tools/differential/expected_since_2.0.0.toml | 29 ++++++++++++++++ tools/differential/expected_since_2.1.0.toml | 29 ++++++++++++++++ tools/differential/expected_since_2.2.0.toml | 29 ++++++++++++++++ tools/differential/expected_since_2.3.0.toml | 29 ++++++++++++++++ 10 files changed, 202 insertions(+), 2 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 28f7557e..19489dbb 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -581,7 +581,7 @@ Excluded (SUFFIX_ACRONYMS — esq, removed 2026-09-08, #316/#489 bundle): Excluded (SUFFIX_ACRONYMS — ph, removed 2026-09-13, #459/#521): -- ph is OUT of SUFFIX_ACRONYMS and must not be put back by a sweep that finds "Ph. D." parsing and assumes the acronym set is what carries it. The split spelling is merged by group's own rule (the `PH`/`D` regexes of `_pipeline/_vocab.py`, v1's fix_phd; see #phd-merge), which never consults acronym membership, and the single-token spellings `phd`/`Ph.D.` are the `phd` entry's. The fragment arrived in the 2019-12-11 bulk Wikipedia post-nominal import (af5bdab, #93) and was never reviewed; what it uniquely did was let the merged `Ph. D.` token pass the acronym test on its first piece, which nothing read until #459's all-caps repair did — with `ph` in the set that repair read `john smith ph. d.` as `John Smith PH. D.` on the default path, not only under force. Measured at the removal (2026-09-11, 1143 corpus names): 0 names move on any role field at any baseline; under `capitalized()` 0 move and the 13 `PH. D.` shapes that the repair had moved under `force=True` go back to `Ph. D.`; `john smith phd` and `ph.d.` still give `Ph.D.`. The criterion is #suffix-acronym-collisions', asked of the WORD: the entry described the machinery, not a credential anyone writes alone. +- ph is OUT of SUFFIX_ACRONYMS and must not be put back by a sweep that finds "Ph. D." parsing and assumes the acronym set is what carries it. The split spelling is merged by group's own rule (the `PH`/`D` regexes of `_pipeline/_vocab.py`, v1's fix_phd; see #phd-merge, whose "merged back by vocabulary" means that regex stage and not a lexicon lookup), which never consults acronym membership, and the single-token spellings `phd`/`Ph.D.` are the `phd` entry's. The fragment arrived in the 2019-12-11 bulk Wikipedia post-nominal import (af5bdab, #93) and was never reviewed. It did two things. One was to let the merged `Ph. D.` token pass the acronym test on its first piece, which nothing read until #459's all-caps repair did — with `ph` in the set that repair read `john smith ph. d.` as `John Smith PH. D.` on the default path, not only under force, and that is what forced the removal. The other, found by the docs review of the fix-up (2026-09-22) and not at the removal, was to read a bare trailing `Ph.` — no `D.` behind it — as a credential: `John Smith Ph.` gave family Smith, suffix Ph. on every release from 1.4.0 through 2.3.0 (wheels measured), and out of the set it falls to the positional read, middle Smith, family Ph., suffix empty; `Smith, John Ph.` moves the same way, to middle Ph. The cost is that CLASS and not one spelling, measured 2026-09-22 on HEAD and on the five wheels with `Lexicon.default().add(suffix_acronyms={"ph"})` as the comparator: the acronym test strips periods, so the undotted `John Smith Ph`/`PH`/`ph` move identically; inside a credential run `Ph.` no longer joins it, so `John Smith Ph. MD` reads family Ph., suffix MD, and `John Smith MD Ph.` reads middle `Smith MD`, family Ph., the real credential leaving the suffix field with the fragment; `John Smith Ph. Jr.` keeps only the Jr.; and the comma form `Smith, Ph.` reads title Ph. where 2.2.0 and 2.3.0 read suffix. `John Smith P.H.` does not move, the by-shape class reading it either way. Every shape in the class is a bare `ph` with no `D.` behind it, which nobody writes — the same criterion as esq's, asked of the machinery rather than a surname: the entry described the merge, not a word. Classified a behavior change, a 2.x parity break, on all five ledgers (`change(suffix-acronym-collisions) ph leaves the acronym set`), with the cases.py row `removed_ph_fragment_leaves_a_bare_trailing_ph_a_name_word` written to carry the plain shape; the ledger rule is literal to that one name, so a future row on any other shape of the class arrives unexplained at every baseline and is classified by widening that rule, not filed as a regression. No corpus name had any shape of the class, which is why the removal measured as 0 role movers at review time (2026-09-11, 1143 names), the corpus-blindness AGENTS.md warns of. Under `capitalized()` the 13 `PH. D.` shapes the repair had moved under `force=True` go back to `Ph. D.`; `john smith phd` and `ph.d.` still give `Ph.D.`. ### suffix-acronym-collisions — the trailing-position collision class, decided (2026-09-07, #342/#454) diff --git a/docs/release_log.rst b/docs/release_log.rst index e5cf30b3..433f873a 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -34,7 +34,7 @@ Release Log - **Repair a credential acronym the case-repair exceptions map does not carry to all-caps instead of title-casing it.** ``HumanName("JOHN SMITH MBA").capitalize()`` gives ``John Smith MBA`` where every release since 1.4.0 gave ``John Smith Mba``; ``john smith jd`` gives ``John Smith JD``. The repair is keyed on the word having parsed in the suffix role from the acronym vocabulary, so a word that is an ordinary name merely sharing a spelling with an acronym is untouched, and the exceptions map still wins first -- ``john smith md`` gives ``John Smith M.D.`` and ``john smith phd`` gives ``John Smith Ph.D.`` as before, and the generational ``jr`` is unaffected (``john smith jr`` gives ``John Smith Jr``). The given-name half of a mixed run is unchanged, so ``QC MP`` gives ``Qc MP`` with the ``QC`` (given role) still title-cased and only the ``MP`` (suffix role) repaired. Twenty-two names move in the differential corpora on the default ``capitalize()`` path and 119 under ``force=True``, every one a single-case name with an acronym suffix the map does not carry; no role field moves. See the ``R4`` entry of ``docs/design/decisions.md`` (#459) - - **Remove ph from the default post-nominal acronyms.** The fragment existed only so the merged ``Ph. D.`` token could pass the acronym test on its first piece, and the repair above would have read ``john smith ph. d.`` as ``John Smith PH. D.``; the parser merges the split spelling by its own rule, so ``HumanName("John Smith Ph. D.")`` still gives suffix ``Ph. D.``, ``john smith ph. d.`` capitalizes to ``John Smith Ph. D.``, and ``phd``/``Ph.D.`` are unchanged. No name in the differential corpora moves on any field. See the ``Excluded (SUFFIX_ACRONYMS -- ph)`` entry of ``docs/design/decisions.md`` (#459) + - **Remove ph from the default post-nominal acronyms.** The fragment existed only so the merged ``Ph. D.`` token could pass the acronym test on its first piece, and the repair above would have read ``john smith ph. d.`` as ``John Smith PH. D.``; the parser merges the split spelling by its own rule, so ``HumanName("John Smith Ph. D.")`` still gives suffix ``Ph. D.``, ``john smith ph. d.`` capitalizes to ``John Smith Ph. D.``, and ``phd``/``Ph.D.`` are unchanged. The cost is a bare ``ph`` with no ``D.`` behind it, dotted or not, alone or inside a credential run: ``HumanName("John Smith Ph.")`` gives middle ``Smith``, last ``Ph.``, where every release since 1.4.0 gave suffix ``Ph.``, and ``John Smith MD Ph.`` gives middle ``Smith MD``, last ``Ph.``, the ``MD`` leaving the suffix with it. A caller who needs that back adds it: ``Lexicon.default().add(suffix_acronyms={"ph"})``. See the ``Excluded (SUFFIX_ACRONYMS -- ph)`` entry of ``docs/design/decisions.md`` (#459) * 2.3.0 - September 12, 2026 diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 36b5cd13..819b2c4a 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -401,6 +401,25 @@ def _check_cjk_shape_purity(self) -> None: "consideration reports: the parity note above is about " "the reading, not about whether a fork was called", shape=2), + Case("removed_ph_fragment_leaves_a_bare_trailing_ph_a_name_word", + "John Smith Ph.", + {"given": "John", "middle": "Smith", "family": "Ph."}, + classification="fix(#459)", + notes="the accepted cost of 'ph' leaving SUFFIX_ACRONYMS " + "(#459/#521, 2026-09-13), pinned so the reversal is " + "visible. The fragment let the merged 'Ph. D.' token pass " + "the acronym test on its first piece, and #459's all-caps " + "repair would have read 'john smith ph. d.' as " + "'John Smith PH. D.'; the merge itself keys on the PH/D " + "regexes of _pipeline/_vocab.py and never needed the " + "entry. What the entry uniquely carried was a bare " + "trailing 'Ph.' -- no 'D.' behind it -- as a credential, " + "a spelling nobody writes; out of the set it falls to the " + "positional read, family 'Ph.', where every release since " + "1.4.0 read suffix. Same criterion as the esq rows above, " + "asked of the machinery: the entry described the merge, " + "not a word. decisions.md, Excluded (SUFFIX_ACRONYMS -- ph)", + shape=1), Case("suffix_word_esq_still_reads_as_a_suffix", "John Smith Esq", {"given": "John", "family": "Smith", "suffix": "Esq"}, notes="the other half of the row above, and what the removal " diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index a68cde57..c52de55d 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -1204,6 +1204,11 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: # post-nominal that is not title vocabulary. "fix(#316) a trailing Latin title on a native-script name is a title": ("王先生, V.", "田中さん, Dr.", "田中さん II"), + # The ph boundary: the merged split spelling in both positions, and + # the single-token spellings the `phd` entry carries. + "change(suffix-acronym-collisions) ph leaves the acronym set": + ("John Smith Ph. D.", "Smith, Ph. D.", "john smith phd", + "John Smith Ph.D."), # The esq boundary is every spelling SUFFIX_WORDS still carries, # in each of the three positions the corpora write it in. "change(suffix-acronym-collisions) esq leaves the acronym set": @@ -3170,6 +3175,12 @@ def _claim(rule: dict) -> _Claim: #: both is growth into names the rule genuinely describes. _CORPUS_CLAIMS: dict[str, dict[str, _Claim]] = { "expected_since_1.4.0.toml": { + # The ph removal (#459/#521): one literal name, the cases.py + # row written to carry the bare trailing `Ph.` shape, since no + # corpus name had it -- which is why the removal measured as + # zero role movers at review time. + "change(suffix-acronym-collisions) ph leaves the acronym set": + _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), # #436/#437's Latin alternation, first in every ledger. # Ten corpus names, `suffix` alone: the rule moves the # SEPARATOR and no role, so a widening that took a role would @@ -3887,6 +3898,12 @@ def _claim(rule: dict) -> _Claim: _Claim(1, ('maiden', 'suffix'), 'e20491ebfe62', None), }, "expected_since_2.0.0.toml": { + # The ph removal (#459/#521): one literal name, the cases.py + # row written to carry the bare trailing `Ph.` shape, since no + # corpus name had it -- which is why the removal measured as + # zero role movers at review time. + "change(suffix-acronym-collisions) ph leaves the acronym set": + _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), # #436/#437's Latin alternation, first in every ledger. # Ten corpus names, `suffix` alone: the rule moves the # SEPARATOR and no role, so a widening that took a role would @@ -4402,6 +4419,12 @@ def _claim(rule: dict) -> _Claim: # the 2.0.0 mapping above, the same regex classifying the same # names. "expected_since_2.2.0.toml": { + # The ph removal (#459/#521): one literal name, the cases.py + # row written to carry the bare trailing `Ph.` shape, since no + # corpus name had it -- which is why the removal measured as + # zero role movers at review time. + "change(suffix-acronym-collisions) ph leaves the acronym set": + _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), # #436/#437's Latin alternation, first in every ledger. # Ten corpus names, `suffix` alone: the rule moves the # SEPARATOR and no role, so a widening that took a role would @@ -4689,6 +4712,12 @@ def _claim(rule: dict) -> _Claim: '4de0e7570bd6', ('DEFAULT',)), }, "expected_since_2.1.0.toml": { + # The ph removal (#459/#521): one literal name, the cases.py + # row written to carry the bare trailing `Ph.` shape, since no + # corpus name had it -- which is why the removal measured as + # zero role movers at review time. + "change(suffix-acronym-collisions) ph leaves the acronym set": + _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), # #436/#437's Latin alternation, first in every ledger. # Ten corpus names, `suffix` alone: the rule moves the # SEPARATOR and no role, so a widening that took a role would @@ -5171,6 +5200,12 @@ def _claim(rule: dict) -> _Claim: '4de0e7570bd6', ('DEFAULT',)), }, "expected_since_2.3.0.toml": { + # The ph removal (#459/#521): one literal name, the cases.py + # row written to carry the bare trailing `Ph.` shape, since no + # corpus name had it -- which is why the removal measured as + # zero role movers at review time. + "change(suffix-acronym-collisions) ph leaves the acronym set": + _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), # #383/#479's three rules, the first this ledger carries. The # role rule is the 2.x shape of the 1.4.0 rule of the same # name -- two corpus names, the union of two disjoint role diff --git a/tools/differential/corpus_shapes.jsonl b/tools/differential/corpus_shapes.jsonl index b74c1eb8..5053cae5 100644 --- a/tools/differential/corpus_shapes.jsonl +++ b/tools/differential/corpus_shapes.jsonl @@ -59,6 +59,7 @@ {"name": "John Smith J.u.n.i.o.r.", "shape": 1} {"name": "John Smith Jr.", "shape": 1} {"name": "John Smith Ma", "shape": 1} +{"name": "John Smith Ph.", "shape": 1} {"name": "John Smith Q.W.E.R.T.", "shape": 1} {"name": "John Smith R.A.I.", "shape": 1} {"name": "John Smith X.Y.Z.", "shape": 1} diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index f5900948..a84fc800 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -4361,3 +4361,32 @@ issue = "fix(#274/#397) a maiden clause inside a suffix-comma tail leaves the su # name in the corpora. name_regex = "^Smith, John, PhD née Puig Mr\\. - i Soler$" fields = ["maiden", "suffix"] + +[[change]] +issue = "change(suffix-acronym-collisions) ph leaves the acronym set" +# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps +# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' +# token pass the acronym test on its first piece -- the merge itself +# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working +# without it -- and its only unique coverage was a bare trailing +# 'Ph.' with no 'D.' behind it, read as a credential by every release +# since 1.4.0. Out of the set that spelling falls to the positional +# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. +# +# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag +# says: a deliberate 2.x parity break at every baseline, argued in +# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's +# and carried by the 2.4.0 release-log bullet. No corpus name had the +# shape until the cases.py row was written to carry it, which is why +# the removal measured as 0 role movers at review time; 'John Smith +# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. +# +# Literal to the one corpus name that carries the shape. The CLASS +# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', +# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', +# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every +# one a bare ph with no D. behind it (the decisions block lists them, +# measured 2026-09-22). A row carrying one of those arrives +# unexplained here by design: widen this rule to it, do not file it. +name_regex = "^John Smith Ph\\.$" +fields = ["family", "middle", "suffix"] diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml index 4e884a2c..c14bf7ca 100644 --- a/tools/differential/expected_since_2.0.0.toml +++ b/tools/differential/expected_since_2.0.0.toml @@ -3252,3 +3252,32 @@ issue = "fix(#397) a link inside a maiden clause stays in the birth name" name_regex = "^(?:Doe, Jane nee Puig i Soler|Jane Doe nee Puig i Soler|Smith, John, PhD née Puig Mr\\. - i Soler)$" fields = ["family", "maiden", "middle", "suffix"] orders = ["DEFAULT"] + +[[change]] +issue = "change(suffix-acronym-collisions) ph leaves the acronym set" +# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps +# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' +# token pass the acronym test on its first piece -- the merge itself +# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working +# without it -- and its only unique coverage was a bare trailing +# 'Ph.' with no 'D.' behind it, read as a credential by every release +# since 1.4.0. Out of the set that spelling falls to the positional +# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. +# +# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag +# says: a deliberate 2.x parity break at every baseline, argued in +# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's +# and carried by the 2.4.0 release-log bullet. No corpus name had the +# shape until the cases.py row was written to carry it, which is why +# the removal measured as 0 role movers at review time; 'John Smith +# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. +# +# Literal to the one corpus name that carries the shape. The CLASS +# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', +# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', +# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every +# one a bare ph with no D. behind it (the decisions block lists them, +# measured 2026-09-22). A row carrying one of those arrives +# unexplained here by design: widen this rule to it, do not file it. +name_regex = "^John Smith Ph\\.$" +fields = ["family", "middle", "suffix"] diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml index 7bd7fbe9..a8fd61dd 100644 --- a/tools/differential/expected_since_2.1.0.toml +++ b/tools/differential/expected_since_2.1.0.toml @@ -3163,3 +3163,32 @@ issue = "fix(#397) a link inside a maiden clause stays in the birth name" name_regex = "^(?:Doe, Jane nee Puig i Soler|Jane Doe nee Puig i Soler|Smith, John, PhD née Puig Mr\\. - i Soler)$" fields = ["family", "maiden", "middle", "suffix"] orders = ["DEFAULT"] + +[[change]] +issue = "change(suffix-acronym-collisions) ph leaves the acronym set" +# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps +# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' +# token pass the acronym test on its first piece -- the merge itself +# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working +# without it -- and its only unique coverage was a bare trailing +# 'Ph.' with no 'D.' behind it, read as a credential by every release +# since 1.4.0. Out of the set that spelling falls to the positional +# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. +# +# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag +# says: a deliberate 2.x parity break at every baseline, argued in +# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's +# and carried by the 2.4.0 release-log bullet. No corpus name had the +# shape until the cases.py row was written to carry it, which is why +# the removal measured as 0 role movers at review time; 'John Smith +# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. +# +# Literal to the one corpus name that carries the shape. The CLASS +# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', +# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', +# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every +# one a bare ph with no D. behind it (the decisions block lists them, +# measured 2026-09-22). A row carrying one of those arrives +# unexplained here by design: widen this rule to it, do not file it. +name_regex = "^John Smith Ph\\.$" +fields = ["family", "middle", "suffix"] diff --git a/tools/differential/expected_since_2.2.0.toml b/tools/differential/expected_since_2.2.0.toml index 68047f6e..f143faa1 100644 --- a/tools/differential/expected_since_2.2.0.toml +++ b/tools/differential/expected_since_2.2.0.toml @@ -1622,3 +1622,32 @@ issue = "fix(#397) a link inside a maiden clause stays in the birth name" name_regex = "^(?:Doe, Jane nee Puig i Soler|Jane Doe nee Puig i Soler|Smith, John, PhD née Puig Mr\\. - i Soler)$" fields = ["family", "maiden", "middle", "suffix"] orders = ["DEFAULT"] + +[[change]] +issue = "change(suffix-acronym-collisions) ph leaves the acronym set" +# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps +# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' +# token pass the acronym test on its first piece -- the merge itself +# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working +# without it -- and its only unique coverage was a bare trailing +# 'Ph.' with no 'D.' behind it, read as a credential by every release +# since 1.4.0. Out of the set that spelling falls to the positional +# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. +# +# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag +# says: a deliberate 2.x parity break at every baseline, argued in +# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's +# and carried by the 2.4.0 release-log bullet. No corpus name had the +# shape until the cases.py row was written to carry it, which is why +# the removal measured as 0 role movers at review time; 'John Smith +# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. +# +# Literal to the one corpus name that carries the shape. The CLASS +# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', +# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', +# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every +# one a bare ph with no D. behind it (the decisions block lists them, +# measured 2026-09-22). A row carrying one of those arrives +# unexplained here by design: widen this rule to it, do not file it. +name_regex = "^John Smith Ph\\.$" +fields = ["family", "middle", "suffix"] diff --git a/tools/differential/expected_since_2.3.0.toml b/tools/differential/expected_since_2.3.0.toml index 11b0725b..7415ed28 100644 --- a/tools/differential/expected_since_2.3.0.toml +++ b/tools/differential/expected_since_2.3.0.toml @@ -939,3 +939,32 @@ issue = "fix(#397) a link inside a maiden clause stays in the birth name" name_regex = "^(?:Doe, Jane nee Puig i Soler|Jane Doe nee Puig i Soler|Smith, John, PhD née Puig Mr\\. - i Soler)$" fields = ["family", "maiden", "middle", "suffix"] orders = ["DEFAULT"] + +[[change]] +issue = "change(suffix-acronym-collisions) ph leaves the acronym set" +# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps +# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' +# token pass the acronym test on its first piece -- the merge itself +# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working +# without it -- and its only unique coverage was a bare trailing +# 'Ph.' with no 'D.' behind it, read as a credential by every release +# since 1.4.0. Out of the set that spelling falls to the positional +# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. +# +# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag +# says: a deliberate 2.x parity break at every baseline, argued in +# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's +# and carried by the 2.4.0 release-log bullet. No corpus name had the +# shape until the cases.py row was written to carry it, which is why +# the removal measured as 0 role movers at review time; 'John Smith +# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. +# +# Literal to the one corpus name that carries the shape. The CLASS +# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', +# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', +# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every +# one a bare ph with no D. behind it (the decisions block lists them, +# measured 2026-09-22). A row carrying one of those arrives +# unexplained here by design: widen this rule to it, do not file it. +name_regex = "^John Smith Ph\\.$" +fields = ["family", "middle", "suffix"]