Skip to content

Commit cc7c6a8

Browse files
derek73claude
andcommitted
Point UNBALANCED_DELIMITER at the token it landed in
Last gap from the emitter audit. The kind carried no tokens, so the ambiguity was locatable only by parsing an offset back out of its detail string -- which is prose, not an API. The stray character does survive into a token ('"Nick', 'Smith)'); nothing was pointing at it. extract_delimited runs before tokens exist, so PendingAmbiguity gains an `origin` character offset that tokenize resolves to the containing token's index once the stream is built. Stages after tokenize set indices directly and leave origin None. Every emitted kind now carries its tokens: 'Jon "Nick Smith' unbalanced-delimiter ['"Nick'] 'John Smith)' unbalanced-delimiter ['Smith)'] 'Van Johnson' particle-or-given ['Van'] 'John Smith MA' suffix-or-family ['MA'] 'JEFFREY (JD) BRICKEN' suffix-or-nickname ['JD'] 'Smith, John, Extra, Jr.' comma-structure ['Extra'] The docstring's "May carry no tokens" is narrowed to what remains true: a character inside a masked region belongs to no token. test_stage_field_ownership caught tokenize writing a field it did not own before -- fixed, and classify's entry corrected in the same pass, since its SUFFIX_OR_NICKNAME emitter had the same omission and only passed because no case-table row triggers it yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3f0b754 commit cc7c6a8

6 files changed

Lines changed: 54 additions & 7 deletions

File tree

nameparser/_pipeline/_extract.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def _unmatched(open_: str, offset: int) -> tuple[int, PendingAmbiguity]:
104104
return (offset, PendingAmbiguity(
105105
AmbiguityKind.UNBALANCED_DELIMITER,
106106
f"unmatched {open_!r} at offset {offset}; treated as literal text",
107+
origin=offset,
107108
))
108109

109110

@@ -216,7 +217,7 @@ def extract_delimited(state: ParseState) -> ParseState:
216217
ambiguities.append(PendingAmbiguity(
217218
AmbiguityKind.UNBALANCED_DELIMITER,
218219
f"unmatched {close!r} at offset {j}; treated as "
219-
f"literal text"))
220+
f"literal text", origin=j))
220221
return dataclasses.replace(
221222
state, extracted=tuple(extracted), masked=tuple(masked),
222223
ambiguities=state.ambiguities + tuple(ambiguities))

nameparser/_pipeline/_state.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,18 @@ class Structure(Enum):
4747
@dataclass(frozen=True, slots=True)
4848
class PendingAmbiguity:
4949
"""An ambiguity recorded mid-pipeline by token INDEX; assemble
50-
materializes real Ambiguity objects over the final tokens."""
50+
materializes real Ambiguity objects over the final tokens.
51+
52+
``origin`` is for the one stage that runs BEFORE tokens exist:
53+
extract_delimited knows only a character offset, so it records that
54+
and tokenize resolves it to the containing token's index. Stages
55+
after tokenize set ``indices`` directly and leave ``origin`` None.
56+
"""
5157

5258
kind: AmbiguityKind
5359
detail: str
5460
indices: tuple[int, ...] = ()
61+
origin: int | None = None
5562

5663

5764
@dataclass(frozen=True, slots=True)

nameparser/_pipeline/_tokenize.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,19 @@ def tokenize(state: ParseState) -> ParseState:
9090
_tokenize_region(state, inner.start, inner.end, role, False,
9191
tokens, commas)
9292
tokens.sort(key=lambda t: t.span)
93+
# extract_delimited runs before tokens exist, so its ambiguities
94+
# carry a character offset instead of an index. Resolve them now
95+
# that the stray character has landed in a token ('"Nick', 'Smith)')
96+
# -- without this the ambiguity is locatable only by parsing the
97+
# offset back out of its detail string. An offset inside a masked
98+
# region belongs to no token; those keep an empty tuple, which the
99+
# kind's contract already allows.
100+
ambiguities = tuple(
101+
a if a.origin is None else dataclasses.replace(
102+
a, indices=tuple(
103+
i for i, t in enumerate(tokens)
104+
if t.span.start <= a.origin < t.span.end))
105+
for a in state.ambiguities)
93106
return dataclasses.replace(state, tokens=tuple(tokens),
94-
comma_offsets=tuple(sorted(commas)))
107+
comma_offsets=tuple(sorted(commas)),
108+
ambiguities=ambiguities)

nameparser/_types.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,10 @@ class AmbiguityKind(StrEnum):
252252
#: particle in other names.
253253
PARTICLE_OR_GIVEN = "particle-or-given"
254254
#: A nickname/maiden delimiter opened without closing (or closed
255-
#: without opening); the text was kept as literal name content.
256-
#: May carry no tokens.
255+
#: without opening); the text was kept as literal name content, so
256+
#: the tokens are the one the stray character ended up inside. Rare
257+
#: exception: a character that lands in no token at all (inside a
258+
#: masked region) leaves the tuple empty.
257259
UNBALANCED_DELIMITER = "unbalanced-delimiter"
258260
#: More comma-separated segments than any recognized name shape;
259261
#: the parse is best-effort over the extra segments.

tests/v2/pipeline/test_extract.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,20 @@ def test_unmatched_open_is_not_double_reported_as_a_close() -> None:
239239
# still produce exactly one ambiguity
240240
assert len(parse('John " Smith').ambiguities) == 1
241241
assert len(parse('Jon "Nick Smith').ambiguities) == 1
242+
243+
244+
@pytest.mark.parametrize(("text", "expected"), [
245+
('Jon "Nick Smith', '"Nick'),
246+
("John (Jack Smith", "(Jack"),
247+
("John Smith)", "Smith)"),
248+
("John Jack) Smith", "Jack)"),
249+
])
250+
def test_unbalanced_delimiter_points_at_its_token(
251+
text: str, expected: str
252+
) -> None:
253+
# the kind was documented as possibly carrying no tokens, but the
254+
# stray character does survive into one -- without it the ambiguity
255+
# is locatable only by parsing an offset back out of the detail
256+
# string, which is not an API
257+
(amb,) = parse(text).ambiguities
258+
assert [t.text for t in amb.tokens] == [expected]

tests/v2/pipeline/test_state.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,15 @@ def test_stage_field_ownership() -> None:
4646

4747
ownership = {
4848
"extract_delimited": {"extracted", "masked", "ambiguities"},
49-
"tokenize": {"tokens", "comma_offsets"},
49+
# tokenize also rewrites ambiguities: extract_delimited runs
50+
# before tokens exist, so its UNBALANCED_DELIMITER entries carry
51+
# a character offset that tokenize resolves to a token index
52+
"tokenize": {"tokens", "comma_offsets", "ambiguities"},
5053
"segment": {"segments", "structure", "ambiguities"},
51-
"classify": {"tokens"},
54+
# classify also emits SUFFIX_OR_NICKNAME: the delimiter escape
55+
# that decides it lives in extract_delimited, which has no token
56+
# index to point at, so the report is raised here instead
57+
"classify": {"tokens", "ambiguities"},
5258
"group": {"tokens", "pieces", "piece_tags", "dropped"},
5359
"assign": {"tokens", "ambiguities"},
5460
"post_rules": {"tokens"},

0 commit comments

Comments
 (0)