Skip to content

Commit 96b4a9a

Browse files
derek73claude
andcommitted
Stripped invisibles are transparent to the interpunct flank guard
The B7 flank guard read text[i-1]/text[i+1] raw, so a policy-stripped invisible adjacent to the dot defeated it: parse("威廉‏·莎士比亚") left the literal dot in the family field, lost the order suppression, and the polluted token defeated the script license too. That input is the real-world case strip_bidi exists for -- RTL documents quoting Chinese transcriptions get U+200E/200F inserted at script transitions, in text visually identical to the clean form. The guard now walks each flank past characters the strip policy removes (bidi when strip_bidi, emoji when strip_emoji) before classifying: a character that vanishes from the token stream cannot occupy a flank position either. Whitespace, commas, and the name-dot separators remain guard-defeating -- a dot beside a space is not between characters -- and the region-local bounds are unchanged, so the pinned edge behavior stays. Also pins the guard's edge coverage the surviving mutants demanded: the trailing-dot upper bound (whose loss is an IndexError, not a wrong answer), the minimal one-char flanks, the one-sided flank (the AND, and that no spurious offset records), and hangul flanks (마틴·루터·킹 -- Korean writes interpunct transcriptions too). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9fdb3f2 commit 96b4a9a

2 files changed

Lines changed: 93 additions & 13 deletions

File tree

nameparser/_pipeline/_tokenize.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@
6565
_classified_char = _script_matcher(*_SCRIPT_RANGES, whole=True)
6666

6767

68+
def _is_emoji(ch: str) -> bool:
69+
cp = ord(ch)
70+
return any(lo <= cp <= hi for lo, hi in _EMOJI_RANGES)
71+
72+
73+
def _flank(text: str, i: int, step: int, limit: int,
74+
state: ParseState) -> str | None:
75+
"""The nearest flank character the strip policy would keep, or
76+
None at the region bound. Stripped invisibles (bidi, emoji) are
77+
TRANSPARENT here: they vanish from the token stream, so they must
78+
not occupy a flank position either -- an RTL document quoting a
79+
transcription puts U+200F beside the dot in visually identical
80+
text. Whitespace and the other separators stay guard-defeating:
81+
a dot beside a space is not between characters."""
82+
while i != limit:
83+
ch = text[i]
84+
if not (state.policy.strip_bidi and _BIDI.match(ch)) \
85+
and not (state.policy.strip_emoji and _is_emoji(ch)):
86+
return ch
87+
i += step
88+
return None
89+
90+
6891
def _ignorable(ch: str, state: ParseState) -> bool:
6992
if ch.isspace():
7093
return True
@@ -90,8 +113,7 @@ def _ignorable(ch: str, state: ParseState) -> bool:
90113
if state.policy.strip_bidi and _BIDI.match(ch):
91114
return True
92115
if state.policy.strip_emoji:
93-
cp = ord(ch)
94-
return any(lo <= cp <= hi for lo, hi in _EMOJI_RANGES)
116+
return _is_emoji(ch)
95117
return False
96118

97119

@@ -105,17 +127,23 @@ def _tokenize_region(state: ParseState, start: int, end: int,
105127
ch = text[i]
106128
is_separator = ch in COMMA_CHARS or _ignorable(ch, state)
107129
if not is_separator and ch == _INTERPUNCT:
108-
# flanks are region-local: a B7 at a region edge has a
109-
# masked or absent neighbor and stays token text.
110-
# Region-local on purpose and defensively: under the
111-
# default delimiters a masked span's edge character is
112-
# never classified, so the two bounds are indistinguishable
113-
# -- but a custom delimiter ending in a classified
114-
# character would otherwise let a B7 split and record
115-
# across a mask seam.
116-
is_separator = (start < i < end - 1
117-
and _classified_char(text[i - 1])
118-
and _classified_char(text[i + 1]))
130+
# flanks are region-local (the _flank walks stop at the
131+
# region bounds): a B7 at a region edge has a masked or
132+
# absent neighbor and stays token text. Region-local on
133+
# purpose and defensively: under the default delimiters a
134+
# masked span's edge character is never classified, so
135+
# the two bounds are indistinguishable -- but a custom
136+
# delimiter ending in a classified character would
137+
# otherwise let a B7 split and record across a mask seam.
138+
# The flank scan reads raw text like segmentation matching
139+
# does (#272's stance): NFD hangul degrades to no-split,
140+
# never wrong-split -- classification NFC-normalizes but
141+
# the guard does not.
142+
left = _flank(text, i - 1, -1, start - 1, state)
143+
right = _flank(text, i + 1, +1, end, state)
144+
is_separator = (left is not None and right is not None
145+
and _classified_char(left)
146+
and _classified_char(right))
119147
if is_separator:
120148
if tok_start is not None:
121149
tokens.append(WorkToken(text[tok_start:i],

tests/v2/pipeline/test_tokenize.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,58 @@ def test_interpunct_at_edges_stays() -> None:
139139
assert [t.text for t in state.tokens] == ["·威廉"]
140140

141141

142+
def test_stripped_bidi_mark_is_transparent_to_the_flank_guard() -> None:
143+
# an RTL document quoting a transcription: U+200F beside the dot,
144+
# visually identical to the clean form -- the stripped mark must
145+
# not occupy a flank position
146+
state = _tokenized("威廉‏·莎士比亚")
147+
assert [t.text for t in state.tokens] == ["威廉", "莎士比亚"]
148+
assert state.interpunct_offsets != ()
149+
150+
151+
def test_stripped_emoji_is_transparent_to_the_flank_guard() -> None:
152+
state = _tokenized("威廉\U0001f600·莎士比亚")
153+
assert [t.text for t in state.tokens] == ["威廉", "莎士比亚"]
154+
155+
156+
def test_unstripped_bidi_mark_defeats_the_guard() -> None:
157+
# the contrast: with strip_bidi off the mark is real token text,
158+
# an unclassified flank -- the dot stays interior
159+
state = _tokenized("威廉‏·莎士比亚",
160+
policy=Policy(strip_bidi=False))
161+
assert state.interpunct_offsets == ()
162+
163+
164+
def test_interpunct_trailing_edge_stays() -> None:
165+
# the upper bound: a trailing dot has no right flank -- and
166+
# dropping the bound is an IndexError, not a wrong answer
167+
state = _tokenized("威廉·")
168+
assert [t.text for t in state.tokens] == ["威廉·"]
169+
170+
171+
def test_interpunct_minimal_flanks_split() -> None:
172+
state = _tokenized("威·廉")
173+
assert [t.text for t in state.tokens] == ["威", "廉"]
174+
assert state.interpunct_offsets == (1,)
175+
176+
177+
def test_interpunct_one_sided_flank_stays() -> None:
178+
# the AND in the guard: one classified neighbor is not enough,
179+
# and no offset records (a spurious record would fake the
180+
# transcription marker downstream)
181+
state = _tokenized("Anna·威")
182+
assert [t.text for t in state.tokens] == ["Anna·威"]
183+
assert state.interpunct_offsets == ()
184+
185+
186+
def test_hangul_flanks_split_and_record() -> None:
187+
# Korean writes transcribed foreign names with the interpunct too
188+
# (마틴·루터·킹) -- hangul flanks are classified flanks
189+
state = _tokenized("김·민준")
190+
assert [t.text for t in state.tokens] == ["김", "민준"]
191+
assert state.interpunct_offsets == (1,)
192+
193+
142194
def test_interpunct_in_delimited_regions_splits_but_does_not_record() -> None:
143195
# a B7 inside a nickname must not mark the OUTER name as a
144196
# transcription -- same flag that keeps nickname commas out of

0 commit comments

Comments
 (0)