Skip to content

Commit 96832ce

Browse files
derek73claude
andcommitted
Pin the interpunct behavior: case rows, corpus, differential guards (#298)
Five case rows land in tests/v2/cases.py, in the table's canonical given/family/middle spelling: - zh_interpunct_transcription_source_order: 威廉·莎士比亚 divides on the B7 and keeps source order (fix(#298)). - zh_interpunct_nakaguro_typed_stays_roster: the SAME transcription typed with the Japanese nakaguro reads as a 姓・名 roster pair, family-first (fix(#272)). A limitation row, chosen not accidental: the design is codepoint-scoped (spec 2026-07-30 decision 5), so cross-convention input reads by the convention of the codepoint it was typed with -- only the Chinese B7 marks a transcription. - ja_interpunct_b7_katakana: sloppy-IME B7 between katakana divides like the nakaguro; order was already positional (fix(#298)). - latin_punt_volat_is_name_interior: Gal·la Marcet, the flank guard's reason -- Latin-flanked B7 is interior, never a divider (parity). - zh_interpunct_suppresses_segmentation: 马丁·路德·金 under zh -- the dot gates segmentation off, 马丁 stays whole (fix(#298)). The differential CJK rule's character class gains U+00B7, the second sanctioned extra beside U+FF65: both dots change parses without being classified by any Script (B7 doubles as Catalan's punt volat). test_differential_cjk_rule_matches_the_script_ranges pins the pair as sanctioned_extras; the compound rule's lookahead copies classified spans only and is untouched. corpus_cjk.jsonl regenerates 31 -> 35: the four CJK-bearing rows enter (Gal·la Marcet correctly does not -- no classified codepoint). Harness hand-run: 689 names, 48 intentional diffs, zero unexplained; the CJK rule classifies 27 including all the B7 names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6752667 commit 96832ce

4 files changed

Lines changed: 77 additions & 25 deletions

File tree

tests/v2/cases.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,4 +651,41 @@ def __post_init__(self) -> None:
651651
notes="delimited content tokenizes under the same separator "
652652
"rules: the dot renders back as a space in the nickname "
653653
"join -- a decision, not an accident"),
654+
Case("zh_interpunct_transcription_source_order", "威廉·莎士比亚",
655+
{"given": "威廉", "family": "莎士比亚"},
656+
classification="fix(#298)",
657+
notes="间隔号-divided Han is a transcribed foreign name and "
658+
"keeps source order -- the B7 is the transcription "
659+
"marker, playing the role pure katakana plays in the "
660+
"kana license; it divides only between classified "
661+
"characters"),
662+
Case("zh_interpunct_nakaguro_typed_stays_roster", "威廉・莎士比亚",
663+
{"given": "莎士比亚", "family": "威廉"},
664+
classification="fix(#272)",
665+
notes="the SAME transcription typed with the Japanese "
666+
"nakaguro reads as the dot's own typography says -- a "
667+
"姓・名 roster pair, family-first (#272) -- because the "
668+
"nakaguro records nothing (spec 2026-07-30 decision 5: "
669+
"codepoint-scoped; only the Chinese B7 marks a "
670+
"transcription). A limitation row: chosen, not "
671+
"accidental -- cross-convention input reads by the "
672+
"convention of the codepoint it was typed with"),
673+
Case("ja_interpunct_b7_katakana", "マイケル·ジャクソン",
674+
{"given": "マイケル", "family": "ジャクソン"},
675+
classification="fix(#298)",
676+
notes="sloppy-IME B7 between katakana divides like the "
677+
"nakaguro; katakana was never licensed, so order was "
678+
"already positional -- the split is the fix"),
679+
Case("latin_punt_volat_is_name_interior", "Gal·la Marcet",
680+
{"given": "Gal·la", "family": "Marcet"},
681+
notes="the Catalan punt volat: U+00B7 with Latin neighbors "
682+
"is interior to the name, never a divider -- the flank "
683+
"guard's whole reason"),
684+
Case("zh_interpunct_suppresses_segmentation", "马丁·路德·金",
685+
{"given": "马丁", "middle": "路德", "family": "金"},
686+
locale="zh",
687+
classification="fix(#298)",
688+
notes="马 is a listed zh surname, but a 间隔号-divided name "
689+
"is a transcription: the dot gates segmentation off, "
690+
"so 马丁 stays whole and the positional read stands"),
654691
)

tests/v2/test_regex_sync.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -192,16 +192,20 @@ 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 one span, which
195+
The rule is also WIDER than the table by exactly two spans, 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
200-
kana stays out of _SCRIPT_RANGES on purpose. Naming that span here
201-
rather than relaxing the comparison to a subset check is what
202-
keeps the pin honest in both directions: a THIRD source of
203-
divergence still fails, and the one sanctioned difference has to
204-
be written down to exist.
200+
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.
205209
"""
206210
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
207211
/ "expected_changes.toml")
@@ -222,16 +226,18 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
222226
(int(lo, 16), int(hi, 16))
223227
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
224228
matched[0]["name_regex"])}
225-
# the one span the rule carries that no Script claims (see above)
226-
halfwidth_dot = (0xFF65, 0xFF65)
227-
assert not any(lo <= halfwidth_dot[0] <= hi
228-
for spans in _policy._SCRIPT_RANGES.values()
229-
for lo, hi in spans), (
230-
"U+FF65 is classified now; drop it from the sanctioned extras")
229+
# 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
233+
for spans in _policy._SCRIPT_RANGES.values()
234+
for lo, hi in spans), (
235+
f"U+{extra:04X} is classified now; drop it from the "
236+
"sanctioned extras")
231237
expected = {span
232238
for spans in _policy._SCRIPT_RANGES.values()
233239
for span in spans
234-
if span[1] <= 0xFFFF} | {halfwidth_dot}
240+
if span[1] <= 0xFFFF} | sanctioned_extras
235241
assert declared == expected, (
236242
f"{toml_path.name}'s CJK name_regex declares {sorted(declared)}; "
237243
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")

tools/differential/corpus_cjk.jsonl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
"みなみ"
77
"マイケル"
88
"マイケル ジャクソン"
9+
"マイケル·ジャクソン"
910
"マイケル・ジャクソン"
1011
"佐々木 太郎"
1112
"司马相如"
1213
"夏侯惇"
14+
"威廉·莎士比亚"
15+
"威廉・莎士比亚"
1316
"山田 エミ"
1417
"山田 太郎 (マイケル・ジャクソン)"
1518
"山田「タロ」太郎"
@@ -20,6 +23,7 @@
2023
"田中『ハナ』花子"
2124
"諸葛亮"
2225
"阿明"
26+
"马丁·路德·金"
2327
"高橋 みなみ"
2428
"高橋みなみ"
2529
"高橋・一郎"

tools/differential/expected_changes.toml

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,22 @@ issue = "fix(#271/#272) native-script CJK: family-first order, hangul segmentati
3232
# supplementary-plane kana is hentaigana and other archaic forms no
3333
# modern name uses, so _SCRIPT_RANGES does not list it either.
3434
#
35-
# U+FF65 is the one span here that _SCRIPT_RANGES does NOT have, and
36-
# it is deliberate: the halfwidth middle dot separates tokens like its
37-
# fullwidth twin, so 'マイケル・ジャクソン' splits where 1.4 left one token,
38-
# but halfwidth kana is excluded from CLASSIFICATION on purpose (a
39-
# separate normalization problem). The rest of the halfwidth block
40-
# (U+FF66-U+FF9F) is left out for the usual tightness reason, and it
41-
# was measured rather than assumed: a dotless halfwidth name such as
42-
# 'マイケル ジャクソン' is byte-identical on both sides, so covering the
43-
# block would pre-excuse a future regression on a shape that is
44-
# parity today. The dot is the whole mechanism; the dot is the whole
45-
# span.
35+
# U+FF65 and U+00B7 are the two spans here that _SCRIPT_RANGES does
36+
# NOT have, and both are deliberate. The halfwidth middle dot
37+
# separates tokens like its fullwidth twin, so 'マイケル・ジャクソン' splits
38+
# where 1.4 left one token, but halfwidth kana is excluded from
39+
# CLASSIFICATION on purpose (a separate normalization problem). The
40+
# rest of the halfwidth block (U+FF66-U+FF9F) is left out for the
41+
# usual tightness reason, and it was measured rather than assumed: a
42+
# dotless halfwidth name such as 'マイケル ジャクソン' is byte-identical on
43+
# both sides, so covering the block would pre-excuse a future
44+
# regression on a shape that is parity today. U+00B7, the
45+
# context-sensitive 间隔号 (#298), is FF65's situation again: it
46+
# divides only between classified-script characters and pins source
47+
# order, so '威廉·莎士比亚' splits where 1.4 kept one token -- but a
48+
# codepoint that doubles as Catalan's punt volat can never itself be
49+
# classified as a CJK script. The dots are the whole mechanism; the
50+
# dots are the whole spans.
4651
#
4752
# corpus_cjk.jsonl (#295) exists so this rule fires in every real
4853
# run: build_cjk_corpus.py harvests every CJK-bearing text in
@@ -52,7 +57,7 @@ issue = "fix(#271/#272) native-script CJK: family-first order, hangul segmentati
5257
# two corpora have by construction: v1's banks had no reason to test
5358
# CJK, and build_issues_corpus.py requires an internal space, which
5459
# unspaced names never have.
55-
name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65]"
60+
name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65\\u00B7-\\u00B7]"
5661
fields = ["first", "middle", "last"]
5762

5863
[[change]]

0 commit comments

Comments
 (0)