Skip to content

Commit ee6aaa6

Browse files
derek73claude
andcommitted
fix(differential,guards,docs): the PR review findings, measured
Four reviewers on #455. Two converged on the same Critical, and five of the rest are corrections of mine that were half-applied or written without being driven through classify(). CRITICAL -- main()'s wiring had no composition test. Measured on mutants built outside the repo: deleting the roles_by_issue accumulator, deleting the report loop, or dropping `or overwide` from the return each left the whole suite green, and with the accumulator gone the check reports nothing forever while looking wired up. The file already made this call for the sibling check -- _run_main's own docstring says "every unit test proves a helper WORKS while none proves main() calls it, and in a gate the composition is the part that can go silently permissive" -- and the dormancy check has three such tests. This adds two, verified to kill all three mutants, one of them pinning exit 0 so the check cannot pass by being unconditional. CRITICAL -- four prose claims falsified by classify(), all mine: - "a maiden diff on the nee name goes to fix(#274)" is true of {family, maiden} and FALSE of {given, family, maiden}, the shape the rule would actually grow into, because fix(#274)'s own fields stop at maiden/middle/family. Same claim in decisions.md's reusable-lesson bullet, which is the one a future arc copies. Both now name the shape rather than the role, which is the bullet's own stated method. - "`middle` alongside `title` or `suffix` is the combination #452 protects here" -- measured over the 23 names the regex reaches, {middle, suffix} is absorbed on four of them. The comment now carries the per-shape breakdown. - the emoji bullet said FOUR blocks at the front and "two narrower blocks" at the back: my own correction, half-applied. - a cross-reference pointed at the wrong paragraph AND named the wrong trio, including the next rule's only name. HIGH -- _CROSS_RULE_WINNERS covered one of three ledgers and its coverage assertion was `<=`, so a ledger with no rows read as "no contest" rather than "nobody looked". Both sibling rosters use equality. The narrowings are what made that expensive: shrinking a rule's fields hands every shape it no longer admits to whatever claims it next, and measured, that moved shapes in both 2.x files. Assertion is now `==`, both ledgers have sections, and the two handovers are pinned -- with the issue strings MEASURED after a guessed one was caught by the guard itself. Also: the OVER-DECLARED report now names the ledger, because this rule's correct fields differ per baseline and a message without the file sends the reader to edit a rule that is not the broken one; the report says so when --corpus made the union partial, where the printed repair could orphan a name; the docstring records that the union is a per-RULE bound, so a diff shape no single name produced is still claimable; `if not moved` became `is None`; the roster comment's flat "FORCED" is walked back to match decisions.md; a 2.0.0 paragraph that misdescribed the one above it is fixed; and AGENTS.md's release checklist now says a skipped baseline loses the over-declaration audit too. 5349 passed; gates 229/0, 194/0, 102/0, no OVER-DECLARED. Refs #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 13e432a commit ee6aaa6

7 files changed

Lines changed: 196 additions & 38 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ uv run sphinx-build -b html docs dist/docs
9999
# 1. Run the differential gate at EVERY baseline that has a ledger with rules,
100100
# and confirm each exits 0. The dynamic check only runs for the ledger
101101
# matching the baseline being run, so a ledger left out here gets no
102-
# dynamic dormancy check at all -- see tools/differential/README.md:
102+
# dynamic dormancy check AND no over-declaration check (#452) at all
103+
# -- and neither is hypothetical: #452 found 3, 5 and 6 stale
104+
# declarations sitting in the three files. See
105+
# tools/differential/README.md:
103106
# uv run python tools/differential/compare.py --baseline 1.4.0 # the v1 compat contract
104107
# uv run python tools/differential/compare.py # the previous minor
105108
# uv run python tools/differential/compare.py --baseline 2.0.0 # 2.0.0's ledger has rules too

docs/design/decisions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,7 @@ Decisions that landed:
630630
- 2026-08-27 #451 — a rule whose declared `fields` EXCEED every diff it explains is a defect in its own right, not untidiness. Measured on the catch-all: it declared `{given, family, suffix}`, and by the time this bundle's first three commits had given its `{given, family}` names rules of their own, nothing it still explained moved `given`. Note WHEN the excess appeared, because it is the whole mechanism — the declaration was earned when the rule was written and became excess as its explained set SHRANK under it, which is exactly `fix(#424)` at decisions.md#H1, where #410 narrowed a diff from three roles to two and the rule kept matching by superset with no run naming it. A rule does not have to widen to become over-declared; the diffs beneath it only have to leave. The two together are why #452 was filed — nothing counts what a rule actually explains, only whether it explains anything.
631631
- 2026-08-27 #451 — a ledger rule may classify a reading NOBODY WANTS, provided its `issue` says so. `fix(#342)` and `fix(#397)` carry NOT WANTED in the issue text, the cause-not-consent sentence decisions.md#P5 established for the #369 regression, and a delete-when-fixed instruction the dormancy check enforces once the fix lands.
632632
- 2026-08-27 #451 — one vocabulary per alternation, FORCED rather than chosen. `_LatinCopy` carries one `vocabulary` and `test_latin_alternations_mean_something_the_vocabulary_ships` asserts exactly one roster key per alternation, so `jr` (SUFFIX_WORDS) and `mc`/`mp` (SUFFIX_ACRONYMS) cannot share one. That constraint forces one of the three splits, and forces it only given the roster as written: `_LatinCopy.vocabulary` is a hand-supplied frozenset, so declaring `SUFFIX_WORDS | SUFFIX_ACRONYMS` would let `(jr\.?|v|mp|mc)` pass the member checks — measured. Declining to write a union vocabulary is a judgement, that a rule's alternation should name one wordlist a reader can go and check, and it is recorded here as one rather than as a constraint. The other two are forced separately and are recorded below under the declined single-rule tail: `m\.?a\.?` cannot be an alternation member at all, and the numeral alternation copies no wordlist. Four rules, three independent reasons, none of them taste.
633-
- 2026-08-27 #451 — a ledger rule's character class must not reach a codepoint the IMPLEMENTATION does not act on, and is pinned to that. A SUBSET, not a copy: `test_the_emoji_boundary_rule_copies_the_dividing_ranges` asserts `claimed - divides == set()` and nothing in the other direction, deliberately — `_EMOJI_RANGES` has FOUR blocks and the rule copies the two astral ones, leaving the BMP pair out because no corpus name reaches them through its token anchor. Reading it as equality would instruct the next author to pre-claim the BMP half, which is the #372 shape this same section leans on. The emoji rule's first draft spanned `U+1F300-U+1FAFF` while `_tokenize.py`'s `_EMOJI_RANGES` divides on two narrower blocks: it claimed 1072 codepoints where the parser leaves the token whole, so its own prose ("an emoji inside a token divides it") was false for every one of them and a `{given, family}` diff with another cause would have classified as intended. `test_the_emoji_boundary_rule_copies_the_dividing_ranges` holds it, and fails on the draft it replaced.
633+
- 2026-08-27 #451 — a ledger rule's character class must not reach a codepoint the IMPLEMENTATION does not act on, and is pinned to that. A SUBSET, not a copy: `test_the_emoji_boundary_rule_copies_the_dividing_ranges` asserts `claimed - divides == set()` and nothing in the other direction, deliberately — `_EMOJI_RANGES` has FOUR blocks and the rule copies the two astral ones, leaving the BMP pair out because no corpus name reaches them through its token anchor. Reading it as equality would instruct the next author to pre-claim the BMP half, which is the #372 shape this same section leans on. The emoji rule's first draft spanned `U+1F300-U+1FAFF` while the two ASTRAL blocks of `_tokenize.py`'s `_EMOJI_RANGES` are narrower than that span: it claimed 1072 codepoints where the parser leaves the token whole, so its own prose ("an emoji inside a token divides it") was false for every one of them and a `{given, family}` diff with another cause would have classified as intended. `test_the_emoji_boundary_rule_copies_the_dividing_ranges` holds it, and fails on the draft it replaced.
634634
- 2026-08-27 #451`_sorted_rules` is KEPT although the ban makes it the identity on every ledger that loads. Verified against all three: the ban removes the only shape that could occupy its second tier, so file order settles every tie there is and the function reorders nothing. Kept because it is what makes the ban safe to state for any reader that does not call `validate_rules` first — a future tool, a REPL, a test fixture. Deleting it would move the guarantee from the code into a convention, which is the trade #451 was filed to undo. Recorded as a decision rather than as a consequence precisely so a later dead-code sweep finds the reasoning before the `return`.
635635
- 2026-08-27 #451 — past-tense counts stay in a ledger comment; live ones do not. "It claimed 25 until #372" is a fixed fact. "Four corpus names, and the prose fits all four" was written when the rule explained four and still read that way at fourteen, because nothing recomputes it. Where a count is owned by another comment, point at the owner rather than restating it — two copies means one gets updated and the other does not.
636636
- 2026-08-28 #452 — a rule's declared `fields` must EQUAL the union of the diffs it explains, checked by `compare.py` at the end of every run and failing it like an unexplained diff. The statement is exact rather than heuristic, which is what makes it cheap: `classify()` already requires `declared >= union` for the rule to match the names it matches, so the only possible error is the other direction, and the union is simultaneously the check and the repair. Narrowing to it cannot orphan a name, since every name a rule explains contributed to it. Measured before landing: 3 of 67 explaining rules over-declared at 1.4.0, 5 of 58 at 2.0.0, 6 of 51 at 2.1.0 — all fourteen narrowed first, so the check was silent the day it arrived.
@@ -639,7 +639,7 @@ Decisions that landed:
639639

640640
Found rather than decided, and worth as much:
641641

642-
- **A fields-only rule silently falsifies OTHER comments.** The 2.x ledgers' idiom "such a name should arrive UNEXPLAINED and be read once" holds there because no rule above the one making the promise claims that diff shape. A fields-only rule is the extreme way to break it, not the only way: measured while fixing #452, a `maiden` diff on `fix(#367)`'s own probe goes to `fix(#274)` in the 1.4.0 ledger with no fields-only rule anywhere. The reusable check is "does anything above me claim this shape", which is `classify()` driven directly — NOT "is there a catch-all". In the 1.4.0 ledger it was false for anything of the catch-all's shape, and nothing said so. Measured while the rule still lived, by driving `compare.classify()` directly over the seven `_MUST_NOT_MATCH` probes of the two rules #451 ported, at diff shape `{given, family}`: ZERO arrived unexplained. Four went to the catch-all ('Mcintyre Smith Jr.', 'Los Santos', 'John Sir de Mesnil', 'Sir Smith'), two to `fix(comma-precomma-family)` ('McDonald, Ronald', 'Smith, Sir de Vaux'), and one ('Ste Marie') to the neighbouring `fix(#360) ste` rule that exists to claim it. Driven through `classify()` rather than read off a gate run, deliberately: six of the seven are in no corpus, so a run would never reach them — which is also why the promise they carried could go false unnoticed. The blast radius of the shape was never just its own heading.
642+
- **A fields-only rule silently falsifies OTHER comments.** The 2.x ledgers' idiom "such a name should arrive UNEXPLAINED and be read once" holds there because no rule above the one making the promise claims that diff shape. A fields-only rule is the extreme way to break it, not the only way: measured while fixing #452, a `{family, maiden}` diff on `fix(#367)`'s own probe goes to `fix(#274)` in the 1.4.0 ledger, which has no fields-only rule anywhere. Note the shape, not the role: `{given, family, maiden}` on the same probe is claimed by nothing, because `fix(#274)`'s `fields` stop at maiden/middle/family. That is the point — the question is only ever answerable per shape. The reusable check is "does anything above me claim this shape", which is `classify()` driven directly — NOT "is there a catch-all". In the 1.4.0 ledger it was false for anything of the catch-all's shape, and nothing said so. Measured while the rule still lived, by driving `compare.classify()` directly over the seven `_MUST_NOT_MATCH` probes of the two rules #451 ported, at diff shape `{given, family}`: ZERO arrived unexplained. Four went to the catch-all ('Mcintyre Smith Jr.', 'Los Santos', 'John Sir de Mesnil', 'Sir Smith'), two to `fix(comma-precomma-family)` ('McDonald, Ronald', 'Smith, Sir de Vaux'), and one ('Ste Marie') to the neighbouring `fix(#360) ste` rule that exists to claim it. Driven through `classify()` rather than read off a gate run, deliberately: six of the seven are in no corpus, so a run would never reach them — which is also why the promise they carried could go false unnoticed. The blast radius of the shape was never just its own heading.
643643
- **Copy-paste is how a ledger comment goes false without being edited.** All three ledgers carried the same `fix(#379)` comment; four of its sentences were true only in the 1.4.0 file they were written for, including one citing `fix(suffix-routing)`, which exists in no 2.x ledger, and one saying a name "falls to the fields-only catch-all" twenty lines after the same file says no fields-only rule exists there. Corrected in `8237b54`. A cross-ledger claim needs re-deriving per file, not copying.
644644

645645
Declined:

tests/v2/test_differential.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,52 @@ def test_main_exits_1_and_names_a_rule_that_explained_nothing(
585585
assert "unexplained: 0" in out
586586

587587

588+
def test_main_exits_1_and_names_an_over_declared_rule(
589+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
590+
"""The #452 gate. `wide` explains the only diff here and declares
591+
two roles it never moves -- the run must say so and fail, even
592+
though every diff IS explained and no rule is idle.
593+
594+
Nothing else pins this, which is the same gap _run_main's own
595+
docstring names for the dormancy check: five unit tests prove
596+
over_declared_rules WORKS and none proved main() calls it.
597+
Measured on mutants built outside the repo -- deleting the
598+
`roles_by_issue` accumulator, deleting this report loop, or
599+
dropping `or overwide` from main's return each leaves the whole
600+
suite green (#455 review).
601+
"""
602+
code, out = _run_main(
603+
tmp_path, monkeypatch,
604+
'[[change]]\nissue = "wide"\nname_regex = "Smith"\n'
605+
'fields = ["family", "given", "suffix"]\n', _DIFFERS)
606+
assert code == 1
607+
# the ledger is named before the issue: this rule's correct
608+
# `fields` differ per baseline, so the file is part of the finding
609+
assert "OVER-DECLARED" in out and "'wide'" in out
610+
assert out.count(".toml: 'wide'") == 1
611+
assert "['given', 'suffix']" in out # the roles nothing moves
612+
assert "['family']" in out # the repair
613+
# the diff itself was explained; this failure is only about the rule
614+
assert "unexplained: 0" in out
615+
616+
617+
def test_main_accepts_a_rule_declaring_exactly_what_it_moves(
618+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
619+
"""The other direction, so the #452 check cannot pass by being
620+
unconditional. `exact` declares the one role the one diff moves,
621+
and the run is silent and exits 0.
622+
623+
Its partner above would still pass if over_declared_rules reported
624+
every rule; this is what makes that impossible.
625+
"""
626+
code, out = _run_main(
627+
tmp_path, monkeypatch,
628+
'[[change]]\nissue = "exact"\nname_regex = "Smith"\n'
629+
'fields = ["family"]\n', _DIFFERS)
630+
assert code == 0
631+
assert "OVER-DECLARED" not in out
632+
633+
588634
def test_main_only_feeds_diffing_names_to_the_dormancy_check(
589635
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
590636
"""If a non-diffing name reached `diffing`, a rule matching only

tests/v2/test_ledger_guards.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,12 +1039,19 @@ class _LatinCopy(NamedTuple):
10391039
# partial for fix(#379)'s reason, SUFFIX_ACRONYMS running to
10401040
# hundreds of entries that nobody writes after a bare name word.
10411041
#
1042-
# This is the roster entry that FORCED #451's split into four rules
1043-
# rather than one: the sibling rule's `jr` is a SUFFIX_WORDS entry,
1044-
# _LatinCopy carries exactly one `vocabulary`, and the test below
1045-
# asserts exactly one roster key per alternation -- so an
1046-
# alternation spanning both vocabularies could not be pinned
1047-
# against either, and had to become two rules.
1042+
# This is the roster entry behind one of #451's three splits, and
1043+
# it forces that split only GIVEN THE ROSTER AS WRITTEN: the
1044+
# sibling rule's `jr` is a SUFFIX_WORDS entry, _LatinCopy carries
1045+
# exactly one `vocabulary`, and the test below asserts exactly one
1046+
# roster key per alternation -- so an alternation spanning both
1047+
# vocabularies cannot be pinned against either AS DECLARED HERE.
1048+
# `vocabulary` is a hand-supplied frozenset, though: declaring
1049+
# SUFFIX_WORDS | SUFFIX_ACRONYMS would let `(jr\.?|v|mp|mc)` pass
1050+
# the member checks, measured. Declining to write a union is a
1051+
# judgement -- an alternation should name one wordlist a reader can
1052+
# go and check -- and decisions.md records it as one rather than as
1053+
# a constraint. An earlier version of this comment said FORCED
1054+
# flatly and was walked back (#452 review).
10481055
"fix(suffix-routing) a two-token name ending in a credential acronym keeps it in `suffix`":
10491056
_LatinCopy(vocabulary=SUFFIX_ACRONYMS,
10501057
covers=frozenset({"mc", "mp"})),
@@ -2069,6 +2076,39 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None:
20692076
("김민준씨 Jr.", ("family", "given", "suffix")):
20702077
"fix(cjk-glued-honorific-peel) glued honorific peels into suffix",
20712078
},
2079+
# The two 2.x ledgers had NO section here until #452, and the
2080+
# coverage assertion below was `<=`, so their absence read as "no
2081+
# contest to pin" rather than "nobody looked". The #452 narrowings
2082+
# are what made that expensive: shrinking a rule's `fields` hands
2083+
# every shape it no longer admits to whatever claims it next, and
2084+
# measured across the fourteen, that moved shapes in both files.
2085+
# Neither _CORPUS_CLAIMS nor the gate's totals can see a handover --
2086+
# reach is regex-only and the total is per-corpus -- so these rows
2087+
# are the only thing that would.
2088+
"expected_since_2.0.0.toml": {
2089+
# fix(#296) lost `family` and `given`; {family} on this name is
2090+
# one of the shapes it stopped admitting, and fix(#379) takes it.
2091+
# The right home -- a tussenvoegsel attaching to the family is
2092+
# exactly what that rule is about -- which is not the point: the
2093+
# point is that a later edit sends it somewhere else in silence.
2094+
("Nguyen, Van", ("family",)):
2095+
"fix(#379) a tussenvoegsel after a family comma attaches to the family",
2096+
# fix(#412) lost `middle`; this shape went to fix(#445), which
2097+
# sits BEHIND it in file order, so the handover was decided by
2098+
# the narrowing rather than by position.
2099+
("Jane née and Jones Smith", ("family", "maiden", "middle")):
2100+
"fix(#445) the lone name word beside a marker a connective join no longer absorbs",
2101+
},
2102+
"expected_since_2.1.0.toml": {
2103+
# The same two handovers, measured against this baseline's own
2104+
# run rather than copied from the 2.0.0 rows -- the ledgers
2105+
# differ, and #452's own lesson is that a claim true in one file
2106+
# is not thereby true in its siblings.
2107+
("Nguyen, Van", ("family",)):
2108+
"fix(#379) a tussenvoegsel after a family comma attaches to the family",
2109+
("Jane née and Jones Smith", ("family", "maiden", "middle")):
2110+
"fix(#445) the lone name word beside a marker a connective join no longer absorbs",
2111+
},
20722112
}
20732113

20742114

@@ -2107,9 +2147,17 @@ def test_the_recorded_rule_still_wins_each_contested_name() -> None:
21072147
f"describe is #372, and it stays green everywhere else")
21082148
checked += 1
21092149
assert checked, "no contested name was checked, so this pin is vacuous"
2110-
assert set(_CROSS_RULE_WINNERS) <= {led.name for led in _LEDGERS}, (
2111-
f"_CROSS_RULE_WINNERS names ledgers that do not exist: "
2112-
f"{sorted(set(_CROSS_RULE_WINNERS) - {L.name for L in _LEDGERS})}")
2150+
assert set(_CROSS_RULE_WINNERS) == {led.name for led in _LEDGERS}, (
2151+
f"_CROSS_RULE_WINNERS must name every ledger on disk, with an "
2152+
f"explicit empty mapping for one that genuinely has no contest. "
2153+
f"Missing: {sorted({L.name for L in _LEDGERS} - set(_CROSS_RULE_WINNERS))}; "
2154+
f"unknown: {sorted(set(_CROSS_RULE_WINNERS) - {L.name for L in _LEDGERS})}. "
2155+
f"This was `<=` until #452, which made a ledger with no rows "
2156+
f"indistinguishable from one needing none -- and the #452 "
2157+
f"narrowings moved shapes between rules in the two 2.x ledgers "
2158+
f"that had no section at all. Both sibling rosters "
2159+
f"(_CORPUS_CLAIMS, _SPAN_BEARING_RULES) use equality; this one "
2160+
f"was the odd one out.")
21132161

21142162

21152163
class _Excluded(NamedTuple):

0 commit comments

Comments
 (0)