Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions docs/design/decisions.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/design/mechanisms.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ than inventing one.

Problem shape. A guard needs to know what the answer WAS, so it can detect the answer changing. Contract statement. Store the measured answer as literal data (a roster) and compare against it; never re-derive the expectation from the same inputs the check reads, because a derivation from the same data always agrees with itself. Lives in. tests/v2/test_ledger_guards.py (_CORPUS_CLAIMS, _EXCLUSION_EFFECT, _CROSS_RULE_WINNERS, _SPAN_BEARING_RULES, _HONORIFIC_SOURCES, _LATIN_ALTERNATION_SOURCES), tools/differential/compare.py (_CORPUS_FLOORS), tests/v2/test_facade_cases.py (_CORE_ONLY_IDS). Reach for it when. Writing a check whose expected value is computed by the code under test, or a comment that enumerates ids/counts — make it data the suite asserts.

## LEDGER-RULE-SEPARATION — fields separate rules, file order doesn't
## LEDGER-RULE-SEPARATION — file order decides, fields narrow by subset

Problem shape. Two differential-ledger rules claim overlapping names. Contract statement. Fields subsets and matching predicates separate ledger rules BETWEEN tiers; within a tier, file order decides and the narrower rule must be written first. A fields-only rule sorts last unconditionally and takes what nothing narrower named. How it works. Detail is owned by tools/differential/README.md. The within-tier clause is measured, not theoretical: in the 1.4 ledger the comma-honorific-peel rule's fields are a strict subset of the comma-compound rule's, both carry a name_regex, and a pure reorder reattributes seven names — caught by _CROSS_RULE_WINNERS and by nothing else in the suite (#375's mutation). Whether that pair should be separated by a predicate instead of by order is
Problem shape. Two differential-ledger rules claim overlapping names. Contract statement. Every ledger rule must carry a `name_regex` — since #451 `validate_rules` REJECTS a rule with `fields` and no `name_regex`, and one with neither was already rejected — so every rule sits in ONE tier, the sort is stable, and FILE ORDER decides every contest: the narrower rule must be written first. `fields` narrows a rule by subset; it does not separate rules by sorting. The two-tier sort in `_sorted_rules` is KEPT although the ban makes it the identity on every ledger that loads (measured on all three): it is the defence for a reader that does not call `validate_rules` first — a future tool, a REPL, a test fixture — and its docstring in tools/differential/compare.py says so. How it works. Detail is owned by tools/differential/README.md. The file-order clause is measured, not theoretical: in the 1.4 ledger the comma-honorific-peel rule's fields are a strict subset of the comma-compound rule's, both carry a name_regex, and a pure reorder reattributes seven names — caught by _CROSS_RULE_WINNERS and by nothing else in the suite (#375's mutation). Whether that pair should be separated by a predicate instead of by order is
[#382](https://github.com/derek73/python-nameparser/issues/382). The old #271/#272
slug taboo is RETIRED (#333): the canonical-rule selector that keyed on those substrings is deliberately deleted — rule authors are free to use them in compound slugs — and the surviving rosters select on their own explicit keys (_HONORIFIC_SOURCES and _LATIN_ALTERNATION_SOURCES by named issue strings, _SPAN_BEARING_RULES by exact leading fix(...) tag). Lives in. tools/differential/compare.py, the expected_since_*.toml ledgers. Reach for it when. A ledger rule's behavior seems to depend on where it sits in the file — within a tier it does, and the reorder mutation is the test (run twice in #375; it fails _CROSS_RULE_WINNERS). History: #372 (closed) measured the fields-only rule owning 1639 of 5257 name×field pairs as filed (2026-08-10); #375/#376 then cut its classifier-of-record share sharply, and the residual pair ownership is the last-resort tier working as designed, not a defect. #372's two proposed mechanical checks were DECLINED with measurements (see decisions.md#differential-ledger), not left open.
slug taboo is RETIRED (#333): the canonical-rule selector that keyed on those substrings is deliberately deleted — rule authors are free to use them in compound slugs — and the surviving rosters select on their own explicit keys (_HONORIFIC_SOURCES and _LATIN_ALTERNATION_SOURCES by named issue strings, _SPAN_BEARING_RULES by exact leading fix(...) tag). Lives in. tools/differential/compare.py, the expected_since_*.toml ledgers. Reach for it when. A ledger rule's behavior seems to depend on where it sits in the file — it does, and the reorder mutation is the test (run twice in #375; it fails _CROSS_RULE_WINNERS). History: #372 (closed) measured the then-existing fields-only rule owning 1639 of 5257 name×field pairs as filed (2026-08-10); #375/#376 then cut its classifier-of-record share sharply, and the residual pair ownership was read as the last-resort tier working as designed rather than a defect — until #451 retired the shape outright (decisions.md#differential-ledger). #372's two proposed mechanical checks were DECLINED with measurements (see decisions.md#differential-ledger), not left open.

## CANONICAL-VOCABULARY-AT-THE-BOUNDARY — one vocabulary at the comparison

Expand Down
118 changes: 81 additions & 37 deletions tests/v2/test_differential.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,34 @@ def test_malformed_rule_error_names_the_ledger_it_came_from() -> None:
compare.validate_rules([{}], "expected_since_1.4.0.toml")


def test_a_rule_with_fields_and_no_regex_is_rejected() -> None:
"""The shape #451 retired, closed so it cannot return.

A rule with no `name_regex` claims every name whose diff fits its
`fields`, and _CORPUS_CLAIMS records its reach as the whole corpus
-- already at maximum, so arrivals never move it. The one rule with
this shape grew from 4 explained names to 14 across six behavior
families with every guard green.
"""
with pytest.raises(SystemExit, match="no 'name_regex'"):
compare.validate_rules(
[{"issue": "fix(x) a rule with no name narrowing",
"fields": ["given", "family", "suffix"]}],
"test_ledger.toml")


def test_a_rule_with_a_regex_and_no_fields_or_both_stays_legal() -> None:
"""The neighbouring shapes #451 did NOT retire. `name_regex` alone
still narrows by name; `name_regex` plus `fields` narrows by both.
Only the fields-only shape -- no name narrowing at all -- is new
to reject."""
compare.validate_rules(
[{"issue": "x", "name_regex": "Smith"}], "test_ledger.toml")
compare.validate_rules(
[{"issue": "x", "name_regex": "Smith", "fields": ["given"]}],
"test_ledger.toml")


def test_classify_declines_a_diff_touching_a_field_the_rule_omits() -> None:
"""The subset check is the tightness mechanism of every `fields`
rule -- a rule claims a diff only when EVERY changed field is one it
Expand Down Expand Up @@ -266,19 +294,24 @@ def test_v2_fields_matches_the_Role_enum() -> None:
({"issue": "x", "name_regex": r"\b"}, "matches every one of"),
({"issue": "x", "name_regex": r"[\s\S]"}, "matches every one of"),
# seven roles without _ambiguities: below baseline 2.0 that IS the
# whole vocabulary, so it claims every diff
({"issue": "x", "fields": ["title", "given", "middle", "family",
"suffix", "nickname", "maiden"]},
# whole vocabulary, so it claims every diff. name_regex is along
# for the ride so this pins the roles check, not the #451 one.
({"issue": "x", "name_regex": "Smith",
"fields": ["title", "given", "middle", "family",
"suffix", "nickname", "maiden"]},
"all seven roles"),
# uncompilable: without this it raises mid-run, after the worker
({"issue": "x", "name_regex": "Smith("}, "invalid 'name_regex'"),
({"issue": "x", "fields": []}, "empty 'fields'"),
({"issue": "x", "fields": ["famly"]}, "not roles"),
({"issue": "x", "name_regex": "Smith", "fields": []}, "empty 'fields'"),
({"issue": "x", "name_regex": "Smith", "fields": ["famly"]},
"not roles"),
# facade vocabulary is not role vocabulary; it would never match
({"issue": "x", "fields": ["first"]}, "not roles"),
({"issue": "x", "fields": ["title", "given", "middle", "family",
"suffix", "nickname", "maiden",
"_ambiguities"]}, "all seven roles"),
({"issue": "x", "name_regex": "Smith", "fields": ["first"]},
"not roles"),
({"issue": "x", "name_regex": "Smith",
"fields": ["title", "given", "middle", "family",
"suffix", "nickname", "maiden",
"_ambiguities"]}, "all seven roles"),
({"issue": "x", "fields": ["given"], "dormant": ""}, "not a non-empty"),
({"issue": "x", "fields": ["given"], "dormant": True}, "not a non-empty"),
# widening _RULE_KEYS is exactly the edit that could let a near-miss
Expand Down Expand Up @@ -353,7 +386,8 @@ def test_ambiguities_is_a_legal_field_name() -> None:
this pseudo-field is the only name that can classify it -- and the
2.0 ledger's first rule depends on it."""
compare.validate_rules(
[{"issue": "x", "fields": ["_ambiguities"]}], "ledger.toml")
[{"issue": "x", "name_regex": "Smith", "fields": ["_ambiguities"]}],
"ledger.toml")


#: What _run_worker was asked for, so a test can prove main forwarded
Expand Down Expand Up @@ -454,7 +488,8 @@ def test_main_exits_0_when_every_diff_is_claimed(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
code, out = _run_main(
tmp_path, monkeypatch,
'[[change]]\nissue = "claimed"\nfields = ["family"]\n', _DIFFERS)
'[[change]]\nissue = "claimed"\nname_regex = "Smith"\n'
'fields = ["family"]\n', _DIFFERS)
assert code == 0
assert "UNEXPLAINED" not in out
assert "## claimed (1)" in out
Expand All @@ -470,26 +505,28 @@ def test_main_validates_the_ledger_before_running_anything(
'[[change]]\nissue = "wide"\nname_regex = ""\n', _DIFFERS)


def test_main_sorts_a_name_regex_rule_ahead_of_a_fields_only_one(
def test_main_rejects_a_broad_fields_only_rule_before_running_anything(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A broad fields-only rule written FIRST must not claim a diff the
specific name_regex rule below it owns. Deleting main's
_sorted_rules call leaves _sorted_rules' own test passing.

'broad' is declared dormant because in THIS fixture -- one corpus
name, always won by 'specific' -- it is permanently shadowed by
construction, which is exactly the case main()'s dormancy report
(#372) now calls out. Without the declaration this test would be
pinning main's sort order and main's dormancy report at once, and a
failure could not tell which one broke.
"""This used to pin main's _sorted_rules call: a broad fields-only
rule written FIRST could not claim a diff a specific name_regex
rule below it owned, because _sorted_rules puts every name_regex
rule ahead of every fields-only one. That scenario is retired by
#451 -- a fields-only rule is rejected at validate_rules, so main()
never reaches _sorted_rules with one. What is left to pin here is
that main() still validates before running, for this shape too,
the same composition fact test_main_validates_the_ledger_before_
running_anything covers for a different malformed shape. The
tier-sort mechanism itself stays pinned directly by
test_name_regex_rules_sort_ahead_of_fields_only_rules and
test_rule_sort_is_stable_within_a_tier, which call _sorted_rules
without going through validate_rules.
"""
_, out = _run_main(
tmp_path, monkeypatch,
'[[change]]\nissue = "broad"\nfields = ["family"]\n'
'dormant = "always shadowed by \'specific\' below, by construction '
'of this fixture"\n'
'[[change]]\nissue = "specific"\nname_regex = "Smith"\n', _DIFFERS)
assert "## specific (1)" in out and "broad" not in out
with pytest.raises(SystemExit, match="no 'name_regex'"):
_run_main(
tmp_path, monkeypatch,
'[[change]]\nissue = "broad"\nfields = ["family"]\n'
'[[change]]\nissue = "specific"\nname_regex = "Smith"\n',
_DIFFERS)


def test_main_exits_1_and_names_a_rule_that_explained_nothing(
Expand All @@ -503,7 +540,8 @@ def test_main_exits_1_and_names_a_rule_that_explained_nothing(
"""
code, out = _run_main(
tmp_path, monkeypatch,
'[[change]]\nissue = "explains-it"\nfields = ["family"]\n'
'[[change]]\nissue = "explains-it"\nname_regex = "Smith"\n'
'fields = ["family"]\n'
'[[change]]\nissue = "idle"\nname_regex = "ZZNOSUCHNAME"\n'
'fields = ["family"]\n', _DIFFERS)
assert code == 1
Expand All @@ -529,7 +567,8 @@ def test_main_only_feeds_diffing_names_to_the_dormancy_check(
"""
code, out = _run_main(
tmp_path, monkeypatch,
'[[change]]\nissue = "explains-it"\nfields = ["family"]\n'
'[[change]]\nissue = "explains-it"\nname_regex = "Smith"\n'
'fields = ["family"]\n'
'[[change]]\nissue = "idle"\nname_regex = "Jones"\n'
'fields = ["family"]\n', _DIFFERS,
extra=[("Alice Jones",
Expand All @@ -551,7 +590,8 @@ def test_main_exits_1_when_a_declared_dormant_rule_wakes_up(
a false statement in the ledger, so it fails the run too."""
code, out = _run_main(
tmp_path, monkeypatch,
'[[change]]\nissue = "awake"\nfields = ["family"]\n'
'[[change]]\nissue = "awake"\nname_regex = "Smith"\n'
'fields = ["family"]\n'
'dormant = "claims to be idle, but explains the only diff here"\n',
_DIFFERS)
assert code == 1
Expand Down Expand Up @@ -735,7 +775,8 @@ def test_main_claims_an_ambiguity_only_diff_when_a_rule_names_it(
v2 = {**_SAME_V2, "_ambiguities": ["SEGMENTATION"]}
code, out = _run_main(
tmp_path, monkeypatch,
'[[change]]\nissue = "seg"\nfields = ["_ambiguities"]\n',
'[[change]]\nissue = "seg"\nname_regex = "Smith"\n'
'fields = ["_ambiguities"]\n',
_SAME_FACADE, baseline="2.0.0", baseline_v2=v2)
assert code == 0 and "## seg (1)" in out

Expand Down Expand Up @@ -862,10 +903,12 @@ def test_validate_exclusions_accepts_the_shipped_entries() -> None:

def test_classify_refuses_an_excluded_shape() -> None:
"""The whole point: an excluded name reports UNEXPLAINED however
many rules would otherwise claim it. Two do, for the shape this
many rules would otherwise claim it. Two did, for the shape this
was built for -- fix(comma-family) on file order, and the
fields-only fix(suffix-routing) which has no name_regex at all and
so reaches every name."""
fields-only fix(suffix-routing) which had no name_regex at all and
so reached every name. #451 deleted that second one, and no ledger
has a fields-only rule now; the fixture below keeps one because
the behaviour it pins is compare.classify's, not any ledger's."""
rules = [{"issue": "broad", "name_regex": ","},
{"issue": "broader", "fields": ["given", "suffix"]}]
never = [{"why": "parity", "name_regex": r"(?i)\bph\.\s*d\.\s*$",
Expand Down Expand Up @@ -949,7 +992,8 @@ def test_validate_rules_accepts_a_declared_dormant_rule() -> None:
"""`dormant` is a legal key, so a rule that declares one is not
rejected as a misspelling."""
compare.validate_rules(
[{"issue": "x", "fields": ["given"], "dormant": "no corpus name"}],
[{"issue": "x", "name_regex": "ZZNOSUCHNAME", "fields": ["given"],
"dormant": "no corpus name"}],
"expected_since_1.4.0.toml")


Expand Down
Loading