Skip to content

Commit b0414a6

Browse files
derek73claude
andcommitted
Add the kana Script members and the effective_script kana license (#272)
Task 1 of the #272 Japanese-support plan: classification only, no order/segmentation defaults change. Script gains HIRAGANA (U+3040- U+309F) and KATAKANA (U+30A0-U+30FF, which includes the prolonged mark and the U+30FB middle dot). _vocab.effective_script extends single_script with the kana license from the 2026-07-29 amendment: a mixed Han/hiragana/katakana token is Japanese and resolves to the HIRAGANA carrier entry; pure-katakana keeps single_script's own answer since nothing defaults on it. Updated two pre-existing pins that this table change broke: the single_script test asserting kana was unclassified (now KATAKANA), and test_regex_sync's #271 differential-rule sync check, which now scopes its expected spans to the HAN/HANGUL scripts #271 actually governs rather than every _SCRIPT_RANGES entry, since #272 grows the same table for an unrelated reason. Quality-review fix-first pass, folded into this same commit: both classifiers now run on an NFC-normalized copy of the text, never the raw input. NFD decomposes precomposed katakana onto a base character plus a combining voiced/semi-voiced sound mark (U+3099/U+309A), which sit in the HIRAGANA block rather than katakana's -- classifying raw NFD text could therefore see a pure-katakana token as Han-free but kana-mixed and wrongly hand it the kana license (HIRAGANA) instead of declining it (KATAKANA). Separately, NFD decomposes Hangul syllables onto bare jamo (U+1100-U+11FF), entirely outside the HANGUL range, so raw NFD Korean input missed the shipped family-first order rule altogether and silently fell back to the positional default -- a live gap in #294's shipped behavior, not merely a #272 nicety. Both directions are demonstrated in tests/v2/pipeline/test_vocab.py by simulating the pre-fix classifier against NFD input built with unicodedata.normalize() (never pasted as decomposed literals): the pre-fix classifier returns None for both an NFD katakana and an NFD hangul string, and would have returned HIRAGANA (wrongly) for the raw NFD katakana case under a naive license check. Matching (is_initial, suffix lookups, etc.) deliberately stays on raw text elsewhere in the module -- NFD only ever costs a match there, never a wrong one, so that asymmetry is safe and unchanged. The normalized copy is used for classification only; token text and rendered spans are exactly what the caller wrote, still NFD if that's what came in (see the new test_nfd_korean_input_still_reads_family_first in test_parser.py, which compares NFC-normalized field values since the rendered family/ given text itself stays NFD). Also folded in from the same review: reworded the "no supplementary- plane kana to chase" claim (false -- 311 assigned codepoints exist; none are worth chasing, since they're archaic/phonetic-extension forms no modern name uses); added the halfwidth-kana exclusion rationale and the block-vs-UAX#24 clause to the range-table comment; corrected "kana-only" to "katakana-only" in effective_script's docstring (さくらエミ is kana-only and licensed); added a forward-pointer from single_script to effective_script; moved _JA_SCRIPTS/_JA_PATTERN up beside _SCRIPT_PATTERNS and changed _JA_SCRIPTS from a sorted frozenset to a plain table-ordered tuple (nothing uses membership); made HIRAGANA's Policy docstring self-contained; folded the temporal-named Script-enum test into test_script_values_are_the_public_names; and trimmed the Han/Hangul-duplicate assertions out of the kana single_script test (renamed test_kana_singles_classify). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c9d5a7f commit b0414a6

6 files changed

Lines changed: 216 additions & 22 deletions

File tree

nameparser/_pipeline/_vocab.py

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
import re
12+
import unicodedata
1213

1314
from nameparser._lexicon import Lexicon, _normalize
1415
from nameparser._policy import Script
@@ -42,17 +43,37 @@
4243
# 𠮷, U+20BB7), so leaving them out silently mis-orders those names;
4344
# unassigned gaps inside the span are harmless, since no real name
4445
# contains an unassigned codepoint. HANGUL: precomposed syllables
45-
# only -- modern Korean text never writes names as bare jamo. Kana is
46-
# DELIBERATELY absent: a kana token identifies Japanese, whose
47-
# conventions are #272's segmenter, not this table's. The ranges
48-
# below must stay mutually disjoint: single_script returns the FIRST
49-
# covering entry (dict iteration order), so an overlapping future
50-
# script (e.g. a ja entry that also covers Han) would make the result
51-
# order-dependent instead of well-defined.
46+
# only -- modern Korean text never writes names as bare jamo.
47+
# HIRAGANA/KATAKANA (#272): the two kana blocks, each in full. There
48+
# IS a supplementary-plane kana repertoire (Kana Supplement, Kana
49+
# Extended-A/B, Small Kana Extension, U+1AFF0-U+1B16F, 311 assigned
50+
# codepoints) but none of it is WORTH chasing the way Han's astral
51+
# block is: those codepoints are hentaigana and other archaic/
52+
# phonetic-extension forms no modern Japanese name uses, unlike
53+
# supplementary Han, which real surnames genuinely need. Halfwidth
54+
# kana (U+FF65-U+FF9D) is likewise deliberately excluded -- legacy
55+
# bank/CSV data uses it, but it is a separate normalization problem;
56+
# Task 2b's separator handling only touches the halfwidth DOT
57+
# (U+FF65), not the rest of that block. This table classifies by
58+
# Unicode BLOCK, not the UAX #24 Script property: U+30A0, U+30FB
59+
# (the middle dot), and U+30FC (the prolonged sound mark) all carry
60+
# Script=Common under UAX #24, and the combining kana voicing marks
61+
# U+3099-U+309C are Common/Inherited -- yet every one of them is
62+
# needed here, and block membership, not the Script property, is
63+
# what puts them in range. The katakana block's upper end (U+30FF)
64+
# including the middle dot U+30FB is load-bearing for
65+
# effective_script's kana license below (see its docstring) -- a
66+
# later task turns U+30FB into a tokenize-level separator, but until
67+
# then it classifies as ordinary katakana. The ranges below must stay
68+
# mutually disjoint: single_script returns the FIRST covering entry
69+
# (dict iteration order), so an overlapping future script would make
70+
# the result order-dependent instead of well-defined.
5271
_SCRIPT_RANGES: dict[Script, tuple[tuple[int, int], ...]] = {
5372
Script.HAN: ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF),
5473
(0x20000, 0x323AF)),
5574
Script.HANGUL: ((0xAC00, 0xD7A3),),
75+
Script.HIRAGANA: ((0x3040, 0x309F),),
76+
Script.KATAKANA: ((0x30A0, 0x30FF),),
5677
}
5778

5879
# Derived, never hand-written: one character class per script, in the
@@ -65,6 +86,21 @@
6586
for script, ranges in _SCRIPT_RANGES.items()
6687
}
6788

89+
#: The Japanese repertoire: the union effective_script's kana license
90+
#: quantifies over. A tuple in the table's own order (HAN, then
91+
#: HIRAGANA, then KATAKANA -- HANGUL simply omitted, the relative
92+
#: order of the rest is unchanged), not a frozenset: nothing consults
93+
#: membership today, only iterates to build the pattern below, so
94+
#: there is nothing set-ness would buy; a later task that needs
95+
#: membership can convert it then.
96+
_JA_SCRIPTS = (Script.HAN, Script.HIRAGANA, Script.KATAKANA)
97+
_JA_PATTERN = re.compile(
98+
"["
99+
+ "".join(f"\\U{lo:08x}-\\U{hi:08x}"
100+
for s in _JA_SCRIPTS
101+
for lo, hi in _SCRIPT_RANGES[s])
102+
+ "]+")
103+
68104

69105
def is_initial(text: str) -> bool:
70106
"""'A.' / 'j.' / bare capital -- v1's is_an_initial."""
@@ -162,18 +198,72 @@ def period_joined_vocab(text: str, lexicon: Lexicon) -> str | None:
162198
return None
163199

164200

201+
def _normalized_for_script(text: str) -> str | None:
202+
"""The guard AND the NFC normalization single_script and
203+
effective_script's license path both need, single-sourced so they
204+
cannot drift: None for the two shapes neither ever classifies
205+
(empty, and the common all-ASCII Latin token -- skipped before
206+
normalizing, since ASCII is already NFC and every _SCRIPT_RANGES
207+
entry is non-ASCII regardless), else an NFC-normalized copy.
208+
209+
NFC, not raw: NFD input decomposes precomposed katakana onto a
210+
base character plus a COMBINING mark (U+3099/U+309A, which sit in
211+
the HIRAGANA block, not katakana's), so classifying raw NFD text
212+
can hand a pure-katakana token the kana license by accident; NFD
213+
also decomposes Hangul syllables onto bare jamo (U+1100-U+11FF),
214+
entirely outside the HANGUL range, so raw NFD Korean input misses
215+
the shipped family-first order rule rather than merely misfiring.
216+
Normalizing first fixes both. This is classification-only and
217+
read-only: the returned copy is never what gets tokenized, so
218+
token text and spans stay exactly what the caller wrote.
219+
220+
MATCHING (is_initial, suffix lookups, etc.) deliberately stays on
221+
raw text elsewhere in this module -- unlike script classification,
222+
NFD only ever costs a match there (a suffix word written NFD fails
223+
to match its NFC vocabulary entry), never wrong-matches, so the
224+
asymmetry is safe: one direction needs a fix, the other doesn't.
225+
"""
226+
if not text or text.isascii():
227+
return None
228+
return unicodedata.normalize("NFC", text)
229+
230+
165231
def single_script(text: str) -> Script | None:
166232
"""The one Script whose ranges cover EVERY char of `text`, else
167233
None (mixed-script text has no well-defined convention to apply;
168-
the caller falls back to the positional default)."""
169-
if not text:
170-
return None # the + below needs one char; "" belongs to no script
171-
if text.isascii():
172-
# every _SCRIPT_RANGES entry is non-ASCII (lowest today is
173-
# U+3400): skip the patterns for the overwhelmingly common
174-
# Latin token (the _tokenize._ignorable ASCII-floor precedent)
234+
the caller falls back to the positional default). Classifies an
235+
NFC-normalized copy of `text` -- see _normalized_for_script.
236+
Callers wanting the kana-mixed license (a kanji+kana composite
237+
resolving to HIRAGANA) want effective_script, not this function."""
238+
normalized = _normalized_for_script(text)
239+
if normalized is None:
175240
return None
176241
for script, pattern in _SCRIPT_PATTERNS.items():
177-
if pattern.fullmatch(text):
242+
if pattern.fullmatch(normalized):
178243
return script
179244
return None
245+
246+
247+
def effective_script(text: str) -> Script | None:
248+
"""single_script, extended by the kana license (#272 amendment):
249+
a MIXED token wholly within Han∪hiragana∪katakana is Japanese --
250+
it necessarily contains kana (pure Han is not mixed), cannot be
251+
Chinese, and is not a foreign transcription (those are
252+
katakana-only: マイケル has no kanji, but さくらエミ -- hiragana
253+
plus katakana -- is kana-only AND licensed) -- and resolves to the
254+
HIRAGANA carrier entry. Pure-katakana stays KATAKANA
255+
(single_script's answer): a lone katakana token is predominantly a
256+
transcribed foreign name, so nothing defaults on it."""
257+
script = single_script(text)
258+
if script is not None:
259+
return script
260+
# normalized is None for both shapes _JA_PATTERN could never match
261+
# anyway (empty text, or the all-ASCII text single_script's fast
262+
# path already ruled out) -- real work, not a leftover "if text"
263+
# guard: unlike the pre-NFC version, None here also covers the
264+
# ASCII case, which single_script's own empty check alone would
265+
# not.
266+
normalized = _normalized_for_script(text)
267+
if normalized is not None and _JA_PATTERN.fullmatch(normalized):
268+
return Script.HIRAGANA
269+
return None

nameparser/_policy.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ class Script(StrEnum):
4545
HAN = "han"
4646
#: Korean Hangul (precomposed syllables). Unambiguously Korean.
4747
HANGUL = "hangul"
48+
#: Japanese hiragana. Never transcribes foreign names, so a mixed
49+
#: kanji+kana token (高橋みなみ) is Japanese and resolves HERE --
50+
#: this member is the carrier key in script_orders/segment_scripts.
51+
HIRAGANA = "hiragana"
52+
#: Japanese katakana. A PURE-katakana token is predominantly a
53+
#: transcribed foreign name in its original order (マイケル), so
54+
#: no default behavior keys on this member; it exists so the
55+
#: classifier can name what it deliberately declines.
56+
KATAKANA = "katakana"
4857

4958

5059
# Order-spec constants (#270). Each reads as its contents because roles

tests/v2/pipeline/test_vocab.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import unicodedata
2+
13
from nameparser._lexicon import Lexicon
24
from nameparser._pipeline._vocab import (
3-
_SCRIPT_RANGES, is_initial, is_suffix_lenient, is_suffix_strict,
4-
single_script,
5+
_SCRIPT_RANGES, effective_script, is_initial, is_suffix_lenient,
6+
is_suffix_strict, single_script,
57
)
68
from nameparser._policy import Script
79

@@ -51,7 +53,10 @@ def test_single_script_requires_every_char_in_one_script() -> None:
5153
assert single_script("Smith") is None
5254
assert single_script("毛zedong") is None # mixed
5355
assert single_script("毛김") is None # mixed CJK
54-
assert single_script("イチロー") is None # kana: not HAN
56+
# kana classifies as its own script (#272), not HAN -- was None
57+
# before kana had a table entry; single_script's job is telling
58+
# kana apart from Han, not lumping the two together
59+
assert single_script("イチロー") is Script.KATAKANA
5560

5661

5762
def test_single_script_range_edges() -> None:
@@ -82,3 +87,62 @@ def test_no_script_range_reaches_ascii() -> None:
8287
# it fails here rather than silently going unclassified.
8388
assert all(lo >= 0x80
8489
for ranges in _SCRIPT_RANGES.values() for lo, _ in ranges)
90+
91+
92+
def test_kana_singles_classify() -> None:
93+
assert single_script("みなみ") is Script.HIRAGANA
94+
assert single_script("エミ") is Script.KATAKANA
95+
assert single_script("ー") is Script.KATAKANA # prolonged mark, in-block
96+
97+
98+
def test_effective_script_kana_license() -> None:
99+
# pure single-script tokens pass through unchanged
100+
assert effective_script("山田") is Script.HAN
101+
assert effective_script("みなみ") is Script.HIRAGANA
102+
assert effective_script("マイケル") is Script.KATAKANA
103+
# the license: a MIXED token wholly in Han∪kana is Japanese and
104+
# resolves to the HIRAGANA carrier entry
105+
assert effective_script("高橋みなみ") is Script.HIRAGANA # kanji+hira
106+
assert effective_script("山田エミ") is Script.HIRAGANA # kanji+kata
107+
assert effective_script("さくらエミ") is Script.HIRAGANA # hira+kata
108+
# outside the license: anything beyond the JA repertoire
109+
assert effective_script("毛김") is None
110+
assert effective_script("山田x") is None
111+
# NOT None: U+30FB sits INSIDE the katakana block (verified by
112+
# codepoint), so this stays a PURE-katakana token, same as
113+
# "マイケル" above -- the license only ever fires on a MIXED
114+
# token, and this one isn't mixed. It has no order-default entry
115+
# (DEFAULT_SCRIPT_ORDERS carries no KATAKANA key), which is where
116+
# "declines the license" actually shows up. A later task turns
117+
# U+30FB into a tokenize-level separator, so this string arrives
118+
# at effective_script as two tokens instead of one.
119+
assert effective_script("マイケル・ジャクソン") is Script.KATAKANA
120+
assert effective_script("") is None
121+
122+
123+
def test_nfd_katakana_still_classifies_and_declines_the_license() -> None:
124+
# Built via normalize(), never pasted as decomposed literals --
125+
# NFD "ガガ" is base katakana カ + COMBINING VOICED SOUND MARK
126+
# (U+3099) twice; U+3099 sits in the HIRAGANA block, not
127+
# katakana's, so classifying raw NFD text would see one char from
128+
# each block and either call it mixed (single_script: None) or
129+
# wrongly grant the kana license (effective_script: HIRAGANA) for
130+
# what is really one pure-katakana token. NFC-normalizing first
131+
# (the #272 amendment's NFC decision) recomposes it back to ガガ,
132+
# which reads as ordinary katakana either way -- the license
133+
# still correctly declines, because this token isn't mixed.
134+
nfd = unicodedata.normalize("NFD", "ガガ")
135+
assert nfd != "ガガ" # sanity: confirms the decomposition actually ran
136+
assert single_script(nfd) is Script.KATAKANA
137+
assert effective_script(nfd) is Script.KATAKANA
138+
139+
140+
def test_nfd_hangul_still_classifies() -> None:
141+
# NFD decomposes each precomposed syllable onto 2-3 jamo
142+
# (U+1100-U+11FF), entirely outside the HANGUL range -- raw NFD
143+
# input would silently miss the shipped family-first order rule
144+
# rather than merely misclassify. Built via normalize(), not
145+
# pasted decomposed literals, same reason as above.
146+
nfd = unicodedata.normalize("NFD", "김민준")
147+
assert nfd != "김민준" # sanity: confirms the decomposition actually ran
148+
assert single_script(nfd) is Script.HANGUL

tests/v2/test_parser.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dataclasses
22
import pickle
3+
import unicodedata
34

45
import pytest
56

@@ -451,6 +452,21 @@ def test_wholly_cjk_names_read_family_first_by_default() -> None:
451452
assert parse("毛泽东").family == "毛泽东"
452453

453454

455+
def test_nfd_korean_input_still_reads_family_first() -> None:
456+
# fix(#271) classification, landed via the #272 NFC-classification
457+
# amendment: NFD decomposes each Hangul syllable onto bare jamo,
458+
# entirely outside the HANGUL range, so raw NFD input used to miss
459+
# script_orders' family-first rule and fall back to the positional
460+
# default -- a live gap in #294's shipped behavior until
461+
# single_script started classifying an NFC-normalized copy.
462+
n = parse(unicodedata.normalize("NFD", "김 민준"))
463+
# classification-only: the rendered text is exactly what was
464+
# typed (still NFD, spans untouched), so compare NFC-normalized --
465+
# the point under test is the ORDER (family first), not encoding
466+
assert (unicodedata.normalize("NFC", n.family),
467+
unicodedata.normalize("NFC", n.given)) == ("김", "민준")
468+
469+
454470
def test_latin_names_are_untouched_by_script_orders() -> None:
455471
n = parse("John Smith")
456472
assert (n.given, n.family) == ("John", "Smith")

tests/v2/test_policy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def test_name_order_rejects_plain_string_tuples() -> None:
6363

6464
def test_script_values_are_the_public_names() -> None:
6565
assert Script.HAN == "han" and Script.HANGUL == "hangul"
66+
assert Script.HIRAGANA == "hiragana" and Script.KATAKANA == "katakana"
6667

6768

6869
def test_patronymic_rules_coerce_and_reject() -> None:

tests/v2/test_regex_sync.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from nameparser.config import regexes as _config
2525
from nameparser._pipeline import _assign, _post_rules, _tokenize, _vocab
26+
from nameparser._policy import Script
2627
from nameparser import _render
2728

2829

@@ -114,6 +115,9 @@ def test_initial_copies_agree_with_each_other_and_config() -> None:
114115
("_tokenize", "_BIDI"): None, # re_bidi, not a REGEXES key
115116
# Mirrors _pipeline._state.COMMA_CHARS, not nameparser.config
116117
("_render", "_COMMA_CHAR"): None,
118+
# #272: derived from _SCRIPT_RANGES itself (like _SCRIPT_PATTERNS),
119+
# not hand-copied from anywhere -- no config counterpart to pin.
120+
("_vocab", "_JA_PATTERN"): None,
117121
}
118122

119123
_MODULES = {"_assign": _assign, "_post_rules": _post_rules,
@@ -176,8 +180,17 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
176180
Han's astral block is out of scope on both sides. The rule omits
177181
it deliberately -- no corpus name reaches it, see the comment
178182
there -- so the comparison runs over the BMP spans only, and a new
179-
BMP script added to _SCRIPT_RANGES still fails here until the rule
180-
covers it.
183+
BMP script added to _SCRIPT_RANGES for the SAME #271 behavior
184+
(family-first order / hangul segmentation) still fails here until
185+
the rule covers it.
186+
187+
#272 added HIRAGANA/KATAKANA to _SCRIPT_RANGES for an unrelated
188+
reason (kana classification for effective_script's license, not
189+
the order-flip or segmentation #271's diff rule explains -- no
190+
default keys on either kana member), so the comparison is scoped
191+
to the #271 scripts by name rather than every table entry; a
192+
future script added for #271's own reason still must extend both
193+
sides, same as before.
181194
"""
182195
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
183196
/ "expected_changes.toml")
@@ -191,8 +204,9 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
191204
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
192205
matched[0]["name_regex"])}
193206
expected = {span
194-
for spans in _vocab._SCRIPT_RANGES.values()
195-
for span in spans if span[1] <= 0xFFFF}
207+
for script in (Script.HAN, Script.HANGUL)
208+
for span in _vocab._SCRIPT_RANGES[script]
209+
if span[1] <= 0xFFFF}
196210
assert declared == expected, (
197211
f"{toml_path.name}'s #271 name_regex declares {sorted(declared)}; "
198212
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")

0 commit comments

Comments
 (0)