From 48819a73f1c26dcb90bc0dfe224d4ca64d96c182 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 00:46:55 -0700 Subject: [PATCH 01/17] Rename the 1.4 ledger to name its baseline --- tests/v2/cases.py | 2 +- tests/v2/test_regex_sync.py | 8 ++++---- tools/differential/README.md | 6 +++--- tools/differential/compare.py | 8 ++++---- .../{expected_changes.toml => expected_since_1.4.0.toml} | 0 5 files changed, 12 insertions(+), 12 deletions(-) rename tools/differential/{expected_changes.toml => expected_since_1.4.0.toml} (100%) diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 3b5ce5d9..6e150c40 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -177,7 +177,7 @@ def __post_init__(self) -> None: "the pair pre-parse and re-appended it, reordering the " "tail, where 2.0 renders it as written. Same words, " "same roles, different order, and the harness cannot " - "currently see it: expected_changes.toml states that a " + "currently see it: expected_since_1.4.0.toml states that a " "diffing trailing 'Ph. D.' must fail the run, but this " "input is absorbed by fix(comma-family), whose " "name_regex is a bare comma -- measured on a probe " diff --git a/tests/v2/test_regex_sync.py b/tests/v2/test_regex_sync.py index 7d314ef3..cdd51452 100644 --- a/tests/v2/test_regex_sync.py +++ b/tests/v2/test_regex_sync.py @@ -192,7 +192,7 @@ def _expected_bmp_spans() -> set[tuple[int, int]]: def test_differential_cjk_rule_matches_the_script_ranges() -> None: - """The CJK rule in tools/differential/expected_changes.toml hand- + """The CJK rule in tools/differential/expected_since_1.4.0.toml hand- copies the script spans from _policy._SCRIPT_RANGES into a character class. A TOML file cannot import the constant, so this is the one copy with no possible alternative -- and the one whose divergence @@ -237,7 +237,7 @@ def test_differential_cjk_rule_matches_the_script_ranges() -> None: has to be written down to exist. """ toml_path = (Path(__file__).parents[2] / "tools" / "differential" - / "expected_changes.toml") + / "expected_since_1.4.0.toml") rules = tomllib.loads(toml_path.read_text())["change"] matched = [r for r in rules if "#271" in r["issue"] or "#272" in r["issue"]] @@ -284,7 +284,7 @@ def test_every_span_bearing_rule_matches_the_script_ranges() -> None: uniqueness -- compound slugs must avoid them. """ toml_path = (Path(__file__).parents[2] / "tools" / "differential" - / "expected_changes.toml") + / "expected_since_1.4.0.toml") rules = tomllib.loads(toml_path.read_text())["change"] table_spans = _expected_bmp_spans() checked = [] @@ -349,7 +349,7 @@ def test_differential_honorific_rule_matches_the_suffix_vocabulary() -> None: from nameparser.config.suffixes import SUFFIX_NOT_ACRONYMS toml_path = (Path(__file__).parents[2] / "tools" / "differential" - / "expected_changes.toml") + / "expected_since_1.4.0.toml") rules = tomllib.loads(toml_path.read_text())["change"] matched = [r for r in rules if "cjk-honorific-suffix" in r["issue"]] assert len(matched) == 1 diff --git a/tools/differential/README.md b/tools/differential/README.md index f683c0de..80815cca 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -27,13 +27,13 @@ uv run python tools/differential/compare.py name as a line of JSON, and diffs the two component dicts on the seven v1 field names (`title`, `first`, `middle`, `last`, `suffix`, `nickname`, `maiden` -- both sides use these keys, so no field mapping -is needed). Every diff is checked against `expected_changes.toml`: +is needed). Every diff is checked against `expected_since_1.4.0.toml`: - Matches a rule -> counted as an intentional, classified change. - Matches no rule -> printed under `UNEXPLAINED` and the run exits 1. An unexplained diff means either a real 2.0 parity bug (fix it, don't -allowlist it) or a known change whose `expected_changes.toml` rule +allowlist it) or a known change whose `expected_since_1.4.0.toml` rule needs widening. The run must exit 0 before a 2.0 release; the classified summary it prints is the source for the "Behavior Changes" section of `docs/release_log.rst`. @@ -165,7 +165,7 @@ as new issues arrive. Over-collection is fine in both builders: the comparator just parses more names, and junk like `Bridge (1.4)` costs one parse and produces no diff. -## `expected_changes.toml` +## `expected_since_1.4.0.toml` Each `[[change]]` entry needs `issue` (a short label, ideally an issue number or `fix()` matching a `tests/v2/cases.py` diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 8477504e..a98767cf 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -1,6 +1,6 @@ """Differential harness (migration spec S5): 1.4-on-PyPI vs the working tree over the corpus. Every diff must classify against -expected_changes.toml or the run fails. +expected_since_1.4.0.toml or the run fails. uv run python tools/differential/compare.py [--corpus corpus.jsonl] """ @@ -25,12 +25,12 @@ def validate_rules(rules: list[dict[str, object]]) -> None: issue = rule.get("issue") if not isinstance(issue, str) or not issue: raise SystemExit( - f"expected_changes.toml rule #{i + 1} has no string " + f"expected_since_1.4.0.toml rule #{i + 1} has no string " f"'issue': {rule!r}") if not isinstance(rule.get("name_regex"), str) \ and not isinstance(rule.get("fields"), list): raise SystemExit( - f"expected_changes.toml rule #{i + 1} ({issue!r}) has " + f"expected_since_1.4.0.toml rule #{i + 1} ({issue!r}) has " f"neither 'name_regex' nor 'fields' -- it would match " f"every diff and shadow every later rule") @@ -60,7 +60,7 @@ def main() -> int: paths = ([Path(p) for p in args.corpus] if args.corpus else sorted(HERE.glob("corpus*.jsonl"))) rules = tomllib.loads( - (HERE / "expected_changes.toml").read_text()).get("change", []) + (HERE / "expected_since_1.4.0.toml").read_text()).get("change", []) validate_rules(rules) # Most-specific-first: a name_regex rule outranks a fields-only rule # wherever both match, so file order stops being load-bearing. The diff --git a/tools/differential/expected_changes.toml b/tools/differential/expected_since_1.4.0.toml similarity index 100% rename from tools/differential/expected_changes.toml rename to tools/differential/expected_since_1.4.0.toml From e19a6db7fd2920011e9a402059d49158f34295df Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 00:49:01 -0700 Subject: [PATCH 02/17] Compare baseline versions as release tuples, not strings --- tests/v2/test_differential.py | 53 +++++++++++++++++++++++++++++++++++ tools/differential/compare.py | 35 +++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/v2/test_differential.py diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py new file mode 100644 index 00000000..0a352868 --- /dev/null +++ b/tests/v2/test_differential.py @@ -0,0 +1,53 @@ +"""Unit tests for the differential gate's decision logic. + +`tools/` is outside `testpaths`, and adding it would run +`--doctest-modules` over the corpus builders, so `compare.py` is +imported by path here -- the same way `test_regex_sync.py` already +imports `build_cjk_corpus`. + +Only pure logic is covered: nothing here spawns `uv` or the network. +What is tested is what produces FALSE CONFIDENCE when it silently +misbehaves -- which surfaces get compared, which ledger gets consulted, +and above all whether a version tell is believed. +""" +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + +_TOOLS = Path(__file__).parents[2] / "tools" / "differential" + + +def _load_compare() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "differential_compare", _TOOLS / "compare.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +compare = _load_compare() + + +def test_parse_version_pads_a_short_release_to_three_parts() -> None: + """A requested '2.0' and a wheel reporting '2.0.0' are the same + release; comparing the raw strings would call them unequal and + abort a correct run as a tell mismatch.""" + assert compare._parse_version("2.0") == compare._parse_version("2.0.0") + + +def test_parse_version_orders_numerically_not_lexically() -> None: + """The bug string comparison would introduce: '10.0.0' sorts BELOW + '2.0.0' as text.""" + assert compare._parse_version("10.0.0") > compare._parse_version("2.0.0") + + +def test_parse_version_ignores_a_prerelease_segment() -> None: + assert compare._parse_version("2.0.0rc1") == (2, 0, 0) + + +def test_parse_version_rejects_a_string_with_no_release_in_it() -> None: + with pytest.raises(SystemExit, match="cannot parse a version"): + compare._parse_version("not-a-version") diff --git a/tools/differential/compare.py b/tools/differential/compare.py index a98767cf..545ffa50 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -15,6 +15,41 @@ FIELDS = ("title", "first", "middle", "last", "suffix", "nickname", "maiden") +DEFAULT_BASELINE = "2.0.0" +REPO_ROOT = HERE.parents[1] +#: The v2 API's names for the same seven roles FIELDS names in v1 +#: vocabulary. Both are compared from baseline 2.0 on. +V2_FIELDS = ("title", "given", "middle", "family", "suffix", "nickname", + "maiden") +#: The two roles the FACADE names differently from Role. Diffs from +#: both surfaces canonicalize to Role's names before classification, so +#: a ledger rule names a role once -- and names it the way the codebase +#: already does everywhere else (AGENTS.md, "canonical field order"). +#: The facade's vocabulary is the one that expires, at 3.0. +_V1_TO_ROLE = {"first": "given", "last": "family"} + + +def _parse_version(text: str) -> tuple[int, int, int]: + """The numeric release tuple, padded to three parts. Every version + comparison in this file goes through it. + + Explicit because string comparison is wrong twice over here: it + orders '1.4.0' < '2.0.0' by luck and would misorder a future + '10.0.0', and it would call a requested '2.0' unequal to a wheel + reporting '2.0.0' -- turning a correct run into a spurious tell + mismatch, which is an abort on a run that was fine. + + A prerelease segment is ignored: '2.0.0rc1' is release (2, 0, 0), + because what is being asked is which RELEASE answered. + """ + m = re.match(r"\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?", text) + if not m: + raise SystemExit( + f"cannot parse a version from {text!r}: expected a numeric " + f"release like '2.0.0'") + major, minor, micro = (int(p) if p else 0 for p in m.groups()) + return (major, minor, micro) + def validate_rules(rules: list[dict[str, object]]) -> None: """Reject malformed allowlist rules LOUDLY at startup. A rule with From d0cb31e677bf50d5a7f53684cc8c34dbfd3c0925 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 00:50:57 -0700 Subject: [PATCH 03/17] Derive compared surfaces from the baseline version --- tests/v2/test_differential.py | 15 +++++++++++++++ tools/differential/compare.py | 14 ++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 0a352868..c378c53e 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -51,3 +51,18 @@ def test_parse_version_ignores_a_prerelease_segment() -> None: def test_parse_version_rejects_a_string_with_no_release_in_it() -> None: with pytest.raises(SystemExit, match="cannot parse a version"): compare._parse_version("not-a-version") + + +@pytest.mark.parametrize("version,expected", [ + ("1.4.0", {"facade"}), + ("1.4", {"facade"}), + ("1.9.9", {"facade"}), + ("2.0.0", {"facade", "v2"}), + ("2.0", {"facade", "v2"}), + ("2.1.0", {"facade", "v2"}), + # the row string comparison gets wrong: '10.0.0' < '2.0.0' as text + ("10.0.0", {"facade", "v2"}), +]) +def test_surfaces_are_derived_from_the_baseline( + version: str, expected: set[str]) -> None: + assert compare._surfaces_for(version) == expected diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 545ffa50..506c9932 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -51,6 +51,20 @@ def _parse_version(text: str) -> tuple[int, int, int]: return (major, minor, micro) +def _surfaces_for(version: str) -> frozenset[str]: + """Which output surfaces a baseline can be compared on. + + 1.4 has no v2 API, so a pre-2.0 baseline compares the facade + alone. From 2.0 on both are compared: the v2 API is the primary + surface for 2.x users, and its ambiguity kinds catch a change the + field diff cannot see -- a parse that starts or stops reporting + SEGMENTATION while every field stays byte-identical. + """ + if _parse_version(version) >= (2, 0, 0): + return frozenset({"facade", "v2"}) + return frozenset({"facade"}) + + def validate_rules(rules: list[dict[str, object]]) -> None: """Reject malformed allowlist rules LOUDLY at startup. A rule with neither name_regex nor fields would match every diff and shadow From 09b7dedef05ee8074055252223dbd7afe2662e0e Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 00:51:30 -0700 Subject: [PATCH 04/17] Select the ledger by baseline, and make the rule sort testable --- tests/v2/test_differential.py | 38 +++++++++++++++++++++++++++++++++++ tools/differential/compare.py | 27 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index c378c53e..c9287de4 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -66,3 +66,41 @@ def test_parse_version_rejects_a_string_with_no_release_in_it() -> None: def test_surfaces_are_derived_from_the_baseline( version: str, expected: set[str]) -> None: assert compare._surfaces_for(version) == expected + + +def test_allowlist_path_is_named_for_its_baseline() -> None: + assert compare._allowlist_for("1.4.0").name == "expected_since_1.4.0.toml" + + +def test_allowlist_for_a_baseline_with_no_ledger_is_a_hard_error() -> None: + """Not an empty rule set. An empty set classifies nothing, so every + diff reports as unexplained -- which reads as a catastrophic + regression rather than as a missing file, and sends the reader + hunting the parser instead of the ledger.""" + with pytest.raises(SystemExit, match="no allowlist for baseline"): + compare._allowlist_for("9.9.9") + + +def test_name_regex_rules_sort_ahead_of_fields_only_rules() -> None: + """Most-specific-first, so file order stops being load-bearing.""" + rules = [{"issue": "broad", "fields": ["first"]}, + {"issue": "specific", "name_regex": "Smith"}] + assert [r["issue"] for r in compare._sorted_rules(rules)] \ + == ["specific", "broad"] + + +def test_rule_sort_is_stable_within_a_tier() -> None: + rules = [{"issue": "a", "name_regex": "A"}, + {"issue": "b", "name_regex": "B"}] + assert [r["issue"] for r in compare._sorted_rules(rules)] == ["a", "b"] + + +def test_classify_returns_none_when_no_rule_matches() -> None: + rules = [{"issue": "x", "name_regex": "Zzz"}] + assert compare.classify("John Smith", {"first"}, rules) is None + + +def test_classify_takes_the_first_matching_rule() -> None: + rules = [{"issue": "specific", "name_regex": "Smith"}, + {"issue": "broad", "fields": ["first"]}] + assert compare.classify("John Smith", {"first"}, rules) == "specific" diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 506c9932..d71224f0 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -65,6 +65,33 @@ def _surfaces_for(version: str) -> frozenset[str]: return frozenset({"facade"}) +def _allowlist_for(version: str) -> Path: + """The ledger for a baseline, one file per baseline so each + release's classified changes stay as history. + + A missing file is a hard error rather than an empty rule set: an + empty set classifies nothing, so every diff reports UNEXPLAINED and + the run reads as a catastrophic regression instead of as a missing + file. + """ + path = HERE / f"expected_since_{version}.toml" + if not path.exists(): + raise SystemExit( + f"no allowlist for baseline {version!r}: expected {path}. " + f"Create it before running this baseline -- an absent " + f"ledger cannot classify anything, so every diff would " + f"report as unexplained.") + return path + + +def _sorted_rules(rules: list[dict[str, object]]) -> list[dict[str, object]]: + """Most-specific-first: a name_regex rule outranks a fields-only + rule wherever both match, so file order stops being load-bearing. + The sort is stable, so rules within a tier keep the order they were + written in.""" + return sorted(rules, key=lambda r: not isinstance(r.get("name_regex"), str)) + + def validate_rules(rules: list[dict[str, object]]) -> None: """Reject malformed allowlist rules LOUDLY at startup. A rule with neither name_regex nor fields would match every diff and shadow From d4a088caf5773705bc46f9b5686113e3c14862be Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 00:54:03 -0700 Subject: [PATCH 05/17] Generate the baseline worker from a pinned template --- tests/v2/test_differential.py | 20 +++++++++++ tools/differential/compare.py | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index c9287de4..854b88e1 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -104,3 +104,23 @@ def test_classify_takes_the_first_matching_rule() -> None: rules = [{"issue": "specific", "name_regex": "Smith"}, {"issue": "broad", "fields": ["first"]}] assert compare.classify("John Smith", {"first"}, rules) == "specific" + + +def test_worker_source_carries_the_requested_pin() -> None: + src = compare._worker_source("2.0.0", want_v2=True) + assert 'dependencies = ["nameparser==2.0.0"]' in src + + +def test_worker_source_always_emits_a_version_tell() -> None: + """The tell is the whole defence against a worker that silently + resolved to the checkout, so it is not conditional on anything.""" + for want_v2 in (True, False): + src = compare._worker_source("1.4.0", want_v2=want_v2) + assert "__version__" in src and "__file__" in src + + +def test_worker_source_gates_the_v2_import_on_the_baseline() -> None: + """1.4 has no nameparser.parse to import; asking for it would make + the worker die on import rather than report a clean facade diff.""" + assert "WANT_V2 = False" in compare._worker_source("1.4.0", want_v2=False) + assert "WANT_V2 = True" in compare._worker_source("2.0.0", want_v2=True) diff --git a/tools/differential/compare.py b/tools/differential/compare.py index d71224f0..34d48352 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -92,6 +92,70 @@ def _sorted_rules(rules: list[dict[str, object]]) -> list[dict[str, object]]: return sorted(rules, key=lambda r: not isinstance(r.get("name_regex"), str)) +_WORKER_TEMPLATE = '''\ +# /// script +# requires-python = ">=3.9" +# dependencies = ["nameparser==@@VERSION@@"] +# /// +"""GENERATED by tools/differential/compare.py -- edit the template +there, not a copy of this. + +Writes a VERSION TELL as its first stdout line, then one result per +input name. The tell exists because the alternative failure is +invisible: a worker that silently resolved to the checkout answers +every query as the working tree while the run is labelled with the +baseline, so every diff vanishes and the run reports parity -- the +precise opposite of the truth. +""" +import json +import sys + +import nameparser +from nameparser import HumanName + +V1_FIELDS = ("title", "first", "middle", "last", "suffix", "nickname", + "maiden") +V2_FIELDS = ("title", "given", "middle", "family", "suffix", "nickname", + "maiden") +WANT_V2 = @@WANT_V2@@ + +print(json.dumps({"__version__": nameparser.__version__, + "__file__": nameparser.__file__}), flush=True) + +if WANT_V2: + from nameparser import parse + +for line in sys.stdin: + line = line.strip() + if not line: + continue + name = json.loads(line) + row = {"facade": {k: v or "" + for k, v in HumanName(name).as_dict().items() + if k in V1_FIELDS}} + if WANT_V2: + p = parse(name) + v2 = {f: (getattr(p, f, "") or "") for f in V2_FIELDS} + v2["_ambiguities"] = sorted( + {a.kind.name for a in getattr(p, "ambiguities", ())}) + row["v2"] = v2 + print(json.dumps(row, ensure_ascii=False), flush=True) +''' + + +def _worker_source(version: str, want_v2: bool) -> str: + """Render the worker with its dependency pin substituted. + + Sentinel replacement rather than str.format or f-strings: the + worker body is mostly literal braces, and escaping every one of + them is a defect waiting to happen in a file whose silent + misbehavior is the thing this harness exists to prevent. + """ + return (_WORKER_TEMPLATE + .replace("@@VERSION@@", version) + .replace("@@WANT_V2@@", "True" if want_v2 else "False")) + + def validate_rules(rules: list[dict[str, object]]) -> None: """Reject malformed allowlist rules LOUDLY at startup. A rule with neither name_regex nor fields would match every diff and shadow From 797a96343544c23baf05b250c096ba88067a361c Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 00:55:00 -0700 Subject: [PATCH 06/17] Verify which library answered before trusting a comparison --- tests/v2/test_differential.py | 35 +++++++++++++++++ tools/differential/compare.py | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 854b88e1..182d2fa9 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -124,3 +124,38 @@ def test_worker_source_gates_the_v2_import_on_the_baseline() -> None: the worker die on import rather than report a clean facade diff.""" assert "WANT_V2 = False" in compare._worker_source("1.4.0", want_v2=False) assert "WANT_V2 = True" in compare._worker_source("2.0.0", want_v2=True) + + +_WHEEL = "/Users/x/.cache/uv/environments-v2/w/lib/python3.11/" \ + "site-packages/nameparser/__init__.py" + + +def test_tell_accepts_a_matching_wheel() -> None: + compare._check_tell({"__version__": "2.0.0", "__file__": _WHEEL}, "2.0.0") + + +def test_tell_accepts_an_equivalent_short_release() -> None: + compare._check_tell({"__version__": "2.0.0", "__file__": _WHEEL}, "2.0") + + +def test_tell_rejects_a_version_mismatch() -> None: + with pytest.raises(SystemExit, match="not the requested"): + compare._check_tell( + {"__version__": "2.1.0", "__file__": _WHEEL}, "2.0.0") + + +def test_tell_rejects_a_module_loaded_from_the_checkout() -> None: + """The failure the whole design exists to make impossible. An + editable install reports the TREE's version, so when the tree and + the baseline share a version the version half of the tell agrees + and only the path gives it away. + """ + checkout = _TOOLS.parents[1] / "nameparser" / "__init__.py" + with pytest.raises(SystemExit, match="CHECKOUT"): + compare._check_tell( + {"__version__": "2.0.0", "__file__": str(checkout)}, "2.0.0") + + +def test_tell_rejects_an_empty_tell() -> None: + with pytest.raises(SystemExit): + compare._check_tell({}, "2.0.0") diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 34d48352..834ddafa 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -8,6 +8,7 @@ import json import re import subprocess +import tempfile import tomllib from pathlib import Path @@ -156,6 +157,77 @@ def _worker_source(version: str, want_v2: bool) -> str: .replace("@@WANT_V2@@", "True" if want_v2 else "False")) +def _check_tell(tell: dict[str, str], version: str) -> None: + """Abort before comparing anything if the wrong library answered. + + This is the check the README's trap sections exist for. Both halves + matter and neither implies the other: an editable install reports + the TREE's version, so version-only agreement proves nothing when + the tree and the baseline share a number, while a genuine wheel at + the wrong version passes any path check. + """ + got = tell.get("__version__", "") + where = tell.get("__file__", "") + if not got or not where: + raise SystemExit( + f"baseline worker produced no usable version tell " + f"({tell!r}); comparison aborted") + if _parse_version(got) != _parse_version(version): + raise SystemExit( + f"baseline worker reports nameparser {got!r}, not the " + f"requested {version!r} (loaded from {where}). See the " + f"invocation traps in tools/differential/README.md; " + f"comparison aborted.") + if Path(where).resolve().is_relative_to(REPO_ROOT): + raise SystemExit( + f"baseline worker loaded nameparser from the CHECKOUT " + f"({where}), so it answers as the working tree while " + f"reporting {got!r} -- every diff would vanish and the run " + f"would read as parity. Comparison aborted.") + + +def _run_worker(version: str, want_v2: bool, + names: list[str]) -> tuple[dict[str, str], list[dict]]: + """Run the baseline worker from a temp dir OUTSIDE the worktree. + + The placement is the safety mechanism, not plumbing. uv reads + genuine PEP 723 metadata from a real script path, and sys.path[0] + is the script's directory -- a temp dir holding no nameparser -- so + the checkout cannot shadow the pinned wheel. The README notes that + an absolute script path from outside the project is the one + invocation variant that does not lie; this makes it the only one + reachable. + """ + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "baseline_worker.py" + script.write_text(_worker_source(version, want_v2), encoding="utf-8") + proc = subprocess.Popen( + ["uv", "run", "--no-project", str(script)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + cwd=tmp) + payload = "".join(json.dumps(n, ensure_ascii=False) + "\n" + for n in names) + out, _ = proc.communicate(payload) + # hard checks, not asserts: -O must not turn a crashed worker into + # a truncated-but-green comparison + if proc.returncode != 0: + raise SystemExit( + f"baseline worker exited {proc.returncode}; comparison aborted") + lines = out.splitlines() + if not lines: + raise SystemExit( + "baseline worker produced no output, not even a version " + "tell; comparison aborted") + tell = json.loads(lines[0]) + _check_tell(tell, version) + results = [json.loads(x) for x in lines[1:]] + if len(results) != len(names): + raise SystemExit( + f"worker returned {len(results)} results for {len(names)} " + f"corpus names; comparison aborted") + return tell, results + + def validate_rules(rules: list[dict[str, object]]) -> None: """Reject malformed allowlist rules LOUDLY at startup. A rule with neither name_regex nor fields would match every diff and shadow From b3d4dae74aa31c41f83d4e7b4d6fa49e12e1c8fe Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 00:57:09 -0700 Subject: [PATCH 07/17] Canonicalize v2 field names, and partition diffs by input script --- tests/v2/test_differential.py | 31 +++++++++++++++++++++++++++++++ tools/differential/compare.py | 19 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 182d2fa9..13c9c4a7 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -159,3 +159,34 @@ def test_tell_rejects_a_module_loaded_from_the_checkout() -> None: def test_tell_rejects_an_empty_tell() -> None: with pytest.raises(SystemExit): compare._check_tell({}, "2.0.0") + + +def test_facade_field_names_canonicalize_to_role_vocabulary() -> None: + """Both surfaces name the same seven roles with different words, + and Role's names win -- AGENTS.md already makes Role's declaration + order canonical "defined once and derived everywhere", and the + facade's vocabulary expires at 3.0.""" + assert compare._canonical_field("first") == "given" + assert compare._canonical_field("last") == "family" + assert compare._canonical_field("middle") == "middle" + assert compare._canonical_field("_ambiguities") == "_ambiguities" + + +def test_canonical_field_is_idempotent_on_role_names() -> None: + """Both surfaces' diffs pass through it, and the v2 surface's names + are already canonical, so applying it must be a no-op there.""" + for role in compare.V2_FIELDS: + assert compare._canonical_field(role) == role + + +@pytest.mark.parametrize("name,latin", [ + ("John Smith", True), + ("Anna Müller", True), + ("Jane Smith (née Jones)", True), + ("田中さん", False), + ("김민준", False), + ("Хосе Сантос", False), + ("威廉·莎士比亚", False), +]) +def test_latin_only_partition(name: str, latin: bool) -> None: + assert compare._is_latin_only(name) is latin diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 834ddafa..daaf84c6 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -228,6 +228,25 @@ def _run_worker(version: str, want_v2: bool, return tell, results +def _canonical_field(field: str) -> str: + """A role's canonical name: Role's, not the facade's. Applied to + diffs from BOTH surfaces, so it must be a no-op on names that are + already canonical.""" + return _V1_TO_ROLE.get(field, field) + + +def _is_latin_only(name: str) -> bool: + """Every character below U+0250 -- Latin, ASCII punctuation and + Latin-1 accents. + + The partition is by SCRIPT OF THE INPUT, not by which issue claimed + the diff, because the question it answers is whether a user parsing + Western names sees any change at all. That number goes into the + release notes. + """ + return all(ord(ch) < 0x250 for ch in name) + + def validate_rules(rules: list[dict[str, object]]) -> None: """Reject malformed allowlist rules LOUDLY at startup. A rule with neither name_regex nor fields would match every diff and shadow From c680c97c60e876a4018417c1ebd520a33c5f581c Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 01:00:46 -0700 Subject: [PATCH 08/17] Point the gate at any released baseline, in Role's vocabulary --- tests/v2/test_differential.py | 20 +++++ tools/differential/compare.py | 92 ++++++++++++-------- tools/differential/expected_since_1.4.0.toml | 24 ++--- 3 files changed, 89 insertions(+), 47 deletions(-) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 13c9c4a7..6706b766 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -179,6 +179,26 @@ def test_canonical_field_is_idempotent_on_role_names() -> None: assert compare._canonical_field(role) == role +def test_every_ledger_rule_names_roles_canonically() -> None: + """The trap this guards: a rule written in facade vocabulary parses + fine, validates fine, and simply never matches -- the ledger grows + an entry that does nothing, classification silently loosens, and + nothing anywhere says so. Sweeps every ledger, so a new baseline's + file is covered the day it is added.""" + import tomllib + ledgers = sorted(_TOOLS.glob("expected_since_*.toml")) + assert ledgers, "no ledgers found; this test would pass vacuously" + for ledger in ledgers: + rules = tomllib.loads( + ledger.read_text(encoding="utf-8")).get("change", []) + for rule in rules: + for field in rule.get("fields", []): + assert field == compare._canonical_field(field), ( + f"{ledger.name}: rule {rule['issue']!r} names " + f"{field!r}; use " + f"{compare._canonical_field(field)!r}") + + @pytest.mark.parametrize("name,latin", [ ("John Smith", True), ("Anna Müller", True), diff --git a/tools/differential/compare.py b/tools/differential/compare.py index daaf84c6..f2f55178 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -1,8 +1,14 @@ -"""Differential harness (migration spec S5): 1.4-on-PyPI vs the working -tree over the corpus. Every diff must classify against -expected_since_1.4.0.toml or the run fails. +"""Differential harness (migration spec S5): a released baseline vs +the working tree over the corpora. Every diff must classify against +that baseline's ledger or the run fails. - uv run python tools/differential/compare.py [--corpus corpus.jsonl] + uv run python tools/differential/compare.py [--baseline VERSION] + +--baseline 1.4.0 answers the v1 compat contract; the default answers +what changes for a user upgrading from the previous minor. + +Redirect to a file rather than piping: under zsh, `| tail` replaces the +exit code with tail's, so a failing run reads as a passing one. """ import argparse import json @@ -287,17 +293,20 @@ def main() -> int: ap.add_argument("--corpus", action="append", metavar="PATH", help="corpus file; repeatable. Defaults to every " "corpus*.jsonl beside this script.") + ap.add_argument("--baseline", default=DEFAULT_BASELINE, metavar="VERSION", + help=f"released version to compare the tree against " + f"(default {DEFAULT_BASELINE}). Use 1.4.0 for the " + f"v1 compat contract, the previous minor for a " + f"release's blast radius.") args = ap.parse_args() + baseline = args.baseline + surfaces = _surfaces_for(baseline) paths = ([Path(p) for p in args.corpus] if args.corpus else sorted(HERE.glob("corpus*.jsonl"))) rules = tomllib.loads( - (HERE / "expected_since_1.4.0.toml").read_text()).get("change", []) + _allowlist_for(baseline).read_text()).get("change", []) validate_rules(rules) - # Most-specific-first: a name_regex rule outranks a fields-only rule - # wherever both match, so file order stops being load-bearing. The - # sort is stable, so rules within a tier keep the order they were - # written in. - rules.sort(key=lambda r: not isinstance(r.get("name_regex"), str)) + rules = _sorted_rules(rules) # A glob that matches nothing must not read as "everything passed". # Comparing zero names would print 0 unexplained and exit 0 -- the # harness's own stated nightmare (see validate_rules), and a @@ -322,50 +331,63 @@ def main() -> int: print("corpora: " + ", ".join(f"{name} ({n})" for name, n in per_file.items())) - proc = subprocess.Popen( - ["uv", "run", "--no-project", str(HERE / "worker_v1.py")], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) - v1_input = "".join(json.dumps(n, ensure_ascii=False) + "\n" - for n in corpus) - v1_lines, _ = proc.communicate(v1_input) - v1_results = [json.loads(line) for line in v1_lines.splitlines()] - # hard checks, not asserts: -O must not turn a crashed worker into - # a truncated-but-green comparison - if proc.returncode != 0: - raise SystemExit( - f"worker_v1.py exited {proc.returncode}; comparison aborted") - if len(v1_results) != len(corpus): - raise SystemExit( - f"worker returned {len(v1_results)} results for " - f"{len(corpus)} corpus names; comparison aborted") + want_v2 = "v2" in surfaces + tell, old_rows = _run_worker(baseline, want_v2, corpus) + print(f"baseline: nameparser {tell['__version__']} ({tell['__file__']})") - from nameparser import HumanName # the working tree (2.0 facade) + from nameparser import HumanName # the working tree + if want_v2: + from nameparser import parse by_issue: dict[str, list[str]] = {} unexplained: list[tuple[str, dict[str, str], dict[str, str]]] = [] - for name, old in zip(corpus, v1_results): - new = {k: v or "" for k, v in HumanName(name).as_dict().items()} - diff = {f for f in FIELDS if old.get(f, "") != new.get(f, "")} + for name, old in zip(corpus, old_rows): + new = {k: v or "" for k, v in HumanName(name).as_dict().items() + if k in FIELDS} + # canonicalized on the way in: the ledger speaks Role's names, + # and the facade is the surface whose vocabulary differs + diff = {_canonical_field(f) for f in FIELDS + if old["facade"].get(f, "") != new.get(f, "")} + if want_v2: + p = parse(name) + new_v2 = {f: (getattr(p, f, "") or "") for f in V2_FIELDS} + new_v2["_ambiguities"] = sorted( + {a.kind.name for a in getattr(p, "ambiguities", ())}) + diff |= {_canonical_field(f) + for f in (*V2_FIELDS, "_ambiguities") + if old["v2"].get(f, "") != new_v2.get(f, "")} if not diff: continue issue = classify(name, diff, rules) if issue is None: - unexplained.append((name, old, new)) + unexplained.append((name, old["facade"], new)) else: by_issue.setdefault(issue, []).append(name) + changed = [n for names in by_issue.values() for n in names] \ + + [n for n, _, _ in unexplained] + latin = sum(1 for n in changed if _is_latin_only(n)) print(f"corpus: {len(corpus)} names; " f"intentional diffs: {sum(map(len, by_issue.values()))}; " - f"unexplained: {len(unexplained)}\n") + f"unexplained: {len(unexplained)}; " + f"{latin} of {len(changed)} changed names are Latin-only\n") for issue, names in sorted(by_issue.items()): print(f"## {issue} ({len(names)})") for n in names[:10]: print(f" {n!r}") print() - for name, old, new in unexplained: + if unexplained: + print("Field names below are Role's, matching what a ledger " + "`fields` rule must say.\n") + for name, old_facade, new in unexplained: print(f"UNEXPLAINED {name!r}") for f in FIELDS: - if old.get(f, "") != new.get(f, ""): - print(f" {f}: {old.get(f, '')!r} -> {new.get(f, '')!r}") + if old_facade.get(f, "") != new.get(f, ""): + # Role's name, not the facade's: this block exists to + # be turned into a ledger rule, and a rule naming the + # facade's `first` would parse, validate, and never + # match -- with nothing to say so. + print(f" {_canonical_field(f)}: " + f"{old_facade.get(f, '')!r} -> {new.get(f, '')!r}") return 1 if unexplained else 0 diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index 9b118c2e..d96872ef 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -59,12 +59,12 @@ issue = "fix(#271/#272/#298) native-script CJK: family-first order, hangul segme # CJK, and build_issues_corpus.py requires an internal space, which # unspaced names never have. name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65]" -fields = ["first", "middle", "last"] +fields = ["given", "middle", "family"] [[change]] issue = "fix(#274) maiden markers consumed" name_regex = "(?i)\\b(n[ée]e|born|geb\\.?|roz\\.?)\\b" -fields = ["maiden", "middle", "last"] +fields = ["maiden", "middle", "family"] [[change]] issue = "fix(cjk-maiden-marker) maiden marker consumed, compounding with the CJK order flip" @@ -77,7 +77,7 @@ issue = "fix(cjk-maiden-marker) maiden marker consumed, compounding with the CJK # (#328). The regex is the marker itself, so this rule can claim # nothing else. name_regex = "旧姓" -fields = ["first", "middle", "last", "maiden"] +fields = ["given", "middle", "family", "maiden"] [[change]] issue = "fix(comma-family) lone post-comma piece routes to suffix/title, not first" @@ -125,7 +125,7 @@ issue = "fix(comma-family) lone post-comma piece routes to suffix/title, not fir # next to it, '田中さん, V.', appears under no rule at all: it is # parity with 1.4.0, so it never reaches classify(). name_regex = "," -fields = ["first", "title", "suffix"] +fields = ["given", "title", "suffix"] [[change]] issue = "fix(suffix-routing) two-token name with unambiguous trailing suffix stays suffix" @@ -137,7 +137,7 @@ issue = "fix(suffix-routing) two-token name with unambiguous trailing suffix sta # glued Latin honorific -- see the note on fix(comma-family) above for # why its four comma-bearing siblings split between that rule and # fix(cjk-comma-compound) instead. -fields = ["first", "last", "suffix"] +fields = ["given", "family", "suffix"] [[change]] issue = "fix(suffix-delimiter-rendering) no-space delimiter core token kept whole" @@ -173,7 +173,7 @@ issue = "feat(#269) Arabic بن prefix chains onto family (non-Latin new-recogni # Word-bounded: a bare "بن" would also match the substring inside e.g. # لبنان ("Lebanon") and silently absorb unrelated middle/last diffs. name_regex = "\\bبن\\b" -fields = ["middle", "last"] +fields = ["middle", "family"] [[change]] issue = "feat(#273) typographic nickname delimiters recognized by default" @@ -213,7 +213,7 @@ issue = "fix(cjk-delimited-nickname) delimiter recognition compounds with the CJ # test_regex_sync's differential pin selects the canonical CJK rule # by those substrings and asserts it is unique. name_regex = "(?s)(?=.*[「」『』・・])(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65])" -fields = ["first", "last", "nickname"] +fields = ["given", "family", "nickname"] [[change]] issue = "fix(cjk-fullwidth-paren-nickname) fullwidth-parenthesis recognition compounds with the CJK order flip" @@ -247,7 +247,7 @@ issue = "fix(cjk-fullwidth-paren-nickname) fullwidth-parenthesis recognition com # avoids the literal #271/#272 substrings the canonical-rule pin # selects by. name_regex = "(?s)(?=.*[()])(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65])" -fields = ["first", "middle", "last", "nickname"] +fields = ["given", "middle", "family", "nickname"] [[change]] issue = "fix(cjk-comma-compound) comma routing compounds with the CJK order flip" @@ -272,7 +272,7 @@ issue = "fix(cjk-comma-compound) comma routing compounds with the CJK order flip # match fix(comma-family) first instead, purely on file order -- see # the note there for why. name_regex = "(?s)(?=.*,)(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65])" -fields = ["first", "middle", "last", "title", "suffix"] +fields = ["given", "middle", "family", "title", "suffix"] [[change]] issue = "fix(cjk-honorific-suffix) postnominal honorifics recognized, compounding with the CJK order flip" @@ -306,7 +306,7 @@ issue = "fix(cjk-honorific-suffix) postnominal honorifics recognized, compoundin # in the corpus (Latin+glued like Andersonさん included) and every # one classified there -- none carried a middle-field diff. name_regex = "(?:^| )(?:씨|박사|박사님|선생님|교수님|군|양|님|先生|女士|小姐|博士|教授|様|氏|殿|さん|さま|くん|ちゃん)$" -fields = ["first", "middle", "last", "suffix"] +fields = ["given", "middle", "family", "suffix"] [[change]] issue = "feat(#269) non-Latin titles/conjunctions recognized" @@ -327,7 +327,7 @@ issue = "feat(#269) non-Latin titles/conjunctions recognized" # script over loosening this one -- the \bبن\b rule below is the # model. name_regex = "[\\u0400-\\u04FF]" -fields = ["title", "first", "middle"] +fields = ["title", "given", "middle"] [[change]] issue = "fix(leading-credential) a split 'Ph. D.' before the name stays one unit" @@ -343,7 +343,7 @@ issue = "fix(leading-credential) a split 'Ph. D.' before the name stays one unit # stays unclassified below; widening this regex would mask a # regression in the shape that is the whole reason fix_phd exists. name_regex = "^Ph\\. ?D\\." -fields = ["title", "first", "middle", "suffix"] +fields = ["title", "given", "middle", "suffix"] # Deliberately NOT a [[change]] rule: TRAILING 'Ph. D.' split-token # healing ('John Ph. D.', 'John Smith, Ph. D.') is PARITY, not a 2.0 From 176adc98f908ba5d799c2f67143a08e46f83455b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 01:01:44 -0700 Subject: [PATCH 09/17] Drop the hand-written v1 worker for the generated one --- tools/differential/worker_v1.py | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 tools/differential/worker_v1.py diff --git a/tools/differential/worker_v1.py b/tools/differential/worker_v1.py deleted file mode 100644 index f854bd0e..00000000 --- a/tools/differential/worker_v1.py +++ /dev/null @@ -1,25 +0,0 @@ -# /// script -# requires-python = ">=3.9" -# dependencies = ["nameparser==1.4.*"] -# /// -"""v1 worker: reads JSON name strings on stdin (one per line), writes -the 1.4 component dict per line. Run ONLY via: - - uv run --no-project tools/differential/worker_v1.py - ---no-project is load-bearing: without it uv installs the working tree -and shadows the 1.4 pin from PyPI. -""" -import json -import sys - -from nameparser import HumanName - -for line in sys.stdin: - line = line.strip() - if not line: - continue - name = json.loads(line) - n = HumanName(name) - print(json.dumps({k: v or "" for k, v in n.as_dict().items()}, - ensure_ascii=False), flush=True) From b3c636ddcc0bee103d691210939499509326ef79 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 01:09:37 -0700 Subject: [PATCH 10/17] Classify every 2.0-to-2.1 diff --- tools/differential/expected_since_2.0.0.toml | 199 +++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tools/differential/expected_since_2.0.0.toml diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml new file mode 100644 index 00000000..b392ed56 --- /dev/null +++ b/tools/differential/expected_since_2.0.0.toml @@ -0,0 +1,199 @@ +# Ledger for baseline 2.0.0 -- what changes for a user upgrading from +# the previous minor. Same rule grammar as expected_since_1.4.0.toml: +# every rule needs `issue`; optional `name_regex` and `fields` narrow +# it, and compare.py sorts name_regex rules ahead of fields-only ones. +# +# `fields` names roles the way Role does -- title, given, middle, +# family, suffix, nickname, maiden -- for BOTH compared surfaces. A +# rule saying "first" or "last" parses and validates and then never +# matches; the guard in tests/v2/test_differential.py catches it. +# +# Rules here are NOT a subset of the 1.4 ledger's. That one classifies +# everything 2.x changed since 1.4, including what 2.0 itself changed; +# this one classifies only what 2.1 changed on top of 2.0. A rule +# copied across without checking will either over-match (hiding a real +# 2.1 regression behind a 2.0-era label) or never fire. +# +# Every rule below carries a `name_regex`, so they all sit in one tier +# and file order breaks ties between them. Where that matters it is +# said on the rule; the ordering decision made twice here is that the +# GLUED honorific rule precedes the SPACED one, because a name can +# carry both shapes at once. + +[[change]] +issue = "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots" +# The 2.1 East Asian defaults, in the fields they move name pieces +# between. '毛 泽东' and '毛泽东' read family-first through the new +# Policy.script_orders (#271); '김민준' and '남궁민수' additionally +# split, hangul segmentation being on by default (#271), which is also +# where the one _ambiguities diff in the run comes from -- 남궁민수 now +# reports SEGMENTATION, a kind 2.0 did not have. '高橋 みなみ' and +# '山田 エミ' take the same order rule under the kana license (#272), +# while wholly-katakana '마이클·잭슨'-shaped transcriptions stay +# positional and merely divide. The two dots divide tokens: the +# nakaguro ・/・ unconditionally (#272) and the 间隔号 · between +# classified-script characters (#298), so 'マイケル・ジャクソン', +# '威廉·莎士比亚' and '马丁·路德·金' come apart where 2.0 held one +# token. '〆木 太郎' rides in this class too: #303 counts the shime +# mark as Han, which is what puts the name in scope of the order rule +# at all. +# +# One rule, not five, for the reason the 1.4 ledger gives its twin: +# these are one diff shape -- pieces moving between given/middle/family +# on a native-script CJK name -- and splitting by issue would need a +# rule per script and per dot with no gain in tightness, the fields +# list being the narrow half. +# +# `suffix` is deliberately absent, which is what keeps this rule from +# swallowing the honorific work below; `maiden`, `nickname` and `title` +# are absent for the same reason against the compound rules further +# down. The class is copied verbatim from the same rule in +# expected_since_1.4.0.toml rather than rewritten in literal +# characters: tests/v2/test_regex_sync.py pins that spelling against +# the script table, and a hand-rewritten twin drifts silently. +name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65]" +fields = ["given", "middle", "family", "_ambiguities"] + +[[change]] +issue = "fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix" +# '田中さん', '김민준씨', '王小明先生', 'Andersonさん': #308 splits an +# honorific written against the name off the end of its token and +# routes it to `suffix`, where 2.0 left it inside the name. #312 lets +# the peel reach across a comma ('김, 민준씨', '田中, 太郎さん', +# '威廉·莎士比亚さん'), #319 makes it survive an all-credential +# post-comma run ('田中さん, V.', '田中さん, Ph. D.', '김민준씨, V.', +# '김민준씨, J.씨'), and #320 stops an ASCII period vetoing the +# honorific it finds there ('田中さん 様.', '田中さん, 様.'). Every one +# of them is the same diff shape, so they share a rule; the peeled name +# then goes through the ordinary machinery, which is why `given` and +# `family` move alongside `suffix` (김민준씨 -> family 김, given 민준, +# suffix 씨). +# +# Keyed on GLUED_HONORIFICS specifically -- the peelable subset -- and +# not on the wider spaced vocabulary, because that subset is exactly +# the vetting this rule needs. 양, 군, 氏, 博士, 殿 and 君 are excluded +# from it precisely because they can END a name (김지양 is a given +# name, 鵜殿 a surname), so keying on the wider set would let this rule +# claim a suffix regression on '김지양' or '鵜殿' -- names that are in +# this run for the order flip alone and must stay in the rule above. +# +# The lookbehind is the glued half: the honorific must sit against a +# preceding non-space, non-comma character, so a SPACED honorific never +# matches here and falls through to the rule below. The trailing +# `\.?(?=$|[ ,])` is the whole-token half, judged on the name STRING: +# without it any name merely CONTAINING one of these strings would +# match, and the optional period is #320's shape. `middle` is +# deliberately not in `fields` -- no glued diff in this run carries +# one, and admitting it would pre-excuse a middle regression that the +# spaced rule below legitimately has. +# +# Sits AHEAD of the spaced rule on purpose: '선생님, J.씨' opens with a +# spaced honorific and ends with a glued one, and it is the glued 씨 +# that moves. File order is the only thing that decides which label it +# reports under, both rules being in the name_regex tier. That +# ordering also decides '田中さん 様.' and '田中さん, 様.', which carry +# both shapes for real; the glued さん is the piece #308 moves. +# +# One name lands here that is spaced, and it is left that way: the +# SPACED '김민준 박사님' matches on its 님, which sits glued to 사 +# inside 박사님 and ends the string. The label is still true of it -- +# #308 is the change that added 박사님 to the vocabulary at all, so its +# spaced form routes to `suffix` because of #308 -- and closing the +# overlap would take a lookbehind asserting the match is not interior +# to a longer listed honorific, which is more machinery than a correct +# label is worth here. +name_regex = "(?<=[^\\s,])(?:박사님|선생님|교수님|박사|씨|님|先生|女士|小姐|教授|様|さん|さま|くん|ちゃん)\\.?(?=$|[ ,])" +fields = ["given", "family", "suffix"] + +[[change]] +issue = "fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix" +# '王小明 先生', '김민준 씨', '田中 太郎 様', '田中 殿': #307 ships the +# spaced CJK honorifics as suffix vocabulary, so a trailing 先生/씨/様 +# moves to `suffix` where the family-first default of 2.1 would +# otherwise have made it the given name. #308 added さん, さま, くん, +# ちゃん, 殿, 님 and 박사님 to that vocabulary, so their spaced forms +# ('田中 さん', '김민준 박사님') move here too, and #320 lets a trailing +# ASCII period through ('김민준 씨.', '김민준 양.'). +# +# Whole-token on the name STRING -- preceded by start, space or comma, +# followed by end, space or comma, with #320's optional period between. +# That anchor is what the 1.4 ledger's honorific rule exists to +# explain: unanchored, any name ENDING in 양 or 군 would match, and a +# real suffix regression on the glued given name '김지양' would be +# absorbed as intentional. The alternation is the CJK half of +# SUFFIX_NOT_ACRONYMS, written longest-first so '김민준 박사님' matches +# 박사님 rather than stalling on 박사. +# +# `middle` is in `fields` here where the glued rule omits it, and the +# reason is measured: a three-token spaced name loses its middle to the +# order flip while the honorific leaves ('田中 太郎 様' -> given 太郎, +# family 田中, suffix 様; '김민준 박사 씨' likewise). +name_regex = "(?:^|[ ,])(?:박사님|선생님|교수님|박사|씨|님|군|양|先生|女士|小姐|博士|教授|様|氏|殿|さん|さま|くん|ちゃん)\\.?(?=$|[ ,])" +fields = ["given", "middle", "family", "suffix"] + +[[change]] +issue = "fix(#309) 旧姓 maiden marker consumed, compounding with the CJK order flip" +# '山田 花子 旧姓 佐藤', '山田花子 旧姓 佐藤': #309 adds the Japanese +# maiden-name marker to the default vocabulary, so the marker is +# consumed and the name behind it routes to `maiden`, where 2.0 left +# both as plain name text. `given`/`family` move in the same parse +# because the remainder is wholly Han and takes the family-first +# reading (#271), and `middle` empties because that is where 2.0 held +# the marker. +# +# The regex is the marker itself, so the rule can claim nothing that +# does not contain it. Note what is NOT here: '山田 花子(旧姓 佐藤)' +# is in this run as a bare {given, family} order flip, classified by +# the first rule. #329 is gated on a non-empty Policy.maiden_delimiters +# and the harness runs the default policy, where () is a #273 +# NICKNAME delimiter -- so no #329 diff can appear at all, and adding a +# rule for one would be an allowlist entry covering nothing. +name_regex = "旧姓" +fields = ["given", "middle", "family", "maiden"] + +[[change]] +issue = "fix(#272) nakaguro inside delimited content renders as a space, compounding with the CJK order flip" +# '山田 太郎 (マイケル・ジャクソン)': the nakaguro is a token separator +# now, and rendering follows it, so the extracted nickname comes back +# 'マイケル ジャクソン' where 2.0 echoed the dot -- while the wholly-Han +# remainder takes the family-first flip in given/family. Neither +# single-change rule may claim the union: the first rule's fields +# exclude `nickname` on purpose, so a lone nickname regression stays +# loud. +# +# Both lookaheads are required. A delimiter alone matches every +# parenthesized nickname in the Latin corpus and would let this rule +# absorb a bare given/family regression on one; the nakaguro alone is +# already in the first rule's territory. Requiring both confines the +# rule to the one shape it explains. No `middle`: this diff has none, +# and the 1.4 ledger's twin rule reserves that field for the fullwidth +# pair, which under the default policy cannot produce a 2.1 diff. +name_regex = "(?s)(?=.*[・・])(?=.*[((「『])" +fields = ["given", "family", "nickname"] + +[[change]] +issue = "fix(#298) 间隔号 division changes the comma reading, sending the credential from title to suffix" +# '威廉·莎士比亚, PhD': one name, two intended changes at once. #298 +# divides the transcription, so the pre-comma run is two words where +# 2.0 saw one, and the comma structure that follows from the word count +# re-reads the lone post-comma credential -- PhD moves out of `title` +# and into `suffix` -- while given/family take #298's division itself. +# Neither the first rule (whose fields exclude title/suffix, on +# purpose) nor the honorific rules (which key on vocabulary this name +# has none of) may claim the union. +# +# All three lookaheads are required. A comma alone matches every Latin +# 'Smith, Jr.' in the corpus; the interpunct alone is the Catalan punt +# volat too ('Gal·la Marcet'), which is exactly the input class #298's +# flank guard exists to protect; the classified-codepoint lookahead is +# what confines the rule to names #298 can actually reach, and its +# class is the same pinned copy of _SCRIPT_RANGES the first rule +# carries. +# +# The interpunct is written as an escape rather than as itself, alone among +# the punctuation spelled literally in this file: · (U+00B7), ・ +# (U+30FB) and ・ (U+FF65) are three different separators with three +# different rules in 2.1, and they are indistinguishable enough on +# screen that a literal here would be unreviewable. +name_regex = "(?s)(?=.*,)(?=.*\\u00B7)(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65])" +fields = ["given", "middle", "family", "title", "suffix"] From c8a203d35e4a0c81286ded383a33b5d1716146d6 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 01:14:07 -0700 Subject: [PATCH 11/17] Document baselines, and stop restating the corpus roster --- AGENTS.md | 2 +- tools/differential/README.md | 209 +++++++++++++++++++++++------------ 2 files changed, 139 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8ad28cb..8d3dd7a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,7 @@ Each named attribute (`title`, `first`, etc.) is a `@property` that joins its co The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These conventions apply to all new-API code and are stricter than the v1 sections above. The full design record (rationale, settled-decision logs, dated amendments) lives in untracked `docs/superpowers/specs/`; this section is the enforceable subset. **A commit that establishes or amends one of these conventions must update this section in the same commit** — grep-driven staleness sweeps miss paraphrased prose (see Workflow above), so write-time maintenance is the mechanism, audits are the backstop. - **Module layout**: every new module is underscore-private (`_types.py`, `_lexicon.py`, `_policy.py`, `_locale.py`, `_render.py`, `_pipeline/`, `_parser.py`, plus the facade layer: `_facade.py`, `_config_shim.py`). The public import surface is exactly `nameparser` and `nameparser.locales`; `nameparser/__init__.py` holds re-exports and `__all__` only — no logic. Since the M11 swap, the old paths are import-path-preserving re-exports: `nameparser.parser` re-exports the `_facade` `HumanName`, `nameparser.config` re-exports the `_config_shim` names (`Constants`, `CONSTANTS`, `SetManager`, `TupleManager`, `RegexTupleManager`); the `config/` DATA modules stay the vocabulary source through 2.x. The whole facade layer is deleted in 3.0. -- **Facade layer** (`_facade.py`, `_config_shim.py`): the v1-compat `HumanName`/`Constants` over the core. Key mechanisms: `Constants._generation` dirty-tracking (every mutation bumps; facades resolve their `Parser` lazily via `_cached_parser(lexicon, policy)`); `Constants._snapshot()` mirrors `_lexicon._default_lexicon()` (equality-pinned); the facade pickles v1-SHAPED state (component lists, one `__setstate__` path for 1.4 and 2.x blobs; components rebuild via `replace()`, never a re-parse); `_V1_HOOKS` overrides warn once per subclass (#280). The compat contract is the migration spec's promise: warning-free 1.4 code behaves identically except release-log-classified fixes — `tools/differential/` (dev-only, not shipped) verifies this against 1.4-on-PyPI over a checked-in corpus of ~650 names (no exact count here: `corpus_issues.jsonl` grows whenever it is regenerated, and the run prints its own per-file totals) (two files: `corpus.jsonl` from the v1 test banks at a pinned ref, `corpus_issues.jsonl` harvested from the issue tracker; `compare.py` globs `corpus*.jsonl` and fails loudly if none match). `parser.py:NNNN` citations throughout the 2.0 code refer to the PRE-swap v1 file, deleted at the M11 swap; resolve them with `git show 2d5d8c2:nameparser/parser.py`. +- **Facade layer** (`_facade.py`, `_config_shim.py`): the v1-compat `HumanName`/`Constants` over the core. Key mechanisms: `Constants._generation` dirty-tracking (every mutation bumps; facades resolve their `Parser` lazily via `_cached_parser(lexicon, policy)`); `Constants._snapshot()` mirrors `_lexicon._default_lexicon()` (equality-pinned); the facade pickles v1-SHAPED state (component lists, one `__setstate__` path for 1.4 and 2.x blobs; components rebuild via `replace()`, never a re-parse); `_V1_HOOKS` overrides warn once per subclass (#280). The compat contract is the migration spec's promise: warning-free 1.4 code behaves identically except release-log-classified fixes — `tools/differential/` (dev-only, not shipped) verifies this against 1.4-on-PyPI, and `--baseline` points the same gate at any released version — run it at 1.4.0 for the compat contract, at the previous minor for a release's blast radius. `tools/differential/README.md` owns the corpus roster and what each file is blind to; don't restate it here. `parser.py:NNNN` citations throughout the 2.0 code refer to the PRE-swap v1 file, deleted at the M11 swap; resolve them with `git show 2d5d8c2:nameparser/parser.py`. - **Layering is enforced by `tests/v2/test_layering.py`** (exact-module matching; `if TYPE_CHECKING:` imports don't count): `_types` imports nothing internal at module level — its rendering delegates import `_render` at call time; `_lexicon` and `_policy` sit above `_types` independently (`_lexicon` may import `nameparser.config.*` DATA modules only — vocabulary is single-sourced from the v1 data modules through 2.x, e.g. `config/maiden_markers.py`); `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types` and `_lexicon` (for `Lexicon.default()` and `_normalize`); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers; `_parser` sits on everything except `_render`; the facade layer (`_facade`, `_config_shim`, `parser`, `config/__init__`, `__main__`) may import anything public plus `_render`; locale pack modules (`locales/*.py`) import `_locale`/`_lexicon`/`_policy`/`_types` only (`_types` joined the list in #272 — `locales/ja.py`'s segmenter factory constructs a `Segmentation`), and the `locales/__init__` additionally lazy-imports its packs (PEP 562). Extend the test's `ALLOWED` table when adding a module. - **Canonical field order** — the seven roles in `Role` enum declaration order, defined once and derived everywhere (properties, `as_dict`, reprs, `comparison_key`). Never restate the order literally. `Role` is a `StrEnum`: members compare as their field-name strings, and `tokens_for()` coerces strings via `_coerce_enum`. - **Method organization**, fixed section order in every class: fields + `__post_init__` validation → alternative constructors → dunders (construction/equality → protocol → operators) → properties → public methods by concern (access → editing → comparison → rendering delegates) → private helpers last, except a helper serving exactly one section may sit at that section's head. Sanctioned deviation, facade layer only: `HumanName` and the shim `Constants` organize by v1 concern groups (`# -- render defaults --`, `# -- config / parsing --`, `# -- fields --`, ..., dunders and pickle last) — the classes mirror v1's own surface and die in 3.0; the canonical order still binds every core type. diff --git a/tools/differential/README.md b/tools/differential/README.md index 80815cca..c321e175 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -1,87 +1,124 @@ -# Differential harness (v1 vs 2.0) +# Differential harness (a released baseline vs the working tree) -Dev-only tooling for the 2.0 migration (migration plan S5). Not -shipped (excluded from the wheel by the packaging config -- only -`nameparser/` is packaged) and not CI-gated. Run it by hand when -touching parsing behavior, and before cutting a 2.0 release. +Dev-only tooling for the 2.x line (migration plan S5). Not shipped +(excluded from the wheel by the packaging config -- only `nameparser/` +is packaged) and not CI-gated. Run it by hand when touching parsing +behavior, and before cutting a release. Two processes, two environments: -- `worker_v1.py` runs under a **pinned nameparser 1.4** installed fresh - from PyPI via a PEP 723 inline script. It must be invoked with - `uv run --no-project` -- **without `--no-project`, `uv` installs the - working tree as an editable dependency and the 1.4 pin never takes - effect**, silently comparing 2.0 against itself. +- A **baseline worker** runs under a pinned nameparser installed fresh + from PyPI via a PEP 723 inline script. It is not a checked-in file: + `compare.py` renders it from a template with the version pin + substituted and writes it to a temp directory outside the worktree. + That placement is a safety mechanism rather than plumbing -- it is + what makes the invocation traps below unreachable rather than merely + documented. - `compare.py` runs in the project's own dev environment and imports - `nameparser` normally (the 2.0 facade, which still speaks the v1 - component names). + `nameparser` normally: the working tree, on whichever surfaces the + baseline supports. ## Running it ``` uv run python tools/differential/build_corpus.py --ref > tools/differential/corpus.jsonl # only when regenerating -uv run python tools/differential/compare.py +uv run python tools/differential/compare.py --baseline 1.4.0 +uv run python tools/differential/compare.py --baseline 2.0.0 ``` `compare.py` spawns the worker as a subprocess, feeds it every corpus -name as a line of JSON, and diffs the two component dicts on the seven -v1 field names (`title`, `first`, `middle`, `last`, `suffix`, -`nickname`, `maiden` -- both sides use these keys, so no field mapping -is needed). Every diff is checked against `expected_since_1.4.0.toml`: +name as a line of JSON, and diffs the two sides field by field. Every +diff is checked against that baseline's ledger: - Matches a rule -> counted as an intentional, classified change. - Matches no rule -> printed under `UNEXPLAINED` and the run exits 1. -An unexplained diff means either a real 2.0 parity bug (fix it, don't -allowlist it) or a known change whose `expected_since_1.4.0.toml` rule -needs widening. The run must exit 0 before a 2.0 release; the classified -summary it prints is the source for the "Behavior Changes" section of -`docs/release_log.rst`. - -## Do not put `python` in front of the worker - -`compare.py` spawns the worker by **script path**: - -``` -uv run --no-project tools/differential/worker_v1.py -``` - -Inserting `python` before the path -- -`uv run --no-project python tools/differential/worker_v1.py` -- makes -`python` the command and the script a mere argument, so `uv` never -reads the script's PEP 723 inline metadata and the `nameparser==1.4.*` -pin is never installed. With nothing to satisfy, `uv` runs the script -in the project's own `.venv`, where the working tree is installed -editable (`__editable__.nameparser-2.0.0.pth`) -- so the import -resolves to the checkout and **2.x answers every query while the -output is labelled 1.4.0**. Reproduced twice while working on #320. +An unexplained diff means either a real parity bug (fix it, don't +allowlist it) or a known change whose ledger rule needs widening. The +run must exit 0 at every baseline you claim before a release; the +classified summary it prints is the source for the "Behavior Changes" +section of `docs/release_log.rst`. + +## Baselines + +`--baseline VERSION` chooses what the tree is compared against, and +two things follow from it: which ledger is read +(`expected_since_.toml`, a hard error if absent) and which +surfaces are compared (the facade alone below 2.0, which has no v2 +API; both from 2.0 on, ambiguity kinds included). + +Run both before cutting a release: + +- `--baseline 1.4.0` — the v1 compat contract. +- `--baseline ` — what changes for a user upgrading. + +The worker is generated per run, with the pin substituted, into a temp +directory outside the worktree. Its first output line is a version +tell, and `compare.py` aborts before comparing anything if the wrong +version answered or if the module resolved inside the checkout. + +A rule's `fields` names roles the way `Role` does — `title`, `given`, +`middle`, `family`, `suffix`, `nickname`, `maiden` — whichever surface +the diff came from. The facade reports `first`/`last`; those are +canonicalized on the way in, and the `UNEXPLAINED` block prints the +canonical name so what you read is what you write. + +## The two invocation traps the temp dir closes + +Neither trap below is hypothetical -- the second was reproduced twice +while working on #320 -- and they are recorded here because the +generated worker's placement is the thing that disarms them. A later +change that moves the worker back inside the worktree, or invokes it +from a cwd inside the project, reopens both. The analysis is the +reason for the design, so it outlives the bug. + +**Without `--no-project`,** `uv` installs the working tree as an +editable dependency and the version pin never takes effect, silently +comparing the tree against itself. `compare.py` passes `--no-project`, +and runs the worker from a temp cwd where there is no project to +discover in the first place. + +**With `python` in front of the script path** -- +`uv run --no-project python