Skip to content

Commit c78adde

Browse files
derek73claude
andcommitted
Classify 〆 (U+3006) as Han
The one behavior change of this branch: the HAN entry's opening span widens from the 々 singleton (0x3005, 0x3005) to (0x3005, 0x3006), taking in 〆, the shime mark. Unlike 々 -- Script=Han under UAX #24, merely outside every CJK ideograph block -- 〆 carries Script=Common, so this is the table deliberately reaching PAST the Script property, not around a block boundary. It earns the reach: 〆 functions solely as a component of Japanese surnames (〆木 Shimeki, 〆谷 Shimetani, 〆野) and appears in no other script's names. Before the change 〆木 was a mixed-script token: unlicensed, positional, given-first -- the name reversed and never gated into segmentation. The widening also makes 〆木太郎 wholly Japanese to the ja pack's adapter, which would have handed it to namedivider -- whose 0.4.x rule path cuts every attested 〆 surname at offset 1 (〆 | 木太郎, score 1.0, algorithm='rule'; measured): a wrong family arriving with NO SEGMENTATION report, since 1.0 clears the floor. The adapter now declines 〆-bearing tokens before the divider is consulted (pinned stub-based in tests/v2/test_locales.py, no extra needed): the family-first ORDER fix stands regardless, and division can return when a divider knows the mark. The differential toml's character class is a hand copy of the table, and its sync guard tripped in between exactly as designed: test_differential_cjk_rule_matches_the_script_ranges failed with "expected_changes.toml's CJK name_regex declares [(12293, 12293), ...]; _SCRIPT_RANGES' BMP spans are [(12293, 12294), ...]" until the CJK rule's 々-々 was widened to 々-〆 to match. Differential harness re-run by hand: 654 corpus names, 18 intentional diffs, 0 unexplained, exit 0 -- neither corpus contains a 〆 name, so totals are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b265058 commit c78adde

7 files changed

Lines changed: 73 additions & 16 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Release Log
2222
- Fix names written wholly in Han or Hangul parsing given-first: native-script CJK now reads family-first by default, through the new ``Policy.script_orders`` table, so ``"毛 泽东"`` gives family ``毛`` where 1.x gave family ``泽东``. A name that is a single unspaced token moves the same way — ``"毛泽东"`` and ``"山田太郎"`` now land in ``family``/``last`` where 1.x put them in ``given``/``first``, with the string itself untouched, which makes it the easiest form of this change to miss — a lone token renders the same whichever field holds it, whereas the spaced form does show up in the output (``str(HumanName("毛 泽东"))`` is now ``"泽东 毛"``). No language detection is involved: Chinese and Japanese both write the family name first in native script, so the script settles the order without anyone having to know which language it is. Latin-script and mixed-script names are never affected, and an explicit comma still wins. **Default-on: changes parse output for wholly-CJK names**, through ``HumanName`` as well as the 2.0 API (closes #271)
2323
- Fix unspaced Korean names not splitting: the census surname list now ships as default vocabulary (``Lexicon.surnames``) with hangul segmentation on by default (``Policy.segment_scripts``), so ``"김민준"`` parses family ````, given ``민준`` where 1.x returned the whole string as ``first``. Rendering follows the split, so ``str(HumanName("김민준"))`` is now ``"민준 김"`` where 1.x echoed the input back unchanged. Nothing but Korean is written in hangul and its surnames are a closed census set, which is what makes the split safe as a default rather than a pack. **Default-on**, and it reaches ``HumanName`` too. ``Policy(segment_scripts=())`` turns the split off; ``Policy(script_orders={})`` separately restores the positional reading; clearing both restores 2.0 behavior exactly (#271)
2424
- Fix Japanese names carrying kana parsing given-first: the family-first rule above extends to any name whose characters stay within kanji and kana while carrying at least one kana character, so ``"高橋 みなみ"`` gives family ``高橋`` and ``"山田 エミ"`` family ``山田`` where 1.x read both the other way round, and a lone such token (``"高橋みなみ"``, ``"みなみ"``) lands in ``family``/``last`` where 1.x put it in ``given``/``first``. The reasoning is the one hangul already uses: hiragana never transcribes a foreign name, and a transcription is kana ALONE, so kanji-plus-kana is a Japanese person's name written in Japanese order. A name written **wholly in katakana** is deliberately excluded and stays positional — it is predominantly a transcribed foreign name (``"マイケル ジャクソン"``) already in given-first order. **Default-on: changes parse output for kana-bearing Japanese names**, through ``HumanName`` as well as the 2.0 API, and the spaced forms change what the name renders as (``str(HumanName("高橋 みなみ"))`` is now ``"みなみ 高橋"``). ``Policy(script_orders={})`` clears this entry along with the Han and Hangul ones (#272)
25+
- Fix names containing 〆 (U+3006, the shime mark that opens Japanese surnames like 〆木 and 〆谷) parsing given-first: the script classifier now counts 〆 as Han, extending the 々 entry the table already carries, and going a step further than it — 々 is Script=Han and merely outside the ideograph blocks, while 〆 is Script=Common — so these names take the East Asian family-first reading like any other wholly-Han name. ``Policy(script_orders={})`` restores the positional reading for these names exactly as for other wholly-Han names
2526
- Fix the katakana middle dot ``・`` (U+30FB, and its halfwidth twin U+FF65) being read as part of a name rather than as the divider it is: it now separates tokens exactly as a space does. A transcribed foreign name therefore divides into its parts and, being wholly katakana, keeps its source order — ``"マイケル・ジャクソン"`` gives given ``マイケル``, family ``ジャクソン``, where 1.x left the whole string in ``first`` — while a kanji pair written the same way takes the family-first rule (``"高橋・一郎"`` → family ``高橋``). Native Japanese names never contain this character and no other script's names use it, so the separation is unconditional, which also means it is **not** covered by the two policy opt-outs. Rendering follows, the way the Korean split's does: the dot comes back as a space, so ``str(HumanName("マイケル・ジャクソン"))`` is now ``"マイケル ジャクソン"``, and that reaches delimited content too — the nickname in ``"山田 太郎 (マイケル・ジャクソン)"`` renders ``"マイケル ジャクソン"``. The Chinese interpunct ``·`` is deliberately NOT included: U+00B7 is also the Catalan punt volat and appears inside legitimate names (``Gal·la``) (#272)
2627
- Fix NFD-decomposed input missing the East Asian defaults entirely: script classification now normalizes to NFC before deciding, so a Korean or Japanese name typed on macOS — where decomposed text is routine — gets the same order rule as its composed twin, which it silently did not before. Segmentation MATCHING deliberately stays raw, so an unspaced NFD hangul name is ordered correctly but not split, rather than being split in the wrong place. One gotcha worth stating plainly: parse output preserves the encoding it was given, so for NFD input ``name.family == "김"`` is ``False`` even though it is the same name — compare NFC-normalized text when comparing across encodings (#272)
2728

nameparser/_policy.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ class Script(StrEnum):
6666
# so the packs' predicates build on it too, through _script_matcher
6767
# below (the hand-copies this replaced dated from when the table
6868
# lived in the pipeline, which packs must not import).
69-
# HAN: the ideographic iteration mark U+3005, the URO plus Extension
69+
# HAN: the ideographic iteration mark U+3005 and the shime mark
70+
# U+3006, the URO plus Extension
7071
# A, the compatibility block, and the supplementary-plane block
7172
# (Ext B-I + CJK Compat Ideographs Supplement, 0x20000-0x323AF) --
7273
# rare surnames are the biggest real source of supplementary-plane
@@ -76,13 +77,21 @@ class Script(StrEnum):
7677
# U+3005 々 is the block-vs-Script case, running the OPPOSITE way to
7778
# U+30FB below: 々 already IS Script=Han under UAX #24 (Scripts.txt
7879
# reads `3005 ; Han`), but it sits in CJK Symbols and Punctuation,
79-
# outside every CJK ideograph block this table spans -- so the
80-
# singleton entry is what a BLOCK table needs to reach a character the
81-
# Script property would have classified correctly for free. It earns
82-
# the reach: 々 repeats the preceding kanji and appears only inside
83-
# Han-written names -- 佐々木 (Sasaki, a top-20 Japanese surname),
84-
# 野々村, 奈々. Omitting it made 佐々木 a mixed-script token: the name
85-
# reversed and never gated into segmentation.
80+
# outside every CJK ideograph block this table spans -- so a
81+
# singleton entry was what a BLOCK table needed to reach a character
82+
# the Script property would have classified correctly for free. It
83+
# earns the reach: 々 repeats the preceding kanji and appears only
84+
# inside Han-written names -- 佐々木 (Sasaki, a top-20 Japanese
85+
# surname), 野々村, 奈々. Omitting it made 佐々木 a mixed-script token:
86+
# the name reversed and never gated into segmentation.
87+
# U+3006 〆 (the shime mark) extends that singleton to a two-codepoint
88+
# span on a DIFFERENT justification: unlike 々, 〆 is Script=Common
89+
# under UAX #24, so this is the table deliberately reaching PAST the
90+
# Script property, not around a block boundary -- justified because 〆
91+
# functions solely as a component of Japanese surnames (〆木 Shimeki,
92+
# 〆谷 Shimetani, 〆野) and appears in no other script's names.
93+
# Leaving it out made 〆木 a mixed-script token: the name reversed and
94+
# never gated into segmentation.
8695
# HANGUL: precomposed syllables only -- modern Korean
8796
# text never writes names as bare jamo.
8897
# HIRAGANA/KATAKANA (#272): the two kana blocks, each in full. There
@@ -120,7 +129,7 @@ class Script(StrEnum):
120129
# future script would make the result order-dependent instead of
121130
# well-defined.
122131
_SCRIPT_RANGES: dict[Script, tuple[tuple[int, int], ...]] = {
123-
Script.HAN: ((0x3005, 0x3005), (0x3400, 0x4DBF), (0x4E00, 0x9FFF),
132+
Script.HAN: ((0x3005, 0x3006), (0x3400, 0x4DBF), (0x4E00, 0x9FFF),
124133
(0xF900, 0xFAFF), (0x20000, 0x323AF)),
125134
Script.HANGUL: ((0xAC00, 0xD7A3),),
126135
Script.HIRAGANA: ((0x3040, 0x309F),),

nameparser/locales/ja.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,10 @@ def ja_segmenter(*, gbdt: bool = False) -> Segmenter:
127127
matters for offline, sandboxed or air-gapped deployments.
128128
129129
The adapter declines -- returns None, leaving the token whole --
130-
for text outside the Japanese repertoire, for text too short to
131-
divide, for any answer that fails to reconstruct its input, and for
130+
for text outside the Japanese repertoire, for text bearing the
131+
shime mark 〆 (namedivider 0.4.x's rule path cuts it in the wrong
132+
place), for text too short to divide, for any answer that fails to
133+
reconstruct its input, and for
132134
any score outside [0, 1] by more than float noise (a divider
133135
scoring 87.0 is broken, and its division is worth no more than its
134136
score). An
@@ -168,6 +170,15 @@ def segment(text: str) -> Segmentation | None:
168170
# answer for it regardless -- the same union DEVIATES declares.
169171
if not _wholly_japanese(text):
170172
return None
173+
# namedivider 0.4.x has no knowledge of the shime mark: its
174+
# rule path cuts 〆木太郎 to 〆 | 木太郎 at confidence 1.0
175+
# (measured -- wrong for every attested 〆 surname: 〆木, 〆谷,
176+
# 〆野), so a wrong family would arrive with no SEGMENTATION
177+
# report. Decline instead: the family-first ORDER fix for 〆
178+
# stands regardless, and division can return when a divider
179+
# knows the mark.
180+
if "〆" in text:
181+
return None
171182
# namedivider RAISES below two characters ("Name length needs
172183
# at least 2 chars"), and a segmenter's exceptions propagate by
173184
# contract, so a one-character token -- routine in spaced input

tests/v2/cases.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,10 +619,25 @@ def __post_init__(self) -> None:
619619
notes="々 (U+3005, the ideographic iteration mark) repeats "
620620
"the preceding kanji and is Script=Han under UAX #24, "
621621
"but sits outside every CJK ideograph BLOCK -- and the "
622-
"classifier is a block table, so it needs a singleton "
622+
"classifier is a block table, so it needs its own "
623623
"entry to count as Han; without one 佐々木 -- a top-20 "
624624
"Japanese surname -- would be a mixed-script token and "
625625
"reverse"),
626+
Case("ja_shime_mark_is_han", "〆木 太郎",
627+
{"family": "〆木", "given": "太郎"},
628+
classification="fix(shime-mark)",
629+
notes="〆 (U+3006, the shime mark) opens real Japanese "
630+
"surnames -- 〆木 Shimeki, 〆谷 Shimetani, 〆野 -- but "
631+
"carries Script=Common under UAX #24; the table counts "
632+
"it as Han anyway (a deliberate step PAST the Script "
633+
"property, unlike 々's), else 〆木 is a mixed-script "
634+
"token and the name reverses"),
635+
Case("ja_shime_lone_token_takes_family", "〆木太郎",
636+
{"family": "〆木太郎"},
637+
classification="fix(shime-mark)",
638+
notes="wholly-Han lone token under the family-first entry: "
639+
"the whole token lands in family, unsegmented by "
640+
"default"),
626641
Case("ja_nakaguro_inside_a_nickname",
627642
"山田 太郎 (マイケル・ジャクソン)",
628643
{"family": "山田", "given": "太郎",

tests/v2/pipeline/test_vocab.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def test_script_ranges_are_pairwise_disjoint() -> None:
183183
# every entry of every script rather than script by script,
184184
# because the adjacent blocks are exactly where a future edit
185185
# would go wrong -- HIRAGANA ends at U+309F and KATAKANA opens at
186-
# U+30A0 with no gap at all, and HAN's singleton U+3005 sits just
186+
# U+30A0 with no gap at all, and HAN's U+3005-U+3006 span sits just
187187
# below both.
188188
spans = [(lo, hi, script)
189189
for script, ranges in _SCRIPT_RANGES.items()

tests/v2/test_locales.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,26 @@ def test_ja_adapter_guard_stack_against_a_stub(
269269
assert seg("林") is None # namedivider raises below 2
270270

271271

272+
def test_ja_adapter_declines_shime_tokens(
273+
monkeypatch: pytest.MonkeyPatch) -> None:
274+
# Since U+3006 classifies as Han, 〆木太郎 is wholly Japanese and
275+
# reaches the adapter -- but namedivider 0.4.x has no knowledge of
276+
# the shime mark: measured, its rule path cuts every attested 〆
277+
# surname at offset 1 (〆木太郎 -> 〆 | 木太郎, score 1.0,
278+
# algorithm='rule'), a wrong family arriving with NO SEGMENTATION
279+
# report because 1.0 clears the floor. The adapter declines these
280+
# tokens before the divider is ever consulted.
281+
calls: list[str] = []
282+
283+
def divide(text: str) -> _FakeDivided:
284+
calls.append(text)
285+
return _FakeDivided(text[:1], text[1:], 1.0)
286+
287+
_fake_namedivider(monkeypatch, divide)
288+
assert locales.ja_segmenter()("〆木太郎") is None
289+
assert calls == [] # the decline sits before divide_name
290+
291+
272292
def test_ja_adapter_defensive_branches_against_a_stub(
273293
monkeypatch: pytest.MonkeyPatch) -> None:
274294
# reconstruction failure -> decline; an empty side -> the stated

tools/differential/expected_changes.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ issue = "fix(#271/#272) native-script CJK: family-first order, hangul segmentati
5050
# tracked as #272's and #271's shared gap in issue #295, which also
5151
# records the provenance decision a fix needs; until it is closed the
5252
# behavioral guarantee lives in the fix(#271)/fix(#272) rows of
53-
# tests/v2/cases.py, and this rule is kept ready for the moment a CJK
54-
# name lands in a corpus.
55-
name_regex = "[\\u3005-\\u3005\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65]"
53+
# tests/v2/cases.py and the fix(shime-mark) rows, whose 〆 span rides
54+
# in on the same HAN entry, and this rule is kept ready for the moment
55+
# a CJK name lands in a corpus.
56+
name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65]"
5657
fields = ["first", "middle", "last"]
5758

5859
[[change]]

0 commit comments

Comments
 (0)