Ledger rule order: declare the contest, don't assume narrow-first - #499
Merged
Conversation
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>
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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>
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 #382.
What changes
An order-decided contest between two ledger rules must now be declared. Where
the later rule's
fieldsare a strict subset of the earlier one's, bothname_regexes reach a common corpus name, and theirordersare 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 sayingwhat the earlier one describes that it does not.
Three checks at three homes:
validate_rules(corpus-free, startup): well-formed list oftables;
issuenames a rule present in the ledger and strictly LATER in thefile; non-blank
why; no duplicate targets; no self-reference; and no key butissue/whyinside an entry.main()after the corpus loads and before the worker pass,so a refusal costs seconds rather than the multi-minute pinned-wheel run.
tests/v2/test_ledger_guards.pyatpytestspeed. Thisis 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 addledger 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
fieldsmerely intersectboth 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_WINNERSstays the instrument — rather than by oversight. Widening tothe 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.
马丁·路德·金씨falsifiesit:
fix(#272/#308)(nakaguro division and glued hangul honorific) andfix(cjk-glued-honorific-peel)(the peel alone) both admit it, and the wider rulewins 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 preciselythe crux the issue was filed over.
So
fields-subset is a proxy for specificity and the wrong one where a wider ruledescribes 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.xledger. 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 theorderstest 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
whysomeone had to write — and it is also why theexemptions are worth having: most document a hazard that would activate if a
rule's
fieldswidened.The two checks are not symmetric under
--corpus. A smaller name set removescontests, 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 andover_declared_rulesas a thirdcheck reading the flag at a third strength.
Corrections this arc surfaced
_CROSS_RULE_WINNERSrecorded田中さん IIas 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_namestructurally cannot catch this — it feeds
classify()the recorded shape — whichis 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 longerexplains; repointed, with the measured inventory of the seven it does.
expected_since_1.4.0.tomlandexpected_since_2.1.0.tomlheaders stillstated "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,fieldsor
orderschanged (TOML-parsed againstc1a98a9, not grepped). No name changedhands:
_CROSS_RULE_WINNERSdiffers only in the corrected shape above, and the_ORDER_EXEMPTION_EFFECTnegative 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:
Full suite 6462 passed, 156 skipped, 5 xfailed;
ruffclean;mypyclean on 111source files.
design-docs-reviewerran the eleven axes over the docs commits and returned threeconfirmed findings, all fixed in
7fcd531: a false "never less" claim about thepredicate'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.mdsentence closing #382's openquestion — 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 killedit: 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
fix(cjk-comma-compound)reaches 23 radar names and 0 contract) #495 — do the CJK comma ledger rules still earn their place after CJK names join the shape inventory pure, and the comma zoo goes tolerated #488'sdemotion? Three of the five radar-only pairs involve rules with zero
contract-tier regex reach.
and no guard checks a recorded diff shape against a real run.
fieldsoverlap without nesting? (マイケル・ジャクソンgoes to whichever of two rules comes first, and the contest check never sees the pair) #498 — should file order decide between rules whosefieldsoverlap withoutnesting?
マイケル・ジャクソン,威廉・莎士比亚and高橋・一郎are contract-tierand a pure reorder of two rules reattributes all three, with nothing in the suite
failing.
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 whatit asked for, and its own example had been removed by #467.
🤖 Generated with Claude Code