Skip to content

Commit 2a52a55

Browse files
derek73claude
andcommitted
Word a report's field from the word's final role (#626)
Five of assign's reports named the field their word was read into from the role it held at assign, and two later rules still move a word: H1 takes a lone unit behind a title into the family, and P6's attachment takes a trailing name word into the family at the end of a name read family-first. So `Attorney General of Minnesota` said 'General of Minnesota' was "read as a given name" (since 2.3.0), and `Kim Min Do` under FAMILY_FIRST said 'Do' was "read as a middle name", each with the word in the family. The decision stays at its site; the field's wording moves to assemble, which alone knows final roles. An emitter naming a field writes its sentence up to the field and passes the rest as PendingAmbiguity.field_tail; assemble joins them around FIELD_READING of the first referent's final role. All five emitters use it. Measured on master over 51,835 names under all three orders: 2,689 of 66,208 field claims named the wrong field; on the tree, 0. No role, kind, initials or rendering moves on the fingerprint; 40 details change, all title-or-name 'given' -> 'family'. Guarded by test_a_report_names_the_field_its_word_lands_in over every case row under all three orders (control: 10 failures on master). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 26cdb89 commit 2a52a55

11 files changed

Lines changed: 180 additions & 60 deletions

File tree

‎AGENTS.md‎

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

‎docs/design/decisions.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Entry conventions:
1818
- 2026-09-20 #397 (second review) — A REPORT MUST NOT CONTRADICT THE READING BESIDE IT, and the shape could not arise before this cycle. `classify`'s `conjunction-or-initial` fork offers exactly two readings and its detail says which it took ("it is read as an initial"). Where the rest of the parse then roles that letter SUFFIX, the fork resolved to NEITHER branch: `JOHN QUINCY SMITH I` carried the connective-or-initial beside a `suffix-or-name` saying the same token reads as a generational suffix, and `john smith i`, `HENRY I`, `SMITH, JOHN I`, `JOSEP CAROD I ROVIRA III` and `abdul e i` were the same. `i` is the first word that is both a MARKED connective and suffix vocabulary, so no parse before #397 could reach it — which is why the rule is written down now rather than having been.
1919
DECIDED: the report is WITHDRAWN where the role is SUFFIX or TITLE, in `_pipeline/_assemble.py`, the one place that sees final roles and the ambiguity list together — and beside a withdrawal that was already there, for a report whose referents did not survive assembly. That is the narrow reading of mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE rather than an exception to it: classify still owns the fork and still takes it where it is taken; what goes is a report whose subject the parse went on to read as something else. Emitting later was the alternative and was rejected as more invasive for the same answer — the fork's inputs (the case class, the own-words span) live in classify and moving the emitter moves them.
2020
MEASURED: over the review's 43,925-name grid under eight configurations, 9,806 rows on 1,637 distinct names lose exactly one `conjunction-or-initial` and nothing else — no report of any other kind moves and none is gained. On the deduped corpus-union-cases population the only name affected is `JOHN QUINCY SMITH I`, a row this round added, so the ledger's three-name `fix(#397) the Catalan link reports a connective-or-initial in a one-case name` rule is untouched: all three of its names role the letter MIDDLE and all three still report. The invariant is `tests/v2/test_properties.py::test_no_report_contradicts_the_reading_beside_it`, which states it twice — no connective-or-initial on a token roled SUFFIX or TITLE, and no token named by two reports whose details assert different readings — and fails on 8,204 rows at `dc3bdf9c`, 0 here.
21+
- 2026-10-08 #626 — A REPORT NAMES THE FIELD ITS WORD LANDS IN. Five of assign's reports worded the field from the role the word held at assign (`read as a given name`), and two rules still move a word after that: H1 moves a lone unit behind a title to the family, and P6's attachment takes a trailing name word into the family at the end of a name read family-first. So `Attorney General of Minnesota` reported 'General of Minnesota' "read as a given name by convention" with the unit in the family (since 2.3.0, #491's join clause), and `Kim Min Do` under FAMILY_FIRST told the caller 'Do' was "read as a middle name" with 'Do' in the family (this cycle, since #289 made the Title-case 'Do' a name word). #614's second review had fixed one instance of the class (`Freiherr von Berg Ma`) by moving its report to the stage that knows the outcome.
22+
DECIDED: the DECISION stays at its site and the WORDING of the field moves to assemble, the one place that knows final roles (the same reasoning as the 2026-09-20 withdrawal above). An emitter whose detail names a field writes the sentence up to the field and hands the rest as `PendingAmbiguity.field_tail`; assemble joins them around `FIELD_READING` of the first referent's final role. All five such emitters in assign use it, including the three that were right on every measured input (H4's all-suffix name, O5's lone word, the leading ambiguous particle), so no later rule can make them wrong. A placeholder in the detail was declined: token text can carry any characters, and a name containing the placeholder would corrupt its own report. Reports worded at a site whose field is fixed (a comma's credential reading, P6's own attachment report) keep their literal wording.
23+
MEASURED 2026-10-08 on master (26cdb891), each report's field phrase compared against its tokens' final roles: over a generated grid of 51,835 names (the corpus names plus heads × trailing words, comma shapes and random runs) under all three orders, 2,689 of 66,208 field claims named the wrong field, in two emitters, title-or-name's join branch (1,575) and the trailing peel's picks (1,114); on the tree, 0. Fingerprint against master, over the differential corpora, the case-table names and a generated comma grid (13,090 names) under six policies (the three orders, `lenient_comma_suffixes=False`, a `/` suffix delimiter and `CapsSuffixes.OFF`): no role, kind, initials or rendering moves; 40 details change, every one `read as a given name` → `read as a family name` on title-or-name, ten names under each of the four given-first policies. Pinned by `test_a_report_names_the_field_its_word_lands_in` in tests/v2/test_cases.py over every case row under all three orders, whose recorded control is 10 failures on master.
2124

2225
### A2 — the empty name
2326

‎docs/design/mechanisms.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Problem shape. A retired default rides in on old pickles, but a user may have de
114114

115115
## AMBIGUITY-AT-THE-DECISION-SITE — emit where the branch is taken
116116

117-
Problem shape. An ambiguity report should fire exactly when the parse chose between live readings — no more, no less — and the choosing happens in code, not in vocabulary. Contract statement. Emit at the site that takes the branch, not where an ambiguous tag sits; when a fork's two branches are decided in different stages, EVERY deciding stage carries an emitter; and a branch that runs but changes nothing is not a decision and must not report. How it works. PARTICLE_OR_GIVEN fires from assignment for a lone leading particle and from grouping when a title shifts it off the front — for two years only the first site emitted. And keying on
117+
Problem shape. An ambiguity report should fire exactly when the parse chose between live readings — no more, no less — and the choosing happens in code, not in vocabulary. Contract statement. Emit at the site that takes the branch, not where an ambiguous tag sits; when a fork's two branches are decided in different stages, EVERY deciding stage carries an emitter; and a branch that runs but changes nothing is not a decision and must not report. The decision is the site's, but a FIELD the report names is not: a later rule can still move the word, so the emitter leaves the field to assemble (`PendingAmbiguity.field_tail`), which words it from the final role (#626). How it works. PARTICLE_OR_GIVEN fires from assignment for a lone leading particle and from grouping when a title shifts it off the front — for two years only the first site emitted. And keying on
118118
"the code got here" instead of "the outcome differed" once reported
119119
a fork for all ambiguous particles on "Dr. Van Jr.". Lives in. The AmbiguityKind emitters across _pipeline/ (rule A1 is the observable contract). Reach for it when. Adding any ambiguous vocabulary or any new fork — count the deciding sites, then count the emitters.
120120

‎docs/design/rules.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2429,7 +2429,9 @@ A1. Rationale: a caller can only act on doubt that is reported.
24292429
contradicts: a letter both a connective and an initial, read as
24302430
the generation it also spells, is neither of the two the
24312431
connective-or-initial fork offered, and only that fork's report
2432-
goes — the generation's own stands.
2432+
goes — the generation's own stands. And a report that names the
2433+
field its word was read into names the field the word holds in
2434+
the result, whatever rule moved it after the report was made.
24332435
"Van Johnson" → ambiguities=("particle-or-given",)
24342436
"JOHN QUINCY SMITH I" → ambiguities=("suffix-or-name",)
24352437
"Jane „JD Smith" → ambiguities=("unbalanced-delimiter",)
@@ -2439,7 +2441,7 @@ A1. Rationale: a caller can only act on doubt that is reported.
24392441
segmenter's own error, which propagates — a user-code error is
24402442
not a content error. (Needs the optional extra to demonstrate,
24412443
so no example line.)
2442-
history: decisions.md#A1 · interacts: P3, S2 · implemented: nameparser/_pipeline/_assemble.py, nameparser/_pipeline/_state.py
2444+
history: decisions.md#A1 · interacts: H1, P3, P6, S2 · implemented: nameparser/_pipeline/_assemble.py, nameparser/_pipeline/_state.py
24432445

24442446
A2. Rationale: an input with no name content names nobody, and
24452447
saying so beats inventing fields from punctuation.

‎docs/release_log.rst‎

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

3737
- **Fix a comma part read wholly as suffixes reporting a particle in it as chained onto a name.** ``parse("John Smith, Jr., Freiherr von Richthofen").ambiguities`` names ``comma-structure`` alone, where 2.0 through 2.3 also named ``particle-or-given`` for ``von`` -- a word the same parse had put in the suffix, so the report described a reading it never made. This fix moves no field; the title change below (#603) moves ``Freiherr`` to the title. A part the parser consumes as suffixes, after a suffix comma or past the second comma, reports what the part is and nothing about its words as names, and the particle chain's credential-acronym report this release adds (above) keeps the same bound: ``John Smith, PhD Do Ma`` reports the comma's decision once and not again for ``Ma``. See the 2026-09-28 bullet of the ``C1`` entry in ``docs/design/decisions.md``
3838

39+
- **Fix an ambiguity report naming the field its word was first placed in rather than the one it ends up in.** ``parse("Attorney General of Minnesota").ambiguities`` says ``'General of Minnesota'`` was "read as a family name by convention", where 2.3.0 said "read as a given name" of a name whose family it was. A report that names a field now names the field the word holds in the result, whatever rule moved it after the report was made. This fix moves no field and no report kind. See the #626 entry under ``A1`` in ``docs/design/decisions.md`` (closes #626)
40+
3941
- **A credential after the name core starts a suffix run to the end of its part.** ``HumanName("John Smith PhD Jones")`` gives suffix ``PhD Jones`` and reports ``suffix-or-name`` for the name word it took, where 2.3.0 gave middle ``Smith PhD``, last ``Jones``; ``Smith, John PhD Jones`` gives suffix ``PhD Jones`` where 2.3.0 gave middle ``Jones``, suffix ``PhD``. A title in the run is a title, so ``Eric H. Holder Jr. Attorney General`` gives title ``Attorney General``, suffix ``Jr.``, as the comma spelling ``Eric H. Holder, Jr. Attorney General`` already read, where 2.3.0 gave middle ``H. Holder Jr. Attorney``, last ``General``. Only an unambiguous credential or generational word starts the run, and only behind the name core -- two name words with no comma, or the given part after one. A credential that is also a name does not start one, nor does a surname particle or a single letter, so ``John Smith MA Jones`` and ``Mohamed Ali Abd Allah`` read as before, and a bare title word starts nothing: ``Mary Jane King Smith`` keeps middle ``Jane King``. See the ``S2`` entry of ``docs/design/decisions.md`` (closes #602)
4042

4143
- **Fix a comma part that opens with a credential being read as a given name.** ``HumanName("John Smith, PhD Jones")`` gives first ``John``, last ``Smith``, suffix ``PhD Jones`` and reports ``suffix-or-name`` for the name word it took, where 2.3.0 gave first ``PhD``, middle ``Jones``, last ``John Smith`` (and 1.4.0 title ``PhD``, first ``Jones``). With one word before the comma the whole part is suffixes: ``Doe, PhD Jones`` gives last ``Doe``, suffix ``PhD Jones``. A title word in the part stays a title. A word that is a title as well as a credential (``MD``, ``Ms``) opens nothing, so ``Smith, Ms Jane`` keeps title ``Ms``, first ``Jane``. An undeclared delimiter becomes one more word of the part: ``Steven Hardman, RN - CRNA`` gives first ``Steven``, last ``Hardman``, suffix ``RN - CRNA``, where 1.4.0 through 2.3.0 gave first ``RN``, middle ``-``, last ``Steven Hardman``. The split spelling now counts like the joined one, so ``Smith, John Ph. D. Jones`` gives suffix ``Ph. D. Jones``, where 2.3.0 gave middle ``Jones``, suffix ``Ph. D.``. See the ``C1`` entry of ``docs/design/decisions.md`` (closes #603)

‎nameparser/_pipeline/_assemble.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Not a stage: converts the final ParseState into a public ParsedName.
22
3-
Consumes: tokens (all roles set), dropped, ambiguities (by index).
3+
Consumes: tokens (all roles set), dropped, ambiguities (by index;
4+
a report naming a field is worded from its word's final role, #626).
45
Produces: a validated ParsedName -- the constructor re-checks every
56
invariant (span order/bounds, ambiguity subset), so a pipeline bug
67
that would produce an invalid result dies HERE, not in a renderer
@@ -18,7 +19,7 @@
1819
"""
1920
from __future__ import annotations
2021

21-
from nameparser._pipeline._state import ParseState
22+
from nameparser._pipeline._state import FIELD_READING, ParseState
2223
from nameparser._types import (
2324
Ambiguity, AmbiguityKind, ParsedName, Role, Token,
2425
)
@@ -112,8 +113,11 @@ def assemble(state: ParseState) -> ParsedName:
112113
and any(t.role in (Role.SUFFIX, Role.TITLE)
113114
for t in materialized)):
114115
continue
115-
ambiguities.append(
116-
Ambiguity(pending.kind, pending.detail, materialized))
116+
detail = pending.detail
117+
if pending.field_tail is not None and materialized:
118+
detail = (f"{detail}{FIELD_READING[materialized[0].role]}"
119+
f"{pending.field_tail}")
120+
ambiguities.append(Ambiguity(pending.kind, detail, materialized))
117121
return ParsedName(original=state.original,
118122
tokens=tuple(final.values()),
119123
ambiguities=tuple(ambiguities))

0 commit comments

Comments
 (0)