Skip to content
Merged
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
25 changes: 17 additions & 8 deletions docs/design/decisions.md

Large diffs are not rendered by default.

68 changes: 54 additions & 14 deletions docs/design/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,20 +134,23 @@ P1. Rationale: a particle OPENING a name has the whole rest of the
How MANY name words it attaches to depends on the order. Under a
family-first order it takes exactly ONE — declaring that order
asserts that what follows the family is not more surname. Under
the default order it takes the rest of the name: nothing there
marks where the surname ends. Whether the default order should
narrow the same way is #471 — today `de Mesnil Jean` reports the
whole string as the family, and that reach is 1.4.0's. One name
word means one UNIT — a particle chain (P2), a conjunction join
(P3) or a bound given-name pair (P5) is taken whole or not at
all. A title does not move the opening position (P4), but a
family comma does end the question: the comma has already fixed
the surname, so there is no positional read left for an order to
narrow, and a particle opening the part AFTER it takes the rest
of that part whatever order is declared. What is left over is not read by O4's rule for a whole name,
which would make the first leftover a second family name; it is
laid out as the positions AFTER the family in the declared order,
the family slot being already filled.
the default order it takes the rest of the name, and that is a
reading rather than a gap in one: a never-given particle marks
where a surname BEGINS, surnames are routinely several words,
and no declaration has said this one ends before the string
does. So `de Mesnil Jean` reports the whole string as the
family, which is 1.4.0's reach as well (#471, declined by
design). One name word means one UNIT — a particle chain (P2),
a conjunction join (P3) or a bound given-name pair (P5) is
taken whole or not at all. A title does not move the opening
position (P4), but a family comma does end the question: the
comma has already fixed the surname, so there is no positional
read left for an order to narrow, and a particle opening the
part AFTER it takes the rest of that part whatever order is
declared. What is left over is not read by O4's rule for a
whole name, which would make the first leftover a second family
name; it is laid out as the positions AFTER the family in the
declared order, the family slot being already filled.
"de la Vega" → family="de la Vega"
"Sir de Mesnil" → family="de Mesnil"
"Mesnil de" family-first → given="de"
Expand All @@ -161,6 +164,18 @@ P1. Rationale: a particle OPENING a name has the whole rest of the
"Mc Donald" → family="Mc Donald"
"de los Santos" → family="de los Santos"
"van Gogh" → given="van" · boundary
Accepted: the default-order reach has two faces, and the second
is a real cost. A surname-only string is read right, which is
what the reach is for; a family-first listing read under the
default order is read wrong, all of it landing in the family.
The remedy is the declaration or the comma — declare a
family-first order, which gives family `de la Cruz`, given
`Juan`, middle `Carlos`, or write the family comma. What
NEITHER order settles is the comma's alone: `de la Family
Family Given` and `de la Family Given Given` are the same
string shape, and no reading of the words tells them apart.
"de la Torre Vega" → family="de la Torre Vega"
"de la Cruz Juan Carlos" → family="de la Cruz Juan Carlos"
Accepted: a bare "de" stays the given name — there is nothing to
fold into, and inventing a surname would be worse.
"de" → given="de"
Expand Down Expand Up @@ -951,6 +966,7 @@ C2. Rationale: text beyond the recognized comma parts should be
## Name order (O)

Background: written name order varies by convention: given-first (the library's default reading), family-first, and family-first with the given name last (Vietnamese, where the person is called by the last element, given names are frequently two syllables — the given_names view stays correct wherever the internal boundary falls — and quốc ngữ is Latin script, so no native-script signal exists at all). The order is declared by the caller or a locale pack, never detected — but a few conventions leave a recognizable trace in the name itself. Patronymics are one: East Slavic names carry a father's-name derivative with distinctive endings between given and family, and Turkic names use a standalone marker word ("oglu" son-of, "qizi" daughter-of) after the father's name. Where such a trace is present and unambiguous, an opted-in parser can restore the intended reading from a family-first listing.
A declared order is a property of the DATA SOURCE rather than of any one string: the caller sets it to match how their records are written, and it governs what no vocabulary and no script license has already claimed (O4). A declared FAMILY-FIRST order outranks what the parser could infer from the shape of a particular name; the given-first reading is the parser's DEFAULT rather than a declaration it can tell apart from one, and the traces above are what refine it (O1, O2). And the declaration yields where a name's own script settles the order instead (W4, where a name written wholly in an East Asian script reads family-first whatever order the caller declared). Two consequences run through this document. Under the default given-first order a string opening with a never-given particle is a surname whose given name is simply absent, so the fold takes the rest of it (P1). Under a declared family-first order the caller has already said that what follows the family is not more surname — so the fold stops there, and the rotations below, whose whole job is to RESTORE the default reading from a family-first listing, have nothing left to restore. A shape neither order settles is the family comma's job, and the parser does not guess at it.

O1. Rationale: an East Slavic name written family-first still shows
its patronymic — the distinctive ending identifies which word is
Expand All @@ -962,9 +978,26 @@ O1. Rationale: an East Slavic name written family-first still shows
the words are restored to given, patronymic, family. A middle
word that also carries the ending blocks the reading, because
the surname itself may be patronymic-derived.
The restoration is the default order's work: it recovers the
given-first reading a family-first listing hides, so where the
caller has DECLARED a family-first order there is nothing left
to restore and position decides.
"Сидоров Иван Петрович" [ru] → family="Сидоров"
"Sidorov Ivan Petrovich Jr." [ru] → family="Sidorov"
"Иван Петрович Абрамович" [ru] → family="Абрамович" · boundary
Accepted: under a declared family-first order the readings part
on natural-order input, and the declaration wins. A family-first
listing reads the same either way under FAMILY_FIRST — the first
example above is that same parse with FAMILY_FIRST declared,
and under FAMILY_FIRST_GIVEN_LAST the listing's given and middle
swap, so only the family is invariant — while with East Slavic
handling active and FAMILY_FIRST declared the natural-order
Иван Петрович Сидоров reads family Иван, given Петрович, middle
Сидоров. That is the caller's declaration being honored on input
they said was written family-first, not a defect (#384). No
registered example annotation combines a pack with an order, so
the parse is pinned in tests/v2/test_locales.py instead, and
decisions.md#O1 records why options 2 and 3 were declined.
history: decisions.md#O1 · implemented: nameparser/_pipeline/_post_rules.py

O2. Rationale: a Turkic patronymic marker is a separate word that
Expand All @@ -975,12 +1008,19 @@ O2. Rationale: a Turkic patronymic marker is a separate word that
nicknames aside — ending in a standalone patronymic marker reads
family-first: the first name word is the family name, and the
marker stays beside the father's name in the middle.
The scope is O1's: the restoration recovers the default reading,
so a declared family-first order stands in its place.
"Ali Ahmad Vali oglu" [tr_az] → family="Ali"
"Ali Ahmad Vali oglu Jr." [tr_az] → family="Ali"
Accepted: any other count of name words keeps its positional
reading, even when that leaves the marker itself in a name
field.
"Ali Ahmad oglu" [tr_az] → family="oglu" · boundary
Accepted: the order scope above has this rule's own consequence
reached by a second route — with Turkic handling active and
FAMILY_FIRST_GIVEN_LAST declared, Ali Ahmad Vali oglu reads
given oglu, the marker standing in a name field because the
declaration put the name's last word there (#384).
history: decisions.md#O2 · implemented: nameparser/_pipeline/_post_rules.py

O3. Rationale: several traditions write compound family names
Expand Down
2 changes: 2 additions & 0 deletions docs/release_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Release Log

- **Mark ba as an acronym that is also an ordinary name, so a bare trailing Ba keeps the family name.** ``HumanName("Anna Ba")`` gives last ``Ba`` and reports a suffix-or-name ambiguity, where 2.0.0 through 2.2.0 gave suffix ``Ba`` and no last name. The SPACED full-name form keeps the credential reading: ``John Smith BA`` still gives suffix ``BA``, now flagged, and the dotted ``John Smith B.A.`` is an unflagged suffix, the periods settling it. The COMMA forms move, and this is the marking's real cost: ``Smith, BA`` gives first ``BA``, and ``John Smith, BA`` gives first ``BA``, last ``John Smith``, where 2.0.0 through 2.2.0 gave suffix ``BA`` for both -- what ``Smith, Ed`` costs, which S2 already accepted for the other ambiguous acronyms. A bracketed or quoted ``John Smith (BA)`` falls through to nickname parsing, as the 2.0 note for ``ma``/``do`` below recorded for that pair. Write ``B.A.`` to keep the credential reading. BA is a common credential and Ba a real surname in Vietnamese and Senegalese Fula, which is the ``ma``/``Ma`` shape exactly. No corpus name moves (#342)

- **Fix the East Slavic and Turkic patronymic rotations overriding a declared family-first name order.** With ``patronymic_rules`` opted in and ``Policy(name_order=FAMILY_FIRST)``, ``Мицкевич Адам Юзеф`` gave last ``Адам`` through 2.2.0 and now gives last ``Мицкевич`` -- the reading the declaration asks for -- and ``oglu Ahmad Vali Ali`` with Turkic handling gave last ``Ahmad`` and now ``oglu``. The rotations exist to restore the given-first reading a family-first listing hides, so under a declared family-first order the declaration decides. No corpus name moves. See the ``O1`` entry of ``docs/design/decisions.md`` (closes #384)

**Additions**

- **Add the renunciate titles to the given-name title list, so a renunciate's one name is a given name.** ``HumanName("Swami Vivekananda")`` gives first ``Vivekananda`` with an empty last name, where every release since 1.4.0 gave last ``Vivekananda``; ``Guru Nanak``, ``Baba Ramdev`` and ``Lama Zopa`` move the same way, and so do the Devanagari and Bengali spellings added below. Two name words behind the title are unchanged -- ``Swami Vivekananda Saraswati`` keeps last ``Saraswati`` -- and a surname-retaining title is untouched: ``Rabbi Cohen`` still gives last ``Cohen``. ``venerable`` is deliberately not in the list, the traditions using it splitting on whether the family name survives. See the ``indic-honorifics`` entry of ``docs/design/decisions.md`` (closes #346)
Expand Down
19 changes: 18 additions & 1 deletion nameparser/_pipeline/_post_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@
_RENDERS_ELSEWHERE = frozenset({Role.TITLE, Role.NICKNAME, Role.MAIDEN})


def _rotations_apply(state: ParseState) -> bool:
# Both patronymic rotations RESTORE the given-first reading a
# family-first listing hides, so rules.md#O1's scope clause holds
# them to the default order: a caller who declared family-first has
# already said what the rotation would infer, and position decides
# (decisions.md#O1, the 2026-09-07 entry on #384). `state.order`,
# not policy.name_order, for the reason the P1 fold gives -- a
# script_orders entry can override the policy, and the roles the
# rotations read are the ones assign actually made. None means
# assign positioned nothing: a family comma (which the NO_COMMA
# test already excludes) or an early return with no name piece to
# position, so there is no declaration to defer to and the
# rotation's own shape test decides.
return state.structure is Structure.NO_COMMA and (
state.order is None or state.order[0] is Role.GIVEN)


def _mark_suffix_entries(tokens: list[WorkToken], state: ParseState) -> None:
# In place over the caller's token list, the way every other rule
# in post_rules writes: a state-in/state-out spelling here cost
Expand Down Expand Up @@ -462,7 +479,7 @@ def post_rules(state: ParseState) -> ParseState:
# middle_as_family fold below runs comma or not (v1 order:
# patronymics first, then handle_middle_name_as_last)
rules = state.policy.patronymic_rules
rotations_apply = state.structure is Structure.NO_COMMA
rotations_apply = _rotations_apply(state)
# rules.md#O1: "a name of exactly three name words — titles,
# suffixes and nicknames aside — whose last name word carries a
# patronymic ending and whose middle name word does not reads as
Expand Down
6 changes: 5 additions & 1 deletion tests/v2/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,11 @@ def _check_cjk_shape_purity(self) -> None:
"ambiguous, so this fold cannot fire on it. See "
"decisions.md#P1, 2026-08-17.) 1.4.0 gives last 'de la "
"Cruz Juan Carlos' too, so this row must not move when "
"#395 lands"),
"#395 lands. #471 DECLINED 2026-09-07: the reach is "
"the reading and not a gap in one -- a declared order "
"is a property of the data source, so under the "
"default order nothing has said this surname ends "
"before the string does. See decisions.md#P1"),
Case("leading_never_given_particle_two_leftovers_family_first",
"de la Cruz Juan Carlos",
{"family": "de la Cruz", "given": "Juan", "middle": "Carlos"},
Expand Down
Loading