Skip to content

Ledger rule order: declare the contest, don't assume narrow-first - #499

Merged
derek73 merged 25 commits into
masterfrom
claude/docs-ledger-tooling-pr-699dcb
Sep 3, 2026
Merged

Ledger rule order: declare the contest, don't assume narrow-first#499
derek73 merged 25 commits into
masterfrom
claude/docs-ledger-tooling-pr-699dcb

Conversation

@derek73

@derek73 derek73 commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes #382.

What changes

An order-decided contest between two ledger rules must now be declared. Where
the later rule's fields are a strict subset of the earlier one's, both
name_regexes reach a common corpus name, and their orders are not disjoint,
every diff fitting the narrower set is admitted by both — so classify()
returning the first match means file position alone picks the winner.

Narrow-first is the declaration-free default. A wide-first pair stays legal and
carries a [[change.precedes_narrower]] block naming the later rule and saying
what the earlier one describes that it does not.

Three checks at three homes:

  • Shape, in validate_rules (corpus-free, startup): well-formed list of
    tables; issue names a rule present in the ledger and strictly LATER in the
    file; non-blank why; no duplicate targets; no self-reference; and no key but
    issue/why inside an entry.
  • Contest, in main() after the corpus loads and before the worker pass,
    so a refusal costs seconds rather than the multi-minute pinned-wheel run.
  • Contest again, in tests/v2/test_ledger_guards.py at pytest speed. This
    is the one that matters for Should file order be allowed to separate ledger rules within a tier? (김, 민준씨 is claimed by whichever of two rules comes first) #382's stated purpose — three later v2.3 bundles add
    ledger rules, and gating only on a full differential run would be far too weak.

What the check does not cover, stated plainly. Nesting is sufficient for an
order-decided contest, not necessary: two rules whose fields merely intersect
both admit any diff inside the intersection, so position decides between them too.
Measured, 60 such non-nested overlapping pairs share a corpus name. They are
outside the check by the same reasoning the documents already give for EQUAL
fields — neither rule is narrower, so "narrow-first" has no referent and
_CROSS_RULE_WINNERS stays the instrument — rather than by oversight. Widening to
the general predicate would demand 111 declarations where the real one is eleven,
which is the roster-nobody-reads argument this ledger already makes about the 657
figure. Filed as #498 with a worked contract-tier case.

The premise #382 filed on is false

Its option 3 proposed a mechanical narrow-first check. 马丁·路德·金씨 falsifies
it: fix(#272/#308) (nakaguro division and glued hangul honorific) and
fix(cjk-glued-honorific-peel) (the peel alone) both admit it, and the wider rule
wins by position — correctly, because it describes what happens to the name.
Narrow-first would reattribute it to a rule describing half of that, which is
#372's defect reintroduced by a check. The name is contract tier
(corpus_rules.jsonl) and absent from _CROSS_RULE_WINNERS, so it is precisely
the crux the issue was filed over.

So fields-subset is a proxy for specificity and the wrong one where a wider rule
describes a compound. The escape hatch is argued rather than deferred, which is
what #452's entry asked of the first rule that genuinely needed one: there, 0 of
179 rules had the banned shape and all fourteen over-declarations were narrowed
before the check landed. Here eleven pairs are correct as written.

Measurement (2026-09-02)

11 wide-first contested pairs in expected_since_1.4.0.toml; 0 in each 2.x
ledger. Six are contested over contract-tier names, five only over radar (#488's
demotion; see #495).

Without the corpus-reach condition the same scan reports 657 wide-first pairs
— of 1350 nested either way under fields-subset alone, 1348 with the orders
test kept. An exemption roster in the hundreds is one nobody writes and nobody
reads, so fields-subset alone is not a usable predicate.

Nine of the eleven pairs are latent, not live. Measured against the 1.4.0
wheel, the real diff needs a field the narrower rule does not declare, so that
rule is ineligible wherever it sits. Only two are decided by position today. That
is the honest cost of a static predicate — it over-reports relative to measured
diffs, and the price is a why someone had to write — and it is also why the
exemptions are worth having: most document a hazard that would activate if a
rule's fields widened.

The two checks are not symmetric under --corpus. A smaller name set removes
contests, which makes the undeclared check strictly more lenient and makes the
vacancy check false-alarm. So vacancy refuses only on a full run and NOTEs under
--corpus, joining the corpus-floor skip and over_declared_rules as a third
check reading the flag at a third strength.

Corrections this arc surfaced

  • _CROSS_RULE_WINNERS recorded 田中さん II as diffing ("given","suffix");
    measured, it is {family, given, suffix}. Four sites carried the wrong shape.
    The winner is unchanged under both. test_the_recorded_rule_still_wins_each_contested_name
    structurally cannot catch this — it feeds classify() the recorded shape — which
    is filed as Measured claims in the differential comments go stale unnoticed (a reviewer read "751 names" as fact; the corpus holds 1116) #497.
  • fix(comma-precomma-family) opened its comment naming two names it no longer
    explains; repointed, with the measured inventory of the seven it does.
  • The expected_since_1.4.0.toml and expected_since_2.1.0.toml headers still
    stated "write the narrower rule first" as law, in the file holding all eleven
    declared pairs. A contributor reading only that would reorder — the one repair
    the gate and the guard forbid.

Verification

Rule ORDER is identical in all four ledgers and no rule's name_regex, fields
or orders changed (TOML-parsed against c1a98a9, not grepped). No name changed
hands: _CROSS_RULE_WINNERS differs only in the corrected shape above, and the
_ORDER_EXEMPTION_EFFECT negative control — which ignores exemptions by design —
records the same 11 contests before and after.

Every exemption is load-bearing: dropping any one reports exactly its own pair as
undeclared, 11 for 11.

Gate at every baseline, all exit 0:

baseline names intentional unexplained radar unclassified
1.4.0 1113 352 0 0
2.0.0 1120 247 0 0
2.1.0 1120 155 0 0
2.2.0 1120 14 0 0

Full suite 6462 passed, 156 skipped, 5 xfailed; ruff clean; mypy clean on 111
source files.

design-docs-reviewer ran the eleven axes over the docs commits and returned three
confirmed findings, all fixed in 7fcd531: a false "never less" claim about the
predicate's error direction (finding above, now #498), an over-generalization that
the narrower rule's prose would be FALSE of the co-matched names when most
exemptions say merely partial, and a mechanisms.md sentence closing #382's open
question — which asked about its option 1, a regex predicate — with an answer about
option 3. Option 1 now has a Declined: bullet carrying the evidence that killed
it: narrowing the earlier rule's own regex cannot shrink the later rule's reach, so
its stated effect is unreachable, measured by implementing it.

Follow-ups filed

Also closed #496 (not planned) — a family-side twin for the pre-comma glued
honorific was weighed and declined: all three affected names are radar tier since
#488, so no gate can demand the rule, and #495 asks whether the radar-only rules
already here still earn their place. The finding survives the decline: pair 9's
exemption still says the holding rule's label is wider than those names are.

Also closed: #470 (not planned) — the Accepted: clause already carries what
it asked for, and its own example had been removed by #467.

🤖 Generated with Claude Code

derek73 and others added 19 commits September 2, 2026 14:22
Every same-tier rule pair whose fields are strictly nested and whose
regexes reach a common corpus name: the narrower rule's diffs are
admitted by both, so position alone picks the winner. Recorded as data
with exemptions ignored, which is the negative control for the guard
that follows.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`orders` is the third key _entry_matches narrows by, and the detector
was blind to it: two rules scoped to disjoint orders never see the same
comparison, so file order decides nothing between them and a reported
contest there would demand a justification for a hazard that cannot
occur. A rule declaring no `orders` stays order-blind and keeps
contesting, which is every rule in every shipped ledger -- the roster
is unchanged at 11 pairs, which is what says nothing live moved.

Also: cite #382 where the neighbours cite their issues, name the
prepared reach for what it returns and give it the NamedTuple shape its
siblings have, add the vacuity assertion the sibling rosters carry, and
phrase the tier split so it outlives a radar-to-contract promotion.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
precedes_narrower names ONE later rule and why, never a blanket
opt-out a narrower rule added tomorrow would inherit. Shape only here;
whether the pair is really contested needs the corpus and lands next.

Rejects a rule key misplaced into the exemption block: TOML binds every
bare key after [[change.precedes_narrower]] to the exemption, so an
`orders` written below it would vanish from the rule silently.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…epeat

Review found two live branches no test killed -- a 'precedes_narrower'
that is not a list of tables, and an entry with no 'issue'. Both are
now rows in the malformed-rule table, the second matching on 'entry
with' so it cannot pass against the rule-level message instead.

Also: a repeated target is refused, as a repeated 'fields' name is --
two reasons for one pair means one is stale and the ledger cannot say
which. A rule naming its own issue gets its own message rather than
being told it sits earlier in the file than itself. The position map
merges into the dedupe walk that was already there, and the block now
states why 'later in the file' means 'loses to this rule' at all: it
holds only while #451 and #456 keep every rule in one tier.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
undeclared_contests filters order_contests by what each rule declares;
vacant_exemptions reports the other direction, an exemption whose pair
stopped being contested. A permission nobody re-earned reads exactly
like a live one.

_declared_over reads the key leniently while validate_rules stays
strict, and the direction is the point: an entry this reader cannot
make sense of declares nothing, so the contest is reported rather than
retired by a typo.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
657 is the count with the `orders` test still in place; 1350 is
`fields`-subset ALONE, both conditions gone. Written as one sentence
the two read as the same scan, and neither is reproducible from the
description. The docstring now says which basis each number comes
from, how to recompute both, and states the argument in a form that
outlives the digits.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six review findings, all in prose but one.

_declared_over's docstring claimed a malformed entry "declares
nothing -- the safe direction". False: an entry naming a real rule
with a missing or blank `why` reads here as a good declaration and
RETIRES the pair, which is the likeliest hand-edit slip there is. The
split is safe only because validate_rules runs first and covers the
shipped files, so the docstring now says the guarantee is borrowed,
and drops the inverted claim that a stricter reader would be more
dangerous -- declaring less can only report more.

The test docstring inherited the same overreach: the malformed shapes
pin crash-safety, not the leniency, and a strict variant passes all
three of them. Narrowed to what it pins.

undeclared_contests keys `by_issue` with the default the two adjacent
functions use, and names the second borrowed guarantee: the mapping is
last-wins, so duplicate issues would let one copy's declaration retire
the other's contest, and validate_rules is what makes that
unreachable.

vacant_exemptions returns _Vacancy rather than a bare pair, on
_Dormancy's precedent -- the caller formats these into a message and
should not be indexing [0]/[1].

Doc nits: order_contests has no knobs, so the recompute recipe says to
reimplement its loop (and names the PYTHONPATH a fixtures import
needs); and the shared-no-name test carries its control inline instead
of pointing at a neighbouring test a rename would break.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_CROSS_RULE_WINNERS recorded it as diffing ("given", "suffix"). Measured
against the real 1.4.0 wheel it is {family, given, suffix}:

    first  '田中さん' -> ''
    last   'II'      -> '田中'
    suffix ''        -> 'さん, II'

The roster's docstring says "The diff shapes are measured against the
1.4.0 wheel, not guessed." This one was guessed; its structural twin
'김민준씨 Jr.' was recorded with `family` and is the corroboration.

The winner does not move: classify() returns
fix(cjk-glued-honorific-peel) under both shapes, since `given` is
outside the numeral rule's {family, suffix} either way. So every
argument that rested on the shape survives -- but each stated the wrong
field set, and all four sites are corrected here: the roster row and
the suffix-routing note beside it, the _MUST_NOT_MATCH comment that
reasons about the same subset test, and the two ledger comments in
expected_since_1.4.0.toml that repeat the claim.

test_the_recorded_rule_still_wins_each_contested_name structurally
cannot catch this: it feeds classify() the RECORDED shape and never
checks that shape against a real comparison, so a wrong shape that
still routes to the same rule passes forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lains

fix(comma-precomma-family) opened its comment on 'Bob Jones, author' /
'MD, PHD'. It explains neither today: measured against the 1.4.0 wheel,
'Bob Jones, author' keeps its pre-comma split and classify() sends it to
fix(comma-family) a comma followed only by titles keeps the given/family
split, and 'MD, PHD' moves four fields and goes to fix(#296) a
credential-only comma string reads a name and its postnominal. Both
handovers are already pinned in _CROSS_RULE_WINNERS -- the comment was
the only thing left saying otherwise.

Repointed at 'Smith, Dr.' / 'Smith, Prof.', which are the pure shape the
comment argues, and added the measured inventory of all seven names the
rule claims at this baseline. Three of the seven are not the pure
pre-comma move (a particle joining the family, a family run re-ordering,
a pre-comma run splitting); all seven move exactly {given, family},
which is what `fields` says and the rule's title summarises.

Comment only: no rule, regex, field list or classification moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each names the narrower rule it outranks and what it describes that the
narrower one does not -- six over contract-tier names, five radar-only
since #488 and pointing at #495. No rule moved: reordering would change
which rule classifies a name and break _CROSS_RULE_WINNERS.

Nine of the eleven are LATENT and say so. The measured diff needs a
field the narrower rule does not declare, so that rule is ineligible
wherever it sits and file order decides nothing today; the exemption
documents the hazard that wakes if its `fields` widen, which is the edit
most likely to make one live. Only two are live handovers today:
fix(comma-family)'s C1 example over the precomma merge, where the
narrower rule's prose is the NEGATION of what 'John Smith, Mr.'
measurably does, and fix(cjk-comma-compound) over the peel rule on nine
radar names.

That second one is written carefully. Three of its nine names --
'王先生, V.', '田中さん, V.' and '김민준씨, V.' -- show no comma routing
and no order flip when measured; the whole diff is the glued peel. So
the exemption does NOT claim the earlier rule describes a compound. It
says the order stays because the peel rule disclaims comma names
entirely, and points at #496 for the missing family-side twin of
fix(cjk-comma-honorific-peel) that would actually describe them.

The pairs from fix(#296) and from the peel rule onto the suffix-routing
rules are regex accidents rather than competing descriptions: those
patterns open on a run of non-space characters, which swallows the
trailing comma in 'Smith,' and matches kana and hangul as readily as
Latin. Each says that rather than inventing a description contest.

_ORDER_EXEMPTION_EFFECT is unchanged at 11 rows, which is the proof:
this commit changed which contests are DECLARED, not which exist. The
differential's classified output at every baseline is byte-identical
apart from the worker's temp path.

Closes #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wrong

Review found six claims in the `why` texts that measurement falsifies.
No verdict moves -- all eleven pairs remain exemptions, no rule is
reordered, and the roster still holds eleven rows. What was wrong is
supporting argument.

Two were serious, and both are the same failure: a claim about which
names ANOTHER rule explains, asserted without driving classify() over
the corpus.

- fix(comma-precomma-family)'s comment claimed "every one of the seven
  moves {given, family} and nothing else", two sentences after saying
  'Smith, de Mesnil Jean' has `first` empty on both sides. Measured: all
  seven move `family`, six move `given` with it, and that one moves
  `family` alone. Now says so.

- The pair-1 exemption rested on "every name that rule does explain has
  a one-word pre-comma piece with no split to keep". False: 'Dr. Do Van
  Johnson, MD' is one of its seven and splits (1.4 first 'Do Van
  Johnson' -> given 'Do', family 'Van Johnson'). The same commit that
  wrote that sentence had measured the opposite 180 lines below -- the
  copied-claim-goes-false mode this ledger records as its own lesson,
  reproduced against a fresh measurement on the same branch. Rewritten
  onto the discriminator that is true of the name at issue: on 'John
  Smith, Mr.' the precomma rule's stated behaviour is HALF true --
  'Smith' becomes the family, 'John' does not -- so the earlier rule
  describes the split and the later one describes a whole-run move that
  does not happen. The generalisation is now explicitly disclaimed
  rather than asserted. Also fixes the verb: 1.4 has no split (first
  'John Smith'), so 2.x CREATES one rather than keeping it.

Four smaller ones:

- Pair 9's arithmetic. Fifteen of the seventeen co-matched names have a
  diff the peel rule admits, not nine; nine is what this rule HOLDS,
  because six of the fifteen go to rules above both (five to
  fix(cjk-comma-honorific-peel), '田中さん, Dr.' to fix(#271/#272/#298)).
  The conclusion was right and the route to it was not. All three counts
  are now classify() over the seventeen, and the entry says so.
- Pair 4 cited "a few rules below" for a quote 52 rules away. It is the
  jr rule's own comment -- the rule the exemption targets -- so the
  entry now names it and quotes its "second line of defence" framing,
  which is what this declaration is.
- Pair 11 claimed the trailing 'Jr.' "is not what moves anything". It
  moves: 1.4 read it as the family. The real point is that `given` is
  where the diff leaves the jr rule's reach, and `given` comes from the
  peel segmenting the OTHER token. Restated.
- Pair 1 opened "the one live pair of the eleven" while pair 9 declares
  itself live too. Now "one of the two live pairs".

Verified: undeclared_contests 0 and vacant_exemptions 0 on all four
ledgers, _ORDER_EXEMPTION_EFFECT unchanged at 11 rows (this commit does
not touch the test file), and the differential's classified output at
baseline 1.4.0 is byte-identical to before the arc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review caught pair 9 claiming "Radar-only since #488, so no gate demands
either rule". The first half is right and the conclusion is not: tier is
a property of the corpus file a NAME comes from, and a rule's reach
usually spans both tiers.

Measured by deleting each rule from a copy of the harness and
re-classifying the corpus (the copy reproduces the real run
byte-for-byte across all 72 buckets, so the deletion is the only
variable):

  drop fix(cjk-comma-compound)      -> 0 unexplained, run still exits 0
  drop fix(cjk-glued-honorific-peel) -> 12 contract-tier names
                                        UNEXPLAINED, run fails

Fourteen of the seventeen names the peel rule explains are contract
('Andersonさん', '王先生', '김민준씨', ...). So the gate does demand it,
and the sentence as written contradicted #495, which already records
that rule as one to keep. Only the compound rule is radar-only -- all 23
names its regex reaches and all 11 it explains are radar.

Swept the other ten `why` texts for the same overreach, as asked. It is
in TWO more, both from 2163d61:

- pair 2 said "whether the rules should survive their demotion is #495"
  of fix(#296) credential-only and the lone-post-comma routing rule. The
  routing rule explains ten names and eight are contract.
- pair 3 said "these rules outlive the demotion" of the same #296 rule
  and fix(comma-precomma-family), which explains seven of which three
  are contract ('Berg, abdul vd', 'Smith, Dr.', 'Smith, de Mesnil
  Jean').

Both now say the demotion reaches the NAMES and name which single rule
#495 actually weighs. Pairs 10 and 11 were right about the name and
loose about the rule -- 10's "whether the rule outlives the demotion"
did not say which, and 11's bare "(#495)" invited the deletion reading
of a rule with fourteen contract names -- so both now name the candidate
explicitly (the numeral rule is radar-only; the jr rule and the peel
rule are not). Pairs 1, 4, 5, 6, 7 and 8 make no tier claim about a
rule; pair 7's "Contract tier (corpus_rules.jsonl)" is about the name
and is correct.

Ledger comments only. undeclared_contests 0 and vacant_exemptions 0 on
all four ledgers, _ORDER_EXEMPTION_EFFECT untouched at 11 rows (this
commit does not touch the test file), and the classified output at
baseline 1.4.0 is byte-identical to the pre-arc run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… runs

validate_rules cannot ask this -- it runs before any corpus is read,
and whether two rules contest a diff is a question about names. Checked
where the names arrive, and ahead of the worker pass so a refusal does
not cost the multi-minute wait first.

The name population is the LOADED entries rather than the glob the unit
guard reads, deliberately: --corpus narrows what a run compares, and a
run is judged on the names it compared.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Narrowing the corpus removes contests, and the two checks read that in
opposite directions. Fewer contests is fewer pairs anyone owes a
declaration, so `undeclared` is fail-closed under --corpus. `vacant`
inverts: a live declaration whose contested names are outside the run
reads exactly like a stale one. Every one of the six corpora, run alone
against expected_since_1.4.0.toml, reported vacancies -- 11 of the 11
exemptions for three of them -- so --corpus refused every narrowing the
README documents, and told the reader to delete exemptions the full
gate needs.

A partial run now NOTEs the count, the way over_declared_rules handles
its identical subset hazard and for the reason the corpus-floor roster
is skipped under --corpus: narrowing is the point of the flag. The full
run still refuses.

Also states in the comment that the names are the LOADED entries, ahead
of the baseline-minimum shape skip -- 1120 against the 1113 the 1.4.0
run compares -- and why the check stays ahead of it: it then asks the
same question at every baseline, as the unit guard does.

_run_main grows `corpus_flag`, the only way to reach main()'s
`if not args.corpus` branches from a test.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…efault

LEDGER-RULE-SEPARATION stated narrow-first as the design and left #382
open; a wider rule describing a compound behavior can be the better
classifier, so the mechanical check would have reattributed names to a
rule describing half of what happens to them. `precedes_narrower` was
documented nowhere, though a contributor now meets it from a failing
tool run, so tools/differential/README.md gains the key beside `dormant`
and `orders`: the nested array-of-tables shape, the one-rule target and
the required `why`, the contest predicate, the `--corpus` asymmetry, and
the TOML trap that a rule key written below the block joins the
exemption.

decisions.md records the arc -- the falsified premise with
'马丁·路德·金씨' as the worked case, the third narrowing key found in
review, the vacancy check's inverted behavior under a narrowed corpus,
the nine latent pairs against two live ones, the rule-tier-from-name-tier
error class, and why this hatch is argued where #452's and #456's were
declined. Every figure re-derived: 11 wide-first contests at 1.4.0 and 0
in each 2.x ledger, 6 contract-backed and 5 radar-only, against 367 of
646 wide-first nested pairs in that ledger without the corpus-reach
condition (657 of 1350 across all four). Gate unchanged at 352/247/155/14
intentional, 0 unexplained, 0 radar-unclassified.

Refs #382, #495, #496, #497

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… absolutes

Review of 960c6d0 (docs-design reviewer, eleven axes, every figure
re-derived) confirmed the numbers and found five prose defects plus two
stale absolutes outside the diff. All measured:

- The `over_declared_rules` precedent was WRONG in both new documents.
  That check still fails the run under `--corpus`; it only appends a
  NOTE saying its union is over a subset. Measured:
  `compare.py --baseline 1.4.0 --corpus corpus_shapes.jsonl` exits 1 on
  OVER-DECLARED alone. Three checks read the flag at three different
  strengths -- floor roster skipped, over-declaration fails-with-a-note,
  vacancy notes only -- and both files now say so, because "make the
  three consistent" was a licence to reintroduce the regression 95a8c1c
  fixed.
- "every --corpus run told the contributor to delete eleven exemptions"
  was a universal over a measured range: run alone, corpus.jsonl,
  corpus_cjk.jsonl and corpus_shapes.jsonl report 11 of 11, the other
  three report 8, 7 and 5.
- "every one was caught by re-running the wheel" contradicted 76beea4,
  which records two of that round's six as reading-only findings (a
  citation pointing 52 rules away; two `why` texts each claiming to be
  the one live pair).
- "nine CJK names" did not follow from the liveness definition stated
  in the same sentence: 15 of the 17 co-matched names have a diff the
  peel rule admits, and nine is what the pair HOLDS after six go to
  rules above both. Both counts now stated with their questions.
- The #452 quotation was applied as though `precedes_narrower` were the
  hatch #452 declined. It is not -- an over-declared rule still exits
  non-zero -- so the entry now takes the procedure and not the hatch,
  and says that #456's 179 and #452's 67/58/51 count different
  populations.

Two absolutes outside 960c6d0's diff, both now false and both able to
send a reader the wrong way:

- mechanisms.md said the open cycle's ledger "carries no rules, so the
  identity holds trivially there". It has carried fix(#462) since #494;
  the identity holds there for the ordinary reason.
- expected_since_1.4.0.toml and expected_since_2.1.0.toml still stated
  "write the narrower rule first" as law, in the file holding all
  eleven declared wide-first pairs. A contributor reading only the file
  they are editing would reorder one to comply -- the one repair the
  gate message and the guard both forbid. Comment-only; no rule, regex,
  field list or classification moved.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two follow-ups to the review of 960c6d0 / 66b4459.

compare.py's comment above the contest checks claimed a partial run
"NOTES that count and does not act on it, the way over_declared_rules
handles its identical subset hazard". Measured, that is false and in the
lenient direction: `overwide` feeds the exit code on every run (2149),
so over-declaration FAILS under --corpus and only appends an advisory
NOTE that its union is over a subset. Three checks read the flag at
three strengths -- floor roster skipped, over-declaration fails-with-a-
note, vacancy notes only -- and the comment now says so, with the reason
the strengths differ (only vacancy's VERDICT inverts under narrowing,
not merely its evidence) and a warning against levelling them, which is
the edit that would reintroduce what 95a8c1c fixed. The per-corpus
vacancy counts are named rather than summarised as "three of them".
Comment only; no code changed.

decisions.md's tier-split recompute now names the roster to read.
The 1116 distinct corpus names split 326 contract / 790 radar, and the
figure attracts a specific wrong answer: reading `corpus.jsonl` as
contract gives exactly 786 / 330. It is the LARGEST corpus at 486
distinct names and #468 demoted it to radar, so the contract tier is the
three small files -- corpus_cjk.jsonl 73, corpus_rules.jsonl 248,
corpus_shapes.jsonl 35. Four independent confirmations: the _CORPUS_TIERS
literal at compare.py:639, main()'s contract-first load order, the gate's
own `corpora:` line (contract files print first), and decisions.md's #468
bullet saying corpus.jsonl became RADAR. That wrong reading was measured
twice before a third recompute caught it, so the entry records the trap
and the cheap check rather than only the digits.

Refs #382

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three review findings on the rule-order arc, all prose; no code and no
ledger rule changes.

decisions.md said the static predicate "refuses more than it strictly
must and never less", and README.md carried the premise. False: nesting
is SUFFICIENT for an order-decided contest, not necessary. Two rules
whose `fields` merely intersect both admit any diff inside the
intersection, so file order decides between them too and the check
cannot see it. Measured 2026-09-02 over the four ledgers, on pairs
sharing a corpus name whose `orders` are not disjoint: 11 strictly
nested wide-first, 40 nested either way, 11 equal, 111 with any
intersection, so 60 overlap without nesting or equality. The carve-out
the documents already made for EQUAL `fields` is extended to every pair
where neither set contains the other -- the same reasoning, and the one
that leaves 111 declarations off the table. Worked blind spot filed as
#498: fix(#271/#272/#298) and fix(cjk-delimited-nickname) intersect in
{family, given} without nesting, and a swap reattributes three
contract-tier CJK names the check never mentions. The per-name-detection
decline keeps its real reason (a worker pass means a later bundle's rule
goes unchecked at pytest speed) and loses the error-direction one.

decisions.md claimed all eleven declared pairs would have the narrower
rule's prose FALSE of the co-matched names. Reading the eleven `why`
texts, most say partial rather than false -- compound versus component,
which is the arc's load-bearing distinction and which pair 1's own block
names as the thing it is the exception to. The summary now says false OR
merely partial, and the required-block-content sentence no longer
demands "what it describes that the later one does not" of the three
regex-accident pairs, where the later rule reaches the name through a
bare \S+ run rather than by describing it.

mechanisms.md answered a different question than the marker it replaced
asked. #382 option 1 -- narrowing the peel rule's name_regex -- is not
narrow-first, and it was never given a Declined bullet. It has one now,
on measured evidence: the peel and compound rules ship a BYTE-IDENTICAL
name_regex and each rule's reach is computed from its own pattern, so
narrowing the peel rule cannot shrink the compound rule's reach; with
the peel rule's fields nested inside the compound rule's, order
independence would need the LATER rule to stop reaching the name.
Implemented to check it -- a GLUED_HONORIFICS alternation drops the peel
reach 23 -> 19, leaves the compound rule at 23, keeps all seven names
whose 1.4.0 diff is exactly {given, suffix}, and a swap in the narrowed
ledger still reattributes all seven.

Gate unchanged at all four baselines: 352 / 247 / 155 / 14 intentional,
0 unexplained, 0 radar-unclassified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#496 asked whether a glued honorific peeling off a PRE-comma family
name should get its own rule, the family-side twin of
fix(cjk-comma-honorific-peel). It is closed as not planned, and the
three sites that cited it as an open gap said "until it closes" and
"the gap is" -- which reads as work pending rather than a question
answered.

The reason it is answered: every name that would need the twin is
radar tier. '王先生, V.', '田中さん, V.' and '김민준씨, V.' come from
corpus_cjk_tolerated.jsonl, the file #488 created by demoting the
composed comma/Latin-wrapper CJK forms, as do '김, 민준씨' -- the name
#382 was filed over -- and all seventeen names the compound/peel pair
is contested across. On the radar tier an unmatched diff is reported
and never fatal, so nothing can demand the rule. And #495 argues the
opposite direction for the same corner, recording
fix(cjk-comma-compound) as having zero contract-tier reach: the open
question here is whether it needs FEWER rules, not a second one for
the same radar names.

decisions.md carries the full reason, since a resolved-as-no needs a
home with the evidence that killed it or the next reader re-derives
the proposal; the exemption's `why` and the reordering Declined bullet
say it in one clause each. The finding itself is untouched everywhere:
the compound rule's label is still wider than those three names are.

Tiers read from compare._CORPUS_TIERS, not from a hand-built file→tier
map -- corpus.jsonl is the largest corpus and is radar (#497). Gate
unchanged: 352 / 247 / 155 / 14 intentional, 0 unexplained, 0 radar
unclassified at all four baselines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73 derek73 added docs Documentation fixes and updates tooling tests labels Sep 3, 2026
@derek73 derek73 self-assigned this Sep 3, 2026
@derek73 derek73 added this to the v2.3 milestone Sep 3, 2026
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.66%. Comparing base (c1a98a9) to head (b821043).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #499   +/-   ##
=======================================
  Coverage   98.66%   98.66%           
=======================================
  Files          45       45           
  Lines        3219     3219           
=======================================
  Hits         3176     3176           
  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.

derek73 and others added 5 commits September 2, 2026 19:33
Review corrections to the #496 decline recorded in 4ac5977. Prose
only: no code, no ledger rule, no rule order changed, and the gate
still reports 352 / 247 / 155 / 14 intentional, 0 unexplained and 0
radar unclassified at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0.

The tier claim held and its derivation did not. Sixteen of the
seventeen contested names are in corpus_cjk_tolerated.jsonl; the
seventeenth, 'Dr 田中さん, V.', is in corpus_issues.jsonl alone --
harvested and append-only, radar since #468, never touched by #488
('Dr 김민준씨, Jr.' is in both). A reader auditing "all 17 are radar"
the way the sentence told them to would open a 26-name file, find
16, and conclude the count had drifted. Worse, the reopen trigger was
set up around promotion out of the tolerated file, which is clearing
`tolerated` on the case rows -- not the mechanism that governs a
corpus_issues.jsonl name, so a promotion of that one would have
escaped the watch. Both halves now say the split and both promotion
routes.

"An unmatched diff on a radar name is reported and never fatal" is
denied by _CORPUS_TIERS' own note and by main()'s two-reasons
comment: a [[never]] exclusion outranks the tier and routes the name
to unexplained. The conclusion survives -- an exclusion forbids
explaining, so it cannot demand a rule either -- and the sentence now
says "no gate can demand it", the shape the ledger's `why` already
used.

Third, the decline sat under "Found rather than decided" while the
arc's other five 2026-09-02 rejections sit in Declined:, so a future
author scanning that list would not find #496 and would re-propose
the twin. It gets a Declined: bullet carrying the evidence, the #495
tension and the reopen trigger; the finding bullet keeps its subject
-- the ledger's vocabulary -- and points there instead of repeating
the argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_Reach.orders' says "there is no set of every order to put here".
_legal_orders() IS that set. The None encoding is still right, but for
a different reason: substituting the legal set would change BEHAVIOR on
input validate_rules never saw -- a hand-built rule with
orders = ["MADE_UP"] would intersect to the empty set and stop being a
contest, where classify() would still run it and it IS one. As written
the comment invites a future cleanup that would quietly lose contests.

_declared_over's enumeration of "the shapes still visible here"
undercounts in both directions. An empty list is a fourth shape refused
toward reporting, and an entry whose `issue` is the empty string is
ACCEPTED here -- validate_rules refuses it as a blanket opt-out, but
the isinstance(str) test admits it. State the actual sets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he flag

`if vacant and args.corpus` reads --corpus as "this run was narrowed".
The flag is action="append", so naming all six corpora explicitly is
the full gate wearing a flag -- and a genuinely stale exemption then
only NOTEd and the run exited 0, where the flagless run refuses.

main() already computes the right question three lines from where it is
needed, for the corpus-floor roster: set(_CORPUS_FLOORS) - the names on
disk. Hoist it to `full_corpus` and key the downgrade on that. The
inversion argument is untouched -- a real subset still only NOTEs,
because a live declaration whose contested names are outside the run
reads exactly like a stale one.

Both refusal messages also under-advise next to the guard messages
their reader will hit next. The undeclared one says to declare a
[[change.precedes_narrower]] block "naming the later one" and stops; a
contributor who follows it verbatim hits a second refusal from
validate_rules for the missing `why`. The vacant one offers only
"Delete the exemption", where a vacancy has a second reachable cause --
a corpus name that left while staying above its floor -- which
test_ledger_guards.py already names and this did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e guarantee

All four `validate_rules` occurrences in this file were prose inside
docstrings; there were zero calls. So both order-contest guards ran the
scanners on unvalidated ledger data -- while _rule_reach's and
_declared_over's own docstrings say outright that their leniency is
safe only because validate_rules ran first. That guarantee lives in
test_differential.py, one module away.

Demonstrated: a whitespace-only `why` on a real exemption in
expected_since_1.4.0.toml passes both guards at HEAD, and the file
reports all 33 tests passing on an exemption nobody justified --
_declared_over reads the entry as a good declaration and retires the
pair. With this call the same edit fails both guards.

Costs nothing: the shipped ledgers already validate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_CONTESTED held one wide rule and one narrower one, so "this rule
declares THAT rule" and "this rule declares something" were the same
sentence and no test could tell them apart. Measured: replacing
undeclared_contests' per-target test with a rule-level one --
`if not _declared_over(...)` -- left all 251 tests in these two modules
passing, as did the equivalent mutation in vacant_exemptions. A rule
could then opt out of every narrower rule added after it, which is the
widening validate_rules' blank-'issue' refusal exists to refuse and
which compare.py's own message promises against.

Give the fixture a third rule: one wide rule strictly containing two
narrower ones whose fields are disjoint from each other, all three
regexes reaching one name. Declaring only the first must leave exactly
the second pair reported, and a rule carrying one live and one stale
declaration must report exactly the stale one. Both mutants now die.
The shipped 1.4 ledger already has the shape -- two of its rules are
the earlier side of two contests each.

Five more gaps in the same area:

- `undeclared` was never tested on a FULL run. _run_main defaults to
  --corpus, so making the refusal conditional on args.corpus -- a gate
  run that never refuses an undeclared contest -- survived the suite.
- the --corpus downgrade had no test that a run NAMING every corpus
  refuses a stale exemption. New `names_every_corpus` parameter on
  _run_main reaches it.
- the loaded-vs-compared decision had no test. main() reads the LOADED
  entries, ahead of the baseline-minimum shape skip, and its comment
  defends that at length; moving the block after `kept`, or filtering
  shape-tagged entries out of corpus_names, both survived -- the
  source-index test only asserts the call site precedes _run_worker(),
  which both mutations preserve. A shape-4 name contested by a pair
  that reaches nothing else kills both, with a 2.0.0 run as the control.
- _Vacancy was pinned only by value, so it passed against bare tuples
  and would have survived deleting the class. Assert the field names.
- "Condition 4" referenced an enumeration that exists nowhere (the
  docstring and README both say "three questions"); `assert code in
  (0, 1)` cannot fail, main() having one return for the run outcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he order-arc prose

Five-agent review of PR #499. Prose only -- no rule, `name_regex`,
`fields` or file order moved, and the gate is unchanged at all four
baselines (352 / 247 / 155 / 14 intentional, 0 unexplained, 0 radar
unclassified).

- the wide-first exemption on fix(#296)/routing quoted "ten of which
  eight are contract" for the routing rule. Measured with the gate's
  own per-issue heading, it explains EIGHT, six of them contract.
- "radar tier since #488" at three sites: 'Jr., PhD' and 'MD, PHD'
  are in corpus_issues.jsonl alone, radar since #468's split and
  untouched by #488, which created corpus_cjk_tolerated.jsonl and
  holds neither name. The _ORDER_EXEMPTION_EFFECT roster comment now
  says the five radar-only pairs get there by two warrants, and to
  read the tier off _CORPUS_TIERS rather than off a demotion.
- decisions.md said two of the three regex-accident pairs are false
  and one partial. Measured against the 1.4.0 wheel it is the other
  way about: only fix(#296)/jr is false ('Smith, Jr.' was title 'Jr.',
  first 'Smith' at 1.4, so no family->suffix move happens), while
  peel/numeral is partial exactly as peel/jr is -- on '田中さん II' the
  numeral does leave `family` for `suffix`, as on its own 'John V'.
- the peel rule's comment claimed `fields` and not file order
  separates it from fix(cjk-comma-compound). True for the union rows
  only: the seven names it explains diff exactly {given, suffix},
  both rules' fields admit that, the two regexes are byte-identical,
  and swapping them reattributes all seven.

Also: "fail-closed" was labelling a check that errs toward NOT
refusing, colliding with the standard sense the same files use for
the _CORPUS_FLOORS/_CORPUS_TIERS rosters -- now "can only
under-report, never false-alarm", at all three sites. The peel rule's
17/14 is stated on the TIER predicate in both places rather than on
the file in one. And the eleven `why` blocks get ONE recompute recipe
in the ledger header, per docs/design/AGENTS.md's rule that a
drifting count carries its recompute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73
derek73 merged commit 777c1ec into master Sep 3, 2026
11 checks passed
@derek73
derek73 deleted the claude/docs-ledger-tooling-pr-699dcb branch September 3, 2026 04:41
derek73 added a commit that referenced this pull request Sep 3, 2026
Record the #495 decline, and correct a tier figure that shipped in #499
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.

Should file order be allowed to separate ledger rules within a tier? (김, 민준씨 is claimed by whichever of two rules comes first)

1 participant