Skip to content

Commit 1748493

Browse files
derek73claude
andcommitted
Add the v1-vs-2.0 differential harness with a classified allowlist
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 785c3e5 commit 1748493

7 files changed

Lines changed: 869 additions & 0 deletions

File tree

docs/release_log.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
Release Log
22
===========
3+
* 2.0.0 - unreleased
4+
5+
**Behavior Changes (draft)**
6+
7+
.. note::
8+
This section is a **draft**, generated from the v1-vs-2.0
9+
differential harness (``tools/differential/``) run against the
10+
pre-M12 v1 test corpus (486 name strings). It will be replaced
11+
by hand-written release notes before 2.0 ships; entries below
12+
are grouped by the harness's classification and reference the
13+
``tests/v2/cases.py`` row (if any) that pins the new behavior.
14+
15+
- Fix ``"Andrews, M.D."``-shaped input: the lone strict-suffix-or-title piece after a comma (e.g. ``"Smith, Dr."``, ``"Andrews, M.D."``) was routed to ``first`` in 1.x; 2.0 routes it to ``suffix``/``title`` instead, since the pre-comma piece is definitionally the family name (``tests/v2/cases.py`` rows ``family_comma_lone_suffix_piece``, ``family_comma_lone_title``; verified live against 1.4.0 -- 1/486 corpus names diff, all others parity)
16+
- Fix a lone recognized trailing suffix (e.g. ``"Johnson PhD"``, ``"Mr. Johnson PhD"``) being routed to ``first``/``last`` in 1.x when no comma is present; 2.0 keeps a recognized suffix in ``suffix`` (``tests/v2/cases.py`` rows ``suffix_stays_suffix``, ``suffix_stays_suffix_title``; not present in the differential corpus -- no v1 test string exercises the bare two-token shape, so this is unverified against 1.4 live but pinned by the case table)
17+
- Fix maiden-name markers (``née``/``nee``/``born``/``geb.``/``roz.``) being folded into ``middle``/``last`` in 1.x (e.g. ``"Jane Smith née Jones"`` → ``middle="Smith née"``); 2.0 recognizes the marker and routes the following name to the new ``maiden`` field (closes #274; ``tests/v2/cases.py`` row ``maiden_marker``; not present in the differential corpus)
18+
- Data change: ``ma``/``do`` added to ``suffix_acronyms_ambiguous`` so a bare common surname (``"Jack Ma"``) is no longer misread as a suffix acronym, restoring v1's older (pre-regression) parity (``tests/v2/cases.py`` row ``ambiguous_surname_acronyms``). Side effect: parenthesized/quoted ``"(MA)"``/``"(DO)"`` (no periods) no longer escape to ``suffix`` the way 1.x did -- they now fall through to nickname parsing like any other ambiguous-acronym delimited content. Not present in the differential corpus
19+
- Change suffix-delimiter rendering: with a custom ``Policy``/``Constants`` suffix delimiter configured (e.g. ``suffix_delimiter="/"``, ``"John Smith, RN/CRNA"``), 1.x split the token and rendered ``suffix="RN, CRNA"``; 2.0 keeps the no-space delimiter-core token whole (``suffix="RN/CRNA"``) -- role assignment is unchanged, only rendering differs (anti-#100, migration plan deviation 5; ``tests/v2/cases.py`` row ``suffix_delimiter_no_space_core``). Only fires with a non-default policy, so it does not appear in the (default-policy) differential corpus
20+
21+
Everything else in the 486-name differential corpus (built from the
22+
v1 test banks as of commit ``2d5d8c2``, pre-dating the M12 test
23+
reconciliation) parses identically between nameparser 1.4.0 and
24+
this working tree; see ``tools/differential/README.md`` for how to
25+
reproduce the comparison.
26+
327
* 1.4.0 - July 12, 2026
428

529
- Add ``Constants.copy()``, a detached deep copy that preserves the source instance's current customizations (unlike ``Constants()``, which always starts from library defaults) -- useful as ``CONSTANTS.copy()`` for a private snapshot of the shared config (#260)

tools/differential/README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Differential harness (v1 vs 2.0)
2+
3+
Dev-only tooling for the 2.0 migration (migration plan S5). Not
4+
shipped (excluded from the wheel by the packaging config -- only
5+
`nameparser/` is packaged) and not CI-gated. Run it by hand when
6+
touching parsing behavior, and before cutting a 2.0 release.
7+
8+
Two processes, two environments:
9+
10+
- `worker_v1.py` runs under a **pinned nameparser 1.4** installed fresh
11+
from PyPI via a PEP 723 inline script. It must be invoked with
12+
`uv run --no-project` -- **without `--no-project`, `uv` installs the
13+
working tree as an editable dependency and the 1.4 pin never takes
14+
effect**, silently comparing 2.0 against itself.
15+
- `compare.py` runs in the project's own dev environment and imports
16+
`nameparser` normally (the 2.0 facade, which still speaks the v1
17+
component names).
18+
19+
## Running it
20+
21+
```
22+
uv run python tools/differential/build_corpus.py --ref <ref> > tools/differential/corpus.jsonl # only when regenerating
23+
uv run python tools/differential/compare.py
24+
```
25+
26+
`compare.py` spawns the worker as a subprocess, feeds it every corpus
27+
name as a line of JSON, and diffs the two component dicts on the seven
28+
v1 field names (`title`, `first`, `middle`, `last`, `suffix`,
29+
`nickname`, `maiden` -- both sides use these keys, so no field mapping
30+
is needed). Every diff is checked against `expected_changes.toml`:
31+
32+
- Matches a rule -> counted as an intentional, classified change.
33+
- Matches no rule -> printed under `UNEXPLAINED` and the run exits 1.
34+
35+
An unexplained diff means either a real 2.0 parity bug (fix it, don't
36+
allowlist it) or a known change whose `expected_changes.toml` rule
37+
needs widening. The run must exit 0 before a 2.0 release; the classified
38+
summary it prints is the source for the "Behavior Changes" section of
39+
`docs/release_log.rst`.
40+
41+
## Corpus provenance
42+
43+
`corpus.jsonl` is checked in as a test fixture. It was built by
44+
`build_corpus.py`'s AST walk over every top-level `tests/test_*.py`
45+
file (the v1-style test banks; `tests/v2/` is a separate 2.0-only
46+
harness and is deliberately not scanned), reading each file via
47+
`git show <ref>:<path>` rather than the working tree:
48+
49+
```
50+
uv run python tools/differential/build_corpus.py --ref 2d5d8c2 > tools/differential/corpus.jsonl
51+
```
52+
53+
`2d5d8c2` ("Trim constant-factor waste on the tokenize hot path") is
54+
the last commit before M12 reconciled/edited the v1 test banks against
55+
the 2.0 facade -- M12 changed some expectations in place and deleted
56+
bucket-A tests outright, which would have shrunk and skewed the
57+
corpus. Reading history at an old ref via `git show` is a read-only
58+
operation on this worktree's own log; it does not check out, stash, or
59+
otherwise mutate anything.
60+
61+
The AST extraction over-collects on purpose (string literals passed as
62+
`HumanName(...)`'s first argument, plus string members of module-level
63+
list/dict/tuple banks that contain a space) -- more candidate strings
64+
is more coverage, and the corpus is deduplicated. Obvious non-names
65+
(strings containing `{`, `@`, or a backslash -- format placeholders,
66+
decorator/email-shaped fixtures, escape sequences) are dropped.
67+
68+
Regenerate the corpus only if the v1 test banks are revisited again at
69+
a still-earlier point in history; otherwise leave the checked-in file
70+
alone so the harness stays comparable run to run.
71+
72+
## `expected_changes.toml`
73+
74+
Each `[[change]]` entry needs `issue` (a short label, ideally an
75+
issue number or `fix(<slug>)` matching a `tests/v2/cases.py`
76+
classification) and may narrow its match with `name_regex` (searched
77+
against the raw input string) and/or `fields` (the diffing rule
78+
matches only if the observed diff fields are a subset of this list).
79+
Keep both as tight as the actual diff allows -- a loose rule can mask
80+
a real regression.
81+
82+
Some entries in the seed list are for behavior families that this
83+
particular corpus (pre-M12 v1 test strings) happens not to contain any
84+
example of (e.g. custom suffix-delimiter rendering, which only fires
85+
under a non-default `Policy`). They're kept in the file anyway,
86+
matching the family documented in `tests/v2/cases.py`, so the rule is
87+
ready the moment a matching string is added to the corpus.

tools/differential/build_corpus.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Extract every plausible test-name string from the v1 test suite:
2+
string literals passed as the first argument to HumanName(...) calls,
3+
plus string elements/keys of module-level list/dict banks. Dev tooling
4+
-- over-collection is fine, the comparator just parses more names.
5+
6+
PROVENANCE: the checked-in tools/differential/corpus.jsonl was built
7+
against commit 2d5d8c2 ("Trim constant-factor waste on the tokenize
8+
hot path"), the last commit before M12 reconciled/edited the v1 test
9+
banks (M12 rewrote expectations and deleted the bucket-A tests, which
10+
would have shrunk the corpus). Reading historical test content is
11+
fine; per the migration rules, no git *operations* are ever run against
12+
the original (non-worktree) checkout -- this script only shells out to
13+
`git show <ref>:<path>` inside the current worktree's own history,
14+
which contains that commit as an ancestor.
15+
16+
Regenerate with:
17+
uv run python tools/differential/build_corpus.py --ref 2d5d8c2 \\
18+
> tools/differential/corpus.jsonl
19+
"""
20+
import argparse
21+
import ast
22+
import json
23+
import subprocess
24+
import sys
25+
from pathlib import Path
26+
27+
ROOT = Path(__file__).resolve().parents[2]
28+
TESTS = ROOT / "tests"
29+
30+
# Obvious non-names the AST heuristic sweeps up along with real names:
31+
# format-string placeholders, decorators/emails, escape sequences.
32+
_NOISE_CHARS = ("{", "@", "\\")
33+
34+
35+
def _is_name_like(value: str) -> bool:
36+
return not any(ch in value for ch in _NOISE_CHARS)
37+
38+
39+
def _strings_from(tree: ast.Module) -> set[str]:
40+
found: set[str] = set()
41+
for node in ast.walk(tree):
42+
if (isinstance(node, ast.Call)
43+
and getattr(node.func, "id", getattr(
44+
node.func, "attr", "")) == "HumanName"
45+
and node.args
46+
and isinstance(node.args[0], ast.Constant)
47+
and isinstance(node.args[0].value, str)):
48+
found.add(node.args[0].value)
49+
if isinstance(node, ast.Assign) and isinstance(
50+
node.value, (ast.List, ast.Tuple, ast.Dict)):
51+
for c in ast.walk(node.value):
52+
if isinstance(c, ast.Constant) and isinstance(c.value, str) \
53+
and " " in c.value and "\n" not in c.value:
54+
found.add(c.value)
55+
return {n for n in found if _is_name_like(n)}
56+
57+
58+
def _working_tree_sources() -> dict[str, str]:
59+
return {path.name: path.read_text()
60+
for path in sorted(TESTS.glob("test_*.py"))}
61+
62+
63+
def _ref_sources(ref: str) -> dict[str, str]:
64+
"""Read every top-level tests/test_*.py file as it existed at `ref`,
65+
via `git show`, without touching the working tree or index."""
66+
listing = subprocess.run(
67+
["git", "-C", str(ROOT), "ls-tree", "-r", "--name-only", ref,
68+
"--", "tests"],
69+
capture_output=True, text=True, check=True).stdout
70+
paths = sorted(
71+
p for p in listing.splitlines()
72+
if p.startswith("tests/") and Path(p).parent == Path("tests")
73+
and Path(p).name.startswith("test_") and p.endswith(".py"))
74+
sources = {}
75+
for path in paths:
76+
show = subprocess.run(
77+
["git", "-C", str(ROOT), "show", f"{ref}:{path}"],
78+
capture_output=True, text=True, check=True)
79+
sources[Path(path).name] = show.stdout
80+
return sources
81+
82+
83+
def main() -> None:
84+
ap = argparse.ArgumentParser()
85+
ap.add_argument(
86+
"--ref", default=None,
87+
help="git ref to read tests/test_*.py from (via `git show`) "
88+
"instead of the working tree, e.g. 2d5d8c2 (pre-M12).")
89+
args = ap.parse_args()
90+
91+
sources = _ref_sources(args.ref) if args.ref else _working_tree_sources()
92+
names: set[str] = set()
93+
for filename, text in sources.items():
94+
try:
95+
tree = ast.parse(text, filename=filename)
96+
except SyntaxError as e:
97+
print(f"skipping {filename}: {e}", file=sys.stderr)
98+
continue
99+
names |= _strings_from(tree)
100+
101+
for name in sorted(names):
102+
print(json.dumps(name, ensure_ascii=False))
103+
print(f"{len(names)} names", file=sys.stderr)
104+
105+
106+
if __name__ == "__main__":
107+
main()

tools/differential/compare.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Differential harness (migration spec S5): 1.4-on-PyPI vs the working
2+
tree over the corpus. Every diff must classify against
3+
expected_changes.toml or the run fails.
4+
5+
uv run python tools/differential/compare.py [--corpus corpus.jsonl]
6+
"""
7+
import argparse
8+
import json
9+
import re
10+
import subprocess
11+
import tomllib
12+
from pathlib import Path
13+
14+
HERE = Path(__file__).resolve().parent
15+
FIELDS = ("title", "first", "middle", "last", "suffix", "nickname",
16+
"maiden")
17+
18+
19+
def classify(name: str, diff_fields: set[str],
20+
rules: list[dict[str, object]]) -> str | None:
21+
for rule in rules:
22+
name_regex = rule.get("name_regex")
23+
if isinstance(name_regex, str) and not re.search(name_regex, name):
24+
continue
25+
fields = rule.get("fields")
26+
if isinstance(fields, list) and not diff_fields <= set(fields):
27+
continue
28+
issue = rule["issue"]
29+
assert isinstance(issue, str)
30+
return issue
31+
return None
32+
33+
34+
def main() -> int:
35+
ap = argparse.ArgumentParser()
36+
ap.add_argument("--corpus", default=str(HERE / "corpus.jsonl"))
37+
args = ap.parse_args()
38+
rules = tomllib.loads(
39+
(HERE / "expected_changes.toml").read_text()).get("change", [])
40+
corpus = [json.loads(line) for line in
41+
Path(args.corpus).read_text().splitlines() if line.strip()]
42+
43+
proc = subprocess.Popen(
44+
["uv", "run", "--no-project", str(HERE / "worker_v1.py")],
45+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
46+
v1_input = "".join(json.dumps(n, ensure_ascii=False) + "\n"
47+
for n in corpus)
48+
v1_lines, _ = proc.communicate(v1_input)
49+
v1_results = [json.loads(line) for line in v1_lines.splitlines()]
50+
assert len(v1_results) == len(corpus), "worker line count mismatch"
51+
52+
from nameparser import HumanName # the working tree (2.0 facade)
53+
by_issue: dict[str, list[str]] = {}
54+
unexplained: list[tuple[str, dict[str, str], dict[str, str]]] = []
55+
for name, old in zip(corpus, v1_results):
56+
new = {k: v or "" for k, v in HumanName(name).as_dict().items()}
57+
diff = {f for f in FIELDS if old.get(f, "") != new.get(f, "")}
58+
if not diff:
59+
continue
60+
issue = classify(name, diff, rules)
61+
if issue is None:
62+
unexplained.append((name, old, new))
63+
else:
64+
by_issue.setdefault(issue, []).append(name)
65+
66+
print(f"corpus: {len(corpus)} names; "
67+
f"intentional diffs: {sum(map(len, by_issue.values()))}; "
68+
f"unexplained: {len(unexplained)}\n")
69+
for issue, names in sorted(by_issue.items()):
70+
print(f"## {issue} ({len(names)})")
71+
for n in names[:10]:
72+
print(f" {n!r}")
73+
print()
74+
for name, old, new in unexplained:
75+
print(f"UNEXPLAINED {name!r}")
76+
for f in FIELDS:
77+
if old.get(f, "") != new.get(f, ""):
78+
print(f" {f}: {old.get(f, '')!r} -> {new.get(f, '')!r}")
79+
return 1 if unexplained else 0
80+
81+
82+
if __name__ == "__main__":
83+
raise SystemExit(main())

0 commit comments

Comments
 (0)