Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/design/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ Open: [#380](https://github.com/derek73/python-nameparser/issues/380) covers "Be

- 2026-07-03 (maiden-bucket design; #274 filed 2026-07-07, landed in the v2 core, PR #288) — the marker takes everything after it up to a trailing suffix, greedily: "née Jones Smith" is a two-word maiden name, matching how the marker is actually used in running text. The marker itself is dropped as structural, like a delimiter character.

- 2026-08-19 #399 (PR #409) — a maiden marker bounds a particle join reaching it from the left, where the marker is actually taken as a marker. rules.md carried the opposite as an Accepted limitation ("a particle chain swallows a marker in its path, the join (P2) running first"), but that was a consequence of stage order rather than a judgment: the chain merges pieces before the marker is consumed, and the consumer looks for a lone marker piece which by then no longer exists. So `Ursula von der Leyen geb. Albrecht` lost the maiden name that `Ursula Leyen geb. Albrecht` kept.

The gate on the stop is the whole substance of the rule and was added in review, not in the first cut. Sharing one marker-piece predicate between the stop and the consumer settles what a marker piece IS; it does NOT settle whether the consumer takes, because the consumer also needs a non-suffix piece after the marker. Stopping without that left the marker as a piece of its own, and a lone trailing piece takes a role field: `Jane van der Berg née` read middle 'van der Berg', family 'née' — the marker became the surname and the real surname was demoted, which is the stranding the stop exists to prevent, one field over. Three consequences of the ungated form, each measured and each fixed by the gate: `Jane van der Nee` moved a Dutch bearer of the attested surname Nee out of the family (M1 already names Nee as a surname); `Ursula von der Leyen geb.`, an ordinary truncated record, reported the marker as the surname; and `St St née` stopped reporting its particle-or-given fork, because group's emitter is guarded on the chain having merged something and the stop made it merge nothing for a different reason than the guard assumes — an A1 violation of the shape #405 tracks, and worse than #405's, since it REMOVES a report callers already see. The gate tests the consumer's own condition rather than restating it, which is what makes the two halves agree. M2 states the gate ("a marker left as a word bounds nothing"); the trailing-marker rows pin it.

Weighed against moving the maiden handler ahead of the chain, and the recorded reason for preferring the stop has been corrected twice. It is NOT that the reorder disturbs names whose maiden side carries its own particle chain: `Anna Müller geb. von der Berg` is identical before #399, after it, and under a reconstructed reorder, and mechanically cannot differ — no suffix and no conjunction on the maiden side means merged and unmerged pieces hand the consumer the same words. The reason is scope: the reorder changes what the consumer receives for every name rather than adding one condition, and two review sweeps found no test and no corpus name that separates the two options, so it would ship unmeasurable. A review sweep also found the reorder would reach maiden names the stop does not (the P3/P5 join-swallow below) at a cost on the given side, through P3's rootname count dropping when the maiden words leave early; neither figure is re-derived here, and the reorder stays available if that class is ever worth fixing.

Two things fell out rather than being decided separately: the same issue's open question about the leftover given slot (the marker used to compete for it, giving `de la Cruz née Vega` given "née" under a family-first order; consumed and dropped, it never reaches the placement — though a marker the consumer declines still does, which is M2's "just a word" reading and not a leak), and the wholly-particle family a stop can leave, which #404's R2 reading already covers — "Jane de la née Jones" reports family "de la" with a non-empty base. A 305,364-parse sweep at review found no empty-base or partition violation either side of the change.

Two limits stay, now recorded in M2 rather than left implicit: the bound reaches only a marker standing as a word of its own, so P3's connective join and P5's bound-given join each still absorb a marker before the bound can see it — the deleted Accepted clause covered a class of which this fixed one instance and left two. Tests carry the verification: of the marker-bearing differential corpus names none had a particle ahead of the marker, so the gate was blind to the class. Adding the cross-script case row put one such name in the CJK corpus, so it is gate-visible from here on, with its own ledger rule.

Open (M2):
[#317](https://github.com/derek73/python-nameparser/issues/317)
the fullwidth-colon marker (旧姓:佐藤 arrives as one word; the head-peel question).
Expand Down
32 changes: 24 additions & 8 deletions docs/design/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ P1. Rationale: a never-given particle standing alone cannot be
P2. Rationale: a particle is written as part of the surname it
precedes, and a title stands outside the name entirely.
A particle joins the words after it into one name 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;
join running until the next particle starts a group of its own,
a trailing suffix begins, a maiden marker takes the words after
it (M2), or the name ends. The final group reads as the family
name;
earlier groups read by position. The chain begins wherever the
name begins, and a preceding title does not move that point.
Where P1's fold has claimed the opening, the fold decides the
Expand All @@ -169,11 +171,13 @@ P2. Rationale: a particle is written as part of the surname it
"Dr. John van der Berg" → family="van der Berg"
"Juan de" → family="de" · boundary
"de la Cruz Juan Carlos" family-first → family="de la Cruz"
"John van der Berg PhD" → family="van der Berg"
"John van der Berg née Jones" → family="van der Berg"
Accepted: a caller wanting the combined double-surname reading
(#132's ask) has it as the surnames view rather than the
family field.
"Vincent van Gogh van Beethoven" → surnames="van Gogh van Beethoven"
history: decisions.md#P2 · interacts: P1, P4 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py
history: decisions.md#P2 · interacts: P1, P4, M2 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py

P3. Rationale: connective words ("y", "of the") bind name words into
one name part; but a single letter in a short name is more
Expand Down Expand Up @@ -426,21 +430,33 @@ M2. Rationale: a maiden marker announces that what follows it is the
word takes the words after it — up to any trailing suffix — as
the maiden name, and the marker itself is dropped. A marker
with nothing after it, or nothing before it, is just a word.
A marker taken this way also bounds a particle join arriving from
its left (P2), so the family name's particles stop at the marker
instead of absorbing it; a marker left as a word bounds nothing.
Where the bound leaves a family of nothing but particles, they
are not in particle position and read as ordinary words (R2).
"Jane Smith née Jones" → maiden="Jones"
"Jane née Jones Smith" → maiden="Jones Smith"
"Jane Smith née Jones PhD" → suffix="PhD"
"Jones née" → family="née" · boundary
"née Jones" → family="Jones" · boundary
"Jane van der Berg née Jones" → maiden="Jones"
"Jane de la née Jones" → family="de la"
"Jane van der Berg née" → family="van der Berg née"
"Jane van der Berg née PhD" → family="van der Berg née"
Accepted: the fullwidth-colon spelling arrives as one word, so
the marker inside it goes unrecognized; #317 tracks whether it
should peel.
"山田 花子 旧姓:佐藤" → maiden=""
Accepted: a marker straight after a comma is post-comma given
text, not a marker; and a particle chain swallows a marker in
its path, the join (P2) running first.
"Jane Smith, née Jones" → maiden=""
"Jane de la née Jones" → family="de la née Jones"
history: decisions.md#M2 · interacts: P2, M1 · implemented: nameparser/_pipeline/_group.py
text, not a marker; and the bound reaches only a marker standing
as a word of its own, so the connective join (P3) and the
bound-given join (P5) each still absorb a marker before the bound
can see it.
"Jane Smith, née Jones" → maiden=""
"Jane van der Berg née y Jones" → maiden=""
"van der Berg, abdul née Jones" → given="abdul née"
history: decisions.md#M2 · interacts: P2, P3, P5, R2, M1 · implemented: nameparser/_pipeline/_group.py

## Commas & structure (C)

Expand Down
2 changes: 2 additions & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Release Log

- 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)

- Fix a maiden name being lost when a particle stood in front of the marker. ``"Ursula Leyen geb. Albrecht"`` reported maiden ``Albrecht`` correctly, but ``"Ursula von der Leyen geb. Albrecht"`` -- the same words one particle chain apart -- gave family ``von der Leyen geb. Albrecht`` and no maiden name at all, and ``"Jane van der Berg née Jones"`` failed the same way. The particle chain absorbed everything up to the next particle or suffix, and the marker is consumed later in the same grouping stage, by which point the chain had merged the marker away and there was no marker word left standing to find. A suffix already stopped the chain; a marker now does too, so those read family ``von der Leyen`` maiden ``Albrecht`` and family ``van der Berg`` maiden ``Jones``. Only a particle that is not the name's own first word ever reached the marker, which is why a single leading particle always worked -- ``"von Müller geb. Schmidt"`` was already right -- while a leading run of two did not: in ``"von der Müller geb. Schmidt"`` the second particle starts a chain of its own. The bound applies only where the marker is actually taken as a marker. A marker needs a word after it to hand the maiden name to, so a trailing one is just a word and bounds nothing: ``"Jane van der Berg née"`` and ``"Jane van der Berg née PhD"`` both keep family ``van der Berg née``, which is the reading ``"Jones née"`` -> family ``née`` already had. That distinction is what keeps a name like ``"Jane van der Nee"`` intact -- ``Nee`` is an attested surname as well as a marker spelling -- and what keeps ``"St St née"`` reporting its ``PARTICLE_OR_GIVEN`` fork. Two consequences fall out. Under a family-first order the marker used to survive as an ordinary name word and take the leftover given slot, so ``"de la Cruz née Vega"`` read given ``née``, middle ``Vega`` under ``Policy(name_order=FAMILY_FIRST)`` and given ``Vega``, middle ``née`` under ``Policy(name_order=FAMILY_FIRST_GIVEN_LAST)``; consumed and dropped it never reaches that placement, and both orders now read family ``de la Cruz``, maiden ``Vega``. And stopping the chain can leave a family of nothing but particles, which the #385 rule above already covers: ``"Jane de la née Jones"`` reports family ``de la`` with ``family_base`` ``de la`` rather than an empty base. Two limits remain, and both are recorded in ``rules.md#M2``: the bound reaches only a marker standing as a word of its own, so a conjunction join and a bound given-name join each still absorb one first -- ``"Jane van der Berg née y Jones"`` and ``"van der Berg, abdul née Jones"`` are unchanged. The bracketed form was never affected, since ``"Ursula von der Leyen (geb. Albrecht)"`` is extracted before grouping. No differential corpus name carried a particle ahead of a marker, so this class was invisible to the harness at every baseline; a name of the shape was added to the CJK corpus with this change, and it now moves one name with its own ledger rule (closes #399)

**Deprecations**

- Rename the four vocabularies whose 1.x names described the fields they feed in v1's words, so the data layer matches the ``Lexicon``:
Expand Down
60 changes: 52 additions & 8 deletions nameparser/_pipeline/_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ def _is_title_piece(piece: Sequence[int], ptags: Set[str],

# rules.md#P2: "a particle joins the words after it into one name
# 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)
# its own, a trailing suffix begins, a maiden marker takes the words
# after it (M2), 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)
def _is_prefix_piece(piece: Sequence[int], ptags: Set[str],
Expand All @@ -87,6 +89,22 @@ def _is_suffix_piece(piece: Sequence[int], ptags: Set[str],
return "vocab:suffix" in tags and "initial" not in tags


# rules.md#M2: "a recognized maiden marker standing after at least
# one name word takes the words after it — up to any trailing
# suffix — as the maiden name, and the marker itself is dropped"
# (history: decisions.md#M2)
#
# Shared deliberately with the prefix chain's stop (#399): a chain that
# stopped at something the consumer below would not then take would
# leave the marker stranded inside the family name, which is the very
# defect the stop exists to fix. One definition, so the two cannot
# disagree about what a marker piece is.
def _is_maiden_marker_piece(piece: Sequence[int],
tokens: Sequence[WorkToken]) -> bool:
return (len(piece) == 1
and "vocab:maiden-marker" in tokens[piece[0]].tags)


# rules.md#P3: "a recognized connective joins its neighbors into one
# name part, connective runs included — except a single-letter
# connective in a three-word name, which stays a name word, and a
Expand Down Expand Up @@ -133,6 +151,21 @@ def suffix(k: int) -> bool:
def conj(k: int) -> bool:
return _is_conj_piece(pieces[k], ptags[k], tokens)

def maiden_marker_stop(k: int) -> bool:
# A marker bounds the chain only where the consumer below will
# actually take it (#399). That consumer needs a non-suffix
# piece after the marker; stopping without one left the marker
# as a piece of its own, and a lone trailing piece takes a role
# field -- so the marker became the family name and the real
# surname was demoted to the middle ("Jane van der Berg née"
# read middle 'van der Berg', family 'née'). That is the defect
# this stop exists to prevent, one field over. Testing the
# consumer's own condition rather than restating it is what
# makes the two halves agree.
return (_is_maiden_marker_piece(pieces[k], tokens)
and any(not _is_suffix_piece(pieces[x], ptags[x], tokens)
for x in range(k + 1, len(pieces))))

def merge(lo: int, hi: int, add: Set[str] = frozenset(),
drop: Set[str] = frozenset()) -> None:
# pieces/ptags are parallel arrays; every merge must update
Expand Down Expand Up @@ -253,6 +286,18 @@ def merge(lo: int, hi: int, add: Set[str] = frozenset(),
# classifies its leading piece as a TITLE and is already
# covered here.
#
# A maiden marker stops the scan for a different reason than a
# suffix does (#399): it is not a name word at all but the
# boundary between two names, and the piece that consumes it
# runs later in this same stage. Absorbing it took the maiden
# name into the family with it -- "Ursula von der Leyen geb.
# Albrecht" read family 'von der Leyen geb. Albrecht' where
# "Ursula Leyen geb. Albrecht" reported maiden 'Albrecht'. Only
# a NON-leading particle ever reached the marker, so a leading
# single particle always worked (P4 chains nothing) while a
# leading run of two did not, the second particle's own chain
# firing.
#
# The `, 0` fallback is inert by construction rather than a
# default worth testing: it is reached only when every piece is
# a title and none is a prefix, and the loop below merges
Expand All @@ -267,7 +312,8 @@ def merge(lo: int, hi: int, add: Set[str] = frozenset(),
j = k + 1
while j < len(pieces) and prefix(j):
j += 1
while j < len(pieces) and not prefix(j) and not suffix(j):
while (j < len(pieces) and not prefix(j) and not suffix(j)
and not maiden_marker_stop(j)):
j += 1
# The other half of PARTICLE_OR_GIVEN. _assign reports the
# fork when an ambiguous particle stays a lone leading piece
Expand Down Expand Up @@ -430,11 +476,9 @@ def group(state: ParseState) -> ParseState:
# maiden markers: a non-leading marker piece consumes following
# pieces until a suffix; consumed tokens become MAIDEN, the
# marker is dropped (#274)
m = next(
(k for k in range(1, len(pieces))
if len(pieces[k]) == 1
and "vocab:maiden-marker" in tokens[pieces[k][0]].tags),
None)
m = next((k for k in range(1, len(pieces))
if _is_maiden_marker_piece(pieces[k], tokens)),
None)
if m is not None:
j = m + 1
consumed: list[int] = []
Expand Down
3 changes: 2 additions & 1 deletion nameparser/_pipeline/_post_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ def _retag(tokens: list[WorkToken], i: int, role: Role) -> None:

# rules.md#P2: "a particle joins the words after it into one name
# part, the join running until the next particle starts a group of
# its own or the name ends"
# its own, a trailing suffix begins, a maiden marker takes the words
# after it (M2), or the name ends"
# rules.md#P3: "the joined part is ONE name word wherever another
# rule counts them"
# rules.md#P5: "a recognized bound given-name word joins the word
Expand Down
Loading