Fail the gate when a ledger rule declares a role it never explains - #455
Merged
Conversation
…exercise Each declared a role no diff it explains moves: `middle` on fix(cjk-comma-compound) and on both fix(#367) particle rules. classify() admits a rule when the diff is a SUBSET of its `fields`, so the excess is not inert -- it lets the rule keep claiming a diff that shrinks out of the declared role, which is what #410 found on fix(#424) and what #452 is about. Narrowed to the measured union of the diffs each rule explains, which cannot orphan a name: every name a rule explains contributed to that union and still matches. _CORPUS_CLAIMS re-measured, not adjusted. Gate 229/0. Refs #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xercise Same defect as the previous commit, measured against this baseline: two fix(#296) credential rules declare `family` and `given`, two fix(#367) particle rules and fix(#412) declare `middle`, and no diff any of them explains moves those roles here. The fix(#296) pair keeps `family` and `given` in the 1.4.0 ledger on purpose -- that baseline produces bigger diffs and does move them, so the same rule is exactly exercised there and over-declared here. Each ledger is measured on its own run rather than copied. _CORPUS_CLAIMS re-measured. Gate 194/0. Refs #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ercise The last of the fourteen. Three fix(#296) credential rules declare `family` and/or `given`, two fix(#367) particle rules and fix(#412) declare `middle`, and no diff any of them explains moves those roles at this baseline. Measured against this ledger's own run rather than copied from the 2.0.0 edits. The fix(#296) rules keep the wider declaration in the 1.4.0 ledger on purpose: that baseline reads the pre-comma word as `first`, so all four roles move there and the declaration is earned. Every ledger now declares exactly the roles its rules exercise, which is what the check two commits from now asserts. _CORPUS_CLAIMS re-measured. Gate 102/0. Refs #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…han its diffs Pure and unwired: the next commit calls it. classify() admits a rule when the diff is a SUBSET of its `fields`, so a rule keeps matching as the diff beneath it shrinks -- #410 narrowed 'Freiherr von Richthofen V' from three roles to two while the rule declaring all three kept claiming it, and no run said so. The statement is exact rather than heuristic: classify() already REQUIRES declared >= union(explained diffs), so the only possible error is the other direction and `declared == union` is the whole check. The union is also the repair. Dormant rules and rules with no `fields` are skipped, neither as an exemption -- one explains nothing by declaration and dormant_rules owns that finding, the other declares no roles to exceed. Refs #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xplains Wires over_declared_rules into main(): a `roles_by_issue` accumulator beside `by_issue`, an OVER-DECLARED report beside the dormancy one, and a third clause on the return. Same exit code as an unexplained diff and a dormant rule, because all three mean the ledger has stopped describing what the code does. Silent on all three ledgers, because the previous three commits narrowed the fourteen rules it would have named -- measured, 0 over-declared where there were 14. Proved able to fail: widening one fix(#367) rule back by `middle` reports it. Closes #452 for the explained-reach half; the reach ceiling stays open on that issue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…heck The rule grammar gains the requirement; the fields-only arc section gains the decision, including why the statement is exact rather than heuristic (classify() already requires declared >= union, so the only possible error is the other direction) and why over-declaration is baseline-relative -- the fix(#296) pair is exactly exercised at 1.4.0 and over-declared at both 2.x baselines, which is why each ledger was measured on its own run rather than copied. mechanisms.md#LEDGER-RULE-SEPARATION said `fields` narrows by subset and stopped there, which is now half the contract: the exactness requirement is added beside it. The `[[never]]` passage says the requirement does not reach exclusions, which explain no diff, so the new paragraph above it cannot be read as governing them. Declined and recorded: `explains = N` per rule, on maintenance cost rather than merit, with the note that it is the design to revisit if a name-count shrink ever bites. Two figures corrected against measurement rather than pasted. The fix(#296) rule has TWO field lists across the three ledgers, not three -- both 2.x files narrowed to the same {suffix, title}. And #414's harvest moved 17 of the 30 _CORPUS_CLAIMS entries then present, not 20; the roster comment that owns the count says twenty and is wrong, left uncorrected by a prose-only bundle and recorded as such. Refs #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the #452 doc review found, both the failure mode this issue is about, one level up. test_over_declared_rules_skips_a_dormant_rule could not fail. It passed roles_by_issue={}, which the `if not moved` guard catches whether or not the `dormant` clause exists -- mutation-tested, the test is green with that clause deleted. A test that cannot fail, inside the check just landed to catch rules that cannot be seen to drift. It now passes a dormant rule that DID explain a diff, which is the only separating input and also the only interesting one: that state is what dormant_rules reports as NO LONGER DORMANT, one defect with one remedy (remove the `dormant` key), and reporting it here as well would demand a second, contradictory one. Verified against a mutant built outside the repo: with the clause deleted the rule is flagged, so the test now fails on its removal. _CORPUS_CLAIMS' own comment said "Twenty of these moved at once when corpus_rules.jsonl landed (#414)". Measured by parsing the roster out of 7a10689^ and 7a10689 with ast and diffing per-entry: 30 entries before, 44 after, 17 changed and 14 added. Three ways -- that commit, the whole of PR #415, the backtick-harvest commit after it -- all give 17. The comment carries the figure and the recipe now, and the decisions.md entry that cites it agrees rather than noting a contradiction it left standing. 5347 passed; gates 229/0, 194/0, 102/0, no OVER-DECLARED. Refs #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three are in the fields-only arc section, all three are mine, and all three were found by the design-docs review of the #452 docs commit. Measured, not argued. The reusable-lesson bullet named the wrong cause. It said the 2.x "should arrive UNEXPLAINED" idiom is true there BECAUSE those ledgers have no fields-only rule. A fields-only rule is the extreme way to break that promise, 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, which has no fields-only rule at all. The check that generalizes is "does anything above me claim this shape", driven through classify(). This bullet is the one a future arc copies, so the wrong cause would have propagated. The emoji bullet asserted equality where the guard checks a subset, and said two blocks where _EMOJI_RANGES has four. The rule copies the two ASTRAL blocks and leaves the BMP pair out because no corpus name reaches them through its token anchor; the guard asserts only that the class reaches nothing the tokenizer ignores. Read as equality it would tell the next author to pre-claim the BMP half -- the #372 shape this same section leans on. "FORCED rather than chosen ... none of them taste" overstated by one. _LatinCopy.vocabulary is a hand-supplied frozenset, so declaring SUFFIX_WORDS | SUFFIX_ACRONYMS would let (jr\.?|v|mp|mc) pass the member checks -- measured. Two of the three splits are forced; the third is a judgement that an alternation should name one wordlist a reader can go and check, and it is recorded as one now. Refs #451, #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #455 +/- ##
=======================================
Coverage 98.64% 98.64%
=======================================
Files 45 45
Lines 3177 3177
=======================================
Hits 3134 3134
Misses 43 43 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the explained-reach half of #452. The reach ceiling stays open on that issue.
classify()returns the first rule whose declaredfieldsare a SUPERSET of the observed diff. So a rule keeps matching when the diff beneath it shrinks — and shrinking is the common direction, since most parser fixes move fewer roles rather than more.decisions.md#H1records the case that already happened: #410 narrowedFreiherr von Richthofen Vfrom{given, family, suffix}to{family, suffix}while thefix(#424)rule declaring all three kept claiming it — "a shrinking diff kept matching and no run ever named it. A real movement behind a green gate." That rule was narrowed by hand. Nothing would have said so.This adds the check that does.
The check
compare.pynow asserts, at the end of every run, that each rule's declaredfieldsequal the union of the diffs it explained, and fails the run like an unexplained diff.The statement is exact rather than heuristic, and that is what makes it cheap.
classify()already requiresdeclared ⊇ unionfor a rule to match the names it matches — declaring less would stop it matching one it explains. So the only possible error is the other direction, anddeclared == unionis the whole check. The union is simultaneously the check and the repair, and narrowing to it cannot orphan a name, because every name a rule explains contributed to it.No recorded number, so no corpus harvest can make it stale.
The migration
Measured before landing, then re-measured after:
All fourteen were narrowed before the check landed, so it is silent the day it arrives and every commit leaves the gate green.
Over-declaration is baseline-relative, which is why each ledger was measured on its own run rather than copied.
fix(#296) a lone post-comma credential is a suffixdeclares{family, given, suffix, title}: exactly exercised at 1.4.0, where v1 reads the pre-comma word asfirstand all four roles move, and over-declared at both 2.x baselines, where the same behaviour moves only{suffix, title}. A reader diffing the three files sees one rule with two different field lists and should read that as correct.No escape hatch
Decided, not deferred.
dormantalready covers explains-nothing in both directions; a rule with nofieldshas nothing to over-declare; and the ledger's own doctrine — "a rule that pre-claims shapes it has never seen is the #372 failure mode" — makes strictness the existing principle. Accepted cost, written down so it is not rediscovered as a surprise: the first rule that genuinely needs a wider declaration has to argue for a key the waydormantwas argued for in #373.Proved able to fail
Two ways, neither touching a tracked file:
middleto onefix(#367)rule reports it;main()end to end — withtomllib.loadsand_run_workermonkeypatched in memory over a one-name corpus, the gate printsOVER-DECLAREDand returns 1.A guard that cannot fail is worthless, and this branch found one of those inside its own new tests — see below.
Two commits that came out of review, not the plan
626e2a8—test_over_declared_rules_skips_a_dormant_rulecould not fail. It passedroles_by_issue={}, which theif not movedguard catches whether or not thedormantclause exists; mutation-tested, the test was green with that clause deleted. A test that cannot fail, inside the check built to catch rules that cannot be seen to drift. It now uses the only separating input — adormantrule that did explain a diff, which is also the only interesting one, being exactly theNO LONGER DORMANTstate.Same commit:
_CORPUS_CLAIMS' own docstring said "Twenty of these moved at once when corpus_rules.jsonl landed (#414)". Parsing the roster out of7a10689^and7a10689withastand diffing per entry: 30 before, 44 after, 17 changed and 14 added — the same three ways (that commit, all of PR #415, the backtick-harvest commit after it). The comment carries 17 and the recipe now.13e432a— the design-docs review found three claims in the already-merged #451 bullets that are false. All three were mine:maidendiff onfix(#367)'s own probe goes tofix(#274)in the 1.4.0 ledger, which has no fields-only rule at all. That is the bullet a future arc copies, so the wrong cause would have propagated._EMOJI_RANGEShas four. Read as equality it instructs the next author to pre-claim the BMP half — the Ledger rules classify diffs they do not describe, and a rule that stops explaining anything is invisible #372 shape the same section leans on._LatinCopy.vocabularyis a hand-supplied frozenset, so aSUFFIX_WORDS | SUFFIX_ACRONYMSdeclaration would let(jr\.?|v|mp|mc)pass. Two of the three splits are forced; the third is a judgement, recorded as one now.Not in scope
explains = Nper rule, this issue's own first sketch — declined on maintenance cost, not merit: 179 numbers across three ledgers, every one liable to move when a corpus does. Recorded as the design to revisit if a name-count shrink ever bites._CROSS_RULE_WINNERShas noexpected_since_2.0.0.tomlsection at all, so no 2.0.0 contest is pinned. Pre-existing and wider than this issue.Verification
Gates unmoved throughout: 1.4.0
229/0, 2.0.0194/0, 2.1.0102/0, noOVER-DECLAREDand noEXPLAINED NOTHING.tests/v2/5347 passed, 156 skipped, 1 xfailed.Not the verification: a green gate. It was green through every instance this issue is about.
🤖 Generated with Claude Code