Skip to content

Commit ef7e116

Browse files
derek73claude
andcommitted
Give the differential harness a CJK corpus generated from the case table
The fix(#271/#272) rule in expected_changes.toml had never classified a name in a real run: corpus.jsonl regenerates from v1's test banks (no reason to test CJK) and build_issues_corpus.py requires an internal space, which unspaced names -- the exact shape the rule explains -- never have. The rule was verified once against a throwaway synthetic corpus during #294 and was otherwise dead weight the harness could not distinguish from coverage. corpus_cjk.jsonl is the third corpus with the third provenance: generated by build_cjk_corpus.py from every distinct case-table text bearing a codepoint the script table classifies, through the same _script_matcher the parser uses. Row context is deliberately ignored -- the corpus carries name STRINGS and the harness parses them with the default facade, so a zh-scoped row's text is simply one more CJK name to diff. The selection self-extends: a case row added for future CJK work enters the corpus at the next regeneration, and test_regex_sync.py pins the checked-in file against the generator's selection so a stale corpus fails the suite. First real run: 685 names, 44 intentional diffs, 0 unexplained, with the CJK rule claiming 23 names. It also immediately surfaced a shape no rule covered -- a name where delimiter recognition (corner brackets, the nakaguro nickname join) and the CJK family-first flip change fields in the SAME diff, which neither single-change rule may claim because each excludes the other's fields on purpose. The new fix(cjk-delimited-nickname) rule takes exactly that union, scoped to the CJK-typographic delimiters so it cannot absorb a Latin delimiter diff; its issue slug avoids the literal #271/#272 substrings the sync test uses to select the one canonical CJK rule. Closes #295 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 58e93d7 commit ef7e116

5 files changed

Lines changed: 157 additions & 13 deletions

File tree

tests/v2/test_regex_sync.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
the last test reaches outside the package altogether, to a TOML file
1616
that could not import a Python constant if it wanted to.
1717
"""
18+
import importlib.util
19+
import json
1820
import re
1921
import tomllib
2022
from pathlib import Path
@@ -232,3 +234,28 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
232234
assert declared == expected, (
233235
f"{toml_path.name}'s CJK name_regex declares {sorted(declared)}; "
234236
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")
237+
238+
239+
def test_cjk_corpus_matches_the_case_table() -> None:
240+
"""corpus_cjk.jsonl is GENERATED, not curated (#295): every
241+
distinct case-table text bearing a codepoint the script table
242+
classifies, sorted -- see build_cjk_corpus.py for why the other
243+
two corpora cannot carry these names. The checked-in file must
244+
equal what the generator would write, so a CJK case row added
245+
without regenerating fails HERE instead of silently narrowing
246+
the differential gate back toward the blind spot #295 closed.
247+
Same promise as the toml pin above, aimed at a generated artifact
248+
instead of a hand copy.
249+
"""
250+
tools = Path(__file__).parents[2] / "tools" / "differential"
251+
spec = importlib.util.spec_from_file_location(
252+
"build_cjk_corpus", tools / "build_cjk_corpus.py")
253+
assert spec is not None and spec.loader is not None
254+
module = importlib.util.module_from_spec(spec)
255+
spec.loader.exec_module(module)
256+
checked_in = [json.loads(line) for line in
257+
(tools / "corpus_cjk.jsonl")
258+
.read_text(encoding="utf-8").splitlines()]
259+
assert checked_in == module.selected_names(), (
260+
"corpus_cjk.jsonl is stale: regenerate with "
261+
"`uv run python tools/differential/build_cjk_corpus.py`")

tools/differential/README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ needs widening. The run must exit 0 before a 2.0 release; the classified
3838
summary it prints is the source for the "Behavior Changes" section of
3939
`docs/release_log.rst`.
4040

41-
## The two corpora
41+
## The three corpora
4242

4343
`compare.py` reads **every** `corpus*.jsonl` beside it by default
4444
(deduped), because a corpus you have to ask for by name is a corpus
@@ -48,11 +48,18 @@ that stops being run. Pass `--corpus PATH` (repeatable) to narrow it.
4848
|---|---|---|
4949
| `corpus.jsonl` | v1's own test suite at a pinned ref | anything 2.0 added — v1's authors had no reason to test a typographic nickname delimiter or a Cyrillic title |
5050
| `corpus_issues.jsonl` | name-like strings harvested from the GitHub issue tracker | anything nobody ever reported |
51+
| `corpus_cjk.jsonl` | the CJK-bearing rows of `tests/v2/cases.py`, via `build_cjk_corpus.py` (#295) | anything the case table itself missed — it re-witnesses reviewed expectations at the 1.4 boundary rather than discovering new shapes |
5152

5253
They are deliberately separate rather than merged: `corpus.jsonl` is
53-
reproducible forever from an immutable git ref, while the issue
54-
tracker is mutable, so regenerating the second one is an explicit,
55-
reviewable act that can only add names.
54+
reproducible forever from an immutable git ref, the issue tracker is
55+
mutable, so regenerating `corpus_issues.jsonl` is an explicit,
56+
reviewable act that can only add names — and the CJK corpus exists
57+
because BOTH of those are structurally blind to unspaced CJK (v1's
58+
banks never tested it; `build_issues_corpus.py` requires an internal
59+
space, which unspaced names never have). It regenerates from the case
60+
table, and `tests/v2/test_regex_sync.py` pins the checked-in file
61+
against the generator's selection, so a CJK case row added without
62+
regenerating fails the suite instead of silently narrowing this gate.
5663

5764
The issue corpus earned its place on the first run — 166 of its 198
5865
names were not in `corpus.jsonl`, and it immediately surfaced five
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Regenerate corpus_cjk.jsonl from the CJK rows of the case table.
2+
3+
The third corpus, with the third provenance (#295): corpus.jsonl
4+
regenerates from v1's test banks at an immutable ref and
5+
corpus_issues.jsonl harvests the issue tracker, but neither can carry
6+
an unspaced CJK name -- v1's authors had no reason to test one, and
7+
build_issues_corpus.py requires an internal space, which is exactly
8+
the shape #271 classifies. This file derives from the reviewed case
9+
table instead: every distinct `text` in tests/v2/cases.py containing
10+
a character the script table classifies, regardless of the row's
11+
policy/locale context -- the corpus carries name STRINGS and the
12+
differential run parses them with the default facade, so a name from
13+
a zh-scoped row is simply one more CJK name to diff.
14+
15+
Selection is by the shipped table (nameparser._policy._SCRIPT_RANGES,
16+
through the same _script_matcher the parser uses), so a script added
17+
to the table widens the harvest on the next regeneration, and a case
18+
row added for future CJK work (#298's 间隔号 forms, say) enters the
19+
corpus by being written down in the table everyone already reviews.
20+
21+
Regenerate after editing CJK case rows:
22+
23+
uv run python tools/differential/build_cjk_corpus.py
24+
25+
tests/v2/test_regex_sync.py pins the checked-in file against this
26+
module's selection, so a stale corpus fails the suite rather than
27+
silently narrowing the differential gate.
28+
"""
29+
from __future__ import annotations
30+
31+
import json
32+
import sys
33+
from pathlib import Path
34+
35+
HERE = Path(__file__).resolve().parent
36+
ROOT = HERE.parents[1]
37+
sys.path.insert(0, str(ROOT))
38+
39+
from nameparser._policy import _SCRIPT_RANGES, _script_matcher # noqa: E402
40+
from tests.v2.cases import CASES # noqa: E402
41+
42+
OUT = HERE / "corpus_cjk.jsonl"
43+
44+
_has_cjk = _script_matcher(*_SCRIPT_RANGES)
45+
46+
47+
def selected_names() -> list[str]:
48+
"""Every distinct case-table text bearing a classified codepoint,
49+
sorted for a deterministic file."""
50+
return sorted({case.text for case in CASES if _has_cjk(case.text)})
51+
52+
53+
def main() -> None:
54+
names = selected_names()
55+
with OUT.open("w", encoding="utf-8") as fh:
56+
for name in names:
57+
fh.write(json.dumps(name, ensure_ascii=False) + "\n")
58+
print(f"wrote {len(names)} names to {OUT.name}")
59+
60+
61+
if __name__ == "__main__":
62+
main()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"Dr 김민준, Jr."
2+
"John 王"
3+
"〆木 ひろ"
4+
"〆木 太郎"
5+
"〆木太郎"
6+
"みなみ"
7+
"マイケル"
8+
"マイケル ジャクソン"
9+
"マイケル・ジャクソン"
10+
"佐々木 太郎"
11+
"司马相如"
12+
"夏侯惇"
13+
"山田 エミ"
14+
"山田 太郎 (マイケル・ジャクソン)"
15+
"山田「タロ」太郎"
16+
"张伟"
17+
"毛 泽东"
18+
"毛 김"
19+
"毛泽东"
20+
"田中『ハナ』花子"
21+
"諸葛亮"
22+
"阿明"
23+
"高橋 みなみ"
24+
"高橋みなみ"
25+
"高橋・一郎"
26+
"高橋一郎"
27+
"김 민준"
28+
"김민준"
29+
"남궁"
30+
"남궁민수"
31+
"남궁민수, 지훈"

tools/differential/expected_changes.toml

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,13 @@ issue = "fix(#271/#272) native-script CJK: family-first order, hangul segmentati
4444
# parity today. The dot is the whole mechanism; the dot is the whole
4545
# span.
4646
#
47-
# Expected to match nothing against the current corpora, which contain
48-
# no CJK at all -- build_issues_corpus.py requires an internal space,
49-
# and unspaced names are the shape this classifies. That blind spot is
50-
# tracked as #272's and #271's shared gap in issue #295, which also
51-
# records the provenance decision a fix needs; until it is closed the
52-
# behavioral guarantee lives in the fix(#271)/fix(#272) rows of
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.
47+
# corpus_cjk.jsonl (#295) exists so this rule fires in every real
48+
# run: it derives from the fix(#271)/fix(#272)/fix(shime-mark) rows of
49+
# tests/v2/cases.py (build_cjk_corpus.py; the 〆 span rides in on the
50+
# same HAN entry), closing the blind spot the other two corpora have
51+
# by construction -- v1's banks had no reason to test CJK, and
52+
# build_issues_corpus.py requires an internal space, which unspaced
53+
# names never have.
5654
name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65]"
5755
fields = ["first", "middle", "last"]
5856

@@ -123,6 +121,25 @@ issue = "feat(#273) typographic nickname delimiters recognized by default"
123121
name_regex = "[“”„«»「」『』()]"
124122
fields = ["middle", "nickname"]
125123

124+
[[change]]
125+
issue = "fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip"
126+
# '山田「タロ」太郎', '山田 太郎 (マイケル・ジャクソン)': one name, two
127+
# intended changes at once -- the corner-bracket/nakaguro handling
128+
# (2.0's typographic delimiters; 2.1's dot separator rendering the
129+
# nickname join with a space) changes `nickname`, while the same
130+
# name's wholly-CJK remainder takes the 2.1 family-first flip in
131+
# first/last. Neither single-change rule may claim the union (the
132+
# delimiter rule's fields exclude first/last, the CJK rule's exclude
133+
# nickname -- each on purpose, so a lone regression in the other's
134+
# fields stays loud). Scoped to the CJK-typographic delimiters and
135+
# the nakaguro only: a name containing 「」『』 or ・ is CJK-adjacent by
136+
# construction, so this cannot absorb a Latin delimiter diff. The
137+
# issue slug deliberately avoids the literal #271/#272 strings --
138+
# test_regex_sync's differential pin selects THE CJK rule by those
139+
# substrings and asserts it is unique.
140+
name_regex = "[「」『』・・]"
141+
fields = ["first", "middle", "last", "nickname"]
142+
126143
[[change]]
127144
issue = "feat(#269) non-Latin titles/conjunctions recognized"
128145
# 'г-н Иван Петров' (Cyrillic title), 'Хосе И Мария Сантос' (Cyrillic

0 commit comments

Comments
 (0)