Skip to content

Commit d993db0

Browse files
derek73claude
andcommitted
Report unmatched closing delimiters too
AmbiguityKind.UNBALANCED_DELIMITER has always documented itself as "a delimiter opened without closing (or closed without opening)", but only the first half existed. The scan is opener-driven -- it finds an open and looks rightward for its close -- so a close with nothing to its left was never in the search space: 'Jon "Nick Smith' -> unbalanced-delimiter 'John Smith)' -> (nothing) No design reason, just the direction of the walk. Both signal the same malformed input. Sweep for boundary-valid closes that no match consumed, reusing _close_ok -- which is what keeps apostrophes out of it. Verified on the 486-name corpus: "O'connor", "O'B.", "Queen's" all have close_ok=False because the apostrophe is mid-word, so none of them fire. Offsets already reported as unmatched OPENS are skipped, so a symmetric delimiter that satisfies both boundary tests still yields exactly one ambiguity ('John " Smith'). One corpus name newly reports: "Mari' Aube'", two word-final apostrophes. That is arguably correct -- a trailing apostrophe really is ambiguous between punctuation and a closing quote -- and the ambiguity is advisory, so the parse is unchanged (given "Mari'", family "Aube'"). Worth knowing it will be commoner on transliterated Arabic/Hebrew/Slavic data, where word-final apostrophes are ordinary. Found by auditing the existing emitters against the decision-site rule documented in the previous commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 49c67a2 commit d993db0

2 files changed

Lines changed: 61 additions & 3 deletions

File tree

nameparser/_pipeline/_extract.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,30 @@ def extract_delimited(state: ParseState) -> ParseState:
174174
# An unmatched-open candidate whose character was consumed by a
175175
# later successful match (the bulk pass above runs ahead of the
176176
# main scan) is literal content there, not a dangling delimiter.
177-
ambiguities = tuple(
177+
reported = {offset for offset, _ in unbalanced}
178+
ambiguities = [
178179
a for offset, a in unbalanced
179-
if not _overlaps(Span(offset, offset + 1), masked))
180+
if not _overlaps(Span(offset, offset + 1), masked)]
181+
# The scan above is opener-driven: it searches for an open and then
182+
# looks rightward for its close, so a close with no open to its
183+
# LEFT is never in its search space. Sweep for those separately --
184+
# they signal the same malformed input, and the kind's contract has
185+
# always covered them ("opened without closing, or closed without
186+
# opening"). Same boundary test as the matched path, which is what
187+
# keeps the apostrophe in "O'connor" out of it.
188+
for _, open_, close in order:
189+
start = 0
190+
while (j := text.find(close, start)) != -1:
191+
start = j + 1
192+
if (j in reported # already an open
193+
or not _close_ok(text, j, len(close))
194+
or _overlaps(Span(j, j + len(close)), masked)):
195+
continue
196+
reported.add(j)
197+
ambiguities.append(PendingAmbiguity(
198+
AmbiguityKind.UNBALANCED_DELIMITER,
199+
f"unmatched {close!r} at offset {j}; treated as "
200+
f"literal text"))
180201
return dataclasses.replace(
181202
state, extracted=tuple(extracted), masked=tuple(masked),
182-
ambiguities=state.ambiguities + ambiguities)
203+
ambiguities=state.ambiguities + tuple(ambiguities))

tests/v2/pipeline/test_extract.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import dataclasses
22

3+
import pytest
4+
5+
from nameparser import parse
36
from nameparser._lexicon import Lexicon
47
from nameparser._pipeline._extract import extract_delimited
58
from nameparser._pipeline._state import ParseState
@@ -179,3 +182,37 @@ def test_nested_delimiters_inner_scan_order_pins() -> None:
179182
# flow into the extracted span verbatim -- v1 parity, deliberate
180183
out = extract_delimited(_state('John ("Jack") Kim'))
181184
assert out.extracted == ((Role.NICKNAME, Span(6, 12)),)
185+
186+
187+
@pytest.mark.parametrize(("text", "char"), [
188+
("John Smith)", ")"),
189+
("John Jack) Smith", ")"),
190+
('John Smith"', '"'),
191+
("John Smith」", "」"), # a #273 typographic pair
192+
])
193+
def test_unmatched_close_is_reported(text: str, char: str) -> None:
194+
# the scan is opener-driven, so a close with no open to its left was
195+
# never in its search space and went unreported -- even though the
196+
# AmbiguityKind docstring promises "or closed without opening"
197+
kinds = [a.kind for a in parse(text).ambiguities]
198+
assert kinds == [AmbiguityKind.UNBALANCED_DELIMITER]
199+
assert char in parse(text).ambiguities[0].detail
200+
201+
202+
@pytest.mark.parametrize("text", [
203+
"Brian O'connor", # apostrophe mid-word, not a delimiter
204+
"O'B. John Smith",
205+
"The Queen's Bench",
206+
"John (Jack) Smith", # matched pair
207+
'John "Jack" Smith',
208+
])
209+
def test_no_spurious_unmatched_close(text: str) -> None:
210+
assert [a for a in parse(text).ambiguities
211+
if a.kind is AmbiguityKind.UNBALANCED_DELIMITER] == []
212+
213+
214+
def test_unmatched_open_is_not_double_reported_as_a_close() -> None:
215+
# a symmetric delimiter can satisfy both boundary tests; it must
216+
# still produce exactly one ambiguity
217+
assert len(parse('John " Smith').ambiguities) == 1
218+
assert len(parse('Jon "Nick Smith').ambiguities) == 1

0 commit comments

Comments
 (0)