Skip to content

Commit c4ab1ca

Browse files
derek73claude
andcommitted
Retighten the differential rule; pin the review-found gaps (#298)
The B7 sanctioned-extra span was a pure loss, removed: the flank guard guarantees every name the dot can change contains a classified character, so B7 names already matched the CJK rule through their flanks -- the span's only actual effect was letting the rule claim first/middle/last diffs on punt-volat Latin names (Gal·la Marcet), pre-excusing a regression on exactly the input class the guard protects. The sync test goes back to one sanctioned extra, and its overlap loop now checks whole spans instead of discarding each span's high end. Three case rows close review-found gaps: the Korean transcription 마이클·잭슨 (마 is a listed census surname, and the spaced form really does mis-split 마|이클 today -- the dot is what rescues it), the suffix-comma composition 威廉·莎士比亚, PhD, and the half-flanked 王·Smith parity row. The suffix-comma row's corpus entry surfaced a genuine compound diff -- comma routing (title to suffix) union the CJK flip (first/last) -- that neither single-change rule may claim, each excluding the other's fields on purpose; the new fix(cjk-comma-compound) rule takes exactly that union, gated by the same two-lookahead shape as the delimiter compound, and the compound sync pin is parametrized over both slugs so neither span copy goes unpinned. Docs: usage.rst and migrate.rst state the per-flank judgment (a hangul character beside a Han one qualifies), the state-global breadth (a dot anywhere reads the whole name as a transcription listing), and the half-flanked outcome (the undivided dot remains part of the word); AGENTS.md's doctrine paragraph counts five defaults and names the 间隔号 marker among them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 96b4a9a commit c4ab1ca

7 files changed

Lines changed: 110 additions & 56 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ logging.getLogger('HumanName').setLevel(logging.DEBUG)
7575

7676
The library has two layers: `nameparser/config/` (data) and `nameparser/parser.py` (logic).
7777

78-
**Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Four defaults fall out of it. Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=()`) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) needs the flanked-by-classified-script guard `_tokenize_region` gives it (#298). Han segmentation stays OPT-IN (`locales.ZH` for Chinese, `locales.JA` for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so `locales.JA` activates the stage and a pluggable `Parser(segmenter=...)` does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes.
78+
**Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Five defaults fall out of it. Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order. And the 间隔号 U+00B7 (#298) is the transcription marker for scripts that HAVE no transcription script: a name it divides (威廉·莎士比亚 — flanked by classified characters on both sides, so Catalan's Gal·la is untouched) keeps its source order and never segments — the orthography names the convention, exactly as pure katakana does, with the divider carrying the signal instead of the script. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=()`) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) needs the flanked-by-classified-script guard `_tokenize_region` gives it (#298). Han segmentation stays OPT-IN (`locales.ZH` for Chinese, `locales.JA` for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so `locales.JA` activates the stage and a pluggable `Parser(segmenter=...)` does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes.
7979

8080
### Configuration layer (`nameparser/config/`)
8181

docs/migrate.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,8 +431,9 @@ surface is frozen for 2.x — so the way out is the 2.0 API:
431431
restores 1.4's reading of every shape above that turns on order or
432432
splitting. The middle dots are the exception: both the katakana dot
433433
and the Chinese interpunct ``·`` (U+00B7, dividing a transcription
434-
like ``威廉·莎士比亚``, and a separator only between characters of a
435-
classified script where the katakana dot is unconditional) are decided
434+
like ``威廉·莎士比亚``, and a separator only between classified-script
435+
characters — each side judged on its own — where the katakana dot is
436+
unconditional) are decided
436437
in tokenization rather than by policy, so a name written with either
437438
still divides at the dot, and still renders with a space, whatever
438439
those two fields are set to.

docs/usage.rst

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -193,15 +193,18 @@ punctuation: a foreign name transcribed into Han characters keeps its
193193
source order and divides its parts with the 间隔号, the interpunct
194194
``·`` (U+00B7) — 威廉·莎士比亚 is William Shakespeare, given name
195195
first. The dot itself is the marker, so nameparser reads U+00B7 as a
196-
token separator when it sits between characters of a classified
197-
script, and a name it divides keeps the order it was written in and is
196+
token separator when it sits between classified-script characters
197+
(each side judged on its own — a hangul character beside a Han one
198+
qualifies), and a dot anywhere in the name reads the whole name as a
199+
transcription listing: it keeps the order it was written in and is
198200
never segmented — the role pure katakana plays for Japanese
199201
transcriptions, played here by the divider instead of the script.
200-
Because only a classified script on both sides makes it a divider, the
201-
same codepoint interior to a Latin-script name — the Catalan punt
202-
volat in ``Gal·la`` — is untouched. The Japanese middle dot ・
203-
(covered next) is a different mark carrying a different convention,
204-
and keeps its own reading.
202+
Because only classified characters on both sides make it a divider,
203+
the same codepoint interior to a Latin-script name — the Catalan punt
204+
volat in ``Gal·la`` — is untouched, and a dot with a classified
205+
character on just one side (``王·Smith``) stays part of the word
206+
undivided. The Japanese middle dot ・ (covered next) is a different
207+
mark carrying a different convention, and keeps its own reading.
205208

206209
Japanese
207210
~~~~~~~~~

tests/v2/cases.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,4 +688,23 @@ def __post_init__(self) -> None:
688688
notes="马 is a listed zh surname, but a 间隔号-divided name "
689689
"is a transcription: the dot gates segmentation off, "
690690
"so 马丁 stays whole and the positional read stands"),
691+
Case("ko_interpunct_transcription_source_order", "마이클·잭슨",
692+
{"given": "마이클", "family": "잭슨"},
693+
classification="fix(#298)",
694+
notes="Korean writes transcribed foreign names with the "
695+
"interpunct too: the dot suppresses the hangul "
696+
"family-first entry AND the default segmentation -- 마 "
697+
"is a listed census surname, and the spaced form "
698+
"마이클 잭슨 really does mis-split 마|이클 today, so "
699+
"the dot is what rescues the ko transcription"),
700+
Case("zh_interpunct_with_suffix_comma", "威廉·莎士比亚, PhD",
701+
{"given": "威廉", "family": "莎士比亚", "suffix": "PhD"},
702+
classification="fix(#298)",
703+
notes="the transcription reading composes with a suffix "
704+
"comma: the marker is structure-independent"),
705+
Case("zh_interpunct_half_flanked_stays", "王·Smith",
706+
{"given": "王·Smith"},
707+
notes="one classified neighbor is not enough: the guard "
708+
"requires both, so the undivided dot remains part of "
709+
"the word -- declining, not deciding"),
691710
)

tests/v2/test_regex_sync.py

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -192,20 +192,22 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
192192
it, see the comment there -- so the comparison runs over the BMP
193193
spans only.
194194
195-
The rule is also WIDER than the table by exactly two spans, which
195+
The rule is also WIDER than the table by exactly one span, which
196196
the equality has to know about or it would just fail forever. The
197197
halfwidth middle dot U+FF65 changes parses without being
198198
classified as anything: tokenize separates on it, so a halfwidth
199199
transcription splits where 1.4 kept one token, while halfwidth
200200
kana stays out of _SCRIPT_RANGES on purpose. U+00B7 -- the
201-
context-sensitive 间隔号 (#298) -- is the same situation: it
202-
divides between classified characters and pins source order,
203-
changing parses, yet doubles as Catalan's punt volat and so can
204-
never itself be classified. Naming those spans here rather than
205-
relaxing the comparison to a subset check is what keeps the pin
206-
honest in both directions: an unsanctioned source of divergence
207-
still fails, and each sanctioned difference has to be written
208-
down to exist.
201+
context-sensitive 间隔号 (#298) -- also changes parses yet is
202+
deliberately NOT an extra: its flank guard means every name it
203+
can change matches the class through a flanking character
204+
already, and a B7 span's only actual effect would be letting the
205+
rule claim diffs on punt-volat Latin names (Gal·la), pre-excusing
206+
a regression on exactly the guarded class. Naming the sanctioned
207+
span here rather than relaxing the comparison to a subset check
208+
is what keeps the pin honest in both directions: an unsanctioned
209+
source of divergence still fails, and each sanctioned difference
210+
has to be written down to exist.
209211
"""
210212
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
211213
/ "expected_changes.toml")
@@ -227,13 +229,13 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
227229
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
228230
matched[0]["name_regex"])}
229231
# the two spans the rule carries that no Script claims (see above)
230-
sanctioned_extras = {(0xFF65, 0xFF65), (0xB7, 0xB7)}
231-
for extra, _ in sorted(sanctioned_extras):
232-
assert not any(lo <= extra <= hi
232+
sanctioned_extras = {(0xFF65, 0xFF65)}
233+
for xlo, xhi in sanctioned_extras:
234+
assert not any(lo <= xhi and xlo <= hi
233235
for spans in _policy._SCRIPT_RANGES.values()
234236
for lo, hi in spans), (
235-
f"U+{extra:04X} is classified now; drop it from the "
236-
"sanctioned extras")
237+
f"U+{xlo:04X}-U+{xhi:04X} is classified now; drop it "
238+
"from the sanctioned extras")
237239
expected = {span
238240
for spans in _policy._SCRIPT_RANGES.values()
239241
for span in spans
@@ -243,29 +245,35 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
243245
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")
244246

245247

246-
def test_differential_compound_rule_matches_the_script_ranges() -> None:
247-
"""The fix(cjk-delimited-nickname) rule (#295) carries the SECOND
248-
hand copy of the script spans in the toml: its require-a-classified-
249-
codepoint lookahead exists so the delimiter set alone cannot claim a
250-
Latin name's first/last regression ('John 「Jack」 Kennedy' -- the
251-
corner brackets sit outside every classified span). Pin that copy to
252-
the table exactly as the canonical CJK rule's is, and pin the
253-
delimiter set itself, so widening either (to ASCII quotes, say) is
254-
an explicit decision here rather than a silent absorption change.
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.
255260
256261
Selection note for future rule authors: the canonical-CJK-rule pin
257262
above selects by the literal '#271'/'#272' substrings and asserts
258-
uniqueness -- this rule's slug avoids them on purpose, and any new
259-
rule's must too.
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).
260266
"""
261267
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
262268
/ "expected_changes.toml")
263269
rules = tomllib.loads(toml_path.read_text())["change"]
264-
matched = [r for r in rules if "cjk-delimited-nickname" in r["issue"]]
270+
matched = [r for r in rules if slug in r["issue"]]
265271
assert len(matched) == 1
266272
regex = matched[0]["name_regex"]
267-
assert "[「」『』・・]" in regex, (
268-
"the compound rule's delimiter set changed; decide deliberately")
273+
if slug == "cjk-delimited-nickname":
274+
assert "[「」『』・・]" in regex, (
275+
"the compound rule's delimiter set changed; decide "
276+
"deliberately")
269277
declared = {
270278
(int(lo, 16), int(hi, 16))
271279
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
@@ -275,7 +283,7 @@ def test_differential_compound_rule_matches_the_script_ranges() -> None:
275283
for span in spans
276284
if span[1] <= 0xFFFF} | {(0xFF65, 0xFF65)}
277285
assert declared == expected, (
278-
f"compound rule's codepoint lookahead declares {sorted(declared)}; "
286+
f"{slug}'s codepoint lookahead declares {sorted(declared)}; "
279287
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")
280288

281289

tools/differential/corpus_cjk.jsonl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"司马相如"
1313
"夏侯惇"
1414
"威廉·莎士比亚"
15+
"威廉·莎士比亚, PhD"
1516
"威廉・莎士比亚"
1617
"山田 エミ"
1718
"山田 太郎 (マイケル・ジャクソン)"
@@ -20,6 +21,7 @@
2021
"毛 泽东"
2122
"毛 김"
2223
"毛泽东"
24+
"王·Smith"
2325
"田中『ハナ』花子"
2426
"諸葛亮"
2527
"阿明"
@@ -33,3 +35,4 @@
3335
"남궁"
3436
"남궁민수"
3537
"남궁민수, 지훈"
38+
"마이클·잭슨"

0 commit comments

Comments
 (0)