Skip to content

Fail the gate when a ledger rule declares a role it never explains - #455

Merged
derek73 merged 9 commits into
masterfrom
claude/452-declared-reach
Aug 29, 2026
Merged

Fail the gate when a ledger rule declares a role it never explains#455
derek73 merged 9 commits into
masterfrom
claude/452-declared-reach

Conversation

@derek73

@derek73 derek73 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes the explained-reach half of #452. The reach ceiling stays open on that issue.

classify() returns the first rule whose declared fields are 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#H1 records the case that already happened: #410 narrowed Freiherr von Richthofen V from {given, family, suffix} to {family, suffix} while the fix(#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.py now asserts, at the end of every run, that each rule's declared fields equal 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 requires declared ⊇ union for 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, and declared == union is 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:

ledger rules explaining ≥1 over-declared before after
1.4.0 67 3 0
2.0.0 58 5 0
2.1.0 51 6 0

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 suffix declares {family, given, suffix, title}: exactly exercised at 1.4.0, where v1 reads the pre-comma word as first and 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. dormant already covers explains-nothing in both directions; a rule with no fields has 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 way dormant was argued for in #373.

Proved able to fail

Two ways, neither touching a tracked file:

  • the pure function on a widened rule — restoring middle to one fix(#367) rule reports it;
  • main() end to end — with tomllib.loads and _run_worker monkeypatched in memory over a one-name corpus, the gate prints OVER-DECLARED and 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

626e2a8test_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 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 — a dormant rule that did explain a diff, which is also the only interesting one, being exactly the NO LONGER DORMANT state.

Same commit: _CORPUS_CLAIMS' own docstring said "Twenty of these moved at once when corpus_rules.jsonl landed (#414)". Parsing the roster out of 7a10689^ and 7a10689 with ast and 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:

  • the section's reusable-lesson bullet named the wrong cause: the 2.x "should arrive UNEXPLAINED" idiom holds because no rule above claims that diff shape, not because those ledgers have no fields-only rule. Measured — 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. That is the bullet 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. 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.
  • "FORCED rather than chosen … none of them taste" overstated by one: _LatinCopy.vocabulary is a hand-supplied frozenset, so a SUFFIX_WORDS | SUFFIX_ACRONYMS declaration 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

  • The reach ceiling, A ledger rule's explained set is printed but never checked, so a diff can shrink underneath it and every run stays green #452's other half — bounds what a rule reaches rather than what it explains, and stays on the issue with its measurements (today's maximum is 25.6%).
  • explains = N per 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_WINNERS has no expected_since_2.0.0.toml section 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.0 194/0, 2.1.0 102/0, no OVER-DECLARED and no EXPLAINED 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

derek73 and others added 8 commits August 28, 2026 23:53
…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>
@derek73 derek73 added docs Documentation fixes and updates tooling tests labels Aug 29, 2026
@derek73 derek73 self-assigned this Aug 29, 2026
@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.64%. Comparing base (a5de5d2) to head (ee6aaa6).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation fixes and updates tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant