Skip to content

Commit 0b6a792

Browse files
derek73claude
andcommitted
review round: PR #517 findings
Three review agents on the whole PR. The order Background is narrowed to what the code can implement -- a declared FAMILY-FIRST order outranks the shape; an explicit GIVEN_FIRST is the default and cannot be told apart from it -- and the two decisions.md restatements become citations. The #470 citation is corrected (closed not-planned 2026-09-02, and it decided that prose in an Accepted clause is the right home, not a workaround); the option-3 decline is scoped to listings carrying the trace; the capability-claim lesson is attributed to AGENTS.md axis 10; the zero-movers reason covers the Turkic trigger too; the gate's unwitnessable conditions are recorded as such. The gate becomes a module-level helper so a reachability probe can patch it back to the ungated condition and demand the 2.2.0 readings return -- without it a refactor that stops the rotation reaching family-first placement leaves all five stand-down rows green while asserting nothing. O2's Accepted clause gains its pin; a peeled-title parameter joins the East Slavic stand-down test. The #384 gate gets its release-note bullet: it changes what 2.2.0 does for a supported configuration, and corpus movement is not the house exemption. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4523155 commit 0b6a792

6 files changed

Lines changed: 123 additions & 50 deletions

File tree

docs/design/decisions.md

Lines changed: 8 additions & 8 deletions
Large diffs are not rendered by default.

docs/design/rules.md

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -140,17 +140,17 @@ P1. Rationale: a particle OPENING a name has the whole rest of the
140140
and no declaration has said this one ends before the string
141141
does. So `de Mesnil Jean` reports the whole string as the
142142
family, which is 1.4.0's reach as well (#471, declined by
143-
design). One name
144-
word means one UNIT — a particle chain (P2), a conjunction join
145-
(P3) or a bound given-name pair (P5) is taken whole or not at
146-
all. A title does not move the opening position (P4), but a
147-
family comma does end the question: the comma has already fixed
148-
the surname, so there is no positional read left for an order to
149-
narrow, and a particle opening the part AFTER it takes the rest
150-
of that part whatever order is declared. What is left over is not read by O4's rule for a whole name,
151-
which would make the first leftover a second family name; it is
152-
laid out as the positions AFTER the family in the declared order,
153-
the family slot being already filled.
143+
design). One name word means one UNIT — a particle chain (P2),
144+
a conjunction join (P3) or a bound given-name pair (P5) is
145+
taken whole or not at all. A title does not move the opening
146+
position (P4), but a family comma does end the question: the
147+
comma has already fixed the surname, so there is no positional
148+
read left for an order to narrow, and a particle opening the
149+
part AFTER it takes the rest of that part whatever order is
150+
declared. What is left over is not read by O4's rule for a
151+
whole name, which would make the first leftover a second family
152+
name; it is laid out as the positions AFTER the family in the
153+
declared order, the family slot being already filled.
154154
"de la Vega" → family="de la Vega"
155155
"Sir de Mesnil" → family="de Mesnil"
156156
"Mesnil de" family-first → given="de"
@@ -966,7 +966,7 @@ C2. Rationale: text beyond the recognized comma parts should be
966966
## Name order (O)
967967

968968
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.
969-
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) — it outranks what the parser could infer from the shape of a particular name, and 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.
969+
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.
970970

971971
O1. Rationale: an East Slavic name written family-first still shows
972972
its patronymic — the distinctive ending identifies which word is
@@ -987,16 +987,17 @@ O1. Rationale: an East Slavic name written family-first still shows
987987
"Иван Петрович Абрамович" [ru] → family="Абрамович" · boundary
988988
Accepted: under a declared family-first order the readings part
989989
on natural-order input, and the declaration wins. A family-first
990-
listing reads the same either way — the first example above is
991-
that same parse with FAMILY_FIRST declared — while with East
992-
Slavic handling active and FAMILY_FIRST declared the
993-
natural-order Иван Петрович Сидоров reads family Иван, given
994-
Петрович, middle Сидоров. That is the caller's declaration being
995-
honored on input they said was written family-first, not a
996-
defect (#384). The example grammar takes one annotation, so a
997-
pack and an order cannot share an example line; the parse is
998-
pinned in tests/v2/test_locales.py and decisions.md#O1 records
999-
why options 2 and 3 were declined.
990+
listing reads the same either way under FAMILY_FIRST — the first
991+
example above is that same parse with FAMILY_FIRST declared,
992+
and under FAMILY_FIRST_GIVEN_LAST the listing's given and middle
993+
swap, so only the family is invariant — while with East Slavic
994+
handling active and FAMILY_FIRST declared the natural-order
995+
Иван Петрович Сидоров reads family Иван, given Петрович, middle
996+
Сидоров. That is the caller's declaration being honored on input
997+
they said was written family-first, not a defect (#384). No
998+
registered example annotation combines a pack with an order, so
999+
the parse is pinned in tests/v2/test_locales.py instead, and
1000+
decisions.md#O1 records why options 2 and 3 were declined.
10001001
history: decisions.md#O1 · implemented: nameparser/_pipeline/_post_rules.py
10011002

10021003
O2. Rationale: a Turkic patronymic marker is a separate word that

docs/release_log.rst

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

1919
- **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)
2020

21+
- **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)
22+
2123
**Additions**
2224

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

nameparser/_pipeline/_post_rules.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@
6565
_RENDERS_ELSEWHERE = frozenset({Role.TITLE, Role.NICKNAME, Role.MAIDEN})
6666

6767

68+
def _rotations_apply(state: ParseState) -> bool:
69+
# Both patronymic rotations RESTORE the given-first reading a
70+
# family-first listing hides, so rules.md#O1's scope clause holds
71+
# them to the default order: a caller who declared family-first has
72+
# already said what the rotation would infer, and position decides
73+
# (decisions.md#O1, the 2026-09-07 entry on #384). `state.order`,
74+
# not policy.name_order, for the reason the P1 fold gives -- a
75+
# script_orders entry can override the policy, and the roles the
76+
# rotations read are the ones assign actually made. None means
77+
# assign positioned nothing: a family comma (which the NO_COMMA
78+
# test already excludes) or an early return with no name piece to
79+
# position, so there is no declaration to defer to and the
80+
# rotation's own shape test decides.
81+
return state.structure is Structure.NO_COMMA and (
82+
state.order is None or state.order[0] is Role.GIVEN)
83+
84+
6885
def _mark_suffix_entries(tokens: list[WorkToken], state: ParseState) -> None:
6986
# In place over the caller's token list, the way every other rule
7087
# in post_rules writes: a state-in/state-out spelling here cost
@@ -462,18 +479,7 @@ def post_rules(state: ParseState) -> ParseState:
462479
# middle_as_family fold below runs comma or not (v1 order:
463480
# patronymics first, then handle_middle_name_as_last)
464481
rules = state.policy.patronymic_rules
465-
# Both rotations RESTORE the given-first reading a family-first
466-
# listing hides, so rules.md#O1's scope clause holds them to the
467-
# default order: a caller who declared family-first has already
468-
# said what the rotation would infer, and position decides
469-
# (decisions.md#O1, the 2026-09-07 entry on #384). `state.order`,
470-
# not policy.name_order, for the reason the P1 fold above gives --
471-
# a script_orders entry can override the policy, and the roles the
472-
# rotations read are the ones assign actually made. None means
473-
# assign consulted no order at all, which is the default reading.
474-
read_order = state.order
475-
rotations_apply = state.structure is Structure.NO_COMMA and (
476-
read_order is None or read_order[0] is Role.GIVEN)
482+
rotations_apply = _rotations_apply(state)
477483
# rules.md#O1: "a name of exactly three name words — titles,
478484
# suffixes and nicknames aside — whose last name word carries a
479485
# patronymic ending and whose middle name word does not reads as

tests/v2/pipeline/test_post_rules.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,19 +123,25 @@ def test_no_swap_when_more_fields_present() -> None:
123123
#: word. This column is the negative control: it is the reading the
124124
#: two stand-down tests below exist to prevent, recorded as data and
125125
#: re-measurable (`pip install nameparser==2.2.0`) rather than left in
126-
#: a comment. Nothing asserts on it -- what the tests assert is the
127-
#: expected column -- but a failure quotes it, so a rotation that
128-
#: fires again says so in the message.
126+
#: a comment. The stand-down tests do not assert on it -- what they
127+
#: assert is the expected column -- but a failure quotes it, so a
128+
#: rotation that fires again says so in the message. The vacuity probe
129+
#: below DOES assert on it, which is what keeps this column honest.
130+
#: The key is (input, declared order): each input picks out its own
131+
#: pack, `Мицкевич` being East Slavic and `oglu` Turkic, so no pack
132+
#: identifier is needed to disambiguate a row.
129133
_PRE_GATE_FAMILY = {
130134
("Мицкевич Адам Юзеф", FAMILY_FIRST): "Адам",
131135
("Мицкевич Адам Юзеф", FAMILY_FIRST_GIVEN_LAST): "Юзеф",
136+
("Dr. Мицкевич Адам Юзеф", FAMILY_FIRST): "Адам",
137+
("Dr. Мицкевич Адам Юзеф", FAMILY_FIRST_GIVEN_LAST): "Юзеф",
132138
("oglu Ahmad Vali Ali", FAMILY_FIRST): "Ahmad",
133139
("oglu Ahmad Vali Ali", FAMILY_FIRST_GIVEN_LAST): "Ali",
134140
}
135141

136142

137143
def _assert_rotation_stood_down(pack: Policy, order: Policy, text: str,
138-
family: str) -> None:
144+
family: str, title: str = "") -> None:
139145
"""The pack opted in under `order` reads `text` exactly as `order`
140146
alone reads it, family included."""
141147
out = _parsed(text, dataclasses.replace(pack,
@@ -149,6 +155,7 @@ def _assert_rotation_stood_down(pack: Policy, order: Policy, text: str,
149155
f"the pack changed the reading; before the gate the 2.2.0 "
150156
f"wheel read family {pre_gate!r} here")
151157
assert _by_role(out, Role.FAMILY) == family
158+
assert _by_role(out, Role.TITLE) == title
152159

153160

154161
def test_east_slavic_rotation() -> None:
@@ -181,9 +188,18 @@ def test_east_slavic_skips_when_middle_is_also_patronymic() -> None:
181188
assert _by_role(out, Role.FAMILY) == "Abramovich"
182189

183190

191+
@pytest.mark.parametrize("text,title", [
192+
("Мицкевич Адам Юзеф", ""),
193+
# a peeled title must not move the shape the gate is claimed of:
194+
# rules.md#O1 counts name words "titles, suffixes and nicknames
195+
# aside", so the rotation still reaches this input pre-gate
196+
# (_PRE_GATE_FAMILY records the same family as the bare form) and
197+
# the title has to survive the stand-down
198+
("Dr. Мицкевич Адам Юзеф", "Dr."),
199+
])
184200
@pytest.mark.parametrize("order", _FAMILY_FIRST)
185201
def test_east_slavic_stands_down_under_a_declared_family_first_order(
186-
order: Policy) -> None:
202+
order: Policy, text: str, title: str) -> None:
187203
# rules.md#O1's scope clause (decisions.md#O1, 2026-09-07, #384):
188204
# the rotation RESTORES the default reading a family-first listing
189205
# hides, so a declared family-first order supplies that reading and
@@ -196,8 +212,7 @@ def test_east_slavic_stands_down_under_a_declared_family_first_order(
196212
# order class and the two place the given name differently: the
197213
# rotation rewrote a different word under each (_PRE_GATE_FAMILY),
198214
# so one order alone would leave the other's reading unpinned.
199-
_assert_rotation_stood_down(_ES, order, "Мицкевич Адам Юзеф",
200-
"Мицкевич")
215+
_assert_rotation_stood_down(_ES, order, text, "Мицкевич", title)
201216

202217

203218
def test_east_slavic_off_by_default() -> None:
@@ -226,6 +241,30 @@ def test_turkic_stands_down_under_a_declared_family_first_order(
226241
"oglu")
227242

228243

244+
def test_the_order_gate_is_what_stands_the_rotations_down(
245+
monkeypatch: pytest.MonkeyPatch) -> None:
246+
"""Reachability probe: with the gate patched back to the ungated
247+
condition, every stand-down input must rotate again, reading the
248+
family _PRE_GATE_FAMILY records from the 2.2.0 wheel. If this
249+
fails green-side, the stand-down rows above have gone vacuous --
250+
the rotation no longer reaches their inputs."""
251+
from nameparser._pipeline import _post_rules
252+
monkeypatch.setattr(
253+
_post_rules, "_rotations_apply",
254+
lambda state: state.structure is _post_rules.Structure.NO_COMMA)
255+
for pack, text in ((_ES, "Мицкевич Адам Юзеф"),
256+
(_ES, "Dr. Мицкевич Адам Юзеф"),
257+
(_TK, "oglu Ahmad Vali Ali")):
258+
for order in (_FF, _FFGL):
259+
policy = dataclasses.replace(pack,
260+
name_order=order.name_order)
261+
out = _parsed(text, policy)
262+
assert _by_role(out, Role.FAMILY) == \
263+
_PRE_GATE_FAMILY[text, policy.name_order], (
264+
f"{text!r} no longer reaches the rotation under "
265+
f"{policy.name_order}; the stand-down rows are vacuous")
266+
267+
229268
def test_leading_never_given_particle_folds_into_family() -> None:
230269
# v1 handle_non_first_name_prefix: a leading particle that is never
231270
# a given name ('de') means the whole name is a surname

tests/v2/test_locales.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from nameparser import Locale, Parser, locales, parse, parser_for
1515
from nameparser._lexicon import _VOCAB_FIELDS, Lexicon
1616
from nameparser._policy import (
17-
FAMILY_FIRST, UNSET, PatronymicRule, Policy, Script, _SCRIPT_RANGES,
17+
FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, UNSET, PatronymicRule, Policy,
18+
Script, _SCRIPT_RANGES,
1819
)
1920
from nameparser._types import AmbiguityKind
2021
from nameparser.locales import ja as _ja
@@ -773,6 +774,30 @@ def test_the_rotation_stands_down_under_a_declared_family_first_order(
773774
"Иван", "Петрович", "Сидоров")
774775

775776

777+
def test_the_turkic_marker_lands_in_a_name_field_under_both_orders(
778+
) -> None:
779+
"""Pins the prose decisions.md#O1's 2026-09-07 entry states for O2.
780+
781+
Gate-independent: `Ali Ahmad Vali oglu` is natural-order input,
782+
so the rotation would not fire on it under the default order
783+
either. What the two readings show is O2's own 2026-07-02
784+
Accepted consequence reached by a second route -- a declared
785+
family-first order leaves the marker standing in a name field.
786+
Under FAMILY_FIRST that costs nothing visible, the family being
787+
the word the rotation would have chosen anyway; under
788+
FAMILY_FIRST_GIVEN_LAST the marker becomes the GIVEN name, which
789+
is the reading rules.md#O2 carries as Accepted.
790+
"""
791+
ff = parser_for(locales.TR_AZ, base=Parser(
792+
policy=Policy(name_order=FAMILY_FIRST)))
793+
n = ff.parse("Ali Ahmad Vali oglu")
794+
assert (n.family, n.given, n.middle) == ("Ali", "Ahmad", "Vali oglu")
795+
ffgl = parser_for(locales.TR_AZ, base=Parser(
796+
policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST)))
797+
n = ffgl.parse("Ali Ahmad Vali oglu")
798+
assert (n.family, n.given, n.middle) == ("Ali", "oglu", "Ahmad Vali")
799+
800+
776801
def test_locales_import_is_lazy(monkeypatch: pytest.MonkeyPatch) -> None:
777802
# importing the package must not import any pack module; PEP 562
778803
# loads them on first attribute access (the lazy-access contract:

0 commit comments

Comments
 (0)