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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ uv run sphinx-build -b html docs dist/docs
# vocabulary (maiden markers, ambiguous acronyms). An alternation
# matching no key fails as undeclared -- add it, or record it in
# _NOT_A_VOCABULARY_COPY if it copies nothing.
# A ledger may also carry [[never]] entries -- shapes that must stay
# unexplained, documented in tools/differential/README.md. compare.py's
# validate_exclusions checks them at startup and
# tests/v2/test_ledger_guards.py records what each one silences --
# which rules would claim it with exclusions off, and how much corpus it
# captures -- in _EXCLUSION_EFFECT. That IS an enrollment: an entry
# added, or its name_regex/fields/examples edited, moves the record and
# must be re-recorded deliberately, the same forcing function
# _CORPUS_CLAIMS applies to rules. (A `why`-only edit does not move it.)
# 9. Open the next cycle's VERSION: bump VERSION in nameparser/_version.py to
# the minor now being worked, and set PRE_RELEASE = 'dev'. The tree then says
# what it is building rather than what it last shipped -- docs/conf.py reads
Expand Down
10 changes: 10 additions & 0 deletions tests/v2/_differential_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ def _rules(ledger: Path) -> list[dict]:
ledger.read_text(encoding="utf-8")).get("change", [])


def _exclusions(ledger: Path) -> list[dict]:
"""The [[never]] table of one ledger.

Same .get default as _rules and for the same reason: a ledger with
no exclusions is the normal case, not a malformed file.
"""
return tomllib.loads(
ledger.read_text(encoding="utf-8")).get("never", [])


def load_tool(stem: str) -> ModuleType:
"""A module from tools/differential/, loaded by path.

Expand Down
124 changes: 113 additions & 11 deletions tests/v2/test_differential.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,13 @@ def test_default_baseline_has_a_ledger_and_nothing_else_in_it() -> None:
run abort -- and it would also make the carve-out inert, since it
would name a file nothing iterates over.

And it must define nothing at the top level except `change`. This is
the one ledger allowed to be empty, so a mistyped table header --
`[[changes]]`, `[[rules]]` -- reads as a legitimately empty open
cycle everywhere instead of as a broken file: every sweep gets zero
rules and passes, while the author believes they shipped a rule.
The other ledgers are protected by having to be non-empty; this one
needs saying out loud.
And it must define nothing at the top level except `change` and
`never`, the two keys anything reads. This is the one ledger allowed
to be empty, so a mistyped table header -- `[[changes]]`, `[[rules]]`,
`[[nevr]]` -- reads as a legitimately empty open cycle everywhere
instead of as a broken file: every sweep gets zero rules and passes,
while the author believes they shipped a rule. The other ledgers are
protected by having to be non-empty; this one needs saying out loud.
"""
import tomllib
open_cycle = _TOOLS / f"expected_since_{compare.DEFAULT_BASELINE}.toml"
Expand All @@ -310,10 +310,11 @@ def test_default_baseline_has_a_ledger_and_nothing_else_in_it() -> None:
f"{open_cycle.name} does not exist; a bare compare.py run would "
f"hard-error, and the empty-ledger carve-out would be inert")
keys = set(tomllib.loads(open_cycle.read_text(encoding="utf-8")))
assert keys <= {"change"}, (
f"{open_cycle.name} defines {sorted(keys - {'change'})} at the top "
f"level. Only `change` is read, so anything else is a typo that "
f"would read as an empty ledger rather than as a broken one")
assert keys <= {"change", "never"}, (
f"{open_cycle.name} defines {sorted(keys - {'change', 'never'})} at "
f"the top level. Only `change` and `never` are read, so anything "
f"else is a typo that would read as an empty ledger rather than as "
f"a broken one")


def test_validate_rules_accepts_the_shipped_ledgers() -> None:
Expand Down Expand Up @@ -723,3 +724,104 @@ def test_main_aborts_on_a_corpus_with_no_floor(
_run_main(tmp_path, monkeypatch,
'[[change]]\nissue = "x"\nname_regex = "ZZZ"\n',
_SAME_FACADE, floor=None)


# The exclusion grammar. Every row is a way a [[never]] entry can look
# correct and protect nothing -- the same failure the rules' own
# validator exists for, pointed at the section that DISABLES
# classification instead of the one that performs it.
@pytest.mark.parametrize("entry,expect", [
({}, "no string 'why'"),
({"why": ""}, "no string 'why'"),
({"why": "x"}, "no string 'name_regex'"),
({"why": "x", "name_regex": "Smith("}, "invalid 'name_regex'"),
({"why": "x", "name_regex": "a"}, "no 'examples'"),
({"why": "x", "name_regex": "a", "examples": []}, "no 'examples'"),
({"why": "x", "name_regex": "a", "examples": "b"}, "not a list of strings"),
({"why": "x", "name_regex": "a", "examples": ["a", 1]},
"not a list of strings"),
# an example the entry does not actually protect
({"why": "x", "name_regex": "zzz", "examples": ["John Smith"]},
"does not match its own"),
# a misspelled key deletes half the declaration, exactly as for rules
({"why": "x", "name_regex": "a", "examples": ["a"], "reason": "b"},
"unknown key"),
# would silence the entire ledger
({"why": "x", "name_regex": ".", "examples": ["a"]},
"matches every one of"),
])
def test_validate_exclusions_rejects_an_entry_that_protects_nothing(
entry: dict, expect: str) -> None:
with pytest.raises(SystemExit, match=expect):
compare.validate_exclusions([entry], "expected_since_1.4.0.toml")


def test_validate_exclusions_accepts_the_shipped_entries() -> None:
"""The guards above must not be so strict they reject real entries."""
import tomllib
for ledger in sorted(_TOOLS.glob("expected_since_*.toml")):
parsed = tomllib.loads(ledger.read_text(encoding="utf-8"))
compare.validate_exclusions(parsed.get("never", []), ledger.name)


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
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."""
rules = [{"issue": "broad", "name_regex": ","},
{"issue": "broader", "fields": ["given", "suffix"]}]
never = [{"why": "parity", "name_regex": r"(?i)\bph\.\s*d\.\s*$",
"examples": ["John Smith, Ph. D."]}]
assert compare.classify("John Smith, Ph. D.", {"suffix"}, rules) == "broad"
assert compare.classify(
"John Smith, Ph. D.", {"suffix"}, rules, never) is None
# a name the exclusion does not cover is unaffected
assert compare.classify("Smith, Dr.", {"suffix"}, rules, never) == "broad"


@pytest.mark.parametrize("entry,expect", [
({"why": "x", "name_regex": "a", "examples": ["a"], "fields": "given"},
"not a list of strings"),
({"why": "x", "name_regex": "a", "examples": ["a"], "fields": []},
"empty 'fields'"),
({"why": "x", "name_regex": "a", "examples": ["a"], "fields": ["nope"]},
"not roles"),
# the facade's vocabulary is not the role vocabulary
({"why": "x", "name_regex": "a", "examples": ["a"], "fields": ["first"]},
"not roles"),
# all seven means "any diff", which is what omitting the key does
({"why": "x", "name_regex": "a", "examples": ["a"],
"fields": ["title", "given", "middle", "family", "suffix",
"nickname", "maiden"]}, "omit 'fields'"),
])
def test_validate_exclusions_rejects_a_bad_fields_narrowing(
entry: dict, expect: str) -> None:
with pytest.raises(SystemExit, match=expect):
compare.validate_exclusions([entry], "expected_since_1.4.0.toml")


def test_an_excluded_shape_stays_classifiable_on_other_roles() -> None:
"""The reason `fields` exists. ASCII parens mark nicknames, maiden
names, suffixes and credentials alike, so an exclusion that names
the nickname reading must not silence a suffix diff on the same
name. Such a diff would not be hidden -- an excluded name reports
UNEXPLAINED and exits non-zero -- but it could never be classified
as intended either, leaving an area under active development
permanently unexplainable."""
rules = [{"issue": "catch-all", "fields": ["given", "suffix",
"nickname", "middle"]}]
never = [{"why": "ascii pairs were already handled in 1.4",
"name_regex": r"\w\s+\([^)]+\)\s+\w",
"fields": ["nickname", "middle"],
"examples": ["John (Jack) Kennedy"]}]
name = "Lon (Jr.) Williams"
# the reading the exclusion names is refused
assert compare.classify(name, {"nickname"}, rules, never) is None
assert compare.classify(name, {"middle"}, rules, never) is None
# a different reading of the same name is still classifiable
assert compare.classify(name, {"suffix"}, rules, never) == "catch-all"
# and a mixed diff is not a subset of the exclusion, so it survives
assert compare.classify(
name, {"nickname", "suffix"}, rules, never) == "catch-all"
200 changes: 198 additions & 2 deletions tests/v2/test_ledger_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ class declares, which members an alternation offers. Those are exact
Split from test_regex_sync.py, which shares none of this (#352).
"""
import hashlib
import itertools
import json
import re
from pathlib import Path
from types import ModuleType
from typing import NamedTuple

import pytest
Expand All @@ -47,8 +49,8 @@ class declares, which members an alternation offers. Those are exact
GLUED_HONORIFICS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_WORDS)

from ._differential_fixtures import (
_CORPUS_NAMES, _LEDGERS, _TOOLS, _UNCLASSIFIED_NAMES, _claimed, _rules,
_unclassified_names, load_tool)
_CORPUS_NAMES, _LEDGERS, _TOOLS, _UNCLASSIFIED_NAMES, _claimed,
_exclusions, _rules, _unclassified_names, load_tool)


# The one sanctioned divergence between the differential rules'
Expand Down Expand Up @@ -1115,3 +1117,197 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None:
f"_CORPUS_CLAIMS names ledgers that do not exist: "
f"{sorted(set(_CORPUS_CLAIMS) - {L.name for L in _LEDGERS})}")



class _Excluded(NamedTuple):
"""What a [[never]] entry silences, in the two dimensions that can
change under it."""
#: corpus names its name_regex captures
captures: int
#: sha256[:12] of those names, so a regex swap holding the count
#: still fails -- the identity-free lesson _CORPUS_CLAIMS records
digest: str
#: rules that WOULD claim a protected reading of its examples, with
#: exclusions switched OFF. This is the #328 event: a rule widened
#: to reach a protected shape joins this tuple.
absorbed_by: tuple[str, ...]


#: What each exclusion silences today, keyed by its name_regex.
#:
#: The first version of this guard asked whether a rule claims a
#: protected shape WITH exclusions active. It could not fail: classify()
#: consults exclusions first and returns None, and the guard only asked
#: about subsets the exclusion covers, so the answer was None by
#: construction. Measured: prepending a catch-all rule, and deleting
#: every rule in the ledger, both left it green across all 387 subsets.
#: It was the tenth inert measurement recorded in this tree.
#:
#: So ask with exclusions OFF, and record the answer. Then a rule
#: widened to reach a protected shape changes `absorbed_by` and demands
#: a decision -- which is the event #328 is about, and the one the
#: harness cannot report because the exclusion (correctly) hides it.
#:
#: `captures` and `digest` cover the opposite direction, which nothing
#: else watched: an exclusion widened by name_regex silences real
#: classifications, and BEFORE this record CI stayed green while the
#: release gate broke. Measured then: dropping the Ph. D. entry's
#: Latin anchor left the whole suite passing and took a bare run to
#: unexplained: 1. Measured now: the same edit fails this pin -- the
#: record is keyed by name_regex, so editing one is exactly what it
#: catches -- while the gate still goes to unexplained: 1.
#:
#: One limit worth naming: `absorbed_by` records only the FIRST rule
#: matching each subset, so a rule appended behind one that already
#: answers those subsets is invisible here. A rule reaching a
#: protected READING is caught; a rule shadowed by an existing one on
#: every subset it claims is not.
_EXCLUSION_EFFECT: dict[str, _Excluded] = {
"(?i)^[\\u0000-\\u024f]*\\bph\\.\\s*d\\.\\s*$":
_Excluded(3, "5a12a8117651",
("fix(comma-family)", "fix(suffix-routing)")),
'[\\w.]\\s+[("\'][^)"\']+[)"\']\\s+\\w':
_Excluded(13, "42d04d428edf", ()),
}


def _protectable_fields(compare: ModuleType) -> tuple[str, ...]:
"""The universe both exclusion pins quantify over: every field a
rule's `fields` may name, which is what an exclusion's may name too.

Not `Role` alone. validate_exclusions accepts `_ambiguities` -- a
SEGMENTATION-only diff is facade-identical, so it is the one name
that can classify one -- and a universe of the seven roles never
builds a subset containing it. Measured: a rule with
`fields = ["_ambiguities"]` and no `name_regex` claims the
ambiguity-only reading of every protected shape, which is the #328
event in that dimension, and only the wider universe grows
`absorbed_by` and fails.

It does NOT close the matching hole on the exclusion side: an entry
narrowing itself to `fields = ["_ambiguities"]` protects nothing
anyone would notice and passes both pins under either universe,
because `absorbed_by` is legitimately empty for the honest entry
too. Taking the universe from compare's own set at least keeps the
two from drifting apart as `_RULE_FIELDS` grows.
"""
return tuple(sorted(compare._RULE_FIELDS))


def test_every_exclusion_silences_what_is_recorded() -> None:
"""Both directions an exclusion can drift, recorded rather than
derived -- because a derivation from the same data always agrees
with itself, which is how the first version of this guard came to
be tautological.

`absorbed_by` is asked with exclusions OFF. That is the only way to
see the #328 event at all: once an entry is in place the harness
reports nothing, correctly, so a rule widened to reach a protected
shape is invisible everywhere else. When this tuple grows, someone
has to decide whether the new rule is legitimate and the exclusion
is now doing real work, or whether the rule reached too far.

`captures`/`digest` are the opposite drift. An over-wide exclusion
silences classifications a rule should make -- loud at release,
silent in CI, which is the wrong way round for something a push
can introduce.
"""
compare = load_tool("compare")
roles = _protectable_fields(compare)
actual: dict[str, _Excluded] = {}
for ledger in _LEDGERS:
rules = compare._sorted_rules(_rules(ledger))
for entry in _exclusions(ledger):
captured = sorted(name for name in _CORPUS_NAMES
if re.search(entry["name_regex"], name))
covered = entry.get("fields")
absorbed = set()
for example in entry["examples"]:
for size in range(1, len(roles) + 1):
for combo in itertools.combinations(roles, size):
diff = set(combo)
if covered is not None and not diff <= set(covered):
continue
claimed = compare.classify(example, diff, rules)
if claimed:
absorbed.add(claimed.split(")")[0] + ")")
actual[entry["name_regex"]] = _Excluded(
len(captured),
hashlib.sha256(
"\n".join(captured).encode("utf-8")).hexdigest()[:12],
tuple(sorted(absorbed)))
assert actual == _EXCLUSION_EFFECT, (
f"what an exclusion silences has moved. Recorded "
f"{_EXCLUSION_EFFECT}, now {actual}. A grown `absorbed_by` means "
f"a rule now reaches a protected shape -- decide whether that "
f"rule is right before recording it. A changed captures/digest "
f"means the exclusion itself moved, which is loud at release "
f"and silent here until this fails.")
assert actual, (
"no ledger declares a [[never]] entry, so this pin is passing "
"vacuously")


def test_a_fields_narrowing_actually_narrows_something() -> None:
"""The other direction, which nothing else watches.

test_every_exclusion_silences_what_is_recorded pins an entry's
reach by NAME -- how much corpus it captures, and which rules would
claim its examples. It says nothing about whether the `fields`
narrowing on top of that reach still leaves anything behind, and
the two failures are not symmetric: an over-wide `fields` silences
diffs a rule should explain, on names that are not examples and so
are looked at nowhere else.

Note where this fails when `fields` is DELETED outright: on the
vacuity assert at the end, not on the per-entry assert below, since
a deleted key drops the entry from the loop entirely. That works
only while one entry carries `fields`. A second one would leave the
deletion green here -- caught instead by `absorbed_by` in the
recorded pin, which is then asked about every reading rather than
the three the key covers, and sees rules claim them. Measured:
deleting this entry's `fields` grows its `absorbed_by` from () to
('fix(suffix-routing)',).

Measured: deleting `fields = ["nickname", "middle"]` from the
ASCII-pairs entry passes every other check in this tree. The entry
then refuses ANY diff on the thirteen corpus names it captures --
including 'Jenny (Johnson) Baker' and 'Lon (Jr.) Williams', whose
parens are a maiden name and a suffix, both under active
development. Nothing failed, because none of those names diffs
today and none of them is an example.

So: an entry that bothers to name `fields` must leave something
behind. If no captured corpus name is still classifiable on a
reading outside them, the narrowing is not narrowing -- either it
was deleted, or it grew to cover everything the entry reaches.
"""
compare = load_tool("compare")
roles = _protectable_fields(compare)
checked = 0
for ledger in _LEDGERS:
rules = compare._sorted_rules(_rules(ledger))
never = _exclusions(ledger)
for entry in never:
covered = entry.get("fields")
if covered is None:
continue
checked += 1
captured = [name for name in _CORPUS_NAMES
if re.search(entry["name_regex"], name)]
survives = [
(name, sorted(combo))
for name in captured
for size in (1, 2)
for combo in itertools.combinations(roles, size)
if not set(combo) <= set(covered)
and compare.classify(name, set(combo), rules, never)]
assert survives, (
f"{ledger.name}: the entry for {entry['name_regex']!r} names "
f"fields={covered}, but no corpus name it captures is still "
f"classifiable on any reading outside them. It captures "
f"{len(captured)} names, so the narrowing has stopped "
f"narrowing -- check it was not deleted or widened to cover "
f"the whole entry.")
assert checked, (
"no exclusion declares `fields`, so this pin is passing vacuously")
Loading
Loading