From 259b8681244a212aa74a8a9b24189ff9b624a09f Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Mon, 17 Aug 2026 21:44:30 -0700 Subject: [PATCH] The leading-particle claim takes its run plus one word (#390, retry) Second attempt. PR #391 put the claim in assign and could not reach three routes; this one puts it in GROUPING, where the pieces are built. de la Vega Juan family='de la Vega Juan' -> family='de la Vega' given='Juan' de la Cruz Maria family='de la Cruz Maria' -> family='de la Cruz' given='Maria' de Mesnil Juan family='de Mesnil Juan' -> family='de Mesnil' given='Juan' Smith, de Mesnil family='Smith de Mesnil' -> family='Smith' given='de Mesnil' What the first attempt could not do from assign: split a chained piece ("de la Vega Juan" groups as [de][la Vega Juan], so taking two pieces takes the whole name), reach the FAMILY_COMMA branch, or survive the particle-vocabulary gaps that made "de los Santos" regress to given='Santos'. #360 fixed the third by landing first, and it holds: de los Santos still reads family='de los Santos'. P4 is AMENDED rather than worked around. A never-given particle leading the name now chains, through its run onto one word; an ambiguous one still chains nothing, so "Van Johnson" survives. A run INSIDE the name still joins greedily -- P2 is untouched -- and the asymmetry is the two positions meaning different things: "pennie von bergen wessels" is a US politician whose surname is all three words, while "de la Vega Juan" is a surname plus a given name. Identical shape, and only position tells them apart. The comma path changed deliberately. "Smith, de Mesnil" was family="Smith de Mesnil" in v1; nobody writes a comma to mean "all of this is the family name", so the comma stays the primary order signal and the post-comma run reads as given text. A v1 parity break, and the three tests that pinned the old reading now pin this one. The guard was wrong twice before it was right, both times by testing a NEGATIVE. "vocab:particle-ambiguous" not in tags passes vacuously for a conjunction-merged piece headed by "and", which swallowed "and van Buren"; and a both-vocabulary word led the chain until `not title` was added -- the trap decisions.md#P1 already records under Declined. The head token must POSITIVELY be a never-given particle. Ledger: the new diff was again absorbed by fix(suffix-routing), the fields-only catch-all whose own comment says its prose fits all four of its names. Its own rule in all three ledgers, suffix-routing back to four, claims recorded, and a _CROSS_RULE_WINNERS row pins that 'de Mesnil Garcia' keeps the #390 rule -- without it the name falls to the catch-all with the gate still at 0 unexplained. Verification: differential exit 0 at 1.4.0, 2.0.0 and 2.1.0; 3470 tests, ruff and mypy clean. Both deviates: #364 markers removed from rules.md#P1, P4's statement and examples amended, P1's implemented: now names group/assign/post_rules. Co-Authored-By: Claude Opus 5 --- docs/design/decisions.md | 2 + docs/design/rules.md | 35 +++++++++----- nameparser/_pipeline/_assign.py | 35 +++++++++++++- nameparser/_pipeline/_group.py | 51 ++++++++++++++++++-- tests/test_particles.py | 9 +++- tests/v2/pipeline/test_post_rules.py | 19 ++++++-- tests/v2/test_ledger_guards.py | 13 +++++ tools/differential/expected_since_1.4.0.toml | 21 ++++++++ tools/differential/expected_since_2.0.0.toml | 21 ++++++++ tools/differential/expected_since_2.1.0.toml | 21 ++++++++ 10 files changed, 201 insertions(+), 26 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 0c3cee30..bbf7c1b0 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -46,6 +46,8 @@ the 2026-08-16 entries below. The survivor is the degenerate bare "van Mesnil Juan" gives given="van", middle="Mesnil", family="Juan". given="de" is the reading the vocabulary exists to forbid, and the fold is what prevents it. The all-orders agreement in P1 is deliberate and is W4's shape: a wholly-hangul name reads family="김" under every declared order because the script carries a signal the order does not override, and a leading never-given particle is the Latin-script analogue. decisions.md#O4 already draws the line — "Words no vocabulary has claimed read by position" — so name_order governs the unclaimed remainder, which is most inputs. +- 2026-08-17 #390 (retry; the first attempt is PR #391, closed unmerged) — the claim moved into GROUPING, and that is what the first attempt could not do from assign. Three routes violated P1 there: a chained piece could not be split ("de la Vega Juan" groups as [de][la Vega Juan], so taking two pieces takes the whole name), the FAMILY_COMMA branch was never reached, and "de los Santos" regressed to given="Santos" because the particle vocabulary had gaps. #360 fixed the third by landing first. P4 is AMENDED rather than worked around: a never-given particle leading the name now chains, through its run onto one word, while an ambiguous one still chains nothing so "Van Johnson" survives. A run INSIDE the name still joins greedily (P2 unchanged), and the asymmetry is the two positions meaning different things -- "pennie von bergen wessels" is a US politician whose surname is all three words, while "de la Vega Juan" is a surname plus a given name. Identical shape; only position tells them apart. The comma path changed too, deliberately: "Smith, de Mesnil" was family="Smith de Mesnil" in v1 and is now family="Smith", given="de Mesnil". Nobody writes a comma to mean "all of this is the family name", so the comma stays the primary order signal and the post-comma run reads as given text. A v1 parity break, recorded. Guard shape, twice wrong before it was right: the head-token test must assert the token IS a never-given particle, not merely that it lacks the ambiguous tag. A conjunction merge makes "and van" one prefix-tagged piece headed by "and", which carries no particle tag at all, so the negative test passed vacuously and swallowed "and van Buren"; and a both-vocabulary word (st, do, freiherr) led the chain until `not title` was added, which is the same trap this section already records under Declined. + Declined: - A strict xfail asserting "de Mesnil" → family under FAMILY_FIRST (#359 review) — #359 deliberately left those semantics open, and a strict xfail decides the question by the back door. diff --git a/docs/design/rules.md b/docs/design/rules.md index fdd8e31d..e31090a6 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -108,8 +108,8 @@ P1. Rationale: a never-given particle standing alone cannot be "de la Vega" → family="de la Vega" "Sir de Mesnil" → family="de Mesnil" "Mesnil de" family-first → family="Mesnil de" - "de Mesnil Juan" → family="de Mesnil" deviates: #364 (today: family="de Mesnil Juan") - "de Mesnil Juan" → given="Juan" deviates: #364 (today: given="") + "de Mesnil Juan" → family="de Mesnil" + "de Mesnil Juan" → given="Juan" "Mc Donald" → family="Mc Donald" "de los Santos" → family="de los Santos" "van Gogh" → given="van" · boundary @@ -123,7 +123,7 @@ P1. Rationale: a never-given particle standing alone cannot be the bare particle reading as a given name, not any name part that begins with one. "Juan de la Vega" family-first → family="Juan" - history: decisions.md#P1 · interacts: P2, P4, P6 · implemented: nameparser/_pipeline/_post_rules.py + history: decisions.md#P1 · interacts: P2, P4, P6 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py P2. Rationale: a particle is written as part of the surname it precedes, and a title stands outside the name entirely. @@ -161,18 +161,27 @@ P3. Rationale: connective words ("y", "of the") bind name words into "Хосе И Мария Сантос" → given="Хосе И Мария" history: decisions.md#P3 · implemented: nameparser/_pipeline/_group.py -P4. Rationale: a particle links forward from inside a name; at the - very front there is no name yet to be inside. - A particle in the name's leading position chains nothing: the - words stay separate, and any surname reading the name gets - comes from the fold (P1) or from position (O4), never from a - join. This is why a title before a leading particle changes - nothing (the title is not a name word), and why "Van Johnson" - is a given-name reading at all. +P4. Rationale: an ambiguous particle at the very front may be the + given name itself, so it must not swallow what follows; a + never-given particle cannot be, so it may. + An AMBIGUOUS particle in the name's leading position chains + nothing: the words stay separate, which is why "Van Johnson" is + a given-name reading at all. A NEVER-GIVEN particle there does + chain, through its run onto one name word (P1) — and a run + inside the name still joins everything after it (P2), because + what follows a particle mid-name is more surname while what + follows one at the front is a given name. A title before either + changes nothing: the title is not a name word. "Van Johnson" → given="Van" - "Sir de Mesnil" → pieces=[["Sir"], ["de"], ["Mesnil"]] + "Sir de Mesnil" → pieces=[["Sir"], ["de", "Mesnil"]] + "de la Vega Juan" → pieces=[["de", "la", "Vega"], ["Juan"]] "John van der Berg" → pieces=[["John"], ["van", "der", "Berg"]] · boundary - history: decisions.md#P2 · interacts: P1 · implemented: nameparser/_pipeline/_group.py + Accepted: the two positions read the same shape differently, and + only position tells them apart — "pennie von bergen wessels" is + one compound surname while "de la Vega Juan" is a surname plus a + given name. + "pennie von bergen wessels" → family="von bergen wessels" + history: decisions.md#P2 · interacts: P1, P2 · implemented: nameparser/_pipeline/_group.py P5. Rationale: some given-name words are incomplete alone — "abdul" is a bound form that the next word completes. diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index 15b5ddab..4809e942 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -268,7 +268,40 @@ def _assign_main(seg_idx: int, state: ParseState, order = _effective_order(state.policy, [pieces[i] for i in name_pieces], tokens, dot_divided=bool(state.interpunct_offsets)) - roles = _name_positions(order, len(name_pieces)) + # rules.md#P1: "a never-given particle standing alone where the + # given name would go — or opening the name — marks the name as + # surname-only: the particle run and the one name word it attaches + # to are the family, and any name words beyond that read by + # position." (history: decisions.md#P1) + # group() has already bounded that run to one word (#390), so the + # claim is simply: the leading piece IS the family. It happens here, + # before positions are handed out, because the particle is evidence + # about the name and name_order governs only what no vocabulary has + # claimed (decisions.md#O4). + # A piece of nothing but particles is NOT claimed -- bare "de" has + # no name word to attach to and stays a given name, which is P1's + # own Accepted consequence. + claimed: list[int] = [] + if name_pieces: + head = pieces[name_pieces[0]] + if ("particle" in tokens[head[0]].tags + and "vocab:particle-ambiguous" not in tokens[head[0]].tags + and any("particle" not in tokens[i].tags for i in head)): + claimed, name_pieces = name_pieces[:1], name_pieces[1:] + if claimed: + # The family slot is taken, so the remainder positions itself as + # if it did not exist: ask for one more slot than there are + # pieces and drop FAMILY. Reusing _name_positions rather than + # re-deriving the order keeps one definition of "by position" -- + # under FAMILY_FIRST a plain count would hand the leftover the + # family a second time. + roles: list[Role] = [ + r for r in _name_positions(order, len(name_pieces) + 1) + if r is not Role.FAMILY] + for piece_idx in claimed: + _set_roles(tokens, pieces[piece_idx], Role.FAMILY) + else: + roles = list(_name_positions(order, len(name_pieces))) for pos, piece_idx in enumerate(name_pieces): _set_roles(tokens, pieces[piece_idx], roles[pos]) for piece_idx in suffix_pieces: diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py index eda3b39d..ba55a6fc 100644 --- a/nameparser/_pipeline/_group.py +++ b/nameparser/_pipeline/_group.py @@ -68,8 +68,8 @@ def _is_title_piece(piece: Sequence[int], ptags: Set[str], # part, the join running until the next particle starts a group of # its own or the name ends. The final group reads as the family # name; earlier groups read by position." (history: decisions.md#P2) -# rules.md#P4: "a particle in the name's leading position chains -# nothing: the words stay separate" (history: decisions.md#P2) +# rules.md#P4: "An AMBIGUOUS particle in the name's leading position +# chains nothing: the words stay separate" (history: decisions.md#P2) def _is_prefix_piece(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: if "prefix" in ptags: @@ -257,16 +257,57 @@ def merge(lo: int, hi: int, add: Set[str] = frozenset(), # nothing unless some piece is a prefix. leading = next((k for k in range(len(pieces)) if not title(k) or prefix(k)), 0) + # rules.md#P1: "the particle run and the one name word it + # attaches to are the family, and any name words beyond that + # read by position" (history: decisions.md#P1) + # (#390): a NEVER-GIVEN particle leading the name + # chains too, unlike the ambiguous one P4 leaves alone -- but + # only through its run onto ONE word, where a mid-name run + # absorbs everything to the next particle or suffix. + # + # The asymmetry is the two positions meaning different things, + # not an inconsistency to tidy away. Mid-name, what follows a + # particle run is MORE SURNAME: "pennie von bergen wessels" is a + # US politician and "von bergen wessels" is one compound name + # (tests/test_particles.py pins it). Leading, the name opens + # with a surname, so what follows the group is a GIVEN name -- + # "de la Vega Juan" is family "de la Vega" plus given "Juan". + # Identical shape, and only the position tells them apart. + # + # Ambiguous particles keep P4 whole: "Van Johnson" must stay a + # given-name reading, which is the fork _assign reports. + # Three conditions, and each was earned by a failure: + # - the head token must POSITIVELY be a particle. A conjunction + # merge makes 'and van' one prefix-tagged piece whose head is + # 'and', which carries no particle tag at all -- testing only + # for the absence of the ambiguous tag passes vacuously and + # swallowed "and van Buren". + # - not a title. A word in BOTH vocabularies (st, do, freiherr, + # or any overlap a caller configures) must stay the title it + # is; decisions.md#P1 records this as Declined for the same + # reason "St John Smith" broke there. + # - not ambiguous, so P4 keeps "Van Johnson" whole. + head_tok = tokens[pieces[leading][0]] + lead_bounded = (prefix(leading) + and not title(leading) + and "particle" in head_tok.tags + and "vocab:particle-ambiguous" not in head_tok.tags) k = 0 while k < len(pieces): - if k == leading or not prefix(k): + if (k == leading and not lead_bounded) or not prefix(k): k += 1 continue j = k + 1 while j < len(pieces) and prefix(j): j += 1 - while j < len(pieces) and not prefix(j) and not suffix(j): - j += 1 + if k == leading: + # the run plus exactly one word; a suffix ends it, so + # "de Mesnil Jr." keeps Jr. a suffix + if j < len(pieces) and not suffix(j): + j += 1 + else: + while j < len(pieces) and not prefix(j) and not suffix(j): + j += 1 # The other half of PARTICLE_OR_GIVEN. _assign reports the # fork when an ambiguous particle stays a lone leading piece # ("Van Johnson" -> given under the default order, family diff --git a/tests/test_particles.py b/tests/test_particles.py index 3edeb863..8108d6cc 100644 --- a/tests/test_particles.py +++ b/tests/test_particles.py @@ -394,5 +394,10 @@ def test_leading_non_first_name_prefix_with_middle_name_as_last(self) -> None: # the later opt-in handle_middle_name_as_last has nothing left to do. constants = Constants(middle_name_as_last=True) hn = HumanName("de Mesnil Garcia", constants=constants) - self.m(hn.first, "", hn) - self.m(hn.last, "de Mesnil Garcia", hn) + # #390: the claim takes the particle run plus ONE word, so + # 'Garcia' survives as the given name where it used to be swept + # into the family. middle is empty either way, which is the + # interaction this test actually guards. + self.m(hn.first, "Garcia", hn) + self.m(hn.middle, "", hn) + self.m(hn.last, "de Mesnil", hn) diff --git a/tests/v2/pipeline/test_post_rules.py b/tests/v2/pipeline/test_post_rules.py index b4e1d50a..de2e6581 100644 --- a/tests/v2/pipeline/test_post_rules.py +++ b/tests/v2/pipeline/test_post_rules.py @@ -136,7 +136,9 @@ def test_degenerate_bare_particle_stays_given() -> None: # three pieces, so the fold has a MIDDLE to move as well as the # given -- the `givens + middles` half of the repair, and the only # no-comma corpus name that reaches it - ("de Mesnil Garcia", "de Mesnil Garcia", "", ""), + # #390: the claim takes the particle run plus ONE word, so Garcia + # survives by position -- the only no-comma corpus name it moves + ("de Mesnil Garcia", "de Mesnil", "Garcia", ""), # ... and the trailing suffix run is peeled before the rule looks, # comma or no comma (NO_COMMA and SUFFIX_COMMA both fold) ("de Mesnil MD", "de Mesnil", "", "MD"), @@ -198,7 +200,7 @@ def test_family_first_leading_particle_cases_that_do_not_fold( @pytest.mark.parametrize("text,title,given,middle,family,suffix", [ ("de Mesnil", "", "", "", "de Mesnil", ""), ("de la Vega", "", "", "", "de la Vega", ""), - ("de Mesnil Garcia", "", "", "", "de Mesnil Garcia", ""), + ("de Mesnil Garcia", "", "Garcia", "", "de Mesnil", ""), ("Dr. de MD Mesnil", "Dr.", "", "", "de MD Mesnil", ""), ("de Mesnil MD", "", "", "", "de Mesnil", "MD"), ("De Mesnil, MD", "", "", "", "De Mesnil", "MD"), @@ -213,7 +215,11 @@ def test_family_first_leading_particle_cases_that_do_not_fold( # comma already fixed the family), and both of 1b's sites then # agree, the opening piece of segment 1 and the lone given being # the same token. Pinned here so the re-key cannot quietly drop it - ("Smith, de Mesnil", "", "", "", "Smith de Mesnil", ""), + # #390: the comma is the primary order signal -- nobody writes a + # comma to mean "all of this is the family name", so the post-comma + # run reads as given text rather than folding into the family. v1 + # gave family="Smith de Mesnil"; this is a deliberate parity break + ("Smith, de Mesnil", "", "de Mesnil", "", "Smith", ""), ("Smith, van Gogh", "", "van", "Gogh", "Smith", ""), ]) def test_default_order_is_unchanged_by_the_family_first_fold( @@ -229,9 +235,12 @@ def test_default_order_is_unchanged_by_the_family_first_fold( @pytest.mark.parametrize("policy", _FAMILY_FIRST) def test_family_comma_fold_is_order_independent(policy: Policy) -> None: + # The comma fixes the family in every order; what changed in #390 is + # that the post-comma particle group stays GIVEN text instead of + # folding into it. Still order-independent, which is what this pins. out = _parsed("Smith, de Mesnil", policy) - assert _by_role(out, Role.FAMILY) == "Smith de Mesnil" - assert not _by_role(out, Role.GIVEN) + assert _by_role(out, Role.FAMILY) == "Smith" + assert _by_role(out, Role.GIVEN) == "de Mesnil" @pytest.mark.parametrize("policy", _FAMILY_FIRST) diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index dc8d7d2e..68fd5687 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -1048,6 +1048,9 @@ def _claim(rule: dict) -> _Claim: _Claim(1, ('given', 'middle', 'suffix', 'title'), "390e7f814d13"), "fix(#367) a title no longer displaces a leading particle out of the leading position": _Claim(1, ('family', 'given', 'middle'), "dce0ae6df4be"), + "fix(#390) the leading-particle claim takes the particle run " + "plus one word, not the rest of the name": + _Claim(1, ('family', 'given'), "caf836e3556c"), }, "expected_since_2.0.0.toml": { "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots": @@ -1064,10 +1067,16 @@ def _claim(rule: dict) -> _Claim: _Claim(1, ('family', 'given', 'suffix', 'title'), "1d45596e6fdb"), "fix(#367) a title no longer displaces a leading particle out of the leading position": _Claim(1, ('family', 'given', 'middle'), "dce0ae6df4be"), + "fix(#390) the leading-particle claim takes the particle run " + "plus one word, not the rest of the name": + _Claim(1, ('family', 'given'), "caf836e3556c"), }, "expected_since_2.1.0.toml": { "fix(#367) a title no longer displaces a leading particle out of the leading position": _Claim(1, ('family', 'given', 'middle'), "dce0ae6df4be"), + "fix(#390) the leading-particle claim takes the particle run " + "plus one word, not the rest of the name": + _Claim(1, ('family', 'given'), "caf836e3556c"), }, } @@ -1156,6 +1165,10 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: #: a diff shape that shifted is a finding, not a number to update. _CROSS_RULE_WINNERS: dict[str, dict[tuple[str, tuple[str, ...]], str]] = { "expected_since_1.4.0.toml": { + # Without fix(#390) this falls to the fields-only + # fix(suffix-routing) catch-all with the gate still at 0 + # unexplained -- a rule claiming a diff it does not describe. + ("de Mesnil Garcia", ("family", "given")): "fix(#390)", ("Andrews, M.D.", ("given", "suffix")): "fix(comma-family)", ("田中, 太郎さん", ("given", "suffix")): "fix(cjk-comma-honorific-peel)", ("김, 민준씨", ("given", "suffix")): "fix(cjk-comma-honorific-peel)", diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index dde79d00..d1ff831f 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -179,6 +179,27 @@ issue = "fix(comma-precomma-family) pre-comma run reads as family, not given" name_regex = "^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*$" fields = ["given", "family"] +[[change]] +issue = "fix(#390) the leading-particle claim takes the particle run plus one word, not the rest of the name" +# 'de Mesnil Garcia': the baseline swept every remaining name word into +# the family, giving last='de Mesnil Garcia' with no first. rules.md#P1 +# takes the particle run and the ONE word it attaches to, so 'Garcia' +# survives by position. Diff is exactly {given, family} -- no suffix +# moves and nothing is consumed. +# +# Its own rule for the reason #372 gave the three names above one: it +# falls to the fields-only fix(suffix-routing) below otherwise, whose +# prose is "two-token name with unambiguous trailing suffix stays +# suffix" and whose own comment says that prose fits all four of its +# names. 'Garcia' is not a suffix and nothing here routes one. +# +# The lookalikes that do NOT diff bound this rule's reach: 'de la Vega' +# is the run plus one word already, and 'de Mesnil Jr.' has two name +# words because Jr. is a suffix. Only three-or-more-name-word shapes +# reach here, and the corpus holds one. +name_regex = "^de Mesnil Garcia$" +fields = ["given", "family"] + [[change]] issue = "fix(suffix-routing) two-token name with unambiguous trailing suffix stays suffix" # 'Johnson PhD' / 'Smith Jr.' / 'John V' / 'QC MP': v1 routed a lone diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml index d8cb0fe7..99e83087 100644 --- a/tools/differential/expected_since_2.0.0.toml +++ b/tools/differential/expected_since_2.0.0.toml @@ -283,3 +283,24 @@ issue = "fix(#367) a title no longer displaces a leading particle out of the lea # classification. name_regex = "(?i)^mr\\.\\s+van\\b" fields = ["given", "middle", "family"] + +[[change]] +issue = "fix(#390) the leading-particle claim takes the particle run plus one word, not the rest of the name" +# 'de Mesnil Garcia': the baseline swept every remaining name word into +# the family, giving last='de Mesnil Garcia' with no first. rules.md#P1 +# takes the particle run and the ONE word it attaches to, so 'Garcia' +# survives by position. Diff is exactly {given, family} -- no suffix +# moves and nothing is consumed. +# +# Its own rule for the reason #372 gave the three names above one: it +# falls to the fields-only fix(suffix-routing) below otherwise, whose +# prose is "two-token name with unambiguous trailing suffix stays +# suffix" and whose own comment says that prose fits all four of its +# names. 'Garcia' is not a suffix and nothing here routes one. +# +# The lookalikes that do NOT diff bound this rule's reach: 'de la Vega' +# is the run plus one word already, and 'de Mesnil Jr.' has two name +# words because Jr. is a suffix. Only three-or-more-name-word shapes +# reach here, and the corpus holds one. +name_regex = "^de Mesnil Garcia$" +fields = ["given", "family"] diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml index 796b1062..d942e90a 100644 --- a/tools/differential/expected_since_2.1.0.toml +++ b/tools/differential/expected_since_2.1.0.toml @@ -94,3 +94,24 @@ issue = "fix(#367) a title no longer displaces a leading particle out of the lea # classification. name_regex = "(?i)^mr\\.\\s+van\\b" fields = ["given", "middle", "family"] + +[[change]] +issue = "fix(#390) the leading-particle claim takes the particle run plus one word, not the rest of the name" +# 'de Mesnil Garcia': the baseline swept every remaining name word into +# the family, giving last='de Mesnil Garcia' with no first. rules.md#P1 +# takes the particle run and the ONE word it attaches to, so 'Garcia' +# survives by position. Diff is exactly {given, family} -- no suffix +# moves and nothing is consumed. +# +# Its own rule for the reason #372 gave the three names above one: it +# falls to the fields-only fix(suffix-routing) below otherwise, whose +# prose is "two-token name with unambiguous trailing suffix stays +# suffix" and whose own comment says that prose fits all four of its +# names. 'Garcia' is not a suffix and nothing here routes one. +# +# The lookalikes that do NOT diff bound this rule's reach: 'de la Vega' +# is the run plus one word already, and 'de Mesnil Jr.' has two name +# words because Jr. is a suffix. Only three-or-more-name-word shapes +# reach here, and the corpus holds one. +name_regex = "^de Mesnil Garcia$" +fields = ["given", "family"]