Skip to content

Commit 06bab72

Browse files
derek73claude
andcommitted
Fix the last two M12 bugs and lift the reconciliation skip list
- bound given names join freely in FAMILY_COMMA's post-comma segment (v1 reserve_last=False, parser.py:1366): 'salem, abdul salam' -> given 'abdul salam'. _group_segment takes the per-segment bound_required (3 main / 2 post-comma / 0 family segment, which v1 never bound-joined). - middle_as_family renders in v1's prepend order: folded tokens carry vocab:folded-middle and the family view orders them before the original family tokens (spans cannot reorder, anti-#100) -- 'Hassan, Mohamad Ahmad Ali' -> family 'Ahmad Ali Hassan', matching the no-comma and rotated forms. The stage-ownership contract gains tags for post_rules accordingly. With these, every v1 test file is reconciled: the conftest collect_ignore_glob is GONE (the mypy exclude went earlier). The full v1 suite runs against the facade: 1160 passed, 12 xfailed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fb524d8 commit 06bab72

6 files changed

Lines changed: 43 additions & 36 deletions

File tree

nameparser/_pipeline/_group.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def _is_rootname(piece: Sequence[int], ptags: Set[str],
7474

7575
def _group_segment(seg: tuple[int, ...], additional: int,
7676
tokens: Sequence[WorkToken],
77+
bound_required: int = 3,
7778
) -> tuple[list[Piece], list[set[str]]]:
7879
pieces: list[Piece] = [[i] for i in seg]
7980
ptags: list[set[str]] = [set() for _ in seg]
@@ -156,18 +157,23 @@ def merge(lo: int, hi: int, add: Set[str] = frozenset(),
156157
j += 1
157158
merge(k, j, drop={"prefix"})
158159
k += 1
159-
# bound given names: first non-title piece joins the next when
160-
# enough rootname pieces remain (v1 reserve_last)
160+
# bound given names: first non-title piece joins the next.
161+
# bound_required is v1's reserve_last: 3 keeps a family piece in
162+
# reserve (main segments); 2 joins freely (FAMILY_COMMA's
163+
# post-comma segment, v1 reserve_last=False -- 'salem, abdul
164+
# salam' -> given 'abdul salam'); 0 disables (family segment,
165+
# which v1 never bound-joined).
161166
first_name_k = next(
162167
(k for k in range(len(pieces)) if not title(k)), None)
163-
if (first_name_k is not None
168+
if (bound_required
169+
and first_name_k is not None
164170
and first_name_k + 1 < len(pieces)
165171
and len(pieces[first_name_k]) == 1
166172
and "vocab:bound-given"
167173
in tokens[pieces[first_name_k][0]].tags):
168174
non_suffix = sum(1 for k in range(len(pieces))
169175
if not title(k) and not suffix(k))
170-
if non_suffix >= 3:
176+
if non_suffix >= bound_required:
171177
merge(first_name_k, first_name_k + 2)
172178
return pieces, ptags
173179

@@ -186,8 +192,14 @@ def group(state: ParseState) -> ParseState:
186192
cores = delimiter_cores(state.policy.extra_suffix_delimiters)
187193
tail_start = {Structure.SUFFIX_COMMA: 1,
188194
Structure.FAMILY_COMMA: 2}.get(state.structure)
195+
family_comma = state.structure is Structure.FAMILY_COMMA
189196
for seg_idx, seg in enumerate(state.segments):
190-
pieces, ptags = _group_segment(seg, additional, tokens)
197+
if family_comma:
198+
bound_required = 2 if seg_idx == 1 else 0
199+
else:
200+
bound_required = 3
201+
pieces, ptags = _group_segment(seg, additional, tokens,
202+
bound_required)
191203
if tail_start is not None and seg_idx >= tail_start:
192204
# v1 renders each tail COMMA SEGMENT as one suffix entry
193205
# ('Smith, V MD' -> suffix 'V MD'); a delimiter core inside

nameparser/_pipeline/_post_rules.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,12 @@ def post_rules(state: ParseState) -> ParseState:
118118
_retag(tokens, f, Role.MIDDLE)
119119
_retag(tokens, g, Role.FAMILY)
120120
# rule 4: opt-in fold of middles into family (v1
121-
# handle_middle_name_as_last; span order reproduces v1's prepend)
121+
# handle_middle_name_as_last). v1 PREPENDED middle_list to
122+
# last_list; spans cannot reorder (anti-#100), so folded tokens
123+
# carry a tag and the family views order them first.
122124
if state.policy.middle_as_family:
123125
for i in _idx(tokens, Role.MIDDLE):
124-
_retag(tokens, i, Role.FAMILY)
126+
tokens[i] = dataclasses.replace(
127+
tokens[i], role=Role.FAMILY,
128+
tags=tokens[i].tags | {"vocab:folded-middle"})
125129
return dataclasses.replace(state, tokens=tuple(tokens))

nameparser/_types.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ def _text_for(self, *roles: Role, tag: str | None = None,
306306
without_tag: str | None = None) -> str:
307307
suffix_join = roles == (Role.SUFFIX,)
308308
parts: list[str] = []
309+
folded: list[str] = []
309310
for tok in self.tokens:
310311
if tok.role not in roles:
311312
continue
@@ -318,9 +319,13 @@ def _text_for(self, *roles: Role, tag: str | None = None,
318319
# view's ", " join does not split one credential in two
319320
if suffix_join and "joined" in tok.tags and parts:
320321
parts[-1] += " " + tok.text
322+
elif "vocab:folded-middle" in tok.tags:
323+
# middle_as_family fold: v1 PREPENDED middle_list to
324+
# last_list; spans cannot reorder, so the view does
325+
folded.append(tok.text)
321326
else:
322327
parts.append(tok.text)
323-
return (", " if suffix_join else " ").join(parts)
328+
return (", " if suffix_join else " ").join(folded + parts)
324329

325330
@property
326331
def title(self) -> str:

tests/conftest.py

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,6 @@
66

77
from nameparser.config import CONSTANTS
88

9-
# TEMPORARY (Task M12, final remnant): the whole v1 suite is reconciled
10-
# against the 2.0 facade and mypy-clean (the [tool.mypy] exclude twin in
11-
# pyproject.toml is gone). Only the two files below remain skipped, each
12-
# held on one confirmed, repro'd parity bug; delete this list (and the
13-
# entries' tests will run) once those land. Must be GONE before this
14-
# branch leaves draft.
15-
collect_ignore_glob = [
16-
# test_bound_first_names.py is reconciled (bucket A: the two
17-
# is_bound_first_name predicate tests died with the v1 hooks) but 1 test
18-
# fails on a join-guard bug: the FAMILY_COMMA bound-given join does not
19-
# fire when the join would consume the whole post-comma segment. Repro:
20-
# HumanName('salem, abdul salam') -> first='abdul', middle='salam'
21-
# (v1: first='abdul salam'; three-token 'salem, abdul salam ahmed'
22-
# joins fine). Remove once fixed.
23-
"test_bound_first_names.py",
24-
# test_middle_name_as_last.py needs NO bucket edits but 4 tests fail on
25-
# a fold-order bug: v1's middle_name_as_last fold PREPENDED middle_list
26-
# to last_list, so comma and rotated forms converge on the no-comma
27-
# result; v2 renders the folded family in token order. Repros:
28-
# HumanName('Hassan, Mohamad Ahmad Ali', constants=
29-
# Constants(middle_name_as_last=True)) -> last='Hassan Ahmad Ali'
30-
# (v1: 'Ahmad Ali Hassan');
31-
# with patronymic_name_order too: 'Ivanov Petr Sergeyevich' ->
32-
# last='Ivanov Sergeyevich' (v1: 'Sergeyevich Ivanov').
33-
# Remove once fixed.
34-
"test_middle_name_as_last.py",
35-
]
369

3710
# Scalar (non-collection) config attributes that individual tests mutate on the
3811
# global CONSTANTS singleton. Several tests change these without restoring them;

tests/v2/cases.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ class Case:
4141
{"given": "John", "family": "Smith"}),
4242
Case("suffix_comma", "John Smith, PhD",
4343
{"given": "John", "family": "Smith", "suffix": "PhD"}),
44+
Case("bound_given_whole_segment", "salem, abdul salam",
45+
{"given": "abdul salam", "family": "salem"},
46+
notes="v1 joins bound given names freely in the post-comma "
47+
"segment (reserve_last=False, parser.py:1366) -- even "
48+
"when the join consumes the whole segment"),
49+
Case("middle_as_family_fold_order", "Hassan, Mohamad Ahmad Ali",
50+
{"given": "Mohamad", "family": "Ahmad Ali Hassan"},
51+
policy=Policy(middle_as_family=True),
52+
notes="v1 PREPENDED middle_list to last_list; folded tokens "
53+
"carry vocab:folded-middle and the family view orders "
54+
"them first (spans cannot reorder)"),
4455
Case("ambiguous_surname_acronyms", "Jack Ma",
4556
{"given": "Jack", "family": "Ma"},
4657
notes="'ma'/'do' joined suffix_acronyms_ambiguous: common "

tests/v2/pipeline/test_state.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ def test_stage_field_ownership() -> None:
6363
"classify": {"tags"},
6464
"group": {"tags", "role"},
6565
"assign": {"role"},
66-
"post_rules": {"role"},
66+
# post_rules also tags: the middle_as_family fold marks folded
67+
# tokens vocab:folded-middle for the family view's prepend order
68+
"post_rules": {"role", "tags"},
6769
}
6870
for case in CASES:
6971
state = ParseState(original=case.text, lexicon=_Lexicon.default(),

0 commit comments

Comments
 (0)