From 3a51731a8437951d7e45c7b45af67cfb6f90b5a6 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 5 Sep 2026 20:01:15 -0700 Subject: [PATCH 1/4] tooling(differential): the fourteen unowned 1.4.0 contests get their winners Every 1.4.0 diff two or more rules admit, where the winner beats a loser by neither narrow-first nesting nor a `precedes_narrower` declaration and no roster row pins it: fourteen names over seven (winner, loser) pairs, measured 2026-09-05 against the 1.4.0 wheel. SEVEN sit in an EQUAL-`fields` pair -- PAIR A's six and 'Bob Jones, compositeur' -- the clearest case of the class the docs say _CROSS_RULE_WINNERS "stays the instrument" for, a class they scope to merely-intersecting `fields` too; four are contract tier. Nothing is owed at 2.0.0, 2.1.0 or 2.2.0. Each name gets a shape in _RECORDED_DIFFS and a winner in _CROSS_RULE_WINNERS with a per-pair argument. The five that were _WATCHED_DIFFS rows at 1.4.0 move with their shapes unchanged and keep their 2.x rows, where each has one admitter -- so the watched roster falls 41 -> 36 at 1.4.0 (107 -> 102 rows) over the same 50 distinct names. Re-derived: _RECORDED_DIFFS at 1.4.0 is 45 rows, 32 of them on radar-tier names and 13 with a single admitter at their recorded shape. Every count-carrying site for those figures is updated with its recompute; dated records of what an earlier tree measured keep their numbers and gain a "since #498" clause. Gate at all four baselines: 352 / 247 / 155 / 14 intentional, 0 unexplained, 0 radar unclassified, exit 0, summary lines byte-identical to master. A pin moves no classification. Refs #498 Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 18 +-- docs/design/mechanisms.md | 2 +- tests/v2/test_differential.py | 6 +- tests/v2/test_ledger_guards.py | 225 ++++++++++++++++++++++++++++++++- tools/differential/README.md | 51 ++++---- tools/differential/compare.py | 92 ++++++++++---- 6 files changed, 327 insertions(+), 67 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 98d26c9d..b8612a2e 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -809,14 +809,14 @@ Decisions that landed: - 2026-09-03 [#497](https://github.com/derek73/python-nameparser/issues/497) — the recorded shapes live in the TOOL, as `compare._RECORDED_DIFFS`, and the RUN verifies them; `_CROSS_RULE_WINNERS` keeps only which rule wins a contested name and reads the shape from there. There because it is the one place both halves exist: `main()` has measured every name's real diff by the time it asks, where `validate_rules` runs before any corpus is read and the unit suite installs no wheel at all — `tests/v2/test_differential.py`'s own header says "nothing here spawns `uv` or the network", and the tests that need a baseline fake `_run_worker` or fake `Popen` under it. State it that way and not as "every test monkeypatches `_run_worker`", which is false: measured 2026-09-03 on this branch, of 171 `def test_*` functions in that module 20 mention `_run_worker` at all and six reach the real one with `Popen` faked beneath them by `_fake_popen`. RECOMPUTE by walking the module's `def test_*` bodies for the name. That short form is what compare.py's `_RECORDED_DIFFS` provenance note SAID when this entry was written; `f70a0ad`, the very next commit, withdrew it there, and the note now rejects the paraphrase rather than carrying it — so follow that pointer to the history and not to the text. The denominator has moved once already inside this arc: `f70a0ad`'s own message says 168, which is why the argument here is "most never mention it" and the digits carry a date. The hermeticity claim is unharmed and the loose paraphrase of it is what fails, which is this arc's subject arriving inside its own evidence. Read HERMETICITY as the reason and not speed. The rival placement is the unit suite, and the reason it is refused is that a check there would have to install a wheel — not that installing one is slow. The timing finding below retires every cost argument in this file and moves this decision not at all, which is the test such a placement argument has to pass and the reason to say out loud which ground carries it. `_CORPUS_FLOORS` is the precedent — tool-owned, corpus-shaped data that the guards read and monkeypatch — and this is the same direction. - 2026-09-03 #497 — ONE roster, TWO checks, TWO PLACEMENTS — and, since `9360919`, a NOTE over the gap between them, recorded at the end of this entry. (Superseded in its first word 2026-09-05: TWO rosters since the watched-shapes arc below, `_WATCHED_DIFFS` beside `_RECORDED_DIFFS`, both placements unchanged; the departed-name refusal and the `NOT CHECKED` note read the union of the two, while the shape comparison runs once per roster, because the two halves carry different severities — a contest row's mismatch feeds the exit code on either tier, a watched row's only where its default-order entry is contract tier — and one call could not tell them apart.) The asymmetry of the two checks is the decision rather than where the code happened to fit. The ABSENT-NAME half — a recorded row naming a string no corpus holds any more, which nothing else would ever report — refuses PRE-worker, beside `vacant`, because it reads the ledger and the loaded names and nothing the worker produces. It takes the PLACEMENT #382 chose for its own pre-worker refusal and NOT #382's reason for it, which was the cost claim below — `compare.py`'s comment at that placement is one of the sites the timing finding retires. The ground it stands on was rewritten on this branch: a refusal raised after the wheel is installed and the corpus compared prints below the run's own published `baseline:` header, for a comparison it will never report. Not "disowning a comparison it has just published" — measured print order is `baseline:` at the tell, then the comparison loop, then `corpus: … intentional diffs:`, so at that position the header has printed and no line of the comparison has. The ORDER is the argument, and that corrected wording is what `compare.py` now carries at all three of its placement comments. Recorded because it is the second decision the false number was under, and the two came apart differently — the declined check below kept its conclusion on a new argument, and here the ORDER was right all along while the reason for it was not. Only re-deriving a claim says which of its supports was doing the work; a placement that reads unchanged is not thereby unaffected. The MOVED-SHAPE half is the opposite case — it needs the diffs, so it stays after the comparison — and there it may not RAISE. It PRINTS and feeds the exit code, like `over_declared_rules`, its structural sibling and the other post-worker check on recorded data: a raise at that point lands MID-report and takes dormancy, OVER-DECLARED, UNEXPLAINED and the radar block with it, which compare.py measures on a narrowed 1.4.0 run beside the check. A stale roster row must never hide an unexplained diff, that being the gate's primary output. The two pre-worker refusals may raise because nothing has printed yet; this one cannot, and the difference is PRINT ORDER rather than severity. The two halves also read opposite name lists, which is `recorded_diff_mismatches`' docstring's to state and is not restated here. What IS decided here is the THIRD thing this roster produces, added by `9360919` once the gap between those two lists was measured and found not empty: an order-bearing entry the baseline cannot honor sits in a corpus file and outside the compared set at once, so a row on such a name is checked by neither half and reported by neither. `main()` prints a `NOT CHECKED` note over it. A NOTE and not a refusal — the docstring's argument against refusing off the post-skip list stands, and it is an argument against REFUSING and not against SAYING, which are two decisions of which only the first was ever made. Informational, outside the exit code, and gated on neither `full_corpus` nor `args.corpus`, the intersection narrowing itself. Measured 2026-09-03 at 1.4.0 — both the only baseline where the skip fires and the only ledger with rows — the window is three names, none of which carries a roster row today, so the note prints on no run yet. The measurement that motivated it put a deliberately wrong shape on `'de la Cruz née Vega'` over the full corpus at 1.4.0: before `9360919` that exited 0 in 375 stdout lines naming the name in none of them, and it now exits 0 in 378 with the `NOT CHECKED` note naming it — re-measured 2026-09-03 by corrupting the row in memory around `main()`, which leaves the worktree alone. -- 2026-09-03 #497 — a SHAPE may be pinned where a COUNT may not, and that line is the whole of what keeps this roster affordable. A diff shape is one name under two parser versions, so adding a name to a corpus cannot move it; an explained-name count moves on every corpus edit. Shapes drift only when the PARSER changes those names — so the drift and the reason to re-read the roster are the same event, which is exactly what a recorded number usually fails to be. That is why #452's churn objection to `explains = N` (the fields-only arc above, 2026-08-28) does not reach this roster, and why pinning shapes here does not weaken it: the four ledgers carry 201 rules today, one `_CORPUS_CLAIMS` entry each, every one liable to move whenever a corpus does, against 31 recorded shapes that move only with the parser. Read the two CAUSES and not the two totals — the argument is that one set is coupled to corpus edits and the other is not, and it survives either number moving. RECOMPUTE: `sum(len(v) for v in _CORPUS_CLAIMS.values())` in tests/v2/test_ledger_guards.py, and `sum(len(v) for v in _RECORDED_DIFFS.values())` in compare.py. -- 2026-09-03 #497 — four of the 35 rows the roster carried were DELETED rather than corrected. Two names across the two 2.x ledgers, so two rows each, and one of the two NAMES had a correctable shape — count rows or names deliberately, since the provenance note that holds the measurements speaks of the name. Deleted because neither name pinned a CONTEST, which is what the roster is for: at the shape each really produces exactly one rule admits it, so `classify()` was never adjudicating anything. `'Nguyen, Van'` diffs at no baseline at all, so no run ever asks `classify()` about it; `'Jane née and Jones Smith'` is a malformed harvest from a radar corpus whose right parse nobody can state, so a pin on it defends no boundary anyone would argue for — which is why the correctable one went too. compare.py's provenance note holds the per-name, per-baseline measurements behind both. The deletion emptied both 2.x sections, which stay present as explicit empty mappings — the completeness guards require it, and it is what makes emptiness sayable as a POSITION: a row is owed when someone argues a boundary, not before. What the emptiness is NOT — that these ledgers hold no contested diff — is measured and stated beside those sections, and whether the position should change now that those contests are measured rather than merely unexamined is [#501](https://github.com/derek73/python-nameparser/issues/501), open. (Superseded in its emptiness 2026-09-05: the two 2.x sections are no longer empty, the #501 adjudication below having pinned FIVE rows at 2.0.0 and one at 2.1.0, so `_CROSS_RULE_WINNERS` reads 31 / 5 / 1 / 0 — measured 2026-09-05 — and #501 is closed. The criterion is not superseded and is what filled them: a row is owed when someone argues a boundary, and three of the six were found already argued, in the 2.0.0 ledger's own comment. Read the sentences above as the position emptiness stood on for as long as it stood.) Recorded with the deletion because it is the half a later sweep will get wrong: "only one rule admits it" is NOT on its own grounds to delete a row. A substantial minority of the surviving 1.4.0 rows are in that position and stay — the count and its recompute are beside them in test_ledger_guards.py, where six of the thirteen say so in their own comments and the other seven do not, which is why the count is recomputed rather than read off the roster — because the shapes they pin are shapes runs actually make, so a widened `fields` or a moved rule hands the name over and the test says so. Counting admitters is not the test at all: 11 of those 13 route to a DIFFERENT rule under some other shape, and mechanisms.md#RECORDED-ROSTERS now carries that measurement in place of the criterion it stated first. The four deleted rows could not do that work at any edit, which is the difference. +- 2026-09-03 #497 — a SHAPE may be pinned where a COUNT may not, and that line is the whole of what keeps this roster affordable. A diff shape is one name under two parser versions, so adding a name to a corpus cannot move it; an explained-name count moves on every corpus edit. Shapes drift only when the PARSER changes those names — so the drift and the reason to re-read the roster are the same event, which is exactly what a recorded number usually fails to be. That is why #452's churn objection to `explains = N` (the fields-only arc above, 2026-08-28) does not reach this roster, and why pinning shapes here does not weaken it: the four ledgers carry 201 rules today, one `_CORPUS_CLAIMS` entry each, every one liable to move whenever a corpus does, against 31 recorded shapes that move only with the parser (45 since #498). Read the two CAUSES and not the two totals — the argument is that one set is coupled to corpus edits and the other is not, and it survives either number moving. RECOMPUTE: `sum(len(v) for v in _CORPUS_CLAIMS.values())` in tests/v2/test_ledger_guards.py, and `sum(len(v) for v in _RECORDED_DIFFS.values())` in compare.py. +- 2026-09-03 #497 — four of the 35 rows the roster carried were DELETED rather than corrected. Two names across the two 2.x ledgers, so two rows each, and one of the two NAMES had a correctable shape — count rows or names deliberately, since the provenance note that holds the measurements speaks of the name. Deleted because neither name pinned a CONTEST, which is what the roster is for: at the shape each really produces exactly one rule admits it, so `classify()` was never adjudicating anything. `'Nguyen, Van'` diffs at no baseline at all, so no run ever asks `classify()` about it; `'Jane née and Jones Smith'` is a malformed harvest from a radar corpus whose right parse nobody can state, so a pin on it defends no boundary anyone would argue for — which is why the correctable one went too. compare.py's provenance note holds the per-name, per-baseline measurements behind both. The deletion emptied both 2.x sections, which stay present as explicit empty mappings — the completeness guards require it, and it is what makes emptiness sayable as a POSITION: a row is owed when someone argues a boundary, not before. What the emptiness is NOT — that these ledgers hold no contested diff — is measured and stated beside those sections, and whether the position should change now that those contests are measured rather than merely unexamined is [#501](https://github.com/derek73/python-nameparser/issues/501), open. (Superseded in its emptiness 2026-09-05: the two 2.x sections are no longer empty, the #501 adjudication below having pinned FIVE rows at 2.0.0 and one at 2.1.0, so `_CROSS_RULE_WINNERS` read 31 / 5 / 1 / 0 at that point — measured 2026-09-05 — and #501 is closed. It reads 45 / 5 / 1 / 0 since #498's fourteen pins, landed the same day; only the 1.4.0 figure moved. RECOMPUTE for either: `{k: len(v) for k, v in _CROSS_RULE_WINNERS.items()}` in tests/v2/test_ledger_guards.py. The criterion is not superseded and is what filled them: a row is owed when someone argues a boundary, and three of the six were found already argued, in the 2.0.0 ledger's own comment. Read the sentences above as the position emptiness stood on for as long as it stood.) Recorded with the deletion because it is the half a later sweep will get wrong: "only one rule admits it" is NOT on its own grounds to delete a row. A substantial minority of the surviving 1.4.0 rows are in that position and stay — the count and its recompute are beside them in test_ledger_guards.py, where six of the thirteen say so in their own comments and the other seven do not, which is why the count is recomputed rather than read off the roster — because the shapes they pin are shapes runs actually make, so a widened `fields` or a moved rule hands the name over and the test says so. Counting admitters is not the test at all: 11 of those 13 route to a DIFFERENT rule under some other shape, and mechanisms.md#RECORDED-ROSTERS now carries that measurement in place of the criterion it stated first. The four deleted rows could not do that work at any edit, which is the difference. - 2026-09-03 #497 — the prose sweep is sorted by this file's own criterion (#451, "past-tense counts stay in a ledger comment; live ones do not") into three treatments, and the treatment follows from what the count is DOING rather than from how wrong it is. A count that IS the finding and reads past tense stays. A count standing beside the enumeration it counts loses the digit — the list is self-checking and the digit is the only part that can rot. A live exhaustiveness claim with nothing recomputing it is REGROUNDED on the property it was reaching for, so that the sentence stops depending on a number — saying a widening claims all but a handful of the whole population the assertion is over says what a fraction said and cannot go stale. One judgement goes with it and is the arc's own: a figure that could NOT be reproduced was not replaced with a fresh one. `_reaches_non_vocabulary`'s docstring now carries the shape of its finding, the instruction for measuring it, and no LIVE figure for the magnitude the old figures asserted — a replacement digit nobody can re-derive is the defect rather than the repair, and this file has already been burned by that shape (the #414 roster comment under the fields-only arc's `explains = N` bullet). The past-tense digits explaining why the live ones are gone stay, which is the first treatment applied inside the third, and a later sweep should not read them as survivors of the pass. Found rather than decided, and worth as much: - **The cost that decided a decline had never been measured, and the decline survived losing it.** "The multi-minute worker pass" entered 2026-08-05 in `7767ba2`, at ONE site, with no measurement beside it. The census of where it reached, the timing figures that replace it, their recompute and the `_run_worker` timing trap all live ONCE, in the rule-order arc's `Declined:` bullet on precise per-name contest detection above — the argument they falsify — and none of them is restated here; that census is the figure in this arc most likely to move, so pointing at its owner rather than copying it is the #451 rule applied to the very claim that demonstrates why. Two notes on reading it that belong here rather than there. Not every site spells the phrase — one says the pass "costs minutes" — so count the CLAIM and not the wording (`git grep -n -i minute 514a2a1`, this branch's base). And measured, the worker pass is a fraction of a second and a whole gate run is seconds at most, where the phrase said minutes: read that class and not the digits. Three things transfer past them, and a coda about removing it. The claim was never a measurement anyone got wrong — it was a plausible sentence about a subprocess that installs a wheel, and it propagated by COPYING, the mechanism the fields-only arc recorded for ledger comments and which reaches code comments identically. It was load-bearing and its conclusion still stands, rewritten on COVERAGE: a run-time check sees one ledger, for the baseline that run was invoked with, and only when somebody invokes the tool, where a static predicate covers a rule from the moment it is written. And that is how a false number stays invisible for a month — an argument with two supports loses one and nothing downstream of it changes, so no reader ever meets a contradiction. Removing it was itself hard to do cleanly: review of the commit that removed the phrase found four instances of the very defect that commit exists to remove, fixed in `40c82a7`. -- **A row pinning a race with one runner is never exercised as a contest, so its shape only ever had to agree with itself.** That is why nothing caught the four, and it is the general shape rather than a fact about two names. The four arrived with #452's work (`ee6aaa6`, PR #455) under two claims of measurement — the roster header's "The diff shapes are measured against the 1.4.0 wheel, not guessed" and the 2.1.0 block's own "measured against this baseline's own run rather than copied from the 2.0.0 rows" — and every one of the 31 rows at 1.4.0 agrees with the run today, at all four baselines. Do NOT read that as the 1.4.0 half having been sound: one of those 31 was guessed too, `田中さん II`, wrong on a genuinely CONTESTED row, found by hand a day before this branch opened (`e5e4955`, the finding the arc above filed as #497) and already corrected when the 31 were re-measured. So the one-runner mechanism explains why THESE four survived a check that would have caught a contested row, not why every wrong shape survived — the contested one needed somebody to sit down with the wheel, which is the cost the run now removes. The transferable test is what to ask of a recorded INPUT: not "is it checked" but "would its CONSUMER answer differently if it were wrong". mechanisms.md#RECORDED-ROSTERS forbids re-deriving the EXPECTATION from the inputs the check reads; this roster obeyed that to the letter and still went wrong, on the other side of the assertion, which is why that entry now carries the limit. +- **A row pinning a race with one runner is never exercised as a contest, so its shape only ever had to agree with itself.** That is why nothing caught the four, and it is the general shape rather than a fact about two names. The four arrived with #452's work (`ee6aaa6`, PR #455) under two claims of measurement — the roster header's "The diff shapes are measured against the 1.4.0 wheel, not guessed" and the 2.1.0 block's own "measured against this baseline's own run rather than copied from the 2.0.0 rows" — and every one of the 31 rows at 1.4.0 agreed with the run on 2026-09-03, at all four baselines; the 45 rows there since #498 all agree on 2026-09-05, the gate exiting 0 with no `MOVED SHAPE` at any baseline. Do NOT read that as the 1.4.0 half having been sound: one of those 31 was guessed too, `田中さん II`, wrong on a genuinely CONTESTED row, found by hand a day before this branch opened (`e5e4955`, the finding the arc above filed as #497) and already corrected when the 31 were re-measured. So the one-runner mechanism explains why THESE four survived a check that would have caught a contested row, not why every wrong shape survived — the contested one needed somebody to sit down with the wheel, which is the cost the run now removes. The transferable test is what to ask of a recorded INPUT: not "is it checked" but "would its CONSUMER answer differently if it were wrong". mechanisms.md#RECORDED-ROSTERS forbids re-deriving the EXPECTATION from the inputs the check reads; this roster obeyed that to the letter and still went wrong, on the other side of the assertion, which is why that entry now carries the limit. - **The scan that scoped the sweep was wrong three ways, and it is the scan rather than the three fixed lines that the next sweep will reuse.** Two of its 19 machine-readable hits were false positives — a version number, and a hypothesis rather than a measurement — so the real population was 17. Its file list omitted `expected_since_2.2.0.toml`, which held a further copy of a claim it was already treating in two other ledgers, so the sweep would have shipped two of three siblings corrected. And its predicate matched only lines BEGINNING with `#`, which made every docstring invisible to it: the stalest figures in the whole sweep survived the first pass for that reason alone, and a docstring pass over the two guard modules then found two more. Read the sweep as BOUNDED and not exhaustive — that docstring pass covered `tests/v2/test_ledger_guards.py` and `tests/v2/test_differential.py`, and compare.py's own docstrings were never swept. And a fourth thing, which is the finding about the finding: the scan's PREDICATE was not preserved. Its 19 hits and their 17 survivors are recorded in `8593f3a`'s commit message and cannot be re-derived from the tree, because nothing says what it matched beyond the leading `#`. That is the reusable artifact of a sweep — not the lines it fixed — so the next one has to write its own and has no earlier hit count to check itself against. Write the predicate down where the results go. - **What the stale claims were, because the classes recur and the digits do not.** (i) A count of a population that had grown by half underneath it: a guard comment gave a Latin-half widening as claiming "644 of the 654 unclassified corpus names", where the same expression claims 994 of 1008 today (`len(_UNCLASSIFIED_NAMES)`, intersected with `_claimed("[A-Za-z]")`, in tests/v2/test_ledger_guards.py) — and since the argument was that the widening reaches essentially the whole population the assertion is over, it is written that way now and needs no recompute. (ii) A count of FILES: "all five corpora", in three places, where six `corpus*.jsonl` sit on disk and a run globs every one of them — the corpus count was never the argument, and the gate's own `corpora:` line is the check. (iii) A live count inside the very paragraph that names stale counts as the file's standing hazard — `fix(#274)`'s ledger comment walked "all 31 the regex claims" where that regex claims 32 today (`len(_claimed(rule["name_regex"]))`). (iv) A count over a self-checking enumeration: "Six such widenings" above a list of four, where nobody can now recover which two are missing and the list was always the evidence. The fifth is the one worth the space, and the docstring pass found it: `_carries` said the `isascii()` split covers "12 of the 16 entries", where at sweep time `MAIDEN_MARKERS` shipped 17 with 12 of them non-ASCII (measured 2026-09-03; RECOMPUTE with `len(MAIDEN_MARKERS)` and `sum(not m.isascii() for m in MAIDEN_MARKERS)` from `nameparser.config.maiden_markers`) — the NUMERATOR was right and stayed right while the denominator rotted. The repair names the predicate rather than counting its output, which is why the guard's own note past-tenses both figures and points the reader at the `isascii()` split itself. A count can go stale in one of its two halves, which is the shape a re-reader is least likely to catch, and it is a reason to prefer naming the predicate over counting its output. - **The tier split inverts the ranking that #452's still-open half rests on.** Beside the fields-equals-union check that landed, #452 carries a second proposal: a ceiling on how much of the corpus one rule may REACH, measured there as a share of the whole corpus. #468's tiering makes that the wrong denominator, because a radar diff cannot fail the run — a rule reaching many radar names is not the hazard a rule reaching many contract names is. Measured over the corpus population and tier split the rule-order arc's RECOMPUTE above already owns and this bullet does not restate, the two comma rules reach 288 names, 25.8% of all of them, but 64 contract names, 19.6% of that tier; `fix(#271/#272/#298)` reaches 108, 9.7%, and 80 contract, 24.5%. So by total reach the comma rules are the outlier and by contract reach the CJK rule is, and a ceiling on total reach would rank the wrong rule as the biggest hazard. Read the INVERSION rather than the four percentages: it is a claim about which tier the denominator counts, and it survives any drift that does not reverse the two tiers' composition. Recorded here because the proposal is open and the measurements in it predate the tiering. RECOMPUTE: tier every corpus name off `compare._CORPUS_TIERS`, contract winning where a string sits in files of both tiers, then take each rule's `name_regex` reach split by tier. @@ -842,7 +842,7 @@ Decisions that landed: Found rather than decided, and worth as much: - **The spec's 385 / 71 was GROSS of #452 and measured on a population the arc had already retracted; the net figure over the roster is the one to argue from.** `over_declared_rules` requires a rule's `fields` to EQUAL the union of the diffs it explains, so on a rule explaining only one name any shrink fires OVER-DECLARED and is not silent at all. Measured 2026-09-05 over the 113 rows `_WATCHED_DIFFS` holds: 390 alternative shapes classify to the same rule and 72 diffs are exposed by that count; net of #452 — keeping only the subsets `S` for which the union of the rule's OTHER explained diffs at that baseline, joined with `S`, still equals the rule's `fields` — 337 alternatives over 64 exposed diffs, and 44 further alternatives route to a different rule. Per baseline the gross / net / other-rule split reads 105 / 96 / 23 at 1.4.0, 158 / 137 / 20 at 2.0.0, 127 / 104 / 1 at 2.1.0 and 0 / 0 / 0 at 2.2.0. Over the 109 sole-watched rows alone — the roster minus the four #501 contests, which is the population the label "sole-watched" names — 367 gross over 68 exposed diffs, 314 net over 60, 35 other-rule, the whole difference sitting at 2.0.0 where the four contested rows are. The spec's first draft measured 385 / 71 gross, 330 / 62 net and 35 other-rule, with 123 / 112 / 23 at 1.4.0, over the population the next bullet retracts: 52 names and 113 diffs, holding the four names with 1.4.0 contest rows (45 rows there, `'MD, PHD'` alone contributing 14 alternatives) and not the four #501 rows — the same total by coincidence, so those figures read as the roster's and were not. The 8 diffs that leave the exposed count under the net test — `'Esq. van Gogh'` at all three baselines where it diffs, `'Dr. Do Van Johnson, MD'` at both 2.x, `'Aishwarya Rai'`, `'Jack M.A.'`, `'Jane van der Berg 旧姓 Jones'` — are exactly the diffs that sit ALONE on their rule (the 72 → 64 drop; the spec's population had 9, `'Carod i'` the ninth, its 71 → 62), but they do NOT account for all the alternatives #452 catches: they carry 24 of the 53 caught over the roster (26 of 55 over the spec's population), and the other 29 come from diffs whose rule explains other names that do not between them cover its `fields`, so the diff stays exposed while some of its alternatives are caught. Two figures are robust across all three populations, and they are what the argument rests on: the worst case, `'Ph. D., Jr.'` at 2.0.0 and 2.1.0, 30 alternatives against a five-field rule, all 30 surviving #452; and the 35 other-rule alternatives over the sole-watched rows, equally unpinned and equally silent, a handover nobody would see — the metric UNDERCOUNTS in that direction whatever it counts in the other. Both corrections leave the conclusion where it was. AMENDED 2026-09-05, later the same day: the #501 adjudication below moved six of these rows out of `_WATCHED_DIFFS`, so the population every figure in this bullet is over went from 113 rows to 107 and the roster figures above are a snapshot of the tree this arc's own commits left. Re-run by the RECOMPUTE below over the 107: 363 gross alternatives over 66 exposed diffs, 310 net of #452 over 58, 35 other-rule, with 105 / 96 / 23 at 1.4.0, 133 / 112 / 11 at 2.0.0, 125 / 102 / 1 at 2.1.0 and 0 / 0 / 0 at 2.2.0, and 8 diffs alone on their rule. Both of the figures the argument actually rests on reproduce unchanged, which is the point of having named them: `'Ph. D., Jr.'` at 2.0.0 and at 2.1.0 is still 30 alternatives against a five-field rule with all 30 surviving #452, and the other-rule count is still 35 — the two rows that left carried no other-rule alternative between them. RECOMPUTE: spy on `compare.classify` through a real `main()` run at each baseline, keep the default-order calls on the names keyed in `_WATCHED_DIFFS` for that ledger (the roster's own keys, not a scan of `tests/`; drop the four #501 names for the sole-watched figures), and for each enumerate the non-empty subsets of the winning rule's `fields`, sorting each by whether `classify()` routes it to the winner, to another rule, or to nothing; the #452-aware count applies the union test above, and a diff is "alone on its rule" when no other `(name, order)` call at that baseline classified to the same rule. -- **A "where is this named" population must include the tool.** The `tests/` scan cannot see `compare.py`, so four names carrying 1.4.0 contest rows — `'Bob Jones, author'`, `'Carod i'`, `'MD, PHD'`, `'van ma van'` — scored as sole-watched there, watched by exactly the mechanism the arc extends. The definition gained its last clause (not already keyed in `_RECORDED_DIFFS` for that ledger); three of the four return at a 2.x baseline where they have no contest row, and `'Carod i'` diffs under the default order at 1.4.0 ONLY, so it leaves the population entirely: 51 names, not the 52 the `tests/`-only scan gave, and 41 rows at 1.4.0 where the spec's first table said 45. (AMENDED 2026-09-05, later the same day: `'MD, PHD'` leaves entirely too once the #501 adjudication below pins its 2.x pair, since it then carries a contest row at every baseline it diffs at — so TWO of the four return at a 2.x baseline and two leave, and the population is 50 names against the same scan's 52. Re-measured by the recipe: 41 / 31 / 30 / 5 = 107 rows over 50 names, the scan's own 45 / 32 / 31 / 5 = 113 over 52 unchanged, and the roster is exactly the 50-name set.) One more clause the recipe carried and the definition did not until the design-docs review: explained by a ledger rule at that baseline — a diff no rule explains is already printed by every run as unclassified, so a rule is the only weak watcher a row is needed for; a definition clause and not a live count, since `radar unclassified` reads 0 at every baseline today and the two sets coincide. RECOMPUTE: the population recipe is in `_WATCHED_DIFFS`' header; print the names its last clause removes per ledger, and the four appear at 1.4.0 and none elsewhere. +- **A "where is this named" population must include the tool.** The `tests/` scan cannot see `compare.py`, so four names carrying 1.4.0 contest rows — `'Bob Jones, author'`, `'Carod i'`, `'MD, PHD'`, `'van ma van'` — scored as sole-watched there, watched by exactly the mechanism the arc extends. The definition gained its last clause (not already keyed in `_RECORDED_DIFFS` for that ledger); three of the four return at a 2.x baseline where they have no contest row, and `'Carod i'` diffs under the default order at 1.4.0 ONLY, so it leaves the population entirely: 51 names, not the 52 the `tests/`-only scan gave, and 41 rows at 1.4.0 where the spec's first table said 45. (AMENDED 2026-09-05, later the same day: `'MD, PHD'` leaves entirely too once the #501 adjudication below pins its 2.x pair, since it then carries a contest row at every baseline it diffs at — so TWO of the four return at a 2.x baseline and two leave, and the population is 50 names against the same scan's 52. Re-measured by the recipe: 41 / 31 / 30 / 5 = 107 rows over 50 names, the scan's own 45 / 32 / 31 / 5 = 113 over 52 unchanged, and the roster is exactly the 50-name set.) One more clause the recipe carried and the definition did not until the design-docs review: explained by a ledger rule at that baseline — a diff no rule explains is already printed by every run as unclassified, so a rule is the only weak watcher a row is needed for; a definition clause and not a live count, since `radar unclassified` reads 0 at every baseline today and the two sets coincide. RECOMPUTE: the population recipe is in `_WATCHED_DIFFS`' header; print the names its last clause removes per ledger, and the four appear at 1.4.0 and none elsewhere. (Stale twice over by the end of 2026-09-05, and re-measured that day by exactly that recipe: the clause removes NINE at 1.4.0 — the four plus `'Bob Jones, compositeur'`, `'MD, DO, DDS'`, `'Smith, Ph. D. Jr. MD'`, `'Smith, Ph. D. MD'` and `'Smith, Ph.D. Jr.'`, the five #498 pinned — and `'MD, PHD'` again at 2.0.0 and at 2.1.0 since #501 pinned its 2.x pair, none at 2.2.0. Rows per ledger after the clause: 36 / 31 / 30 / 5.) - **`corpus_shapes.jsonl`'s table row counted ENTRIES where the population counts NAMES.** A `(name, order)` pair is an entry, so a name compared under three orders is three, while the population is names diffing under the default order. Measured 2026-09-05 over the four baselines: 14 distinct names diff there, 11 under the default order, and the other three — `'de Mesnil Jean, Dr.'`, `'de la Cruz Juan Carlos, Dr.'`, `'de la Cruz née Vega'` — diff only under a declared order, the same trio that is the `NOT CHECKED` window at 1.4.0. The spec's row read "16 entries"; by the recipe here — distinct `(name, order)` pairs on which `classify()` was called across the four baselines — the count is 18, and the basis for 16 was not recorded, so 16 is not carried. Names and default-order names are the figures the population argument rests on, and both reproduce. RECOMPUTE with the same spy, restricted to that file's names, counting pairs, names, and names with `order is None`. - **The per-file split is not a partition.** 49 of the 51 sole-watched names sit in `corpus_issues.jsonl` and 3 in `corpus.jsonl`, with `'dr Vincent van Gogh dr'` in both, so the per-file counts overlap by one and 49 + 3 reads 52 for a 51-name population. (AMENDED 2026-09-05, later the same day: 48 and 3 over the 50 the #501 adjudication below leaves, the same name overlapping, so 48 + 3 reads 51 for a 50-name population — the shape of the finding, which is that the split is not a partition, is what survives the digits.) RECOMPUTE from the population script's JSON against `_load_entries` over the two radar files. - **A missing roster section was read as an empty one (found 2026-09-05, in the PR's review round).** `main()` read both shape rosters with `.get(ledger.name, {})`, so a ledger with no section checked nothing and said nothing: with the 1.4.0 key deleted from `_WATCHED_DIFFS` in memory, a full run at that baseline checked its 41 rows against nothing, printed the same 375 lines (differing only in the worker environment's path on the `baseline:` line, which differs between any two runs) and exited 0 — the guards would have caught the deletion at pytest speed, but the run may not assume the suite ran, which is `_CORPUS_TIERS`' own ground. Now refused pre-worker, beside the `both` refusal, naming the dict and the ledger; `_CORPUS_TIERS` refuses a VALUE outside `contract`/`radar` at load on the same principle, since every downstream tier read is an `!=` against one literal and a misspelling would land on whichever side it happened to fall. RECOMPUTE on a tree before the refusal: `del compare._WATCHED_DIFFS['expected_since_1.4.0.toml']` around `main()` at `--baseline 1.4.0`, and diff stdout and the exit code against an unmodified run, with two unmodified runs diffed as the noise control. @@ -855,7 +855,7 @@ Declined: - Pinning winners for the six #501 contests (2026-09-05) — stays #501's. The four 2.0.0 rows here and `'MD, PHD'`'s two record SHAPES, not winners: a shape move is now a finding on them, while a handover to another rule is not — file order still decides the winner, and only a winner pin sees it. #501's own objection to pinning a boundary nobody has argued stands, and the issue is scoped down by that comment, not closed. (REVERSED the same day, 2026-09-05, by the #501 adjudication below, which argued all six and moved the rows — read this bullet as what THIS ARC declined to do in its own commits and not as a standing position. What it got right is kept there: the rows recorded shapes and not winners for as long as nobody had argued the boundaries. What it got wrong is that three of the six had already been argued, in the 2.0.0 ledger's own comment, so the objection was about where the argument was written rather than about whether one existed.) - A checked-in generator for the rows (2026-09-05) — the rows are a snapshot, and a generator that re-derives them on demand is the re-derivation hazard with a command line (mechanisms.md#RECORDED-ROSTERS). A scratch script and a recipe in the roster comment, as `_CORPUS_FLOORS` and `_CORPUS_CLAIMS` do. -The measurement, and how to redo it. `_WATCHED_DIFFS` holds 41 / 36 / 31 / 5 rows at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0, 113 in all; the 2.0.0 section is 32 sole-watched rows plus the four #501 contests (`'김민준 박사님'` and `'선생님'`, contract tier and fatal; `'田中さん 様.'` and `'田中さん, 様.'`, `corpus_cjk_tolerated.jsonl`, radar and printed — the first of that pair read contract when this was measured earlier the same day, and the period-class demotion under #cjk-comma-demotion above moved it hours later, which is the point the snapshot makes better than the prose could: a row's fatality is a property of the tier its NAME reads at, and no roster in `compare.py` records it). 51 distinct sole-watched names; every row a default-order shape, so `NOT CHECKED` names none of them. (AMENDED 2026-09-05, hours later: those four rows and `'MD, PHD'`'s two — at 2.0.0 and 2.1.0 — left for `_RECORDED_DIFFS` with the #501 adjudication below, so the roster now reads 41 / 31 / 30 / 5 = 107 over 50 names and the 2.0.0 section is 31 sole-watched rows and nothing else. The figures in this paragraph were true of the tree this arc's commits left; run the RECOMPUTE at the end of it for today's.) Four mutation controls, in memory around `main()` with the worktree untouched, re-run 2026-09-05 against the amended data commit: `'QC MP'` corrupted at 1.4.0 (its only row — it does not diff at 2.0.0) prints `MOVED SHAPE (radar) expected_since_1.4.0.toml: 1 watched diff shape(s) disagree with this run` and exits 0, `'Smith Dr'` at 2.0.0 likewise; `'선생님'` corrupted at 2.0.0 prints `MOVED SHAPE expected_since_2.0.0.toml: 1 watched diff shape(s) disagree with this run` ending "This fails the run because the name is contract tier" and exits 1; `'Ph. D., Jr.'` corrupted at 2.1.0 prints the `(radar)` block and exits 0; and `'MD, PHD'` inserted into the 1.4.0 watched section beside its contest row is refused pre-worker, before any `baseline:` line, as "1 name(s) sit in both `_RECORDED_DIFFS['expected_since_1.4.0.toml']` and `_WATCHED_DIFFS['expected_since_1.4.0.toml']`", and fails `test_the_watched_roster_is_disjoint_and_names_every_ledger` naming it — the guard half wrapping `load_tool`, since that helper executes a fresh `compare` module per call and a mutation on an imported one never reaches it. A control whose corruption changed nothing would have meant an inert row; none did. The arc moved no classification: 352 / 247 / 155 / 14 intentional diffs, 0 unexplained and 0 radar-unclassified at every baseline, byte-identical summary lines before and after every commit. The quiet-tree claim that made this the moment to snapshot: measured 2026-09-05 at `4dd776a`, `git rev-list --count a965272..4dd776a -- nameparser/` is 5 commits since the tier split, moving 38 inserted and 15 deleted lines across two files, `_facade.py` (the #462 initials fix and its review rounds) and `_pipeline/_script_segment.py` (`f046d5d`, the tier-split day's own W3 demotion). PINNED TO THAT COMMIT deliberately, and NO LIVE HEAD FIGURE STANDS BESIDE IT — a first draft of this paragraph put one here as a contrast, and that number was already wrong at the commit that wrote it, which is precisely the defect the pin exists to avoid rather than an illustration of it. What can be said without a number that rots: the period-class bundle's commits after `4dd776a` touched `nameparser/_pipeline/_script_segment.py`'s comments and its W2/W3 citations — and the commit correcting this very sentence was one of them, after a draft that named two by hash had been made stale by the third — so the same range recomputed at HEAD is LARGER than the pinned one on all three counts, and it moves again with every further comment-only commit. Neither the count of such commits nor their hashes belongs here: `git log --oneline 4dd776a..HEAD -- nameparser/` lists them, and a figure that would have to be rewritten by each commit that changes it is not a figure prose can hold. So this is a POINT measurement of the tree the snapshot was taken on, and rereading it as a live one is the error to avoid — the figure goes stale by design, since the next feature is what the snapshot exists to make legible. RECOMPUTE the row counts with `{k: len(v) for k, v in compare._WATCHED_DIFFS.items()}`, the population with the script in the dict's header, the controls with `compare._WATCHED_DIFFS[ledger][name] = (...)` around `main()`, and the quiet tree with the command above plus `git diff --shortstat a965272..4dd776a -- nameparser/` — with the PINNED endpoint and not `HEAD`. A `HEAD` substitution answers a different question, and this paragraph deliberately does not state its answer. +The measurement, and how to redo it. `_WATCHED_DIFFS` holds 41 / 36 / 31 / 5 rows at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0, 113 in all; the 2.0.0 section is 32 sole-watched rows plus the four #501 contests (`'김민준 박사님'` and `'선생님'`, contract tier and fatal; `'田中さん 様.'` and `'田中さん, 様.'`, `corpus_cjk_tolerated.jsonl`, radar and printed — the first of that pair read contract when this was measured earlier the same day, and the period-class demotion under #cjk-comma-demotion above moved it hours later, which is the point the snapshot makes better than the prose could: a row's fatality is a property of the tier its NAME reads at, and no roster in `compare.py` records it). 51 distinct sole-watched names; every row a default-order shape, so `NOT CHECKED` names none of them. (AMENDED 2026-09-05, hours later: those four rows and `'MD, PHD'`'s two — at 2.0.0 and 2.1.0 — left for `_RECORDED_DIFFS` with the #501 adjudication below, so the roster now reads 41 / 31 / 30 / 5 = 107 over 50 names and the 2.0.0 section is 31 sole-watched rows and nothing else — since #498, 36 / 31 / 30 / 5 = 102 over the same 50, the five names it pinned at 1.4.0 having left that section alone. The figures in this paragraph were true of the tree this arc's commits left; run the RECOMPUTE at the end of it for today's.) Four mutation controls, in memory around `main()` with the worktree untouched, re-run 2026-09-05 against the amended data commit: `'QC MP'` corrupted at 1.4.0 (its only row — it does not diff at 2.0.0) prints `MOVED SHAPE (radar) expected_since_1.4.0.toml: 1 watched diff shape(s) disagree with this run` and exits 0, `'Smith Dr'` at 2.0.0 likewise; `'선생님'` corrupted at 2.0.0 prints `MOVED SHAPE expected_since_2.0.0.toml: 1 watched diff shape(s) disagree with this run` ending "This fails the run because the name is contract tier" and exits 1; `'Ph. D., Jr.'` corrupted at 2.1.0 prints the `(radar)` block and exits 0; and `'MD, PHD'` inserted into the 1.4.0 watched section beside its contest row is refused pre-worker, before any `baseline:` line, as "1 name(s) sit in both `_RECORDED_DIFFS['expected_since_1.4.0.toml']` and `_WATCHED_DIFFS['expected_since_1.4.0.toml']`", and fails `test_the_watched_roster_is_disjoint_and_names_every_ledger` naming it — the guard half wrapping `load_tool`, since that helper executes a fresh `compare` module per call and a mutation on an imported one never reaches it. A control whose corruption changed nothing would have meant an inert row; none did. The arc moved no classification: 352 / 247 / 155 / 14 intentional diffs, 0 unexplained and 0 radar-unclassified at every baseline, byte-identical summary lines before and after every commit. The quiet-tree claim that made this the moment to snapshot: measured 2026-09-05 at `4dd776a`, `git rev-list --count a965272..4dd776a -- nameparser/` is 5 commits since the tier split, moving 38 inserted and 15 deleted lines across two files, `_facade.py` (the #462 initials fix and its review rounds) and `_pipeline/_script_segment.py` (`f046d5d`, the tier-split day's own W3 demotion). PINNED TO THAT COMMIT deliberately, and NO LIVE HEAD FIGURE STANDS BESIDE IT — a first draft of this paragraph put one here as a contrast, and that number was already wrong at the commit that wrote it, which is precisely the defect the pin exists to avoid rather than an illustration of it. What can be said without a number that rots: the period-class bundle's commits after `4dd776a` touched `nameparser/_pipeline/_script_segment.py`'s comments and its W2/W3 citations — and the commit correcting this very sentence was one of them, after a draft that named two by hash had been made stale by the third — so the same range recomputed at HEAD is LARGER than the pinned one on all three counts, and it moves again with every further comment-only commit. Neither the count of such commits nor their hashes belongs here: `git log --oneline 4dd776a..HEAD -- nameparser/` lists them, and a figure that would have to be rewritten by each commit that changes it is not a figure prose can hold. So this is a POINT measurement of the tree the snapshot was taken on, and rereading it as a live one is the error to avoid — the figure goes stale by design, since the next feature is what the snapshot exists to make legible. RECOMPUTE the row counts with `{k: len(v) for k, v in compare._WATCHED_DIFFS.items()}`, the population with the script in the dict's header, the controls with `compare._WATCHED_DIFFS[ledger][name] = (...)` around `main()`, and the quiet tree with the command above plus `git diff --shortstat a965272..4dd776a -- nameparser/` — with the PINNED endpoint and not `HEAD`. A `HEAD` substitution answers a different question, and this paragraph deliberately does not state its answer. ### differential-ledger, the #501 adjudication (2026-09-05) @@ -868,12 +868,12 @@ Decisions that landed: - 2026-09-05 #501 — `'김민준 박사님'` → `fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix`, at 2.0.0, and the pin holds only because of the 2026-09-05 narrowing of the glued-peel `name_regex` in both ledgers (`a6a82d6`). Before the narrowing the glued rule reached the name on the 님 interior to 박사님 and, sitting first, took it. Measured against the 2.0.0 wheel the diff is `{family, given, suffix}`: the wheel reads first `'김민준'`, last `'박사님'`, and the tree reads given `'민준'`, family `'김'`, suffix `'박사님'` — the honorific WHOLE and standing as its own word, with nothing peeled off the name. The prose test is rules.md#W2's second sentence, "A part that is not name text — a post-nominal word standing on its own — is never the name's end: the split-off steps past it to the name word behind, and never dissects it", and that rule's witness line for this very string, `"김민준 박사님" → suffix="박사님"`. So the glued rule's title was FALSE of the name, on a contract-tier string (`corpus_cjk.jsonl`), held green by every guard in the suite from the date the comment's own parenthetical carries (2026-08-05) until this arc. What the pin buys, measured: restoring the pre-narrowing regex ALONE — one edit, no reorder, the glued rule still sitting ahead of the spaced one — hands the name straight back, 1 of the 247 classifications moving with `unexplained` still 0, and this row is the only thing that says so. The guard's first line is "expected_since_2.0.0.toml: '김민준 박사님' diffing ['family', 'given', 'suffix'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix'." - 2026-09-05 #501 — `'선생님'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, at 2.0.0. Measured against the wheel the whole diff is `{family, given}` — the wheel reads first `'선생님'` and the tree reads family `'선생님'`, the 2.1 order flip on a hangul string and nothing else — which is exactly the shape that rule's comment claims for itself, "these are one diff shape -- pieces moving between given/middle/family on a native-script CJK name", and which rules.md#W2's own boundary line `"선생님" → family="선생님"` says is name text rather than a peel. The glued rule's claim on it was the same regex accident, the interior 님, and worse than a partial label: it declares `suffix` and NO `suffix` moves here at all. It never won, the order rule being written first (sorted index 0 against the glued rule's 1), and the narrowing has since taken its admission away. Recorded because the pair is the #498 class and nothing else can see it: the two rules' `fields` INTERSECT on `{family, given}` — `{family, given, suffix}` against `{_ambiguities, family, given, middle}` — with neither nested, so `undeclared_contests` looks past them in either arrangement and no `precedes_narrower` block is available to declare them. What the pin buys, measured: a reorder ALONE now moves nothing here (0 of the 247), so the row's whole work is against a RE-WIDENING — restore the wide regex and swap the two rules and 2 classifications move, `unexplained` staying 0, with the guard's first line "expected_since_2.0.0.toml: '선생님' diffing ['family', 'given'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#271/#272/#298) native-script CJK: …'". - 2026-09-05 #501 — `'MD, PHD'` → `fix(#296) a credential-only comma string reads a name and its postnominal`, at 2.0.0 AND at 2.1.0, as a row apiece rather than one shared row. Both #296 rules reach the string and declare EQUAL `fields` at each 2.x baseline, `["title", "suffix"]`, so `fields` cannot separate them at all and file order is the whole decision (sorted indices 25 and 26 at 2.0.0, 12 and 13 at 2.1.0). Measured against both wheels the diff is `{suffix, title}` — title `'PHD'` on the wheel becomes suffix `'PHD'` on the tree, family `'MD'` on both sides. The distinguishing clause is a property of the STRING and not of the rules' `fields`: the credential-only rule opens "'Jr., PhD' / 'MD, PHD': a string that is nothing BUT credentials", while the lone-post-comma rule is written for "A one-word name, a comma, one short word". `'MD, PHD'` holds no name, so the first rule's prose is true of it and the second's is not. Two rows and not one because the roster is keyed per ledger and a shape is baseline-relative — the 1.4.0 row on the same string and the same winner rests on a different measurement, `{family, given, suffix, title}` there, the comma-family move not yet having landed. What the pin buys, measured: swapping the two rules moves 1 of the 247 at 2.0.0 and 1 of the 155 at 2.1.0, `unexplained` staying 0 at both, and the guard fails at BOTH baselines, first line "expected_since_2.0.0.toml: 'MD, PHD' diffing ['suffix', 'title'] is now explained by 'fix(#296) a lone post-comma credential is a suffix', not 'fix(#296) a credential-only comma string reads a name and its postnominal'." and the same sentence under `expected_since_2.1.0.toml`. -- 2026-09-05 #501 — the glued-peel `name_regex` is NARROWED so it stops matching a listed honorific INTERIOR to a longer listed one, in BOTH ledgers that ship it (`expected_since_1.4.0.toml` and `expected_since_2.0.0.toml` carry it byte-identically). `(? None: "abu bakr al baghdadi": "fix(initials-per-word) a bound-given run", "Berg, abdul van": "fix(initials-per-word) a bound-given run", + # #498's fourteen, adjudicated 2026-09-05. Every one is a + # contest the static predicates are silent on by construction: + # the two rules' `fields` are EQUAL or merely INTERSECT, so + # neither is narrower -- `order_contests` asks for nesting and + # finds none in either arrangement, and `precedes_narrower` + # has no narrower rule to name. What a row here adds is the + # ARGUMENT, which rule should explain the name and why. + # Grouped by (winner, loser) pair, so a reader editing one + # rule sees its whole cost. + # + # The "moves N of the 352" figures below were measured + # 2026-09-05, not reasoned. RECOMPUTE: drive compare.main() at + # --baseline 1.4.0 with classify() spied, move the loser above + # the winner in a COPY of the run's own sorted rule list, and + # re-classify the run's own 352 classified calls against that + # copy; N is how many change label. Nothing on disk moves. The + # `fields` readings below are the same recipe with one role + # dropped from the winner instead of a rule moved. + # + # PAIR A, six names, EQUAL `fields`. `fix(#325)` and + # `fix(comma-family) lone post-comma piece routes to + # suffix/title, not first` declare the same roles, so `fields` + # separates them not at all and file order is the whole + # decision. The distinguishing clause is a property of the + # STRING and the #325 rule names it: a credential run after a + # one-word family comma, followed by ANOTHER suffix, collapsing + # whole into `suffix` -- measured at the 1.4.0 wheel, whatever + # the wheel held in `title` or `given` empties into it + # ('Smith, Ph. D. Jr.': title 'Jr.' -> '', suffix 'Ph. D.' -> + # 'Ph. D. Jr.'). The lone-post-comma rule's prose describes a + # SINGLE post-comma piece, which none of the six is. Five hold + # the split credential the winner is named for ('Ph. D.', + # 'Ph.D.'); 'Smith, PhD Jr.' is a REACH rather than a fit -- + # 'PhD' is not split -- and the winning rule's own comment + # names it separately as one "which the regex reaches too". A + # documented reach whose alternative describes nothing is not + # the regex accident #372 refuses. Measured 2026-09-05, that + # name reads title 'PhD' -> '', given 'Jr.' -> '', suffix '' -> + # 'PhD Jr.', and TWO halves of the clause that names it in the + # winning rule's own comment disagree with that: it renders + # the new suffix 'PhD, Jr.', with a comma no run produces, + # and it says "title 'PhD' at every baseline", where the 1.4.0 + # title MOVES, 'PhD' -> '', the whole run going to `suffix`. + # The ledger's prose is a separate concern and is not touched + # here; this row is where the discrepancy is on the record. + # 'Smith, Ph. D. Jr.' is contract tier; the other five are + # radar and are pinned on the same terms, the argument + # being the row's subject and not the tier. Moving the loser + # ahead hands all six to it and moves 7 of the 352 + # classifications -- the six, plus 'Smith, Jr., PhD' as + # collateral. + "Smith, Ph. D. III": + "fix(#325) a split credential followed by another suffix " + "after a one-word family comma reads as suffixes", + "Smith, Ph. D. Jr.": + "fix(#325) a split credential followed by another suffix " + "after a one-word family comma reads as suffixes", + "Smith, Ph. D. Jr. MD": + "fix(#325) a split credential followed by another suffix " + "after a one-word family comma reads as suffixes", + "Smith, Ph. D. MD": + "fix(#325) a split credential followed by another suffix " + "after a one-word family comma reads as suffixes", + "Smith, Ph.D. Jr.": + "fix(#325) a split credential followed by another suffix " + "after a one-word family comma reads as suffixes", + "Smith, PhD Jr.": + "fix(#325) a split credential followed by another suffix " + "after a one-word family comma reads as suffixes", + # PAIR B, three names, OVERLAPPING `fields`. This is the pair + # #498 was filed on: `fix(#271/#272/#298)` declares + # {family, given, middle} and `fix(cjk-delimited-nickname)` + # {family, given, nickname}, intersecting in {family, given} + # with neither containing the other. All three names diff + # exactly {family, given} -- the 2.1 family-first order flip on + # a nakaguro-delimited native-script string, given + # 'マイケル・ジャクソン' -> 'マイケル' with family '' -> + # 'ジャクソン' -- and `nickname` is EMPTY on both sides of all + # three, so the nickname rule's prose, whose subject is a name + # doing two things at once, describes none of this diff while + # the order rule describes the whole of it and names one of the + # three among its own examples. All three are contract tier. + # Swapping the two rules reattributes all three and nothing + # else (3 of the 352, no collateral), and the pair nests in + # neither arrangement, so `order_contests` has nothing to + # report about it and `precedes_narrower` has no narrower rule + # to name -- the blind spot this row's argument fills. + "マイケル・ジャクソン": + "fix(#271/#272/#298) native-script CJK: family-first " + "order, hangul segmentation, the kana license and the dots", + "威廉・莎士比亚": + "fix(#271/#272/#298) native-script CJK: family-first " + "order, hangul segmentation, the kana license and the dots", + "高橋・一郎": + "fix(#271/#272/#298) native-script CJK: family-first " + "order, hangul segmentation, the kana license and the dots", + # PAIR C and PAIR D, two names, OVERLAPPING `fields`. Both diff + # {family, given} alone, the order flip and nothing else: + # `title` and `suffix` are byte-identical on both sides of both + # names, so no comma routing moved a role, and '田中さん, Dr.' + # keeps its glued さん whole (family '' -> '田中さん' entire, + # `suffix` empty on both sides), so nothing was peeled. So + # `fix(cjk-comma-compound)` and `fix(cjk-glued-honorific-peel)` + # -- which reach the strings through the comma and the glued + # さん respectively -- each describe a mechanism that did not + # fire, and the order rule describes what did; the peel rule's + # stated scope, a name with "no comma anywhere", excludes the + # string outright. 'Dr 김민준, Jr.' contests the comma rule + # alone, and its whole diff is the hangul segmentation the + # order rule names: given '김민준' -> '민준', family '' -> '김'. + # '田中さん, Dr.' contests BOTH, and is one name carrying two + # unowned pairs. The comma rule's own disposition table -- the + # four `shape -> rule` rows in `fix(cjk-comma-compound)`'s + # comment -- does not concede this shape to the winner BY + # NAME: its one row naming '田中さん, Dr.' hands the name to + # `fix(#296)` under {middle, suffix}, a different shape + # from the {family, given} measured here, and the row that + # does concede to the winner names no name -- it is + # `{given, middle, family} all 23`, which carries `middle`. + # The measurement is what settles the pair: the winner + # describes the whole of a {family, given} diff, and the comma + # rule's own `title` and `suffix` are byte-identical on both + # sides, so its comma routing moved nothing. Moving the + # comma rule ahead moves 9 of the 352 and the peel rule ahead + # 15, the widest of the seven; and for the peel pair a `fields` + # narrowing does NOT hand the name over, since + # `fix(cjk-comma-compound)` takes it instead, so a reorder is + # the only single edit that does. + "Dr 김민준, Jr.": + "fix(#271/#272/#298) native-script CJK: family-first " + "order, hangul segmentation, the kana license and the dots", + "田中さん, Dr.": + "fix(#271/#272/#298) native-script CJK: family-first " + "order, hangul segmentation, the kana license and the dots", + # PAIR E, one name, OVERLAPPING `fields`. '田中さん, 様.' moves + # {family, given, suffix} through TWO mechanisms at once, both + # legible in the measured values: the glued さん peels off + # (family '田中さん' -> '田中') while the post-comma 様. leaves + # `given` for `suffix`, the two arriving there together as + # suffix '' -> 'さん, 様.'. The winner is named for the comma + # half. The peel half belongs to the name's OTHER admitter, + # `fix(cjk-glued-honorific-peel)`, which is nested and DECLARED + # over by a `precedes_narrower` block, so that half of the + # contest is justified where it stands and only the + # honorific-suffix pair is owed a row. That loser disclaims the + # glued diffs outright and is written for a SPACED honorific on + # a name that also takes an order flip -- and measured, no + # `middle` moves and no flip occurs, `given` going to empty + # rather than gaining a name. The honorific-suffix rule's + # `fields` and the comma rule's intersect without nesting, so + # nothing else can see it. Moving the loser ahead moves 2 of + # the 352; a `fields` narrowing does not buy this pair either, + # since dropping any of the three shared roles sends the name + # to the glued rule instead. (The 2.0.0 section below pins this + # same string to the GLUED rule: a different ledger, where that + # pair nests narrow-first.) + "田中さん, 様.": + "fix(cjk-comma-compound) comma routing compounds with the " + "CJK order flip", + # PAIR F, one name, EQUAL `fields`. The all-titles rule and + # fix(comma-precomma-family) both declare {given, family} and + # both reach the string; the + # all-titles rule is the one whose prose is true of it, to the + # value -- measured, given 'Bob Jones' -> 'Bob', family '' -> + # 'Jones', with `title` 'compositeur' unmoved on both sides, + # which is what that rule says happens -- where the + # precomma-merge rule's prose says the pre-comma RUN reads as + # family, and the run does not move whole. That rule's own + # comment already concedes the sibling 'Bob Jones, author' to + # the winner on this reasoning, and the winner's regex reaches + # 'compositeur' by the same literal. Moving the loser ahead + # moves 3 of the 352 -- this name, 'Bob Jones, author' and + # 'John Smith, Mr.'. The pin is spelled with the FULL issue + # string because THREE rules in this ledger begin + # `fix(comma-family)` (measured 2026-09-05 with tomllib over + # the ledger's `[[change]]` issues): under the `startswith` + # the winner guard compares with, a bare `fix(comma-family)` + # would be satisfied by any of the three, the lone-post-comma + # rule that loses PAIRs A and G included. The full string + # still does not separate the winner from the THIRD of them, + # though -- the winner's issue is a strict PREFIX of + # `... keeps the given/family split, the C1 example`, so no + # prefix comparison can tell those two apart. What holds the + # row against that rule is exact equality, which is what a run + # supplies: measured 2026-09-05, classify() returns the + # winner's issue EXACTLY, and the C1 rule cannot reach this + # string at all -- its name_regex is + # (?i)^john\s+smith,\s*mr\.?(\s+jr\.?)?$ whole, anchored at + # both ends on a literal this name shares no prefix with. + "Bob Jones, compositeur": + "fix(comma-family) a comma followed only by titles keeps " + "the given/family split", + # PAIR G, one name, OVERLAPPING `fields`. 'MD, DO, DDS' holds + # no name at all -- every piece is a credential -- and the + # dropped-prenominal rule names the string and states its diff + # verbatim, title 'DO' -> given 'DO', which is the whole of + # what moved: `family` 'MD' and `suffix` 'DDS' are identical on + # both sides. The lone-post-comma rule's prose is about a + # one-word NAME followed by a comma and a lone credential; this + # string has neither, and the rule routes a piece OUT of + # `given` where this name routes one IN, so it is true of no + # part of the diff. Neither single edit that would hand the + # name over is free. A reorder ahead of the winner necessarily + # passes `fix(#325)` as well and drags all six of PAIR A with + # it: 8 of the 352 in all, this name plus PAIR A's six plus + # PAIR A's own collateral 'Smith, Jr., PhD'. The `fields` + # narrowing is smaller and NOT surgical -- dropping `given` or + # `title` from the winner moves 2, handing this name over and + # sending 'Do Quang Minh', a radar-tier corpus_issues.jsonl + # name the winner is the sole explainer of, to no rule at all, + # so radar unclassified goes 0 -> 1. The pin costs neither. + "MD, DO, DDS": + "fix(#296) a dropped prenominal takes the name position it " + "occupies", }, # The two 2.x ledgers had NO section here until #452, and the # coverage assertion below was `<=`, so their absence read as "no @@ -2757,14 +2971,17 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: # a run can contradict. # # Nor is "only one rule admits it" grounds on its own to delete a - # row: 13 of the 31 at 1.4.0 are in that position too, and two of + # row: 13 of the 45 at 1.4.0 are in that position too, and two of # the six below joined them with the narrowing -- '김민준 박사님' # and '선생님' have one admitter each now, the glued rule's regex - # having stopped reaching them. SIX of the 13 + # having stopped reaching them. None of #498's fourteen joins the + # 13: every one has two or more admitters at its recorded shape by + # the definition of the sweep that found it, so the numerator held + # while the denominator grew (measured 2026-09-05). SIX of the 13 # say so where they sit -- the jr rule's surplus and the # bound-given trio, three rows each -- and the other seven do not, # so take the count from the recompute rather than from the - # comments. RECOMPUTE (measured 2026-09-03): count the rows of + # comments. RECOMPUTE (measured 2026-09-05): count the rows of # compare._RECORDED_DIFFS['expected_since_1.4.0.toml'] for which # exactly one rule of compare._sorted_rules over this ledger # satisfies compare._entry_matches at the recorded shape. They @@ -2837,7 +3054,7 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: # the winner here, so this row does not hold WITHOUT that # commit; it is not a reordering. # - # One admitter today, which is the 13-of-31 position above and + # One admitter today, which is the 13-of-45 position above and # not a reason to drop the row. Measured 2026-09-05: restoring # the pre-narrowing regex alone -- one edit, no reorder, since # the glued rule still sits ahead of the spaced one -- hands diff --git a/tools/differential/README.md b/tools/differential/README.md index e2ede0fb..96fb134c 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -252,31 +252,32 @@ the default-order entry it was measured on, fatal on a contract name and printed under `MOVED SHAPE (radar)` on a radar one. Measured 2026-09-03, before the rule was written, MOST of the contest rows in `_RECORDED_DIFFS['expected_since_1.4.0.toml']` sat on radar-tier names --- 21 of 31, and 22 of the same 31 today, the 2026-09-05 period-class -demotion having moved `'김민준 씨.'` from the contract CJK corpus to the -tolerated one (measured 2026-09-05; decisions.md's #501 adjudication -carries the reconciliation, and the recipe below is unchanged) -- so -radar PARSER DRIFT could already fail the run -wherever someone had pinned a winner (RECOMPUTE: for each row, take -the tier of the first corpus file holding the name with contract files -sorted first, as `main()` loads them -- a NAME's tier, which is the -unit this count needs and not the one a watched row's severity reads: -that reads the default-order ENTRY's tier, per the `MOVED SHAPE` -section below, and the two recipes agree on every name that has no -declared-order entry); the rule keeps that, on the -ground that it is the argument and not the tier a contest row defends. -Which of the two outranks the tier more WIDELY is -deliberately not claimed here, because the answer inverts with the unit -and neither unit is the point: the exclusions number two against 31 -rows, while the two `[[never]]` patterns reach 60 corpus names between -them, 37 of those radar-tier -- more radar names than the roster pins -(measured 2026-09-03 by matching each `name_regex` against every name -in the `corpus*.jsonl` glob, tiers read the same way). That is a pin -doing what pins do -- -someone wrote the row by hand and it says what the name does -- but it -is not what "a radar diff can never fail the run" leads a reader to -expect. Read the tier rule as being about UNMATCHED diffs, which is -the only thing it was ever measured over. +-- 21 of 31, and 32 of the 45 today, the fourteen #498 pinned on +2026-09-05 adding ten radar rows and four contract ones on top of the +2026-09-05 period-class demotion, which moved `'김민준 씨.'` from the +contract CJK corpus to the tolerated one (measured 2026-09-05; +decisions.md's #501 adjudication carries the reconciliation, and the +recipe below is unchanged) -- so radar PARSER DRIFT could already fail +the run wherever someone had pinned a winner (RECOMPUTE: for each row, +take the tier of the first corpus file holding the name with contract +files sorted first, as `main()` loads them -- a NAME's tier, which is +the unit this count needs and not the one a watched row's severity +reads: that reads the default-order ENTRY's tier, per the +`MOVED SHAPE` section below, and the two recipes agree on every name +that has no declared-order entry); the rule keeps that, on the ground +that it is the argument and not the tier a contest row defends. Which +of the two outranks the tier more WIDELY is deliberately not claimed +here, because the answer inverts with the unit and neither unit is the +point: the exclusions number two against 45 rows, while the two +`[[never]]` patterns reach 60 corpus names between them, 37 of those +radar-tier (measured 2026-09-03 by matching each `name_regex` against +every name in the `corpus*.jsonl` glob, tiers read the same way) -- +more radar names than the roster pins, which is 32 as of 2026-09-05. +That is a pin doing what pins do -- someone wrote the row by hand and +it says what the name does -- but it is not what "a radar diff can +never fail the run" leads a reader to expect. Read the tier rule as +being about UNMATCHED diffs, which is the only thing it was ever +measured over. A corpus line is a bare JSON string or an object carrying `name` and, optionally, `tests` or `shape` -- the input-shape id from diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 5d99afd9..239b2aea 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -1712,11 +1712,13 @@ class _ShapeMismatch(NamedTuple): #: Default-order shapes only, because the roster classifies with no #: order. recorded_diff_mismatches below says what that leaves out. #: -#: PROVENANCE. The 31 rows at 1.4.0 are measured against the 1.4.0 -#: wheel, as the roster always claimed, and all 31 still agree -- -#: re-measured 2026-09-03 by driving main() at all four baselines and -#: feeding its `diffing` and its post-skip corpus to -#: recorded_diff_mismatches, which is the recompute: wrap _run_worker to +#: PROVENANCE. The 45 rows at 1.4.0 are measured against the 1.4.0 +#: wheel, as the roster always claimed, and all 45 still agree -- the +#: original 31 re-measured 2026-09-03, the fourteen #498 added measured +#: 2026-09-05 by the sweep that found them, and every one of the 45 +#: checked by the same recompute: drive main() at all four baselines and +#: feed its `diffing` and its post-skip corpus to +#: recorded_diff_mismatches, wrapping _run_worker to #: capture the post-skip entries and dormant_rules to capture `diffing`, #: since both receive exactly what main() built. #: @@ -1786,6 +1788,28 @@ class _ShapeMismatch(NamedTuple): "Abu Bakr Al Baghdadi, MD": ("_initials",), "abu bakr al baghdadi": ("_initials",), "Berg, abdul van": ("_initials",), + # #498's fourteen, adjudicated 2026-09-05: every 1.4.0 diff + # two or more rules admitted, where the winner beats a loser + # by neither narrow-first nesting nor a `precedes_narrower` + # declaration, and nothing pinned it. Shapes measured against + # the 1.4.0 wheel by the sweep that found them, not guessed; + # the five that were _WATCHED_DIFFS rows moved here with their + # shapes unchanged, which is what this dict's NO WINNER clause + # below says happens the day a winner is argued. + "マイケル・ジャクソン": ("family", "given"), + "威廉・莎士比亚": ("family", "given"), + "高橋・一郎": ("family", "given"), + "Dr 김민준, Jr.": ("family", "given"), + "田中さん, Dr.": ("family", "given"), + "田中さん, 様.": ("family", "given", "suffix"), + "Bob Jones, compositeur": ("family", "given"), + "MD, DO, DDS": ("given", "title"), + "Smith, Ph. D. III": ("given", "suffix"), + "Smith, Ph. D. Jr.": ("suffix", "title"), + "Smith, Ph. D. Jr. MD": ("given", "suffix", "title"), + "Smith, Ph. D. MD": ("suffix", "title"), + "Smith, Ph.D. Jr.": ("given", "suffix"), + "Smith, PhD Jr.": ("given", "suffix", "title"), }, # #501's six, moved here from _WATCHED_DIFFS with their shapes # unchanged. The four CJK rows sit at 2.0.0 alone: the honorific @@ -1803,7 +1827,7 @@ class _ShapeMismatch(NamedTuple): # below applied as written and accepted deliberately (#501): a # contest row is fatal on either tier because it carries an # argument, and a moved shape has made that argument's premise - # false. The precedent is the section above, where 22 of the 31 + # false. The precedent is the section above, where 32 of the 45 # rows are radar-tier names on exactly those terms (measured # 2026-09-05). "expected_since_2.0.0.toml": { @@ -1906,18 +1930,24 @@ class _ShapeMismatch(NamedTuple): #: every baseline today, so the two sets coincide); and not already #: keyed in _RECORDED_DIFFS for that #: ledger. The last clause is the one a scan of tests/ cannot supply, -#: because that scan cannot see this file, and it excludes four names -#: that carry contest rows: 'Bob Jones, author', 'Carod i', 'MD, PHD', -#: 'van ma van'. TWO of them return at a 2.x baseline where they have -#: no contest row. The other two have no row in this dict at all: +#: because that scan cannot see this file, and it excludes nine names +#: that carry contest rows: 'Bob Jones, author', 'Bob Jones, +#: compositeur', 'Carod i', 'MD, DO, DDS', 'MD, PHD', 'Smith, Ph. D. +#: Jr. MD', 'Smith, Ph. D. MD', 'Smith, Ph.D. Jr.', 'van ma van'. +#: SEVEN of them return at a 2.x baseline where they have +#: no contest row -- the five #498 pinned at 1.4.0 among them, each +#: still sole-watched at 2.0.0 and 2.1.0. The other two have no row in +#: this dict at all: #: 'Carod i' diffs under the default order at 1.4.0 only, where its #: contest row stands, and 'MD, PHD' carries a contest row at every one #: of the three baselines it diffs at since #501 pinned its 2.x pair. #: That is why the population is 50 names where the tests/-only scan #: says 52. -#: The counts: 41 / 31 / 30 / 5 rows, 107 in all, over those 50 names +#: The counts: 36 / 31 / 30 / 5 rows, 102 in all, over those 50 names #: -- and the roster is now exactly the population, the five contest -#: rows beyond it having gone to _RECORDED_DIFFS with #501. +#: rows beyond it having gone to _RECORDED_DIFFS with #501 and five +#: more with #498, which left the population by gaining a +#: _RECORDED_DIFFS key rather than by ceasing to be watched anywhere. #: 48 of the 50 sit in corpus_issues.jsonl and 3 in corpus.jsonl, with #: 'dr Vincent van Gogh dr' in both, so the per-file counts overlap by #: one and are not a partition. Every row is a default-order shape, @@ -1929,9 +1959,14 @@ class _ShapeMismatch(NamedTuple): #: calls whose order is None and whose rule is not None; apply the #: four clauses above with the literal set from ast.walk over #: tests/**/*.py EXCLUDING test_ledger_guards.py, as the POPULATION -#: clause says -- run over every file it yields 38 / 28 / 27 / 4 rows -#: rather than 41 / 31 / 30 / 5, since _CROSS_RULE_WINNERS' keys and -#: a few guard literals then score as watchers -- the tier sets from +#: clause says -- run over every file it yields 33 / 23 / 22 / 4 rows +#: rather than 36 / 31 / 30 / 5, since _CROSS_RULE_WINNERS' keys and +#: a few guard literals then score as watchers, and #498's fourteen +#: keys are exactly that kind of literal: the two halves of this +#: sentence moved for different reasons on 2026-09-05, the second +#: because five rows left this dict and the first because those five +#: are watched at 2.x too, where they now score as watched by the +#: guard file -- the tier sets from #: _load_entries over corpus*.jsonl #: through _CORPUS_TIERS, and that ledger's _RECORDED_DIFFS keys. Not #: by replaying the corpus load by hand: the (name, order) dedup, the @@ -1956,7 +1991,6 @@ class _ShapeMismatch(NamedTuple): "Anna Müller (geb. Schmidt)": ("maiden", "nickname"), "Anna Müller geb. Schmidt": ("family", "maiden", "middle"), "Attorney General of Minnesota": ("_initials",), - "Bob Jones, compositeur": ("family", "given"), "Dean of Chemistry": ("_initials",), "Dean of Chemistry Robert Johns": ("_initials",), "Deputy Secretary of State": ("_initials",), @@ -1975,7 +2009,6 @@ class _ShapeMismatch(NamedTuple): "Jong, van der": ("_initials",), "Jose e Maria Santos": ("_initials",), "Juan Garcia y Lopez": ("_initials",), - "MD, DO, DDS": ("given", "title"), "Mesnil Garcia van": ("_initials",), "Mohamad X": ("family", "suffix"), "Ph. D., Jr.": ("family", "given"), @@ -1983,14 +2016,19 @@ class _ShapeMismatch(NamedTuple): "Sander van": ("_initials",), "Smith Jones, Ph. D. Jr.": ("suffix",), "Smith, Ph. D.": ("family", "given"), - "Smith, Ph. D. Jr. MD": ("given", "suffix", "title"), - "Smith, Ph. D. MD": ("suffix", "title"), - "Smith, Ph.D. Jr.": ("given", "suffix"), "Smith, Prof.": ("family", "given"), "Ursula von der Leyen (geb. Albrecht)": ("maiden", "nickname"), "dr Vincent James van Gogh dr": ("family", "suffix"), "dr Vincent van Gogh dr": ("family", "suffix"), "dr Vincent van der Gogh dr": ("family", "suffix"), + # 'Bob Jones, compositeur', 'MD, DO, DDS', 'Smith, Ph. D. Jr. + # MD', 'Smith, Ph. D. MD' and 'Smith, Ph.D. Jr.' are GONE from + # this section: #498 argued a winner for each on 2026-09-05 + # and the five rows moved to _RECORDED_DIFFS with their shapes + # unchanged. Each is still watched at 2.0.0 and 2.1.0, where + # exactly one rule admits it and there is no contest to own, + # so the DISTINCT-NAME count in the header above does not + # move -- only the 1.4.0 row count does. }, "expected_since_2.0.0.toml": { "Anh do": ("_initials",), @@ -2594,10 +2632,11 @@ def main() -> int: # which is where the two checks part: `vacant` prints a count a # reader might act on, and here recorded_diff_mismatches has # already dropped those names without reporting one, so a NOTE - # would add noise and no information. Measured 2026-09-03, it would - # name 18 to 30 of the 31 rows depending on which corpus was asked - # for (30 for corpus_shapes.jsonl) against `vacant`'s 5 to 11 of - # 11. Recompute, from the worktree root: + # would add noise and no information. Measured 2026-09-05, it would + # name 28 to 43 of the 45 rows depending on which corpus was asked + # for (43 for corpus_shapes.jsonl) against `vacant`'s 5 to 11 of + # 11 (measured 2026-09-03, and untouched by a roster row). + # Recompute, from the worktree root: # uv run python -c "import sys;sys.path.insert(0,'tools/\ # differential');import compare,pathlib;r=set(compare.\ # _RECORDED_DIFFS['expected_since_1.4.0.toml']);[print(p.name,\ @@ -2617,9 +2656,10 @@ def main() -> int: # is a statement -- this ledger has no row of that kind -- where a # missing one is nobody having looked, and a `.get` default reads # the two alike: with the 1.4.0 key deleted from _WATCHED_DIFFS, - # a full run at that baseline checks 41 rows fewer, prints the + # a full run at that baseline checks 36 rows fewer, prints the # same 375 lines (differing only in the worker environment's path - # on the `baseline:` line, as any two runs do) and exits 0. The + # on the `baseline:` line, as any two runs do) and exits 0 + # (measured 2026-09-05). The # pytest-speed guards hold # each dict's keys equal to the ledgers on disk # (test_the_watched_roster_is_disjoint_and_names_every_ledger, and From 96575cf40e3eaacc593dc0a97fb46ecc49764d05 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 5 Sep 2026 20:36:57 -0700 Subject: [PATCH 2/4] tooling(differential): a contest no rule outranks by nesting or declaration must be pinned `unowned_contests` walks a run's own `diffing` list, re-asks _entry_matches of every rule, and reports each (winner, loser) pair where the winner beats a loser by neither narrow-first nesting nor a `precedes_narrower` declaration and the name has no _RECORDED_DIFFS row. Equal `fields` and a bare intersection are both reported; narrow-first stays #382's declaration-free default and a declared wide-first pair stays covered by its `why`. A wide-first UNDECLARED pair is classified (kind `wide-undeclared`) rather than assumed unreachable, because the tests hand this rule lists nothing validated. The three kinds are TOKENS the caller renders, the shape _Dormant set: `_UNOWNED_WHY` beside `_DORMANT_WHY` holds the wording. Run-time by necessity: a pin is a shape beside a winner and the shape is what a run measured, so there is no static form of the question -- without a diff the population is the 60-pair static reach and with one it was 14. A _WATCHED_DIFFS row does not justify: it pins no winner, and the `set(winners) == set(shapes)` guard is what makes a _RECORDED_DIFFS key mean one exists. No vacancy half, and the docstring says why a dissolved contest keeps its pin. Pure and unwired: nothing calls it yet, so the gate is byte-identical at all four baselines and the machinery bisects apart from the severity. Refs #498 Co-Authored-By: Claude Fable 5.1 --- tests/v2/test_differential.py | 303 ++++++++++++++++++++++++++++++++++ tools/differential/compare.py | 215 ++++++++++++++++++++++++ 2 files changed, 518 insertions(+) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 883136af..8ea5ad39 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -1579,6 +1579,309 @@ def test_an_exemption_for_a_pair_that_is_no_contest_is_vacant() -> None: "fix(narrow-b) the other half of it")] +#: A NON-NESTED pair over one name: `fix(a)`'s {family, given} and +#: `fix(b)`'s {family, suffix} intersect in {family} and neither +#: contains the other, so order_contests looks past the pair entirely +#: and only file order decides which rule classify() hands a {family} +#: diff to. The #498 class, in two rules' worth of TOML. +_OVERLAPPING: list[dict[str, object]] = [ + {"issue": "fix(a) the winner", "name_regex": "Smith", + "fields": ["family", "given"]}, + {"issue": "fix(b) the loser", "name_regex": "Smith", + "fields": ["family", "suffix"]}, +] + +#: One row of main()'s `diffing`: (name, diff fields, order). Both +#: rules above admit it, since {family} is a subset of each. +_ONE_DIFF: list[tuple[str, set[str], str | None]] = [ + ("John Smith", {"family"}, None)] + +#: main()'s `tier_of` for that one name. +_ONE_TIER: dict[str, str] = {"John Smith": "contract"} + + +def test_an_overlapping_pair_with_no_pin_is_an_unowned_contest() -> None: + """The #498 predicate's positive case, and every field of the row. + + NamedTuple equality is by value, so asserting the tuple alone + would pass against a bare 7-tuple; the field reads below are what + main()'s message prints, so each is named. + """ + found = compare.unowned_contests( + _ONE_DIFF, _OVERLAPPING, [], set(), _ONE_TIER) + assert len(found) == 1, found + row = found[0] + assert row.name == "John Smith" + assert row.order is None + assert row.diff == ("family",) + assert row.winner == "fix(a) the winner" + assert row.loser == "fix(b) the loser" + assert row.kind == "overlap" + assert row.tier == "contract" + + +def test_an_equal_fields_pair_with_no_pin_is_an_unowned_contest() -> None: + """The class the docs name _CROSS_RULE_WINNERS the instrument for. + + Equal `fields` is the special case of "neither contains the + other", and seven of #498's fourteen are in it (measured + 2026-09-05) -- so a predicate that reported only the + strict-intersection case would miss half the class it exists for. + """ + equal = [dict(_OVERLAPPING[0], fields=["family"]), + dict(_OVERLAPPING[1], fields=["family"])] + found = compare.unowned_contests(_ONE_DIFF, equal, [], set(), _ONE_TIER) + assert [(r.loser, r.kind) for r in found] == [ + ("fix(b) the loser", "equal")] + + +def test_a_narrow_first_pair_is_justified_without_a_pin() -> None: + """#382's declaration-free default, unchanged by this check. + + The winner's `fields` are a STRICT subset of the loser's, which is + the arrangement narrow-first says needs no declaration -- and the + wider predicate that would demand a pin here anyway was declined + (decisions.md, the #498 completeness check). + """ + narrow_first = [dict(_OVERLAPPING[0], fields=["family"]), + dict(_OVERLAPPING[1], fields=["family", "given"])] + assert compare.unowned_contests( + _ONE_DIFF, narrow_first, [], set(), _ONE_TIER) == [] + # ... and the same pair with the nesting SWAPPED reports, so the + # silence above is narrow-first and not the fixture gone quiet. + assert len(compare.unowned_contests( + _ONE_DIFF, narrow_first[::-1], [], set(), _ONE_TIER)) == 1 + + +def test_a_declared_wide_first_pair_is_justified_without_a_pin() -> None: + """A `precedes_narrower` block covers the pair it names. + + The winner is the WIDER rule, which is the arrangement #382 says + needs a declaration, and the declaration names the loser. + """ + declared = [dict(_OVERLAPPING[0], fields=["family", "given"], + precedes_narrower=[{"issue": "fix(b) the loser", + "why": "a describes both halves"}]), + dict(_OVERLAPPING[1], fields=["family"])] + assert compare.unowned_contests( + _ONE_DIFF, declared, [], set(), _ONE_TIER) == [] + # ... and the same pair with the declaration dropped reports, so + # the silence above is the `precedes_narrower` block. + assert len(compare.unowned_contests( + _ONE_DIFF, [dict(declared[0], precedes_narrower=[]), declared[1]], + [], set(), _ONE_TIER)) == 1 + + +def test_an_undeclared_wide_first_pair_is_classified_not_assumed_away( +) -> None: + """The wide-first pair carrying NO declaration is REPORTED. + + This shape cannot reach a ledger main() runs: undeclared_contests + refuses it pre-worker, so on any validated ledger the branch is + dead. It is classified anyway rather than assumed unreachable + because this function is handed rule lists nothing validated -- + every fixture in this file is one -- and a check that presumes + another check's guarantee reports nothing when that guarantee is + what broke. `wide-undeclared` is a TOKEN; _UNOWNED_WHY carries the + wording the caller prints. + """ + undeclared = [dict(_OVERLAPPING[0], fields=["family", "given"]), + dict(_OVERLAPPING[1], fields=["family"])] + found = compare.unowned_contests( + _ONE_DIFF, undeclared, [], set(), _ONE_TIER) + assert [r.kind for r in found] == ["wide-undeclared"], found + assert set(compare._UNOWNED_WHY) == {"equal", "overlap", + "wide-undeclared"} + + +def test_a_pinned_name_is_justified_whatever_the_kind() -> None: + """`pinned` is the set the caller passes and nothing else. + + main() passes set(_RECORDED_DIFFS[ledger]) -- the keys of the + roster whose rows carry a winner. The function reads no dict of + its own, so this test says exactly what "pinned" means and the + main()-level tests say which dict supplies it. + """ + assert compare.unowned_contests( + _ONE_DIFF, _OVERLAPPING, [], {"John Smith"}, _ONE_TIER) == [] + # ... and the same call with an empty set reports, so the silence + # above is the pin and not the fixture having gone quiet. + assert len(compare.unowned_contests( + _ONE_DIFF, _OVERLAPPING, [], set(), _ONE_TIER)) == 1 + + +def test_an_excluded_name_is_not_in_the_population() -> None: + """Exclusions are honored the way classify() honors them. + + A name a [[never]] entry refuses was classified to NOTHING, so it + has no winner and cannot have a contest -- demanding a pin for it + would demand an argument about a rule that did not win. Read + without `fields`, as classify() reads an exclusion. + """ + never: list[dict[str, object]] = [ + {"why": "nothing here may be explained", + "examples": ["John Smith"], "name_regex": "Smith"}] + assert compare.unowned_contests( + _ONE_DIFF, _OVERLAPPING, never, set(), _ONE_TIER) == [] + # ... and the same call with the exclusion dropped reports, so the + # silence above is the [[never]] entry and not the fixture. + assert len(compare.unowned_contests( + _ONE_DIFF, _OVERLAPPING, [], set(), _ONE_TIER)) == 1 + + +def test_one_admitting_rule_is_no_contest() -> None: + """The control: with the second rule's regex pulled off the name, + the same fixture reports nothing -- so every assertion above is + the pair being seen, not the loop running twice.""" + apart = [_OVERLAPPING[0], dict(_OVERLAPPING[1], name_regex="Jones")] + assert compare.unowned_contests( + _ONE_DIFF, apart, [], set(), _ONE_TIER) == [] + # ... and putting a SECOND admitter back on the name reports, so + # the silence above is the count of admitters and nothing else. + assert len(compare.unowned_contests( + _ONE_DIFF, apart + [_OVERLAPPING[1]], [], set(), _ONE_TIER)) == 1 + + +def test_a_rule_scoped_to_another_order_does_not_admit_the_diff() -> None: + """`orders` narrows the admitting set here as it does in classify(). + + A rule scoped to FAMILY_FIRST is not in the running for a + default-order comparison at all, so the pair is not a contest -- + the losing rule could not have taken the diff whatever the file + order. The winner stays order-blind, which is what a rule written + before shape-tagged entries existed is, so the only thing moving + between the two calls is the comparison's own order. + """ + scoped = [_OVERLAPPING[0], + dict(_OVERLAPPING[1], orders=["FAMILY_FIRST"])] + assert compare.unowned_contests( + _ONE_DIFF, scoped, [], set(), _ONE_TIER) == [] + family_first: list[tuple[str, set[str], str | None]] = [ + ("John Smith", {"family"}, "FAMILY_FIRST")] + found = compare.unowned_contests( + family_first, scoped, [], set(), _ONE_TIER) + assert [(r.loser, r.order) for r in found] == [ + ("fix(b) the loser", "FAMILY_FIRST")] + + +def test_the_reported_diff_is_every_role_sorted() -> None: + """A two-role diff rides the row whole and in a stable order. + + `diff` is what main() prints beside the name, and the reader + compares it against a _RECORDED_DIFFS shape, which is stored + sorted -- so a row carrying one role of three, or the set's + iteration order, would read as a shape mismatch that is really a + formatting bug. Three roles rather than two because a set of + strings iterates in an order PYTHONHASHSEED decides: dropping the + `sorted` leaves this assertion right one time in six rather than + one in two, and right always at the single-role diff every other + test here uses. Both rules admit all three roles, which is what + makes the pair a contest over the whole diff. + """ + wide = [dict(_OVERLAPPING[0], + fields=["family", "given", "middle", "nickname"]), + dict(_OVERLAPPING[1], + fields=["family", "given", "middle", "suffix"])] + three_role: list[tuple[str, set[str], str | None]] = [ + ("John Smith", {"middle", "given", "family"}, None)] + found = compare.unowned_contests(three_role, wide, [], set(), _ONE_TIER) + assert len(found) == 1, found + assert found[0].diff == ("family", "given", "middle") + assert found[0].kind == "overlap" + + +def test_one_name_contesting_two_losers_reports_both() -> None: + """'田中さん, Dr.' is this shape on the shipped ledger: one name, + two admitting rules behind the winner, and each pair owed its own + row because each is a separate boundary somebody has to argue.""" + trio = _OVERLAPPING + [ + {"issue": "fix(c) the other loser", "name_regex": "Smith", + "fields": ["family", "nickname"]}] + found = compare.unowned_contests(_ONE_DIFF, trio, [], set(), _ONE_TIER) + assert [(r.winner, r.loser) for r in found] == [ + ("fix(a) the winner", "fix(b) the loser"), + ("fix(a) the winner", "fix(c) the other loser")] + + +def test_the_rule_list_is_sorted_here_the_way_classify_sorts_it() -> None: + """The winner is the rule that would actually TAKE the diff. + + `_sorted_rules` puts a name_regex rule ahead of a fields-only one + whatever the file order, so a caller handing this function a list + in the raw order would be told the fields-only rule won a diff + classify() gives to the other. Sorted internally, as dormant_rules + sorts for the same reason: the shadower it names has to be the + rule that really won. The fixture is in the WRONG order on + purpose, and classify() over the same list is the control. + """ + fields_only: dict[str, object] = { + "issue": "fix(fields-only) no regex, so it sorts second", + "fields": ["family", "suffix"]} + wrong_order = [fields_only, _OVERLAPPING[0]] + found = compare.unowned_contests( + _ONE_DIFF, wrong_order, [], set(), _ONE_TIER) + assert [(r.winner, r.loser) for r in found] == [ + ("fix(a) the winner", "fix(fields-only) no regex, so it sorts " + "second")], found + # the control, in main()'s own arrangement: classify() sorts + # NOTHING -- main() hands it `_sorted_rules`' output -- so the + # winner above is that same tier ordering and not this function's + # own opinion, while the raw list would have answered the other way + assert compare.classify("John Smith", {"family"}, + compare._sorted_rules(wrong_order), [], + None) == found[0].winner + assert compare.classify( + "John Smith", {"family"}, wrong_order, [], None) == found[0].loser + + +def test_a_name_missing_from_the_tier_map_raises() -> None: + """`tier_of` is main()'s own map and a miss is a BUG, not a row. + + Every compared name has a key there, so a KeyError here is the + fail-closed reading main()'s other tier lookups take: a row + printed with a guessed tier would say the wrong thing about + whether the run may fail on it. + """ + with pytest.raises(KeyError): + compare.unowned_contests(_ONE_DIFF, _OVERLAPPING, [], set(), {}) + + +def test_the_row_carries_the_order_its_comparison_ran_under() -> None: + """The stated limit, made visible in the data. + + A _RECORDED_DIFFS row is a DEFAULT-order shape, so a contest + measured under a declared order is reported here and could not be + absorbed by a row. None exists on any shipped ledger today -- all + fourteen of #498's are order-None -- and the row carries the order + so main() can say the limit only when it applies. + """ + ordered: list[tuple[str, set[str], str | None]] = [ + ("John Smith", {"family"}, "FAMILY_FIRST")] + found = compare.unowned_contests( + ordered, _OVERLAPPING, [], set(), _ONE_TIER) + assert [r.order for r in found] == ["FAMILY_FIRST"] + + +def test_a_pin_cannot_absorb_a_declared_order_contest() -> None: + """The stated limit in the GATE and not only in the row. + + `pinned` is a set of _RECORDED_DIFFS keys and a row there records + a DEFAULT-order shape, so it cannot say which rule wins a contest + measured under FAMILY_FIRST. A name-only gate would let the row + absorb it silently, which is the one thing the limit says cannot + happen; the same pin over the same name's DEFAULT-order comparison + does absorb it, which is what makes the difference the order and + not the pin. + """ + ordered: list[tuple[str, set[str], str | None]] = [ + ("John Smith", {"family"}, "FAMILY_FIRST")] + found = compare.unowned_contests( + ordered, _OVERLAPPING, [], {"John Smith"}, _ONE_TIER) + assert [r.order for r in found] == ["FAMILY_FIRST"], found + assert compare.unowned_contests( + _ONE_DIFF, _OVERLAPPING, [], {"John Smith"}, _ONE_TIER) == [] + + #: What _run_worker was asked for, so a test can prove main forwarded #: the baseline and the corpus rather than defaults of its own. _WORKER_CALL: dict = {} diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 239b2aea..6ec14280 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -2220,6 +2220,221 @@ def recorded_diff_mismatches( return out +#: How a winner beat a loser, as a TOKEN rather than a sentence. +#: Hoisted out of _UnownedContest so the row's field, the local in +#: unowned_contests and _UNOWNED_WHY's key type are ONE declaration: +#: three hand copies of the same three-way Literal drift the moment a +#: fourth kind is added, and mypy would keep every one of them happy. +_UnownedKind = Literal["equal", "overlap", "wide-undeclared"] + + +class _UnownedContest(NamedTuple): + """A measured contest with no owner (#498). + + Named rather than a bare tuple for _Vacancy's reason: the caller + formats these into a message, and `c.winner`/`c.loser` says which + end is which where `c[3]`/`c[4]` would not. + """ + name: str + #: the name_order the comparison ran under; None = the default + order: str | None + #: the diff shape this run measured, sorted + diff: tuple[str, ...] + #: issue of the rule classify() gave the diff to + winner: str + #: issue of an admitting rule the winner beat + loser: str + #: how the winner beat it -- "equal" where the two declare the same + #: roles, "overlap" where neither `fields` set contains the other, + #: and "wide-undeclared" for a wide-first pair carrying no + #: `precedes_narrower`, which is REPORTED rather than assumed + #: unreachable. A TOKEN, like _Dormant.kind: the wording lives in + #: _UNOWNED_WHY below, which the caller renders. + kind: _UnownedKind + #: the name's tier, from main()'s `tier_of`; the caller passes the + #: run's own map and a miss RAISES, as main()'s other tier lookups + #: do -- every compared name has a key there + tier: str + + +#: How each `_UnownedContest.kind` reads in the report, beside +#: _DORMANT_WHY and for its reason: the computation never has to know +#: the wording, and rephrasing one is a change to output alone. +_UNOWNED_WHY: dict[_UnownedKind, str] = { + "equal": "equal `fields`, file order the whole decision", + "overlap": ("`fields` overlap without nesting, so neither is the " + "narrower"), + "wide-undeclared": ("the loser is NARROWER and undeclared -- " + "undeclared_contests refuses this on any ledger " + "main() loads, so seeing it here means a " + "hand-built rule list"), +} + + +def unowned_contests( + diffing: list[tuple[str, set[str], str | None]], + rules: list[dict[str, object]], + exclusions: list[dict[str, object]], + pinned: set[str], + tier_of: dict[str, str]) -> list[_UnownedContest]: + """Contested diffs no rule outranks and no row pins (#498). + + `undeclared_contests` above refuses a ledger where a WIDER rule + sits ahead of a NARROWER one it reaches a corpus name with, unless + the wider rule declares why. Nesting is SUFFICIENT for an + order-decided contest and not necessary: two rules whose `fields` + merely intersect -- or are EQUAL -- both admit any diff inside the + intersection, classify() returns the first, and that check cannot + see the pair. The docs say _CROSS_RULE_WINNERS "stays the + instrument" there. This is what checks that the instrument was + applied. Between the two, every measured contest has an owner. + + It is handed ALL of `diffing` rather than only the names + classify() gave a rule to, and the two populations are the same + one: classify() returns a rule exactly when no exclusion matched + it and at least one rule admits the diff, which are the exclusion + check and the admitting-rule check below. This walk needs TWO + admitters where classify() needs one, so it can only narrow that + population further, never widen it. Passing the whole list keeps + the caller from having to keep a second one in step with it. + + RUN-TIME by necessity, and that is not a cost this trades away. + A pin is a shape beside a winner, and the shape is what a run + measured; there is no static form of "every contested name is + pinned". Without a diff the population is the static reach of + every pair this check can report -- 71 of them, measured + 2026-09-05 over the four ledgers with `_rule_reach`: pairs sharing + a corpus name whose `orders` are not disjoint and whose `fields` + intersect without either containing the other (60) or are equal + (11 more; the loop is in decisions.md, the 2026-09-02 "nesting is + sufficient ... not necessary" bullet, whose own 60 is the + overlap-only count) -- and demanding a pin for each is the roster + nobody writes, which is the answer decisions.md already gives + about the 657 and 111 figures. WITH a diff the population was 14 + names over 15 rows at 1.4.0 (measured 2026-09-05; '田中さん, Dr.' + carries two losers) and 0 at all three 2.x baselines. + The static check covers the hazard that has a declaration site + from the moment a rule is written; this covers the hazard that + has none, from the moment a run measures it. The coverage limit + is real and accepted: one ledger per invocation, and only when + the gate runs. + + A _WATCHED_DIFFS row does NOT justify a contest, which is why + `pinned` takes one roster and not the union. That roster records + a shape with NO WINNER, and a contest's whole question is which + rule should win. What makes "a key in _RECORDED_DIFFS" mean "a + winner is pinned" is the `set(winners) == set(shapes)` equality + in tests/v2/test_ledger_guards.py, held in both directions -- so + this function's justification is borrowed from that guard, the + way _declared_over's shape guarantee is borrowed from + validate_rules, and it is stated rather than assumed for the same + reason. A THIRD borrow, and the same shape: every row here reads + `winner != loser` because validate_rules refuses DUPLICATE issues, + the guarantee undeclared_contests leans on for its `by_issue` -- + on a hand-built list carrying one issue twice, the loop below + compares a rule against its own issue string and can report a + contest a rule holds with itself. + + NO VACANCY HALF, unlike `vacant_exemptions` beside the static + check, and the asymmetry is deliberate. A declaration whose pair + stopped being a contest is a justification for a hazard that is + gone and reads exactly like a live one. A PIN whose contest + dissolves is not: it keeps saying what the name diffs to and + which rule takes it, which is what 13 of the 45 rows at 1.4.0 + already do with a single admitter apiece (measured 2026-09-05: + count the rows of _RECORDED_DIFFS['expected_since_1.4.0.toml'] + for which exactly one rule of _sorted_rules over that ledger + satisfies _entry_matches at the recorded shape) + (mechanisms.md#RECORDED-ROSTERS -- the test to apply to a recorded + input is not "is it checked" but "would the consumer answer + DIFFERENTLY if it were wrong"). So nothing here inverts under a subset + either: under `--corpus` the population is the entries the run + loaded, so this check UNDER-reports and never false-alarms, and + `full_corpus` is not read. + + STATED LIMIT. A _RECORDED_DIFFS row is a DEFAULT-order shape, so + a contest measured under a declared order would be reported here + and could not be absorbed by a row at all. The pin gate below is + ORDER-AWARE for exactly that reason: `pinned` skips a name only + on its default-order comparison, so a row cannot silently absorb + the declared-order contest it does not describe. None exists on + any shipped ledger today -- every one of the fourteen is + order-None -- and `order` rides each row so the caller can name + the limit only when it applies, the way the NOT CHECKED note does. + """ + # classify() must be asked in the order main() asked it, or the + # winner named here is not the rule that actually took the diff. + # Sorting internally makes that true whatever the caller passes; + # the sort is stable and idempotent, so doing it twice costs + # nothing. dormant_rules carries the same line for the same reason. + rules = _sorted_rules(rules) + + def fields_of(rule: dict[str, object]) -> frozenset[str]: + """A rule's declared roles. A missing or mistyped `fields` + reads as EMPTY, and that runs the OPPOSITE way from + _entry_matches' leniency over the same shape rather than being + it seen from the other side: that predicate IGNORES a non-list + `fields` and admits every diff, which is the WIDEST a rule can + be, while frozenset() here is the NARROWEST. So the two + readers disagree about the same rule, and _declared_over's + docstring is the model for saying which way each disagreement + falls ("a stricter reader declares LESS and so can only report + MORE" is its version). On the WINNER the effect is + UNDER-reporting: `won < lost` fires and the pair retires as + narrow-first, where a reader agreeing with _entry_matches + would have called the winner the wider one. On a LOSER it + falls the other way -- `lost < won` fires and the pair is + reported wide-undeclared where the wide reading would have + retired it as narrow-first. Neither is reachable on a ledger + main() loads: validate_rules refuses a `fields` that is not a + list of strings, and its #456 check refuses a rule carrying + `name_regex` without one. This exists for the hand-built rule + lists the tests pass in.""" + declared = rule.get("fields") + return (frozenset(f for f in declared if isinstance(f, str)) + if isinstance(declared, list) else frozenset()) + + out: list[_UnownedContest] = [] + for name, diff, order in diffing: + # Exclusions FIRST and outright, as classify() consults them: + # a name a [[never]] entry refused was classified to nothing, + # so it has no winner and is not in the population. + if any(_entry_matches(x, name, diff) for x in exclusions): + continue + # A pin justifies every pair on the name whatever their kind: + # the row says which rule wins, which is the question a + # contest asks, and it says it once for the name rather than + # once per losing rule. ORDER-AWARE, per the stated limit + # above: a _RECORDED_DIFFS row is a DEFAULT-order shape, so it + # cannot own a contest measured under a declared order, and + # `order is None` is what keeps a row from absorbing one it + # does not describe. + if order is None and name in pinned: + continue + admit = [r for r in rules if _entry_matches(r, name, diff, order)] + if len(admit) < 2: + continue + winner = admit[0] + won = fields_of(winner) + declared_over = _declared_over(winner) + for rule in admit[1:]: + lost = fields_of(rule) + issue = str(rule.get("issue", "")) + if won < lost: + continue # narrow-first: #382's declaration-free default + if lost < won and issue in declared_over: + continue # wide-first, and the winner says why + kind: _UnownedKind = ( + "equal" if won == lost + else "wide-undeclared" if lost < won + else "overlap") + out.append(_UnownedContest( + name=name, order=order, diff=tuple(sorted(diff)), + winner=str(winner.get("issue", "")), loser=issue, + kind=kind, tier=tier_of[name])) + return out + + def _two_causes(rows: list[_ShapeMismatch]) -> str: """The MOVED SHAPE blocks' disclaimer over a row with no measured shape. One-time like the lead it follows, and conditional as well: From 9313fc06aab13bca08079aaecc4ae986063da803 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 5 Sep 2026 20:52:26 -0700 Subject: [PATCH 3/4] tooling(differential): UNPINNED CONTEST joins the exit code main() calls unowned_contests over its own `diffing`, the sorted rules, the exclusions, the ledger's _RECORDED_DIFFS keys and the tier map, and prints a block grouped by (winner, loser) pair: one lead carrying the repair (pin the name in both rosters with the argument, or narrow the accident away) and the disclaimer (a contest is not a defect, an unowned one is), then one line per name with its order tag, tier, shape and the _UNOWNED_WHY clause its kind renders to. The count is PAIR(S) and not diff(s), the rows being one per (name, losing rule), so a name contesting two losers is two of them. The declared-order limit prints only when a row carries an order, the way NOT CHECKED names its own, and the lead says that the population is the entries this run loaded -- a `--corpus` subset can only under-report. The block sits after the three MOVED SHAPE blocks and above the Role-vocabulary legend, and `unowned` joins the exit expression beside `shape_bad`: fatal on both tiers, because a contest row is fatal on either tier for carrying an argument and an owed row is fatal for carrying none yet. MUTATION CONTROL on the shipped tree, in memory around main() so the worktree is untouched: with 'Smith, Ph. D. Jr.' deleted from _RECORDED_DIFFS['expected_since_1.4.0.toml'], a 1.4.0 run exits 1 with UNPINNED CONTEST expected_since_1.4.0.toml: 1 contested pair(s) ... naming that row at [contract] ['suffix', 'title'] (equal `fields`, file order the whole decision). Unmutated, no block prints at any of the four baselines. Gate at all four: 352 / 247 / 155 / 14 intentional, exit 0, summary lines byte-identical to master. Refs #498 Co-Authored-By: Claude Fable 5.1 --- tests/v2/test_differential.py | 365 ++++++++++++++++++++++++++++++++++ tools/differential/compare.py | 67 ++++++- 2 files changed, 429 insertions(+), 3 deletions(-) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 8ea5ad39..3b177646 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -2769,6 +2769,272 @@ def test_a_contest_row_and_a_watched_radar_row_print_apart( assert code == 1, out +#: A NON-NESTED pair over the fixture corpus, as ledger text: neither +#: rule's `fields` contains the other's, so the pre-worker contest +#: refusal looks past the pair and the #498 check is the only thing +#: that sees it. Two properties keep the fixture's verdict this +#: check's alone. The WINNER reaches both corpus names, so its +#: declared `fields` equal the union of the diffs it explains and +#: OVER-DECLARED stays quiet. The LOSER declares `dormant`, because +#: it explains nothing -- the winner is written first -- and +#: EXPLAINED NOTHING would otherwise fail the run for a reason that +#: is not this one. +_UNOWNED_LEDGER = ( + '[[change]]\nissue = "fix(a) the winner"\n' + 'name_regex = "Smith|Jones"\nfields = ["family", "given"]\n' + '\n' + '[[change]]\nissue = "fix(b) the loser"\n' + 'name_regex = "Smith"\nfields = ["family", "suffix"]\n' + 'dormant = "shadowed by fix(a) on the only name it reaches"\n') + +#: 'Alice Jones' with the GIVEN name altered, so the winner rule +#: explains a {given} diff too and its `fields` are exactly the union +#: of what it explains. +_JONES_GIVEN = {"title": "", "first": "ALICIA", "middle": "", + "last": "Jones", "suffix": "", "nickname": "", + "maiden": ""} + +#: The clause main() renders for an `overlap` row, from +#: compare._UNOWNED_WHY. Written out rather than read off the mapping, +#: as every other block's wording is asserted in this file: a test +#: that quoted the dict would pass whatever the dict said. +_OVERLAP_WHY = "`fields` overlap without nesting" + + +def test_main_reports_an_unowned_contest_and_exits_1( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The gate's verdict on a contest nobody owns (#498). + + unowned_contests has its own unit tests above; this pins that + main() calls it, names both rules and the kind, and FAILS the + run -- the half that can go silently permissive, as + test_main_exits_1_and_reports_an_unclassified_diff is for the + primary output. + """ + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", {}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", {}) + code, out = _run_main(tmp_path, monkeypatch, _UNOWNED_LEDGER, _DIFFERS, + extra=[("Alice Jones", _JONES_GIVEN)]) + # PAIR(S), not diff(s): the count is rows, one per (name, losing + # rule), and the noun has to say so or a reader counts names by it + assert ("UNPINNED CONTEST expected_since_1.4.0.toml: 1 contested " + "pair(s)" in out), out + # the LEAD as main() renders it, not the two issues asserted + # apart: `in out` on each passes with the (winner, loser) grouping + # key INVERTED, which is the half of this message that says which + # rule the tree hands the name to. + assert ("'fix(a) the winner'\n outranks 'fix(b) the loser' on:" + in out), out + # the row, name and shape together: the shape prints in the + # `list(row.diff)` form every MOVED SHAPE row uses, so the bare + # tuple a dropped `list()` would render fails here too. + assert "'John Smith' [contract] ['family']" in out, out + assert _OVERLAP_WHY in out and out.count(_OVERLAP_WHY) == 1, out + # the repair names BOTH halves of a pin, since a shape without a + # winner is the other roster's row and would not close this + assert "_RECORDED_DIFFS" in out and "_CROSS_RULE_WINNERS" in out, out + # the disclaimer: a contest is not a defect, an unowned one is + assert "not a defect" in out, out + # no declared order in the fixture, so the order limit must not + # print -- it is named only when it applies, as NOT CHECKED is. + # Scoped to the BLOCK: the word appears in other parts of a + # report (the role legend, another check's note), so asserting + # over the whole of `out` would be a claim about the rest of + # main() rather than about this message. The block runs from its + # header to the blank line the printer ends it with. + block = out[out.index("UNPINNED CONTEST"):] + block = block[:block.index("\n\n")] + assert "`orders`" not in block, block + assert code == 1, out + + +def test_a_recorded_row_owns_a_contest_and_the_run_goes_quiet( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The same ledger with the name pinned: silent, and exit 0. + + The shape recorded is the shape the run measures, so no MOVED + SHAPE prints either -- 0 here is the pin having closed the + finding and not some other check having gone quiet. + """ + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", + {"John Smith": ("family",)}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", {}) + code, out = _run_main(tmp_path, monkeypatch, _UNOWNED_LEDGER, _DIFFERS, + extra=[("Alice Jones", _JONES_GIVEN)]) + assert "UNPINNED CONTEST" not in out, out + assert "MOVED SHAPE" not in out, out + assert code == 0, out + + +def test_a_watched_row_does_not_own_a_contest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A shape with no winner cannot answer "which rule wins". + + _WATCHED_DIFFS records a shape and pins nobody, so a row there + leaves the contest unowned -- the sentence the check's docstring + rests on, asserted at the level where the two dicts are actually + read apart. The shape recorded AGREES with the run, so no MOVED + SHAPE prints and the 1 is this check's alone. + """ + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", {}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", + {"John Smith": ("family",)}) + code, out = _run_main(tmp_path, monkeypatch, _UNOWNED_LEDGER, _DIFFERS, + extra=[("Alice Jones", _JONES_GIVEN)]) + assert "UNPINNED CONTEST" in out, out + assert "MOVED SHAPE" not in out, out + assert code == 1, out + + +def test_an_unowned_contest_on_a_radar_name_still_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Fatal on both tiers, exactly as a contest ROW is. + + A contest row is fatal on either tier because it carries an + argument; an owed row is fatal because it carries none yet. + Scoping this to contract tier was declined for the roster it + completes (decisions.md, 2026-09-02), and the reason -- the + roster already pins radar names -- still holds: 32 of the 45 + rows at 1.4.0 sit on radar-tier names. + """ + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", {}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", {}) + code, out = _run_main(tmp_path, monkeypatch, _UNOWNED_LEDGER, _DIFFERS, + extra=[("Alice Jones", _JONES_GIVEN)], + tier="radar") + assert "UNPINNED CONTEST" in out, out + assert "[radar]" in out, out + assert code == 1, out + + +#: _UNOWNED_LEDGER plus a rule that is dormant IN FACT and does not +#: say so, which is what makes an EXPLAINED NOTHING block print: its +#: regex reaches no corpus name at all, so the diagnosis is +#: "reverted". Its `fields` are the winner's, so nothing about it can +#: reach the contest above. +_UNOWNED_LEDGER_WITH_A_DEAD_RULE = _UNOWNED_LEDGER + ( + '\n' + '[[change]]\nissue = "fix(c) the inert one"\n' + 'name_regex = "Nobody Here"\nfields = ["family", "given"]\n') + +#: 'Zed Quux' with a nickname the tree does not produce, so its diff +#: is {nickname} and NO rule in the ledger above reaches the name -- +#: an UNEXPLAINED row, which is the only thing that makes main() print +#: the Role-vocabulary legend the ordering assertion needs. +_QUUX_NICKNAME = {"title": "", "first": "Zed", "middle": "", + "last": "Quux", "suffix": "", "nickname": "ZZ", + "maiden": ""} + + +def test_the_unowned_block_prints_after_the_moved_shape_blocks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Print order, which the blocks' readers differ on. + + MOVED SHAPE is about a row that already exists and has gone + false; UNPINNED CONTEST is about a row that does not exist yet. + A reader repairing the first should not have to scroll past the + second. Below it sit EXPLAINED NOTHING, which is about a RULE + rather than a name, and the Role-vocabulary legend that the + UNEXPLAINED and radar blocks share -- so the whole run of four is + asserted rather than the one boundary, since a block moving past + either neighbour is the same defect. + + The moved row is on 'Alice Jones' rather than 'John Smith': a + _RECORDED_DIFFS row on the contested name would OWN the contest + and there would be nothing to order. + """ + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", + {"Alice Jones": ("nickname",)}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", {}) + code, out = _run_main(tmp_path, monkeypatch, + _UNOWNED_LEDGER_WITH_A_DEAD_RULE, _DIFFERS, + extra=[("Alice Jones", _JONES_GIVEN), + ("Zed Quux", _QUUX_NICKNAME)]) + moved = out.index("MOVED SHAPE expected_since_1.4.0.toml: 1 recorded") + unowned = out.index("UNPINNED CONTEST expected_since_1.4.0.toml") + nothing = out.index("EXPLAINED NOTHING 'fix(c) the inert one'") + legend = out.index("Field names below are Role's") + assert moved < unowned < nothing < legend, out + assert code == 1, out + + +#: The two-name variant of _UNOWNED_LEDGER: the LOSER's regex reaches +#: both contested names and both diff {family}, which is inside the +#: pair's intersection, so ONE (winner, loser) heading carries two +#: rows. 'Carol Brown' is the winner's alone and diffs {given}, which +#: is what keeps the winner's `fields` equal to the union it explains +#: and OVER-DECLARED quiet. +_TWO_NAME_UNOWNED_LEDGER = ( + '[[change]]\nissue = "fix(a) the winner"\n' + 'name_regex = "Smith|Jones|Brown"\nfields = ["family", "given"]\n' + '\n' + '[[change]]\nissue = "fix(b) the loser"\n' + 'name_regex = "Smith|Jones"\nfields = ["family", "suffix"]\n' + 'dormant = "shadowed by fix(a) on both names it reaches"\n') + +#: 'Alice Jones' with the FAMILY altered, so her diff is {family} -- +#: the same shape as 'John Smith' under _DIFFERS, and inside the +#: contested pair's intersection. +_JONES_FAMILY = {"title": "", "first": "Alice", "middle": "", + "last": "JONESY", "suffix": "", "nickname": "", + "maiden": ""} + +#: 'Carol Brown' with the GIVEN altered: the winner's third name, +#: uncontested, and the only reason its `given` is not over-declared. +_BROWN_GIVEN = {"title": "", "first": "CAROLINE", "middle": "", + "last": "Brown", "suffix": "", "nickname": "", + "maiden": ""} + + +def test_two_names_contesting_the_same_pair_print_under_one_heading( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The grouping, at the level where a reader sees it. + + `by_pair` exists so somebody editing one rule meets its whole cost + at once rather than the same pair once per name -- the shape + `fix(#271/#272/#298)` has at 1.4.0, heading three pairs over five + names. An ungrouped printer passes every assertion the one-row + tests make, so this is where that goes wrong: the heading once, + both names under it, in corpus order. + """ + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", {}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", {}) + code, out = _run_main(tmp_path, monkeypatch, _TWO_NAME_UNOWNED_LEDGER, + _DIFFERS, + extra=[("Alice Jones", _JONES_FAMILY), + ("Carol Brown", _BROWN_GIVEN)]) + assert "2 contested pair(s)" in out, out + heading = "'fix(a) the winner'\n outranks 'fix(b) the loser' on:" + assert out.count(heading) == 1, out + rows = out[out.index(heading) + len(heading):] + rows = rows[:rows.index("\n\n")] + assert rows.splitlines()[1:] == [ + " 'John Smith' [contract] ['family'] " + f"({_OVERLAP_WHY}, so neither is the narrower)", + " 'Alice Jones' [contract] ['family'] " + f"({_OVERLAP_WHY}, so neither is the narrower)"], rows + # 'Carol Brown' is the winner's alone, so it is not a contest and + # must not appear -- the control on the block's population. + # Scoped to the BLOCK: the name is in the classified listing above + # like every other explained name, so asserting over the whole of + # `out` would be a claim about that listing instead. + block = out[out.index("UNPINNED CONTEST"):] + assert "Carol Brown" not in block[:block.index("\n\n")], block + assert code == 1, out + + def test_a_corpus_run_is_silent_about_a_watched_name_outside_it( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The contest roster's inversion, for the watched one: under @@ -3011,6 +3277,105 @@ def test_a_watched_row_on_a_declared_order_only_name_reads_its_first_entry( assert got == code, out +def _order_bearing_unowned_ledger(loser_fields: str) -> str: + """A 2.0.0 ledger whose two rules both admit `'John Smith'`'s + FAMILY_FIRST `{family}` diff, the loser declaring `loser_fields`. + + `'Alice Jones'` diffs `{given}` under the default order and the + winner alone explains it, which is what keeps the winner's + `fields` equal to the union it explains at both widths below and + OVER-DECLARED quiet. The loser declares `dormant` because it + explains nothing -- the winner is written first -- so EXPLAINED + NOTHING does not fail the run for a reason that is not the + block's. + """ + return ('[[change]]\nissue = "fix(a) the winner"\n' + 'name_regex = "Smith|Jones"\nfields = ["family", "given"]\n' + '\n' + '[[change]]\nissue = "fix(b) the loser"\n' + f'name_regex = "Smith"\nfields = {loser_fields}\n' + 'dormant = "shadowed by fix(a) on the only name it reaches"\n') + + +def _order_bearing_unowned_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + loser_fields: str) -> tuple[int, str]: + """That ledger driven over one FAMILY_FIRST entry and one + default-order entry, with both shape rosters emptied for 2.0.0 -- + emptied rather than left alone so nothing on disk can own the + contest, and keyed rather than deleted because `main()` refuses a + ledger either dict has no section for.""" + from nameparser import HumanName + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_2.0.0.toml", {}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_2.0.0.toml", {}) + smith = _tree_v2_row("John Smith", "FAMILY_FIRST") + smith["family"] = "SMYTHE" + return _run_main_over( + tmp_path, monkeypatch, + [("corpus_x.jsonl", "contract", + [{"name": "John Smith", "shape": 4}, "Alice Jones"])], + [{"v2": smith}, + {"facade": {"title": "", "first": "ALICIA", "middle": "", + "last": "Jones", "suffix": "", "nickname": "", + "maiden": "", + "_initials": HumanName("Alice Jones").initials() or ""}, + "v2": _tree_v2_row("Alice Jones", None)}], + _order_bearing_unowned_ledger(loser_fields)) + + +def test_an_unowned_contest_under_a_declared_order_names_the_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The stated limit, printed only where it applies. + + A _RECORDED_DIFFS row is a DEFAULT-order shape, so no row can + absorb a contest measured under FAMILY_FIRST -- the repair the + lead offers is unavailable and the block has to say so, the way + NOT CHECKED names its own window. The order rides the ROW as well + as the note, since a reader given the note alone cannot tell which + of several rows it is about. + """ + code, out = _order_bearing_unowned_run( + tmp_path, monkeypatch, '["family", "suffix"]') + assert ("UNPINNED CONTEST expected_since_2.0.0.toml: 1 contested " + "pair(s)" in out), out + block = out[out.index("UNPINNED CONTEST"):] + block = block[:block.index("\n\n")] + assert ("NOTE: a row below carries a declared order, and a " + "_RECORDED_DIFFS row is a DEFAULT-order shape, so no row " + "can absorb it: narrow the pair, or scope one of the two " + "rules with `orders`." in block), block + assert ("'John Smith' [order: FAMILY_FIRST] [contract] ['family']" + in block), block + assert code == 1, out + + +#: The clause main() renders for an `equal` row, from +#: compare._UNOWNED_WHY, truncated as _OVERLAP_WHY is: a test quoting +#: the whole mapping entry would pass whatever the mapping said. +_EQUAL_WHY = "equal `fields`, file order" + + +def test_main_renders_the_equal_fields_clause( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The kind the docs call _CROSS_RULE_WINNERS the instrument for. + + Seven of #498's fourteen sit in an EQUAL-`fields` pair, so a + printer that rendered only the `overlap` clause would be wrong + about half the class. Same fixture as the sibling above with the + loser's `fields` widened to equal the winner's, so the kind is the + only thing that moved. + """ + code, out = _order_bearing_unowned_run( + tmp_path, monkeypatch, '["family", "given"]') + block = out[out.index("UNPINNED CONTEST"):] + block = block[:block.index("\n\n")] + assert _EQUAL_WHY in block, block + assert _OVERLAP_WHY not in block, block + assert code == 1, out + + def test_a_tier_outside_the_two_literals_is_refused_at_load( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """_CORPUS_TIERS' value is validated where its key's presence is, diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 6ec14280..1d771028 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -3346,6 +3346,61 @@ def _tree_parse(name: str, order: str | None) -> object: f"without promising." + _two_causes(watched_radar)) _print_moved_rows(watched_radar, "_WATCHED_DIFFS") + # The completeness half of the contest checks (#498). The + # pre-worker `undeclared_contests` refusal covers the pairs where + # nesting supplies the contested shape's existence; this covers + # the pairs where only a measured diff can, which is why it is + # HERE and not up there -- it needs `diffing`, and `diffing` needs + # the worker. `recorded` is the same per-ledger dict the shape + # check reads, and its KEYS are what "a winner is pinned" means. + # + # FATAL on both tiers, in the exit expression beside `shape_bad`. + # A contest row is fatal on either tier because it carries an + # argument; an owed row is fatal because it carries none yet. + unowned = unowned_contests(diffing, rules, exclusions, + set(recorded), tier_of) + if unowned: + # Grouped by (winner, loser), so a reader editing one rule + # sees its whole cost at once rather than meeting the same + # pair once per name. dict preserves insertion order, which + # is `diffing` order, which is corpus order. + by_pair: dict[tuple[str, str], list[_UnownedContest]] = {} + for row in unowned: + by_pair.setdefault((row.winner, row.loser), []).append(row) + # PAIR(S) and not diff(s): the rows are one per (name, losing + # rule), so one name contesting two losers is two of them -- + # '田中さん, Dr.' is that shape at 1.4.0 -- and a reader + # counting names off this number would be short. + print(f"UNPINNED CONTEST {ledger.name}: {len(unowned)} contested " + f"pair(s), one per name and losing rule, that no rule " + f"outranks by nesting or declaration and " + f"no row pins. Pin each name below in _RECORDED_DIFFS " + f"(tools/differential/compare.py) and _CROSS_RULE_WINNERS " + f"(tests/v2/test_ledger_guards.py), with the argument for " + f"the winner beside the row -- or narrow the accident " + f"away until the pair stops admitting the same diff. A " + f"contest is not a defect: file order decides one " + f"legitimately, and narrow-first and a declared " + f"`precedes_narrower` pair are both justified above. An " + f"UNOWNED contest is: nothing in the tree says which rule " + f"should win it, so a reorder or a widened `fields` hands " + f"the name over silently. A _WATCHED_DIFFS row does NOT " + f"pin -- it records a shape and no winner. The population " + f"is the entries THIS run loaded, so a `--corpus` subset " + f"can only UNDER-report: silence is no verdict on the " + f"names it did not compare." + + (" NOTE: a row below carries a declared order, and a " + "_RECORDED_DIFFS row is a DEFAULT-order shape, so no " + "row can absorb it: narrow the pair, or scope one of " + "the two rules with `orders`." + if any(r.order is not None for r in unowned) else "")) + for (winner, loser), rows in by_pair.items(): + print(f" {winner!r}\n outranks {loser!r} on:") + for row in rows: + print(f" {row.name!r}{_order_tag(row.order)} " + f"[{row.tier}] {list(row.diff)} " + f"({_UNOWNED_WHY[row.kind]})") + print() dormancy = dormant_rules(rules, set(by_issue), diffing, exclusions) for dormant in dormancy.undeclared: print(f"EXPLAINED NOTHING {dormant.issue!r}\n " @@ -3412,13 +3467,19 @@ def _tree_parse(name: str, order: str | None) -> object: # `fields` it names are no longer what the code moves. A recorded # shape the run contradicts is the fifth, and it reaches furthest: # _CROSS_RULE_WINNERS feeds that shape to classify() as an input, so - # a wrong one takes the roster's verdict with it. One exit code for - # all five terms below, so none of them is the one nobody noticed. + # a wrong one takes the roster's verdict with it. A measured contest + # nobody has pinned is the SIXTH, and among the ROSTER terms -- + # `shape_bad`'s two rosters and this one -- it is the only one about + # a row that does not exist: those two read a row somebody wrote and + # ask whether the run still agrees, while this one asks for a row + # nobody has written yet. In that it joins `unexplained`, which is a + # diff no rule was written for. One exit code for all six terms + # below, so none of them is the one nobody noticed. # `shape_bad` is the contest rows plus the watched rows on contract # names; a watched row on a radar name printed above and is not in # it, by the severity rule _WATCHED_DIFFS' header argues. return 1 if unexplained or dormancy.undeclared or dormancy.awake \ - or overwide or shape_bad else 0 + or overwide or shape_bad or unowned else 0 if __name__ == "__main__": From 24fad2842412afdf3c27e7d40f988fcd6c9e7db0 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 5 Sep 2026 21:17:16 -0700 Subject: [PATCH 4/4] docs(design): the #498 completeness check decisions.md gains a dated `### differential-ledger, the #498 completeness check (2026-09-05)` entry after the #501 adjudication: the four-baseline population figures with their recipe (47/18/14 at 1.4.0, 3/3/0, 1/1/0, 0/0/0), the equal-fields finding, the scope decision with the 9-more-names / 23-in-all figure and that #501's gap (ii) stays open by choice, the coverage argument that says what the 2026-09-02 decline declined and what this is, the seven verdicts with their measured old and new values, and the mutation control. The 2026-09-02 "nesting is sufficient, not necessary" bullet and the run-time-detection Declined bullet each gain one sentence pointing here, so neither reads as the last word on a class that has since been closed. README's "What counts as a contest" gains the second predicate, its severity, and the no-vacancy-half asymmetry; the #498 paragraph goes from "blind spot, filed" to "closed by", keeping the worked case and the static figures. mechanisms.md#CROSS-RULE-OUTCOME-PINS says the roster's completeness is now enforced rather than stated, and #LEDGER-RULE-SEPARATION's "#498 carrying the worked case" clause says how it was closed. Refs #498 Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 42 ++++++++++++++++++++++++++++++++---- docs/design/mechanisms.md | 4 ++-- tools/differential/README.md | 27 +++++++++++++++++++++-- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index b8612a2e..a6b8851e 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -774,7 +774,7 @@ The sixth ledger arc, and the one that closes the question mechanisms.md#LEDGER- Decisions that landed: - 2026-09-02 #382 — an order-decided contest must be DECLARED, and narrow-first is the declaration-free default. A pair is a contest when the later rule's `fields` are a strict subset of the earlier one's, some corpus name's `name_regex` reaches both, and some comparison order reaches both; where the EARLIER rule is the wider one it carries a `precedes_narrower` block naming the later rule and saying why it is the classifier of record — usually what it describes that the later one does not, and in the three regex-accident pairs (`fix(#296)`/jr, and the two `fix(cjk-glued-honorific-peel)`/`fix(suffix-routing)` pairs) that the later rule reaches the name through its REGEX rather than by describing it — both `fix(suffix-routing)` patterns are anchored at both ends and open on a bare `\S+` run, which swallows the trailing comma of `Smith,` and matches hangul as readily as Latin, while the prose they carry is scoped to a comma-less two-token Latin name. Only ONE of the three is false outright — `fix(#296)`/jr, where 1.4.0 read `'Smith, Jr.'` as title `'Jr.'`, first `'Smith'`, so the family→suffix movement the jr rule describes never happens on it. The other two are PARTIAL, and symmetrically so: on `김민준씨 Jr.` and on `田中さん II` alike the trailing token really does leave `family` for `suffix` (1.4.0 first `'田中さん'`, last `'II'` → tree last `'田中'`, suffix `'さん, II'`, exactly the movement the numeral rule shows on its own `John V`), and what neither `fix(suffix-routing)` rule can describe is the peel and the segmentation that move `given`. Recomputed 2026-09-02 against the 1.4.0 wheel; the exemptions themselves claim only the scope mismatch, so do not read a falsity claim into the two partial ones. `compare.py` refuses the run, before the worker spawns, where such a pair goes undeclared. Reordering is NOT the alternative fix and the failure message says so: it moves which rule classifies a name and breaks `_CROSS_RULE_WINNERS`. What the check buys is that nothing else in the suite can see the hazard at all: `_CORPUS_CLAIMS` measures each rule alone, the gate total is per-corpus, and `_CROSS_RULE_WINNERS` pins contested outcomes only for names somebody hand-added. -- 2026-09-02 #382 — the check covers NESTED pairs, and nesting is sufficient for an order-decided contest rather than necessary. Two rules whose `fields` merely INTERSECT both admit any diff inside that intersection, so `classify()` hands such a name to whichever is written first exactly as it does for a nested pair, and `order_contests` cannot see it. That class sits outside the check by the reasoning `order_contests`' docstring already states for EQUAL `fields` — neither rule is narrower, so "narrow-first" says nothing about the pair, `precedes_narrower` has no narrower rule to name, and `_CROSS_RULE_WINNERS` stays the instrument — which covers every pair where neither `fields` set contains the other, equal `fields` being its special case. Not oversight, and not free either: measured 2026-09-02 over the four ledgers, counting pairs that share a corpus name and whose `orders` are not disjoint, 11 are strictly nested wide-first (what the check refuses), 40 nest in either direction, 11 have equal `fields`, and 111 have any non-empty intersection — so 60 overlap without nesting or equality. Widening to the general predicate would demand 111 written justifications where the real number is eleven, the same kind of answer this entry already gives below about the 657 figure: a predicate whose roster nobody can write out one justification at a time is not a usable predicate, whichever condition inflated it. The blind spot is worked and filed as [#498](https://github.com/derek73/python-nameparser/issues/498): `fix(#271/#272/#298)` (`{family, given, middle}`) and `fix(cjk-delimited-nickname)` (`{family, given, nickname}`) intersect in `{family, given}` without nesting, three contract-tier corpus names (`マイケル・ジャクソン`, `威廉・莎士比亚`, `高橋・一郎`) diff exactly those two roles against the 1.4.0 wheel, swapping the two rules reattributes all three, none is in `_CROSS_RULE_WINNERS`, and `order_contests` lists the pair in neither arrangement. RECOMPUTE the five figures with `_rule_reach` per ledger over the `corpus*.jsonl` union, classifying each pair by whether `a.fields & b.fields`, `b.fields < a.fields`, `a.fields < b.fields` or `a.fields == b.fields`; #498's body carries the loop. +- 2026-09-02 #382 — the check covers NESTED pairs, and nesting is sufficient for an order-decided contest rather than necessary. Two rules whose `fields` merely INTERSECT both admit any diff inside that intersection, so `classify()` hands such a name to whichever is written first exactly as it does for a nested pair, and `order_contests` cannot see it. That class sits outside the check by the reasoning `order_contests`' docstring already states for EQUAL `fields` — neither rule is narrower, so "narrow-first" says nothing about the pair, `precedes_narrower` has no narrower rule to name, and `_CROSS_RULE_WINNERS` stays the instrument — which covers every pair where neither `fields` set contains the other, equal `fields` being its special case. Not oversight, and not free either: measured 2026-09-02 over the four ledgers, counting pairs that share a corpus name and whose `orders` are not disjoint, 11 are strictly nested wide-first (what the check refuses), 40 nest in either direction, 11 have equal `fields`, and 111 have any non-empty intersection — so 60 overlap without nesting or equality. Widening to the general predicate would demand 111 written justifications where the real number is eleven, the same kind of answer this entry already gives below about the 657 figure: a predicate whose roster nobody can write out one justification at a time is not a usable predicate, whichever condition inflated it. The blind spot is worked and filed as [#498](https://github.com/derek73/python-nameparser/issues/498): `fix(#271/#272/#298)` (`{family, given, middle}`) and `fix(cjk-delimited-nickname)` (`{family, given, nickname}`) intersect in `{family, given}` without nesting, three contract-tier corpus names (`マイケル・ジャクソン`, `威廉・莎士比亚`, `高橋・一郎`) diff exactly those two roles against the 1.4.0 wheel, swapping the two rules reattributes all three, none is in `_CROSS_RULE_WINNERS`, and `order_contests` lists the pair in neither arrangement. RECOMPUTE the five figures with `_rule_reach` per ledger over the `corpus*.jsonl` union, classifying each pair by whether `a.fields & b.fields`, `b.fields < a.fields`, `a.fields < b.fields` or `a.fields == b.fields`; #498's body carries the loop. Closed 2026-09-05 by #498, which did not widen this predicate: the class is now covered at RUN time, where a measured diff makes the population 14 rather than the 71 pairs of the equal-or-overlap class (the 60 counted here plus the 11 with equal `fields`), and every name in it is pinned — the three named here among them, so read that "none is in `_CROSS_RULE_WINNERS`" as the state this measurement found (the #498 completeness check below). - 2026-09-02 #382 — an ESCAPE HATCH here, where #452's and #456's were declined, and on the terms #452 set. Both of those bans were free to state: measured at the time, 0 of the 179 rules across the three ledgers then on disk had #456's shape, and #452's fourteen over-declarations (3 of 67 EXPLAINING rules at 1.4.0, 5 of 58 at 2.0.0, 6 of 51 at 2.1.0 — a different and smaller population than #456's 179, which counts every rule) were all narrowed before the check landed, so neither ban had to argue with a rule that was correct as written. #452's entry states the price of that strictness — "the first rule that genuinely needs a wider declaration has to argue for a key the way `dormant` was argued for in #373". Read that as the PROCEDURE it sets, not as a hatch this key opens: `precedes_narrower` is not a wider `fields` declaration and does nothing for an over-declared rule, which still exits the run non-zero. What carries over is the standard of proof, and here it is measured rather than asserted: eleven pairs in `expected_since_1.4.0.toml` are wide-first and every one of them is correct where it sits, so a ban would have had eleven rules to reorder or eleven prose descriptions to falsify. The hatch is narrowed the way `dormant` is: it names ONE rule (a blanket opt-out would be inherited by every narrower rule added later, which is the widening the check exists to refuse), the `why` is required, and a declaration standing over a pair that is no longer contested is refused as loudly as an undeclared contest. - 2026-09-02 #382 (decided in review) — the vacancy half REFUSES only on a full run and prints a NOTE under `--corpus`. The two checks are not symmetric under a narrowed name set, and the asymmetry is the whole reason: narrowing removes contests, so for the undeclared check `--corpus` is only ever more lenient (it can only under-report, never false-alarm — do not call that fail-closed, which this repo uses for the rosters that REFUSE on a missing entry), while for the vacancy check it INVERTS — a live declaration whose contested names all sit outside the subset reads as vacant. Shipped as a regression and caught in review: as first written, a `--corpus` run against the 1.4 ledger exited 1 and told the contributor to delete exemptions the full gate needs — measured, each of the six corpora run ALONE reports vacancies, 11 of the 11 for `corpus.jsonl`, `corpus_cjk.jsonl` and `corpus_shapes.jsonl`, and 8 / 7 / 5 for the other three — after which deleting them would have made the full run refuse with that many undeclared contests. Read the shape and not the digits: the number varies with the subset, and only zero would have been safe. The file already treats `--corpus` differently in two places and this is the third, so the three should be read together rather than made uniform: the corpus-floor roster is SKIPPED entirely under the flag, `over_declared_rules` still FAILS the run and appends a NOTE saying the union it computed is over a subset, and the vacancy check does not fail at all. The strengths differ because the error directions do — only the vacancy check inverts under narrowing. - 2026-09-02 #382 — TWO name populations, deliberately, rather than one shared function. `main()` must check the corpus it ACTUALLY compares, because `--corpus` narrows it; the unit guard in tests/v2/test_ledger_guards.py must check every corpus on disk, so that a rule added by a later bundle is checked at pytest speed with no baseline wheel. Forcing one function would break `--corpus`. They agree by construction instead: `_entry_name` in tests/v2/_differential_fixtures.py says in its docstring that it mirrors `compare.py`'s `_load_entries`, and both read the same `corpus*.jsonl` glob. @@ -795,7 +795,7 @@ Declined: - Reordering the eleven (2026-09-02) — the obvious way to make the check unnecessary, and wrong twice. Reordering moves which rule classifies a name, which is a behavior change to the ledger and breaks `_CROSS_RULE_WINNERS`; and no pair is even a reorder CANDIDATE, because in all eleven the narrower rule's own prose would be FALSE or MERELY PARTIAL of the co-matched names if it won, and either disqualifies it as classifier-of-record. Partiality is the commoner half and the arc's load-bearing distinction — compound versus component, which the `fix(#400/#274)`/`fix(#400)` exemption calls "the canonical compound-versus-component shape". Read the eleven `why` texts for it: `fix(#400)` "says nothing about a maiden marker"; a widened `fix(comma-family) lone post-comma piece routes to suffix/title` "would take the union and report only half of it"; `fix(cjk-glued-honorific-peel)` says "nothing about a division"; `feat(#273)`'s prose "has nothing to say about" the remainder rejoining as a name; and two of the three regex-accident pairs are partial rather than false, the `fix(suffix-routing)` jr and numeral rules genuinely naming the `{family, suffix}` half of `김민준씨 Jr.` and of `田中さん II` and describing neither the peel nor the segmentation that moves `given`. Falsity is the minority, and one of the eleven says so of itself: the exemption `fix(comma-family) a comma followed only by titles keeps the given/family split` carries over `fix(comma-precomma-family)` calls its own pair "the one where the narrower rule would be actively WRONG rather than merely partial", and adds that the claim "is about THIS name and is deliberately not generalised". `马丁·路德·金씨` is the clearest compound, and `王先生, V.` the reverse case, where the winner is already the least wrong reading available — and stays so, since the family-side twin that would describe it was weighed as #496 and declined (the next bullet). - A family-side twin of `fix(cjk-comma-honorific-peel)` for a PRE-comma glued honorific (2026-09-02, [#496](https://github.com/derek73/python-nameparser/issues/496), closed as not planned) — the rule that would honestly describe `王先生, V.`, `田中さん, V.` and `김민준씨, V.`, whose diff is the glued peel alone while the label they carry says comma compound (the finding bullet above). Declined on two grounds. FIRST, no gate can demand it: all 17 names the compound/peel pair is contested across measure radar — but by two different warrants, and an audit that assumes one will find the count short. Sixteen sit in `corpus_cjk_tolerated.jsonl`, the file #488 created by demoting the composed comma/Latin-wrapper CJK forms to tolerated input, `김, 민준씨` — the name #382 was filed over — among them. The seventeenth, `Dr 田中さん, V.`, is in `corpus_issues.jsonl` alone: harvested and append-only, radar since #468, and untouched by #488. (`Dr 김민준씨, Jr.` is in both files, and reads radar from either.) Radar means an unmatched diff is reported rather than fatal for want of a RULE; it is not an unconditional never-fatal, since a `[[never]]` exclusion outranks the tier and stays fatal on a radar name — `_CORPUS_TIERS`' own note, and `main()`'s two-reasons comment, which routes an excluded name to `unexplained` rather than to `radar`. No exclusion refuses these, so nothing here can demand the twin. SECOND, [#495](https://github.com/derek73/python-nameparser/issues/495) points the other way over the same corner: it asks whether the radar-only rules already in the ledger still earn their place and records `fix(cjk-comma-compound)` as having zero contract-tier reach, so #496 proposed a SECOND rule for the very names #495 is weighing a first one away from. Fewer rules for this corner is the coherent direction, and that is what makes the decline decisive rather than merely permissive. REOPEN it if a pre-comma glued-honorific name ever reads contract — and watch BOTH routes, because the two files promote by different mechanisms. A `corpus_cjk_tolerated.jsonl` name is promoted by clearing `tolerated` on its case rows, which moves the text into `corpus_cjk.jsonl` (`_CORPUS_TIERS`, and build_cjk_corpus.py's split). `Dr 田中さん, V.` has no case row at all, so there is no flag to clear: it changes tier only by being CHOSEN — a new unmarked row, or a rules.md example — which puts the text in a contract corpus, and the (name, order) dedup loads contract files first and keeps that reading. Read the tier off `_CORPUS_TIERS` either way, never off a hand-built file→tier map (the roster caution above). -- Precise per-name contest detection at differential-run time (2026-09-02; its stated reason replaced 2026-09-03, [#497](https://github.com/derek73/python-nameparser/issues/497)) — it would replace the static predicate's over-reporting with the measured nine-of-eleven split, and it needs the pinned-wheel worker pass to do it. The decline stands, on COVERAGE alone: a run-time check sees one ledger — the one for the baseline that run was invoked with — and only once somebody invokes the tool, whereas the static predicate covers a rule from the moment it is written, including a rule nobody has ever run the wheel against, which is the whole point of #382. Nor can the unit suite simply adopt the precise check: `tests/v2/test_differential.py` is deliberately hermetic — its own header says "nothing here spawns `uv` or the network", and the tests that need a baseline fake `_run_worker` or `Popen` outright — so moving it to pytest speed means spawning a subprocess and, on a cold cache, reaching PyPI. WITHDRAWN, and named here so it is not re-argued: this bullet used to say the check would sit "behind a multi-minute run" and gave that as "the whole of the reason". No measurement was recorded beside it: the phrase entered on 2026-08-05 in `7767ba2` at a single site in `tools/differential/compare.py`, and was copied outward until eight sites carried it — four there, three in `tests/v2/test_differential.py`, and this bullet. Measured 2026-09-03 over the corpus population named above, a whole run is 0.33s wall at baseline 1.4.0 and 0.57s at 2.2.0, the default; the worker pass is 0.10s and 0.29s of those, timed inside `main()`. All four baselines back to back total 1.97s, and a run with `uv`'s cache emptied first, so the pinned wheel is downloaded rather than reused, was still under a second — that last one is NETWORK- and environment-dependent and the RECOMPUTE below does not reach it, covering the warm run alone. Reproducing it means pointing `UV_CACHE_DIR` at an empty directory so the wheel is fetched rather than reused, and the number will differ on a different link. Read it as "the download is not the story either", not as a figure to check against. Read the class and not the digits: the worker pass is a fraction of a second and the whole gate is seconds at most, where the withdrawn phrase said minutes — so no argument here may be built on what the worker pass costs. RECOMPUTE: `time (uv run python tools/differential/compare.py --baseline 1.4.0 >/dev/null)`. For the worker's share, wrap `compare._run_worker` in a timer and then call `main()` — calling `_run_worker` directly on every loaded entry aborts at a 1.4.0 baseline, since the seven order-bearing shape-4/5 entries have to be dropped by the baseline-minimum skip first. Do NOT restate the decline as an error-direction argument either: within the NESTED pairs computing real diffs can only remove them, but the predicate is not a superset of the contests, since nesting is sufficient for one and not necessary (the bullet above, and #498). What the static check buys is coverage of a rule nobody has run the wheel against, not a guarantee of refusing everything it should. +- Precise per-name contest detection at differential-run time (2026-09-02; its stated reason replaced 2026-09-03, [#497](https://github.com/derek73/python-nameparser/issues/497)) — it would replace the static predicate's over-reporting with the measured nine-of-eleven split, and it needs the pinned-wheel worker pass to do it. The decline stands, on COVERAGE alone: a run-time check sees one ledger — the one for the baseline that run was invoked with — and only once somebody invokes the tool, whereas the static predicate covers a rule from the moment it is written, including a rule nobody has ever run the wheel against, which is the whole point of #382. Nor can the unit suite simply adopt the precise check: `tests/v2/test_differential.py` is deliberately hermetic — its own header says "nothing here spawns `uv` or the network", and the tests that need a baseline fake `_run_worker` or `Popen` outright — so moving it to pytest speed means spawning a subprocess and, on a cold cache, reaching PyPI. WITHDRAWN, and named here so it is not re-argued: this bullet used to say the check would sit "behind a multi-minute run" and gave that as "the whole of the reason". No measurement was recorded beside it: the phrase entered on 2026-08-05 in `7767ba2` at a single site in `tools/differential/compare.py`, and was copied outward until eight sites carried it — four there, three in `tests/v2/test_differential.py`, and this bullet. Measured 2026-09-03 over the corpus population named above, a whole run is 0.33s wall at baseline 1.4.0 and 0.57s at 2.2.0, the default; the worker pass is 0.10s and 0.29s of those, timed inside `main()`. All four baselines back to back total 1.97s, and a run with `uv`'s cache emptied first, so the pinned wheel is downloaded rather than reused, was still under a second — that last one is NETWORK- and environment-dependent and the RECOMPUTE below does not reach it, covering the warm run alone. Reproducing it means pointing `UV_CACHE_DIR` at an empty directory so the wheel is fetched rather than reused, and the number will differ on a different link. Read it as "the download is not the story either", not as a figure to check against. Read the class and not the digits: the worker pass is a fraction of a second and the whole gate is seconds at most, where the withdrawn phrase said minutes — so no argument here may be built on what the worker pass costs. RECOMPUTE: `time (uv run python tools/differential/compare.py --baseline 1.4.0 >/dev/null)`. For the worker's share, wrap `compare._run_worker` in a timer and then call `main()` — calling `_run_worker` directly on every loaded entry aborts at a 1.4.0 baseline, since the seven order-bearing shape-4/5 entries have to be dropped by the baseline-minimum skip first. Do NOT restate the decline as an error-direction argument either: within the NESTED pairs computing real diffs can only remove them, but the predicate is not a superset of the contests, since nesting is sufficient for one and not necessary (the bullet above, and #498). What the static check buys is coverage of a rule nobody has run the wheel against, not a guarantee of refusing everything it should. This decline is unchanged and is not what #498 did: the #498 completeness check ADDS a run-time predicate over a class the static one covers not at all, rather than replacing the static one over the class it does cover — so the coverage argument above is the reason both exist (the #498 completeness check below). - Giving the peel rule a predicate the compound rule fails (2026-09-02, #382 option 1) — narrowing `fix(cjk-comma-honorific-peel)`'s `name_regex` to the honorific-bearing shapes, whose stated effect in #382 was that the pair "becomes order-independent and the original contract holds again". Declined because that effect is UNREACHABLE, and measured rather than argued. The two rules ship a BYTE-IDENTICAL `name_regex` (verified 2026-09-02 by comparing the two strings), and `_rule_reach` computes each rule's names from its OWN pattern, so narrowing the peel rule's regex narrows the peel rule's reach and nothing else: the compound rule goes on reaching every one of the names. The general form, which no regex edit escapes — where the earlier rule's `fields` are a SUBSET of the later one's, order-independence requires the LATER rule to stop reaching the name, and an edit to the earlier rule's own regex cannot cause that. Here the peel rule's `{given, suffix}` is a strict subset of the compound rule's `{family, given, suffix, title}`, so every diff the peel rule admits the compound rule admits too. Implemented to check it: narrowing the peel regex to a `GLUED_HONORIFICS` alternation keeps all seven of the co-matched names whose 1.4.0 diff is exactly `{given, suffix}`, and swapping the two rules in the NARROWED ledger still reattributes all seven. So the pair keeps the arrangement the arc settled on, and that is the answer rather than a cost trade: it is narrow-first, the declaration-free default, so it appears in none of the eleven contests `order_contests` reports and owes no `precedes_narrower` block, and all seven names are pinned by name in `_CROSS_RULE_WINNERS` (23 corpus names reach both regexes; the seven are the ones #375's reorder mutation moves). The cost stands as a second reason and not the first: the narrowing would hand-copy more honorific vocabulary into the pattern and grow the `_HONORIFIC_SOURCES` sync-roster surface (mechanisms.md#CURATED-VOCABULARY-ALTERNATION's second half). Option 2's convention plus a check on the wide-first exceptions to it is what the arc took, and option 3 it REFUSED on a false premise (the finding above). One thing this does not settle, and the ledger comment on the peel rule overstates it: `fields` separates the two only for the UNION rows whose diff includes `family`; for those seven it is file order that decides. - Scoping the check to contract-tier contests only (2026-09-02) — five of the eleven are contested only over radar names, so this would have cut the file's exemptions by nearly half. Declined because `_CROSS_RULE_WINNERS` already pins radar names — measured, MOST of the names it pins are radar-tier — so the repo would be inconsistent with itself about whether a radar contest matters. The majority is the whole of the argument and no digit belongs here: a count written at this spot was stale before the branch that wrote it merged, since the 2026-09-03 #497 entry below then deleted four of the roster's rows. RECOMPUTE: union the names in `_CROSS_RULE_WINNERS` (tests/v2/test_ledger_guards.py) and read each one's tier off `compare._CORPUS_TIERS`, via the first `corpus*.jsonl` file holding the name with contract files sorted first, as `main()` loads them. Whether the radar-only CJK comma rules still earn their place after #488's demotion is a real question and is filed as #495; it is a question about those rules, not about the check. - A shared name-population function for both callers (2026-09-02, the spec's own first sketch) — see the two-populations decision above. `main()` and the unit guard must read different populations, so one function would break `--corpus`; they agree by a docstring that names the function it mirrors instead. @@ -809,7 +809,7 @@ Decisions that landed: - 2026-09-03 [#497](https://github.com/derek73/python-nameparser/issues/497) — the recorded shapes live in the TOOL, as `compare._RECORDED_DIFFS`, and the RUN verifies them; `_CROSS_RULE_WINNERS` keeps only which rule wins a contested name and reads the shape from there. There because it is the one place both halves exist: `main()` has measured every name's real diff by the time it asks, where `validate_rules` runs before any corpus is read and the unit suite installs no wheel at all — `tests/v2/test_differential.py`'s own header says "nothing here spawns `uv` or the network", and the tests that need a baseline fake `_run_worker` or fake `Popen` under it. State it that way and not as "every test monkeypatches `_run_worker`", which is false: measured 2026-09-03 on this branch, of 171 `def test_*` functions in that module 20 mention `_run_worker` at all and six reach the real one with `Popen` faked beneath them by `_fake_popen`. RECOMPUTE by walking the module's `def test_*` bodies for the name. That short form is what compare.py's `_RECORDED_DIFFS` provenance note SAID when this entry was written; `f70a0ad`, the very next commit, withdrew it there, and the note now rejects the paraphrase rather than carrying it — so follow that pointer to the history and not to the text. The denominator has moved once already inside this arc: `f70a0ad`'s own message says 168, which is why the argument here is "most never mention it" and the digits carry a date. The hermeticity claim is unharmed and the loose paraphrase of it is what fails, which is this arc's subject arriving inside its own evidence. Read HERMETICITY as the reason and not speed. The rival placement is the unit suite, and the reason it is refused is that a check there would have to install a wheel — not that installing one is slow. The timing finding below retires every cost argument in this file and moves this decision not at all, which is the test such a placement argument has to pass and the reason to say out loud which ground carries it. `_CORPUS_FLOORS` is the precedent — tool-owned, corpus-shaped data that the guards read and monkeypatch — and this is the same direction. - 2026-09-03 #497 — ONE roster, TWO checks, TWO PLACEMENTS — and, since `9360919`, a NOTE over the gap between them, recorded at the end of this entry. (Superseded in its first word 2026-09-05: TWO rosters since the watched-shapes arc below, `_WATCHED_DIFFS` beside `_RECORDED_DIFFS`, both placements unchanged; the departed-name refusal and the `NOT CHECKED` note read the union of the two, while the shape comparison runs once per roster, because the two halves carry different severities — a contest row's mismatch feeds the exit code on either tier, a watched row's only where its default-order entry is contract tier — and one call could not tell them apart.) The asymmetry of the two checks is the decision rather than where the code happened to fit. The ABSENT-NAME half — a recorded row naming a string no corpus holds any more, which nothing else would ever report — refuses PRE-worker, beside `vacant`, because it reads the ledger and the loaded names and nothing the worker produces. It takes the PLACEMENT #382 chose for its own pre-worker refusal and NOT #382's reason for it, which was the cost claim below — `compare.py`'s comment at that placement is one of the sites the timing finding retires. The ground it stands on was rewritten on this branch: a refusal raised after the wheel is installed and the corpus compared prints below the run's own published `baseline:` header, for a comparison it will never report. Not "disowning a comparison it has just published" — measured print order is `baseline:` at the tell, then the comparison loop, then `corpus: … intentional diffs:`, so at that position the header has printed and no line of the comparison has. The ORDER is the argument, and that corrected wording is what `compare.py` now carries at all three of its placement comments. Recorded because it is the second decision the false number was under, and the two came apart differently — the declined check below kept its conclusion on a new argument, and here the ORDER was right all along while the reason for it was not. Only re-deriving a claim says which of its supports was doing the work; a placement that reads unchanged is not thereby unaffected. The MOVED-SHAPE half is the opposite case — it needs the diffs, so it stays after the comparison — and there it may not RAISE. It PRINTS and feeds the exit code, like `over_declared_rules`, its structural sibling and the other post-worker check on recorded data: a raise at that point lands MID-report and takes dormancy, OVER-DECLARED, UNEXPLAINED and the radar block with it, which compare.py measures on a narrowed 1.4.0 run beside the check. A stale roster row must never hide an unexplained diff, that being the gate's primary output. The two pre-worker refusals may raise because nothing has printed yet; this one cannot, and the difference is PRINT ORDER rather than severity. The two halves also read opposite name lists, which is `recorded_diff_mismatches`' docstring's to state and is not restated here. What IS decided here is the THIRD thing this roster produces, added by `9360919` once the gap between those two lists was measured and found not empty: an order-bearing entry the baseline cannot honor sits in a corpus file and outside the compared set at once, so a row on such a name is checked by neither half and reported by neither. `main()` prints a `NOT CHECKED` note over it. A NOTE and not a refusal — the docstring's argument against refusing off the post-skip list stands, and it is an argument against REFUSING and not against SAYING, which are two decisions of which only the first was ever made. Informational, outside the exit code, and gated on neither `full_corpus` nor `args.corpus`, the intersection narrowing itself. Measured 2026-09-03 at 1.4.0 — both the only baseline where the skip fires and the only ledger with rows — the window is three names, none of which carries a roster row today, so the note prints on no run yet. The measurement that motivated it put a deliberately wrong shape on `'de la Cruz née Vega'` over the full corpus at 1.4.0: before `9360919` that exited 0 in 375 stdout lines naming the name in none of them, and it now exits 0 in 378 with the `NOT CHECKED` note naming it — re-measured 2026-09-03 by corrupting the row in memory around `main()`, which leaves the worktree alone. -- 2026-09-03 #497 — a SHAPE may be pinned where a COUNT may not, and that line is the whole of what keeps this roster affordable. A diff shape is one name under two parser versions, so adding a name to a corpus cannot move it; an explained-name count moves on every corpus edit. Shapes drift only when the PARSER changes those names — so the drift and the reason to re-read the roster are the same event, which is exactly what a recorded number usually fails to be. That is why #452's churn objection to `explains = N` (the fields-only arc above, 2026-08-28) does not reach this roster, and why pinning shapes here does not weaken it: the four ledgers carry 201 rules today, one `_CORPUS_CLAIMS` entry each, every one liable to move whenever a corpus does, against 31 recorded shapes that move only with the parser (45 since #498). Read the two CAUSES and not the two totals — the argument is that one set is coupled to corpus edits and the other is not, and it survives either number moving. RECOMPUTE: `sum(len(v) for v in _CORPUS_CLAIMS.values())` in tests/v2/test_ledger_guards.py, and `sum(len(v) for v in _RECORDED_DIFFS.values())` in compare.py. +- 2026-09-03 #497 — a SHAPE may be pinned where a COUNT may not, and that line is the whole of what keeps this roster affordable. A diff shape is one name under two parser versions, so adding a name to a corpus cannot move it; an explained-name count moves on every corpus edit. Shapes drift only when the PARSER changes those names — so the drift and the reason to re-read the roster are the same event, which is exactly what a recorded number usually fails to be. That is why #452's churn objection to `explains = N` (the fields-only arc above, 2026-08-28) does not reach this roster, and why pinning shapes here does not weaken it: the four ledgers carry 201 rules today, one `_CORPUS_CLAIMS` entry each, every one liable to move whenever a corpus does, against 31 recorded shapes that move only with the parser (51 since #498, 45 of them at 1.4.0). Read the two CAUSES and not the two totals — the argument is that one set is coupled to corpus edits and the other is not, and it survives either number moving. RECOMPUTE: `sum(len(v) for v in _CORPUS_CLAIMS.values())` in tests/v2/test_ledger_guards.py, and `sum(len(v) for v in _RECORDED_DIFFS.values())` in compare.py. - 2026-09-03 #497 — four of the 35 rows the roster carried were DELETED rather than corrected. Two names across the two 2.x ledgers, so two rows each, and one of the two NAMES had a correctable shape — count rows or names deliberately, since the provenance note that holds the measurements speaks of the name. Deleted because neither name pinned a CONTEST, which is what the roster is for: at the shape each really produces exactly one rule admits it, so `classify()` was never adjudicating anything. `'Nguyen, Van'` diffs at no baseline at all, so no run ever asks `classify()` about it; `'Jane née and Jones Smith'` is a malformed harvest from a radar corpus whose right parse nobody can state, so a pin on it defends no boundary anyone would argue for — which is why the correctable one went too. compare.py's provenance note holds the per-name, per-baseline measurements behind both. The deletion emptied both 2.x sections, which stay present as explicit empty mappings — the completeness guards require it, and it is what makes emptiness sayable as a POSITION: a row is owed when someone argues a boundary, not before. What the emptiness is NOT — that these ledgers hold no contested diff — is measured and stated beside those sections, and whether the position should change now that those contests are measured rather than merely unexamined is [#501](https://github.com/derek73/python-nameparser/issues/501), open. (Superseded in its emptiness 2026-09-05: the two 2.x sections are no longer empty, the #501 adjudication below having pinned FIVE rows at 2.0.0 and one at 2.1.0, so `_CROSS_RULE_WINNERS` read 31 / 5 / 1 / 0 at that point — measured 2026-09-05 — and #501 is closed. It reads 45 / 5 / 1 / 0 since #498's fourteen pins, landed the same day; only the 1.4.0 figure moved. RECOMPUTE for either: `{k: len(v) for k, v in _CROSS_RULE_WINNERS.items()}` in tests/v2/test_ledger_guards.py. The criterion is not superseded and is what filled them: a row is owed when someone argues a boundary, and three of the six were found already argued, in the 2.0.0 ledger's own comment. Read the sentences above as the position emptiness stood on for as long as it stood.) Recorded with the deletion because it is the half a later sweep will get wrong: "only one rule admits it" is NOT on its own grounds to delete a row. A substantial minority of the surviving 1.4.0 rows are in that position and stay — the count and its recompute are beside them in test_ledger_guards.py, where six of the thirteen say so in their own comments and the other seven do not, which is why the count is recomputed rather than read off the roster — because the shapes they pin are shapes runs actually make, so a widened `fields` or a moved rule hands the name over and the test says so. Counting admitters is not the test at all: 11 of those 13 route to a DIFFERENT rule under some other shape, and mechanisms.md#RECORDED-ROSTERS now carries that measurement in place of the criterion it stated first. The four deleted rows could not do that work at any edit, which is the difference. - 2026-09-03 #497 — the prose sweep is sorted by this file's own criterion (#451, "past-tense counts stay in a ledger comment; live ones do not") into three treatments, and the treatment follows from what the count is DOING rather than from how wrong it is. A count that IS the finding and reads past tense stays. A count standing beside the enumeration it counts loses the digit — the list is self-checking and the digit is the only part that can rot. A live exhaustiveness claim with nothing recomputing it is REGROUNDED on the property it was reaching for, so that the sentence stops depending on a number — saying a widening claims all but a handful of the whole population the assertion is over says what a fraction said and cannot go stale. One judgement goes with it and is the arc's own: a figure that could NOT be reproduced was not replaced with a fresh one. `_reaches_non_vocabulary`'s docstring now carries the shape of its finding, the instruction for measuring it, and no LIVE figure for the magnitude the old figures asserted — a replacement digit nobody can re-derive is the defect rather than the repair, and this file has already been burned by that shape (the #414 roster comment under the fields-only arc's `explains = N` bullet). The past-tense digits explaining why the live ones are gone stay, which is the first treatment applied inside the third, and a later sweep should not read them as survivors of the pass. @@ -841,7 +841,7 @@ Decisions that landed: Found rather than decided, and worth as much: -- **The spec's 385 / 71 was GROSS of #452 and measured on a population the arc had already retracted; the net figure over the roster is the one to argue from.** `over_declared_rules` requires a rule's `fields` to EQUAL the union of the diffs it explains, so on a rule explaining only one name any shrink fires OVER-DECLARED and is not silent at all. Measured 2026-09-05 over the 113 rows `_WATCHED_DIFFS` holds: 390 alternative shapes classify to the same rule and 72 diffs are exposed by that count; net of #452 — keeping only the subsets `S` for which the union of the rule's OTHER explained diffs at that baseline, joined with `S`, still equals the rule's `fields` — 337 alternatives over 64 exposed diffs, and 44 further alternatives route to a different rule. Per baseline the gross / net / other-rule split reads 105 / 96 / 23 at 1.4.0, 158 / 137 / 20 at 2.0.0, 127 / 104 / 1 at 2.1.0 and 0 / 0 / 0 at 2.2.0. Over the 109 sole-watched rows alone — the roster minus the four #501 contests, which is the population the label "sole-watched" names — 367 gross over 68 exposed diffs, 314 net over 60, 35 other-rule, the whole difference sitting at 2.0.0 where the four contested rows are. The spec's first draft measured 385 / 71 gross, 330 / 62 net and 35 other-rule, with 123 / 112 / 23 at 1.4.0, over the population the next bullet retracts: 52 names and 113 diffs, holding the four names with 1.4.0 contest rows (45 rows there, `'MD, PHD'` alone contributing 14 alternatives) and not the four #501 rows — the same total by coincidence, so those figures read as the roster's and were not. The 8 diffs that leave the exposed count under the net test — `'Esq. van Gogh'` at all three baselines where it diffs, `'Dr. Do Van Johnson, MD'` at both 2.x, `'Aishwarya Rai'`, `'Jack M.A.'`, `'Jane van der Berg 旧姓 Jones'` — are exactly the diffs that sit ALONE on their rule (the 72 → 64 drop; the spec's population had 9, `'Carod i'` the ninth, its 71 → 62), but they do NOT account for all the alternatives #452 catches: they carry 24 of the 53 caught over the roster (26 of 55 over the spec's population), and the other 29 come from diffs whose rule explains other names that do not between them cover its `fields`, so the diff stays exposed while some of its alternatives are caught. Two figures are robust across all three populations, and they are what the argument rests on: the worst case, `'Ph. D., Jr.'` at 2.0.0 and 2.1.0, 30 alternatives against a five-field rule, all 30 surviving #452; and the 35 other-rule alternatives over the sole-watched rows, equally unpinned and equally silent, a handover nobody would see — the metric UNDERCOUNTS in that direction whatever it counts in the other. Both corrections leave the conclusion where it was. AMENDED 2026-09-05, later the same day: the #501 adjudication below moved six of these rows out of `_WATCHED_DIFFS`, so the population every figure in this bullet is over went from 113 rows to 107 and the roster figures above are a snapshot of the tree this arc's own commits left. Re-run by the RECOMPUTE below over the 107: 363 gross alternatives over 66 exposed diffs, 310 net of #452 over 58, 35 other-rule, with 105 / 96 / 23 at 1.4.0, 133 / 112 / 11 at 2.0.0, 125 / 102 / 1 at 2.1.0 and 0 / 0 / 0 at 2.2.0, and 8 diffs alone on their rule. Both of the figures the argument actually rests on reproduce unchanged, which is the point of having named them: `'Ph. D., Jr.'` at 2.0.0 and at 2.1.0 is still 30 alternatives against a five-field rule with all 30 surviving #452, and the other-rule count is still 35 — the two rows that left carried no other-rule alternative between them. RECOMPUTE: spy on `compare.classify` through a real `main()` run at each baseline, keep the default-order calls on the names keyed in `_WATCHED_DIFFS` for that ledger (the roster's own keys, not a scan of `tests/`; drop the four #501 names for the sole-watched figures), and for each enumerate the non-empty subsets of the winning rule's `fields`, sorting each by whether `classify()` routes it to the winner, to another rule, or to nothing; the #452-aware count applies the union test above, and a diff is "alone on its rule" when no other `(name, order)` call at that baseline classified to the same rule. +- **The spec's 385 / 71 was GROSS of #452 and measured on a population the arc had already retracted; the net figure over the roster is the one to argue from.** `over_declared_rules` requires a rule's `fields` to EQUAL the union of the diffs it explains, so on a rule explaining only one name any shrink fires OVER-DECLARED and is not silent at all. Measured 2026-09-05 over the 113 rows `_WATCHED_DIFFS` holds: 390 alternative shapes classify to the same rule and 72 diffs are exposed by that count; net of #452 — keeping only the subsets `S` for which the union of the rule's OTHER explained diffs at that baseline, joined with `S`, still equals the rule's `fields` — 337 alternatives over 64 exposed diffs, and 44 further alternatives route to a different rule. Per baseline the gross / net / other-rule split reads 105 / 96 / 23 at 1.4.0, 158 / 137 / 20 at 2.0.0, 127 / 104 / 1 at 2.1.0 and 0 / 0 / 0 at 2.2.0. Over the 109 sole-watched rows alone — the roster minus the four #501 contests, which is the population the label "sole-watched" names — 367 gross over 68 exposed diffs, 314 net over 60, 35 other-rule, the whole difference sitting at 2.0.0 where the four contested rows are. The spec's first draft measured 385 / 71 gross, 330 / 62 net and 35 other-rule, with 123 / 112 / 23 at 1.4.0, over the population the next bullet retracts: 52 names and 113 diffs, holding the four names with 1.4.0 contest rows (45 rows there, `'MD, PHD'` alone contributing 14 alternatives) and not the four #501 rows — the same total by coincidence, so those figures read as the roster's and were not. The 8 diffs that leave the exposed count under the net test — `'Esq. van Gogh'` at all three baselines where it diffs, `'Dr. Do Van Johnson, MD'` at both 2.x, `'Aishwarya Rai'`, `'Jack M.A.'`, `'Jane van der Berg 旧姓 Jones'` — are exactly the diffs that sit ALONE on their rule (the 72 → 64 drop; the spec's population had 9, `'Carod i'` the ninth, its 71 → 62), but they do NOT account for all the alternatives #452 catches: they carry 24 of the 53 caught over the roster (26 of 55 over the spec's population), and the other 29 come from diffs whose rule explains other names that do not between them cover its `fields`, so the diff stays exposed while some of its alternatives are caught. Two figures are robust across all three populations, and they are what the argument rests on: the worst case, `'Ph. D., Jr.'` at 2.0.0 and 2.1.0, 30 alternatives against a five-field rule, all 30 surviving #452; and the 35 other-rule alternatives over the sole-watched rows, equally unpinned and equally silent, a handover nobody would see — the metric UNDERCOUNTS in that direction whatever it counts in the other. Both corrections leave the conclusion where it was. AMENDED 2026-09-05, later the same day: the #501 adjudication below moved six of these rows out of `_WATCHED_DIFFS`, so the population every figure in this bullet is over went from 113 rows to 107 and the roster figures above are a snapshot of the tree this arc's own commits left. Re-run by the RECOMPUTE below over the 107: 363 gross alternatives over 66 exposed diffs, 310 net of #452 over 58, 35 other-rule, with 105 / 96 / 23 at 1.4.0, 133 / 112 / 11 at 2.0.0, 125 / 102 / 1 at 2.1.0 and 0 / 0 / 0 at 2.2.0, and 8 diffs alone on their rule. Both of the figures the argument actually rests on reproduce unchanged, which is the point of having named them: `'Ph. D., Jr.'` at 2.0.0 and at 2.1.0 is still 30 alternatives against a five-field rule with all 30 surviving #452, and the other-rule count is still 35 — the two rows that left carried no other-rule alternative between them. AMENDED again since #498, which moved five more 1.4.0 rows to `_RECORDED_DIFFS`: the roster holds 102 rows, 36 of them at 1.4.0, and re-running the RECOMPUTE below over those 102 (measured 2026-09-05) gives 337 gross alternatives over 61 exposed diffs, 284 net of #452 over 53, 35 other-rule, with 79 / 70 / 23 at 1.4.0 and 133 / 112 / 11, 125 / 102 / 1 and 0 / 0 / 0 unmoved at the three 2.x baselines, and 8 diffs alone on their rule — only the 1.4.0 line moves, since only 1.4.0 rows left. Both figures the argument rests on reproduce a third time: `'Ph. D., Jr.'` at 2.0.0 and at 2.1.0 is still 30 alternatives against a five-field rule with all 30 surviving #452, and the other-rule count is still 35. RECOMPUTE: spy on `compare.classify` through a real `main()` run at each baseline, keep the default-order calls on the names keyed in `_WATCHED_DIFFS` for that ledger (the roster's own keys, not a scan of `tests/`; drop the four #501 names for the sole-watched figures), and for each enumerate the non-empty subsets of the winning rule's `fields`, sorting each by whether `classify()` routes it to the winner, to another rule, or to nothing; the #452-aware count applies the union test above, and a diff is "alone on its rule" when no other `(name, order)` call at that baseline classified to the same rule. - **A "where is this named" population must include the tool.** The `tests/` scan cannot see `compare.py`, so four names carrying 1.4.0 contest rows — `'Bob Jones, author'`, `'Carod i'`, `'MD, PHD'`, `'van ma van'` — scored as sole-watched there, watched by exactly the mechanism the arc extends. The definition gained its last clause (not already keyed in `_RECORDED_DIFFS` for that ledger); three of the four return at a 2.x baseline where they have no contest row, and `'Carod i'` diffs under the default order at 1.4.0 ONLY, so it leaves the population entirely: 51 names, not the 52 the `tests/`-only scan gave, and 41 rows at 1.4.0 where the spec's first table said 45. (AMENDED 2026-09-05, later the same day: `'MD, PHD'` leaves entirely too once the #501 adjudication below pins its 2.x pair, since it then carries a contest row at every baseline it diffs at — so TWO of the four return at a 2.x baseline and two leave, and the population is 50 names against the same scan's 52. Re-measured by the recipe: 41 / 31 / 30 / 5 = 107 rows over 50 names, the scan's own 45 / 32 / 31 / 5 = 113 over 52 unchanged, and the roster is exactly the 50-name set.) One more clause the recipe carried and the definition did not until the design-docs review: explained by a ledger rule at that baseline — a diff no rule explains is already printed by every run as unclassified, so a rule is the only weak watcher a row is needed for; a definition clause and not a live count, since `radar unclassified` reads 0 at every baseline today and the two sets coincide. RECOMPUTE: the population recipe is in `_WATCHED_DIFFS`' header; print the names its last clause removes per ledger, and the four appear at 1.4.0 and none elsewhere. (Stale twice over by the end of 2026-09-05, and re-measured that day by exactly that recipe: the clause removes NINE at 1.4.0 — the four plus `'Bob Jones, compositeur'`, `'MD, DO, DDS'`, `'Smith, Ph. D. Jr. MD'`, `'Smith, Ph. D. MD'` and `'Smith, Ph.D. Jr.'`, the five #498 pinned — and `'MD, PHD'` again at 2.0.0 and at 2.1.0 since #501 pinned its 2.x pair, none at 2.2.0. Rows per ledger after the clause: 36 / 31 / 30 / 5.) - **`corpus_shapes.jsonl`'s table row counted ENTRIES where the population counts NAMES.** A `(name, order)` pair is an entry, so a name compared under three orders is three, while the population is names diffing under the default order. Measured 2026-09-05 over the four baselines: 14 distinct names diff there, 11 under the default order, and the other three — `'de Mesnil Jean, Dr.'`, `'de la Cruz Juan Carlos, Dr.'`, `'de la Cruz née Vega'` — diff only under a declared order, the same trio that is the `NOT CHECKED` window at 1.4.0. The spec's row read "16 entries"; by the recipe here — distinct `(name, order)` pairs on which `classify()` was called across the four baselines — the count is 18, and the basis for 16 was not recorded, so 16 is not carried. Names and default-order names are the figures the population argument rests on, and both reproduce. RECOMPUTE with the same spy, restricted to that file's names, counting pairs, names, and names with `order is None`. - **The per-file split is not a partition.** 49 of the 51 sole-watched names sit in `corpus_issues.jsonl` and 3 in `corpus.jsonl`, with `'dr Vincent van Gogh dr'` in both, so the per-file counts overlap by one and 49 + 3 reads 52 for a 51-name population. (AMENDED 2026-09-05, later the same day: 48 and 3 over the 50 the #501 adjudication below leaves, the same name overlapping, so 48 + 3 reads 51 for a 50-name population — the shape of the finding, which is that the split is not a partition, is what survives the digits.) RECOMPUTE from the population script's JSON against `_load_entries` over the two radar files. @@ -887,6 +887,40 @@ Declined: The measurement, and how to redo it. `_WATCHED_DIFFS` reads 41 / 31 / 30 / 5 = 107 rows over 50 distinct names, and `_RECORDED_DIFFS` 31 / 5 / 1 / 0 = 37 — since #498, 36 / 31 / 30 / 5 = 102 and 45 / 5 / 1 / 0 = 51, over the same 50 names; the roster and the sole-watched population the arc above defines are now exactly the same set, the six contested rows having left. The `tests/`-only scan still says 52 names — 113 rows, 45 / 32 / 31 / 5 — and the two it counts that the roster does not are `'Carod i'` and, since this arc, `'MD, PHD'`, both carrying a contest row at every baseline they diff at. 48 of the 50 sit in `corpus_issues.jsonl` and 3 in `corpus.jsonl`, overlapping on `'dr Vincent van Gogh dr'`, so the per-file counts are not a partition. THREE mutation controls, applied in memory around the guard and one row at a time so that a failure on one cannot mask the rest: (i) the glued and spaced rules swapped at 2.0.0 fails on both `様.` names; (ii) the two `fix(#296)` credential rules swapped fails on `'MD, PHD'` at BOTH 2.x baselines; (iii) the pre-narrowing wide regex restored AND the CJK order rule swapped with the glued rule at 2.0.0 fails on `'김민준 박사님'` and `'선생님'`. Every one of the six rows is moved by some control and each control leaves `unexplained` at 0, so the gate's summary line reports none of them; the first lines of all six failing assertions are quoted in the per-verdict bullets above and in `ddd8b2b`'s message. The arc moved no classification: 352 / 247 / 155 / 14 intentional diffs, 0 unexplained and 0 radar unclassified, exit 0, no `MOVED SHAPE` and no `NOT CHECKED`, measured at `ddd8b2b` this session and recorded at every commit of the branch by its own message — the last of them, the review round, at 1.4.0 and 2.0.0 alone. RECOMPUTE, all of it from one instrumented run: spy on `compare.classify` and `compare._run_worker` through a real `main()` at each of the four baselines, keeping `(name, order, diff, winner, admitters)` per call and the worker's rows per entry — the admitters being the rules satisfying `compare._entry_matches` at the measured shape, which is what makes a contest visible; then the six verdicts' old values read off the worker rows and their new ones off `HumanName` and `_tree_parse` as `main()` computes them, the row counts off the two dicts, the population off the script in `_WATCHED_DIFFS`' header, the reach change from the two regexes over the `corpus*.jsonl` union, `order_contests(rules, names)` per ledger for the unchanged 11 / 0 / 0 / 0, and every control by re-classifying that run's own calls against a mutated copy of the ledger's rules. Do NOT replay the corpus load by hand: the `(name, order)` dedup, the baseline-minimum skip and the tier stamp all happen inside `main()`. +### differential-ledger, the #498 completeness check (2026-09-05) + +The tenth ledger arc, and the one that closes the class the rule-order arc measured and left open on 2026-09-02. `undeclared_contests` (#382) refuses a ledger where a WIDER rule sits ahead of a NARROWER one it reaches a corpus name with, unless the wider rule declares why; nesting is sufficient for an order-decided contest and not necessary, since two rules whose `fields` merely INTERSECT — or are EQUAL — both admit any diff inside the intersection, `classify()` returns the first, and that check looks past the pair in either arrangement. That arc says `_CROSS_RULE_WINNERS` "stays the instrument" there. Nothing checked that the instrument had been applied, and measured 2026-09-05 it had not been: fourteen contested diffs at 1.4.0 had no owner anywhere in the tree. All fourteen are adjudicated and pinned, and `compare.unowned_contests` now fails a gate run that measures a fifteenth. Mechanics — the two rosters and their contracts, the predicate, the message and its severity — are owned by `tools/differential/compare.py` (`unowned_contests`' docstring and `_RECORDED_DIFFS`' header), by `tests/v2/test_ledger_guards.py` (`_CROSS_RULE_WINNERS`' per-pair comments) and by tools/differential/README.md; these are the decisions. Every figure below was measured 2026-09-05 from real `main()` runs at all four baselines with `classify()` and `_run_worker` spied, and each carries its recipe. Closes [#498](https://github.com/derek73/python-nameparser/issues/498). + +The population, per baseline, at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0: diffs two or more rules admit, 47 / 3 / 1 / 0; of those already pinned, 18 / 3 / 1 / 0; owed a pin — the winner outranking some loser by neither narrow-first nesting nor a declaration, and no row for the name — 14 / 0 / 0 / 0. The three 2.x ledgers were complete before this arc; the whole of the debt sat at 1.4.0. + +Decisions that landed: + +- 2026-09-05 #498 — a contest a run MEASURES, whose winner outranks a loser by neither narrow-first nesting nor a `precedes_narrower` declaration, must be pinned in `compare._RECORDED_DIFFS` and `_CROSS_RULE_WINNERS`, and `compare.unowned_contests` fails the gate otherwise. RUN-TIME by necessity, and no static sibling exists to write: a pin is a shape beside a winner and the shape is what a run measured, so without a diff the population is the static reach of every pair sharing a corpus name whose `fields` are equal or merely overlap, which is 60 overlapping pairs and 11 more with equal `fields`, 71 in all — and demanding a pin for each is the roster nobody writes, which is the answer this file already gives about the 657 and 111 figures. With a diff it is 14. The 71 is the equal-or-overlap class the check's predicate names, and it is the 2026-09-02 bullet's own five figures re-measured 2026-09-05 by that bullet's recipe — `_rule_reach` per ledger over the `corpus*.jsonl` union, 1116 distinct names — reading 11 wide-first nested, 40 nested either way, 11 equal and 111 intersecting, so 60 overlap without nesting or equality and 71 are equal or overlapping. Every "60 pairs" in this file and in tools/differential/README.md is that overlap-only figure; the population this check would need statically is the 71. FATAL on both tiers, in the exit expression beside `shape_bad`: a contest row is fatal on either tier because it carries an argument, and an owed row because it carries none yet. Scoping it to contract tier was declined 2026-09-02 for the roster this completes and the reason still holds — the roster already pins radar names, 32 of the 45 rows at 1.4.0. A `_WATCHED_DIFFS` row does NOT justify a contest, which is why the check takes one roster and not the union: that roster records a shape with NO WINNER, and which rule should win is the whole of what a contest asks. What makes "a key in `_RECORDED_DIFFS`" mean "a winner is pinned" is the `set(winners) == set(shapes)` equality in tests/v2/test_ledger_guards.py, held in both directions — a borrowing of the same kind `_declared_over` makes of `validate_rules`, and stated in the docstring rather than assumed. NO VACANCY HALF, unlike `vacant_exemptions` beside the static check, and the asymmetry is deliberate: a declaration whose pair stopped being a contest justifies a hazard that is gone and reads exactly like a live one, where a pin whose contest dissolves goes on saying what the name diffs to and which rule takes it — which is what 13 of the 45 rows at 1.4.0 already do with a single admitter apiece (mechanisms.md#RECORDED-ROSTERS: the test for a recorded row is not "is it exercised" but "would its consumer answer differently if it were wrong"). So nothing inverts under a subset either: under `--corpus` the population is the entries the run loaded, so the check UNDER-reports and never false-alarms, and `full_corpus` is not read. Two limits, real and accepted: one ledger per invocation and only when the gate runs; and a `_RECORDED_DIFFS` row is a DEFAULT-order shape, so a contest measured under a declared order would be reported and no row could absorb it. None exists on any shipped ledger today — every one of the fourteen is order-None — and the message names that limit only when a reported row carries an order, the way `NOT CHECKED` does. +- 2026-09-05 #498 — the predicate STOPS at this class, and the wider one is measured before it is declined. A pin is owed where the winner beats a loser whose `fields` are EQUAL to or OVERLAP (neither nesting) the winner's. Narrow-first stays the declaration-free default #382 set, and a declared wide-first pair stays covered by its `why`. The wider predicate — a pin for every undeclared contest, narrow-first ones included — costs 9 more names at 1.4.0, 5 contract and 4 radar, 23 in all, and the case FOR it is real: it would have forced the argument that #501's regex accident lacked, on a contract-tier name whose winner's prose was false of it. Declined as a pin the default already justifies, and the cost is recorded rather than argued away: the gap #501 called (ii) — a regex accident in a narrow-first or non-nested pair having no declaration site — stays OPEN BY CHOICE. What #498 changes about it is the non-nested half alone, and by a pin rather than a declaration site. RECOMPUTE: drop the narrow-first clause from the loop below; 23 names at 1.4.0 over 24 name-pair rows, the fourteen this arc pinned among them (measured 2026-09-05, with the fourteen removed from the pinned set so the run reads the tree as this arc opened it). +- 2026-09-05 #498 — the COVERAGE argument, and what the 2026-09-02 decline actually declined. That bullet declined REPLACING the static predicate with a run-time one, on coverage alone: a run sees one ledger — the one for the baseline it was invoked with — and only once somebody invokes the tool, where a static predicate covers a rule from the moment it is written, including a rule nobody has ever run the wheel against. It is unchanged, and this check does not touch it. This ADDS: the static check covers the hazard that has a declaration site, this covers the hazard that has none, and between them every measured contest has an owner. Nothing in this entry rests on what a run COSTS: the "multi-minute run" phrase was withdrawn 2026-09-03, a whole gate run is a fraction of a second, and the reason this check is run-time is that a pin needs a measured shape — there is no static form of the question, which is a different claim from the one speed was ever used to make. +- 2026-09-05 #498 — `'Smith, Ph. D. III'`, `'Smith, Ph. D. Jr.'`, `'Smith, Ph. D. Jr. MD'`, `'Smith, Ph. D. MD'`, `'Smith, Ph.D. Jr.'` and `'Smith, PhD Jr.'` → `fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes`, over `fix(comma-family) lone post-comma piece routes to suffix/title, not first`, at 1.4.0. EQUAL `fields` — both declare `{given, title, suffix}` — so `fields` separates the two not at all and file order is the whole decision (sorted indices 10 and 12). Measured against the 1.4.0 wheel, the wheel splits the post-comma run across `title`, `given` and `suffix` and the tree puts the whole of it in `suffix`, whatever the wheel held in `title` or `given` emptying into it: `'Smith, Ph. D. Jr.'` reads title `'Jr.'` → `''` and suffix `'Ph. D.'` → `'Ph. D. Jr.'`; `'Smith, Ph. D. III'` given `'III'` → `''`, suffix → `'Ph. D. III'`; `'Smith, Ph. D. Jr. MD'` title `'Jr.'` → `''`, given `'MD'` → `''`, suffix → `'Ph. D. Jr. MD'`; `'Smith, Ph. D. MD'` title `'MD'` → `''`, suffix → `'Ph. D. MD'`; `'Smith, Ph.D. Jr.'` given `'Ph.D.'` → `''`, suffix `'Jr.'` → `'Ph.D. Jr.'`; `'Smith, PhD Jr.'` title `'PhD'` → `''`, given `'Jr.'` → `''`, suffix `''` → `'PhD Jr.'`. Family is `'Smith'` on both sides of all six. The prose test lands in the LOSER's own terms: the winner's comment says the credential run "was no longer the LONE post-comma piece", and the loser's prose is about a single post-comma piece (`'Smith, Dr.'`, `'Andrews, M.D.'`), which none of the six is. `'Smith, PhD Jr.'` is a REACH and not a fit, its credential unsplit, and the winning rule's comment names it separately as one "which the regex reaches too" — a documented reach whose alternative describes nothing is not the regex accident #372 refuses. Two halves of that same comment disagree with the measurement and are recorded in the row's comment in tests/v2/test_ledger_guards.py rather than repaired here: it renders the new suffix `'PhD, Jr.'`, with a comma no run produces, and says "title 'PhD' at every baseline" where the 1.4.0 title moves to `''`. What the pin buys, measured: moving the loser ahead hands all six over and moves 7 of the 352 classifications — the six plus `'Smith, Jr., PhD'` as collateral — and `fields` being equal, the reorder is the whole of THAT edit; dropping `suffix` from the winner's `fields` hands over all six on its own. +- 2026-09-05 #498 — `'マイケル・ジャクソン'`, `'威廉・莎士比亚'` and `'高橋・一郎'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, over `fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip`, at 1.4.0. The pair #498 was filed on: `{family, given, middle}` against `{family, given, nickname}`, intersecting in `{family, given}` with neither containing the other (sorted indices 0 and 30). Measured against the 1.4.0 wheel all three diff exactly `{family, given}` — `'マイケル・ジャクソン'` reads first `'マイケル・ジャクソン'`, last `''` on the wheel and given `'マイケル'`, family `'ジャクソン'` on the tree; `'威廉・莎士比亚'` given `'莎士比亚'`, family `'威廉'`; `'高橋・一郎'` given `'一郎'`, family `'高橋'` — and `nickname` is EMPTY on both sides of all three. The loser's subject is a name doing two things at once, "the corner-bracket/nakaguro handling ... changes `nickname`, while the same name's wholly-CJK remainder takes the 2.1 family-first flip", and the half it is named for did not fire here, so its prose describes NONE of this diff where the winner's describes the whole of it and names `'マイケル・ジャクソン'` among its own examples. All three are contract tier. What the pin buys, measured: moving the loser ahead hands all three over and moves 3 of the 352 classifications, no collateral. A `fields` narrowing is NOT the same edit and is not an alternative to it: dropping `given` or `family` from the winner hands the three over and moves 43 of the 352 — the three to the nickname rule, three to `fix(cjk-honorific-suffix)`, two to `fix(cjk-comma-compound)` and THIRTY-FIVE to no rule at all, every one of the 35 a contract-tier name (31 loaded from `corpus_cjk.jsonl` and 4 from `corpus_rules.jsonl`, read the way `main()` stamps a name's file, contract files first; nine of the 35 sit in more than one contract corpus), which is an `unexplained` gate failure rather than a reattribution. Read that as the reason the reorder is the only single edit whose cost is countable in these three names (measured 2026-09-05 by the recipe below, the same one with a role dropped instead of a rule moved). The loser's own positional note says it "sits after the tighter single-change rules on purpose" — a ledger comment's adjudication, which nothing in the tree could check until this row. +- 2026-09-05 #498 — `'Dr 김민준, Jr.'` and `'田中さん, Dr.'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, over `fix(cjk-comma-compound) comma routing compounds with the CJK order flip`, at 1.4.0. `{family, given, middle}` against `{family, given, title, suffix}`, intersecting in `{family, given}` without nesting (sorted indices 0 and 33). Measured against the wheel, `'Dr 김민준, Jr.'` reads first `'김민준'`, last `''` and the tree given `'민준'`, family `'김'`, with title `'Dr'` and suffix `'Jr.'` byte-identical on both sides; `'田中さん, Dr.'` reads first `'田中さん'`, last `''` and the tree given `''`, family `'田中さん'`, title `'Dr.'` unmoved. Both diff `{family, given}` — the hangul segmentation and the family-first flip, the two mechanisms the winner's prose names outright — and the comma routing the loser is named for moved nothing. The loser's own disposition table does NOT concede this shape by name: its one row naming `'田中さん, Dr.'` sends the name to `fix(#296)` under `{middle, suffix}`, a different shape from the `{family, given}` measured here, and the row that does concede to the winner names no name — it is `{given, middle, family} all 23 -> fix(#271/#272/#298), the order flip with no comma routing …` — and carries `middle`, which neither name moves. The measurement is what settles the pair, not the table. What the pin buys, measured: moving the loser ahead hands both over and moves 9 of the 352 — the two, plus seven collateral names off `fix(cjk-comma-honorific-peel) glued honorific peels off a post-comma given name`; dropping `given` or `family` from the winner also hands both over — to `fix(cjk-comma-compound)`, this pair's loser — but at the price the nakaguro bullet above measures, since it is the same rule and the same two drops: 43 moved and 35 of them UNEXPLAINED. So here too the reorder is the edit whose cost stays inside this ledger's explained set. +- 2026-09-05 #498 — `'田中さん, Dr.'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, over `fix(cjk-glued-honorific-peel) glued honorific peels into suffix`, at 1.4.0: the SECOND unowned pair on one name, and the reason fifteen rows sit on fourteen names. `{family, given, middle}` against `{family, given, suffix}`, intersecting in `{family, given}` without nesting (sorted indices 0 and 34). Measured, `suffix` is `''` on both sides and the tree's family is the whole token `'田中さん'`: the glued さん is NOT peeled, where the peel this loser is named for would have taken family to `'田中'` and put さん into `suffix` — which is what does happen to `'田中さん, 様.'` in the next bullet, so the contrast between a mechanism that fired and one that did not sits inside this arc's own measurements. Its stated scope excludes the string anyway, the rule being written for a name with "no space before the honorific and no comma anywhere", and this name has a comma. What the pin buys, measured: moving the loser ahead hands the name over and moves 15 of the 352, much the widest of the seven pairs, the peel rule then also taking names off `fix(cjk-comma-compound)` and `fix(cjk-comma-honorific-peel)`. A `fields` narrowing does NOT buy this pair: dropping `given` or `family` from the winner sends the name to `fix(cjk-comma-compound)` and not to this loser, so a reorder is the only single edit that hands it over. +- 2026-09-05 #498 — `'田中さん, 様.'` → `fix(cjk-comma-compound) comma routing compounds with the CJK order flip`, over `fix(cjk-honorific-suffix) postnominal honorifics recognized, compounding with the CJK order flip`, at 1.4.0. `{family, given, title, suffix}` against `{family, given, middle, suffix}`, intersecting in `{family, given, suffix}` with `title` and `middle` keeping either from containing the other (sorted indices 33 and 35). Measured against the wheel: first `'様.'` → given `''`, family `'田中さん'` → `'田中'`, suffix `''` → `'さん, 様.'`, a diff of `{family, given, suffix}`. A genuine TWO-MECHANISM compound, and both halves are legible in the values — the glued さん peels off `田中さん` while the post-comma `様.` leaves `first` for `suffix`, the two arriving there together. The winner describes the comma half; the other half belongs to `fix(cjk-glued-honorific-peel)`, which is nested inside the winner and DECLARED over by a `precedes_narrower` block, so that half of this name's contest is justified where it stands and only the honorific-suffix pair is owed a row. That is the partial label #382 accepts, with every half of the compound owned between the two rules. The loser is the weaker candidate for the same half on its own terms: its prose is about a SPACED trailing honorific whose remainder takes an order flip, it disclaims the glued forms outright — "GLUED diffs (田中さん, 김민준씨, and the 김씨 that segmentation already reached) never match this rule" — and it says nothing about a comma, where the comma is the winner's subject; measured, no `middle` moves and no order flip occurs, `given` going empty rather than gaining a name. What the pin buys, measured: moving the loser ahead hands the name over and moves 2 of the 352 — this name, which comes off `fix(cjk-comma-compound)`, this bullet's winner, and `'田中さん 様.'`, which is the one that comes off the glued rule; a `fields` narrowing buys nothing here either, dropping `given`, `family` or `suffix` from the winner sending the name to the glued rule, so the reorder is again the only single edit. (The 2.0.0 section of `_CROSS_RULE_WINNERS` pins this same string to the GLUED rule. That is a different ledger with a different rule set, where the pair nests narrow-first and no declaration is owed — the #501 adjudication above — and it does not contradict this 1.4.0 verdict.) +- 2026-09-05 #498 — `'Bob Jones, compositeur'` → `fix(comma-family) a comma followed only by titles keeps the given/family split`, over `fix(comma-precomma-family) pre-comma run reads as family, not given`, at 1.4.0. EQUAL `fields`, both `{given, family}`, so file order is the whole decision (sorted indices 5 and 13). Measured against the wheel: first `'Bob Jones'` → given `'Bob'`, last `''` → family `'Jones'`, and title `'compositeur'` on BOTH sides — diff `{family, given}`. The winner's prose names the string and describes those values to the token, "the pre-comma name keeps its positional read -- first 'Bob Jones' -> given 'Bob', family 'Jones', the title staying where it was"; the loser's stated behavior, the pre-comma run reading as family, is half true at most here, `'Jones'` becoming the family and `'Bob'` not, the run not moving whole. The loser's own comment already concedes the sibling string `'Bob Jones, author'` to the winner on exactly that reasoning, and this row records the same for `compositeur`, which the winner's regex reaches by the same literal. It is also why a pin is spelled with the FULL issue string: three rules in this ledger begin `fix(comma-family)` (measured 2026-09-05 with tomllib over the ledger's `[[change]]` issues). What the pin buys, measured: moving the loser ahead moves 3 of the 352 — this name plus `'Bob Jones, author'` and `'John Smith, Mr.'`, and the three do not come off one rule: this name and `'Bob Jones, author'` come off the WINNER, and `'John Smith, Mr.'` off a third rule whose issue extends the winner's, `fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example`. The lone-post-comma rule, the third of the three rules whose issue begins `fix(comma-family)`, loses nothing here — which is the full-issue-string point made in measured form rather than by counting rules that share a prefix. Dropping `given` or `family` from the winner hands this name over on its own, moving 2 (this name and `'Bob Jones, author'`) with nothing left unexplained. +- 2026-09-05 #498 — `'MD, DO, DDS'` → `fix(#296) a dropped prenominal takes the name position it occupies`, over `fix(comma-family) lone post-comma piece routes to suffix/title, not first`, at 1.4.0. `{title, given, middle}` against `{given, title, suffix}`, intersecting in `{given, title}` — which is the measured diff — with neither containing the other (sorted indices 7 and 12). Measured against the wheel: title `'DO'` → `''` and first `''` → given `'DO'`, with family `'MD'` and suffix `'DDS'` byte-identical on both sides. The winner names the string and states its diff verbatim, "the rule's second name is 'MD, DO, DDS': title 'DO' -> given 'DO', the comma anchor's one corpus hit", which is the whole of what moved. The loser is about a one-word NAME followed by a comma and a lone credential; this string holds no name at all and has two post-comma pieces, and its direction is wrong as well — the loser routes a piece OUT of `first` where this name routes one IN. What the pin buys, measured: moving the loser ahead moves 8 of the 352 — this name, all six of the `fix(#325)` pair above, and `'Smith, Jr., PhD'`, since a move above index 7 necessarily passes `fix(#325)` at index 10 — so the reorder is the strictly larger edit here. The `fields` narrowing is smaller and still not an alternative: dropping `given` or `title` from the winner moves 2, handing `'MD, DO, DDS'` to this loser and sending `'Do Quang Minh'` — a radar-tier `corpus_issues.jsonl` name the winner is the sole explainer of — to NO rule, so radar unclassified goes from 0 to 1. Both edits cost something the pin does not; the pin is what records which rule is right without paying either. + +Found rather than decided, and worth as much: + +- **The class is measured in NAMES, not pairs, and the issue's framing had it in pairs.** 60 pairs overlap without nesting and 11 more have equal `fields`, each sharing a corpus name; measured with a diff in hand, 14 names have one that two such rules admit, over seven of those pairs. The static count over-reports for the reason #382's nine-of-eleven finding gave — most co-matched names' real diff needs a role the other rule does not declare — so the rule-order arc's objection to a widened STATIC predicate, that it would demand 111 justifications where the real number is eleven, does not reach a MEASURED one. That is the whole of why option 3 was cheaper than it read. +- **Seven of the fourteen sit in an EQUAL-`fields` pair — the clearest case of the class the docs name `_CROSS_RULE_WINNERS` the instrument for — and not one of the seven was pinned.** Two pairs, and in both `fields` separates the rules not at all and file order is the whole decision: `fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes` ahead of `fix(comma-family) lone post-comma piece routes to suffix/title, not first` over six names, and `fix(comma-family) a comma followed only by titles keeps the given/family split` ahead of `fix(comma-precomma-family) pre-comma run reads as family, not given` over `'Bob Jones, compositeur'`. Equal `fields` is the special case the 2026-09-02 entry reasons FROM — the class it leaves to the instrument is every pair where neither set contains the other, and the other seven names sit in that wider half, over eight of the fifteen rows. The instrument was named there and checked by nothing until this arc, and its clearest cases were the unowned ones. +- **Fourteen names, SEVEN (winner, loser) pairs, fifteen (name, pair) rows — and a first draft counted five, by grouping the rows by WINNER.** `fix(#271/#272/#298)` heads three distinct pairs, against the delimited-nickname rule, the comma-compound rule and the glued-peel rule, which collapse to one under a by-winner count; and `'田中さん, Dr.'` is unowned against TWO losers, so it carries two of the fifteen rows and appears under two of the verdict bullets above. Seven pairs over fifteen rows is what the sweep reports. The fourteen is a count of NAMES, which is the unit the pins are written in — one row per name in each roster, whatever the number of losers behind it — and the three figures answer three different questions rather than disagreeing. + +Declined: + +- The wider narrow-first predicate (2026-09-05, the scope decision above) — 9 more names at 1.4.0 and 23 in all, declined as a pin the declaration-free default already justifies. +- A `precedes_narrower`-shaped per-pair declaration for non-nested pairs (2026-09-05, the issue's option 1) — the roster is per-name and already carries the argument, and a second key would let a PAIR be declared without any name's shape being measured. That is the #501 adjudication's own finding one level down: an argument nothing can check, given a key of its own. +- A static sibling of the check (2026-09-05) — there is none to write, for the reason the first decision above gives: without a diff the population is the 71-pair reach — 60 overlapping pairs and 11 with equal `fields` — and the roster is unwritable. +- Reordering any of the seven pairs (2026-09-05) — a reorder moves which rule classifies a name, which is a behavior change to the ledger and breaks `_CROSS_RULE_WINNERS`; every current winner is argued where it sits, and the seven verdict bullets above are that argument, name by name. A wrong winner would have been closed by a narrowing or a declared reorder and not by a pin; the adjudication found none. + +The measurement, and how to redo it. `_RECORDED_DIFFS` reads 45 / 5 / 1 / 0 = 51 rows at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0 and `_WATCHED_DIFFS` 36 / 31 / 30 / 5 = 102 over the same 50 distinct names — five names moved rosters at 1.4.0 and stayed watched at both 2.x baselines, where each has one admitter and no contest to own, so only the 1.4.0 row count fell and the population did not move. 32 of the 45 rows at 1.4.0 sit on radar-tier names and 13 have a single admitter at their recorded shape; none of the fourteen joins those 13, every one having two or more admitters at its recorded shape by the definition of the sweep that found it. THE POPULATION FIGURES, recomputed: drive `main()` at each baseline with `classify()` wrapped, keep the calls whose issue is not None, and for each list the rules satisfying `compare._entry_matches` at that call's measured shape and order — fewer than two admitters is no contest; for each admitter after the first, the winner's `fields` a strict subset of the loser's is narrow-first and justified, the loser's a strict subset of the winner's WITH the loser's issue in `compare._declared_over(winner)` is declared and justified, and anything else is owed a pin unless the name is a key of `_RECORDED_DIFFS` for that ledger. That gives 47 / 3 / 1 / 0 contested, 18 / 3 / 1 / 0 pinned and 14 / 0 / 0 / 0 owed on the tree this arc opened on, and 0 owed at every baseline since. Do NOT replay the corpus load by hand: the `(name, order)` dedup, the baseline-minimum skip and the tier stamp all happen inside `main()`, so a row's tier is read as `main()` reads it — the default-order entry's where there is one, the first-loaded entry's otherwise — by wrapping `_run_worker` for the entries it was handed. THE VERDICTS' values come off that same run — the old side from the worker's rows for the entries `main()` was handed, the new side from `HumanName` and `_tree_parse` as `main()` computes them — and only the facade is compared at a pre-2.0 baseline, `_surfaces_for` returning `{"facade"}` below 2.0.0, so every old and new value in the bullets above is `HumanName`'s. THE "WHAT THE PIN BUYS" FIGURES are measured the same way and their recipe sits beside the rows in tests/v2/test_ledger_guards.py: move the loser above the winner in a COPY of the run's own sorted rule list and re-classify the run's own 352 classified calls, counting the labels that change; the `fields` readings are that recipe with one role dropped from the winner instead of a rule moved, and nothing on disk moves for either. MUTATION CONTROL, in memory around `main()` at 1.4.0 with the worktree untouched: `'Smith, Ph. D. Jr.'` deleted from `_RECORDED_DIFFS['expected_since_1.4.0.toml']` — the contract-tier row of the six-name equal-`fields` pair — makes the run exit 1 and print `UNPINNED CONTEST expected_since_1.4.0.toml: 1 contested pair(s), one per name and losing rule, that no rule outranks by nesting or declaration and no row pins`, naming the name under its winner as `'Smith, Ph. D. Jr.' [contract] ['suffix', 'title']` with the reason "equal `fields`, file order the whole decision". Unmutated, no `UNPINNED CONTEST` block prints at any of the four baselines; a corruption that changed nothing would have meant an inert check, and this one did not. The arc moved no classification: 352 / 247 / 155 / 14 intentional diffs, 0 unexplained and 0 radar unclassified, exit 0, summary lines byte-identical at every commit of the branch — a pin moves no classification, and neither does a check that fires on nothing. RECOMPUTE the row counts with `{k: len(v) for k, v in compare._RECORDED_DIFFS.items()}` and the same over `_WATCHED_DIFFS`; the radar and one-runner splits by taking each row's tier as above and classifying it at its recorded shape against `_sorted_rules`. + ### comma-suffix-arc — #291/#296/#316 (2026-07-26 → 2026-08-01) #291 was filed 2026-07-26 out of the 2.0 vocabulary cleanup, with diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index 4f6d2727..e79cca75 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -77,7 +77,7 @@ Problem shape. A guard needs to know what the answer WAS, so it can detect the a ## LEDGER-RULE-SEPARATION — file order decides, fields narrow by subset -Problem shape. Two differential-ledger rules claim overlapping names. Contract statement. Every ledger rule must carry a `name_regex` — since #451 `validate_rules` REJECTS a rule with `fields` and no `name_regex`, and one with neither was already rejected — so every rule sits in ONE tier, the sort is stable, and FILE ORDER decides every contest. Narrow-first is the declaration-free DEFAULT, not the contract: a wider rule can be the better classifier where it describes a compound behavior its component rule does not — `马丁·路德·金씨` divides on the nakaguro AND peels its glued honorific, so `fix(#272/#308)` describes it and `fix(cjk-glued-honorific-peel)` describes half of it — which makes `fields`-subset a proxy for specificity and the wrong one there. What IS the contract is that such a pair must be DECLARED: the earlier rule carries a `precedes_narrower` block naming the later one and saying why, and `undeclared_contests` refuses the ledger otherwise (#382). `fields` narrows a rule by subset; it does not separate rules by sorting. Narrowing by subset is not the whole contract: since #452 a rule's `fields` must EQUAL the union of the diffs it explains, and `compare.py` reports OVER-DECLARED and exits non-zero otherwise — a declared role no diff moves is not inert, it lets the rule keep claiming a name whose diff SHRINKS into the excess (decisions.md#differential-ledger). Since #468 there is a THIRD narrowing key: `orders` admits only the comparison orders it lists, the key being optional and its absence the order-blind reading every earlier rule has — a name compared under two orders can move the same roles for opposite reasons, so a rule describing an order-scoped fold would otherwise absorb that fold leaking into the default order (decisions.md#differential-ledger carries the worked case, and the legal set is borrowed from tools/differential/shapes.py rather than copied — plus one member no shape can declare, the `DEFAULT` sentinel naming the comparison run under no declared order, TOML having no null to put in an array). Exclusions take no `orders` and stay order-blind, deliberately. The ban ends the SHAPE and not the property it enabled: a required `name_regex` bounds nothing by itself, since the only width check is the sentinel probe — measured, `[a-z]` validates and reaches 970 of 1120 comparisons (2026-09-01, re-measured the same day after #486 widened the shapes corpus; it read 963 of 1113 before that). What changed is that such a rule now carries a `_CORPUS_CLAIMS` reach and digest, so its breadth is visible once at recording time rather than never (#452). The two-tier sort in `_sorted_rules` is KEPT although the ban makes it the identity on every ledger that loads (four ledgers load today, measured 2026-09-02; the open cycle's carries one rule, `fix(#462)`, and it carries a `name_regex` like every other, so the identity holds there for the same reason and not for want of rules): it is the defence for a reader that does not call `validate_rules` first — a future tool, a REPL, a test fixture — and its docstring in tools/differential/compare.py says so. How it works. Detail is owned by tools/differential/README.md. The file-order clause is measured, not theoretical: in the 1.4 ledger the comma-honorific-peel rule's fields are a strict subset of the comma-compound rule's, both carry a name_regex, and a pure reorder reattributes seven names — caught by _CROSS_RULE_WINNERS and by nothing else in the suite (#375's mutation). That pair is narrow-first and so declares nothing, and #382 settled what to do about it by declining BOTH predicates on offer. Narrowing the peel rule's own `name_regex` to the honorific-bearing shapes (#382 option 1) cannot make the pair order-independent at all, which is measured and not argued: the two rules ship an IDENTICAL `name_regex`, each rule's reach is computed from its own pattern, and the peel rule's `fields` nest inside the compound rule's — so the compound rule goes on reaching those names and goes on admitting their diffs whatever the peel rule's regex is narrowed to, and it would grow the `_HONORIFIC_SOURCES` sync roster for nothing. What holds the pair is what the arc kept: narrow-first order, with the seven names whose diff both rules admit pinned by name in `_CROSS_RULE_WINNERS`. A mechanical narrow-first sort (option 3) was refused on a false premise: it reattributes names to a rule describing half of what happens to them. So order separates this pair, by the declaration-free narrow-first default, and it is the WIDE-first pairs that must declare themselves (the rule-order arc under decisions.md#differential-ledger). What that check covers is NESTED pairs: two rules whose `fields` merely INTERSECT are decided by file order too, and sit outside it by the same reasoning that leaves EQUAL `fields` outside — neither rule is narrower, so there is nothing for a `precedes_narrower` block to name — with #498 carrying the worked case and the measured size of the class. A REGEX ACCIDENT — a rule reaching a name through its pattern rather than by describing it — has no declaration site at all when it lands in a narrow-first or a non-nested pair, `precedes_narrower` sitting on the wide-first rule by construction, so nothing can refuse it and nothing can require a `why`; the repair is to narrow the accident away until the pair stops being a contest, which is what #501 did to the glued-peel `name_regex` after it put a FALSE label on a contract-tier name and stayed green (decisions.md, the #501 adjudication). The old #271/#272 +Problem shape. Two differential-ledger rules claim overlapping names. Contract statement. Every ledger rule must carry a `name_regex` — since #451 `validate_rules` REJECTS a rule with `fields` and no `name_regex`, and one with neither was already rejected — so every rule sits in ONE tier, the sort is stable, and FILE ORDER decides every contest. Narrow-first is the declaration-free DEFAULT, not the contract: a wider rule can be the better classifier where it describes a compound behavior its component rule does not — `马丁·路德·金씨` divides on the nakaguro AND peels its glued honorific, so `fix(#272/#308)` describes it and `fix(cjk-glued-honorific-peel)` describes half of it — which makes `fields`-subset a proxy for specificity and the wrong one there. What IS the contract is that such a pair must be DECLARED: the earlier rule carries a `precedes_narrower` block naming the later one and saying why, and `undeclared_contests` refuses the ledger otherwise (#382). `fields` narrows a rule by subset; it does not separate rules by sorting. Narrowing by subset is not the whole contract: since #452 a rule's `fields` must EQUAL the union of the diffs it explains, and `compare.py` reports OVER-DECLARED and exits non-zero otherwise — a declared role no diff moves is not inert, it lets the rule keep claiming a name whose diff SHRINKS into the excess (decisions.md#differential-ledger). Since #468 there is a THIRD narrowing key: `orders` admits only the comparison orders it lists, the key being optional and its absence the order-blind reading every earlier rule has — a name compared under two orders can move the same roles for opposite reasons, so a rule describing an order-scoped fold would otherwise absorb that fold leaking into the default order (decisions.md#differential-ledger carries the worked case, and the legal set is borrowed from tools/differential/shapes.py rather than copied — plus one member no shape can declare, the `DEFAULT` sentinel naming the comparison run under no declared order, TOML having no null to put in an array). Exclusions take no `orders` and stay order-blind, deliberately. The ban ends the SHAPE and not the property it enabled: a required `name_regex` bounds nothing by itself, since the only width check is the sentinel probe — measured, `[a-z]` validates and reaches 970 of 1120 comparisons (2026-09-01, re-measured the same day after #486 widened the shapes corpus; it read 963 of 1113 before that). What changed is that such a rule now carries a `_CORPUS_CLAIMS` reach and digest, so its breadth is visible once at recording time rather than never (#452). The two-tier sort in `_sorted_rules` is KEPT although the ban makes it the identity on every ledger that loads (four ledgers load today, measured 2026-09-02; the open cycle's carries one rule, `fix(#462)`, and it carries a `name_regex` like every other, so the identity holds there for the same reason and not for want of rules): it is the defence for a reader that does not call `validate_rules` first — a future tool, a REPL, a test fixture — and its docstring in tools/differential/compare.py says so. How it works. Detail is owned by tools/differential/README.md. The file-order clause is measured, not theoretical: in the 1.4 ledger the comma-honorific-peel rule's fields are a strict subset of the comma-compound rule's, both carry a name_regex, and a pure reorder reattributes seven names — caught by _CROSS_RULE_WINNERS and by nothing else in the suite (#375's mutation). That pair is narrow-first and so declares nothing, and #382 settled what to do about it by declining BOTH predicates on offer. Narrowing the peel rule's own `name_regex` to the honorific-bearing shapes (#382 option 1) cannot make the pair order-independent at all, which is measured and not argued: the two rules ship an IDENTICAL `name_regex`, each rule's reach is computed from its own pattern, and the peel rule's `fields` nest inside the compound rule's — so the compound rule goes on reaching those names and goes on admitting their diffs whatever the peel rule's regex is narrowed to, and it would grow the `_HONORIFIC_SOURCES` sync roster for nothing. What holds the pair is what the arc kept: narrow-first order, with the seven names whose diff both rules admit pinned by name in `_CROSS_RULE_WINNERS`. A mechanical narrow-first sort (option 3) was refused on a false premise: it reattributes names to a rule describing half of what happens to them. So order separates this pair, by the declaration-free narrow-first default, and it is the WIDE-first pairs that must declare themselves (the rule-order arc under decisions.md#differential-ledger). What that check covers is NESTED pairs: two rules whose `fields` merely INTERSECT are decided by file order too, and sit outside it by the same reasoning that leaves EQUAL `fields` outside — neither rule is narrower, so there is nothing for a `precedes_narrower` block to name — with #498 carrying the worked case and the measured size of the class, and closing it at RUN time rather than by widening this predicate: a contest a run measures whose winner outranks a loser by neither nesting nor a declaration must be pinned in `_CROSS_RULE_WINNERS`, and `compare.unowned_contests` fails the gate otherwise. A REGEX ACCIDENT — a rule reaching a name through its pattern rather than by describing it — has no declaration site at all when it lands in a narrow-first or a non-nested pair, `precedes_narrower` sitting on the wide-first rule by construction, so nothing can refuse it and nothing can require a `why`; the repair is to narrow the accident away until the pair stops being a contest, which is what #501 did to the glued-peel `name_regex` after it put a FALSE label on a contract-tier name and stayed green (decisions.md, the #501 adjudication). The old #271/#272 slug taboo is RETIRED (#333): the canonical-rule selector that keyed on those substrings is deliberately deleted — rule authors are free to use them in compound slugs — and the surviving rosters select on their own explicit keys (_HONORIFIC_SOURCES and _LATIN_ALTERNATION_SOURCES by named issue strings, _SPAN_BEARING_RULES by exact leading fix(...) tag). Lives in. tools/differential/compare.py, the expected_since_*.toml ledgers. Reach for it when. A ledger rule's behavior seems to depend on where it sits in the file — it does, and the reorder mutation is the test (run twice in #375; it fails _CROSS_RULE_WINNERS). History: #372 (closed) measured the then-existing fields-only rule owning 1639 of 5257 name×field pairs as filed (2026-08-10); #375/#376 then cut its classifier-of-record share sharply, and the residual pair ownership was read as the last-resort tier working as designed rather than a defect — until #451 retired the shape outright (decisions.md#differential-ledger). #372's two proposed mechanical checks were DECLINED with measurements (see decisions.md#differential-ledger), not left open. ## CANONICAL-VOCABULARY-AT-THE-BOUNDARY — one vocabulary at the comparison @@ -90,7 +90,7 @@ Problem shape. A test's input depends on two config sets intersecting (a word th ## CROSS-RULE-OUTCOME-PINS — pin who wins the contest -Problem shape. Every per-rule roster measures a rule alone and the gate total is per-corpus, but WHICH rule wins a contested name is neither — and it is exactly what a reorder or a narrowing changes. Contract statement. Contested outcomes are pinned as data: a roster records which rule classifies which contested name, so a change in the winner fails the suite even when every total is unchanged. How it works. A pure file reorder in the 1.4 ledger fails _CROSS_RULE_WINNERS and nothing else in the suite — the pin is the only guard at that granularity. A shape recorded ALONE pins no winner and lives in the other roster, `compare._WATCHED_DIFFS`, so a reader looking for who wins a name does not look there (#501). The entry criterion is that the boundary is ARGUED ANYWHERE and not that it is argued in the guard module, because a ledger comment's argument about which rule wins a name is exactly what no guard and no run can see — the #501 adjudication is the instance, three of its six boundaries having been argued in a ledger comment and one of those three wrongly, green everywhere for a month (decisions.md, the #501 adjudication). Lives in. tests/v2/test_ledger_guards.py (_CROSS_RULE_WINNERS). Reach for it when. Two rules can claim the same name and you are about to change either one, or their order. +Problem shape. Every per-rule roster measures a rule alone and the gate total is per-corpus, but WHICH rule wins a contested name is neither — and it is exactly what a reorder or a narrowing changes. Contract statement. Contested outcomes are pinned as data: a roster records which rule classifies which contested name, so a change in the winner fails the suite even when every total is unchanged. How it works. A pure file reorder in the 1.4 ledger fails _CROSS_RULE_WINNERS and nothing else in the suite — the pin is the only guard at that granularity. A shape recorded ALONE pins no winner and lives in the other roster, `compare._WATCHED_DIFFS`, so a reader looking for who wins a name does not look there (#501). The entry criterion is that the boundary is ARGUED ANYWHERE and not that it is argued in the guard module, because a ledger comment's argument about which rule wins a name is exactly what no guard and no run can see — the #501 adjudication is the instance, three of its six boundaries having been argued in a ledger comment and one of those three wrongly, green everywhere for a month (decisions.md, the #501 adjudication). The roster's COMPLETENESS is checked at run time over the contests a run measures: `compare.unowned_contests` refuses a measured contest the winner takes by neither nesting nor a declaration and that no row pins, so "`_CROSS_RULE_WINNERS` stays the instrument" for non-nested pairs is now enforced rather than stated (#498). Lives in. tests/v2/test_ledger_guards.py (_CROSS_RULE_WINNERS). Reach for it when. Two rules can claim the same name and you are about to change either one, or their order. ## VOCABULARY-FEEDS-STRUCTURE — a wordlist edit can move the comma decision diff --git a/tools/differential/README.md b/tools/differential/README.md index 96fb134c..4d03dde1 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -739,12 +739,35 @@ Widening the predicate to that general case would demand 111 written justifications where the real number is eleven -- the same argument decisions.md already makes about the 657 figure, that a predicate nobody can answer is not a usable one. The worked blind spot is real -and filed as +and was closed by [#498](https://github.com/derek73/python-nameparser/issues/498): `fix(#271/#272/#298)` and `fix(cjk-delimited-nickname)` intersect in {`family`, `given`} without nesting, and swapping them reattributes three contract-tier names this check never mentions in either -arrangement. +arrangement. All three are pinned now, along with eleven more names +measured the same way, and the second predicate below refuses the +next one (2026-09-05). + +**The second predicate is measured, not static.** `unowned_contests` +(#498) walks a run's own diffs and reports every contested one the +winner takes by neither narrow-first nesting nor a declaration and +that no `_RECORDED_DIFFS` row pins. It has to be run-time and there +is no static sibling: a pin is a shape beside a winner, and without a +diff the population is the whole equal-or-overlap class the predicate +names -- the 60 overlapping pairs above plus the 11 with equal +`fields`, 71 in all -- rather than the 14 names a run finds. The 71 is +those same 2026-09-02 figures re-measured 2026-09-05 by the recipe +they carry; decisions.md#differential-ledger has it. Severity is +FATAL on both tiers, in the exit +code beside the shape check -- a contest ROW is fatal on either tier +because it carries an argument, and an owed row because it carries +none yet. Under `--corpus` the population is the entries the run +loaded, so the check under-reports and never false-alarms, and no +flag is read. Note the ASYMMETRY with `vacant_exemptions` above: +there is no vacancy half here, because a pin whose contest dissolves +keeps saying what the name diffs to and which rule takes it -- 13 of +the 45 rows at 1.4.0 are already in that position (measured +2026-09-05). Two questions, in both tiers, as for `dormant`. Is every contest DECLARED, and does every declaration still stand over a contest? The