From af5a2fda77d72b6e48cb7732ec66bf17a6750e66 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Fri, 25 Sep 2026 00:27:06 -0700 Subject: [PATCH 1/5] feat(suffixes): meng and lac join the ambiguous acronyms MEng and LAc are live credentials and Meng and Lac are borne in the one slot the suffix vocabulary claims, the last word of a name -- Meng a common Chinese surname and given name, Lac a Vietnamese given name (the trailing word in native order, Nguyen Van Lac) and a French surname -- so both take the marking 'ma' and 'ba' carry rather than the removal 'rai' and 'cha' got. Two lines of data; rules.md#S2's words-to-spare and written-case reading does the rest. "wang meng" reads given 'wang', family 'meng' with a suffix-or-name report, where every release from 2.0.0 read suffix 'meng' and no family name (1.4.0 read the family, unflagged); 2.3.0 reported given-or-family there, the one-word-name fork, which was the wrong fork. "tran lac" moves the same way. After a family comma the lone word is the given name again, "Smith, meng" and "Smith, MEng" reading given 'meng' and 'MEng' as 1.4.0 did where every 2.x release read a suffix, and under FAMILY_FIRST "Wang Meng" reads given 'Meng'. "john smith meng" and "nguyen van lac" keep the credential, now reported. The cost is the one 'ma' carries: in a name written in more than one case, a member written neither in capitals nor all lower is the name even with words to spare, and MEng and LAc are written exactly that way. "john smith MEng" reads middle 'smith', family 'MEng' and "Smith, John MEng" reads middle 'MEng', where every release read suffix 'MEng'; the Title-case "Nguyen Van Lac" reads family 'Van Lac', which for a Vietnamese name is the gain. "JOHN SMITH MENG" keeps the suffix on the word count, one case saying nothing, and "Smith, MENG" on the capitals lean. No corpus name carried either word, so nine shape-tagged rows admit the forks to the contract tier -- six with no comma, three after a family comma. The gate classifies them under five fix(#540) rules at each 2.x baseline and two at 1.4.0: 545 -> 548, 509 -> 517, 421 -> 429, 275 -> 283 and 163 -> 171 intentional, 0 unexplained, radar unclassified unchanged. 'nguyen van lac' also diffs at 1.4.0 on its initials, for fix(#385/#402)'s reason, and joins that rule's list; three general comma rules gain reach without explaining, recorded. The render pin gains 'Wang Meng' and 'Tran Lac', and its negative control shows the masks this change declines re-spelling them 'Wang MEng' and 'Tran LAc'. Refs #540 Co-Authored-By: Claude Fable 5.1 --- nameparser/config/suffixes.py | 14 +- tests/v2/cases.py | 116 ++++++++++++- tests/v2/test_ledger_guards.py | 169 +++++++++++++++++-- tests/v2/test_render.py | 20 ++- tools/differential/corpus_shapes.jsonl | 9 + tools/differential/expected_since_1.4.0.toml | 56 +++++- tools/differential/expected_since_2.0.0.toml | 116 ++++++++++++- tools/differential/expected_since_2.1.0.toml | 116 ++++++++++++- tools/differential/expected_since_2.2.0.toml | 106 ++++++++++++ tools/differential/expected_since_2.3.0.toml | 106 ++++++++++++ 10 files changed, 794 insertions(+), 34 deletions(-) diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 2afea5a3..0b6cc3e2 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -248,7 +248,13 @@ # Where the CREDENTIAL dominates, the entry stays unambiguous. # LENGTH is a correlate and not the test -- a short acronym is # more often a common credential AND more often a name -- so do - # not read the letter counts here as a rule. + # not read the letter counts here as a rule. #540 (2026-09-25) + # marked 'lac' and 'meng', the first entries here longer than + # two letters: MEng and LAc are credentials people write after + # their names, and Meng and Lac are borne in the trailing slot + # by real people, and neither reading is rare enough to give the + # word to the other -- so the marking, whatever the letter + # count. # # Removal takes the DOTTED spelling with it too, except by # accident: "John Smith R.A.I." still reads suffix 'R.A.I.' only @@ -283,7 +289,13 @@ 'do', 'ed', 'jd', + # #540: LAc, Licensed Acupuncturist; Lac is a Vietnamese given name + # (the trailing word in native order, Nguyen Van Lac) and a French + # surname + 'lac', 'ma', + # #540: MEng, Master of Engineering; Meng, a Chinese surname and given name + 'meng', }) """ diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 819b2c4a..3a370ab8 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -491,7 +491,8 @@ def _check_cjk_shape_purity(self) -> None: # decisions.md#suffix-acronym-collisions and it compares # FREQUENCIES: how common the word is as a borne name in the # trailing position against how common it is as a credential. - # Rough balance earns the ambiguous marking (ba, do, ed, jd, ma); + # Rough balance earns the ambiguous marking (ba, do, ed, jd, ma, + # and since #540 lac and meng); # the name reading dominating REMOVES the entry, a caller adding # it back with Lexicon.default().add(suffix_acronyms={"cha"}); # the credential dominating leaves it unambiguous. Length is a @@ -590,6 +591,117 @@ def _check_cjk_shape_purity(self) -> None: "NOT move -- only the flag is new. The twin of " "ambiguous_acronym_is_a_suffix_when_a_family_name_remains " "above"), + # -- #540: 'meng' and 'lac' take the marking 'ba' took, on the same + # criterion (decisions.md#suffix-acronym-collisions). MEng and LAc + # are live credentials; Meng is a Chinese surname and given name, + # Lac a Vietnamese given name (the trailing word in native order, + # Nguyen Van Lac) and a French surname, and both stand in the one + # slot the suffix vocabulary claims, the last word of a name. No rule + # moves: these rows are rules.md#S2's forks read through two more + # members, written all-lower so the count decides unless the + # writing says otherwise, and shape-tagged so the differential's + # contract tier sees them -- no corpus name had either word. + Case("bare_meng_is_a_surname", "wang meng", + {"given": "wang", "family": "meng"}, + ambiguities=("suffix-or-name",), + notes="#540's subject: with only two pieces the credential " + "reading would leave no family name, so S2's " + "words-to-spare guard keeps it and reports the fork. " + "2.0.0 through 2.3.0 read suffix 'meng' with no family " + "(2.3.0 reporting given-or-family, the one-word-name " + "fork, which was the wrong fork); 1.4.0 read family " + "'meng' unflagged, so this row restores 1.4.0's roles " + "and adds the flag. The twin of bare_ba_is_a_surname", + shape=1), + Case("bare_lac_is_a_surname", "tran lac", + {"given": "tran", "family": "lac"}, + ambiguities=("suffix-or-name",), + notes="the same fork for the other word, with the same " + "history: suffix 'lac' and no family from 2.0.0 " + "through 2.3.0, family 'lac' at 1.4.0", + shape=1), + Case("meng_is_a_suffix_when_a_family_name_remains", + "john smith meng", + {"given": "john", "family": "smith", "suffix": "meng"}, + ambiguities=("suffix-or-name",), + notes="the words-to-spare half: a full name in front leaves " + "the credential reading available, and a name written " + "wholly in one case says nothing about any word in it, " + "so the count decides. The role does NOT move -- every " + "release read suffix 'meng' -- only the flag is new", + shape=1), + Case("lac_is_a_suffix_when_a_family_name_remains", + "nguyen van lac", + {"given": "nguyen", "family": "van", "suffix": "lac"}, + ambiguities=("suffix-or-name",), + notes="the words-to-spare half for 'lac', read by every " + "release since 1.4.0 as it is here; only the flag is " + "new. The Title-case 'Nguyen Van Lac' does NOT read " + "this way: in a name written in more than one case a " + "Title-case member is the name even with words to " + "spare, so it gives family 'Van Lac' -- the same " + "mechanism the next row pins for 'MEng', though for " + "this Vietnamese name it is the gain: a person, not " + "a credential", + shape=1), + Case("mixed_case_meng_after_a_full_name_reads_as_the_name", + "john smith MEng", + {"given": "john", "middle": "smith", "family": "MEng"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the marking's accepted cost, stated rather than " + "discovered: in a name written in more than one case, " + "S2 reads a member written neither in capitals nor all " + "lower as the name even with words to spare, and MEng " + "is conventionally written exactly that way. Every " + "release from 1.4.0 read suffix 'MEng'. The twin of " + "S2's 'John Smith Ma'; 'John Smith MENG' keeps the " + "credential on the capitals lean", + shape=1), + Case("comma_mixed_case_meng_reads_as_a_middle_name", + "Smith, John MEng", + {"given": "John", "middle": "MEng", "family": "Smith"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the marking's comma cost: it is the same S2 case " + "reading the row above pins, arriving after a family " + "comma, where C1 has already settled the word count " + "and the writing is the only evidence left. Every " + "release from 1.4.0 read suffix 'MEng'. 'John Smith, " + "MEng' keeps the suffix on C1's name-word count", + shape=2), + Case("comma_lone_meng_reads_as_the_given_name", "Smith, meng", + {"given": "meng", "family": "Smith"}, + ambiguities=("suffix-or-name",), + notes="the marking's one-word-before-the-comma path, the " + "reading 'Smith, Ma' gets, arriving for another " + "member: with the word ambiguous, S2 declines the " + "post-comma credential and, with the count leaving it " + "a name, C1 reads it as the given name. 2.0.0 through " + "2.3.0 read " + "family 'Smith', suffix 'meng'; 1.4.0 read given " + "'meng', so this row restores 1.4.0's reading and " + "adds the flag", + shape=2), + Case("comma_lone_mixed_case_meng_reads_as_the_given_name", + "Smith, MEng", + {"given": "MEng", "family": "Smith"}, + ambiguities=("suffix-or-name",), + notes="the same path for the conventional spelling, which " + "is the cost side of it: a writer who meant the " + "credential gets given 'MEng' instead, where 'Smith, " + "MENG' keeps the suffix on the capitals lean and " + "'Smith, John MEng' " + "(comma_mixed_case_meng_reads_as_a_middle_name) reads " + "it as a middle name. 1.4.0 read given 'MEng' too, so " + "this row also restores 1.4.0's reading", + shape=2), + Case("leading_meng_is_a_given_name", "meng li", + {"given": "meng", "family": "li"}, + notes="the control: the marking acts only at the slots S2 " + "reports from, and a leading word is none of them. No " + "report, and every release read it this way", + shape=1), Case("by_design_trailing_mc_reads_as_a_credential", "Donald Mc", {"given": "Donald", "suffix": "Mc"}, classification="fix(suffix-routing)", @@ -3061,7 +3173,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "family": "Doe", "suffix": "BA"}, classification="fix(#531)", ambiguities=("suffix-or-name",), - notes="'ba' is another listed member (one of the five " + notes="'ba' is another listed member (one of those " "decisions.md#suffix-acronym-collisions marked " "ambiguous rather than removing), so the slot is not a " "rule about 'ma' -- it is the whole class", diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index f76a1638..a10bf124 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -1209,6 +1209,28 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: "change(suffix-acronym-collisions) ph leaves the acronym set": ("John Smith Ph. D.", "Smith, Ph. D.", "john smith phd", "John Smith Ph.D."), + # #540's five rules, keyed on the full issue since all five carry + # `fix(#540)`. Each wall is the other rules' names plus the + # spellings a case-blind widening would reach: 'meng li' leads + # with the word and nothing moved; the one-case 'john smith meng' + # and 'JOHN SMITH MENG' keep the credential, so the cost rule has + # no business with either. Its third probe, 'John Smith MENG', is + # not one-case either -- MENG's own capitals keep the credential + # on the capitals lean, in a name otherwise written Title-case. + # The report rule must not reach the names whose ROLES move, and + # the comma-cost rule must not reach the bare-word, no-comma or + # all-lower spellings. The lone-word comma rule must not reach + # its own capitals-lean probe or the two-word comma rule's name. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + ("meng li", "Meng Li", "john smith meng", "john smith MEng"), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + ("john smith meng", "JOHN SMITH MENG", "John Smith MENG"), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + ("wang meng", "tran lac", "john smith MEng", "meng li"), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + ("John Smith, MEng", "Smith, John meng"), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + ("Smith, MENG", "Smith, John MEng"), # 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": @@ -2418,20 +2440,37 @@ class _LatinCopy(NamedTuple): # words; the same reason as fix(#400)'s pair above. frozenset({"^", "\\s"}), frozenset({"^", "[\\s,]"}), - # fix(#385/#402)'s 25 spellings: the 28 corpus names whose + # fix(#385/#402)'s spellings: the corpus names whose # all-particle part moved, listed because "a part of nothing but # particles" is not a property a regex over the raw string can # state. A list of names, the fix(#445) precedent -- that rule and # fix(#410) and fix(#335) are the 1.4.0 ledger's literal lists. - # 'anh van mc' joined 2026-09-18 with its case row. + # 'anh van mc' joined 2026-09-18 with its case row, and 'nguyen + # van lac' 2026-09-25 with #540's. frozenset({"anh do", "smith van der", "yin le", "yin a le", "vai la", "jong van der", "jong, van der", "juan van der", "mesnil garcia de", "mesnil garcia van", "mesnil de", "sander van", "van ma van", "anh van do", "anh van mc", "beethoven ludwig van", "berg jan de jr\\.", "john van mc", "jong anke de", "juan de", "ménil christophe de", - "ménil de", "nguyen thi van", "nguyen, van le", - "van berg jan de"}), + "ménil de", "nguyen thi van", "nguyen van lac", + "nguyen, van le", "van berg jan de"}), + # #540's two lists, one alternative per corpus name: the names + # whose family comes back and the names that keep the credential + # and gain a report. Lists of names, not copies of + # SUFFIX_ACRONYMS_AMBIGUOUS -- the rules' subject is which SLOT + # the member stands in, which no vocabulary decides, and a member + # spelled as the bare word would reach 'meng li' and + # 'john smith MEng'. + frozenset({"tran lac", "wang meng"}), + frozenset({"john smith meng", "nguyen van lac"}), + # #540's lone-word comma rule shares a literal prefix ('Smith, '), + # so its alternation is over the word's two spellings alone (the + # word reads as the given name here), not a full name pair like + # the two lists above -- 'meng'/'MEng' is not a copy of + # SUFFIX_ACRONYMS_AMBIGUOUS's bare 'meng' either, the same reason + # as the two lists above. + frozenset({"MEng", "meng"}), # fix(#462)'s letter shape: a bare capital E/Y or a dotted E./Y. # It is the initial SHAPE (v1's `initial` regex, _render._INITIAL) # intersected with the single-letter conjunctions, not a copy of @@ -3181,6 +3220,15 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's two cost rules, the only #540 rules this baseline + # needs: one literal name each, the mixed-case credential S2 + # reads as the name even with words to spare, no-comma and + # after a family comma. Roles without `_ambiguities`, which + # 1.4.0 does not compare. + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(1, ('family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), # #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 @@ -3362,8 +3410,12 @@ def _claim(rule: dict) -> _Claim: # lines rules.md#R4 gained ('john smith, v', 'john smith, vi'); # its forced 'Dr. med. univ. Margit Popp, MSc' line was already # in corpus_issues.jsonl. Reach, verified name by name. + # 2026-09-25, #540: 366 -> 367, 'Smith, John MEng', the new + # comma-cost row's shape landing in this rule's reach too. + # 2026-09-25, #540: 367 -> 369, 'Smith, meng' and 'Smith, + # MEng', the lone-word comma rows' shape landing here too. "fix(comma-family) lone post-comma piece routes to suffix/title, not first": - _Claim(366, ('given', 'suffix', 'title'), '0b1a15dd9b78', None), + _Claim(369, ('given', 'suffix', 'title'), '5bf76967bc46', None), "fix(comma-family) a comma followed only by titles keeps the given/family split": _Claim(2, ('family', 'given'), "5bd9c6d96c38", None), "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example": @@ -3391,8 +3443,13 @@ def _claim(rule: dict) -> _Claim: # 2026-09-18, #531: 20 -> 21. One new corpus name, 'Doe, MA' -- # the family-word twin of the 'Smith, MA' above, and a lone # post-comma credential by this rule's own description. + # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, + # meng' and 'Smith, MEng' -- reached by the same post-comma + # shape this rule matches, but read the opposite way and + # explained by neither: 1.4.0 already reads the given name + # for both, so there is no diff here to explain. "fix(#296) a lone post-comma credential is a suffix": - _Claim(21, ('family', 'given', 'suffix', 'title'), "3a950b6bb63d", None), + _Claim(23, ('family', 'given', 'suffix', 'title'), "54c1ae9911e1", None), "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": _Claim(6, ('given', 'suffix', 'title'), "7911e0158337", None), "fix(#325) a credential run across a second comma reads as suffixes": @@ -3423,8 +3480,12 @@ def _claim(rule: dict) -> _Claim: # lines rules.md#R4 gained ('john smith, v', 'john smith, vi'); # its forced 'Dr. med. univ. Margit Popp, MSc' line was already # in corpus_issues.jsonl. Reach, verified name by name. + # 2026-09-25, #540: 366 -> 367, 'Smith, John MEng', the new + # comma-cost row's shape landing in this rule's reach too. + # 2026-09-25, #540: 367 -> 369, 'Smith, meng' and 'Smith, + # MEng', the lone-word comma rows' shape landing here too. "fix(comma-precomma-family) pre-comma run reads as family, not given": - _Claim(366, ('family', 'given'), '0b1a15dd9b78', None), + _Claim(369, ('family', 'given'), '5bf76967bc46', None), # 2026-09-20, #397: retitled in place, reach and digest # unchanged -- the rule keeps 'Carod i', which the landing # leaves byte-identical. @@ -3613,8 +3674,12 @@ def _claim(rule: dict) -> _Claim: # spelling of the fragment the corpora carry, and the trailing # ones are protected by the [[never]] entry above, which is # what _EXCLUSION_EFFECT's grown `absorbed_by` records. + # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', which #540's + # case row admitted and which this rule explains here: its + # family 'van' is a one-particle part, this rule's reason and + # not #540's. "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(29, ('_initials',), "541df583f5fc", ('DEFAULT',)), + _Claim(30, ('_initials',), "8b99d25622a3", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # #269's derived-view rule, added 2026-09-13. One corpus name, @@ -3916,6 +3981,20 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's five rules: three literal alternations and two + # literal names, every name one of #540's own case rows. The + # report rule's `_ambiguities`-only roles are its point: a + # widening that took a role would change them here first. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), # #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 @@ -4138,8 +4217,13 @@ def _claim(rule: dict) -> _Claim: # by this rule's own description. # 2026-09-18, #531: 20 -> 21, the same one new name # ('Doe, MA') as the 1.4 twin. + # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, + # meng' and 'Smith, MEng' -- reached by the same post-comma + # shape this rule matches, but read the opposite way and + # explained by neither: at this baseline the diff moves + # `given`, a field outside this rule's own ('suffix', 'title'). "fix(#296) a lone post-comma credential is a suffix": - _Claim(21, ('suffix', 'title'), "3a950b6bb63d", None), + _Claim(23, ('suffix', 'title'), "54c1ae9911e1", None), "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": _Claim(6, ('given', 'suffix', 'title'), "7911e0158337", None), "fix(#325) a credential run across a second comma reads as suffixes": @@ -4192,12 +4276,16 @@ def _claim(rule: dict) -> _Claim: _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "fc6bc9e605e1", ('FAMILY_FIRST',)), "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold, the given-last spelling": _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "3e43a2be022e", ('FAMILY_FIRST_GIVEN_LAST',)), - # #484's two `_initials` rules. Both are literal name lists, so - # reach equals what they explain here -- 27 and 1 -- and both - # digests match the 1.4.0 ledger's, which is the point of - # copying the list verbatim rather than restating it. + # #484's two `_initials` rules. Both are literal name lists; + # reach 30 and 1, explaining 29 and 1 (2026-09-25), the gap + # being 'nguyen van lac' below -- and both digests match the + # 1.4.0 ledger's, which is the point of copying the list + # verbatim rather than restating it. + # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', reached and + # NOT explained at this baseline -- its report moves here, + # which keeps `_initials` out of its diff (#484). "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(29, ('_initials',), "541df583f5fc", ('DEFAULT',)), + _Claim(30, ('_initials',), "8b99d25622a3", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # fix(#462) reaches more than it explains -- the reach is the @@ -4439,6 +4527,20 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's five rules: three literal alternations and two + # literal names, every name one of #540's own case rows. The + # report rule's `_ambiguities`-only roles are its point: a + # widening that took a role would change them here first. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), # #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 @@ -4734,6 +4836,20 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's five rules: three literal alternations and two + # literal names, every name one of #540's own case rows. The + # report rule's `_ambiguities`-only roles are its point: a + # widening that took a role would change them here first. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), # #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 @@ -4932,8 +5048,13 @@ def _claim(rule: dict) -> _Claim: # by this rule's own description. # 2026-09-18, #531: 20 -> 21, the same one new name # ('Doe, MA') as the 1.4 twin. + # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, + # meng' and 'Smith, MEng' -- reached by the same post-comma + # shape this rule matches, but read the opposite way and + # explained by neither: at this baseline the diff moves + # `given`, a field outside this rule's own ('suffix', 'title'). "fix(#296) a lone post-comma credential is a suffix": - _Claim(21, ('suffix', 'title'), "3a950b6bb63d", None), + _Claim(23, ('suffix', 'title'), "54c1ae9911e1", None), "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": _Claim(6, ('given', 'suffix', 'title'), "7911e0158337", None), "fix(#325) a credential run across a second comma reads as suffixes": @@ -4990,8 +5111,10 @@ def _claim(rule: dict) -> _Claim: # shipped in 2.2.0, so it is equally visible from either 2.x # baseline, and the reaches and digests agree because the two # files carry the same literal list. + # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', reached and + # not explained here, as in the 2.0.0 mapping. "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(29, ('_initials',), "541df583f5fc", ('DEFAULT',)), + _Claim(30, ('_initials',), "8b99d25622a3", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # fix(#462), reach and digest as in the 2.0.0 mapping: the same @@ -5224,6 +5347,20 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's five rules: three literal alternations and two + # literal names, every name one of #540's own case rows. The + # report rule's `_ambiguities`-only roles are its point: a + # widening that took a role would change them here first. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), # #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/tests/v2/test_render.py b/tests/v2/test_render.py index cb693ea6..fd5924db 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -1215,10 +1215,22 @@ def test_a_listed_acronym_that_is_a_name_word_gets_no_mask() -> None: in a NAME role the word repairs as a title-cased name word, and in the suffix role, with no mask, the acronym clause writes it in capitals. The recorded negative control is the mask added back, - which re-spells the person.""" + which re-spells the person. + + Since #540 `meng` and `lac` are ambiguous acronyms, so a bare + trailing one after a lone given name is the FAMILY name + (decisions.md#suffix-acronym-collisions): `wang meng` repairs to + `Wang Meng`, where the credential reading gave `Wang MENG`. With + words to spare the credential reading, and its capitals, stay. + That puts the trailing slot on both sides of the fork, and the + control shows the mask re-spelling the surname there too: + `Wang MEng`, `Tran LAc`.""" for text, repaired in (("MENG LI", "Meng Li"), ("edd smith", "Edd Smith"), + ("wang meng", "Wang Meng"), + ("tran lac", "Tran Lac"), ("john smith meng", "John Smith MENG"), + ("nguyen van lac", "Nguyen Van LAC"), ("john smith edd", "John Smith EDD")): assert str(parse(text).capitalized()) == repaired, text hn = HumanName(text) @@ -1228,9 +1240,11 @@ def test_a_listed_acronym_that_is_a_name_word_gets_no_mask() -> None: masked = Parser(lexicon=dataclasses.replace( default, capitalization_exceptions=tuple( default.capitalization_exceptions) - + (("meng", "MEng"), ("edd", "EdD")))) + + (("meng", "MEng"), ("edd", "EdD"), ("lac", "LAc")))) for text, respelled in (("MENG LI", "MEng Li"), - ("edd smith", "EdD Smith")): + ("edd smith", "EdD Smith"), + ("wang meng", "Wang MEng"), + ("tran lac", "Tran LAc")): assert str(masked.capitalized(masked.parse(text))) == respelled diff --git a/tools/differential/corpus_shapes.jsonl b/tools/differential/corpus_shapes.jsonl index 5053cae5..9a043e10 100644 --- a/tools/differential/corpus_shapes.jsonl +++ b/tools/differential/corpus_shapes.jsonl @@ -104,11 +104,17 @@ {"name": "jane doe nee smith ma", "shape": 1} {"name": "john e smith", "shape": 1} {"name": "john smith MA", "shape": 1} +{"name": "john smith MEng", "shape": 1} +{"name": "john smith meng", "shape": 1} {"name": "john smith x.y.z.", "shape": 1} {"name": "jose e maria santos", "shape": 1} {"name": "josep carod i rovira", "shape": 1} {"name": "juan garcia y lopez", "shape": 1} {"name": "juan y garcia", "shape": 1} +{"name": "meng li", "shape": 1} +{"name": "nguyen van lac", "shape": 1} +{"name": "tran lac", "shape": 1} +{"name": "wang meng", "shape": 1} {"name": "ХОСЕ И МАРИЯ САНТОС", "shape": 1} {"name": "Хосе И Мария Сантос", "shape": 1} {"name": "хосе и мария сантос", "shape": 1} @@ -189,10 +195,13 @@ {"name": "Smith, E.S.Q.", "shape": 2} {"name": "Smith, John", "shape": 2} {"name": "Smith, John Jr.", "shape": 2} +{"name": "Smith, John MEng", "shape": 2} {"name": "Smith, John V, Jr.", "shape": 2} {"name": "Smith, John, Extra, Jr.", "shape": 2} {"name": "Smith, MA", "shape": 2} +{"name": "Smith, MEng", "shape": 2} {"name": "Smith, Ma", "shape": 2} +{"name": "Smith, meng", "shape": 2} {"name": "de la Vega, Juan", "shape": 2} {"name": "doe, john ma", "shape": 2} {"name": "Davis Royce, Ed", "shape": 3} diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index a84fc800..74083ccf 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -3091,17 +3091,20 @@ issue = "fix(#385/#402) an all-particle name part initials its words (R2)" # 1.4.0 contributed nothing for such a part ('Anh Do' -> 'A.'; the # tree gives 'A. D.'). The fields did not move, only the view, which # is why no rule could classify this until #484 gave the gate an -# initials column. Reach exactly the 28 names that move (25 spellings, -# three of them case variants), listed literally rather than shaped -# -- 'anh van mc' joined on 2026-09-18 with the case row that -# admitted it to the corpus, the unambiguous-vocabulary twin of the -# 'anh van do' already here: +# initials column. Reach exactly the names that move, listed +# literally rather than shaped (30 corpus names over 26 spellings on +# 2026-09-25; _CORPUS_CLAIMS holds the live reach) -- 'anh van mc' +# joined on 2026-09-18 with the case row that admitted it to the +# corpus, the unambiguous-vocabulary twin of the 'anh van do' already +# here, and 'nguyen van lac' on 2026-09-25 with #540's, its family +# 'van' a one-particle part whose initial 1.4.0 dropped ('n.' -> +# 'n. v.'), which is this rule's reason and not #540's: # the shape is "a part of nothing but particles", which no regex over # the raw string can state, and the fix(#445) rule above set the # precedent for a name list in THIS ledger (fix(#410) and fix(#335) # are two more). 'de los Santos' is NOT here: its # initials moved for a different reason (the rule below). -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen, van le|van berg jan de)$" +name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" fields = ["_initials"] orders = ["DEFAULT"] @@ -4390,3 +4393,44 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Two rules here +# where the 2.x ledgers carry five. 'wang meng' and 'tran lac' read the +# family at 1.4.0 as the tree does, 'Smith, meng' and 'Smith, MEng' read +# the given name the same way, and 'john smith meng' and 'nguyen van lac' +# keep 1.4.0's suffix -- their only new thing is a report, and +# `_ambiguities` is a v2 surface this baseline does not compare. ('nguyen +# van lac' does diff here, on `_initials`, for fix(#385/#402)'s reason, +# and that rule's list carries it.) What is left is the marking's cost, +# paid twice: once bare behind a full name and once after a family comma. +# decisions.md#suffix-acronym-collisions records the decision. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', +# where 1.4.0 read suffix 'MEng'. In a name written in more than one +# case rules.md#S2 reads a member "written in any other cased form +# that is not wholly lower" as the name "even with words to spare", +# and MEng is conventionally written in exactly that form. The same +# rule as the 2.x ledgers' of this name, without `_ambiguities`. +# Literal, one name; the one-case spellings are _MUST_NOT_MATCH +# probes. +name_regex = "^john smith MEng$" +fields = ["family", "middle", "suffix"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# where 1.4.0 read suffix 'MEng'. The same S2 case reading the rule +# above pins, arriving after a family comma, where C1 has already +# settled the word count and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml index c14bf7ca..98a7c32a 100644 --- a/tools/differential/expected_since_2.0.0.toml +++ b/tools/differential/expected_since_2.0.0.toml @@ -1820,9 +1820,13 @@ orders = ["FAMILY_FIRST_GIVEN_LAST"] issue = "fix(#385/#402) an all-particle name part initials its words (R2)" # 'Anh Do' -> 'A. D.', 'Juan van der' -> 'J. v. d.': rules.md#R2/#R3, # shipped in 2.2.0, decisions.md#R2. Fields identical at this -# baseline; only the view moved. The same 28-name literal list as the -# 1.4.0 rule, for the same reason. -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen, van le|van berg jan de)$" +# baseline; only the view moved. The same literal list as the 1.4.0 +# rule, for the same reason. 'nguyen van lac' is in it since +# 2026-09-25 and explains nothing here: its report moves at this +# baseline, and the derived view enters a diff only where every +# role and every ambiguity kind agree (#484), so the list stays one +# list without claiming anything new. +name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" fields = ["_initials"] orders = ["DEFAULT"] @@ -3281,3 +3285,109 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Five rules -- +# the two names whose family name comes back, the marking's cost +# behind a full name, the same cost after a family comma, the lone +# word after a family comma (given name back, cost for the +# conventional spelling), and the two that keep the credential and +# gain a report. No rule of the parser changed: the two words joined +# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads +# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the +# name has words to spare". decisions.md#suffix-acronym-collisions +# records the decision, and the removal and the masks it declined. +# +# Every name here is one of #540's own shape-tagged case rows. No +# corpus name written before the change carried a bare trailing +# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at +# 120033b5), so the gate saw nothing until the rows admitted it. +# One set of five rules for the four 2.x ledgers; the 1.4.0 ledger +# carries the two accepted rules alone, 1.4.0 having read the +# other six names' roles as the tree does. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" +# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and +# 'tran', 'lac') with a suffix-or-name report, where this baseline +# read suffix 'meng' and no family name at all -- reporting nothing +# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the +# one-word-name fork, which was the wrong fork. 1.4.0 read the +# family, which is why that ledger has no copy of this rule. +# +# Literal-anchored to the two names. The class is every name whose +# last word is a bare member with nothing to spare, and a regex for +# it would stand ready to explain a regression on the spellings that +# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py +# carries 'meng li', where the word LEADS and nothing moved. The +# members are names, not a copy of any wordlist, which is what +# _NOT_A_VOCABULARY_COPY records. +name_regex = "^(?:tran lac|wang meng)$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with +# a suffix-or-name report, where every release read suffix 'MEng'. +# The marking's stated cost. In a name written in more than one case +# rules.md#S2 reads a member "written in any other cased form that is +# not wholly lower" as the name "even with words to spare", and MEng +# and LAc are conventionally written in exactly that form -- the cost +# 'John Smith Ma' already carries. +# +# A rule of its own rather than a member of the one above, so the +# gate's summary and the release note name the cost under its own +# heading. Literal, one name: the one-case spellings 'john smith +# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding +# where the writing says nothing, and are _MUST_NOT_MATCH probes. +name_regex = "^john smith MEng$" +fields = ["family", "middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# with a suffix-or-name report, where every release read suffix +# 'MEng'. The same S2 case reading the rule above pins, arriving +# after a family comma, where C1 has already settled the word count +# and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" +# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family +# 'Smith', with a suffix-or-name report, where this baseline read +# suffix and no given name at all -- the third comma path, S2 +# declining the post-comma credential when the word count leaves it +# a name and C1 reading it as the given name. 1.4.0 already read +# the given name here, so that ledger carries no copy of this rule: +# the all-lower spelling restores 1.4.0's reading, and the +# mixed-case spelling is the cost side of the same path. +# +# Literal, two names; 'Smith, MENG' keeps the credential on the +# capitals lean, and 'Smith, John MEng' belongs to the middle-name +# rule above -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, (?:MEng|meng)$" +fields = ["given", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" +# 'john smith meng' and 'nguyen van lac': every role is what this +# baseline read -- suffix 'meng', suffix 'lac' -- because a full name +# in front leaves the credential reading available, and what is new +# is the report rules.md#S2 makes at the trailing slot, where "either +# reading carries the ambiguity flag". `_ambiguities` alone, so this +# rule cannot absorb a ROLE diff on either name, which is the point +# of keeping it apart from the two above. +name_regex = "^(?:john smith meng|nguyen van lac)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml index a8fd61dd..7a2190d3 100644 --- a/tools/differential/expected_since_2.1.0.toml +++ b/tools/differential/expected_since_2.1.0.toml @@ -1741,9 +1741,13 @@ orders = ["FAMILY_FIRST_GIVEN_LAST"] issue = "fix(#385/#402) an all-particle name part initials its words (R2)" # 'Anh Do' -> 'A. D.', 'Juan van der' -> 'J. v. d.': rules.md#R2/#R3, # shipped in 2.2.0, decisions.md#R2. Fields identical at this -# baseline; only the view moved. The same 28-name literal list as the -# 1.4.0 rule, for the same reason. -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen, van le|van berg jan de)$" +# baseline; only the view moved. The same literal list as the 1.4.0 +# rule, for the same reason. 'nguyen van lac' is in it since +# 2026-09-25 and explains nothing here: its report moves at this +# baseline, and the derived view enters a diff only where every +# role and every ambiguity kind agree (#484), so the list stays one +# list without claiming anything new. +name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" fields = ["_initials"] orders = ["DEFAULT"] @@ -3192,3 +3196,109 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Five rules -- +# the two names whose family name comes back, the marking's cost +# behind a full name, the same cost after a family comma, the lone +# word after a family comma (given name back, cost for the +# conventional spelling), and the two that keep the credential and +# gain a report. No rule of the parser changed: the two words joined +# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads +# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the +# name has words to spare". decisions.md#suffix-acronym-collisions +# records the decision, and the removal and the masks it declined. +# +# Every name here is one of #540's own shape-tagged case rows. No +# corpus name written before the change carried a bare trailing +# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at +# 120033b5), so the gate saw nothing until the rows admitted it. +# One set of five rules for the four 2.x ledgers; the 1.4.0 ledger +# carries the two accepted rules alone, 1.4.0 having read the +# other six names' roles as the tree does. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" +# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and +# 'tran', 'lac') with a suffix-or-name report, where this baseline +# read suffix 'meng' and no family name at all -- reporting nothing +# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the +# one-word-name fork, which was the wrong fork. 1.4.0 read the +# family, which is why that ledger has no copy of this rule. +# +# Literal-anchored to the two names. The class is every name whose +# last word is a bare member with nothing to spare, and a regex for +# it would stand ready to explain a regression on the spellings that +# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py +# carries 'meng li', where the word LEADS and nothing moved. The +# members are names, not a copy of any wordlist, which is what +# _NOT_A_VOCABULARY_COPY records. +name_regex = "^(?:tran lac|wang meng)$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with +# a suffix-or-name report, where every release read suffix 'MEng'. +# The marking's stated cost. In a name written in more than one case +# rules.md#S2 reads a member "written in any other cased form that is +# not wholly lower" as the name "even with words to spare", and MEng +# and LAc are conventionally written in exactly that form -- the cost +# 'John Smith Ma' already carries. +# +# A rule of its own rather than a member of the one above, so the +# gate's summary and the release note name the cost under its own +# heading. Literal, one name: the one-case spellings 'john smith +# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding +# where the writing says nothing, and are _MUST_NOT_MATCH probes. +name_regex = "^john smith MEng$" +fields = ["family", "middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# with a suffix-or-name report, where every release read suffix +# 'MEng'. The same S2 case reading the rule above pins, arriving +# after a family comma, where C1 has already settled the word count +# and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" +# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family +# 'Smith', with a suffix-or-name report, where this baseline read +# suffix and no given name at all -- the third comma path, S2 +# declining the post-comma credential when the word count leaves it +# a name and C1 reading it as the given name. 1.4.0 already read +# the given name here, so that ledger carries no copy of this rule: +# the all-lower spelling restores 1.4.0's reading, and the +# mixed-case spelling is the cost side of the same path. +# +# Literal, two names; 'Smith, MENG' keeps the credential on the +# capitals lean, and 'Smith, John MEng' belongs to the middle-name +# rule above -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, (?:MEng|meng)$" +fields = ["given", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" +# 'john smith meng' and 'nguyen van lac': every role is what this +# baseline read -- suffix 'meng', suffix 'lac' -- because a full name +# in front leaves the credential reading available, and what is new +# is the report rules.md#S2 makes at the trailing slot, where "either +# reading carries the ambiguity flag". `_ambiguities` alone, so this +# rule cannot absorb a ROLE diff on either name, which is the point +# of keeping it apart from the two above. +name_regex = "^(?:john smith meng|nguyen van lac)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.2.0.toml b/tools/differential/expected_since_2.2.0.toml index f143faa1..49cc2017 100644 --- a/tools/differential/expected_since_2.2.0.toml +++ b/tools/differential/expected_since_2.2.0.toml @@ -1651,3 +1651,109 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Five rules -- +# the two names whose family name comes back, the marking's cost +# behind a full name, the same cost after a family comma, the lone +# word after a family comma (given name back, cost for the +# conventional spelling), and the two that keep the credential and +# gain a report. No rule of the parser changed: the two words joined +# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads +# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the +# name has words to spare". decisions.md#suffix-acronym-collisions +# records the decision, and the removal and the masks it declined. +# +# Every name here is one of #540's own shape-tagged case rows. No +# corpus name written before the change carried a bare trailing +# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at +# 120033b5), so the gate saw nothing until the rows admitted it. +# One set of five rules for the four 2.x ledgers; the 1.4.0 ledger +# carries the two accepted rules alone, 1.4.0 having read the +# other six names' roles as the tree does. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" +# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and +# 'tran', 'lac') with a suffix-or-name report, where this baseline +# read suffix 'meng' and no family name at all -- reporting nothing +# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the +# one-word-name fork, which was the wrong fork. 1.4.0 read the +# family, which is why that ledger has no copy of this rule. +# +# Literal-anchored to the two names. The class is every name whose +# last word is a bare member with nothing to spare, and a regex for +# it would stand ready to explain a regression on the spellings that +# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py +# carries 'meng li', where the word LEADS and nothing moved. The +# members are names, not a copy of any wordlist, which is what +# _NOT_A_VOCABULARY_COPY records. +name_regex = "^(?:tran lac|wang meng)$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with +# a suffix-or-name report, where every release read suffix 'MEng'. +# The marking's stated cost. In a name written in more than one case +# rules.md#S2 reads a member "written in any other cased form that is +# not wholly lower" as the name "even with words to spare", and MEng +# and LAc are conventionally written in exactly that form -- the cost +# 'John Smith Ma' already carries. +# +# A rule of its own rather than a member of the one above, so the +# gate's summary and the release note name the cost under its own +# heading. Literal, one name: the one-case spellings 'john smith +# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding +# where the writing says nothing, and are _MUST_NOT_MATCH probes. +name_regex = "^john smith MEng$" +fields = ["family", "middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# with a suffix-or-name report, where every release read suffix +# 'MEng'. The same S2 case reading the rule above pins, arriving +# after a family comma, where C1 has already settled the word count +# and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" +# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family +# 'Smith', with a suffix-or-name report, where this baseline read +# suffix and no given name at all -- the third comma path, S2 +# declining the post-comma credential when the word count leaves it +# a name and C1 reading it as the given name. 1.4.0 already read +# the given name here, so that ledger carries no copy of this rule: +# the all-lower spelling restores 1.4.0's reading, and the +# mixed-case spelling is the cost side of the same path. +# +# Literal, two names; 'Smith, MENG' keeps the credential on the +# capitals lean, and 'Smith, John MEng' belongs to the middle-name +# rule above -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, (?:MEng|meng)$" +fields = ["given", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" +# 'john smith meng' and 'nguyen van lac': every role is what this +# baseline read -- suffix 'meng', suffix 'lac' -- because a full name +# in front leaves the credential reading available, and what is new +# is the report rules.md#S2 makes at the trailing slot, where "either +# reading carries the ambiguity flag". `_ambiguities` alone, so this +# rule cannot absorb a ROLE diff on either name, which is the point +# of keeping it apart from the two above. +name_regex = "^(?:john smith meng|nguyen van lac)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.3.0.toml b/tools/differential/expected_since_2.3.0.toml index 7415ed28..70331523 100644 --- a/tools/differential/expected_since_2.3.0.toml +++ b/tools/differential/expected_since_2.3.0.toml @@ -968,3 +968,109 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Five rules -- +# the two names whose family name comes back, the marking's cost +# behind a full name, the same cost after a family comma, the lone +# word after a family comma (given name back, cost for the +# conventional spelling), and the two that keep the credential and +# gain a report. No rule of the parser changed: the two words joined +# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads +# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the +# name has words to spare". decisions.md#suffix-acronym-collisions +# records the decision, and the removal and the masks it declined. +# +# Every name here is one of #540's own shape-tagged case rows. No +# corpus name written before the change carried a bare trailing +# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at +# 120033b5), so the gate saw nothing until the rows admitted it. +# One set of five rules for the four 2.x ledgers; the 1.4.0 ledger +# carries the two accepted rules alone, 1.4.0 having read the +# other six names' roles as the tree does. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" +# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and +# 'tran', 'lac') with a suffix-or-name report, where this baseline +# read suffix 'meng' and no family name at all -- reporting nothing +# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the +# one-word-name fork, which was the wrong fork. 1.4.0 read the +# family, which is why that ledger has no copy of this rule. +# +# Literal-anchored to the two names. The class is every name whose +# last word is a bare member with nothing to spare, and a regex for +# it would stand ready to explain a regression on the spellings that +# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py +# carries 'meng li', where the word LEADS and nothing moved. The +# members are names, not a copy of any wordlist, which is what +# _NOT_A_VOCABULARY_COPY records. +name_regex = "^(?:tran lac|wang meng)$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with +# a suffix-or-name report, where every release read suffix 'MEng'. +# The marking's stated cost. In a name written in more than one case +# rules.md#S2 reads a member "written in any other cased form that is +# not wholly lower" as the name "even with words to spare", and MEng +# and LAc are conventionally written in exactly that form -- the cost +# 'John Smith Ma' already carries. +# +# A rule of its own rather than a member of the one above, so the +# gate's summary and the release note name the cost under its own +# heading. Literal, one name: the one-case spellings 'john smith +# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding +# where the writing says nothing, and are _MUST_NOT_MATCH probes. +name_regex = "^john smith MEng$" +fields = ["family", "middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# with a suffix-or-name report, where every release read suffix +# 'MEng'. The same S2 case reading the rule above pins, arriving +# after a family comma, where C1 has already settled the word count +# and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" +# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family +# 'Smith', with a suffix-or-name report, where this baseline read +# suffix and no given name at all -- the third comma path, S2 +# declining the post-comma credential when the word count leaves it +# a name and C1 reading it as the given name. 1.4.0 already read +# the given name here, so that ledger carries no copy of this rule: +# the all-lower spelling restores 1.4.0's reading, and the +# mixed-case spelling is the cost side of the same path. +# +# Literal, two names; 'Smith, MENG' keeps the credential on the +# capitals lean, and 'Smith, John MEng' belongs to the middle-name +# rule above -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, (?:MEng|meng)$" +fields = ["given", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" +# 'john smith meng' and 'nguyen van lac': every role is what this +# baseline read -- suffix 'meng', suffix 'lac' -- because a full name +# in front leaves the credential reading available, and what is new +# is the report rules.md#S2 makes at the trailing slot, where "either +# reading carries the ambiguity flag". `_ambiguities` alone, so this +# rule cannot absorb a ROLE diff on either name, which is the point +# of keeping it apart from the two above. +name_regex = "^(?:john smith meng|nguyen van lac)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] From 0b9f500f5db5c88567b9f670ee3684f16799e464 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Fri, 25 Sep 2026 00:56:01 -0700 Subject: [PATCH 2/5] docs(#540): the rule's examples, the decision and the release note rules.md#S2 gains four example lines for the two new members -- 'wang meng' as its non-firing boundary, 'tran lac', 'john smith meng' reading the credential with words to spare, and 'john smith MEng' reading the family name on the written case -- and corpus_rules.jsonl takes them; every one is already a shape-tagged case row, so the gate's counts do not move. decisions.md#suffix-acronym-collisions records the marking under the entry's own balance criterion, the fix on every comma path and under FAMILY_FIRST, the costs (the conventional mixed-case spellings behind a full name and after a family comma, the bracketed form), the Title-case 'Nguyen Van Lac' as the gain it is, and the two alternatives declined with their measurements: removing the words loses the bare credential outright, and masks, being role-free, re-spell the restored surnames 'Wang MEng' and 'Tran LAc'. edd and ded stay unmarked, borne as given names. The recount is 608 acronyms with 7 ambiguous, 5 before. The R4 Excluded block's 'to be filed' line goes, answered, with a dated supersession note on the sentences #540 overtook and the lac bearer corrected in place (a Vietnamese given name, the trailing word in native order). The release note states the fix and every cost with its before and after. Closes #540 Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 7 ++++--- docs/design/rules.md | 4 ++++ docs/release_log.rst | 2 ++ tools/differential/corpus_rules.jsonl | 4 ++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 348bf3f2..04fc4d13 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -608,6 +608,9 @@ Closes #342 (a wordlist question) and #454 (a rules.md question) together, becau - **Measurement (2026-09-07).** Five corpus names move, every one from the radar-tier corpus_issues.jsonl, in three diff shapes with one cause: Aishwarya Rai moves {family, suffix}, Lala Lajpat Rai and John Smith RAI move {middle, family, suffix}, and John Smith, RAI and Ahmad Jayadi, CHA move {given, family, suffix}. Recompute by parsing every name in the tools/differential/corpus*.jsonl glob twice — once with the shipped lexicon, once with `Lexicon.default().add(suffix_acronyms={"rai","cha"})`, which RESTORES the two entries this bundle removed so the diff read forwards is the removal's — and diffing the seven name fields plus `ambiguities`; the ba step of the same sweep moves nothing. Aishwarya Rai is the one name whose diff DISAPPEARS at 1.4.0, that release having read family Rai too, which is why the `fix(#342) NOT WANTED` ledger rule was deleted rather than rewritten and why the 1.4.0 gate lists four names under the replacement where the three 2.x ledgers list five. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; they rose by three at 1.4.0 and by five at each 2.x baseline. - **2026-09-15 (#289/#516) — the parking lot is CLOSED, and this entry's "measured 2026-09-07, none of the four is read as a suffix today" sentence is superseded rather than rewritten.** The shape-plus-position heuristic this entry parked is what shipped: the DOTTED shape as `Policy.unlisted_dotted_suffixes`, default on, and the ALL-CAPS shape as `Policy.unlisted_caps_suffixes`, default off. Of the four names the bullet measured, `John Smith X.Y.Z.` and `john smith x.y.z.` now read suffix at the default, and `John Smith XYZ` / `John Smith, XYZ` read suffix only with the opt-in switch. See #S2 for the whole decision and for why the two halves have different defaults. - **2026-09-15 (#289/#516) — the roman-numeral accident is RETIRED, and here is what narrowly means.** This entry's bullet recorded the accident as evidence rather than as behavior anyone relied on. It now fires no longer where every chunk the vocabulary matches is a single ASCII character — measured, `{i, v, 2}` — so `John Smith R.A.I.` and `John Smith X.Y.I.` reach the shape class instead and read by position, which gives the same fields for the first and 1.4.0's family reading for `Jack X.Y.I.`. `John Smith C.H.A.`, the bullet's other half, is unaffected: nothing claimed it before or after. The WIDE retirement was measured and rejected; #S2 records the three names that pay for it. +- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules.md#S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count; bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). +- **Declined 2026-09-25 (#540), with measurement: removing the two words, and masking them.** REMOVAL, the rai/cha answer, measured with `Parser(lexicon=Lexicon.default().remove(suffix_acronyms={"meng", "lac"}, suffix_acronyms_ambiguous={"meng", "lac"}))`: `john smith meng` reads middle `smith`, family `meng`, and `JOHN SMITH MENG` family `MENG`, while `john smith m.eng.` stays a suffix by its dotted shape (S3), reported. Removal loses the bare credential outright where the marking keeps it with words to spare, and C-i's default under uncertainty is the marking. MASKS `meng → MEng` and `lac → LAc`, measured with both added to a private lexicon on top of the marking: the exceptions map is role-free (#R4, 2026-09-23), so `wang meng` renders `Wang MEng`, `tran lac` `Tran LAc` and `meng li` `MEng Li` — the mask re-spells the very surnames the marking restores. A suffix-gated mask would reverse the 2026-09-23 role-free decision for two words and is not taken. Without one, the default path renders `wang meng` as `Wang Meng` (the parent gave `Wang MENG`, the credential clause writing a suffix in capitals), `tran lac` as `Tran Lac`, `john smith meng` as `John Smith MENG` and `nguyen van lac` as `Nguyen Van LAC` by the acronym clause, and keeps a writer's `MEng` as written (#R5, 2026-09-24) — `john smith MEng` gives `john smith MEng`, and `John Smith Meng` under `force=True`, the word being the family name there. Pinned by `tests/v2/test_render.py::test_a_listed_acronym_that_is_a_name_word_gets_no_mask`, whose recorded negative control carries the two masks. +- **Measurement (2026-09-25, #540).** No corpus name written before this change carries a bare trailing `meng` or `lac` (the `tools/differential/corpus*.jsonl` glob at 120033b5), so the population that could move is this change's own shape-tagged case rows, and every mover is one of them. Classified by `fix(#540)` rules: at each 2.x baseline `wang meng` and `tran lac` move {family, suffix, _ambiguities}, `john smith MEng` {middle, family, suffix, _ambiguities}, and `john smith meng` and `nguyen van lac` `_ambiguities` alone; `Smith, John MEng` moves {middle, suffix, _ambiguities} at 2.x and {middle, suffix} at 1.4.0, and `Smith, meng` and `Smith, MEng` move {given, suffix, _ambiguities} at 2.x and nothing at 1.4.0 (1.4.0's own reading); at 1.4.0 otherwise only `john smith MEng` moves, the other four reading their roles as 1.4.0 did and 1.4.0 comparing no ambiguities. `nguyen van lac` also diffs at 1.4.0 on `_initials` (`n.` → `n. v.`) for fix(#385/#402)'s reason — its family `van` is a one-particle part — and joins that rule's literal list. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; this change raised them by three at 1.4.0 and by eight at each 2.x baseline. ### S2 — the case signal at the suffix slot @@ -1386,9 +1389,7 @@ Accepted costs, deferred to the rescoped #459 rather than relitigated here: the Excluded (CAPITALIZATION_EXCEPTIONS — meng, edd, lac, ded, left out of the 2026-09-24 masks, #459): -- `meng` (MEng) and `edd` (EdD) are conventionally mixed-case credentials and listed acronyms, and must not get a mask while the map stays role-free: a mask applies in every role, and both are borne as given names or surnames. Measured 2026-09-24 with each mask added to a private lexicon: `MENG LI` gives `MEng Li`, `meng wang` gives `MEng Wang`, `edd smith` gives `EdD Smith` and `EDD KIMBER` gives `EdD Kimber`, where the shipped map and every release give `Meng Li`, `Meng Wang`, `Edd Smith` and `Edd Kimber`. The same holds for two sweep candidates, `lac` (LAc, Licensed Acupuncturist; `Lac` is a Vietnamese and French surname) and `ded` (DEd; `Ded` is an Albanian given name, `ded gjo luli` giving `DEd Gjo Luli` under the mask). A writer who spells one mixed-case keeps it (decisions.md#R5, 2026-09-24); a caller who wants the mask adds it to a private lexicon. A NOTE on `SUFFIX_ACRONYMS` itself, which this map does not decide. The two name-word collisions there come from two bulk additions, not one: `lac` (with `edd` and `ded`) from the Wikipedia post-nominals import, `af5bdab` (2019-12-11, #93), and `meng` from `3e14ea20` (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). Both read as a suffix after a given name, and lose a family name to it. Measured 2026-09-24 on the released wheels, the parent 4d0680e6 and this tree: `wang meng`, `li meng` and `zhang meng` read family `meng` on 1.4.0 and suffix `meng`, with no family, on every release from 2.0.0; and since #521 on master the suffix renders `Wang MENG` (parent and tree alike), where 2.3.0 gave `Wang Meng`. `nguyen van lac` reads suffix `lac` with family `van` on every release from 1.4.0, and since #521 renders `Nguyen Van LAC` where 2.3.0 gave `Nguyen Van Lac`. This is a #vocabulary-collisions C-i question about the acronym set, not about this map. - -Open: a follow-up issue on `SUFFIX_ACRONYMS`'s `meng`/`lac` name-word collision is to be filed (the note above carries the measurements). +- `meng` (MEng) and `edd` (EdD) are conventionally mixed-case credentials and listed acronyms, and must not get a mask while the map stays role-free: a mask applies in every role, and both are borne as given names or surnames. Measured 2026-09-24 with each mask added to a private lexicon: `MENG LI` gives `MEng Li`, `meng wang` gives `MEng Wang`, `edd smith` gives `EdD Smith` and `EDD KIMBER` gives `EdD Kimber`, where the shipped map and every release give `Meng Li`, `Meng Wang`, `Edd Smith` and `Edd Kimber`. The same holds for two sweep candidates, `lac` (LAc, Licensed Acupuncturist; `Lac` is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; corrected 2026-09-25, #540, from "a Vietnamese and French surname") and `ded` (DEd; `Ded` is an Albanian given name, `ded gjo luli` giving `DEd Gjo Luli` under the mask). A writer who spells one mixed-case keeps it (decisions.md#R5, 2026-09-24); a caller who wants the mask adds it to a private lexicon. A NOTE on `SUFFIX_ACRONYMS` itself, which this map does not decide. The two name-word collisions there come from two bulk additions, not one: `lac` (with `edd` and `ded`) from the Wikipedia post-nominals import, `af5bdab` (2019-12-11, #93), and `meng` from `3e14ea20` (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). Both read as a suffix after a given name, and lose a family name to it. Measured 2026-09-24 on the released wheels, the parent 4d0680e6 and this tree: `wang meng`, `li meng` and `zhang meng` read family `meng` on 1.4.0 and suffix `meng`, with no family, on every release from 2.0.0; and since #521 on master the suffix renders `Wang MENG` (parent and tree alike), where 2.3.0 gave `Wang Meng` (superseded 2026-09-25 by #540: both words are marked ambiguous, `wang meng` reads family `meng` and renders `Wang Meng` — see #suffix-acronym-collisions). `nguyen van lac` reads suffix `lac` with family `van` on every release from 1.4.0, and since #521 renders `Nguyen Van LAC` where 2.3.0 gave `Nguyen Van Lac`. This is a #vocabulary-collisions C-i question about the acronym set, not about this map. Excluded (CAPITALIZATION_EXCEPTIONS — md, ii, iii, iv, removed 2026-09-23, #459): diff --git a/docs/design/rules.md b/docs/design/rules.md index 9ca3d42d..20bf1437 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -1039,6 +1039,10 @@ S2. Rationale: generational suffixes and credentials are recognized "JACK MA" → family="MA" "JOHN SMITH MA" → suffix="MA" "John Smith Ma" → family="Ma" + "wang meng" → family="meng" · boundary + "tran lac" → family="lac" + "john smith meng" → suffix="meng" + "john smith MEng" → family="MEng" "Smith, MA" → suffix="MA" "Smith, Ma" → given="Ma" "Doe, John MA" → suffix="MA" diff --git a/docs/release_log.rst b/docs/release_log.rst index 43a553cf..1dd1f347 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -12,6 +12,8 @@ Release Log - **Fix a credential acronym that is also a surname being read by position alone.** ``HumanName("Jack MA")`` gives suffix ``MA`` where 2.0 through 2.3 gave last ``MA``, and ``John Smith Ma`` gives last ``Ma`` where they gave suffix ``Ma``. In a name written in more than one case, an ambiguous acronym written in capitals is written the way a credential is written and is read as one even where removing it leaves no surname; one written in any other cased form that is not wholly lower is written the way a surname is written and stays one even where there are words to spare (``John Smith ma`` and ``John Smith ed`` -- all lower, no contrast -- give suffix ``ma``/``ed`` instead). A name written wholly in one case says nothing either way and keeps the reading it had: ``JOHN SMITH MA`` is still a credential, ``ANH DO`` still a surname, ``jack ma`` still a surname. The same reading reaches the comma forms, where the words-to-spare count is now a count of NAME words: ``Smith, MA`` gives last ``Smith``, suffix ``MA``; ``Smith Jr., MA`` keeps last ``Smith``; and ``John Smith, MA``, ``John Smith, Ed``, ``john smith, ma`` and ``JOHN SMITH, MA`` all give a suffix again, which is what 1.4.0 read and 2.0 through 2.3 did not. ``Jack Ma`` and ``Anh Do`` are unchanged. The LEAN is inert on a caseless script, but the comma count above is not -- it asks name-word count, not case -- so ``마틴 킹, MA`` and ``田中 太郎, MA`` also give a suffix again (1.4.0 parity on the suffix, two pre-comma name words each) while the single-token ``毛泽东, MA`` does not move, having no case to write a contrast in either way. See the ``S2`` entry of ``docs/design/decisions.md`` (closes #289) + - **Fix a bare trailing Meng or Lac being read as a credential and losing the family name: meng and lac are now acronyms that are also ordinary names.** ``HumanName("wang meng")`` gives first ``wang``, last ``meng``, and ``parse()`` reports a suffix-or-name ambiguity, where every release from 2.0.0 through 2.3.0 gave suffix ``meng`` and no last name; 1.4.0 read last ``meng``, so this is 1.4.0's answer plus the flag. ``li meng`` and ``tran lac`` move the same way, ``Wang, Meng`` gives first ``Meng``, last ``Wang`` again, and ``Parser(policy=Policy(name_order=FAMILY_FIRST)).parse("Wang Meng")`` gives given ``Meng`` where 2.0.0 through 2.3.0 gave family ``Wang``, suffix ``Meng`` and no given name. With a full name in front the credential reading stays: ``john smith meng`` and ``nguyen van lac`` keep suffix ``meng`` and ``lac``, now flagged. But a Title-case ``Nguyen Van Lac`` gives last ``Van Lac`` where every release gave last ``Van``, suffix ``Lac``. The cost is the marking's own, and it falls on the conventional spellings: a ``MEng`` or ``LAc`` written that way, in a name written in more than one case, is read the way ``John Smith Ma`` is (above), so ``John Smith MEng`` gives middle ``Smith``, last ``MEng``, where every release gave suffix ``MEng``. After a comma ``Smith, MEng`` and ``Smith, meng`` give first ``MEng`` and ``meng`` (1.4.0's reading, not the suffix 2.0 through 2.3 gave) and ``Smith, John MEng`` gives middle ``MEng`` (every release gave suffix ``MEng``), and a bracketed ``John Smith (MEng)`` falls through to nickname, where every release gave suffix ``MEng``. Write ``MENG``, or put the credential after a comma behind a full name (``John Smith, MEng``), to keep the credential reading. Meng is a common Chinese surname and given name, Lac a Vietnamese given name (``Nguyen Van Lac``) and a French surname; see the ``suffix-acronym-collisions`` entry of ``docs/design/decisions.md`` (closes #540) + - **New Policy field unlisted_dotted_suffixes, on by default: a dotted acronym nobody has listed is read by position.** ``HumanName("John Smith X.Y.Z.")`` gives suffix ``X.Y.Z.`` where every release gave last ``X.Y.Z.``, while ``Jack X.Y.Z.`` keeps its surname, the same words-to-spare rule a listed acronym takes -- and both readings are reported. Case is irrelevant here: the periods are the signal, so ``john smith x.y.z.`` reads the same way. Words the vocabulary does know are untouched (``M.A.``, ``Ph.D.``, ``A.B.C.``), a single trailing period is still not this shape (``John Smith Xyz.`` keeps last ``Xyz.``), and a dotted run at the FRONT of a name is untouched (``J.R.R. Tolkien``). One accident retires with it: a dotted word whose only vocabulary matches were SINGLE ASCII CHARACTERS -- the roman numerals the suffix list holds, and the lone digit ``2`` -- was reading as a generational suffix, so ``Jack X.Y.I.`` gives last ``X.Y.I.`` again, as 1.4.0 read it, while ``Msc.Ed.``, ``JD.CPA`` and ``Lt.Gov.`` are unchanged. The digit is why a dotted VERSION STRING moves with them and moves SILENTLY: ``John Smith 1.4.2`` gives last ``1.4.2`` where 2.3 gave suffix ``1.4.2``, and ``John Smith, 1.4.2`` gives first ``1.4.2``, last ``John Smith``. Such a token reports nothing at any policy -- it is no acronym either, the shape reading wanting every chunk alphabetic -- and a version string read as a credential was the same accident this retirement removes. That retirement is NOT behind this switch and stands either way -- setting it to ``False`` reads an unlisted dotted word as name material by position instead (``John Smith X.Y.Z.`` keeps last ``X.Y.Z.``), the pre-2.4 reading for THAT half alone. See the ``S2`` and ``suffix-acronym-collisions`` entries of ``docs/design/decisions.md`` (closes #516) - **New Policy field unlisted_caps_suffixes, off by default: an opt-in reading for an unlisted all-caps credential.** It reaches the core parser only -- ``Parser(policy=Policy(unlisted_caps_suffixes=True))`` -- since the field has no v1 ``Constants`` manager. With it on, ``.parse("John Smith XYZ")`` gives given ``John``, last ``Smith``, suffix ``XYZ``, and ``.parse("John Smith, XYZ")`` gives the same three fields. It is off by default because an all-caps surname is a real writing convention that shape cannot separate from a credential: ``Jean DUPONT``, ``Minjun KIM`` and ``Jean Pierre DUPONT`` are surnames in French and Korean records, and the last of those gives given ``Jean``, last ``Pierre``, suffix ``DUPONT`` with the switch on. Off, nothing changes and nothing is reported -- 1.4.0's reading for that whole class. Neither of the two new fields reaches the v1 ``Constants`` API, as ``lenient_comma_suffixes`` does not: a ``HumanName`` tracks the parser's own DEFAULTS, so the dotted reading above (default on) reaches it while this one (default off) cannot be turned on from there. See the ``S2`` entry of ``docs/design/decisions.md`` (closes #516) diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 7088c901..d6cdd1ce 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -343,6 +343,7 @@ "john doe q.c." "john e smith" "john smith EdD" +"john smith MEng" "john smith bsc" "john smith ed" "john smith iii" @@ -353,6 +354,7 @@ "john smith mba" "john smith mcse" "john smith md" +"john smith meng" "john smith p.h.d." "john smith ph. d." "john smith ph.d." @@ -380,9 +382,11 @@ "mohamad ali smith" "née Jones" "qc mp" +"tran lac" "van Berg Jan de" "van Gogh" "van der Berg, abdul née Jones" +"wang meng" "Иван Петрович Абрамович" "Сидоров Иван Петрович" "Хосе И Мария Сантос" From 24d528e64159b45b19470830ec097b8fe2be278c Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Fri, 25 Sep 2026 01:21:22 -0700 Subject: [PATCH 3/5] docs(#540): the whole-branch review round Two costs the marking carries were stated nowhere: a credential run ending in MEng or LAc is pulled into the name ('Mary Jones PhD MEng' reads middle 'Jones PhD', family 'MEng', where every release read the suffix run), rules.md#S2's declined pick stopping the walk as it does for 'Doe, John MA Ma'; and after a family comma a title-shaped credential in front of one reads as a title with no report ('Jane Doe, MS LAc' reads title 'MS', given 'LAc', unflagged), a pre-existing silent path 'Jane Doe, MS Ma' already takes on 2.3.0. The release note and decisions.md#suffix-acronym-collisions name both, the note's workaround narrows to a lone credential, and the silent path is to be filed as a follow-up. suffixes.py's ambiguous-set comment said a bare member counts as a suffix only when written with periods, false since #289 and contradicted by this branch's own 'john smith meng' row; it now points at rules.md#S2, and the docstring and customize.rst widen 'nickname' to a word borne as a name. AGENTS.md's list of decided collisions gains the two words. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- docs/customize.rst | 6 +++--- docs/design/decisions.md | 2 +- docs/release_log.rst | 2 +- nameparser/config/suffixes.py | 13 ++++++++----- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b8606fc..23444945 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,7 +263,7 @@ The library has two layers: `nameparser/config/` (data) and `nameparser/parser.p **Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Five defaults fall out of it, plus a sixth that applies the same not-a-guess standard to specific WORDS rather than to a script (#308's honorific peel, below). Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order. And the 间隔号 U+00B7 (#298) is the transcription marker for scripts that HAVE no transcription script: a name it divides (威廉·莎士比亚 — flanked by classified characters on both sides, so Catalan's Gal·la is untouched) keeps its source order and never segments — the orthography names the convention, exactly as pure katakana does, with the divider carrying the signal instead of the script. And a listed CJK honorific glued to the END of a name token is split off it (#308) — 田中さん is 田中 plus さん — on the same orthography-settles-it test, narrowed for the glued position: an entry peels only where it can never end a name, so 씨/님/さん/様/先生 peel while 양/군/氏/博士/殿 stay spaced-only (김지양 and 田中博士 are names, and ~90 Japanese surnames end in 殿) and 君 is in NEITHER set (王君 is a complete Chinese name), though its kana spelling くん peels. Like the nakaguro's tokenize-level separation described next, it is reached by neither policy opt-out — but for its own reason: the vocabulary carries the license itself rather than borrowing the script's, so `segment_scripts` has nothing to say about it. Since #312 it also crosses the 间隔号, which still stops the surname split standing right beside it: it answers where a name DIVIDES into surname and given, and the peel never asks that question. Whether it also crosses the FAMILY comma is tolerated rather than settled: the 2026-09-01 demotion (rules.md#W3) narrowed that half from contract to best-effort, since no CJK writing system's own convention puts a comma between family and given at all — so `김, 민준씨` reads today exactly as the spaced `김 민준씨` does (family 김, given 민준, suffix 씨) while the split stands down as before, but that reading is watched on the differential's radar tier rather than pinned as contract. Its site is accordingly the name-bearing segment runs — `segments[:2]` under a family comma, and `segments[0]` as before otherwise, the family comma being the one structure that splits the name itself across two runs, with the honorific as often glued to the given side as to the family. That is the whole reach and nothing past it (`김, 민준 지훈씨` peels; `김, 민준, 지훈씨` and `김,, 민준씨` do not, both landing in a third run), and whether `segments[1]` is name text at all is now ASKED rather than inferred from the structure — `segment` does not guarantee it, since a one-word part before the comma reads as FAMILY_COMMA even when the part after it is entirely suffix-shaped, and the peel walking into such a run took `V.` for its site, found no listed tail and abandoned (#319). The question is `segment`'s own suffix-comma predicate, lifted into `_vocab.is_wholly_suffix` so the two stages cannot drift: a wholly suffix-shaped second run is declined and the scan stays in `segments[0]`, so `田中さん, V.` and `田中さん, Ph. D.` give さん up as `田中さん, PhD` always did. The test is necessary but NOT sufficient, and the second condition is not decoration: every honorific tail is also a suffix word, so a glued honorific is itself part of what makes its run read as suffix-shaped, and declining a run that holds the ONLY site loses the peel outright. `segments[0]` must therefore offer a peel site of its own before the second run is declined — `이, J.씨` and `선생님, J.씨` pass the suffix test and are scanned anyway, keeping the pre-#319 reading, while `김민준씨, J.씨` has a site on both sides and peels the person's own 씨 rather than the junk one behind the comma. Uniform in the PEEL, that is — where the credential itself lands is `assign`'s question and still differs by spelling (`V.` → `given`, `PhD` and `Ph. D.` → `suffix`). Not `_is_post_nominal` pluralized: the run predicate says yes both to what the token predicate vetoes (`V.`, `V`, `I` — the class the defect was reported as) and to what the token predicate never sees at all, since `period_joined_vocab` and the delimiter routes are the run predicate's alone (`Msc.Ed.` and `J.씨` reach it that way, and `田中さん, Msc.Ed.` moves with the rest). `Policy(lenient_comma_suffixes=False)` drops this call to the strict token test too — so those three read as name text again and keep the pre-#319 answer, while `Ph. D.` peels under the knob regardless, its merged `phd` passing the strict test. `田中さん, 太郎` is unchanged, and not because of its comma — the honorific there is not at the end of the name, 太郎 is. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=()`) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) needs the flanked-by-classified-script guard `_tokenize_region` gives it (#298). Han segmentation stays OPT-IN (`locales.ZH` for Chinese, `locales.JA` for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so `locales.JA` activates the stage and a pluggable `Parser(segmenter=...)` does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes. -**A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23), as were `rai`, `cha`, `ba` and `mc` before `decisions.md#suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions.md#vocabulary-collisions`, with #360's positional qualifier. +**A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23), as were `rai`, `cha`, `ba` and `mc` before `decisions.md#suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. The same entry marked `meng` and `lac` ambiguous on 2026-09-25 (#540). When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions.md#vocabulary-collisions`, with #360's positional qualifier. **Sweep the forms a change can reach: comma shapes, then `name_order`.** No comma, a FULL name before the comma, and a ONE-WORD name before the comma are three paths, and the third is the miss — #429, #430 and #432 are all that path disagreeing with the full-name path on inputs the full-name path reads correctly. For orders the sweep already exists (`tests/v2/test_cases.py` runs every row under all three) but carries one assertion, R2's family partition, so it checks nothing a new change moves; coverage there has been vacuous before (PR #394's review found the suite passed with `name_order` discarded from grouping). Parse your change's names in each comma shape and each order, and read the ones you did not predict. diff --git a/docs/customize.rst b/docs/customize.rst index c72342db..54430543 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -228,9 +228,9 @@ Words that are also ordinary names Three fields — ``suffix_acronyms_ambiguous``, ``particles_ambiguous`` and ``conjunctions_ambiguous`` — mark entries from ``suffix_acronyms``, ``particles`` and ``conjunctions`` that are also plausible as ordinary -name words on their own (an acronym suffix that doubles as a nickname, -a particle that doubles as a given name, a connective letter that -doubles as an initial). They don't add new vocabulary by themselves; +name words on their own (an acronym suffix that is also +borne as a name, a particle that doubles as a given name, a connective +letter that doubles as an initial). They don't add new vocabulary by themselves; they narrow how an existing entry is read when it appears alone. If you're not sure whether a word you're adding is one of these ambiguous cases, weigh how often it is a diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 04fc4d13..42bd3e80 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -608,7 +608,7 @@ Closes #342 (a wordlist question) and #454 (a rules.md question) together, becau - **Measurement (2026-09-07).** Five corpus names move, every one from the radar-tier corpus_issues.jsonl, in three diff shapes with one cause: Aishwarya Rai moves {family, suffix}, Lala Lajpat Rai and John Smith RAI move {middle, family, suffix}, and John Smith, RAI and Ahmad Jayadi, CHA move {given, family, suffix}. Recompute by parsing every name in the tools/differential/corpus*.jsonl glob twice — once with the shipped lexicon, once with `Lexicon.default().add(suffix_acronyms={"rai","cha"})`, which RESTORES the two entries this bundle removed so the diff read forwards is the removal's — and diffing the seven name fields plus `ambiguities`; the ba step of the same sweep moves nothing. Aishwarya Rai is the one name whose diff DISAPPEARS at 1.4.0, that release having read family Rai too, which is why the `fix(#342) NOT WANTED` ledger rule was deleted rather than rewritten and why the 1.4.0 gate lists four names under the replacement where the three 2.x ledgers list five. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; they rose by three at 1.4.0 and by five at each 2.x baseline. - **2026-09-15 (#289/#516) — the parking lot is CLOSED, and this entry's "measured 2026-09-07, none of the four is read as a suffix today" sentence is superseded rather than rewritten.** The shape-plus-position heuristic this entry parked is what shipped: the DOTTED shape as `Policy.unlisted_dotted_suffixes`, default on, and the ALL-CAPS shape as `Policy.unlisted_caps_suffixes`, default off. Of the four names the bullet measured, `John Smith X.Y.Z.` and `john smith x.y.z.` now read suffix at the default, and `John Smith XYZ` / `John Smith, XYZ` read suffix only with the opt-in switch. See #S2 for the whole decision and for why the two halves have different defaults. - **2026-09-15 (#289/#516) — the roman-numeral accident is RETIRED, and here is what narrowly means.** This entry's bullet recorded the accident as evidence rather than as behavior anyone relied on. It now fires no longer where every chunk the vocabulary matches is a single ASCII character — measured, `{i, v, 2}` — so `John Smith R.A.I.` and `John Smith X.Y.I.` reach the shape class instead and read by position, which gives the same fields for the first and 1.4.0's family reading for `Jack X.Y.I.`. `John Smith C.H.A.`, the bullet's other half, is unaffected: nothing claimed it before or after. The WIDE retirement was measured and rejected; #S2 records the three names that pay for it. -- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules.md#S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count; bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). +- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules.md#S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. A CREDENTIAL RUN ending in one of them pays the same walk-stopping cost: `Mary Jones PhD MEng` reads middle `Jones PhD`, family `MEng`, and `Jane Doe MS LAc` reads middle `Doe MS`, family `LAc`, where every release read the suffix run whole (`PhD MEng`, `MS LAc`); the mechanism is S2's own — the case signal costs a genuine suffix standing behind a name-leaning acronym, the walk stopping at the declined pick rather than continuing past it — pinned by `tests/v2/cases.py`'s `a_declined_ambiguous_pick_stops_the_walk` and, for the family-comma shape, `a_declined_member_ends_the_trailing_run` (`Doe, John MA Ma`). A QUIETER COST sits after a family comma: a title-shaped credential standing in front of it reads as a TITLE with no report — `Jane Doe, MS LAc` reads title `MS`, given `LAc`, family `Jane Doe`, unflagged, where every release read suffix `MS LAc` — and the path is not new: `Jane Doe, MS Ma` already reads that way on 2.3.0, `ma` having been ambiguous there too. A follow-up issue on the silent title-dual path after a family comma is to be filed. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count; bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). - **Declined 2026-09-25 (#540), with measurement: removing the two words, and masking them.** REMOVAL, the rai/cha answer, measured with `Parser(lexicon=Lexicon.default().remove(suffix_acronyms={"meng", "lac"}, suffix_acronyms_ambiguous={"meng", "lac"}))`: `john smith meng` reads middle `smith`, family `meng`, and `JOHN SMITH MENG` family `MENG`, while `john smith m.eng.` stays a suffix by its dotted shape (S3), reported. Removal loses the bare credential outright where the marking keeps it with words to spare, and C-i's default under uncertainty is the marking. MASKS `meng → MEng` and `lac → LAc`, measured with both added to a private lexicon on top of the marking: the exceptions map is role-free (#R4, 2026-09-23), so `wang meng` renders `Wang MEng`, `tran lac` `Tran LAc` and `meng li` `MEng Li` — the mask re-spells the very surnames the marking restores. A suffix-gated mask would reverse the 2026-09-23 role-free decision for two words and is not taken. Without one, the default path renders `wang meng` as `Wang Meng` (the parent gave `Wang MENG`, the credential clause writing a suffix in capitals), `tran lac` as `Tran Lac`, `john smith meng` as `John Smith MENG` and `nguyen van lac` as `Nguyen Van LAC` by the acronym clause, and keeps a writer's `MEng` as written (#R5, 2026-09-24) — `john smith MEng` gives `john smith MEng`, and `John Smith Meng` under `force=True`, the word being the family name there. Pinned by `tests/v2/test_render.py::test_a_listed_acronym_that_is_a_name_word_gets_no_mask`, whose recorded negative control carries the two masks. - **Measurement (2026-09-25, #540).** No corpus name written before this change carries a bare trailing `meng` or `lac` (the `tools/differential/corpus*.jsonl` glob at 120033b5), so the population that could move is this change's own shape-tagged case rows, and every mover is one of them. Classified by `fix(#540)` rules: at each 2.x baseline `wang meng` and `tran lac` move {family, suffix, _ambiguities}, `john smith MEng` {middle, family, suffix, _ambiguities}, and `john smith meng` and `nguyen van lac` `_ambiguities` alone; `Smith, John MEng` moves {middle, suffix, _ambiguities} at 2.x and {middle, suffix} at 1.4.0, and `Smith, meng` and `Smith, MEng` move {given, suffix, _ambiguities} at 2.x and nothing at 1.4.0 (1.4.0's own reading); at 1.4.0 otherwise only `john smith MEng` moves, the other four reading their roles as 1.4.0 did and 1.4.0 comparing no ambiguities. `nguyen van lac` also diffs at 1.4.0 on `_initials` (`n.` → `n. v.`) for fix(#385/#402)'s reason — its family `van` is a one-particle part — and joins that rule's literal list. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; this change raised them by three at 1.4.0 and by eight at each 2.x baseline. diff --git a/docs/release_log.rst b/docs/release_log.rst index 1dd1f347..fe40cbc9 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -12,7 +12,7 @@ Release Log - **Fix a credential acronym that is also a surname being read by position alone.** ``HumanName("Jack MA")`` gives suffix ``MA`` where 2.0 through 2.3 gave last ``MA``, and ``John Smith Ma`` gives last ``Ma`` where they gave suffix ``Ma``. In a name written in more than one case, an ambiguous acronym written in capitals is written the way a credential is written and is read as one even where removing it leaves no surname; one written in any other cased form that is not wholly lower is written the way a surname is written and stays one even where there are words to spare (``John Smith ma`` and ``John Smith ed`` -- all lower, no contrast -- give suffix ``ma``/``ed`` instead). A name written wholly in one case says nothing either way and keeps the reading it had: ``JOHN SMITH MA`` is still a credential, ``ANH DO`` still a surname, ``jack ma`` still a surname. The same reading reaches the comma forms, where the words-to-spare count is now a count of NAME words: ``Smith, MA`` gives last ``Smith``, suffix ``MA``; ``Smith Jr., MA`` keeps last ``Smith``; and ``John Smith, MA``, ``John Smith, Ed``, ``john smith, ma`` and ``JOHN SMITH, MA`` all give a suffix again, which is what 1.4.0 read and 2.0 through 2.3 did not. ``Jack Ma`` and ``Anh Do`` are unchanged. The LEAN is inert on a caseless script, but the comma count above is not -- it asks name-word count, not case -- so ``마틴 킹, MA`` and ``田中 太郎, MA`` also give a suffix again (1.4.0 parity on the suffix, two pre-comma name words each) while the single-token ``毛泽东, MA`` does not move, having no case to write a contrast in either way. See the ``S2`` entry of ``docs/design/decisions.md`` (closes #289) - - **Fix a bare trailing Meng or Lac being read as a credential and losing the family name: meng and lac are now acronyms that are also ordinary names.** ``HumanName("wang meng")`` gives first ``wang``, last ``meng``, and ``parse()`` reports a suffix-or-name ambiguity, where every release from 2.0.0 through 2.3.0 gave suffix ``meng`` and no last name; 1.4.0 read last ``meng``, so this is 1.4.0's answer plus the flag. ``li meng`` and ``tran lac`` move the same way, ``Wang, Meng`` gives first ``Meng``, last ``Wang`` again, and ``Parser(policy=Policy(name_order=FAMILY_FIRST)).parse("Wang Meng")`` gives given ``Meng`` where 2.0.0 through 2.3.0 gave family ``Wang``, suffix ``Meng`` and no given name. With a full name in front the credential reading stays: ``john smith meng`` and ``nguyen van lac`` keep suffix ``meng`` and ``lac``, now flagged. But a Title-case ``Nguyen Van Lac`` gives last ``Van Lac`` where every release gave last ``Van``, suffix ``Lac``. The cost is the marking's own, and it falls on the conventional spellings: a ``MEng`` or ``LAc`` written that way, in a name written in more than one case, is read the way ``John Smith Ma`` is (above), so ``John Smith MEng`` gives middle ``Smith``, last ``MEng``, where every release gave suffix ``MEng``. After a comma ``Smith, MEng`` and ``Smith, meng`` give first ``MEng`` and ``meng`` (1.4.0's reading, not the suffix 2.0 through 2.3 gave) and ``Smith, John MEng`` gives middle ``MEng`` (every release gave suffix ``MEng``), and a bracketed ``John Smith (MEng)`` falls through to nickname, where every release gave suffix ``MEng``. Write ``MENG``, or put the credential after a comma behind a full name (``John Smith, MEng``), to keep the credential reading. Meng is a common Chinese surname and given name, Lac a Vietnamese given name (``Nguyen Van Lac``) and a French surname; see the ``suffix-acronym-collisions`` entry of ``docs/design/decisions.md`` (closes #540) + - **Fix a bare trailing Meng or Lac being read as a credential and losing the family name: meng and lac are now acronyms that are also ordinary names.** ``HumanName("wang meng")`` gives first ``wang``, last ``meng``, and ``parse()`` reports a suffix-or-name ambiguity, where every release from 2.0.0 through 2.3.0 gave suffix ``meng`` and no last name; 1.4.0 read last ``meng``, so this is 1.4.0's answer plus the flag. ``li meng`` and ``tran lac`` move the same way, ``Wang, Meng`` gives first ``Meng``, last ``Wang`` again, and ``Parser(policy=Policy(name_order=FAMILY_FIRST)).parse("Wang Meng")`` gives given ``Meng`` where 2.0.0 through 2.3.0 gave family ``Wang``, suffix ``Meng`` and no given name. With a full name in front the credential reading stays: ``john smith meng`` and ``nguyen van lac`` keep suffix ``meng`` and ``lac``, now flagged. But a Title-case ``Nguyen Van Lac`` gives last ``Van Lac`` where every release gave last ``Van``, suffix ``Lac``. The cost is the marking's own, and it falls on the conventional spellings: a ``MEng`` or ``LAc`` written that way, in a name written in more than one case, is read the way ``John Smith Ma`` is (above), so ``John Smith MEng`` gives middle ``Smith``, last ``MEng``, where every release gave suffix ``MEng``. A credential run ending in one of them goes the same way: ``Mary Jones PhD MEng`` gives middle ``Jones PhD``, last ``MEng``, where every release gave suffix ``PhD MEng`` (``rules.md#S2``'s declined pick stops the walk, the cost ``Doe, John MA Ma`` already pays). After a comma ``Smith, MEng`` and ``Smith, meng`` give first ``MEng`` and ``meng`` (1.4.0's reading, not the suffix 2.0 through 2.3 gave) and ``Smith, John MEng`` gives middle ``MEng`` (every release gave suffix ``MEng``), and a bracketed ``John Smith (MEng)`` falls through to nickname, where every release gave suffix ``MEng``. After a family comma a title-shaped credential in front of it is read as a title with no report: ``Jane Doe, MS LAc`` gives title ``MS``, first ``LAc``, last ``Jane Doe``, where every release gave suffix ``MS LAc``; the same path already reads ``Jane Doe, MS Ma`` that way. Write ``MENG``, or put a lone credential after a comma behind a full name (``John Smith, MEng``), to keep the credential reading. Meng is a common Chinese surname and given name, Lac a Vietnamese given name (``Nguyen Van Lac``) and a French surname; see the ``suffix-acronym-collisions`` entry of ``docs/design/decisions.md`` (closes #540) - **New Policy field unlisted_dotted_suffixes, on by default: a dotted acronym nobody has listed is read by position.** ``HumanName("John Smith X.Y.Z.")`` gives suffix ``X.Y.Z.`` where every release gave last ``X.Y.Z.``, while ``Jack X.Y.Z.`` keeps its surname, the same words-to-spare rule a listed acronym takes -- and both readings are reported. Case is irrelevant here: the periods are the signal, so ``john smith x.y.z.`` reads the same way. Words the vocabulary does know are untouched (``M.A.``, ``Ph.D.``, ``A.B.C.``), a single trailing period is still not this shape (``John Smith Xyz.`` keeps last ``Xyz.``), and a dotted run at the FRONT of a name is untouched (``J.R.R. Tolkien``). One accident retires with it: a dotted word whose only vocabulary matches were SINGLE ASCII CHARACTERS -- the roman numerals the suffix list holds, and the lone digit ``2`` -- was reading as a generational suffix, so ``Jack X.Y.I.`` gives last ``X.Y.I.`` again, as 1.4.0 read it, while ``Msc.Ed.``, ``JD.CPA`` and ``Lt.Gov.`` are unchanged. The digit is why a dotted VERSION STRING moves with them and moves SILENTLY: ``John Smith 1.4.2`` gives last ``1.4.2`` where 2.3 gave suffix ``1.4.2``, and ``John Smith, 1.4.2`` gives first ``1.4.2``, last ``John Smith``. Such a token reports nothing at any policy -- it is no acronym either, the shape reading wanting every chunk alphabetic -- and a version string read as a credential was the same accident this retirement removes. That retirement is NOT behind this switch and stands either way -- setting it to ``False`` reads an unlisted dotted word as name material by position instead (``John Smith X.Y.Z.`` keeps last ``X.Y.Z.``), the pre-2.4 reading for THAT half alone. See the ``S2`` and ``suffix-acronym-collisions`` entries of ``docs/design/decisions.md`` (closes #516) diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 0b6cc3e2..59300e71 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -225,8 +225,10 @@ # own -- a given name or nickname (e.g. 'jd', 'ed') or a common # surname (e.g. 'ma', 'do'). Unambiguous certifications/degrees # (e.g. 'mba', 'cpa', 'phd') don't need an entry. In 2.0 this set - # also gates bare recognition: an ambiguous acronym counts as a - # suffix only when written with periods ('M.A.' yes, 'Ma' no), so + # also gates bare recognition: a bare ambiguous acronym reads as + # the credential only with words to spare in front of it, or + # written in capitals inside a mixed-case name, and the parse + # reports the fork where it decides one (rules.md#S2), so # 'Jack Ma' keeps its family name. # # The other half of the criterion, added 2026-09-07 with #342. @@ -300,9 +302,10 @@ """ Acronym suffixes from SUFFIX_ACRONYMS that also plausibly collide with a -common given-name nickname. Not a partition of SUFFIX_ACRONYMS -- a small, -standalone exception list, read by the delimited-content escape in -``_pipeline/_extract.py`` and by ``_pipeline/_vocab.py``'s period gate. +word borne as a name -- a given name, nickname or surname. Not a +partition of SUFFIX_ACRONYMS -- a small, standalone exception list, read +by the delimited-content escape in ``_pipeline/_extract.py`` and by +``_pipeline/_vocab.py``'s period gate. """ SUFFIX_ACRONYMS = frozenset({ From 4ef0ee2d86e697f023ad280bdad7a8caabca5ea3 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Fri, 25 Sep 2026 21:45:12 -0700 Subject: [PATCH 4/5] fix(#540): the toolkit review round Three reviewers read the pull request (code, tests, comments); this is what they found. A credential run behind a suffix comma that ends in meng or lac is no longer wholly suffix-shaped, so C1 reads the comma as a family comma and the run's words fall into the post-comma name slots: "John Smith, PhD MEng" reads given 'PhD', middle 'MEng', family 'John Smith', and no case form saves it. It is the path "john smith, phd ma" already takes; the release note and decisions.md said "a title-shaped credential reads as a title", which was one corner of it. Two rows pin it, a union-fields ledger rule classifies it at every baseline, and the note's workaround now states what was measured: a lone credential, or a second comma before the run, keeps the credential reading; capitals do not. "Wang M.Eng." reads family 'M.Eng.': S2's period gate counts a member as unambiguous only written one period per letter, and MEng and LAc are the first members whose conventional dotted spelling is chunked. DECIDED (Derek, 2026-09-25): accepted and recorded rather than widening the gate; the question goes to the follow-up issue with the comma re-read. "Nguyen Van Lac" (family 'Van Lac', the gain, moving against 1.4.0 too) and "John Smith MEng PhD" (the cost with a credential behind it) were unpinned and now have rows and rules; the family rule gains a superstring probe, since dropping its anchors had passed every guard. Two claims were false on the wheels: "every release gave suffix 'PhD MEng'" (through 2.2 the joiner was a comma, 'PhD, MEng'), and the dated Excluded bullet's present-tense "Both read as a suffix", whose supersession note now follows it. suffixes.py's comment gains S2's third clause (not Title-case in a mixed-case name), its docstring stops enumerating readers, cases.py's roster comment points at the constant, and AGENTS.md's _initials sentence says what compare.py does: roles AND ambiguity kinds must agree. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 4 +- docs/design/decisions.md | 6 +- docs/design/rules.md | 2 + docs/release_log.rst | 2 +- nameparser/config/suffixes.py | 24 ++-- tests/v2/cases.py | 66 +++++++++- tests/v2/test_ledger_guards.py | 126 ++++++++++++++----- tools/differential/corpus_rules.jsonl | 2 + tools/differential/corpus_shapes.jsonl | 5 + tools/differential/expected_since_1.4.0.toml | 72 +++++++++-- tools/differential/expected_since_2.0.0.toml | 102 ++++++++++++--- tools/differential/expected_since_2.1.0.toml | 102 ++++++++++++--- tools/differential/expected_since_2.2.0.toml | 102 ++++++++++++--- tools/differential/expected_since_2.3.0.toml | 102 ++++++++++++--- 14 files changed, 598 insertions(+), 119 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 23444945..b79300dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Three committed contributor docs carry the parser's normative rules and their re **Counting claims.** A bare count in prose is either an assertion or a liability, keyed by who observes its staleness: asserted counts (a test holds the number) fail CI at change time — the useful kind; dated snapshots ("51 sites at spec time") cannot go stale; standing present-tense prose counts are the forbidden class — promote to an assertion, add a date, or state the invariant and let a test count. After changing how many times something runs, sweep for counts, not for the thing's name. -**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the differential gate's classified summary, be verified against rules.md examples, or -- for a view the gate cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. The classified summary covers the CONTRACT tier plus whatever radar diffs a rule classifies; a radar corpus's unmatched diffs are listed under UNCLASSIFIED (radar) and are not in it, so a claim quantified from the summary alone is silent about them. The gate compares the seven role fields, `_ambiguities` and -- since #484 -- `initials()`, under the `_initials` pseudo-field; so an initials-only change DOES show in the classified summary. But that pseudo-field sees only the names whose initials moved WHILE EVERY ROLE STAYED PUT -- main()'s roles-identical guard keeps it out of any diff a role is already in -- so a count taken from it is a FLOOR on initials movement, not the population: measured 2026-09-02 at 2.1.0 → tree on the v2 surface, 83 of the 1120 compared ENTRIES (1116 distinct names; seven entries carry a declared order rather than the default, and three strings are compared under more than one) changed their `initials()` string and only 28 were visible under `_initials`, the other 55 having moved a role as well. A bullet about how many names' initials changed still needs the recompute recipe. `capitalized()` and any other render view stay invisible to the gate (decisions.md#R4, #R3), and for those the first two sources still cannot reach a claim: a gate run is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. Per-rule ledger toml comments asserting PARSER behavior cite rule IDs under the excerpt discipline; free prose is for ledger mechanics only (owned by tools/differential/README.md). +**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the differential gate's classified summary, be verified against rules.md examples, or -- for a view the gate cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. The classified summary covers the CONTRACT tier plus whatever radar diffs a rule classifies; a radar corpus's unmatched diffs are listed under UNCLASSIFIED (radar) and are not in it, so a claim quantified from the summary alone is silent about them. The gate compares the seven role fields, `_ambiguities` and -- since #484 -- `initials()`, under the `_initials` pseudo-field; so an initials-only change DOES show in the classified summary. But that pseudo-field sees only the names whose initials moved WHILE EVERY ROLE AND EVERY AMBIGUITY KIND STAYED PUT -- main()'s roles-identical guard keeps it out of any diff a role or a report is already in -- so a count taken from it is a FLOOR on initials movement, not the population: measured 2026-09-02 at 2.1.0 → tree on the v2 surface, 83 of the 1120 compared ENTRIES (1116 distinct names; seven entries carry a declared order rather than the default, and three strings are compared under more than one) changed their `initials()` string and only 28 were visible under `_initials`, the other 55 having moved a role as well. A bullet about how many names' initials changed still needs the recompute recipe. `capitalized()` and any other render view stay invisible to the gate (decisions.md#R4, #R3), and for those the first two sources still cannot reach a claim: a gate run is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. Per-rule ledger toml comments asserting PARSER behavior cite rule IDs under the excerpt discipline; free prose is for ledger mechanics only (owned by tools/differential/README.md). **Working on docs/design/ has its own AGENTS.md.** `docs/design/AGENTS.md` carries the landing-a-design distillation checklist, the primary-source review rule, the dated-count convention, and the review axes. Claude Code loads it automatically when a session reads or edits anything under docs/design/; if your tool does not do nested discovery, read it yourself before touching those files or reviewing a change to them. @@ -263,7 +263,7 @@ The library has two layers: `nameparser/config/` (data) and `nameparser/parser.p **Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Five defaults fall out of it, plus a sixth that applies the same not-a-guess standard to specific WORDS rather than to a script (#308's honorific peel, below). Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order. And the 间隔号 U+00B7 (#298) is the transcription marker for scripts that HAVE no transcription script: a name it divides (威廉·莎士比亚 — flanked by classified characters on both sides, so Catalan's Gal·la is untouched) keeps its source order and never segments — the orthography names the convention, exactly as pure katakana does, with the divider carrying the signal instead of the script. And a listed CJK honorific glued to the END of a name token is split off it (#308) — 田中さん is 田中 plus さん — on the same orthography-settles-it test, narrowed for the glued position: an entry peels only where it can never end a name, so 씨/님/さん/様/先生 peel while 양/군/氏/博士/殿 stay spaced-only (김지양 and 田中博士 are names, and ~90 Japanese surnames end in 殿) and 君 is in NEITHER set (王君 is a complete Chinese name), though its kana spelling くん peels. Like the nakaguro's tokenize-level separation described next, it is reached by neither policy opt-out — but for its own reason: the vocabulary carries the license itself rather than borrowing the script's, so `segment_scripts` has nothing to say about it. Since #312 it also crosses the 间隔号, which still stops the surname split standing right beside it: it answers where a name DIVIDES into surname and given, and the peel never asks that question. Whether it also crosses the FAMILY comma is tolerated rather than settled: the 2026-09-01 demotion (rules.md#W3) narrowed that half from contract to best-effort, since no CJK writing system's own convention puts a comma between family and given at all — so `김, 민준씨` reads today exactly as the spaced `김 민준씨` does (family 김, given 민준, suffix 씨) while the split stands down as before, but that reading is watched on the differential's radar tier rather than pinned as contract. Its site is accordingly the name-bearing segment runs — `segments[:2]` under a family comma, and `segments[0]` as before otherwise, the family comma being the one structure that splits the name itself across two runs, with the honorific as often glued to the given side as to the family. That is the whole reach and nothing past it (`김, 민준 지훈씨` peels; `김, 민준, 지훈씨` and `김,, 민준씨` do not, both landing in a third run), and whether `segments[1]` is name text at all is now ASKED rather than inferred from the structure — `segment` does not guarantee it, since a one-word part before the comma reads as FAMILY_COMMA even when the part after it is entirely suffix-shaped, and the peel walking into such a run took `V.` for its site, found no listed tail and abandoned (#319). The question is `segment`'s own suffix-comma predicate, lifted into `_vocab.is_wholly_suffix` so the two stages cannot drift: a wholly suffix-shaped second run is declined and the scan stays in `segments[0]`, so `田中さん, V.` and `田中さん, Ph. D.` give さん up as `田中さん, PhD` always did. The test is necessary but NOT sufficient, and the second condition is not decoration: every honorific tail is also a suffix word, so a glued honorific is itself part of what makes its run read as suffix-shaped, and declining a run that holds the ONLY site loses the peel outright. `segments[0]` must therefore offer a peel site of its own before the second run is declined — `이, J.씨` and `선생님, J.씨` pass the suffix test and are scanned anyway, keeping the pre-#319 reading, while `김민준씨, J.씨` has a site on both sides and peels the person's own 씨 rather than the junk one behind the comma. Uniform in the PEEL, that is — where the credential itself lands is `assign`'s question and still differs by spelling (`V.` → `given`, `PhD` and `Ph. D.` → `suffix`). Not `_is_post_nominal` pluralized: the run predicate says yes both to what the token predicate vetoes (`V.`, `V`, `I` — the class the defect was reported as) and to what the token predicate never sees at all, since `period_joined_vocab` and the delimiter routes are the run predicate's alone (`Msc.Ed.` and `J.씨` reach it that way, and `田中さん, Msc.Ed.` moves with the rest). `Policy(lenient_comma_suffixes=False)` drops this call to the strict token test too — so those three read as name text again and keep the pre-#319 answer, while `Ph. D.` peels under the knob regardless, its merged `phd` passing the strict test. `田中さん, 太郎` is unchanged, and not because of its comma — the honorific there is not at the end of the name, 太郎 is. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=()`) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) needs the flanked-by-classified-script guard `_tokenize_region` gives it (#298). Han segmentation stays OPT-IN (`locales.ZH` for Chinese, `locales.JA` for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so `locales.JA` activates the stage and a pluggable `Parser(segmenter=...)` does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes. -**A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23), as were `rai`, `cha`, `ba` and `mc` before `decisions.md#suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. The same entry marked `meng` and `lac` ambiguous on 2026-09-25 (#540). When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions.md#vocabulary-collisions`, with #360's positional qualifier. +**A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`; 568 of 575 against seven, measured 2026-09-25 after #540), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23), as were `rai`, `cha`, `ba` and `mc` before `decisions.md#suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. The same entry marked `meng` and `lac` ambiguous on 2026-09-25 (#540). When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions.md#vocabulary-collisions`, with #360's positional qualifier. **Sweep the forms a change can reach: comma shapes, then `name_order`.** No comma, a FULL name before the comma, and a ONE-WORD name before the comma are three paths, and the third is the miss — #429, #430 and #432 are all that path disagreeing with the full-name path on inputs the full-name path reads correctly. For orders the sweep already exists (`tests/v2/test_cases.py` runs every row under all three) but carries one assertion, R2's family partition, so it checks nothing a new change moves; coverage there has been vacuous before (PR #394's review found the suite passed with `name_order` discarded from grouping). Parse your change's names in each comma shape and each order, and read the ones you did not predict. diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 42bd3e80..0b3fe951 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -608,9 +608,9 @@ Closes #342 (a wordlist question) and #454 (a rules.md question) together, becau - **Measurement (2026-09-07).** Five corpus names move, every one from the radar-tier corpus_issues.jsonl, in three diff shapes with one cause: Aishwarya Rai moves {family, suffix}, Lala Lajpat Rai and John Smith RAI move {middle, family, suffix}, and John Smith, RAI and Ahmad Jayadi, CHA move {given, family, suffix}. Recompute by parsing every name in the tools/differential/corpus*.jsonl glob twice — once with the shipped lexicon, once with `Lexicon.default().add(suffix_acronyms={"rai","cha"})`, which RESTORES the two entries this bundle removed so the diff read forwards is the removal's — and diffing the seven name fields plus `ambiguities`; the ba step of the same sweep moves nothing. Aishwarya Rai is the one name whose diff DISAPPEARS at 1.4.0, that release having read family Rai too, which is why the `fix(#342) NOT WANTED` ledger rule was deleted rather than rewritten and why the 1.4.0 gate lists four names under the replacement where the three 2.x ledgers list five. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; they rose by three at 1.4.0 and by five at each 2.x baseline. - **2026-09-15 (#289/#516) — the parking lot is CLOSED, and this entry's "measured 2026-09-07, none of the four is read as a suffix today" sentence is superseded rather than rewritten.** The shape-plus-position heuristic this entry parked is what shipped: the DOTTED shape as `Policy.unlisted_dotted_suffixes`, default on, and the ALL-CAPS shape as `Policy.unlisted_caps_suffixes`, default off. Of the four names the bullet measured, `John Smith X.Y.Z.` and `john smith x.y.z.` now read suffix at the default, and `John Smith XYZ` / `John Smith, XYZ` read suffix only with the opt-in switch. See #S2 for the whole decision and for why the two halves have different defaults. - **2026-09-15 (#289/#516) — the roman-numeral accident is RETIRED, and here is what narrowly means.** This entry's bullet recorded the accident as evidence rather than as behavior anyone relied on. It now fires no longer where every chunk the vocabulary matches is a single ASCII character — measured, `{i, v, 2}` — so `John Smith R.A.I.` and `John Smith X.Y.I.` reach the shape class instead and read by position, which gives the same fields for the first and 1.4.0's family reading for `Jack X.Y.I.`. `John Smith C.H.A.`, the bullet's other half, is unaffected: nothing claimed it before or after. The WIDE retirement was measured and rejected; #S2 records the three names that pay for it. -- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules.md#S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. A CREDENTIAL RUN ending in one of them pays the same walk-stopping cost: `Mary Jones PhD MEng` reads middle `Jones PhD`, family `MEng`, and `Jane Doe MS LAc` reads middle `Doe MS`, family `LAc`, where every release read the suffix run whole (`PhD MEng`, `MS LAc`); the mechanism is S2's own — the case signal costs a genuine suffix standing behind a name-leaning acronym, the walk stopping at the declined pick rather than continuing past it — pinned by `tests/v2/cases.py`'s `a_declined_ambiguous_pick_stops_the_walk` and, for the family-comma shape, `a_declined_member_ends_the_trailing_run` (`Doe, John MA Ma`). A QUIETER COST sits after a family comma: a title-shaped credential standing in front of it reads as a TITLE with no report — `Jane Doe, MS LAc` reads title `MS`, given `LAc`, family `Jane Doe`, unflagged, where every release read suffix `MS LAc` — and the path is not new: `Jane Doe, MS Ma` already reads that way on 2.3.0, `ma` having been ambiguous there too. A follow-up issue on the silent title-dual path after a family comma is to be filed. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count; bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). +- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules.md#S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. DECIDED (Derek, 2026-09-25): the bare `Wang M.Eng.` reading — family `M.Eng.`, nothing to spare — is accepted and recorded rather than widening the period gate to cover a chunked dotted spelling; S2's period gate counts a member as unambiguous only when it is written one period per letter (`M.A.`), and MEng and LAc are the first members whose conventional dotted spelling is chunked instead. `John Smith M.Eng.` keeps the suffix by the words-to-spare count, unaffected. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. A CREDENTIAL RUN ending in one of them pays the same walk-stopping cost: `Mary Jones PhD MEng` reads middle `Jones PhD`, family `MEng`, and `Jane Doe MS LAc` reads middle `Doe MS`, family `LAc`, where every release read the whole run as a suffix (`PhD, MEng` and `MS, LAc` through 2.2, `PhD MEng` and `MS LAc` in 2.3); `John Smith MEng PhD` reads middle `Smith`, family `MEng`, suffix `PhD`, the unambiguous credential behind the declined pick still peeled; the mechanism is S2's own — the case signal costs a genuine suffix standing behind a name-leaning acronym, the walk stopping at the declined pick rather than continuing past it — pinned by `tests/v2/cases.py`'s `a_declined_ambiguous_pick_stops_the_walk` and, for the family-comma shape, `a_declined_member_ends_the_trailing_run` (`Doe, John MA Ma`). A QUIETER COST sits behind a suffix comma: once the run's last word is a bare ambiguous member, the run is no longer wholly suffix-shaped, so C1 reads the comma as a family comma instead and the run's own words fall into the usual post-comma name slots — `John Smith, PhD MEng` reads given `PhD`, middle `MEng`, family `John Smith`, and `john smith, phd meng` reads given `phd`, family `john smith`, suffix `meng` (the count then peels `meng` with a word to spare); neither case form saves it, `JOHN SMITH, PHD MENG` reading the same way. A title-listed word standing in front of the run reads as a TITLE instead, with no report: `Jane Doe, MS LAc` reads title `MS`, given `LAc`, family `Jane Doe`, unflagged, where every release read suffix `MS LAc`. The path is not new: `john smith, phd ma` and `Jane Doe, MS Ma` already read this way, `ma` having been ambiguous all along. Pinned by `tests/v2/cases.py`'s `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma`. A follow-up issue is to be filed on the comma re-read by a run ending in an ambiguous member (and its silent title-dual case) and on whether a listed member's chunked dotted spelling should pass the period gate. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count; bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 (unmoved by this change) with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). - **Declined 2026-09-25 (#540), with measurement: removing the two words, and masking them.** REMOVAL, the rai/cha answer, measured with `Parser(lexicon=Lexicon.default().remove(suffix_acronyms={"meng", "lac"}, suffix_acronyms_ambiguous={"meng", "lac"}))`: `john smith meng` reads middle `smith`, family `meng`, and `JOHN SMITH MENG` family `MENG`, while `john smith m.eng.` stays a suffix by its dotted shape (S3), reported. Removal loses the bare credential outright where the marking keeps it with words to spare, and C-i's default under uncertainty is the marking. MASKS `meng → MEng` and `lac → LAc`, measured with both added to a private lexicon on top of the marking: the exceptions map is role-free (#R4, 2026-09-23), so `wang meng` renders `Wang MEng`, `tran lac` `Tran LAc` and `meng li` `MEng Li` — the mask re-spells the very surnames the marking restores. A suffix-gated mask would reverse the 2026-09-23 role-free decision for two words and is not taken. Without one, the default path renders `wang meng` as `Wang Meng` (the parent gave `Wang MENG`, the credential clause writing a suffix in capitals), `tran lac` as `Tran Lac`, `john smith meng` as `John Smith MENG` and `nguyen van lac` as `Nguyen Van LAC` by the acronym clause, and keeps a writer's `MEng` as written (#R5, 2026-09-24) — `john smith MEng` gives `john smith MEng`, and `John Smith Meng` under `force=True`, the word being the family name there. Pinned by `tests/v2/test_render.py::test_a_listed_acronym_that_is_a_name_word_gets_no_mask`, whose recorded negative control carries the two masks. -- **Measurement (2026-09-25, #540).** No corpus name written before this change carries a bare trailing `meng` or `lac` (the `tools/differential/corpus*.jsonl` glob at 120033b5), so the population that could move is this change's own shape-tagged case rows, and every mover is one of them. Classified by `fix(#540)` rules: at each 2.x baseline `wang meng` and `tran lac` move {family, suffix, _ambiguities}, `john smith MEng` {middle, family, suffix, _ambiguities}, and `john smith meng` and `nguyen van lac` `_ambiguities` alone; `Smith, John MEng` moves {middle, suffix, _ambiguities} at 2.x and {middle, suffix} at 1.4.0, and `Smith, meng` and `Smith, MEng` move {given, suffix, _ambiguities} at 2.x and nothing at 1.4.0 (1.4.0's own reading); at 1.4.0 otherwise only `john smith MEng` moves, the other four reading their roles as 1.4.0 did and 1.4.0 comparing no ambiguities. `nguyen van lac` also diffs at 1.4.0 on `_initials` (`n.` → `n. v.`) for fix(#385/#402)'s reason — its family `van` is a one-particle part — and joins that rule's literal list. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; this change raised them by three at 1.4.0 and by eight at each 2.x baseline. +- **Measurement (2026-09-25, #540).** No corpus name written before this change carries a bare trailing `meng` or `lac` (the `tools/differential/corpus*.jsonl` glob at 120033b5), so the population that could move is this change's own shape-tagged case rows, and every mover is one of them. Classified by `fix(#540)` rules: at each 2.x baseline `wang meng` and `tran lac` move {family, suffix, _ambiguities}, `john smith MEng` and `John Smith MEng PhD` move {middle, family, suffix, _ambiguities}, and `john smith meng` and `nguyen van lac` `_ambiguities` alone; `Smith, John MEng` moves {middle, suffix, _ambiguities} at 2.x and {middle, suffix} at 1.4.0; `Smith, meng` and `Smith, MEng` move {given, suffix, _ambiguities} at 2.x and nothing at 1.4.0 (1.4.0's own reading); `John Smith, PhD MEng` moves {given, middle, family, suffix, _ambiguities} and `john smith, phd meng` {given, family, suffix, _ambiguities} at 2.x (without `_ambiguities` at 1.4.0); `Nguyen Van Lac` moves {family, suffix, _ambiguities} at 2.x and {family, suffix} at 1.4.0; and `Wang M.Eng.` moves {family, suffix, _ambiguities} at 2.x only, 1.4.0 reading the same family for an unrelated reason (its two-piece rule -- a lone word after the given name is the family -- not S2's gate). `nguyen van lac` also diffs at 1.4.0 on `_initials` (`n.` → `n. v.`) for fix(#385/#402)'s reason — its family `van` is a one-particle part — and joins that rule's literal list; the same case-insensitive regex also reaches `Nguyen Van Lac` at every baseline, without explaining anything there (its own family move, at 1.4.0 too, is explained by the Title-case Lac rule instead). Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; this change raised them by seven at 1.4.0 and by thirteen at each 2.x baseline. ### S2 — the case signal at the suffix slot @@ -1389,7 +1389,7 @@ Accepted costs, deferred to the rescoped #459 rather than relitigated here: the Excluded (CAPITALIZATION_EXCEPTIONS — meng, edd, lac, ded, left out of the 2026-09-24 masks, #459): -- `meng` (MEng) and `edd` (EdD) are conventionally mixed-case credentials and listed acronyms, and must not get a mask while the map stays role-free: a mask applies in every role, and both are borne as given names or surnames. Measured 2026-09-24 with each mask added to a private lexicon: `MENG LI` gives `MEng Li`, `meng wang` gives `MEng Wang`, `edd smith` gives `EdD Smith` and `EDD KIMBER` gives `EdD Kimber`, where the shipped map and every release give `Meng Li`, `Meng Wang`, `Edd Smith` and `Edd Kimber`. The same holds for two sweep candidates, `lac` (LAc, Licensed Acupuncturist; `Lac` is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; corrected 2026-09-25, #540, from "a Vietnamese and French surname") and `ded` (DEd; `Ded` is an Albanian given name, `ded gjo luli` giving `DEd Gjo Luli` under the mask). A writer who spells one mixed-case keeps it (decisions.md#R5, 2026-09-24); a caller who wants the mask adds it to a private lexicon. A NOTE on `SUFFIX_ACRONYMS` itself, which this map does not decide. The two name-word collisions there come from two bulk additions, not one: `lac` (with `edd` and `ded`) from the Wikipedia post-nominals import, `af5bdab` (2019-12-11, #93), and `meng` from `3e14ea20` (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). Both read as a suffix after a given name, and lose a family name to it. Measured 2026-09-24 on the released wheels, the parent 4d0680e6 and this tree: `wang meng`, `li meng` and `zhang meng` read family `meng` on 1.4.0 and suffix `meng`, with no family, on every release from 2.0.0; and since #521 on master the suffix renders `Wang MENG` (parent and tree alike), where 2.3.0 gave `Wang Meng` (superseded 2026-09-25 by #540: both words are marked ambiguous, `wang meng` reads family `meng` and renders `Wang Meng` — see #suffix-acronym-collisions). `nguyen van lac` reads suffix `lac` with family `van` on every release from 1.4.0, and since #521 renders `Nguyen Van LAC` where 2.3.0 gave `Nguyen Van Lac`. This is a #vocabulary-collisions C-i question about the acronym set, not about this map. +- `meng` (MEng) and `edd` (EdD) are conventionally mixed-case credentials and listed acronyms, and must not get a mask while the map stays role-free: a mask applies in every role, and both are borne as given names or surnames. Measured 2026-09-24 with each mask added to a private lexicon: `MENG LI` gives `MEng Li`, `meng wang` gives `MEng Wang`, `edd smith` gives `EdD Smith` and `EDD KIMBER` gives `EdD Kimber`, where the shipped map and every release give `Meng Li`, `Meng Wang`, `Edd Smith` and `Edd Kimber`. The same holds for two sweep candidates, `lac` (LAc, Licensed Acupuncturist; `Lac` is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; corrected 2026-09-25, #540, from "a Vietnamese and French surname") and `ded` (DEd; `Ded` is an Albanian given name, `ded gjo luli` giving `DEd Gjo Luli` under the mask). A writer who spells one mixed-case keeps it (decisions.md#R5, 2026-09-24); a caller who wants the mask adds it to a private lexicon. A NOTE on `SUFFIX_ACRONYMS` itself, which this map does not decide. The two name-word collisions there come from two bulk additions, not one: `lac` (with `edd` and `ded`) from the Wikipedia post-nominals import, `af5bdab` (2019-12-11, #93), and `meng` from `3e14ea20` (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). Both read as a suffix after a given name, and lose a family name to it (superseded 2026-09-25 by #540: both words are marked ambiguous, `wang meng` reads family `meng` and renders `Wang Meng` — see #suffix-acronym-collisions). Measured 2026-09-24 on the released wheels, the parent 4d0680e6 and this tree: `wang meng`, `li meng` and `zhang meng` read family `meng` on 1.4.0 and suffix `meng`, with no family, on every release from 2.0.0; and since #521 on master the suffix renders `Wang MENG` (parent and tree alike), where 2.3.0 gave `Wang Meng`. `nguyen van lac` reads suffix `lac` with family `van` on every release from 1.4.0, and since #521 renders `Nguyen Van LAC` where 2.3.0 gave `Nguyen Van Lac`. This is a #vocabulary-collisions C-i question about the acronym set, not about this map. Excluded (CAPITALIZATION_EXCEPTIONS — md, ii, iii, iv, removed 2026-09-23, #459): diff --git a/docs/design/rules.md b/docs/design/rules.md index 20bf1437..e0e88686 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -1043,6 +1043,8 @@ S2. Rationale: generational suffixes and credentials are recognized "tran lac" → family="lac" "john smith meng" → suffix="meng" "john smith MEng" → family="MEng" + "Nguyen Van Lac" → family="Van Lac" + "Wang M.Eng." → family="M.Eng." "Smith, MA" → suffix="MA" "Smith, Ma" → given="Ma" "Doe, John MA" → suffix="MA" diff --git a/docs/release_log.rst b/docs/release_log.rst index fe40cbc9..5aba1ab3 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -12,7 +12,7 @@ Release Log - **Fix a credential acronym that is also a surname being read by position alone.** ``HumanName("Jack MA")`` gives suffix ``MA`` where 2.0 through 2.3 gave last ``MA``, and ``John Smith Ma`` gives last ``Ma`` where they gave suffix ``Ma``. In a name written in more than one case, an ambiguous acronym written in capitals is written the way a credential is written and is read as one even where removing it leaves no surname; one written in any other cased form that is not wholly lower is written the way a surname is written and stays one even where there are words to spare (``John Smith ma`` and ``John Smith ed`` -- all lower, no contrast -- give suffix ``ma``/``ed`` instead). A name written wholly in one case says nothing either way and keeps the reading it had: ``JOHN SMITH MA`` is still a credential, ``ANH DO`` still a surname, ``jack ma`` still a surname. The same reading reaches the comma forms, where the words-to-spare count is now a count of NAME words: ``Smith, MA`` gives last ``Smith``, suffix ``MA``; ``Smith Jr., MA`` keeps last ``Smith``; and ``John Smith, MA``, ``John Smith, Ed``, ``john smith, ma`` and ``JOHN SMITH, MA`` all give a suffix again, which is what 1.4.0 read and 2.0 through 2.3 did not. ``Jack Ma`` and ``Anh Do`` are unchanged. The LEAN is inert on a caseless script, but the comma count above is not -- it asks name-word count, not case -- so ``마틴 킹, MA`` and ``田中 太郎, MA`` also give a suffix again (1.4.0 parity on the suffix, two pre-comma name words each) while the single-token ``毛泽东, MA`` does not move, having no case to write a contrast in either way. See the ``S2`` entry of ``docs/design/decisions.md`` (closes #289) - - **Fix a bare trailing Meng or Lac being read as a credential and losing the family name: meng and lac are now acronyms that are also ordinary names.** ``HumanName("wang meng")`` gives first ``wang``, last ``meng``, and ``parse()`` reports a suffix-or-name ambiguity, where every release from 2.0.0 through 2.3.0 gave suffix ``meng`` and no last name; 1.4.0 read last ``meng``, so this is 1.4.0's answer plus the flag. ``li meng`` and ``tran lac`` move the same way, ``Wang, Meng`` gives first ``Meng``, last ``Wang`` again, and ``Parser(policy=Policy(name_order=FAMILY_FIRST)).parse("Wang Meng")`` gives given ``Meng`` where 2.0.0 through 2.3.0 gave family ``Wang``, suffix ``Meng`` and no given name. With a full name in front the credential reading stays: ``john smith meng`` and ``nguyen van lac`` keep suffix ``meng`` and ``lac``, now flagged. But a Title-case ``Nguyen Van Lac`` gives last ``Van Lac`` where every release gave last ``Van``, suffix ``Lac``. The cost is the marking's own, and it falls on the conventional spellings: a ``MEng`` or ``LAc`` written that way, in a name written in more than one case, is read the way ``John Smith Ma`` is (above), so ``John Smith MEng`` gives middle ``Smith``, last ``MEng``, where every release gave suffix ``MEng``. A credential run ending in one of them goes the same way: ``Mary Jones PhD MEng`` gives middle ``Jones PhD``, last ``MEng``, where every release gave suffix ``PhD MEng`` (``rules.md#S2``'s declined pick stops the walk, the cost ``Doe, John MA Ma`` already pays). After a comma ``Smith, MEng`` and ``Smith, meng`` give first ``MEng`` and ``meng`` (1.4.0's reading, not the suffix 2.0 through 2.3 gave) and ``Smith, John MEng`` gives middle ``MEng`` (every release gave suffix ``MEng``), and a bracketed ``John Smith (MEng)`` falls through to nickname, where every release gave suffix ``MEng``. After a family comma a title-shaped credential in front of it is read as a title with no report: ``Jane Doe, MS LAc`` gives title ``MS``, first ``LAc``, last ``Jane Doe``, where every release gave suffix ``MS LAc``; the same path already reads ``Jane Doe, MS Ma`` that way. Write ``MENG``, or put a lone credential after a comma behind a full name (``John Smith, MEng``), to keep the credential reading. Meng is a common Chinese surname and given name, Lac a Vietnamese given name (``Nguyen Van Lac``) and a French surname; see the ``suffix-acronym-collisions`` entry of ``docs/design/decisions.md`` (closes #540) + - **Fix a bare trailing Meng or Lac being read as a credential and losing the family name: meng and lac are now acronyms that are also ordinary names.** ``HumanName("wang meng")`` gives first ``wang``, last ``meng``, and ``parse()`` reports a suffix-or-name ambiguity, where every release from 2.0.0 through 2.3.0 gave suffix ``meng`` and no last name; 1.4.0 read last ``meng``, so this is 1.4.0's answer plus the flag. ``li meng`` and ``tran lac`` move the same way, ``Wang, Meng`` gives first ``Meng``, last ``Wang`` again, and ``Parser(policy=Policy(name_order=FAMILY_FIRST)).parse("Wang Meng")`` gives given ``Meng`` where 2.0.0 through 2.3.0 gave family ``Wang``, suffix ``Meng`` and no given name. With a full name in front the credential reading stays: ``john smith meng`` and ``nguyen van lac`` keep suffix ``meng`` and ``lac``, now flagged. But a Title-case ``Nguyen Van Lac`` gives last ``Van Lac`` where every release gave last ``Van``, suffix ``Lac``. The cost is the marking's own, and it falls on the conventional spellings: a ``MEng`` or ``LAc`` written that way, in a name written in more than one case, is read the way ``John Smith Ma`` is (above), so ``John Smith MEng`` gives middle ``Smith``, last ``MEng``, where every release gave suffix ``MEng``. A credential run ending in one of them goes the same way: ``Mary Jones PhD MEng`` gives middle ``Jones PhD``, last ``MEng``, where every release read the whole run as a suffix (``PhD, MEng`` through 2.2, ``PhD MEng`` in 2.3); ``John Smith MEng PhD`` gives middle ``Smith``, last ``MEng``, suffix ``PhD`` (``rules.md#S2``'s declined pick stops the walk, the cost ``Doe, John MA Ma`` already pays). ``Wang M.Eng.`` gives last ``M.Eng.``, where 2.0 through 2.3 gave suffix ``M.Eng.``: the period gate counts a member as unambiguous only written one period per letter, and ``M.Eng.`` is chunked; ``John Smith M.Eng.`` keeps the suffix. After a comma ``Smith, MEng`` and ``Smith, meng`` give first ``MEng`` and ``meng`` (1.4.0's reading, not the suffix 2.0 through 2.3 gave) and ``Smith, John MEng`` gives middle ``MEng`` (every release gave suffix ``MEng``), and a bracketed ``John Smith (MEng)`` falls through to nickname, where every release gave suffix ``MEng``. A credential run after a comma whose last word is one of them re-reads the comma as a family comma, whatever its letter case: ``John Smith, PhD MEng`` gives first ``PhD``, middle ``MEng``, last ``John Smith`` and ``john smith, phd meng`` gives first ``phd``, last ``john smith``, suffix ``meng``, where every release read the run as a suffix; a title-listed credential in front reads as a title instead (``Jane Doe, MS LAc`` gives title ``MS``, first ``LAc``, unflagged). The path is the one ``john smith, phd ma`` already takes. A lone credential after a comma behind a full name (``John Smith, MEng``) keeps the credential reading, and so does a second comma before a run that ends in one (``John Smith, PhD, MEng`` keeps suffix ``PhD, MEng``); writing it in capitals does not (``JOHN SMITH, PHD MENG`` still re-reads the comma). Meng is a common Chinese surname and given name, Lac a Vietnamese given name (``Nguyen Van Lac``) and a French surname; see the ``suffix-acronym-collisions`` entry of ``docs/design/decisions.md`` (closes #540) - **New Policy field unlisted_dotted_suffixes, on by default: a dotted acronym nobody has listed is read by position.** ``HumanName("John Smith X.Y.Z.")`` gives suffix ``X.Y.Z.`` where every release gave last ``X.Y.Z.``, while ``Jack X.Y.Z.`` keeps its surname, the same words-to-spare rule a listed acronym takes -- and both readings are reported. Case is irrelevant here: the periods are the signal, so ``john smith x.y.z.`` reads the same way. Words the vocabulary does know are untouched (``M.A.``, ``Ph.D.``, ``A.B.C.``), a single trailing period is still not this shape (``John Smith Xyz.`` keeps last ``Xyz.``), and a dotted run at the FRONT of a name is untouched (``J.R.R. Tolkien``). One accident retires with it: a dotted word whose only vocabulary matches were SINGLE ASCII CHARACTERS -- the roman numerals the suffix list holds, and the lone digit ``2`` -- was reading as a generational suffix, so ``Jack X.Y.I.`` gives last ``X.Y.I.`` again, as 1.4.0 read it, while ``Msc.Ed.``, ``JD.CPA`` and ``Lt.Gov.`` are unchanged. The digit is why a dotted VERSION STRING moves with them and moves SILENTLY: ``John Smith 1.4.2`` gives last ``1.4.2`` where 2.3 gave suffix ``1.4.2``, and ``John Smith, 1.4.2`` gives first ``1.4.2``, last ``John Smith``. Such a token reports nothing at any policy -- it is no acronym either, the shape reading wanting every chunk alphabetic -- and a version string read as a credential was the same accident this retirement removes. That retirement is NOT behind this switch and stands either way -- setting it to ``False`` reads an unlisted dotted word as name material by position instead (``John Smith X.Y.Z.`` keeps last ``X.Y.Z.``), the pre-2.4 reading for THAT half alone. See the ``S2`` and ``suffix-acronym-collisions`` entries of ``docs/design/decisions.md`` (closes #516) diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 59300e71..ff9eb1e4 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -211,14 +211,11 @@ """ SUFFIX_ACRONYMS_AMBIGUOUS = frozenset({ # Suffix acronyms that also commonly work as given-name nicknames on - # their own (e.g. "Ed", "JD"). Two readers in 2.x, not the single v1 - # one this comment used to name: _extract._suffix_shaped, deciding - # whether parenthesized/quoted content is a nickname or a suffix - # (content matching one of these stays a nickname, the more common - # reading in ambiguous, delimiter-only context), and _vocab's - # suffix_as_written, which excludes the ambiguous subset from plain - # acronym membership so the period gate below is not dead code. - # _classify also tags membership as "vocab:suffix-ambiguous". + # their own (e.g. "Ed", "JD"). Read wherever the pipeline decides an + # acronym's bare reading (rules.md#S2) and by the delimited-content + # escape (content matching one of these stays a nickname, the more + # common reading in ambiguous, delimiter-only context). _classify + # also tags membership as "vocab:suffix-ambiguous". # # When adding a new entry to SUFFIX_ACRONYMS, also add it here only if # the exact letter sequence could plausibly be someone's name on its @@ -227,9 +224,11 @@ # (e.g. 'mba', 'cpa', 'phd') don't need an entry. In 2.0 this set # also gates bare recognition: a bare ambiguous acronym reads as # the credential only with words to spare in front of it, or - # written in capitals inside a mixed-case name, and the parse - # reports the fork where it decides one (rules.md#S2), so - # 'Jack Ma' keeps its family name. + # written in capitals inside a mixed-case name; inside a + # mixed-case name any other cased form that is not wholly lower + # ('Ma', 'MEng') reads as the name even with words to spare -- + # rules.md#S2. The parse reports the fork where it decides one, + # so 'Jack Ma' keeps its family name. # # The other half of the criterion, added 2026-09-07 with #342. # Being borne at all is only the entry ticket; what decides among @@ -304,8 +303,7 @@ Acronym suffixes from SUFFIX_ACRONYMS that also plausibly collide with a word borne as a name -- a given name, nickname or surname. Not a partition of SUFFIX_ACRONYMS -- a small, standalone exception list, read -by the delimited-content escape in ``_pipeline/_extract.py`` and by -``_pipeline/_vocab.py``'s period gate. +wherever the pipeline decides an acronym's bare reading (rules.md#S2). """ SUFFIX_ACRONYMS = frozenset({ diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 3a370ab8..4c407471 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -491,8 +491,8 @@ def _check_cjk_shape_purity(self) -> None: # decisions.md#suffix-acronym-collisions and it compares # FREQUENCIES: how common the word is as a borne name in the # trailing position against how common it is as a credential. - # Rough balance earns the ambiguous marking (ba, do, ed, jd, ma, - # and since #540 lac and meng); + # Rough balance earns the ambiguous marking (the members of + # SUFFIX_ACRONYMS_AMBIGUOUS; the frozenset is the roster); # the name reading dominating REMOVES the entry, a caller adding # it back with Lexicon.default().add(suffix_acronyms={"cha"}); # the credential dominating leaves it unambiguous. Length is a @@ -696,6 +696,68 @@ def _check_cjk_shape_purity(self) -> None: "it as a middle name. 1.4.0 read given 'MEng' too, so " "this row also restores 1.4.0's reading", shape=2), + Case("comma_credential_run_ending_in_meng_re_reads_the_comma", + "John Smith, PhD MEng", + {"given": "PhD", "middle": "MEng", "family": "John Smith"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the run behind a suffix comma is no longer wholly " + "suffix-shaped once its last word is a bare ambiguous " + "member, so C1 reads the comma as a family comma and " + "the first credential as the given name. Every " + "release read suffix 'PhD MEng' -- the pre-existing " + "'john smith, phd ma' path, which #540 routes two " + "more words into. A follow-up issue is to be filed", + shape=3), + Case("comma_lower_credential_run_ending_in_meng_re_reads_the_comma", + "john smith, phd meng", + {"given": "phd", "family": "john smith", "suffix": "meng"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the same re-read in one case -- the count then " + "peels 'meng' with a word to spare, so the writing's " + "case does not save it, and neither does the " + "all-caps 'JOHN SMITH, PHD MENG' (given 'PHD', " + "family 'JOHN SMITH', suffix 'MENG')", + shape=3), + Case("title_case_lac_behind_a_particle_is_the_name", + "Nguyen Van Lac", + {"given": "Nguyen", "family": "Van Lac"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the mixed-case name's Title-case member leans " + "'name' with words to spare (S2), the gain for a " + "Vietnamese name; every release from 1.4.0 read " + "family 'Van', suffix 'Lac'", + shape=1), + Case("the_accepted_cost_survives_a_credential_behind_it", + "John Smith MEng PhD", + {"given": "John", "middle": "Smith", "family": "MEng", + "suffix": "PhD"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the declined pick stops the walk, and the " + "unambiguous credential behind it is still peeled; " + "every release read suffix 'MEng PhD' ('MEng, PhD' " + "through 2.2)", + shape=1), + Case("dotted_meng_with_nothing_to_spare_is_the_name", + "Wang M.Eng.", + {"given": "Wang", "family": "M.Eng."}, + ambiguities=("suffix-or-name",), + notes="S2's period gate counts a member as unambiguous " + "only written one period per letter ('M.A.'), and " + "MEng and LAc are the first members whose " + "conventional dotted spelling is chunked, so " + "'M.Eng.' with nothing to spare is the family name; " + "accepted and recorded (Derek, 2026-09-25). 1.4.0 " + "read the same family, unflagged, for a different " + "reason -- its two-piece rule (a lone word after the " + "given name is the family), not S2's gate -- so this " + "row is parity. 'John Smith M.Eng.' keeps the suffix " + "by the count and reports; the period-gate question " + "goes to the follow-up issue", + shape=1), Case("leading_meng_is_a_given_name", "meng li", {"given": "meng", "family": "li"}, notes="the control: the marking acts only at the slots S2 " diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index a10bf124..d34e6001 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -1209,8 +1209,8 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: "change(suffix-acronym-collisions) ph leaves the acronym set": ("John Smith Ph. D.", "Smith, Ph. D.", "john smith phd", "John Smith Ph.D."), - # #540's five rules, keyed on the full issue since all five carry - # `fix(#540)`. Each wall is the other rules' names plus the + # #540's eight rules, keyed on the full issue since all eight + # carry `fix(#540)`. Each wall is the other rules' names plus the # spellings a case-blind widening would reach: 'meng li' leads # with the word and nothing moved; the one-case 'john smith meng' # and 'JOHN SMITH MENG' keep the credential, so the cost rule has @@ -1221,16 +1221,27 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: # the comma-cost rule must not reach the bare-word, no-comma or # all-lower spellings. The lone-word comma rule must not reach # its own capitals-lean probe or the two-word comma rule's name. + # Every #540 rule carries one superstring probe too, after a + # mutant dropping the ^...$ anchors passed every other guard here. "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": - ("meng li", "Meng Li", "john smith meng", "john smith MEng"), + ("meng li", "Meng Li", "john smith meng", "john smith MEng", + "Dr. wang meng"), "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - ("john smith meng", "JOHN SMITH MENG", "John Smith MENG"), + ("john smith meng", "JOHN SMITH MENG", "John Smith MENG", + "Dr. john smith MEng"), "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": - ("wang meng", "tran lac", "john smith MEng", "meng li"), + ("wang meng", "tran lac", "john smith MEng", "meng li", + "Dr. john smith meng"), "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": - ("John Smith, MEng", "Smith, John meng"), + ("John Smith, MEng", "Smith, John meng", "Dr. Smith, John MEng"), "fix(#540) a lone meng or MEng after a family comma reads as the given name": - ("Smith, MENG", "Smith, John MEng"), + ("Smith, MENG", "Smith, John MEng", "Dr. Smith, meng"), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + ("John Smith, MEng", "John Smith, PhD", "Dr. John Smith, PhD MEng"), + "fix(#540) a Title-case Lac behind a particle is the family name": + ("nguyen van lac", "Dr. Nguyen Van Lac"), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + ("John Smith M.Eng.", "Wang M.A.", "Dr. Wang M.Eng."), # 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": @@ -2471,6 +2482,14 @@ class _LatinCopy(NamedTuple): # SUFFIX_ACRONYMS_AMBIGUOUS's bare 'meng' either, the same reason # as the two lists above. frozenset({"MEng", "meng"}), + # #540's second pair: the widened cost rule (a full name pair, + # the credential run behind it kept as a separate word) and the + # credential-run rule (a full name pair sharing no literal + # prefix, so its alternation IS over full names like the two + # lists above). Neither copies SUFFIX_ACRONYMS_AMBIGUOUS -- same + # reason as the rest of this roster. + frozenset({"John Smith MEng PhD", "john smith MEng"}), + frozenset({"John Smith, PhD MEng", "john smith, phd meng"}), # fix(#462)'s letter shape: a bare capital E/Y or a dotted E./Y. # It is the initial SHAPE (v1's `initial` regex, _render._INITIAL) # intersected with the single-letter conjunctions, not a copy of @@ -3220,15 +3239,22 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's two cost rules, the only #540 rules this baseline - # needs: one literal name each, the mixed-case credential S2 - # reads as the name even with words to spare, no-comma and - # after a family comma. Roles without `_ambiguities`, which - # 1.4.0 does not compare. + # #540's four rules, the only #540 rules this baseline needs + # (every one of them either an accepted cost or the gain that + # is its mirror): the mixed-case credential read as the name + # even with words to spare, no-comma (now also the credential + # run behind it, widened in) and after a family comma; the + # credential run behind a suffix comma re-reading it as a + # family comma; and the Title-case Lac gain. Roles without + # `_ambiguities`, which 1.4.0 does not compare. "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(1, ('family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + _Claim(2, ('family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": _Claim(1, ('middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), # #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 @@ -3414,8 +3440,11 @@ def _claim(rule: dict) -> _Claim: # comma-cost row's shape landing in this rule's reach too. # 2026-09-25, #540: 367 -> 369, 'Smith, meng' and 'Smith, # MEng', the lone-word comma rows' shape landing here too. + # 2026-09-25, #540: 369 -> 371, 'John Smith, PhD MEng' and + # 'john smith, phd meng', the credential-run comma rows' + # shape landing here too. "fix(comma-family) lone post-comma piece routes to suffix/title, not first": - _Claim(369, ('given', 'suffix', 'title'), '5bf76967bc46', None), + _Claim(371, ('given', 'suffix', 'title'), 'af9e870177b7', None), "fix(comma-family) a comma followed only by titles keeps the given/family split": _Claim(2, ('family', 'given'), "5bd9c6d96c38", None), "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example": @@ -3484,8 +3513,11 @@ def _claim(rule: dict) -> _Claim: # comma-cost row's shape landing in this rule's reach too. # 2026-09-25, #540: 367 -> 369, 'Smith, meng' and 'Smith, # MEng', the lone-word comma rows' shape landing here too. + # 2026-09-25, #540: 369 -> 371, 'John Smith, PhD MEng' and + # 'john smith, phd meng', the credential-run comma rows' + # shape landing here too. "fix(comma-precomma-family) pre-comma run reads as family, not given": - _Claim(369, ('family', 'given'), '5bf76967bc46', None), + _Claim(371, ('family', 'given'), 'af9e870177b7', None), # 2026-09-20, #397: retitled in place, reach and digest # unchanged -- the rule keeps 'Carod i', which the landing # leaves byte-identical. @@ -3678,8 +3710,12 @@ def _claim(rule: dict) -> _Claim: # case row admitted and which this rule explains here: its # family 'van' is a one-particle part, this rule's reason and # not #540's. + # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac' -- reached, not + # explained, by this rule: its role move is explained by the + # Title-case Lac rule instead, so `_initials` never enters its + # diff here. "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(30, ('_initials',), "8b99d25622a3", ('DEFAULT',)), + _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # #269's derived-view rule, added 2026-09-13. One corpus name, @@ -3981,20 +4017,26 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's five rules: three literal alternations and two + # #540's eight rules: five literal alternations and three # literal names, every name one of #540's own case rows. The # report rule's `_ambiguities`-only roles are its point: a # widening that took a role would change them here first. "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), "fix(#540) a lone meng or MEng after a family comma reads as the given name": _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "1d95a4b7cfc9", ('DEFAULT',)), # #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 @@ -4277,15 +4319,19 @@ def _claim(rule: dict) -> _Claim: "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold, the given-last spelling": _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "3e43a2be022e", ('FAMILY_FIRST_GIVEN_LAST',)), # #484's two `_initials` rules. Both are literal name lists; - # reach 30 and 1, explaining 29 and 1 (2026-09-25), the gap - # being 'nguyen van lac' below -- and both digests match the - # 1.4.0 ledger's, which is the point of copying the list - # verbatim rather than restating it. + # reach 31 and 1, explaining 29 and 1 (2026-09-25), the gap + # being 'nguyen van lac' and 'Nguyen Van Lac' below -- and + # both digests match the 1.4.0 ledger's, which is the point + # of copying the list verbatim rather than restating it. # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', reached and # NOT explained at this baseline -- its report moves here, # which keeps `_initials` out of its diff (#484). + # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac' -- the + # case-insensitive regex matches it too, and its role move is + # explained by the Title-case Lac rule below, so #484 keeps + # `_initials` out of its diff the same way. "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(30, ('_initials',), "8b99d25622a3", ('DEFAULT',)), + _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # fix(#462) reaches more than it explains -- the reach is the @@ -4527,20 +4573,26 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's five rules: three literal alternations and two + # #540's eight rules: five literal alternations and three # literal names, every name one of #540's own case rows. The # report rule's `_ambiguities`-only roles are its point: a # widening that took a role would change them here first. "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), "fix(#540) a lone meng or MEng after a family comma reads as the given name": _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "1d95a4b7cfc9", ('DEFAULT',)), # #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 @@ -4836,20 +4888,26 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's five rules: three literal alternations and two + # #540's eight rules: five literal alternations and three # literal names, every name one of #540's own case rows. The # report rule's `_ambiguities`-only roles are its point: a # widening that took a role would change them here first. "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), "fix(#540) a lone meng or MEng after a family comma reads as the given name": _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "1d95a4b7cfc9", ('DEFAULT',)), # #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 @@ -5113,8 +5171,10 @@ def _claim(rule: dict) -> _Claim: # files carry the same literal list. # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', reached and # not explained here, as in the 2.0.0 mapping. + # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac', reached and + # not explained here, as in the 2.0.0 mapping. "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(30, ('_initials',), "8b99d25622a3", ('DEFAULT',)), + _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # fix(#462), reach and digest as in the 2.0.0 mapping: the same @@ -5347,20 +5407,26 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's five rules: three literal alternations and two + # #540's eight rules: five literal alternations and three # literal names, every name one of #540's own case rows. The # report rule's `_ambiguities`-only roles are its point: a # widening that took a role would change them here first. "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "8547712f3964", ('DEFAULT',)), + _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), "fix(#540) a lone meng or MEng after a family comma reads as the given name": _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "1d95a4b7cfc9", ('DEFAULT',)), # #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_rules.jsonl b/tools/differential/corpus_rules.jsonl index d6cdd1ce..ed4b3072 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -238,6 +238,7 @@ "Ménil Christophe de" "Ménil de" "Nguyen Thi Van" +"Nguyen Van Lac" "Nguyen, Thi Van" "Nguyen, Van" "Nguyen, Van Le" @@ -308,6 +309,7 @@ "Van Johnson" "Vega, Juan de la" "Vincent van Gogh van Beethoven" +"Wang M.Eng." "Xyz. John Smith" "Xyz. Smith, John" "Xyz. van Johnson" diff --git a/tools/differential/corpus_shapes.jsonl b/tools/differential/corpus_shapes.jsonl index 9a043e10..c8f65f2e 100644 --- a/tools/differential/corpus_shapes.jsonl +++ b/tools/differential/corpus_shapes.jsonl @@ -58,6 +58,7 @@ {"name": "John Smith E.S.Q.", "shape": 1} {"name": "John Smith J.u.n.i.o.r.", "shape": 1} {"name": "John Smith Jr.", "shape": 1} +{"name": "John Smith MEng PhD", "shape": 1} {"name": "John Smith Ma", "shape": 1} {"name": "John Smith Ph.", "shape": 1} {"name": "John Smith Q.W.E.R.T.", "shape": 1} @@ -95,7 +96,9 @@ {"name": "Md Abdul Karim", "shape": 1} {"name": "Minjun KIM", "shape": 1} {"name": "Mr MA", "shape": 1} +{"name": "Nguyen Van Lac", "shape": 1} {"name": "Sir Bob Andrew Dole", "shape": 1} +{"name": "Wang M.Eng.", "shape": 1} {"name": "X.Y.Z. Smith", "shape": 1} {"name": "abdul Smith Jr Ma", "shape": 1} {"name": "anh van do", "shape": 1} @@ -214,8 +217,10 @@ {"name": "John Smith, MD, Ma", "shape": 3} {"name": "John Smith, MD, R.A.I.", "shape": 3} {"name": "John Smith, PhD", "shape": 3} +{"name": "John Smith, PhD MEng", "shape": 3} {"name": "Steven Hardman, MD, DO, DDS", "shape": 3} {"name": "john smith, ma", "shape": 3} +{"name": "john smith, phd meng", "shape": 3} {"name": "John Smith, Dr.", "shape": 4} {"name": "de Mesnil Jean, Dr.", "shape": 4} {"name": "de la Cruz Juan Carlos", "shape": 4} diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index 74083ccf..b3597cd0 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -3092,13 +3092,15 @@ issue = "fix(#385/#402) an all-particle name part initials its words (R2)" # tree gives 'A. D.'). The fields did not move, only the view, which # is why no rule could classify this until #484 gave the gate an # initials column. Reach exactly the names that move, listed -# literally rather than shaped (30 corpus names over 26 spellings on +# literally rather than shaped (31 corpus names over 26 spellings on # 2026-09-25; _CORPUS_CLAIMS holds the live reach) -- 'anh van mc' # joined on 2026-09-18 with the case row that admitted it to the # corpus, the unambiguous-vocabulary twin of the 'anh van do' already # here, and 'nguyen van lac' on 2026-09-25 with #540's, its family # 'van' a one-particle part whose initial 1.4.0 dropped ('n.' -> -# 'n. v.'), which is this rule's reason and not #540's: +# 'n. v.'), which is this rule's reason and not #540's -- the +# case-insensitive regex reaches the later Title-case 'Nguyen Van +# Lac' too, one spelling for two corpus names: # the shape is "a part of nothing but particles", which no regex over # the raw string can state, and the fix(#445) rule above set the # precedent for a name list in THIS ledger (fix(#410) and fix(#335) @@ -4395,15 +4397,23 @@ name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] # --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Two rules here -# where the 2.x ledgers carry five. 'wang meng' and 'tran lac' read the +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Four rules here +# where the 2.x ledgers carry eight. 'wang meng' and 'tran lac' read the # family at 1.4.0 as the tree does, 'Smith, meng' and 'Smith, MEng' read # the given name the same way, and 'john smith meng' and 'nguyen van lac' # keep 1.4.0's suffix -- their only new thing is a report, and # `_ambiguities` is a v2 surface this baseline does not compare. ('nguyen # van lac' does diff here, on `_initials`, for fix(#385/#402)'s reason, -# and that rule's list carries it.) What is left is the marking's cost, -# paid twice: once bare behind a full name and once after a family comma. +# and that rule's list carries it; the Title-case 'Nguyen Van Lac' +# reaches that same rule too, case-insensitively, but its role move is +# explained by the Title-case Lac rule below instead.) 'Wang M.Eng.' +# also reads the family here, for an unrelated reason -- its two-piece +# rule (a lone word after the given name is the family), not S2's +# gate -- which is why that rule is 2.x only. What is left is four +# rules: the marking's cost, paid twice +# (once bare behind a full name, once after a family comma), the same +# cost arriving as a credential run behind a suffix comma, and the +# Title-case Lac gain that is the cost's mirror. # decisions.md#suffix-acronym-collisions records the decision. # --------------------------------------------------------------- @@ -4415,9 +4425,15 @@ issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the f # that is not wholly lower" as the name "even with words to spare", # and MEng is conventionally written in exactly that form. The same # rule as the 2.x ledgers' of this name, without `_ambiguities`. -# Literal, one name; the one-case spellings are _MUST_NOT_MATCH +# +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where 1.4.0 +# read suffix 'MEng, PhD'. +# +# Literal, two names; the one-case spellings are _MUST_NOT_MATCH # probes. -name_regex = "^john smith MEng$" +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" fields = ["family", "middle", "suffix"] orders = ["DEFAULT"] @@ -4434,3 +4450,43 @@ issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a m name_regex = "^Smith, John MEng$" fields = ["middle", "suffix"] orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, so C1 reads the comma as a family +# comma instead and the run's own words fall into the usual post-comma +# name slots. 'John Smith, PhD MEng' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). 1.4.0 read the whole run as a suffix. The +# pre-existing 'john smith, phd ma' path, which #540 routes two more +# words into; a follow-up issue is to be filed on the mechanism +# itself. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', where 1.4.0 read +# family 'Van', suffix 'Lac'. The mixed-case name's Title-case member +# leans 'name' with words to spare (S2) -- the gain side of the +# marking's cost, for a Vietnamese name (Lac is the trailing word in +# native order, Nguyen Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps its 1.4.0 +# suffix and is a _MUST_NOT_MATCH probe, along with the superstring +# 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml index 98a7c32a..b3b709e0 100644 --- a/tools/differential/expected_since_2.0.0.toml +++ b/tools/differential/expected_since_2.0.0.toml @@ -3287,24 +3287,28 @@ name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] # --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Five rules -- -# the two names whose family name comes back, the marking's cost -# behind a full name, the same cost after a family comma, the lone -# word after a family comma (given name back, cost for the -# conventional spelling), and the two that keep the credential and -# gain a report. No rule of the parser changed: the two words joined -# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads -# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the -# name has words to spare". decisions.md#suffix-acronym-collisions -# records the decision, and the removal and the masks it declined. +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Eight rules +# -- the two names whose family name comes back, the marking's +# cost behind a full name, the same cost after a family comma, the +# lone word after a family comma (given name back, cost for the +# conventional spelling), the same cost arriving as a credential +# run behind a suffix comma, the chunked dotted M.Eng. cost, the +# Title-case Lac gain that is the cost's mirror, and the two that +# keep the credential and gain a report. No rule of the parser +# changed: the two words joined suffix_acronyms_ambiguous, and +# rules.md#S2 reads them as it reads 'ma' and 'ba' -- "A BARE +# ambiguous acronym is consumed only when the name has words to +# spare". decisions.md#suffix-acronym-collisions records the +# decision, and the removal and the masks it declined. # # Every name here is one of #540's own shape-tagged case rows. No # corpus name written before the change carried a bare trailing # 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at # 120033b5), so the gate saw nothing until the rows admitted it. -# One set of five rules for the four 2.x ledgers; the 1.4.0 ledger -# carries the two accepted rules alone, 1.4.0 having read the -# other six names' roles as the tree does. +# One set of eight rules for the four 2.x ledgers; the 1.4.0 +# ledger carries four of them (every rule but the family-comes-back +# one, the lone-word one, the dotted one and the report one), 1.4.0 +# having read the other seven names' roles as the tree does. # --------------------------------------------------------------- [[change]] @@ -3337,12 +3341,17 @@ issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the f # and LAc are conventionally written in exactly that form -- the cost # 'John Smith Ma' already carries. # +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where every +# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). +# # A rule of its own rather than a member of the one above, so the # gate's summary and the release note name the cost under its own -# heading. Literal, one name: the one-case spellings 'john smith +# heading. Literal, two names: the one-case spellings 'john smith # meng' and 'JOHN SMITH MENG' keep the credential, the count deciding # where the writing says nothing, and are _MUST_NOT_MATCH probes. -name_regex = "^john smith MEng$" +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" fields = ["family", "middle", "suffix", "_ambiguities"] orders = ["DEFAULT"] @@ -3391,3 +3400,66 @@ issue = "fix(#540) a bare trailing meng or lac with words to spare stays the cre name_regex = "^(?:john smith meng|nguyen van lac)$" fields = ["_ambiguities"] orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, so C1 reads the comma as a family +# comma instead and the run's own words fall into the usual post-comma +# name slots. 'John Smith, PhD MEng' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). Every release read the whole run as a suffix. +# The pre-existing 'john smith, phd ma' path, which #540 routes two +# more words into; a follow-up issue is to be filed on the mechanism +# itself. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a +# suffix-or-name report, where every release read family 'Van', suffix +# 'Lac'. The mixed-case name's Title-case member leans 'name' with +# words to spare (S2) -- the gain side of the marking's cost, for a +# Vietnamese name (Lac is the trailing word in native order, Nguyen +# Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix +# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, +# along with the superstring 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name" +# 'Wang M.Eng.': given 'Wang', family 'M.Eng.', with a suffix-or-name +# report, where every release from 2.0.0 read suffix 'M.Eng.' with no family name. +# S2's period gate counts a member as unambiguous only when it is +# written one period per letter ('M.A.'), and MEng and LAc are the +# first members whose conventional dotted spelling is chunked instead, +# so 'M.Eng.' with nothing to spare reads as the ambiguous bare word +# does and the family name comes back. Accepted and recorded rather +# than widening the period gate (Derek, 2026-09-25); 1.4.0 already read +# this family, unflagged, for an unrelated reason (its two-piece rule: +# a lone word after the given name is the family), so that ledger +# carries no copy of this rule. +# +# Literal, one name; 'John Smith M.Eng.' keeps the suffix by the count +# (words to spare) and 'Wang M.A.' keeps the suffix too (M.A. passes +# the period gate, unaffected by this change) -- both are +# _MUST_NOT_MATCH probes, along with the superstring 'Dr. Wang M.Eng.'. +name_regex = "^Wang M\\.Eng\\.$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml index 7a2190d3..bb360217 100644 --- a/tools/differential/expected_since_2.1.0.toml +++ b/tools/differential/expected_since_2.1.0.toml @@ -3198,24 +3198,28 @@ name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] # --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Five rules -- -# the two names whose family name comes back, the marking's cost -# behind a full name, the same cost after a family comma, the lone -# word after a family comma (given name back, cost for the -# conventional spelling), and the two that keep the credential and -# gain a report. No rule of the parser changed: the two words joined -# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads -# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the -# name has words to spare". decisions.md#suffix-acronym-collisions -# records the decision, and the removal and the masks it declined. +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Eight rules +# -- the two names whose family name comes back, the marking's +# cost behind a full name, the same cost after a family comma, the +# lone word after a family comma (given name back, cost for the +# conventional spelling), the same cost arriving as a credential +# run behind a suffix comma, the chunked dotted M.Eng. cost, the +# Title-case Lac gain that is the cost's mirror, and the two that +# keep the credential and gain a report. No rule of the parser +# changed: the two words joined suffix_acronyms_ambiguous, and +# rules.md#S2 reads them as it reads 'ma' and 'ba' -- "A BARE +# ambiguous acronym is consumed only when the name has words to +# spare". decisions.md#suffix-acronym-collisions records the +# decision, and the removal and the masks it declined. # # Every name here is one of #540's own shape-tagged case rows. No # corpus name written before the change carried a bare trailing # 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at # 120033b5), so the gate saw nothing until the rows admitted it. -# One set of five rules for the four 2.x ledgers; the 1.4.0 ledger -# carries the two accepted rules alone, 1.4.0 having read the -# other six names' roles as the tree does. +# One set of eight rules for the four 2.x ledgers; the 1.4.0 +# ledger carries four of them (every rule but the family-comes-back +# one, the lone-word one, the dotted one and the report one), 1.4.0 +# having read the other seven names' roles as the tree does. # --------------------------------------------------------------- [[change]] @@ -3248,12 +3252,17 @@ issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the f # and LAc are conventionally written in exactly that form -- the cost # 'John Smith Ma' already carries. # +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where every +# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). +# # A rule of its own rather than a member of the one above, so the # gate's summary and the release note name the cost under its own -# heading. Literal, one name: the one-case spellings 'john smith +# heading. Literal, two names: the one-case spellings 'john smith # meng' and 'JOHN SMITH MENG' keep the credential, the count deciding # where the writing says nothing, and are _MUST_NOT_MATCH probes. -name_regex = "^john smith MEng$" +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" fields = ["family", "middle", "suffix", "_ambiguities"] orders = ["DEFAULT"] @@ -3302,3 +3311,66 @@ issue = "fix(#540) a bare trailing meng or lac with words to spare stays the cre name_regex = "^(?:john smith meng|nguyen van lac)$" fields = ["_ambiguities"] orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, so C1 reads the comma as a family +# comma instead and the run's own words fall into the usual post-comma +# name slots. 'John Smith, PhD MEng' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). Every release read the whole run as a suffix. +# The pre-existing 'john smith, phd ma' path, which #540 routes two +# more words into; a follow-up issue is to be filed on the mechanism +# itself. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a +# suffix-or-name report, where every release read family 'Van', suffix +# 'Lac'. The mixed-case name's Title-case member leans 'name' with +# words to spare (S2) -- the gain side of the marking's cost, for a +# Vietnamese name (Lac is the trailing word in native order, Nguyen +# Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix +# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, +# along with the superstring 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name" +# 'Wang M.Eng.': given 'Wang', family 'M.Eng.', with a suffix-or-name +# report, where every release from 2.0.0 read suffix 'M.Eng.' with no family name. +# S2's period gate counts a member as unambiguous only when it is +# written one period per letter ('M.A.'), and MEng and LAc are the +# first members whose conventional dotted spelling is chunked instead, +# so 'M.Eng.' with nothing to spare reads as the ambiguous bare word +# does and the family name comes back. Accepted and recorded rather +# than widening the period gate (Derek, 2026-09-25); 1.4.0 already read +# this family, unflagged, for an unrelated reason (its two-piece rule: +# a lone word after the given name is the family), so that ledger +# carries no copy of this rule. +# +# Literal, one name; 'John Smith M.Eng.' keeps the suffix by the count +# (words to spare) and 'Wang M.A.' keeps the suffix too (M.A. passes +# the period gate, unaffected by this change) -- both are +# _MUST_NOT_MATCH probes, along with the superstring 'Dr. Wang M.Eng.'. +name_regex = "^Wang M\\.Eng\\.$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.2.0.toml b/tools/differential/expected_since_2.2.0.toml index 49cc2017..c4c6295f 100644 --- a/tools/differential/expected_since_2.2.0.toml +++ b/tools/differential/expected_since_2.2.0.toml @@ -1653,24 +1653,28 @@ name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] # --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Five rules -- -# the two names whose family name comes back, the marking's cost -# behind a full name, the same cost after a family comma, the lone -# word after a family comma (given name back, cost for the -# conventional spelling), and the two that keep the credential and -# gain a report. No rule of the parser changed: the two words joined -# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads -# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the -# name has words to spare". decisions.md#suffix-acronym-collisions -# records the decision, and the removal and the masks it declined. +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Eight rules +# -- the two names whose family name comes back, the marking's +# cost behind a full name, the same cost after a family comma, the +# lone word after a family comma (given name back, cost for the +# conventional spelling), the same cost arriving as a credential +# run behind a suffix comma, the chunked dotted M.Eng. cost, the +# Title-case Lac gain that is the cost's mirror, and the two that +# keep the credential and gain a report. No rule of the parser +# changed: the two words joined suffix_acronyms_ambiguous, and +# rules.md#S2 reads them as it reads 'ma' and 'ba' -- "A BARE +# ambiguous acronym is consumed only when the name has words to +# spare". decisions.md#suffix-acronym-collisions records the +# decision, and the removal and the masks it declined. # # Every name here is one of #540's own shape-tagged case rows. No # corpus name written before the change carried a bare trailing # 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at # 120033b5), so the gate saw nothing until the rows admitted it. -# One set of five rules for the four 2.x ledgers; the 1.4.0 ledger -# carries the two accepted rules alone, 1.4.0 having read the -# other six names' roles as the tree does. +# One set of eight rules for the four 2.x ledgers; the 1.4.0 +# ledger carries four of them (every rule but the family-comes-back +# one, the lone-word one, the dotted one and the report one), 1.4.0 +# having read the other seven names' roles as the tree does. # --------------------------------------------------------------- [[change]] @@ -1703,12 +1707,17 @@ issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the f # and LAc are conventionally written in exactly that form -- the cost # 'John Smith Ma' already carries. # +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where every +# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). +# # A rule of its own rather than a member of the one above, so the # gate's summary and the release note name the cost under its own -# heading. Literal, one name: the one-case spellings 'john smith +# heading. Literal, two names: the one-case spellings 'john smith # meng' and 'JOHN SMITH MENG' keep the credential, the count deciding # where the writing says nothing, and are _MUST_NOT_MATCH probes. -name_regex = "^john smith MEng$" +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" fields = ["family", "middle", "suffix", "_ambiguities"] orders = ["DEFAULT"] @@ -1757,3 +1766,66 @@ issue = "fix(#540) a bare trailing meng or lac with words to spare stays the cre name_regex = "^(?:john smith meng|nguyen van lac)$" fields = ["_ambiguities"] orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, so C1 reads the comma as a family +# comma instead and the run's own words fall into the usual post-comma +# name slots. 'John Smith, PhD MEng' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). Every release read the whole run as a suffix. +# The pre-existing 'john smith, phd ma' path, which #540 routes two +# more words into; a follow-up issue is to be filed on the mechanism +# itself. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a +# suffix-or-name report, where every release read family 'Van', suffix +# 'Lac'. The mixed-case name's Title-case member leans 'name' with +# words to spare (S2) -- the gain side of the marking's cost, for a +# Vietnamese name (Lac is the trailing word in native order, Nguyen +# Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix +# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, +# along with the superstring 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name" +# 'Wang M.Eng.': given 'Wang', family 'M.Eng.', with a suffix-or-name +# report, where every release from 2.0.0 read suffix 'M.Eng.' with no family name. +# S2's period gate counts a member as unambiguous only when it is +# written one period per letter ('M.A.'), and MEng and LAc are the +# first members whose conventional dotted spelling is chunked instead, +# so 'M.Eng.' with nothing to spare reads as the ambiguous bare word +# does and the family name comes back. Accepted and recorded rather +# than widening the period gate (Derek, 2026-09-25); 1.4.0 already read +# this family, unflagged, for an unrelated reason (its two-piece rule: +# a lone word after the given name is the family), so that ledger +# carries no copy of this rule. +# +# Literal, one name; 'John Smith M.Eng.' keeps the suffix by the count +# (words to spare) and 'Wang M.A.' keeps the suffix too (M.A. passes +# the period gate, unaffected by this change) -- both are +# _MUST_NOT_MATCH probes, along with the superstring 'Dr. Wang M.Eng.'. +name_regex = "^Wang M\\.Eng\\.$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.3.0.toml b/tools/differential/expected_since_2.3.0.toml index 70331523..1d1aa7b1 100644 --- a/tools/differential/expected_since_2.3.0.toml +++ b/tools/differential/expected_since_2.3.0.toml @@ -970,24 +970,28 @@ name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] # --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Five rules -- -# the two names whose family name comes back, the marking's cost -# behind a full name, the same cost after a family comma, the lone -# word after a family comma (given name back, cost for the -# conventional spelling), and the two that keep the credential and -# gain a report. No rule of the parser changed: the two words joined -# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads -# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the -# name has words to spare". decisions.md#suffix-acronym-collisions -# records the decision, and the removal and the masks it declined. +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Eight rules +# -- the two names whose family name comes back, the marking's +# cost behind a full name, the same cost after a family comma, the +# lone word after a family comma (given name back, cost for the +# conventional spelling), the same cost arriving as a credential +# run behind a suffix comma, the chunked dotted M.Eng. cost, the +# Title-case Lac gain that is the cost's mirror, and the two that +# keep the credential and gain a report. No rule of the parser +# changed: the two words joined suffix_acronyms_ambiguous, and +# rules.md#S2 reads them as it reads 'ma' and 'ba' -- "A BARE +# ambiguous acronym is consumed only when the name has words to +# spare". decisions.md#suffix-acronym-collisions records the +# decision, and the removal and the masks it declined. # # Every name here is one of #540's own shape-tagged case rows. No # corpus name written before the change carried a bare trailing # 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at # 120033b5), so the gate saw nothing until the rows admitted it. -# One set of five rules for the four 2.x ledgers; the 1.4.0 ledger -# carries the two accepted rules alone, 1.4.0 having read the -# other six names' roles as the tree does. +# One set of eight rules for the four 2.x ledgers; the 1.4.0 +# ledger carries four of them (every rule but the family-comes-back +# one, the lone-word one, the dotted one and the report one), 1.4.0 +# having read the other seven names' roles as the tree does. # --------------------------------------------------------------- [[change]] @@ -1020,12 +1024,17 @@ issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the f # and LAc are conventionally written in exactly that form -- the cost # 'John Smith Ma' already carries. # +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where every +# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). +# # A rule of its own rather than a member of the one above, so the # gate's summary and the release note name the cost under its own -# heading. Literal, one name: the one-case spellings 'john smith +# heading. Literal, two names: the one-case spellings 'john smith # meng' and 'JOHN SMITH MENG' keep the credential, the count deciding # where the writing says nothing, and are _MUST_NOT_MATCH probes. -name_regex = "^john smith MEng$" +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" fields = ["family", "middle", "suffix", "_ambiguities"] orders = ["DEFAULT"] @@ -1074,3 +1083,66 @@ issue = "fix(#540) a bare trailing meng or lac with words to spare stays the cre name_regex = "^(?:john smith meng|nguyen van lac)$" fields = ["_ambiguities"] orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, so C1 reads the comma as a family +# comma instead and the run's own words fall into the usual post-comma +# name slots. 'John Smith, PhD MEng' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). Every release read the whole run as a suffix. +# The pre-existing 'john smith, phd ma' path, which #540 routes two +# more words into; a follow-up issue is to be filed on the mechanism +# itself. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a +# suffix-or-name report, where every release read family 'Van', suffix +# 'Lac'. The mixed-case name's Title-case member leans 'name' with +# words to spare (S2) -- the gain side of the marking's cost, for a +# Vietnamese name (Lac is the trailing word in native order, Nguyen +# Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix +# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, +# along with the superstring 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name" +# 'Wang M.Eng.': given 'Wang', family 'M.Eng.', with a suffix-or-name +# report, where every release from 2.0.0 read suffix 'M.Eng.' with no family name. +# S2's period gate counts a member as unambiguous only when it is +# written one period per letter ('M.A.'), and MEng and LAc are the +# first members whose conventional dotted spelling is chunked instead, +# so 'M.Eng.' with nothing to spare reads as the ambiguous bare word +# does and the family name comes back. Accepted and recorded rather +# than widening the period gate (Derek, 2026-09-25); 1.4.0 already read +# this family, unflagged, for an unrelated reason (its two-piece rule: +# a lone word after the given name is the family), so that ledger +# carries no copy of this rule. +# +# Literal, one name; 'John Smith M.Eng.' keeps the suffix by the count +# (words to spare) and 'Wang M.A.' keeps the suffix too (M.A. passes +# the period gate, unaffected by this change) -- both are +# _MUST_NOT_MATCH probes, along with the superstring 'Dr. Wang M.Eng.'. +name_regex = "^Wang M\\.Eng\\.$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] From f8de0ac1c1d6cab54663c927f18864d63093f617 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Fri, 25 Sep 2026 21:47:48 -0700 Subject: [PATCH 5/5] docs(#540): the follow-up is #544 The comma re-read by a credential run ending in an ambiguous member, its silent title-dual case, and whether a listed member's chunked dotted spelling should pass the period gate are filed as #544; the seven sites that said a follow-up was to be filed now name it. Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 2 +- tests/v2/cases.py | 4 ++-- tools/differential/expected_since_1.4.0.toml | 3 +-- tools/differential/expected_since_2.0.0.toml | 4 ++-- tools/differential/expected_since_2.1.0.toml | 4 ++-- tools/differential/expected_since_2.2.0.toml | 4 ++-- tools/differential/expected_since_2.3.0.toml | 4 ++-- 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 0b3fe951..621a6b35 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -608,7 +608,7 @@ Closes #342 (a wordlist question) and #454 (a rules.md question) together, becau - **Measurement (2026-09-07).** Five corpus names move, every one from the radar-tier corpus_issues.jsonl, in three diff shapes with one cause: Aishwarya Rai moves {family, suffix}, Lala Lajpat Rai and John Smith RAI move {middle, family, suffix}, and John Smith, RAI and Ahmad Jayadi, CHA move {given, family, suffix}. Recompute by parsing every name in the tools/differential/corpus*.jsonl glob twice — once with the shipped lexicon, once with `Lexicon.default().add(suffix_acronyms={"rai","cha"})`, which RESTORES the two entries this bundle removed so the diff read forwards is the removal's — and diffing the seven name fields plus `ambiguities`; the ba step of the same sweep moves nothing. Aishwarya Rai is the one name whose diff DISAPPEARS at 1.4.0, that release having read family Rai too, which is why the `fix(#342) NOT WANTED` ledger rule was deleted rather than rewritten and why the 1.4.0 gate lists four names under the replacement where the three 2.x ledgers list five. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; they rose by three at 1.4.0 and by five at each 2.x baseline. - **2026-09-15 (#289/#516) — the parking lot is CLOSED, and this entry's "measured 2026-09-07, none of the four is read as a suffix today" sentence is superseded rather than rewritten.** The shape-plus-position heuristic this entry parked is what shipped: the DOTTED shape as `Policy.unlisted_dotted_suffixes`, default on, and the ALL-CAPS shape as `Policy.unlisted_caps_suffixes`, default off. Of the four names the bullet measured, `John Smith X.Y.Z.` and `john smith x.y.z.` now read suffix at the default, and `John Smith XYZ` / `John Smith, XYZ` read suffix only with the opt-in switch. See #S2 for the whole decision and for why the two halves have different defaults. - **2026-09-15 (#289/#516) — the roman-numeral accident is RETIRED, and here is what narrowly means.** This entry's bullet recorded the accident as evidence rather than as behavior anyone relied on. It now fires no longer where every chunk the vocabulary matches is a single ASCII character — measured, `{i, v, 2}` — so `John Smith R.A.I.` and `John Smith X.Y.I.` reach the shape class instead and read by position, which gives the same fields for the first and 1.4.0's family reading for `Jack X.Y.I.`. `John Smith C.H.A.`, the bullet's other half, is unaffected: nothing claimed it before or after. The WIDE retirement was measured and rejected; #S2 records the three names that pay for it. -- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules.md#S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. DECIDED (Derek, 2026-09-25): the bare `Wang M.Eng.` reading — family `M.Eng.`, nothing to spare — is accepted and recorded rather than widening the period gate to cover a chunked dotted spelling; S2's period gate counts a member as unambiguous only when it is written one period per letter (`M.A.`), and MEng and LAc are the first members whose conventional dotted spelling is chunked instead. `John Smith M.Eng.` keeps the suffix by the words-to-spare count, unaffected. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. A CREDENTIAL RUN ending in one of them pays the same walk-stopping cost: `Mary Jones PhD MEng` reads middle `Jones PhD`, family `MEng`, and `Jane Doe MS LAc` reads middle `Doe MS`, family `LAc`, where every release read the whole run as a suffix (`PhD, MEng` and `MS, LAc` through 2.2, `PhD MEng` and `MS LAc` in 2.3); `John Smith MEng PhD` reads middle `Smith`, family `MEng`, suffix `PhD`, the unambiguous credential behind the declined pick still peeled; the mechanism is S2's own — the case signal costs a genuine suffix standing behind a name-leaning acronym, the walk stopping at the declined pick rather than continuing past it — pinned by `tests/v2/cases.py`'s `a_declined_ambiguous_pick_stops_the_walk` and, for the family-comma shape, `a_declined_member_ends_the_trailing_run` (`Doe, John MA Ma`). A QUIETER COST sits behind a suffix comma: once the run's last word is a bare ambiguous member, the run is no longer wholly suffix-shaped, so C1 reads the comma as a family comma instead and the run's own words fall into the usual post-comma name slots — `John Smith, PhD MEng` reads given `PhD`, middle `MEng`, family `John Smith`, and `john smith, phd meng` reads given `phd`, family `john smith`, suffix `meng` (the count then peels `meng` with a word to spare); neither case form saves it, `JOHN SMITH, PHD MENG` reading the same way. A title-listed word standing in front of the run reads as a TITLE instead, with no report: `Jane Doe, MS LAc` reads title `MS`, given `LAc`, family `Jane Doe`, unflagged, where every release read suffix `MS LAc`. The path is not new: `john smith, phd ma` and `Jane Doe, MS Ma` already read this way, `ma` having been ambiguous all along. Pinned by `tests/v2/cases.py`'s `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma`. A follow-up issue is to be filed on the comma re-read by a run ending in an ambiguous member (and its silent title-dual case) and on whether a listed member's chunked dotted spelling should pass the period gate. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count; bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 (unmoved by this change) with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). +- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules.md#S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. DECIDED (Derek, 2026-09-25): the bare `Wang M.Eng.` reading — family `M.Eng.`, nothing to spare — is accepted and recorded rather than widening the period gate to cover a chunked dotted spelling; S2's period gate counts a member as unambiguous only when it is written one period per letter (`M.A.`), and MEng and LAc are the first members whose conventional dotted spelling is chunked instead. `John Smith M.Eng.` keeps the suffix by the words-to-spare count, unaffected. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. A CREDENTIAL RUN ending in one of them pays the same walk-stopping cost: `Mary Jones PhD MEng` reads middle `Jones PhD`, family `MEng`, and `Jane Doe MS LAc` reads middle `Doe MS`, family `LAc`, where every release read the whole run as a suffix (`PhD, MEng` and `MS, LAc` through 2.2, `PhD MEng` and `MS LAc` in 2.3); `John Smith MEng PhD` reads middle `Smith`, family `MEng`, suffix `PhD`, the unambiguous credential behind the declined pick still peeled; the mechanism is S2's own — the case signal costs a genuine suffix standing behind a name-leaning acronym, the walk stopping at the declined pick rather than continuing past it — pinned by `tests/v2/cases.py`'s `a_declined_ambiguous_pick_stops_the_walk` and, for the family-comma shape, `a_declined_member_ends_the_trailing_run` (`Doe, John MA Ma`). A QUIETER COST sits behind a suffix comma: once the run's last word is a bare ambiguous member, the run is no longer wholly suffix-shaped, so C1 reads the comma as a family comma instead and the run's own words fall into the usual post-comma name slots — `John Smith, PhD MEng` reads given `PhD`, middle `MEng`, family `John Smith`, and `john smith, phd meng` reads given `phd`, family `john smith`, suffix `meng` (the count then peels `meng` with a word to spare); neither case form saves it, `JOHN SMITH, PHD MENG` reading the same way. A title-listed word standing in front of the run reads as a TITLE instead, with no report: `Jane Doe, MS LAc` reads title `MS`, given `LAc`, family `Jane Doe`, unflagged, where every release read suffix `MS LAc`. The path is not new: `john smith, phd ma` and `Jane Doe, MS Ma` already read this way, `ma` having been ambiguous all along. Pinned by `tests/v2/cases.py`'s `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma`. The comma re-read (with its silent title-dual case) and whether a listed member's chunked dotted spelling should pass the period gate are [#544](https://github.com/derek73/python-nameparser/issues/544)'s questions. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count; bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 (unmoved by this change) with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). - **Declined 2026-09-25 (#540), with measurement: removing the two words, and masking them.** REMOVAL, the rai/cha answer, measured with `Parser(lexicon=Lexicon.default().remove(suffix_acronyms={"meng", "lac"}, suffix_acronyms_ambiguous={"meng", "lac"}))`: `john smith meng` reads middle `smith`, family `meng`, and `JOHN SMITH MENG` family `MENG`, while `john smith m.eng.` stays a suffix by its dotted shape (S3), reported. Removal loses the bare credential outright where the marking keeps it with words to spare, and C-i's default under uncertainty is the marking. MASKS `meng → MEng` and `lac → LAc`, measured with both added to a private lexicon on top of the marking: the exceptions map is role-free (#R4, 2026-09-23), so `wang meng` renders `Wang MEng`, `tran lac` `Tran LAc` and `meng li` `MEng Li` — the mask re-spells the very surnames the marking restores. A suffix-gated mask would reverse the 2026-09-23 role-free decision for two words and is not taken. Without one, the default path renders `wang meng` as `Wang Meng` (the parent gave `Wang MENG`, the credential clause writing a suffix in capitals), `tran lac` as `Tran Lac`, `john smith meng` as `John Smith MENG` and `nguyen van lac` as `Nguyen Van LAC` by the acronym clause, and keeps a writer's `MEng` as written (#R5, 2026-09-24) — `john smith MEng` gives `john smith MEng`, and `John Smith Meng` under `force=True`, the word being the family name there. Pinned by `tests/v2/test_render.py::test_a_listed_acronym_that_is_a_name_word_gets_no_mask`, whose recorded negative control carries the two masks. - **Measurement (2026-09-25, #540).** No corpus name written before this change carries a bare trailing `meng` or `lac` (the `tools/differential/corpus*.jsonl` glob at 120033b5), so the population that could move is this change's own shape-tagged case rows, and every mover is one of them. Classified by `fix(#540)` rules: at each 2.x baseline `wang meng` and `tran lac` move {family, suffix, _ambiguities}, `john smith MEng` and `John Smith MEng PhD` move {middle, family, suffix, _ambiguities}, and `john smith meng` and `nguyen van lac` `_ambiguities` alone; `Smith, John MEng` moves {middle, suffix, _ambiguities} at 2.x and {middle, suffix} at 1.4.0; `Smith, meng` and `Smith, MEng` move {given, suffix, _ambiguities} at 2.x and nothing at 1.4.0 (1.4.0's own reading); `John Smith, PhD MEng` moves {given, middle, family, suffix, _ambiguities} and `john smith, phd meng` {given, family, suffix, _ambiguities} at 2.x (without `_ambiguities` at 1.4.0); `Nguyen Van Lac` moves {family, suffix, _ambiguities} at 2.x and {family, suffix} at 1.4.0; and `Wang M.Eng.` moves {family, suffix, _ambiguities} at 2.x only, 1.4.0 reading the same family for an unrelated reason (its two-piece rule -- a lone word after the given name is the family -- not S2's gate). `nguyen van lac` also diffs at 1.4.0 on `_initials` (`n.` → `n. v.`) for fix(#385/#402)'s reason — its family `van` is a one-particle part — and joins that rule's literal list; the same case-insensitive regex also reaches `Nguyen Van Lac` at every baseline, without explaining anything there (its own family move, at 1.4.0 too, is explained by the Title-case Lac rule instead). Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; this change raised them by seven at 1.4.0 and by thirteen at each 2.x baseline. diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 4c407471..7a900499 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -707,7 +707,7 @@ def _check_cjk_shape_purity(self) -> None: "the first credential as the given name. Every " "release read suffix 'PhD MEng' -- the pre-existing " "'john smith, phd ma' path, which #540 routes two " - "more words into. A follow-up issue is to be filed", + "more words into; #544 asks whether to keep the comma", shape=3), Case("comma_lower_credential_run_ending_in_meng_re_reads_the_comma", "john smith, phd meng", @@ -756,7 +756,7 @@ def _check_cjk_shape_purity(self) -> None: "given name is the family), not S2's gate -- so this " "row is parity. 'John Smith M.Eng.' keeps the suffix " "by the count and reports; the period-gate question " - "goes to the follow-up issue", + "goes to #544", shape=1), Case("leading_meng_is_a_given_name", "meng li", {"given": "meng", "family": "li"}, diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index b3597cd0..4f6459b4 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -4462,8 +4462,7 @@ issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in # family 'john smith', suffix 'meng' (the count then peels 'meng' # with a word to spare). 1.4.0 read the whole run as a suffix. The # pre-existing 'john smith, phd ma' path, which #540 routes two more -# words into; a follow-up issue is to be filed on the mechanism -# itself. +# words into; #544 asks whether the run should keep its comma. # # Literal, two names, fields the union of what each moves ('middle' is # the first name's alone -- the OVER-DECLARED check accepts a field diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml index b3b709e0..5888e41d 100644 --- a/tools/differential/expected_since_2.0.0.toml +++ b/tools/differential/expected_since_2.0.0.toml @@ -3412,8 +3412,8 @@ issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in # family 'john smith', suffix 'meng' (the count then peels 'meng' # with a word to spare). Every release read the whole run as a suffix. # The pre-existing 'john smith, phd ma' path, which #540 routes two -# more words into; a follow-up issue is to be filed on the mechanism -# itself. +# more words into; #544 asks whether the run should keep its +# comma. # # Literal, two names, fields the union of what each moves ('middle' is # the first name's alone -- the OVER-DECLARED check accepts a field diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml index bb360217..8f7acdaf 100644 --- a/tools/differential/expected_since_2.1.0.toml +++ b/tools/differential/expected_since_2.1.0.toml @@ -3323,8 +3323,8 @@ issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in # family 'john smith', suffix 'meng' (the count then peels 'meng' # with a word to spare). Every release read the whole run as a suffix. # The pre-existing 'john smith, phd ma' path, which #540 routes two -# more words into; a follow-up issue is to be filed on the mechanism -# itself. +# more words into; #544 asks whether the run should keep its +# comma. # # Literal, two names, fields the union of what each moves ('middle' is # the first name's alone -- the OVER-DECLARED check accepts a field diff --git a/tools/differential/expected_since_2.2.0.toml b/tools/differential/expected_since_2.2.0.toml index c4c6295f..54cecc2f 100644 --- a/tools/differential/expected_since_2.2.0.toml +++ b/tools/differential/expected_since_2.2.0.toml @@ -1778,8 +1778,8 @@ issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in # family 'john smith', suffix 'meng' (the count then peels 'meng' # with a word to spare). Every release read the whole run as a suffix. # The pre-existing 'john smith, phd ma' path, which #540 routes two -# more words into; a follow-up issue is to be filed on the mechanism -# itself. +# more words into; #544 asks whether the run should keep its +# comma. # # Literal, two names, fields the union of what each moves ('middle' is # the first name's alone -- the OVER-DECLARED check accepts a field diff --git a/tools/differential/expected_since_2.3.0.toml b/tools/differential/expected_since_2.3.0.toml index 1d1aa7b1..1eb3ec9f 100644 --- a/tools/differential/expected_since_2.3.0.toml +++ b/tools/differential/expected_since_2.3.0.toml @@ -1095,8 +1095,8 @@ issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in # family 'john smith', suffix 'meng' (the count then peels 'meng' # with a word to spare). Every release read the whole run as a suffix. # The pre-existing 'john smith, phd ma' path, which #540 routes two -# more words into; a follow-up issue is to be filed on the mechanism -# itself. +# more words into; #544 asks whether the run should keep its +# comma. # # Literal, two names, fields the union of what each moves ('middle' is # the first name's alone -- the OVER-DECLARED check accepts a field