Skip to content

Commit ea088c1

Browse files
derek73claude
andcommitted
feat(particles): a family-first order stops the leading run
A never-given particle opening the name took every remaining word into the family in all three orders. Under a declared family-first order it now takes one name word and leaves the rest to the order: FAMILY_FIRST "de Mesnil Juan" -> family 'de Mesnil', given 'Juan' FAMILY_FIRST_GIVEN_LAST "de la Cruz Juan Carlos" -> middle 'Juan', given 'Carlos' Declaring a family-first order asserts that what follows the family is not more surname, which is the question the stopping point asks. The DEFAULT order is unchanged and that is the accepted cost: with nothing declared, "de Mesnil Juan" has the shape of "pennie von bergen wessels", whose whole text is the surname. Callers who mean otherwise write the comma, which already parses that way. Where it lives, and why not the two sites that failed before: the fold in post_rules. Grouping was PR #394 and assignment PR #391 -- and the piece is the obstacle, since "de la Cruz Juan Carlos" groups as [de] [la Cruz Juan Carlos] once the ambiguous particle chains, so the stop must cut INSIDE a piece. post_rules can: roles are per token, and nothing downstream reads pieces (measured -- only _assign, which runs before it). Grouping stays order-independent. The order is read, not re-derived. assign records the order it used on ParseState.order and the fold keys on that; policy.name_order would disagree with the roles assign already wrote whenever a script_orders entry overrides it. The run counts UNITS: a conjunction join (P3) and a bound given-name pair (P5) each count once, so "de la Vega y Santos Juan" cannot stop between Vega and Santos, and "abdul Rahman" cannot be halved. Both are read off the tags -- the prefix chain has already merged the joined piece away by then. Measured: one differential corpus name moves, "de Mesnil Garcia", under each family-first order; all 751 are byte-identical in the default order. The corpus cannot see more than that -- it runs under the default policy against 1.4.0, which has no name_order -- so the verification that counts is the two-leftover case rows, which mutation-checking confirms are the only thing in the suite that fails when name_order is discarded from the leftover placement. Closes #395 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f3664ff commit ea088c1

9 files changed

Lines changed: 255 additions & 58 deletions

File tree

docs/design/decisions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ the 2026-08-16 entries below. The survivor is the degenerate bare
5252
Why the question kept thrashing, worth recording so a fourth attempt does not start from scratch: "how much does a leading particle run take" is a LANGUAGE judgement being forced through a POSITION heuristic, in a parser that has correctly refused to detect language (decisions.md#O4, rules.md's Not-in-scope). Every argument in the thread — `de`/`do` as Vietnamese surnames, `von` as German, `dos` as Portuguese — is really about which tradition the name belongs to. The order declaration is the one place a CALLER supplies that information instead of the parser guessing, which is why keying on it is better than any position rule we tried.
5353
Implementation consequence: the stopping point may now read name_order, which the superseded sentence forbade. Whether that lives in grouping (order-aware) or as a split in assignment is open — PR #391 showed assignment cannot split an existing piece today, so choosing assignment means giving it token-level slicing.
5454
Supersedes nothing else: #368's reversal, the trailing-orphan rule P6, and the claim that a leading never-given particle takes the FAMILY rather than a given name all stand.
55+
- 2026-08-18 #395 — WHERE THE STOP LIVES, settling what the entry above left open. Not grouping and not assignment: the fold in post_rules, which already retags tokens and is the only site that can express the stop without moving a piece boundary. Grouping was PR #394 (four regressions) and assignment was PR #391, where the finding was that assign cannot split an existing piece — and the piece is the problem: "de la Cruz Juan Carlos" groups as [de][la Cruz Juan Carlos], the ambiguous particle having chained forward, so any rule that stops "after the first name word" must cut INSIDE a piece. post_rules can, because roles are per token and nothing downstream reads pieces (measured: only _assign, which runs before it, and post_rules itself). Grouping therefore stays order-independent, which keeps the 2026-08-16 keystone intact everywhere except the reach of this one fold.
56+
The order is READ, not re-derived: assign now records the order it actually used on ParseState.order, and the fold keys on that. policy.name_order would have been wrong — a script_orders entry can put the family first under a given-first policy, and the roles assign already wrote would then disagree with the roles the fold computes. The same reasoning already appears one function away, where the PARTICLE_OR_GIVEN emitter reads the role off the token rather than assuming given.
57+
A unit, not a token: the fold counts what other rules built, so a conjunction join (P3) and a bound given-name pair (P5) each count once. Both are read back off the TAGS rather than the pieces, because the prefix chain has already merged the joined piece into a longer one and the boundary is gone by then. This is what keeps "de la Vega y Santos Juan" from stopping between Vega and Santos — the case rules.md#P3 was amended for on 2026-08-17, now executable.
58+
Measured: ONE corpus name moves (de Mesnil Garcia), under each family-first order, none under the default. The differential harness cannot see any of it — it runs the corpus under the default policy against 1.4.0, which has no name_order — so its exit 0 is evidence for the accepted cost and for nothing else. The verification that counts is tests/v2/cases.py's two-leftover rows: mutation-checked, discarding name_order from the leftover placement fails exactly one of them and no other test in the suite.
5559

5660
Declined:
5761

docs/design/rules.md

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,32 @@ P1. Rationale: a never-given particle standing alone cannot be
9595
one as the given name, is a surname written out in full.
9696
A never-given particle standing alone where the given name would
9797
go — or opening the name — marks the name as surname-only: the
98-
particle run and the one name word it attaches to are the
99-
family, and any name words beyond that read by position. It
98+
particle run and the name words after it are the family. It
10099
needs another name word to attach to. The run is every particle
101100
in sequence, never-given and ambiguous alike ("de la Vega" is
102101
one group, not "de" plus a separate "la Vega"). An ambiguous particle keeps
103-
whatever reading its position gives it. The reading holds under
104-
every declared name order: a never-given particle is evidence
102+
whatever reading its position gives it. How far the family
103+
reaches is the declared order's to settle: under a family-first
104+
order the run stops after ONE name word, because declaring that
105+
order asserts that what follows the family is not more surname,
106+
and the words beyond it read by position; under the default order
107+
nothing marks where the surname ends, so it runs to the end of
108+
the name. One name word means one UNIT — a conjunction join (P3)
109+
or a bound given-name pair (P5) is taken whole or not at all.
110+
That the particle claims the FAMILY rather than a given name
111+
holds under every order: a never-given particle is evidence
105112
about how the name is written, and a declared order governs only
106113
what no vocabulary has claimed (O4) — the same precedence the
107114
script license takes in W4.
108115
"de la Vega" → family="de la Vega"
109116
"Sir de Mesnil" → family="de Mesnil"
110117
"Mesnil de" family-first → family="Mesnil de"
111-
"de Mesnil Juan" → family="de Mesnil" deviates: #364 (today: family="de Mesnil Juan")
112-
"de Mesnil Juan" → given="Juan" deviates: #364 (today: given="")
118+
"de Mesnil Juan" → family="de Mesnil Juan"
119+
"de Mesnil Juan" family-first → family="de Mesnil"
120+
"de Mesnil Juan" family-first → given="Juan"
121+
"de la Vega y Santos Juan" family-first → family="de la Vega y Santos"
122+
"ibn Awf abdul Rahman" family-first → given="abdul Rahman"
123+
"de la Cruz Juan Carlos" family-first-given-last → given="Carlos"
113124
"Mc Donald" → family="Mc Donald"
114125
"de los Santos" → family="de los Santos"
115126
"van Gogh" → given="van" · boundary
@@ -132,16 +143,20 @@ P2. Rationale: a particle is written as part of the surname it
132143
or the name ends. The final group reads as the family name;
133144
earlier groups read by position. The chain begins wherever the
134145
name begins, and a preceding title does not move that point.
146+
Where P1's fold has claimed the opening, the fold decides the
147+
family instead — and may take only PART of the final group,
148+
since it counts name words and the group is one part.
135149
"John van der Berg" → family="van der Berg"
136150
"John van der Berg Smith" → family="van der Berg Smith"
137151
"Vincent van Gogh van Beethoven" → middle="van Gogh"
138152
"Dr. John van der Berg" → family="van der Berg"
139153
"Juan de" → family="de" · boundary
154+
"de la Cruz Juan Carlos" family-first → family="de la Cruz"
140155
Accepted: a caller wanting the combined double-surname reading
141156
(#132's ask) has it as the surnames view rather than the
142157
family field.
143158
"Vincent van Gogh van Beethoven" → surnames="van Gogh van Beethoven"
144-
history: decisions.md#P2 · implemented: nameparser/_pipeline/_group.py
159+
history: decisions.md#P2 · interacts: P1, P4 · implemented: nameparser/_pipeline/_group.py
145160

146161
P3. Rationale: connective words ("y", "of the") bind name words into
147162
one name part; but a single letter in a short name is more
@@ -176,7 +191,7 @@ P3. Rationale: connective words ("y", "of the") bind name words into
176191
same two words unjoined are two name words and H1 does not fire.
177192
P1's leading run becomes the second once #395 lands — its run
178193
must take the "Vega y Santos" join whole or stop before it.
179-
history: decisions.md#P3 · interacts: H1, P1 · implemented: nameparser/_pipeline/_group.py
194+
history: decisions.md#P3 · interacts: H1, P1 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py
180195

181196
P4. Rationale: a particle links forward from inside a name; at the
182197
very front there is no name yet to be inside.
@@ -197,7 +212,7 @@ P5. Rationale: some given-name words are incomplete alone — "abdul"
197212
one given name.
198213
"abdul salam ahmed salem" → given="abdul salam"
199214
"mohamad ali smith" → given="mohamad" · boundary
200-
history: decisions.md#P5 · implemented: nameparser/_pipeline/_group.py
215+
history: decisions.md#P5 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py
201216

202217
P6. Rationale: a particle ending the name has nothing to link
203218
forward to, so it is not doing a particle's work there. A

docs/release_log.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Release Log
3232

3333
- Fix a name opening with a particle that is *never* a given name being split at the particle under a family-first name order -- ``Policy(name_order=FAMILY_FIRST)`` and ``Policy(name_order=FAMILY_FIRST_GIVEN_LAST)`` alike, and identically: ``"de Mesnil"`` read as family ``de``, given ``Mesnil``, and ``"de la Vega"`` as family ``de``, given ``la Vega``. Each is now the whole surname, as it has always been in the default order. The rule enforcing it asked for the particle by the ``GIVEN`` role, which under a family-first order belongs to the token *after* the particle, so the test read the wrong word and declined. It now also asks by position -- the piece that opens the name -- so both shapes of the same rule are caught: where such a particle stands alone as a piece, either opening the name or in the given position, the name is left with no given name at all, the given and the middles folding into the family. Standing *alone* is the whole of it, and the rule claims nothing wider: ``"Juan de la Vega"`` under ``FAMILY_FIRST`` still reports given ``de la Vega``, because there the particle chained onto the words after it rather than standing alone, and a bare ``"de"`` with nothing to fold into is still reported as the given name. The decision behind the fix: a word that can never be a given name leaves ``name_order`` nothing to decide, so declaring family-first is not a reason to make ``de`` a surname on its own. A leading particle that *may* be a given name is genuinely order-dependent and is untouched -- ``"van Gogh"`` still reads as family ``van``, given ``Gogh`` under both family-first orders. This is also what gives ``Lexicon.particles_ambiguous`` an effect outside the default order: taking a word out of it now changes the parsed fields under a family-first order, where before it moved only the ambiguity report. Seven of the 751 differential corpus names move, the same seven under each family-first order; default-order output is byte-identical over all 751, at the 1.4.0, 2.0.0 and 2.1.0 differential baselines alike (closes #359)
3434

35+
- Change how far a leading never-given particle takes the surname when a family-first ``name_order`` is declared. ``Policy(name_order=FAMILY_FIRST)`` read ``"de Mesnil Juan"`` as family ``de Mesnil Juan`` -- the whole name -- and now reads family ``de Mesnil``, given ``Juan``. Declaring a family-first order asserts that what follows the family is not more surname, and where the surname run ends is exactly that question, so the declaration settles it. The default order is unchanged, deliberately: with no order declared nothing marks where the surname ends, and ``"de Mesnil Juan"`` has the same shape as ``"pennie von bergen wessels"``, a real name whose whole text is the surname. A caller who means family ``de la Vega`` plus given ``Juan`` in the default order writes the comma, which already parses that way. The run takes one name WORD rather than one token: a conjunction-joined run and a bound given-name pair each count once, so the stop cannot land inside one -- ``"de la Vega y Santos Juan"`` reads family ``de la Vega y Santos``, and ``"ibn Awf abdul Rahman"`` reads given ``abdul Rahman``. Where two or more words are left over the two family-first orders differ from each other for the first time: ``"de la Cruz Juan Carlos"`` reads given ``Juan``, middle ``Carlos`` under ``FAMILY_FIRST`` and middle ``Juan``, given ``Carlos`` under ``FAMILY_FIRST_GIVEN_LAST``. An ambiguous leading particle is untouched in every order -- ``"van Gogh Jan Pieter"`` still reads family ``van`` under both family-first orders -- and so is a family comma, where the comma has already fixed the surname (``"Smith, de Mesnil"`` keeps family ``Smith de Mesnil``). One of the 751 differential corpus names moves, ``"de Mesnil Garcia"`` to family ``de Mesnil``, given ``Garcia``, under each family-first order; default-order output is byte-identical over all 751. This reverses the answer #364 was closed on, and the reasoning is recorded at ``docs/design/decisions.md#P1`` (closes #395)
36+
3537
- Change the ``detail`` text of a ``PARTICLE_OR_GIVEN`` ambiguity to name the role the leading particle was actually given. It said "read as a given name" under every ``name_order``, which is false under ``Policy(name_order=FAMILY_FIRST)`` -- there ``"Van Johnson"`` reads as family ``Van``, given ``Johnson``, and the report described the reading not taken. It now ends "read as a family name" in that case, reading the role off the assigned token the way ``SUFFIX_OR_NAME`` already did -- that kind names both parts (``read as a family name rather than a post-nominal``), while this one names only the part it took. The ``kind`` is unchanged and stays ``PARTICLE_OR_GIVEN``: the fork really is particle-or-given, and only the human-readable text moved. Default-order output is identical (#355)
3638

3739
**Deprecations**

nameparser/_pipeline/_assign.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,17 @@ def _name_positions(order: tuple[Role, Role, Role],
174174

175175
def _assign_main(seg_idx: int, state: ParseState,
176176
tokens: list[WorkToken],
177-
ambiguities: list[PendingAmbiguity]) -> None:
177+
ambiguities: list[PendingAmbiguity],
178+
) -> tuple[Role, Role, Role] | None:
179+
"""Returns the order the positional read used, for ParseState.order
180+
-- None on every path that returns before resolving one."""
178181
pieces = state.pieces[seg_idx]
179182
ptags = state.piece_tags[seg_idx]
180183
has_nickname = any(t.role is Role.NICKNAME for t in tokens)
181184
n = _peel_leading_titles(pieces, ptags, tokens)
182185
rest = list(range(n, len(pieces)))
183186
if not rest:
184-
return
187+
return None
185188
# group-flagged suffix pieces (the ph-d merge) are suffixes at ANY
186189
# position -- v1's fix_phd extracted the credential from the string
187190
# before parsing, so position never mattered (PR review I3)
@@ -190,7 +193,7 @@ def _assign_main(seg_idx: int, state: ParseState,
190193
_set_roles(tokens, pieces[k], Role.SUFFIX)
191194
rest = [k for k in rest if "suffix" not in ptags[k]]
192195
if not rest:
193-
return
196+
return None
194197
# rules.md#N3: "a name that is only a nickname and one name word
195198
# reads that word as the family name" (history: decisions.md#N3)
196199
# -- v1's p_len == 1 counted
@@ -199,7 +202,7 @@ def _assign_main(seg_idx: int, state: ParseState,
199202
# name (pinned live 2026-07-17)
200203
if len(pieces) == 1 and len(rest) == 1 and has_nickname:
201204
_set_roles(tokens, pieces[rest[0]], Role.FAMILY)
202-
return
205+
return None
203206
# peel the trailing suffix run: k = first index in rest from which
204207
# every piece is a strict suffix (v1's are_suffixes tail rule, with
205208
# the roman-numeral special: a final roman numeral after a
@@ -304,18 +307,20 @@ def _assign_main(seg_idx: int, state: ParseState,
304307
f"leading {token.text!r} may be a family-name "
305308
f"particle; read as a {token.role.value} name",
306309
tuple(head)))
310+
return order
307311

308312

309313
def assign(state: ParseState) -> ParseState:
310314
tokens = list(state.tokens)
311315
ambiguities = list(state.ambiguities)
312316
if not state.segments:
313317
return state
318+
order: tuple[Role, Role, Role] | None = None
314319
if state.structure is Structure.NO_COMMA:
315-
_assign_main(0, state, tokens, ambiguities)
320+
order = _assign_main(0, state, tokens, ambiguities)
316321
tail = len(state.segments)
317322
elif state.structure is Structure.SUFFIX_COMMA:
318-
_assign_main(0, state, tokens, ambiguities)
323+
order = _assign_main(0, state, tokens, ambiguities)
319324
tail = 1
320325
else: # FAMILY_COMMA
321326
# PARTICLE_OR_GIVEN is deliberately not emitted here: after a
@@ -376,4 +381,5 @@ def assign(state: ParseState) -> ParseState:
376381
for piece in state.pieces[seg_idx]:
377382
_set_roles(tokens, piece, Role.SUFFIX)
378383
return dataclasses.replace(state, tokens=tuple(tokens),
384+
order=order,
379385
ambiguities=tuple(ambiguities))

0 commit comments

Comments
 (0)