Skip to content

Commit 7db3635

Browse files
derek73claude
andcommitted
Add a second differential corpus harvested from the issue tracker
The existing corpus comes from v1's own test suite, which makes it structurally blind to everything 2.0 added: v1's authors had no reason to write a test for a typographic nickname delimiter or a Cyrillic title, so no rule was ever needed to classify those behaviors. Harvest the adversarial half instead -- names users REPORTED as broken, taken from HumanName("...") calls and quoted phrases in issue bodies. 166 of the 198 names were absent from the existing corpus, and the first run paid for the exercise: five intended-but-unclassified 2.0 behaviors (#273 typographic delimiters, #269 non-Latin vocabulary) and one shape no test had considered. That shape is a LEADING "Ph. D.". v1 healed the split 'Ph.'/'D.' pair only when it trailed; leading, it split them into title 'Ph.' and given 'D.' and pushed the real given name to middle. 2.0 keeps the credential whole. Classified as a fix, pinned in cases.py, noted in the release log. The toml's deliberate "leave trailing Ph.D. unclassified so a regression fails" strategy is what surfaced it, and the new rule is anchored to ^ so trailing stays unguarded. Kept as a separate corpus with its own builder: corpus.jsonl is reproducible forever from an immutable git ref, while an issue tracker is mutable, so regenerating this one is an explicit, reviewable act. compare.py now reads every corpus*.jsonl by default -- a corpus you have to ask for by name is one that stops being run -- taking the harness from 486 names to 652, 18 intentional diffs, 0 unexplained. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 17e101b commit 7db3635

7 files changed

Lines changed: 383 additions & 13 deletions

File tree

docs/release_log.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Release Log
5252
- Recognize typographic nickname delimiters by default in both APIs (closes #273): smart quotes (``“Jack”``), German and Polish low-high quotes (``„Hansi“``), Swedish right-right quotes (``”Ann”``), guillemets in either direction (``«Petit»``, ``»Hansi«``), CJK corner brackets (``「タロ」``, ``『ハナ』``) and fullwidth parentheses. In 1.x these leaked into ``middle`` as literal text. Curly *single* quotes stay excluded, because U+2019 is the apostrophe in "O’Connor"
5353
- Fix the pre-comma piece being routed to ``first`` when everything after the comma is a suffix or title: ``"Andrews, M.D."`` now reads family ``Andrews`` / suffix ``M.D.`` where 1.x read given ``M.D.`` / family ``Andrews``, and ``"Smith, Dr."`` moves ``Smith`` from ``first`` to ``family`` (the title was already correct in 1.x). The piece before a comma is definitionally the family name
5454
- Fix a lone recognized trailing suffix with no comma being routed to ``first``/``last``: ``"Johnson PhD"`` and ``"Mr. Johnson PhD"`` now keep the suffix in ``suffix``
55-
- Fix a split ``"Ph. D."`` credential being read as two tokens; it now classifies as one suffix, replacing v1's ``fix_phd`` hook
55+
- Fix a split ``"Ph. D."`` credential being read as two tokens; it now classifies as one suffix, replacing v1's ``fix_phd`` hook. This now holds wherever the credential sits: 1.x healed the pair only when it trailed, so ``"Ph. D. John Smith"`` parsed as title ``Ph.`` / given ``D.`` with the real given name pushed to ``middle``; it now reads given ``John``, family ``Smith``, suffix ``Ph. D.``
5656
- Parse an input with no alphanumeric character to an empty name in both APIs. 1.x kept pure punctuation as a name part, so ``"."`` gave ``first="."`` and ``bool()`` was ``True``; 2.0 empties it, keeping ``bool(parse(x))`` an honest "did I get a name?" test. The check is Unicode-aware, so names in any script are unaffected; only inputs that are entirely punctuation or symbols (``"."``, ``"- -"``) change. Junk embedded in a name with real content -- the stray dot in ``"John . Smith"`` -- is still kept, since that parse is already truthy
5757
- Fold a leading never-given particle into the family name. Note that ``Lexicon.particles_ambiguous`` is the **complement** of v1's ``non_first_name_prefixes``, not a rename -- it lists the particles that *may* double as a given name, where v1 listed the ones that may not. Copying a v1 customization across without inverting it silently reverses the behavior; see :doc:`migrate`
5858
- Add ``ma`` and ``do`` to ``suffix_acronyms_ambiguous``, the set of post-nominals that are also ordinary surnames. An entry there is read as a credential when the name can spare it — written with periods (``"John Smith M.A."``), or when removing it still leaves a given *and* a family name (``"John Smith MA"`` → suffix ``MA``). With only two pieces to go around, the surname reading wins instead: ``"Jack Ma"`` and ``"Anh Do"`` keep their family names. As a side effect, a parenthesized or quoted ``"(MA)"``/``"(DO)"`` now falls through to nickname parsing rather than escaping to ``suffix``, since inside delimiters the nickname reading is the plausible one

tests/v2/cases.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,13 @@ def __post_init__(self) -> None:
435435
Case("phd_split_mid_name", "Dr. John Ph. D. Smith",
436436
{"title": "Dr.", "given": "John", "family": "Smith",
437437
"suffix": "Ph. D."}),
438+
Case("phd_split_leading", "Ph. D. John Smith",
439+
{"given": "John", "family": "Smith", "suffix": "Ph. D."},
440+
classification="fix",
441+
notes="v1 healed 'Ph.'+'D.' only when trailing; leading it "
442+
"split them (title 'Ph.', given 'D.', real given name "
443+
"pushed to middle). Surfaced by the issue-tracker "
444+
"corpus, which is where this shape existed at all."),
438445
Case("leading_never_given_particle", "de la Vega",
439446
{"family": "de la Vega"},
440447
notes="v1 handle_non_first_name_prefix: never-given leading "

tools/differential/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,28 @@ 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
42+
43+
`compare.py` reads **every** `corpus*.jsonl` beside it by default
44+
(deduped), because a corpus you have to ask for by name is a corpus
45+
that stops being run. Pass `--corpus PATH` (repeatable) to narrow it.
46+
47+
| File | Source | Blind to |
48+
|---|---|---|
49+
| `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 |
50+
| `corpus_issues.jsonl` | name-like strings harvested from the GitHub issue tracker | anything nobody ever reported |
51+
52+
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.
56+
57+
The issue corpus earned its place on the first run — 166 of its 198
58+
names were not in `corpus.jsonl`, and it immediately surfaced five
59+
intended-but-unclassified 2.0 behaviors (#273 typographic delimiters,
60+
#269 non-Latin vocabulary) plus one shape no test had considered: a
61+
**leading** `"Ph. D."`, which v1 split into title `Ph.` + given `D.`.
62+
4163
## Corpus provenance
4264

4365
`corpus.jsonl` is checked in as a test fixture. It was built by
@@ -69,6 +91,21 @@ Regenerate the corpus only if the v1 test banks are revisited again at
6991
a still-earlier point in history; otherwise leave the checked-in file
7092
alone so the harness stays comparable run to run.
7193

94+
`corpus_issues.jsonl` is built by `build_issues_corpus.py` from the
95+
issue tracker (`gh issue list --state all`), taking `HumanName("...")`
96+
calls and quoted capitalized phrases -- the two ways a reporter writes
97+
the input that broke on them. Regenerate with:
98+
99+
```
100+
uv run python tools/differential/build_issues_corpus.py > tools/differential/corpus_issues.jsonl
101+
```
102+
103+
Unlike the ref-pinned corpus this is a mutable source, so the
104+
checked-in file is the snapshot under test; re-running only adds names
105+
as new issues arrive. Over-collection is fine in both builders: the
106+
comparator just parses more names, and junk like `Bridge (1.4)` costs
107+
one parse and produces no diff.
108+
72109
## `expected_changes.toml`
73110

74111
Each `[[change]]` entry needs `issue` (a short label, ideally an
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Harvest name-like strings from this project's GitHub issue tracker.
2+
3+
Companion to build_corpus.py, and deliberately a SEPARATE corpus with a
4+
separate builder. The two have different provenance and different
5+
blind spots:
6+
7+
- corpus.jsonl comes from v1's own test suite at a pinned git ref. It
8+
is reproducible forever, and it is structurally blind to anything
9+
2.0 added -- v1's authors had no reason to write a test for a
10+
typographic nickname delimiter or a Cyrillic title.
11+
- corpus_issues.jsonl comes from what USERS REPORTED, which is the
12+
adversarial half: names that broke the parser in the field, in the
13+
reporter's own words. On its first run it surfaced five intended-
14+
but-unclassified 2.0 behaviors and one shape (a LEADING "Ph. D.")
15+
that no test had considered.
16+
17+
Reproducibility differs, and that is why these are not merged. A git
18+
ref is immutable; an issue tracker is not. Re-running this later can
19+
only ADD names as new issues arrive, so the checked-in file is the
20+
snapshot under test and regeneration is an explicit, reviewable act.
21+
22+
Over-collection is fine, same as build_corpus.py: the comparator just
23+
parses more names. Junk like 'Bridge (1.4)' costs one parse and
24+
produces no diff.
25+
26+
Regenerate with (requires `gh` authenticated to the repo):
27+
uv run python tools/differential/build_issues_corpus.py \\
28+
> tools/differential/corpus_issues.jsonl
29+
"""
30+
import json
31+
import re
32+
import subprocess
33+
import sys
34+
35+
# Either an explicit HumanName("...") call -- the reporter showing the
36+
# failing input -- or a quoted, capitalized phrase, which is how names
37+
# appear in prose ("parsing 'Hans "Hansi" Müller' gives ...").
38+
_CANDIDATE = re.compile(
39+
r"""HumanName\(\s*["']([^"']{3,60})["']"""
40+
r"""|["']([A-Z][^"'\n]{4,60})["']"""
41+
)
42+
# Structural characters that mean we caught code or markup, not a name.
43+
_NOT_A_NAME = set('{}<>=/\\|')
44+
45+
46+
def _harvest(text: str) -> set[str]:
47+
found = set()
48+
for match in _CANDIDATE.finditer(text):
49+
value = (match.group(1) or match.group(2) or "").strip()
50+
# Require an internal space: a single word is a surname at best
51+
# and carries no structure to disagree about.
52+
if value and " " in value and not (_NOT_A_NAME & set(value)):
53+
found.add(value)
54+
return found
55+
56+
57+
def main() -> None:
58+
raw = subprocess.run(
59+
["gh", "issue", "list", "--state", "all", "--limit", "1000",
60+
"--json", "number,title,body"],
61+
capture_output=True, text=True, check=True).stdout
62+
issues = json.loads(raw)
63+
names: set[str] = set()
64+
for issue in issues:
65+
names |= _harvest(
66+
f"{issue.get('title') or ''}\n{issue.get('body') or ''}")
67+
for name in sorted(names):
68+
print(json.dumps(name, ensure_ascii=False))
69+
print(f"{len(names)} names from {len(issues)} issues", file=sys.stderr)
70+
71+
72+
if __name__ == "__main__":
73+
main()

tools/differential/compare.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,29 @@ def classify(name: str, diff_fields: set[str],
5050

5151
def main() -> int:
5252
ap = argparse.ArgumentParser()
53-
ap.add_argument("--corpus", default=str(HERE / "corpus.jsonl"))
53+
# Both corpora by default: they have different blind spots (see
54+
# build_issues_corpus.py), and one that has to be asked for by name
55+
# is one that stops being run.
56+
ap.add_argument("--corpus", action="append", metavar="PATH",
57+
help="corpus file; repeatable. Defaults to every "
58+
"corpus*.jsonl beside this script.")
5459
args = ap.parse_args()
60+
paths = ([Path(p) for p in args.corpus] if args.corpus
61+
else sorted(HERE.glob("corpus*.jsonl")))
5562
rules = tomllib.loads(
5663
(HERE / "expected_changes.toml").read_text()).get("change", [])
5764
validate_rules(rules)
58-
corpus = [json.loads(line) for line in
59-
Path(args.corpus).read_text().splitlines() if line.strip()]
65+
# dedupe across files, keeping first-seen order stable for output
66+
corpus, seen = [], set()
67+
for path in paths:
68+
for line in path.read_text().splitlines():
69+
if not line.strip():
70+
continue
71+
name = json.loads(line)
72+
if name not in seen:
73+
seen.add(name)
74+
corpus.append(name)
75+
print(f"corpora: {', '.join(p.name for p in paths)}")
6076

6177
proc = subprocess.Popen(
6278
["uv", "run", "--no-project", str(HERE / "worker_v1.py")],
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"1 & 2, 3 4 5, Mr."
2+
"Ahmad Jayadi, CHA"
3+
"Ahmad ben Husain"
4+
"Ajay Reddy B"
5+
"Alex Ben Johnson"
6+
"Andrew Ma"
7+
"Ann H"
8+
"Anna Müller geb. Schmidt"
9+
"Attorney General of Minnesota"
10+
"Baker (Johnson), Jenny"
11+
"Barbara Scharf"
12+
"Barbara Scharf, CPPM"
13+
"Beth Edmunds"
14+
"Beyoncé Knowles"
15+
"Bob Jones, author"
16+
"Bob Jones, compositeur"
17+
"Bridge (1.4)"
18+
"Brundridge, Contessa A"
19+
"Charles van der van der Berg"
20+
"Cherice J. (Johnson) Williams"
21+
"Claus SchroederVP of"
22+
"Co-Chairs Miller"
23+
"Co-Chairs Muffy Miller"
24+
"DELA CRUZ"
25+
"David Ben Gurion"
26+
"De Groot"
27+
"Doe, John A."
28+
"Doe, John A. Kenneth"
29+
"Dr John Smith"
30+
"Dr. Jason"
31+
"Dr. Jason Ross Jay"
32+
"Dr. Juan Q. Xavier de la Vega III"
33+
"Dr. King"
34+
"Dr. Mazen Elrouqi"
35+
"Dr. med. univ."
36+
"Dr. med. univ. Margit Popp"
37+
"Dr. med. univ. Margit Popp, MSc"
38+
"Duke Ellington"
39+
"Duke of Edinburgh"
40+
"E Anne D"
41+
"E Anne D,Leonardo"
42+
"E Jones"
43+
"E Maria"
44+
"Ed, Royce"
45+
"Eddie Chang Seng Dee Adecco"
46+
"El Dine"
47+
"Elder Soares"
48+
"Eunsoo Johanna Jeong"
49+
"Flávio Juvenal da Silva Jr"
50+
"Flávio Juvenal da Silva Junior"
51+
"GEORGE SHANAHAN IV"
52+
"GREGORY HOUSE M.D."
53+
"Garcia de Jalon"
54+
"Gary Strawsburg, PMP, CSCP, CLSSBB"
55+
"Gebhart GF"
56+
"General of Minnesota"
57+
"Give me a private config"
58+
"Grand Rounds"
59+
"Hans „Hansi“ Müller"
60+
"Hardie Leannon DVM"
61+
"Hardie Leannon V"
62+
"His Holiness"
63+
"His Holiness the Dalai Lama"
64+
"Hisham Ibrahim, CPA, CFA"
65+
"Howard G. Buffett"
66+
"I don"
67+
"Ivan Ivanov"
68+
"Ivan Tham Jun Hoe"
69+
"Ivanov Ivan"
70+
"Ivanov Ivan Ivanovich"
71+
"J A D"
72+
"J. A. D."
73+
"J. None D."
74+
"J. Smith"
75+
"J. Smith Jr."
76+
"J. Smith Sr."
77+
"J. Smith, MD"
78+
"J. Smith, PhD"
79+
"J.A. K.D."
80+
"JA KD"
81+
"JUAN DELA CRUZ"
82+
"Jack MA"
83+
"Jack Ma"
84+
"Jan Kees de Jager"
85+
"Jane Smith (Jones)"
86+
"Jane Smith née Jones"
87+
"Jean «Petit» Dupont"
88+
"Jill St. John"
89+
"Jo Ann"
90+
"Joao da Silva Do Amaral de Souza"
91+
"Joao da Silva do Amaral de Souza"
92+
"Joe E. Smith"
93+
"Joe Smith"
94+
"John Doe ☁️"
95+
"John Q Doe"
96+
"John An Smith"
97+
"John Doe"
98+
"John Doe, Phd, Jr"
99+
"John F Kennedy"
100+
"John Smith"
101+
"John Smith Jr."
102+
"John Smith MA"
103+
"John Smith Ma"
104+
"John Smith, Dr."
105+
"John Smith, Ph. D."
106+
"John Smith, Ph.D."
107+
"John V"
108+
"John W. Ingram"
109+
"John W. Ingram V"
110+
"John W. Ingram, V"
111+
"John of the Doe"
112+
"John “Jack” Kennedy"
113+
"John, Smith, Dr."
114+
"Jose E Maria Santos"
115+
"Juan de la de la Vega"
116+
"Jun Hoe"
117+
"Kajal Patel Hiring Globally"
118+
"King John V."
119+
"La Shawn K. Ford"
120+
"Lamha Yahya Al Mawali"
121+
"Lastname, Firstname"
122+
"Lisa Schmidt, MBA"
123+
"MD - DO - DDS"
124+
"MD, DO, DDS"
125+
"MD, PHD"
126+
"MSc Dr. med. univ."
127+
"Ma, Andrew"
128+
"Maheen Farooqi, ACA"
129+
"Mary Ann H"
130+
"Mary Ann H Luther"
131+
"Mary Ann H,Luther"
132+
"Matthew Matthew"
133+
"Maura Cellario-Pingor, PHR"
134+
"Md,, Phd"
135+
"Mohamad Ali"
136+
"Mohamad X"
137+
"Mohamad X Surname"
138+
"Mohamad Zein El Dine"
139+
"Mohamad Zeineddine"
140+
"Mr XXX"
141+
"Ms A.J. Smith"
142+
"Mx A.J. Smith"
143+
"Müller geb."
144+
"Nguyễn Thị Minh Khai"
145+
"None John None Doe None (None)"
146+
"Nonez Smith"
147+
"Oren ben Horin"
148+
"Ph. D. John Smith"
149+
"Potential Gotcha"
150+
"QC MP"
151+
"Rafael Sousa dos Anjos"
152+
"Reem Al-Bahrani, CPA"
153+
"Rob Edmunds"
154+
"Royce, Ed"
155+
"Said oglu"
156+
"San San Chiou"
157+
"Sander van"
158+
"Sejal Chaturvedi, CSM"
159+
"Sister Souljah"
160+
"Smith Jr."
161+
"Smith Jr., John"
162+
"Smith née"
163+
"Smith, A.B."
164+
"Smith, J. R."
165+
"Smith, J.R."
166+
"Smith, John"
167+
"Smith, John ,"
168+
"Smith, John E, III, Jr"
169+
"St. ___"
170+
"Steven Hardman"
171+
"Steven Hardman, MD - DO - DDS"
172+
"Steven Hardman, MD, DO, DDS"
173+
"Steven Hardman, RN - CRNA"
174+
"Syukri @ Shu Qi Mohd Nor"
175+
"Tham Jun"
176+
"Tham Jun Hoe"
177+
"The Rt Hon"
178+
"The Rt Hon John Jones"
179+
"The Rt Hon Kenneth Clarke QC MP, HMG"
180+
"UNILEVER LTD"
181+
"Unilever Ltd"
182+
"Van Maximilian Carlson"
183+
"Van Nguyen"
184+
"Virginia G. Essandoh, J.D."
185+
"WOOT ALFRED DOUGHERTY III"
186+
"Will De Groot"
187+
"Zein El Dine"
188+
"dr Vincent van Gogh dr"
189+
"e and e"
190+
"joao da silva do amaral de souza"
191+
"scott e. werner"
192+
"scott l. werner"
193+
"van ma van"
194+
"Хосе И Мария Сантос"
195+
"г-н Иван Петров"
196+
"سلمان, محمد"
197+
"سلمان، محمد"
198+
"محمد بن سلمان"

0 commit comments

Comments
 (0)