Skip to content

Commit 0e49f61

Browse files
authored
Merge pull request #304 from derek73/v2.1/cjk-corpus
Differential harness: CJK corpus generated from the case table
2 parents 58e93d7 + 4eff183 commit 0e49f61

5 files changed

Lines changed: 207 additions & 14 deletions

File tree

tests/v2/test_regex_sync.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
Layering is the usual reason for a copy but not the only one, so this
1313
module's scope is the PROMISE rather than that one pair of packages:
1414
the comma-set pin below reads _pipeline._state instead of config, and
15-
the last test reaches outside the package altogether, to a TOML file
16-
that could not import a Python constant if it wanted to.
15+
the last three tests reach outside the package altogether -- two to a
16+
TOML file that could not import a Python constant if it wanted to,
17+
one to a generated corpus whose generator can, and must stay run.
1718
"""
19+
import importlib.util
20+
import json
1821
import re
1922
import tomllib
2023
from pathlib import Path
@@ -232,3 +235,64 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None:
232235
assert declared == expected, (
233236
f"{toml_path.name}'s CJK name_regex declares {sorted(declared)}; "
234237
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")
238+
239+
240+
def test_differential_compound_rule_matches_the_script_ranges() -> None:
241+
"""The fix(cjk-delimited-nickname) rule (#295) carries the SECOND
242+
hand copy of the script spans in the toml: its require-a-classified-
243+
codepoint lookahead exists so the delimiter set alone cannot claim a
244+
Latin name's first/last regression ('John 「Jack」 Kennedy' -- the
245+
corner brackets sit outside every classified span). Pin that copy to
246+
the table exactly as the canonical CJK rule's is, and pin the
247+
delimiter set itself, so widening either (to ASCII quotes, say) is
248+
an explicit decision here rather than a silent absorption change.
249+
250+
Selection note for future rule authors: the canonical-CJK-rule pin
251+
above selects by the literal '#271'/'#272' substrings and asserts
252+
uniqueness -- this rule's slug avoids them on purpose, and any new
253+
rule's must too.
254+
"""
255+
toml_path = (Path(__file__).parents[2] / "tools" / "differential"
256+
/ "expected_changes.toml")
257+
rules = tomllib.loads(toml_path.read_text())["change"]
258+
matched = [r for r in rules if "cjk-delimited-nickname" in r["issue"]]
259+
assert len(matched) == 1
260+
regex = matched[0]["name_regex"]
261+
assert "[「」『』・・]" in regex, (
262+
"the compound rule's delimiter set changed; decide deliberately")
263+
declared = {
264+
(int(lo, 16), int(hi, 16))
265+
for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})",
266+
regex)}
267+
expected = {span
268+
for spans in _policy._SCRIPT_RANGES.values()
269+
for span in spans
270+
if span[1] <= 0xFFFF} | {(0xFF65, 0xFF65)}
271+
assert declared == expected, (
272+
f"compound rule's codepoint lookahead declares {sorted(declared)}; "
273+
f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}")
274+
275+
276+
def test_cjk_corpus_matches_the_case_table() -> None:
277+
"""corpus_cjk.jsonl is GENERATED, not curated (#295): every
278+
distinct case-table text bearing a codepoint the script table
279+
classifies, sorted -- see build_cjk_corpus.py for why the other
280+
two corpora cannot carry these names. The checked-in file must
281+
equal what the generator would write, so a CJK case row added
282+
without regenerating fails HERE instead of silently narrowing
283+
the differential gate back toward the blind spot #295 closed.
284+
Same promise as the toml pin above, aimed at a generated artifact
285+
instead of a hand copy.
286+
"""
287+
tools = Path(__file__).parents[2] / "tools" / "differential"
288+
spec = importlib.util.spec_from_file_location(
289+
"build_cjk_corpus", tools / "build_cjk_corpus.py")
290+
assert spec is not None and spec.loader is not None
291+
module = importlib.util.module_from_spec(spec)
292+
spec.loader.exec_module(module)
293+
checked_in = [json.loads(line) for line in
294+
(tools / "corpus_cjk.jsonl")
295+
.read_text(encoding="utf-8").splitlines()]
296+
assert checked_in == module.selected_names(), (
297+
"corpus_cjk.jsonl is stale: regenerate with "
298+
"`uv run python tools/differential/build_cjk_corpus.py`")

tools/differential/README.md

Lines changed: 10 additions & 3 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
5354
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.
55+
tracker is mutable, so regenerating `corpus_issues.jsonl` is an
56+
explicit, reviewable act that can only add names — and the CJK corpus
57+
exists 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: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,14 @@ 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: build_cjk_corpus.py harvests every CJK-bearing text in
49+
# tests/v2/cases.py -- the fix(#271)/fix(#272)/fix(shime-mark) rows
50+
# AND the parity rows, whose value here is pinning NON-diffs (マイケル
51+
# must not change against 1.4) -- closing the blind spot the other
52+
# two corpora have by construction: v1's banks had no reason to test
53+
# CJK, and build_issues_corpus.py requires an internal space, which
54+
# unspaced names never have.
5655
name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65]"
5756
fields = ["first", "middle", "last"]
5857

@@ -123,6 +122,36 @@ issue = "feat(#273) typographic nickname delimiters recognized by default"
123122
name_regex = "[“”„«»「」『』()]"
124123
fields = ["middle", "nickname"]
125124

125+
[[change]]
126+
issue = "fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip"
127+
# '山田「タロ」太郎', '山田 太郎 (マイケル・ジャクソン)': one name, two
128+
# intended changes at once -- the corner-bracket/nakaguro handling
129+
# (2.0's typographic delimiters; 2.1's dot separator rendering the
130+
# nickname join with a space) changes `nickname`, while the same
131+
# name's wholly-CJK remainder takes the 2.1 family-first flip in
132+
# first/last. Neither single-change rule may claim the union (the
133+
# delimiter rule's fields exclude first/last, the CJK rule's exclude
134+
# nickname -- each on purpose, so a lone regression in the other's
135+
# fields stays loud). Both lookaheads are required: the delimiters
136+
# alone would match 'John 「Jack」 Kennedy' -- the brackets sit in CJK
137+
# Symbols and Punctuation, OUTSIDE every classified span, so a Latin
138+
# name can carry them -- and would then absorb a bare first/last
139+
# regression on it. Requiring a classified codepoint too confines the
140+
# rule to names the order flip can actually reach. The second
141+
# lookahead's class is the same hand copy of _SCRIPT_RANGES the rule
142+
# above carries, pinned by the same sync test. One absorption is
143+
# inherent and accepted: a nickname-ONLY diff on a nakaguro name
144+
# ('マイケル・ジャクソン') classifies here, because subset-matching
145+
# cannot REQUIRE a field to have changed. Positional note: this rule
146+
# overlaps the CJK rule above and the delimiter rule below; within
147+
# the name_regex tier file order decides which label a diff reports
148+
# under, so it sits after the tighter single-change rules on purpose.
149+
# The issue slug deliberately avoids the literal #271/#272 strings --
150+
# test_regex_sync's differential pin selects the canonical CJK rule
151+
# by those substrings and asserts it is unique.
152+
name_regex = "(?s)(?=.*[「」『』・・])(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65])"
153+
fields = ["first", "last", "nickname"]
154+
126155
[[change]]
127156
issue = "feat(#269) non-Latin titles/conjunctions recognized"
128157
# 'г-н Иван Петров' (Cyrillic title), 'Хосе И Мария Сантос' (Cyrillic

0 commit comments

Comments
 (0)