Skip to content

Commit 8ea126e

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 8ea126e

10 files changed

Lines changed: 444 additions & 91 deletions

File tree

docs/design/decisions.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,15 @@ the 2026-08-16 entries below. The survivor is the degenerate bare
4747
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.
4848

4949
- 2026-08-17 — SUPERSEDES the order-independence half of the 2026-08-16 order-precedence keystone entry above (#364, #365, #368). That entry says "no name_order moves that stopping point"; it may. Declaring FAMILY_FIRST or FAMILY_FIRST_GIVEN_LAST is precisely an assertion that what follows the family is NOT more surname, and a particle run's stopping point is exactly the question of where surname material ends — so the declaration is evidence about it, not merely about which slot the result lands in. The grouping/assignment split survives everywhere else; what is narrowed is the claim that the stopping point is one of the things order cannot touch.
50-
Concretely: under GIVEN_FIRST a leading particle run keeps joining to the end of the name, which makes "de Mesnil Juan" read exactly like "pennie von bergen wessels" — the same shape, and this session decided the greedy reading is right for that one. Under a family-first order the run stops after its first non-particle name word, because the caller has said the rest is given or middle.
50+
Concretely: under GIVEN_FIRST a leading particle run keeps joining to the end of the name. (This entry originally said that makes "de Mesnil Juan" read "exactly like pennie von bergen wessels — the same shape". Measured while implementing #395, that is false and is corrected here rather than left standing: pennie is the GIVEN name there, von is ambiguous so this fold can never fire on it, and the surname reading comes from P2's mid-name chain. What the real name does support is the weaker claim the argument needs — that a particle followed by several words can be all surname, "von Bergen Wessels" being one.) Under a family-first order the run stops after its first non-particle name word, because the caller has said the rest is given or middle.
5151
Accepted, and it is the cost that decided #364 three separate ways before this: "de la Vega Juan" under the DEFAULT order stays wholly a surname. A caller who means family "de la Vega" plus given "Juan" writes the comma. The mixed-language shapes are what make the greedy reading look wrong, and the parser cannot see language.
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: a particle chain (P2), a conjunction join (P3) and a bound given-name pair (P5) each count once. All three are read off the TAGS rather than the pieces, but for two different reasons, and the difference matters to anyone who tries to simplify this. The conjunction join grouping DID build and the prefix chain then swallowed — "de la Vega y Santos Juan" is one piece by the time the fold runs — so the boundary is gone. The bound-given join grouping never built at all: P5 joins only where the bound word is the first non-title piece, and at a fold site the first piece is the particle, so "ibn Awf abdul Rahman" arrives as four separate pieces. Restoring piece boundaries in group would fix the first and silently split the second. It also makes P5's new implemented: pointer stronger than a citation — this is the only place that join happens in this shape. 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, with the denominator this log requires: of 751 corpus names, EIGHT reach P1's leading fold at all (DE MESNIL, de, de Mesnil, de Mesnil Garcia, de Mesnil Jr., de la Vega, dos Santos, De Groot) and exactly ONE of those has anything past the run to lose, so one moves (de Mesnil Garcia) under each family-first order and none under the default. The single mover is a fact about the corpus holding no family-first listing with leftovers, not about the blast radius. 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 the two-leftover shape: mutation-checked, discarding name_order from the leftover placement fails three tests across three layers — the FAMILY_FIRST_GIVEN_LAST case row (one of the two, the FAMILY_FIRST row being blind to it by construction), test_post_rules.py's test_the_two_family_first_orders_differ_at_two_leftovers, and rules.md's P1 example for that order. An earlier draft of this sentence said "exactly one, and no other test in the suite": true when measured, false one commit later, because two of the three guards were added after the measurement and it was not re-run. Recording the failure mode as well as the number, since it is this log's recurring one.
5559

5660
Declined:
5761

docs/design/rules.md

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,35 +95,59 @@ 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 it attaches to 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
105-
about how the name is written, and a declared order governs only
106-
what no vocabulary has claimed (O4) — the same precedence the
107-
script license takes in W4.
102+
whatever reading its position gives it. That the particle claims
103+
the FAMILY rather than a given name holds under every order: a
104+
never-given particle is evidence about how the name is written,
105+
and a declared order governs only what no vocabulary has claimed
106+
(O4) — the same precedence the script license takes in W4.
107+
108+
How MANY name words it attaches to depends on the order, and on
109+
which of the two positions above the particle stands in. Opening
110+
the name, under a family-first order, it takes exactly ONE —
111+
declaring that order asserts that what follows the family is not
112+
more surname. Opening the name under the default order, or
113+
standing in the given position under any order, it takes the rest
114+
of the name: nothing there marks where the surname ends. One name
115+
word means one UNIT — a particle chain (P2), a conjunction join
116+
(P3) or a bound given-name pair (P5) is taken whole or not at
117+
all. What is left over is not read by O4's rule for a whole name,
118+
which would make the first leftover a second family name; it is
119+
laid out as the positions AFTER the family in the declared order,
120+
the family slot being already filled.
108121
"de la Vega" → family="de la Vega"
109122
"Sir de Mesnil" → family="de Mesnil"
110123
"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="")
124+
"de Mesnil Juan" → family="de Mesnil Juan"
125+
"de Mesnil Juan" family-first → family="de Mesnil"
126+
"de Mesnil Juan" family-first → given="Juan"
127+
"Smith, de Mesnil Juan" family-first → family="Smith de Mesnil Juan"
128+
"de la Vega y Santos Juan" family-first → family="de la Vega y Santos"
129+
"ibn Awf abdul Rahman" family-first → given="abdul Rahman"
130+
"de la Cruz Juan Carlos" family-first-given-last → given="Carlos"
113131
"Mc Donald" → family="Mc Donald"
114132
"de los Santos" → family="de los Santos"
115133
"van Gogh" → given="van" · boundary
116134
Accepted: a bare "de" stays the given name — there is nothing to
117135
fold into, and inventing a surname would be worse.
118136
"de" → given="de"
137+
Accepted: stopping the run leaves a MIDDLE where the fold never
138+
left one before, so O3 has something to claim that it could not
139+
reach until now. The family it then reports is discontiguous in
140+
the input — words 1-3 plus word 5 — and renders the folded word
141+
first, which is R1's order, not this rule's doing.
142+
"de la Cruz Juan Carlos" family-first+middle_as_family → family="Carlos de la Cruz"
119143
Accepted: only the OPENING position is this rule's subject. A
120144
particle chain standing inside the name is grouped normally (P2)
121145
and positioned by the declared order, so a family-first reading
122146
may report it as the given name; what the vocabulary forbids is
123147
the bare particle reading as a given name, not any name part
124148
that begins with one.
125149
"Juan de la Vega" family-first → family="Juan"
126-
history: decisions.md#P1 · interacts: P2, P4, P6 · implemented: nameparser/_pipeline/_post_rules.py
150+
history: decisions.md#P1 · interacts: O3, O4, P2, P3, P4, P5, P6 · implemented: nameparser/_pipeline/_post_rules.py
127151

128152
P2. Rationale: a particle is written as part of the surname it
129153
precedes, and a title stands outside the name entirely.
@@ -132,16 +156,20 @@ P2. Rationale: a particle is written as part of the surname it
132156
or the name ends. The final group reads as the family name;
133157
earlier groups read by position. The chain begins wherever the
134158
name begins, and a preceding title does not move that point.
159+
Where P1's fold has claimed the opening, the fold decides the
160+
family instead — and may take only PART of the final group,
161+
since it counts name words and the group is one part.
135162
"John van der Berg" → family="van der Berg"
136163
"John van der Berg Smith" → family="van der Berg Smith"
137164
"Vincent van Gogh van Beethoven" → middle="van Gogh"
138165
"Dr. John van der Berg" → family="van der Berg"
139166
"Juan de" → family="de" · boundary
167+
"de la Cruz Juan Carlos" family-first → family="de la Cruz"
140168
Accepted: a caller wanting the combined double-surname reading
141169
(#132's ask) has it as the surnames view rather than the
142170
family field.
143171
"Vincent van Gogh van Beethoven" → surnames="van Gogh van Beethoven"
144-
history: decisions.md#P2 · implemented: nameparser/_pipeline/_group.py
172+
history: decisions.md#P2 · interacts: P1, P4 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py
145173

146174
P3. Rationale: connective words ("y", "of the") bind name words into
147175
one name part; but a single letter in a short name is more
@@ -176,7 +204,7 @@ P3. Rationale: connective words ("y", "of the") bind name words into
176204
same two words unjoined are two name words and H1 does not fire.
177205
P1's leading run becomes the second once #395 lands — its run
178206
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
207+
history: decisions.md#P3 · interacts: H1, P1 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py
180208

181209
P4. Rationale: a particle links forward from inside a name; at the
182210
very front there is no name yet to be inside.
@@ -197,7 +225,7 @@ P5. Rationale: some given-name words are incomplete alone — "abdul"
197225
one given name.
198226
"abdul salam ahmed salem" → given="abdul salam"
199227
"mohamad ali smith" → given="mohamad" · boundary
200-
history: decisions.md#P5 · implemented: nameparser/_pipeline/_group.py
228+
history: decisions.md#P5 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py
201229

202230
P6. Rationale: a particle ending the name has nothing to link
203231
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 a particle followed by several words really can be all surname -- ``von Bergen Wessels`` is one such name. Nothing in ``"de Mesnil Juan"`` distinguishes it from that reading except a declared order or a comma. 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**

0 commit comments

Comments
 (0)