Skip to content

Commit d65dc00

Browse files
derek73claude
andcommitted
Simplify: single-source the strip set, auto-discover span pins, trim accretion
The strip-policy set is now defined ONCE (_stripped, shared by _ignorable and _flank), making the flank-transparency invariant structural: a new strip class added to the policy stays transparent to the interpunct guard by construction instead of silently re-opening the RTL-quote bug. _flank takes a range and the policy (three params instead of five; the bound arithmetic lives at the call sites where the region is in scope), and the right flank is now walked only after the left one classifies -- half the guard work on every Catalan punt-volat name. The dead 'not is_separator' conjunct on the interpunct test is gone (unfalsifiable: B7 is no comma, no space, and _ignorable cannot claim it). The differential span pins single-source through _SANCTIONED_EXTRAS, _declared_spans and _expected_bmp_spans, and the compound-rule pin auto-discovers every span-bearing rule that intersects the table instead of reading a hand-maintained slug roster whose own docstring admitted its failure mode ('or its span copy goes unpinned') -- a third compound rule is now pinned by existing. Review-round comment accretion trimmed to single homes: the nakaguro roster doctrine lives at _NAME_DOT_SEPARATORS (with the 高橋・一郎 example), the placement rationale at _INTERPUNCT, the suppression rationale in _effective_order's docstring. Two duplicate tests deleted: test_undotted_han_still_reads_family_first was byte-identical to test_script_order_applies_when_every_piece_is_one_script, and the assign-level nakaguro roster test restated the pre-existing ja_nakaguro_han_takes_the_han_order case row plus the tokenize-level non-recording pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c4ab1ca commit d65dc00

4 files changed

Lines changed: 123 additions & 124 deletions

File tree

nameparser/_pipeline/_assign.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ def _effective_order(policy: Policy,
8989
order governs the positional read; anything else -- Latin, mixed
9090
scripts, no entry -- falls back to name_order. A 间隔号-divided
9191
name (`dot_divided`, #298) suppresses the whole lookup first: the
92-
dot marks a transcription, which keeps name_order. Piece-level, after
92+
dot marks a transcription -- playing the role pure katakana plays
93+
in the kana license, orthography naming the convention -- so the
94+
license yields to name_order. Piece-level, after
9395
title/suffix peeling: 'Dr. 毛泽东' is a wholly-Han NAME under a
9496
Latin title. Kana-licensed tokens (高橋みなみ, #272) resolve to
9597
HIRAGANA the same way a wholly-Han or wholly-Hangul token resolves
@@ -107,13 +109,8 @@ def _effective_order(policy: Policy,
107109
resolves the SCRIPT for a single token. This function calls that
108110
one per token below.
109111
"""
110-
# #298: a 间隔号-divided name is a transcription -- the B7 plays
111-
# for Han exactly the role pure katakana plays in the kana license
112-
# (orthography naming the convention), so the family-first license
113-
# yields to the positional read. Checked before the script_orders
114-
# lookup: the suppression outranks the license. Codepoint-scoped:
115-
# only U+00B7 records (spec 2026-07-30 decision 5) -- the nakaguro
116-
# is roster typography whose pieces the license reads correctly.
112+
# #298 transcription marker -- see the docstring; codepoint-scoped
113+
# (only U+00B7 records, spec 2026-07-30 decision 5)
117114
if dot_divided:
118115
return policy.name_order
119116
if not policy.script_orders:

nameparser/_pipeline/_tokenize.py

Lines changed: 47 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@
1313
separators and never enter a token, so spans always index the
1414
original exactly as given. Whitespace and the name-dot are
1515
unconditional; emoji/bidi stripping alone is policy-gated
16-
(Policy.strip_emoji/strip_bidi). The Chinese interpunct U+00B7 is the
17-
one context-sensitive separator: it divides (and records) only
18-
between classified-script neighbors, so its rule lives in
19-
_tokenize_region, which has the index _ignorable lacks.
16+
(Policy.strip_emoji/strip_bidi). The Chinese interpunct U+00B7 is
17+
context-sensitive -- see _INTERPUNCT below.
2018
2119
v1's squash_emoji/squash_bidi REMOVED the char and joined neighbors
2220
('A\U0001f600B' -> 'AB'); here an ignorable char is a SEPARATOR
@@ -32,7 +30,7 @@
3230
from nameparser._pipeline._state import (
3331
COMMA_CHARS, ParseState, WorkToken,
3432
)
35-
from nameparser._policy import _SCRIPT_RANGES, _script_matcher
33+
from nameparser._policy import Policy, _SCRIPT_RANGES, _script_matcher
3634
from nameparser._types import Role, Span
3735

3836
# Ported from v1 (nameparser/config/regexes.py, "emoji" and "bidi") --
@@ -49,9 +47,11 @@
4947
# The katakana middle dot and its halfwidth twin divide the parts of
5048
# a foreign name transcribed into katakana (マイケル・ジャクソン) --
5149
# native names never contain them, so they separate unconditionally,
52-
# like whitespace (amendment 2026-07-29 section 1b). The Chinese
53-
# interpunct U+00B7 is context-sensitive instead -- see _INTERPUNCT
54-
# below.
50+
# like whitespace (amendment 2026-07-29 section 1b). They record no
51+
# offset: the nakaguro also divides kanji roster pairs (高橋・一郎,
52+
# 姓・名 -- read family-first by the script license, #272), so it is
53+
# not a transcription marker. Only the Chinese 间隔号 is (#298), and
54+
# it is context-sensitive -- see _INTERPUNCT below.
5555
_NAME_DOT_SEPARATORS = frozenset({"\u30FB", "\uFF65"})
5656

5757
_INTERPUNCT = "\u00B7"
@@ -70,21 +70,28 @@ def _is_emoji(ch: str) -> bool:
7070
return any(lo <= cp <= hi for lo, hi in _EMOJI_RANGES)
7171

7272

73-
def _flank(text: str, i: int, step: int, limit: int,
74-
state: ParseState) -> str | None:
73+
def _stripped(ch: str, policy: Policy) -> bool:
74+
"""True when the strip policy removes `ch` from the token stream.
75+
The ONE definition of that set, shared by _ignorable and _flank: a
76+
character that vanishes from tokens must not occupy a flank
77+
position either, so a new strip class added here stays transparent
78+
to the interpunct guard by construction."""
79+
if policy.strip_bidi and _BIDI.match(ch):
80+
return True
81+
return bool(policy.strip_emoji and _is_emoji(ch))
82+
83+
84+
def _flank(text: str, indices: range, policy: Policy) -> str | None:
7585
"""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:
86+
None if the range exhausts. Stripped invisibles are TRANSPARENT
87+
here: an RTL document quoting a transcription puts U+200F beside
88+
the dot in visually identical text. Whitespace and the other
89+
separators stay guard-defeating: a dot beside a space is not
90+
between characters."""
91+
for i in indices:
8392
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)):
93+
if not _stripped(ch, policy):
8694
return ch
87-
i += step
8895
return None
8996

9097

@@ -97,24 +104,14 @@ def _ignorable(ch: str, state: ParseState) -> bool:
97104
# skip the checks below for every ASCII letter
98105
return False
99106
# unconditional, like whitespace -- not policy-gated, so this sits
100-
# ahead of the policy checks rather than in _tokenize_region beside
101-
# COMMA_CHARS (commas RECORD an offset; these dots must not: the
102-
# nakaguro is Japanese roster/divider typography whose pieces the
103-
# script license already reads correctly -- #272, 高橋・一郎
104-
# family-first, マイケル・ジャクソン positional -- while only the
105-
# Chinese 间隔号 carries the transcription meaning and records,
106-
# #298, handled in _tokenize_region). The only load-bearing
107-
# constraint is "after the isascii fast path" (both dots are
108-
# non-ASCII); being ahead of the bidi/emoji checks below is NOT
109-
# load-bearing -- the three sets are disjoint, so a future edit is
110-
# free to reorder them.
107+
# ahead of the policy check rather than in _tokenize_region beside
108+
# COMMA_CHARS (commas RECORD an offset; these dots must not --
109+
# only the 间隔号 marks a transcription and records, #298, see
110+
# _NAME_DOT_SEPARATORS above). The only load-bearing constraint is
111+
# "after the isascii fast path" (both dots are non-ASCII).
111112
if ch in _NAME_DOT_SEPARATORS:
112113
return True
113-
if state.policy.strip_bidi and _BIDI.match(ch):
114-
return True
115-
if state.policy.strip_emoji:
116-
return _is_emoji(ch)
117-
return False
114+
return _stripped(ch, state.policy)
118115

119116

120117
def _tokenize_region(state: ParseState, start: int, end: int,
@@ -126,24 +123,20 @@ def _tokenize_region(state: ParseState, start: int, end: int,
126123
for i in range(start, end):
127124
ch = text[i]
128125
is_separator = ch in COMMA_CHARS or _ignorable(ch, state)
129-
if not is_separator and ch == _INTERPUNCT:
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))
126+
if ch == _INTERPUNCT:
127+
# Region-local flanks: a B7 at a region edge stays token
128+
# text, and the bound also stops a custom delimiter's
129+
# classified edge character from acting as a flank across
130+
# a mask seam. The scan reads raw text like segmentation
131+
# matching does (#272's stance): NFD hangul degrades to
132+
# no-split, never wrong-split -- classification
133+
# NFC-normalizes but the guard does not.
134+
left = _flank(text, range(i - 1, start - 1, -1),
135+
state.policy)
136+
if left is not None and _classified_char(left):
137+
right = _flank(text, range(i + 1, end), state.policy)
138+
is_separator = (right is not None
139+
and _classified_char(right))
147140
if is_separator:
148141
if tok_start is not None:
149142
tokens.append(WorkToken(text[tok_start:i],

tests/v2/pipeline/test_assign.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -228,21 +228,6 @@ def test_interpunct_suppression_yields_to_explicit_name_order() -> None:
228228
assert _by_role(out, Role.GIVEN) == "莎士比亚"
229229

230230

231-
def test_nakaguro_divided_kanji_keeps_the_roster_reading() -> None:
232-
# the Japanese dot is roster formatting (姓・名), NOT the
233-
# transcription marker -- #272's family-first reading stands
234-
# (codepoint scope, spec decision 5)
235-
out = _assigned("高橋・一郎")
236-
assert _by_role(out, Role.FAMILY) == "高橋"
237-
assert _by_role(out, Role.GIVEN) == "一郎"
238-
239-
240-
def test_undotted_han_still_reads_family_first() -> None:
241-
out = _assigned("毛 泽东")
242-
assert _by_role(out, Role.FAMILY) == "毛"
243-
assert _by_role(out, Role.GIVEN) == "泽东"
244-
245-
246231
def test_script_with_no_table_entry_falls_back() -> None:
247232
# A single, well-defined script the table simply does not list:
248233
# resolution must fall through to name_order rather than pick an

tests/v2/test_regex_sync.py

Lines changed: 71 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,33 @@ def test_comma_char_matches_the_pipeline_comma_set() -> None:
164164
assert set(_render._COMMA_CHAR.pattern.strip("[]")) == set(COMMA_CHARS)
165165

166166

167+
# The one sanctioned divergence between the differential rules'
168+
# character classes and _SCRIPT_RANGES: the halfwidth middle dot
169+
# separates tokens without being classified (halfwidth kana stays out
170+
# of the table on purpose). U+00B7 is deliberately NOT here -- its
171+
# flank guard means every name it can change matches through a
172+
# classified flanking character already. Single-sourced: both span
173+
# pins below read this set.
174+
_SANCTIONED_EXTRAS = frozenset({(0xFF65, 0xFF65)})
175+
176+
177+
def _declared_spans(name_regex: str) -> set[tuple[int, int]]:
178+
"""The \\uXXXX-\\uXXXX span pairs a rule's character class declares."""
179+
return {
180+
(int(lo, 16), int(hi, 16))
181+
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
182+
name_regex)}
183+
184+
185+
def _expected_bmp_spans() -> set[tuple[int, int]]:
186+
"""What a full CJK character class in the toml must declare: the
187+
table's BMP spans plus the sanctioned extras."""
188+
return {span
189+
for spans in _policy._SCRIPT_RANGES.values()
190+
for span in spans
191+
if span[1] <= 0xFFFF} | set(_SANCTIONED_EXTRAS)
192+
193+
167194
def test_differential_cjk_rule_matches_the_script_ranges() -> None:
168195
"""The CJK rule in tools/differential/expected_changes.toml hand-
169196
copies the script spans from _policy._SCRIPT_RANGES into a character
@@ -224,67 +251,64 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
224251
"a Script joined _SCRIPT_RANGES: decide whether the "
225252
f"differential rule in {toml_path.name} should cover it, then "
226253
"update this assertion")
227-
declared = {
228-
(int(lo, 16), int(hi, 16))
229-
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
230-
matched[0]["name_regex"])}
231-
# the two spans the rule carries that no Script claims (see above)
232-
sanctioned_extras = {(0xFF65, 0xFF65)}
233-
for xlo, xhi in sanctioned_extras:
254+
declared = _declared_spans(matched[0]["name_regex"])
255+
# the extras must stay UNclassified, or they belong in the table
256+
for xlo, xhi in _SANCTIONED_EXTRAS:
234257
assert not any(lo <= xhi and xlo <= hi
235258
for spans in _policy._SCRIPT_RANGES.values()
236259
for lo, hi in spans), (
237260
f"U+{xlo:04X}-U+{xhi:04X} is classified now; drop it "
238-
"from the sanctioned extras")
239-
expected = {span
240-
for spans in _policy._SCRIPT_RANGES.values()
241-
for span in spans
242-
if span[1] <= 0xFFFF} | sanctioned_extras
261+
"from _SANCTIONED_EXTRAS")
262+
expected = _expected_bmp_spans()
243263
assert declared == expected, (
244264
f"{toml_path.name}'s CJK name_regex declares {sorted(declared)}; "
245265
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")
246266

247267

248-
@pytest.mark.parametrize("slug", ["cjk-delimited-nickname",
249-
"cjk-comma-compound"])
250-
def test_differential_compound_rules_match_the_script_ranges(
251-
slug: str) -> None:
252-
"""The compound rules carry FURTHER hand copies of the script
253-
spans in the toml: each one's require-a-classified-codepoint
254-
lookahead exists so its trigger set alone (delimiters; a comma)
255-
cannot claim a Latin name's regression ('John 「Jack」 Kennedy',
256-
'Smith, Jr.' -- neither contains a classified character). Pin each
257-
copy to the table exactly as the canonical CJK rule's is, plus the
258-
nickname rule's delimiter set itself, so widening either is an
259-
explicit decision here rather than a silent absorption change.
260-
261-
Selection note for future rule authors: the canonical-CJK-rule pin
268+
def test_every_span_bearing_rule_matches_the_script_ranges() -> None:
269+
"""Auto-discovered pin for every FURTHER hand copy of the script
270+
spans in the toml: any rule whose character class declares spans
271+
intersecting _SCRIPT_RANGES must declare the whole expected class
272+
(table BMP spans + sanctioned extras). The compound rules'
273+
require-a-classified-codepoint lookaheads exist so their trigger
274+
sets alone (delimiters; a comma) cannot claim a Latin name's
275+
regression -- and each such lookahead is a copy nothing else
276+
checks. Discovery replaces a hand-maintained slug roster: a new
277+
compound rule's copy is pinned by existing here, not by an author
278+
remembering to enroll it. Rules whose spans touch OTHER scripts
279+
(Cyrillic, say) are out of scope and skipped by the intersection
280+
test.
281+
282+
Selection note for future rule authors: the canonical-rule pin
262283
above selects by the literal '#271'/'#272' substrings and asserts
263-
uniqueness -- the compound slugs avoid them on purpose, and any
264-
new rule's must too (and its slug joins this parametrize list, or
265-
its span copy goes unpinned).
284+
uniqueness -- compound slugs must avoid them.
266285
"""
267286
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
268287
/ "expected_changes.toml")
269288
rules = tomllib.loads(toml_path.read_text())["change"]
270-
matched = [r for r in rules if slug in r["issue"]]
271-
assert len(matched) == 1
272-
regex = matched[0]["name_regex"]
273-
if slug == "cjk-delimited-nickname":
274-
assert "[「」『』・・]" in regex, (
275-
"the compound rule's delimiter set changed; decide "
276-
"deliberately")
277-
declared = {
278-
(int(lo, 16), int(hi, 16))
279-
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
280-
regex)}
281-
expected = {span
282-
for spans in _policy._SCRIPT_RANGES.values()
283-
for span in spans
284-
if span[1] <= 0xFFFF} | {(0xFF65, 0xFF65)}
285-
assert declared == expected, (
286-
f"{slug}'s codepoint lookahead declares {sorted(declared)}; "
287-
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")
289+
table_spans = _expected_bmp_spans()
290+
checked = []
291+
for rule in rules:
292+
regex = rule.get("name_regex")
293+
if not isinstance(regex, str):
294+
continue
295+
declared = _declared_spans(regex)
296+
if not declared & table_spans:
297+
continue
298+
checked.append(rule["issue"])
299+
assert declared == table_spans, (
300+
f"{rule['issue']!r} declares {sorted(declared)}; expected "
301+
f"{sorted(table_spans)}")
302+
# the canonical rule plus both compound rules, today -- if this
303+
# count drops, a hand copy fell out of discovery's reach
304+
assert len(checked) >= 3, checked
305+
# the delimiter compound's trigger set is its own decision surface
306+
nickname = [r for r in rules
307+
if "cjk-delimited-nickname" in r["issue"]]
308+
assert len(nickname) == 1
309+
assert "[\u300C\u300D\u300E\u300F\u30FB\uFF65]" in nickname[0][
310+
"name_regex"] or "[「」『』・・]" in nickname[0]["name_regex"], (
311+
"the compound rule's delimiter set changed; decide deliberately")
288312

289313

290314
def test_cjk_corpus_matches_the_case_table() -> None:

0 commit comments

Comments
 (0)