diff --git a/AGENTS.md b/AGENTS.md index c672e1eb..98cf4953 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,45 +102,46 @@ uv run sphinx-build -b html docs dist/docs # dynamic dormancy check AND no over-declaration check (#452) at all # -- and neither is hypothetical: #452 found 3, 5 and 6 stale # declarations sitting in the three files. See -# tools/differential/README.md: -# uv run python tools/differential/compare.py --baseline 1.4.0 # the v1 compat contract -# uv run python tools/differential/compare.py # the previous minor -# uv run python tools/differential/compare.py --baseline 2.0.0 # 2.0.0's ledger has rules too -# NOTE (2026-09-03, #503): that is THREE commands against FOUR ledgers -# with rules -- `--baseline 2.1.0` is the one no line above reaches, so -# the "EVERY baseline" instruction is not what this list does. Since -# #497 a third check is per-ledger as well: the recorded-shape one -# reads _RECORDED_DIFFS[ledger.name], so the ledger nobody runs gets no -# dormancy check, no over-declaration check, and no shape check. -# Two of those three are gaps TODAY; the shape check is latent only -# because expected_since_2.1.0.toml's _RECORDED_DIFFS section is empty -# -- and whether the 2.x sections stay empty is #501, which measured -# six contested diffs across the two 2.x ledgers with no pin, one of -# them at 2.1.0 (`MD, PHD`). The first 2.1.0 row to land would be -# checked by nothing a releaser runs. +# tools/differential/README.md. The baselines are DERIVED from the +# ledger glob rather than enumerated, so a ledger opened at step 8 is +# covered at the next release without anyone remembering to add a line +# (the same reason compare.py's `--corpus` default is a glob: one that +# has to be asked for by name is one that stops being run). Redirect +# each run to a file, never pipe it -- under zsh a pipe replaces the +# exit code with the pipe's, so a failing run reads as a passing one: +# for f in tools/differential/expected_since_*.toml; do +# b=${f##*expected_since_}; b=${b%.toml} +# uv run python tools/differential/compare.py --baseline "$b" > "/tmp/gate-$b.txt"; echo "$b exit $?" +# done +# #503 (filed 2026-09-03, fixed on the branch 2026-09-05, closed by its +# merge): this step enumerated three +# commands against four ledgers, so `--baseline 2.1.0` got no dormancy, +# over-declaration or recorded-shape check from any releaser, the shape +# gap "latent" only while that ledger's roster sections were empty; the +# bundle that closed it ARMED it first -- rows now sit in +# expected_since_2.1.0.toml's _WATCHED_DIFFS section (31 on the day; +# `len(compare._WATCHED_DIFFS['expected_since_2.1.0.toml'])` is the +# live figure) -- and the loop above is the fix it chose. # Cost is not the obstacle and no fix may be argued from it: measured # 2026-09-03, a whole run is under a second at every baseline and all # four back to back are about two seconds (0.43s at 1.4.0, 0.56s at -# 2.2.0, 1.97s for the four; decisions.md's 2026-09-03 #497 timing -# finding carries the recompute and retires every cost argument in +# 2.2.0, 1.97s for the four; the recompute lives ONCE, in decisions.md's +# rule-order arc under the Declined bullet on precise per-name contest +# detection, and the 2026-09-03 #497 timing finding points there and +# retires every cost argument in # this repo's differential prose). -# #503 weighs three fixes and none of them is this note's to make: -# add the fourth command; have the checklist DERIVE the list from the -# expected_since_*.toml glob rather than enumerate it; or say that -# 2.1.0 is deliberately outside the audit and fix the "EVERY baseline" -# line to match. The local precedent for the second is compare.py's -# own `--corpus` default, a glob and deliberately so -- "one that has -# to be asked for by name is one that stops being run" -- which is the -# same failure this note records, one argument up. -# Redirect to a file rather than piping — under zsh a pipe replaces the exit -# code with the pipe's, so a failing run reads as a passing one. The -# classified summary it prints is the source for the release notes' behavior -# claims, including the count of changed names that are Latin-only. -# Exit 0 no longer means every diff is classified: since the tier split -# (#468) a radar corpus's unmatched diffs print under UNCLASSIFIED (radar) -# and cannot fail the run. Read that block. A radar diff worth a release -# note gets promoted (a cases.py row plus a shape tag) or classified with a -# rule BEFORE the log is drafted, not after. +# The classified summary each run prints is the source for the release +# notes' behavior claims, including the count of changed names that are +# Latin-only. +# Exit 0 no longer means every diff is classified, and two blocks print +# without failing the run. Since the tier split (#468) a radar corpus's +# unmatched diffs print under UNCLASSIFIED (radar); since the watched-shapes +# arc (#501) a watched shape on a radar name that MOVED prints under +# MOVED SHAPE (radar). Read both. A radar diff worth a release note gets +# promoted (a cases.py row plus a shape tag) or classified with a rule +# BEFORE the log is drafted, not after; a moved shape is a finding, and if +# the move is intended the row is re-recorded in _WATCHED_DIFFS in the +# commit that moved it, saying why there. # 2. Clear PRE_RELEASE in nameparser/_version.py — it carries 'dev' through the # cycle (see step 9), so releasing is setting it to ''. VERSION should already # be the version you are shipping; bump it here only if step 9 was skipped. @@ -171,11 +172,14 @@ uv run sphinx-build -b html docs dist/docs # covers those is _CORPUS_CLAIMS, which records what every rule claims # -- its regex's corpus reach, its roles, and which names -- and so # needs no notion of how a copy is spelled. -# THREE rosters are keyed by FILENAME and checked by EQUALITY, so a new +# FOUR rosters are keyed by FILENAME and checked by EQUALITY, so a new # ledger must be enrolled in every one of them on the day it lands, even -# empty -- each fails loudly and names itself, but that is three separate -# red runs if you add them one at a time. Two more are keyed by rule -# CONTENT and apply only where such a rule exists. +# empty -- each fails loudly and names itself, but that is four separate +# red runs if you add them one at a time. Three sit in the test module +# and one in tools/differential/compare.py; a fifth, compare.py's +# _RECORDED_DIFFS, is enrolled transitively, since the guard holds +# set(_RECORDED_DIFFS) == set(_CROSS_RULE_WINNERS). Two more are keyed +# by rule CONTENT and apply only where such a rule exists. # Required, by filename: # - _SPAN_BEARING_RULES: add the filename, mapped to the set of issue # tags whose rules carry a script-span class (empty set if none). @@ -191,6 +195,12 @@ uv run sphinx-build -b html docs dist/docs # that a ledger with no rows had been indistinguishable from one # needing none -- the two 2.x ledgers whose shapes #452 moved # between rules had no section at all. +# - _WATCHED_DIFFS, in tools/differential/compare.py rather than the +# test module: add the filename mapped to {} while the ledger has +# no watched name, and the same for _RECORDED_DIFFS beside it. The +# run itself refuses, pre-worker, a ledger missing from either +# shape roster -- an empty section is a statement, a missing one is +# nobody having looked -- so the guard and the run agree on it. # Conditional, by rule content: # - _HONORIFIC_SOURCES: if the ledger has a CJK honorific rule, add a # substring of its issue (keyed that way, not by tag) mapped to the diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 5fb4f3bc..d130f445 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -735,7 +735,7 @@ The fourth ledger arc, and the first to change what the gate is FOR rather than Decisions that landed: -- 2026-09-01 #468 — every corpus carries a TIER, and only the CONTRACT tier can fail the run. `corpus.jsonl` (scraped from v1's test banks) and `corpus_issues.jsonl` (harvested from the tracker) became RADAR: their diffs still classify against the ledger, so release notes keep their grouping, but an unmatched one prints under `UNCLASSIFIED (radar)` and the run still exits 0. `corpus_cjk.jsonl`, `corpus_rules.jsonl` and `corpus_shapes.jsonl` keep the full discipline. The reason is a VOCABULARY gap and not a volume one: the gate demanded a rule-precision explanation for names nobody chose, and the honest explanation for many of them was "this string exercises nothing" — which no ledger key can say: a rule means "this diff is intended and here is why", an exclusion means "this shape must never be explained", and neither is the sentence wanted. A rule invented to say it would be the #372 shape by construction, since the only thing it could narrow on is which file the name happens to sit in. `_CORPUS_TIERS` in compare.py is the roster and is fail-closed like `_CORPUS_FLOORS`: a corpus added without a tier is a hard error, so the split forces a decision when a corpus arrives rather than defaulting one. +- 2026-09-01 #468 — every corpus carries a TIER, and only the CONTRACT tier can fail the run. `corpus.jsonl` (scraped from v1's test banks) and `corpus_issues.jsonl` (harvested from the tracker) became RADAR: their diffs still classify against the ledger, so release notes keep their grouping, but an unmatched one prints under `UNCLASSIFIED (radar)` and the run still exits 0. `corpus_cjk.jsonl`, `corpus_rules.jsonl` and `corpus_shapes.jsonl` keep the full discipline. The reason is a VOCABULARY gap and not a volume one: the gate demanded a rule-precision explanation for names nobody chose, and the honest explanation for many of them was "this string exercises nothing" — which no ledger key can say: a rule means "this diff is intended and here is why", an exclusion means "this shape must never be explained", and neither is the sentence wanted. A rule invented to say it would be the #372 shape by construction, since the only thing it could narrow on is which file the name happens to sit in. `_CORPUS_TIERS` in compare.py is the roster and is fail-closed like `_CORPUS_FLOORS`: a corpus added without a tier is a hard error, so the split forces a decision when a corpus arrives rather than defaulting one. The tier rule is about UNMATCHED diffs, and two recorded things outrank it on a radar name — a `[[never]]` exclusion (two bullets below) and a contest row in `_RECORDED_DIFFS` (the watched-shapes arc, 2026-09-05, which states the rule and its ground); a watched row that fails does so on an entry whose tier reads contract — the default-order entry when the name has one, the first-loaded otherwise — which is this rule applied and not an exception to it. - 2026-09-01 #468 — nothing is DELETED to quiet the gate; promotion is the answer to "then how does a radar name ever bind again". A name enters the contract by being CHOSEN — a tests/v2/cases.py row plus a shape tag, which puts it in `corpus_shapes.jsonl` at the next regeneration. Deleting the string was the alternative and is worse in both directions: it loses the radar signal, and it makes the gate's own population editable by whoever finds the gate inconvenient, which is the one edit a differential gate must not make cheap. A meaningless radar string costs one parse and one report line against a comparison of 1120 names today (1113 when this bullet was written, hours earlier the same day; #486's bullet below carries the arithmetic). - 2026-09-01 (decided in review) — a `[[never]]` exclusion OUTRANKS the tier: a diff it refuses reports UNEXPLAINED and exits 1 on either tier. Not a carve-out but the tier's own principle applied — the split governs names nobody looked at, and an exclusion is chosen exactly as a rule is, someone having written its `why` and its `examples`. It is also what the shipped ledgers already promise, and the measurement is what settled it: the 1.4.0 ledger's trailing-`Ph. D.` exclusion, whose `why` says "a diff here is a regression", reaches exactly three corpus names (`John Smith Ph. D.`, `John Smith, Ph. D.`, `John Smith, Ph.D.`) and every one of them lives in `corpus.jsonl` or `corpus_issues.jsonl` alone. Reading the tier as outranking the exclusion would have retired that entry outright, silently, in the commit that split the corpora. Recompute by matching each `[[never]]` entry's `name_regex` over the corpus files and reading which files the matches come from. - 2026-09-01 (decided in review) — a ledger RULE may narrow by comparison order, through a new `orders` key; it is optional, and its absence is the order-blind behavior every rule written before shape-tagged entries has. Forced by the first order-scoped rule anyone wrote: `feat(#395)`'s fold on `de la Cruz Juan Carlos` moves `{family, given, middle}` under the two family-first orders, and the most plausible regression of that very change — the fold leaking into the DEFAULT order — moves those same three roles on that same string, which `corpus_rules.jsonl` carries as a bare string and therefore compares. Order-blind, the rule describing the feature would have absorbed the regression of the feature and called it intentional: #372's failure mode reached from a new direction, and on the one name best placed to hide it. The legal set is BORROWED from shapes.py rather than hand-copied (`_legal_orders`), the same call build_cjk_corpus.py makes for the script table — an order no shape declares is an order no comparison runs under, so a rule scoped to a typo could only ever be dormant. Amended 2026-09-01 (decided in review): the legal set gains a `DEFAULT` SENTINEL, and the two legacy rules that needed it were scoped. Found when the reviewer traced the absorption running the OTHER way from the one above — the order-blind `fix(#399) a maiden marker bounds the particle chain that swallowed it` rule reaches `de la Cruz née Vega`, which `corpus_shapes.jsonl` compares under both family-first orders, and it sorts AHEAD of the two rules written for that name, so a family-first-only `{family, maiden}` regression there would have classified as intentional. The fix a rule wants is `orders`, and no rule could say "default order only": the members are constant NAMES, the default order is the absence of one, and TOML has no null inside an array. So `"DEFAULT"` names it. `fix(#399) …that swallowed it` took `["DEFAULT"]` in both 2.x ledgers and `fix(#296) … 'John Smith, Dr.' keeps its split and its title` took `["DEFAULT", "FAMILY_FIRST"]`, those being the comparisons each one's diffs actually come from. Order-blind rules stay legal — every ledger written before shape tags is full of them — so the absorption is made VISIBLE rather than illegal: a run prints an `ORDER-BLIND` block for each (issue, name, order) where a rule with no `orders` key explained an order-bearing diff, informational and outside the exit code. Recompute by running the gate at each of the four baselines and reading that block; it is empty at all four today. @@ -796,7 +796,7 @@ The seventh ledger arc, and the one that makes a recorded INPUT measurable. The 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. 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 — 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. 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. @@ -815,6 +815,36 @@ Declined: - The differential in CI (2026-09-03) — considered and dropped rather than deferred. It is a release gate that already runs locally on demand, and no sibling check — dormancy, over-declaration, the undeclared-contest refusal, this one — needed a CI job to be worth having. Worth recording as a DECLINE and not an omission precisely because the timing finding removes the only reason nobody proposed it: a job costing seconds is cheap, and cheapness was never the criterion. - A general sweep of every number-carrying comment line (2026-09-03) — the sweep is scoped to the claim-shaped ones, which the scan put at 19 and which were 17 — the pair of figures that cannot be re-derived from the tree, since the scan's predicate was never preserved; they live in `8593f3a`'s commit message and the bullet above says so. The population it declines is larger by nearly two orders of magnitude: measured 2026-09-03, comment lines carrying a digit run to four figures across the differential tooling, its four ledgers and the two guard modules, against 17 claim-shaped ones (RECOMPUTE: count lines matching `^\s*#` that also contain a digit, over `tools/differential/*.py`, `tools/differential/*.toml`, `tests/v2/test_ledger_guards.py` and `tests/v2/test_differential.py`). The exact figure is deliberately not given: an earlier draft of this bullet carried one, and the NEXT commit on this branch added two comment lines and falsified it — inside the entry declining a sweep for exactly that. Most are issue references, version numbers and codepoints, none of which rot. Read the RATIO and not either digit: a hand sweep two orders of magnitude past the claims is how wrong claims get INTRODUCED rather than removed, and this arc demonstrated that at the smaller scale — the commit that removed the false cost claim needed a follow-up for four instances of the same defect inside itself, and the first prose sweep shipped having skipped three instances it had already identified (`8593f3a`). +### differential-ledger, the watched-shapes arc (2026-09-05, #501) + +The eighth ledger arc, and the one that lets a diff shape be recorded WITHOUT a winner. The arc above moved the recorded shapes to where a run verifies them and, in the same commit (`962ae43`), bolted them to the winners with `set(winners) == set(shapes)` — never argued as a constraint, and the reason 51 corpus names whose only watcher was a classification rule could not be watched at the shape level: recording a shape meant pinning a winner, and a winner pin asserts an argument nobody had made. Mechanics — the two dicts and their contracts, the three print blocks, which name list each check reads — are owned by `tools/differential/compare.py` (`_WATCHED_DIFFS`' header, which carries the population and its recompute) and tools/differential/README.md's `MOVED SHAPE` section; these are the decisions. Every figure below was measured 2026-09-05 on this branch from real `main()` runs with `classify()` spied, and each carries its recipe; the one figure that goes stale by design, the quiet-tree count, is dated where it appears. Refs [#501](https://github.com/derek73/python-nameparser/issues/501), which stays open. + +Decisions that landed: + +- 2026-09-05 #501 — a shape MAY be recorded without a winner, in a SECOND roster (`compare._WATCHED_DIFFS`, keyed per ledger like `_RECORDED_DIFFS`, disjoint from it per ledger, both keyed by every ledger on disk — held by the guards at pytest speed and, since the PR's review round, by `main()` pre-worker, which refuses a ledger either dict has no section for) and NOT by relaxing the equality. Two grounds, and the first is the arc above's own placement decision turned around: the run cannot see `_CROSS_RULE_WINNERS` — `compare.py` cannot import the test module, and names that roster only in comments and in the advice it prints — so if the KIND of a row is to decide anything at run time, the kind must be DATA in the tool, and one dict with a relaxed guard leaves the run unable to tell a contest row from a standalone one, which means one repair text and one severity for both. The second is that the equality was doing work the relaxation gives up: a shape whose winner was deleted and forgotten is a deleted-winner slip, and under `<=` it is indistinguishable from a deliberate standalone row. So `set(winners) == set(shapes)` stays, both directions, and its docstring now says why the reverse direction is defended — a shape with no winner is not an orphan to tolerate but a row in the wrong dict. A name in both dicts is refused twice, by the guard at pytest speed and by `main()` pre-worker beside the departed-name refusal, because the tool may not assume the suite ran; the argument is the stronger claim, so such a name belongs in `_RECORDED_DIFFS` alone. The day a winner is argued for a watched name, the row MOVES and the pin goes beside it there; it never gains a partner where it is. +- 2026-09-05 #501 — SEVERITY follows the row's kind first and the tier second: a row with an argument behind it (a pinned winner) is fatal on both tiers; a row that is only a snapshot follows its tier, fatal on a contract name and printed under a non-fatal `MOVED SHAPE (radar)` block, parallel to `UNCLASSIFIED (radar)`, on a radar one. Three grounds, from the spec's review amendment A. A per-name shape is a STRONGER promise than a contract name gets — a contract name needs a RULE, which covers a shape class and batches across names, so a feature that legitimately moves thirty of these would produce thirty findings each needing its own "say why", the per-name burden #468 removed, reintroduced one level down on the very file #468 demoted for that reason (`corpus_issues.jsonl`, 49 of the 51). The `[[never]]` precedent cuts the other way — exclusions are fatal on both tiers because each is a per-name deliberate choice carrying a `why`, and these rows are measured, not reasoned: a sweep, not a choice. And when a snapshot row fires the only repair is to re-snapshot — a contest row has an argument to consult; `'Ph. D., Jr.'` or `'QC MP'` has no adjudicated parse, so "correct the row" means "record whatever it does now", which is mechanisms.md#RECORDED-ROSTERS's re-derivation hazard in a gate's clothing. Two limits on what this decides, stated so nobody reads more into it. It answers the NARROW tier question only — whether a snapshot on a radar name may fail the gate, which `shape_bad` being tier-blind had answered yes by default — and leaves the gate-and-changelog hypothesis where the spec left it: the framing that the ledger is a gate AND a changelog and `_CORPUS_TIERS` splits only the gate is a hypothesis, not codified, and the radar tier's explanation-relief has read 0 at every baseline because no feature work has offered it a case, not because it was declined. And it does not reverse #468; it applies it — "shown, never blocking" reaching a per-name snapshot on a radar name exactly as it reaches an unmatched diff there. It also turns the arc above's radar overlap from unreconciled history into a stated rule: measured 2026-09-03, 21 of the 31 contest rows at 1.4.0 sat on radar-tier names, every one carrying a winner, and they stay fatal because it is the argument and not the tier a contest row defends (RECOMPUTE in tools/differential/README.md's tier paragraph). +- 2026-09-05 #501 (found in the branch's code review) — the tier a watched row's severity follows is the DEFAULT-ORDER ENTRY's, not the name's. A recorded shape is read off the default-order comparison, so that comparison's entry is the one whose tier says whether the shape was promised; the first-loaded (contract-preferring) entry decides only for a name with no default-order entry, which is the name compared under a declared order alone. This is the rule-order arc's third RECOMPUTE trap applied to severity — "a name's tier, in any claim scoped to a BASELINE, is a property of the ENTRY that run compared and not of the files the string appears in", under its #495 decline above, whose measurement is not restated here. The live case is that arc's own: `'John Smith, Dr.'` is contract in `corpus_shapes.jsonl` only as shape 4 (`FAMILY_FIRST`) and radar in `corpus_issues.jsonl` under the default order, so a watched row on it would print and not fail, and the family-first promise would be untouched — it carries no row today, a test naming it. The dict header first said "a name both tiers hold reads contract", which was false of the code as written (`tier_of` is built over the post-skip entries, an order-None entry winning where one exists), and was corrected before the commit was reviewed again. +- 2026-09-05 #501 (found in the design-docs review) — the roster's DEFINING property is "no winner pinned", not "nothing else watches". The four #501 rows are pinned by an exact `Case(...)` literal in `tests/v2/cases.py` and three sit in contract corpora, so "nothing else watches" is the property of the sole-watched SUBPOPULATION and false of the dict as a whole, and a run whose message told a reader of a `'선생님'` row that nothing else watched it would send that reader past the case row that already adjudicates the parse. What every row has, and what the two checks enforce (the disjointness guard, the `both` refusal), is that no winner is pinned. The dict header, the fatal block's lead, the `(radar)` block's lead and the README say "no winner is pinned"; the population comment says which rows are also sole-watched, and that a contested row's reader consults the case row before the roster. +- 2026-09-05 #501 — every sole-watched diff is recorded — 109 sole-watched rows plus the four #501 contests, 113 in all — not the exposed subset. Airtightness is a property of today's rule widths and not of the name: a rule whose `fields` happens to equal a name's diff admits no alternative today, and widening that rule later exposes the name with nothing saying so. And the 35 alternatives over the sole-watched rows that route to a DIFFERENT rule (the finding below; 44 with the four contests counted) are silent whatever the winner's width is, which is a stronger reason than airtightness: "every sole-watched diffing name" survives a rule edit, "the exposed subset" is a snapshot of the widths. + +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. 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. 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. +- **`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. 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. +- **The tier rule a watched row follows was stated three ways and pinned by none (found 2026-09-05, in the PR's review round).** Three mutants of `tier_of` — order-None entries only, first-loaded entry always, `== "contract"` in place of `!= "radar"` — each passed the whole suite and all four gates byte-identically, because every test drove one bare-string corpus, on which the three readings coincide. What pins the rule now is two fixtures `_run_main` cannot build: the `'John Smith, Dr.'` shape (one string, contract under shape 4 in one file and radar under the default order in another, watched row moved on the default-order comparison — prints `MOVED SHAPE (radar)`, exit 0, which refuses first-loaded-always) and a declared-order-only name (a shape-4 entry with no default-order entry, watched row reporting `measured` None — a tier map over order-None entries alone has no key for it and dies mid-report). RECOMPUTE by applying each mutant to a copy of `compare.py` outside the worktree and running `tests/v2/test_differential.py`; before the two tests, all three pass it. + +Declined: + +- Relaxing `set(winners) == set(shapes)` to `<=` (2026-09-05, the spec's first section) — the first decision above in full: the run cannot read the winner roster, so the kind of a row has to be data, and the equality catches the deleted-winner slip the relaxation would legalize. The spec's own review amendment B withdrew it. +- Fatal-on-radar for snapshot rows (2026-09-05, the spec's default by inheritance from `shape_bad`) — the second decision above; the spec's own "What this costs" section accepted 52 radar names becoming able to fail a release, and amendment A refused that cost as the wrong one for a row nobody chose. Had the 51 been made fatal after all, that would have been a reversal of #468 for those names and would sit here as one. +- 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. +- 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 (`'田中さん 様.'`, `'김민준 박사님'`, `'선생님'`, contract tier and fatal; `'田中さん, 様.'`, `corpus_cjk_tolerated.jsonl`, radar and printed). 51 distinct sole-watched names; every row a default-order shape, so `NOT CHECKED` names none of them. 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, `git rev-list --count a965272..HEAD -- 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) — a figure that 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 --stat a965272..HEAD -- nameparser/`. + ### 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 b90ce0a5..20fb61a7 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -73,7 +73,7 @@ than inventing one. ## RECORDED-ROSTERS — record the answer, don't re-derive it -Problem shape. A guard needs to know what the answer WAS, so it can detect the answer changing. Contract statement. Store the measured answer as literal data (a roster) and compare against it; never re-derive the expectation from the same inputs the check reads, because a derivation from the same data always agrees with itself. Known limit, and it is what the contract statement does NOT cover: a roster row can hold a recorded INPUT as well as a recorded expectation, and only the expectation half is asserted. `_CROSS_RULE_WINNERS` obeyed this entry to the letter and still carried wrong rows — it recorded a diff shape per contested name AND the rule that should win, fed the shape to `classify()` as an argument, and asserted only the winner, so a wrong shape that still routed to the same rule agreed with itself for as long as nobody re-measured it by hand. The test to apply to a recorded input is not "is it checked" but "would the consumer answer DIFFERENTLY if it were wrong" — which is a question about the PAIR of shapes and not about the row: a row is inert exactly where the recorded shape and the real one route to the same rule. Counting admitters does not answer it, and is the wrong instrument this entry itself reached for first. Measured 2026-09-03 over `_RECORDED_DIFFS['expected_since_1.4.0.toml']`: 13 of the 31 rows have exactly one rule admitting their recorded shape, and 11 of those 13 route to a DIFFERENT rule under some other shape, so the assertion would have caught a wrong one — `'Andrews, M.D.'` wins `fix(comma-family)` at its recorded `('given','suffix')` and `fix(comma-precomma-family)` at `('family',)`. Nor does "the same rule admits both shapes" make a row inert, since file order still decides: over that ledger 152 of the 246 shape pairs sharing an admitter route to different rules. RECOMPUTE: load the ledger, sort with `_sorted_rules`, and call `classify(name, shape, rules, exclusions, None)` at each row's recorded shape and at every other subset of the roles the roster uses. The four rows #497 deleted were inert for the PARTICULAR wrong shape they happened to carry, and not for any wrong shape — `'Jane née and Jones Smith'` routes to `fix(#445)` at both its recorded and its real shape in either 2.x ledger, and to `fix(#412)` at `('family',)`. The repair is to move the input to where something measures it — the shapes now live in tools/differential/compare.py as `_RECORDED_DIFFS` and a differential run verifies them against the diffs it has already computed, the roster reading them from there — so a recorded roster's recorded half is checked by a run rather than by the guard that consumes it (#497). Lives in. tests/v2/test_ledger_guards.py (_CORPUS_CLAIMS, _EXCLUSION_EFFECT, _CROSS_RULE_WINNERS, _ORDER_EXEMPTION_EFFECT, _SPAN_BEARING_RULES, _HONORIFIC_SOURCES, _LATIN_ALTERNATION_SOURCES), tools/differential/compare.py (_CORPUS_FLOORS, _RECORDED_DIFFS), tests/v2/test_facade_cases.py (_CORE_ONLY_IDS). Reach for it when. Writing a check whose expected value is computed by the code under test, or a comment that enumerates ids/counts — make it data the suite asserts. And when a roster row carries anything the check CONSUMES rather than compares, ask what measures that half. +Problem shape. A guard needs to know what the answer WAS, so it can detect the answer changing. Contract statement. Store the measured answer as literal data (a roster) and compare against it; never re-derive the expectation from the same inputs the check reads, because a derivation from the same data always agrees with itself. Known limit, and it is what the contract statement does NOT cover: a roster row can hold a recorded INPUT as well as a recorded expectation, and only the expectation half is asserted. `_CROSS_RULE_WINNERS` obeyed this entry to the letter and still carried wrong rows — it recorded a diff shape per contested name AND the rule that should win, fed the shape to `classify()` as an argument, and asserted only the winner, so a wrong shape that still routed to the same rule agreed with itself for as long as nobody re-measured it by hand. The test to apply to a recorded input is not "is it checked" but "would the consumer answer DIFFERENTLY if it were wrong" — which is a question about the PAIR of shapes and not about the row: a row is inert exactly where the recorded shape and the real one route to the same rule. Counting admitters does not answer it, and is the wrong instrument this entry itself reached for first. Measured 2026-09-03 over `_RECORDED_DIFFS['expected_since_1.4.0.toml']`: 13 of the 31 rows have exactly one rule admitting their recorded shape, and 11 of those 13 route to a DIFFERENT rule under some other shape, so the assertion would have caught a wrong one — `'Andrews, M.D.'` wins `fix(comma-family)` at its recorded `('given','suffix')` and `fix(comma-precomma-family)` at `('family',)`. Nor does "the same rule admits both shapes" make a row inert, since file order still decides: over that ledger 152 of the 246 shape pairs sharing an admitter route to different rules. RECOMPUTE: load the ledger, sort with `_sorted_rules`, and call `classify(name, shape, rules, exclusions, None)` at each row's recorded shape and at every other subset of the roles the roster uses. The four rows #497 deleted were inert for the PARTICULAR wrong shape they happened to carry, and not for any wrong shape — `'Jane née and Jones Smith'` routes to `fix(#445)` at both its recorded and its real shape in either 2.x ledger, and to `fix(#412)` at `('family',)`. The repair is to move the input to where something measures it — the shapes now live in tools/differential/compare.py as `_RECORDED_DIFFS` and a differential run verifies them against the diffs it has already computed, the roster reading them from there — so a recorded roster's recorded half is checked by a run rather than by the guard that consumes it (#497). A recorded expectation whose only consumer is the run that checks it is the pure case of this entry, and `_WATCHED_DIFFS` is that case: a shape recorded alone, with no winner pinned and no guard reading it as an input, measured by every run and re-derived by nothing (#501). Lives in. tests/v2/test_ledger_guards.py (_CORPUS_CLAIMS, _EXCLUSION_EFFECT, _CROSS_RULE_WINNERS, _ORDER_EXEMPTION_EFFECT, _SPAN_BEARING_RULES, _HONORIFIC_SOURCES, _LATIN_ALTERNATION_SOURCES), tools/differential/compare.py (_CORPUS_FLOORS, _RECORDED_DIFFS, _WATCHED_DIFFS), tests/v2/test_facade_cases.py (_CORE_ONLY_IDS). Reach for it when. Writing a check whose expected value is computed by the code under test, or a comment that enumerates ids/counts — make it data the suite asserts. And when a roster row carries anything the check CONSUMES rather than compares, ask what measures that half. ## LEDGER-RULE-SEPARATION — file order decides, fields narrow by subset @@ -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. 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). 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/tests/v2/test_differential.py b/tests/v2/test_differential.py index f123558f..349d3b35 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -2041,6 +2041,11 @@ def test_main_reports_a_recorded_shape_the_run_contradicts( only falsifiable against a run. recorded_diff_mismatches has its own unit tests above -- this pins that main() calls it, which is the half that can go silently permissive. + + CONTRACT tier, _run_main's default. A contest row is fatal on a + radar name too, and test_a_contest_shape_on_a_radar_name_still_fails + pins that half; the watched-roster tests beside it pin the tier + the other roster's rows DO follow. """ monkeypatch.setitem(compare._RECORDED_DIFFS, "expected_since_1.4.0.toml", @@ -2305,18 +2310,28 @@ def test_a_name_this_baseline_skipped_is_not_a_name_the_corpus_lost( test_the_contest_check_reads_names_this_baseline_will_not_compare. Its control is the sibling above: a name in NO corpus is refused by this same run shape. + + One skipped name per roster, because the note reads the UNION: a + watched row on a skipped entry falls between the two halves for + exactly the reason a contest row does, and a note reading one dict + would leave the other's row silent on the same run. """ import json as _json import sys name = "Ménil Christophe du" + watched_name = "Vega Carlos de la" corpus = tmp_path / "corpus_x.jsonl" corpus.write_text( _json.dumps({"name": name, "shape": 4}, ensure_ascii=False) + "\n" + + _json.dumps({"name": watched_name, "shape": 4}) + "\n" + _json.dumps("John Smith") + "\n", encoding="utf-8") (tmp_path / "expected_since_1.4.0.toml").write_text( _CLAIMS_FAMILY, encoding="utf-8") monkeypatch.setitem(compare._RECORDED_DIFFS, "expected_since_1.4.0.toml", {name: ("family",)}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", + {watched_name: ("family",)}) # wholesale, so the flagless glob below sees a full corpus roster monkeypatch.setattr(compare, "_CORPUS_FLOORS", {corpus.name: 1}) monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") @@ -2348,6 +2363,363 @@ def _fake(v: str, w: bool, n: list[dict]) -> tuple[dict, list[dict]]: assert "NOT CHECKED" in out, out assert name in out, out assert "do not delete" in out, out + # ... and the watched row too, under the same note: the count is + # asserted so a note reading one roster cannot pass by naming the + # other's row in some later line + assert "NOT CHECKED expected_since_1.4.0.toml: 2 recorded" in out, out + assert watched_name in out, out + + +def test_a_watched_shape_on_a_radar_name_prints_and_does_not_fail( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The severity rule's whole point: a row that is only a snapshot + follows its name's tier, and on a radar name that is "shown, never + blocking" (#468). The VERDICT is the pin -- a block that printed + and exited 1 would be the contest roster's behavior on a name that + carries no argument. + + Radar tier, and the ledger claims the family diff, so the run is + clean apart from the row: 0 here is this check's alone, exactly as + the contest twin's 1 is its own. + """ + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", + {"John Smith": ("nickname",)}) + code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, + tier="radar") + assert "MOVED SHAPE (radar)" in out, out + # the dict the row lives in, so the reader edits the right one + assert "_WATCHED_DIFFS records" in out, out + # both shapes, for the reason the contest twin gives + assert "nickname" in out and "family" in out, out + # no row here has a partner pin, so the block must not send a + # reader to a roster that holds nothing for it + assert "_CROSS_RULE_WINNERS" not in out, out + # the ledger explained the diff, so the only radar block is this one + assert "UNCLASSIFIED" not in out, out + assert code == 0, out + + +def test_a_watched_shape_on_a_contract_name_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The same row, the other tier: a contract name's watched shape + fails the run as an unexplained diff on it would. The block is + MOVED SHAPE without the `(radar)` tag, and its count word is + `watched` so the two rosters' blocks read apart on one run.""" + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", + {"John Smith": ("nickname",)}) + code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, + tier="contract") + assert "MOVED SHAPE expected_since_1.4.0.toml: 1 watched" in out, out + assert "(radar)" not in out, out + assert "_WATCHED_DIFFS records" in out, out + assert "_CROSS_RULE_WINNERS" not in out, out + assert code == 1, out + + +def test_a_contest_shape_on_a_radar_name_still_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A contest row carries an argument, so the tier does not soften + it: the winner pinned beside the shape was recorded for the OLD + shape whichever file the name sits in. The contract-tier twin is + test_main_reports_a_recorded_shape_the_run_contradicts.""" + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", + {"John Smith": ("nickname",)}) + code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, + tier="radar") + assert "MOVED SHAPE expected_since_1.4.0.toml: 1 recorded" in out, out + assert "(radar)" not in out, out + assert "_CROSS_RULE_WINNERS" in out, out + assert code == 1, out + + +def test_a_contest_row_and_a_watched_radar_row_print_apart( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Both kinds moved on one run: two blocks, contest first, each + naming its own dict, and the exit code reading only the fatal one. + The radar-only sibling above exits 0, so 1 here is the contest + row's -- which is what "reads only the fatal list" means when both + are present.""" + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", + {"John Smith": ("nickname",)}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", + {"Alice Jones": ("nickname",)}) + code, out = _run_main( + tmp_path, monkeypatch, + '[[change]]\nissue = "claimed"\nname_regex = "Smith|Jones"\n' + 'fields = ["family"]\n', _DIFFERS, + extra=[("Alice Jones", + {"title": "", "first": "Alice", "middle": "", + "last": "JONESY", "suffix": "", "nickname": "", + "maiden": ""})], + tier="radar") + contest = out.index("MOVED SHAPE expected_since_1.4.0.toml: 1 recorded") + radar = out.index("MOVED SHAPE (radar) expected_since_1.4.0.toml: 1 watched") + assert contest < radar, out + assert out.count("_RECORDED_DIFFS records") == 1, out + assert out.count("_WATCHED_DIFFS records") == 1, out + 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 + `--corpus` absence is a fact about the run, and + recorded_diff_mismatches is called on this dict with the same + `compared` it skips by.""" + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", + {"Nobody Here, Esq.": ("family",)}) + code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS) + assert "Nobody Here, Esq." not in out, out + assert "MOVED SHAPE" not in out, out + assert code == 0, out + + +def test_a_full_run_refuses_a_watched_name_no_corpus_holds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The departed-name refusal reads the union, and its repair text + is per roster: a watched row has no partner in _CROSS_RULE_WINNERS, + so the message must not send a reader to delete one. The contest + sibling asserts the partner IS named for its row. + + The contest section is EMPTIED first: a flagless run globs only + the fixture corpus, so every shipped 1.4.0 contest row is departed + on this run too, and the message would name the partner for those + -- correctly -- and the assertion below would be reading the wrong + rows.""" + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", {}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", + {"Nobody Here, Esq.": ("family",)}) + with pytest.raises(SystemExit) as exc: + _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, + corpus_flag=False) + message = str(exc.value) + assert "Nobody Here, Esq." in message + assert "restore" in message and "delete" in message + assert "_WATCHED_DIFFS" in message + assert "_CROSS_RULE_WINNERS" not in message, message + assert not _WORKER_CALL, ( + "main() spawned the worker before refusing the roster row") + + +def test_a_name_in_both_rosters_is_refused_pre_worker( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A row is one kind or the other, and the run cannot pick: the two + kinds carry different severities and different repairs. Refused + before the worker, like the other roster refusals, and under + `--corpus` too -- the overlap is a fact about the dicts, not about + which names this run read.""" + 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", + {"John Smith": ("family",)}) + with pytest.raises(SystemExit, match="sit in both") as exc: + _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS) + message = str(exc.value) + assert "John Smith" in message + assert "_RECORDED_DIFFS" in message and "_WATCHED_DIFFS" in message + assert not _WORKER_CALL, ( + "main() spawned the worker before refusing the overlap") + + +@pytest.mark.parametrize("dict_name", ["_RECORDED_DIFFS", "_WATCHED_DIFFS"]) +def test_a_ledger_missing_from_a_shape_roster_is_refused_pre_worker( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + dict_name: str) -> None: + """An empty section is a statement and a missing one is nobody + having looked, and a `.get(..., {})` read the two alike: with the + 1.4.0 key deleted from _WATCHED_DIFFS, a full run at that baseline + checked 41 rows fewer, printed the same 375 lines and exited 0. + So the run refuses, pre-worker, naming the dict and the ledger -- + the guards in test_ledger_guards.py hold the same key equality at + pytest speed, and the tool may not assume they ran.""" + monkeypatch.delitem(getattr(compare, dict_name), + "expected_since_1.4.0.toml") + with pytest.raises(SystemExit, match="carry no section") as exc: + _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS) + message = str(exc.value) + assert dict_name in message + assert "expected_since_1.4.0.toml" in message + assert "test_ledger_guards.py" in message + assert not _WORKER_CALL, ( + "main() spawned the worker before refusing the missing section") + + +def test_the_departed_name_refusal_names_the_partner_per_roster( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Both rosters departed on one run: one refusal, both names, and + the _CROSS_RULE_WINNERS sentence once -- under the contest name + and not under the watched one. The two single-roster siblings + above each see one list and cannot tell a per-list sentence from + one that rides the whole message.""" + monkeypatch.setitem(compare._RECORDED_DIFFS, + "expected_since_1.4.0.toml", + {"Nobody Contested, Esq.": ("family",)}) + monkeypatch.setitem(compare._WATCHED_DIFFS, + "expected_since_1.4.0.toml", + {"Nobody Watched, Esq.": ("family",)}) + with pytest.raises(SystemExit, match="no corpus holds any more") as exc: + _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, + corpus_flag=False) + message = str(exc.value) + assert "2 recorded diff shape(s)" in message, message + assert message.count("_CROSS_RULE_WINNERS") == 1, message + contest_at = message.index("Nobody Contested, Esq.") + watched_at = message.index("Nobody Watched, Esq.") + partner_at = message.index("_CROSS_RULE_WINNERS") + watched_lead = message.index("in _WATCHED_DIFFS, pinning no winner") + assert partner_at < contest_at < watched_lead < watched_at, message + assert not _WORKER_CALL + + +def _run_main_over( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + corpora: list[tuple[str, str, list[object]]], + rows: list[dict], + ledger_body: str, + baseline: str = "2.0.0") -> tuple[int, str]: + """_run_main's multi-corpus, order-bearing sibling. + + _run_main writes ONE corpus of bare-string lines, so it cannot + build a name compared under a declared order, nor one string held + by two files of different tiers -- and the watched-row tier rule + is stated over exactly those two shapes. `corpora` is + (filename, tier, lines) per file, the lines in either corpus + format; `rows` are the worker's rows in the order main() will send + the entries, which is contract files first and then by filename, + each file in line order. A row that agrees with the tree on every + field is _tree_v2_row(name, order); the facade half is omitted for + an order-bearing entry, as the worker omits it. + """ + import contextlib + import io + import json + import sys + argv = ["compare.py", "--baseline", baseline] + for filename, tier, lines in corpora: + path = tmp_path / filename + path.write_text("\n".join(json.dumps(x) for x in lines) + "\n", + encoding="utf-8") + monkeypatch.setitem(compare._CORPUS_FLOORS, filename, 1) + monkeypatch.setitem(compare._CORPUS_TIERS, filename, tier) + argv += ["--corpus", str(path)] + (tmp_path / f"expected_since_{baseline}.toml").write_text( + ledger_body, encoding="utf-8") + monkeypatch.setattr(compare, "HERE", tmp_path) + + def _fake(v: str, w: bool, n: list[dict]) -> tuple[dict, list[dict]]: + return ({"__version__": v, + "__file__": "/wheel/nameparser/__init__.py"}, rows) + + monkeypatch.setattr(compare, "_run_worker", _fake) + monkeypatch.setattr(sys, "argv", argv) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + code = compare.main() + return code, buf.getvalue() + + +def _default_order_diff(name: str, family: str) -> dict: + """A baseline row for `name` under the default order whose facade + disagrees with the tree on the family alone, and whose v2 half + agrees -- so the run's only diff on the name is {family}.""" + from nameparser import HumanName + given = name.split()[0] + return {"facade": {"title": "", "first": given, "middle": "", + "last": family, "suffix": "", "nickname": "", + "maiden": "", + "_initials": HumanName(name).initials() or ""}, + "v2": _tree_v2_row(name, None)} + + +def test_a_watched_row_reads_the_default_order_entrys_tier( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The 'John Smith, Dr.' shape: one string, contract in one file + only under a declared order and radar in another under the + default. The shape was measured on the default-order comparison, + so that entry's tier decides -- radar, printed, exit 0 -- although + the contract entry loaded FIRST. A rule reading the first-loaded + entry always would fail this run; one reading order-None entries + only would pass it, and the declared-order-only sibling below is + what refuses that one.""" + monkeypatch.setitem(compare._WATCHED_DIFFS, "expected_since_2.0.0.toml", + {"John Smith": ("nickname",)}) + code, out = _run_main_over( + tmp_path, monkeypatch, + [("corpus.jsonl", "radar", ["John Smith"]), + ("corpus_rules.jsonl", "contract", + [{"name": "John Smith", "shape": 4}])], + # contract file first, so its FAMILY_FIRST entry is sent first + [{"v2": _tree_v2_row("John Smith", "FAMILY_FIRST")}, + _default_order_diff("John Smith", "SMYTHE")], + _CLAIMS_FAMILY) + assert "MOVED SHAPE (radar) expected_since_2.0.0.toml: 1 watched" in out, out + assert "This fails the run" not in out, out + assert code == 0, out + + +@pytest.mark.parametrize(("tier", "lead", "code"), [ + ("contract", "MOVED SHAPE expected_since_2.0.0.toml: 1 watched", 1), + ("radar", "MOVED SHAPE (radar) expected_since_2.0.0.toml: 1 watched", 0), +]) +def test_a_watched_row_on_a_declared_order_only_name_reads_its_first_entry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tier: str, lead: str, code: int) -> None: + """A name compared under a declared order ALONE has no + default-order entry, so the first-loaded one decides -- and it + must decide something: the row reports with `measured` None + (nothing compared the name under the default order), which is a + mismatch owed a tier like any other. A tier map built over + order-None entries only has no key for the name and dies + mid-report on it. + + 'Jane Smith' keeps the ledger rule awake: with no default-order + diff at all the rule would be EXPLAINED NOTHING, exit 1 for a + reason that is not the row's, and the contract case could not + be told from it.""" + monkeypatch.setitem(compare._WATCHED_DIFFS, "expected_since_2.0.0.toml", + {"John Smith": ("family",)}) + got, out = _run_main_over( + tmp_path, monkeypatch, + [("corpus_x.jsonl", tier, + [{"name": "John Smith", "shape": 4}, "Jane Smith"])], + [{"v2": _tree_v2_row("John Smith", "FAMILY_FIRST")}, + _default_order_diff("Jane Smith", "SMYTHE")], + _CLAIMS_FAMILY) + assert lead in out, out + assert "measured no default-order diff" in out, out + # the two-cause disclaimer: the check cannot say whether the + # parser stopped moving the name or the name is compared only + # under a declared order, and here it is the second + assert "TWO reach it" in out, out + assert "EXPLAINED NOTHING" not in out, out + assert got == code, 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, + naming the file and the value, before any worker runs. Not a + partition test: main()'s `!= "radar"` split would put a misspelled + tier on the fatal side, but that would be the side the `!=` + happens to fall on, and no such value reaches the split now.""" + with pytest.raises(SystemExit, match="neither 'contract' nor 'radar'") as exc: + _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, + tier="bogus") + message = str(exc.value) + assert "corpus_x.jsonl" in message + assert "'bogus'" in message + assert not _WORKER_CALL, ( + "main() spawned the worker before refusing the tier value") def test_radar_diff_with_no_rule_exits_0_and_is_reported( diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index 93b2966e..0cc14ec0 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -2749,19 +2749,108 @@ def test_the_recorded_rule_still_wins_each_contested_name() -> None: f"was the odd one out.") +def _check_shape_rows(compare: ModuleType, roster: str, ledger_name: str, + shapes: dict[str, tuple[str, ...]]) -> None: + """The per-row checks both shape rosters are held to. A contest + row is fed to classify() as the same kind of input a rule's + `fields` is; a watched row is fed to nothing -- the run compares + it against a diff classify() explained -- and the two are held to + one vocabulary for that reason, since a shape spelled outside it + is one no run can produce and so one no run can contradict. A + departed name agrees with itself forever in either, so the checks + are one function applied twice rather than two copies that drift. + `roster` is the dict's name, for the messages. + test_every_pinned_winner_has_a_recorded_shape's docstring carries + why each assertion is here rather than left to the gate; + test_the_shape_row_checks_refuse_each_illegal_row pins that each + one fires. + """ + # For the message below: which live rows carry '_ambiguities', + # read from the dicts rather than named, so the example cannot + # go stale as rows move. + ambiguity_rows = sorted( + (ledger, name) + for roster_dict in (compare._RECORDED_DIFFS, compare._WATCHED_DIFFS) + for ledger, section in roster_dict.items() + for name, row in section.items() if "_ambiguities" in row) + # `gone`'s question, at pytest speed and against every ledger + # rather than only the one a run was pointed at. The gate asks + # it over the corpora a full run loaded; _CORPUS_NAMES is every + # name in every corpus*.jsonl, which is the same population. + departed = sorted(set(shapes) - set(_CORPUS_NAMES)) + assert not departed, ( + f"{ledger_name}: {departed} carry a recorded shape in {roster} " + f"and sit in no corpus. Nothing measures such a row, so it " + f"agrees with itself forever. Settle whether the name left " + f"DELIBERATELY before editing either roster -- " + f"compare.py's own refusal over the full corpus carries the " + f"question and both repairs") + for name, shape in shapes.items(): + where = f"{ledger_name}: the {roster} shape for {name!r}" + bad = sorted(set(shape) - compare._RULE_FIELDS) + assert not bad, ( + f"{where} names {bad}, which are not roles; expected " + f"from {sorted(compare._RULE_FIELDS)}. classify() is " + f"asked about the shape as a SET, so a misspelled role " + f"is simply a role the diff does not carry: it never " + f"reports as a typo, only as a rule handover or a " + f"MOVED SHAPE finding blaming the parser. Checked " + f"against _RULE_FIELDS -- the set validate_rules checks " + f"a rule's 'fields' against, since a shape is the same " + f"kind of input -- and not against V2_FIELDS, because " + f"'_ambiguities' is legal in a 2.x row (it cannot " + f"appear below 2.0), and {len(ambiguity_rows)} live " + f"row(s) carry it today" + + (f", e.g. {ambiguity_rows[0][1]!r} at " + f"{ambiguity_rows[0][0]}" if ambiguity_rows else "")) + dups = sorted({f for f in shape if shape.count(f) > 1}) + assert not dups, ( + f"{where} repeats {dups}. It is read as a set, so the " + f"repeat changes nothing classify() matches -- it is a " + f"copy-paste slip that would otherwise pass every check " + f"here silently, and the '_initials' check below would " + f"read ('_initials', '_initials') as '_initials' alone") + others = sorted(set(shape) - {"_initials"}) + assert not ("_initials" in shape and others), ( + f"{where} lists '_initials' beside {others}. " + f"'_initials' enters a diff only when every role and " + f"the ambiguity kinds agree (#484), so no diff can " + f"carry it with another field: this shape is one no run " + f"can measure, and the row is unfalsifiable rather than " + f"merely wrong") + assert shape, ( + f"{where} is empty. main() appends to `diffing` only " + f"where a comparison DIFFED, so no run produces an " + f"empty shape and the row can never be contradicted") + + def test_every_pinned_winner_has_a_recorded_shape() -> None: """The roster names a contested name; _RECORDED_DIFFS says what it diffs. A name in one and not the other is a half-recorded pin -- the winner cannot be checked without the shape, and a shape nothing pins a winner for is unverified by the run for no purpose. + EQUALITY, in both directions, and the second is the one to defend + now that a shape CAN legally stand without a winner. It stands in + compare._WATCHED_DIFFS, not here: a shape with no winner is not an + orphan this roster tolerates but a row in the wrong dict, since + the two dicts carry different contracts (a shape beside a winner + adjudicates a contest; a shape alone watches a name no winner is + pinned for) and compare.py reads the severity and the repair text off + which dict a row sits in. So a shape here with no winner is a + deleted-winner slip -- the pin went and the shape was forgotten -- + and relaxing this to `<=` would make that slip indistinguishable + from a deliberate watched row. The watched roster's own guard is + test_the_watched_roster_is_disjoint_and_names_every_ledger. + The row VALUES are checked here too, against the three illegal states compare.validate_rules already refuses for a rule's `fields` (a name outside the role vocabulary, '_initials' beside a role, a repeated role) plus the empty one. A recorded shape is fed to classify() as the same kind of input a rule's `fields` is, so the - wording below mirrors those refusals deliberately -- the two should - read as one check written in two places. + wording mirrors those refusals deliberately -- the two should + read as one check written in two places. _check_shape_rows holds + them, and the watched roster's guard applies the same function. WHY HERE. What each illegal row does WITHOUT these assertions, measured 2026-09-03 by injecting one into the shipped 1.4.0 section @@ -2787,53 +2876,10 @@ def test_every_pinned_winner_has_a_recorded_shape() -> None: assert set(winners) == set(shapes), ( f"{ledger_name}: winners without a recorded shape " f"{sorted(set(winners) - set(shapes))}; shapes with no " - f"pinned winner {sorted(set(shapes) - set(winners))}") - # `gone`'s question, at pytest speed and against every ledger - # rather than only the one a run was pointed at. The gate asks - # it over the corpora a full run loaded; _CORPUS_NAMES is every - # name in every corpus*.jsonl, which is the same population. - departed = sorted(set(shapes) - set(_CORPUS_NAMES)) - assert not departed, ( - f"{ledger_name}: {departed} carry a recorded shape and sit " - f"in no corpus. Nothing measures such a row, so it agrees " - f"with itself forever. Settle whether the name left " - f"DELIBERATELY before editing either roster -- " - f"compare.py's own refusal over the full corpus carries the " - f"question and both repairs") - for name, shape in shapes.items(): - where = f"{ledger_name}: the recorded shape for {name!r}" - bad = sorted(set(shape) - compare._RULE_FIELDS) - assert not bad, ( - f"{where} names {bad}, which are not roles; expected " - f"from {sorted(compare._RULE_FIELDS)}. classify() is " - f"asked about the shape as a SET, so a misspelled role " - f"is simply a role the diff does not carry: it never " - f"reports as a typo, only as a rule handover or a " - f"MOVED SHAPE finding blaming the parser. Checked " - f"against _RULE_FIELDS -- the set validate_rules checks " - f"a rule's 'fields' against, since a shape is the same " - f"kind of input -- and not against V2_FIELDS, because " - f"'_ambiguities' is legal in a 2.x row (it cannot " - f"appear below 2.0) even though no row carries it today") - dups = sorted({f for f in shape if shape.count(f) > 1}) - assert not dups, ( - f"{where} repeats {dups}. It is read as a set, so the " - f"repeat changes nothing classify() matches -- it is a " - f"copy-paste slip that would otherwise pass every check " - f"here silently, and the '_initials' check below would " - f"read ('_initials', '_initials') as '_initials' alone") - others = sorted(set(shape) - {"_initials"}) - assert not ("_initials" in shape and others), ( - f"{where} lists '_initials' beside {others}. " - f"'_initials' enters a diff only when every role and " - f"the ambiguity kinds agree (#484), so no diff can " - f"carry it with another field: this shape is one no run " - f"can measure, and the row is unfalsifiable rather than " - f"merely wrong") - assert shape, ( - f"{where} is empty. main() appends to `diffing` only " - f"where a comparison DIFFED, so no run produces an " - f"empty shape and the row can never be contradicted") + f"pinned winner {sorted(set(shapes) - set(winners))} -- a " + f"shape that is meant to stand without a winner belongs in " + f"compare._WATCHED_DIFFS, not here") + _check_shape_rows(compare, "_RECORDED_DIFFS", ledger_name, shapes) # Per-ledger equality above iterates the ROSTER's keys, so a # _RECORDED_DIFFS section for a ledger the roster does not name is # invisible to it -- and to the run, which reads shapes per ledger @@ -2848,6 +2894,98 @@ def test_every_pinned_winner_has_a_recorded_shape() -> None: f"{sorted(set(_CROSS_RULE_WINNERS) - set(compare._RECORDED_DIFFS))}") +def test_the_watched_roster_is_disjoint_and_names_every_ledger() -> None: + """compare._WATCHED_DIFFS holds the shape of a name no winner is + pinned for -- most of them sole-watched, four of them #501's + contests. Three things are checked here, at pytest speed, because + the gate checks them only for the one ledger a run was pointed + at: the first is the sibling rosters' key convention, applied to + this dict; the second and third are what the dict's header + promises. + + Every ledger on disk is a key -- the same equality the other two + rosters use, and for the same reason: an empty section is a + statement (this ledger has no watched name) where a missing one is + nobody having looked; compare.py's main() refuses a ledger missing + from either shape roster pre-worker on the same ground, so the + guard and the run agree. Per ledger, no name sits in both shape + rosters, since a row is one kind or the other and compare.py reads + a row's severity and its repair text off the dict it is in. And, + stated on its own rather than left to follow from the first two: + no watched row has a winner in _CROSS_RULE_WINNERS. It DOES follow + -- every winner has a _RECORDED_DIFFS row, and no watched name has + one -- but "no watched row has a winner" is the sentence the dict + exists on, and a reader should find it as its own assertion rather + than derive it. + + The row values are held to _check_shape_rows, as the contest + roster's are: a watched shape is fed to classify() by nothing + today, but the gate compares it against a run's diff, and a shape + no run can produce is a row that can never be contradicted. + + And the dict must hold SOME row, the sibling `assert checked` + idiom: every check above is per row, so an all-empty roster passes + all of them vacuously, and a ledger with no watched name is an + empty section beside filled ones, not an all-empty dict. + """ + compare = load_tool("compare") + on_disk = {led.name for led in _LEDGERS} + assert set(compare._WATCHED_DIFFS) == on_disk, ( + f"_WATCHED_DIFFS must name every ledger on disk, with an " + f"explicit empty mapping for one that has no watched name. " + f"Missing: {sorted(on_disk - set(compare._WATCHED_DIFFS))}; " + f"unknown: {sorted(set(compare._WATCHED_DIFFS) - on_disk)}") + for ledger_name, watched in compare._WATCHED_DIFFS.items(): + # `.get`, both here and for the winners below: a ledger this + # dict names that either sibling lacks is the every-ledger + # equality's finding, in its own words, not a bare KeyError. + recorded = compare._RECORDED_DIFFS.get(ledger_name, {}) + both = sorted(set(watched) & set(recorded)) + assert not both, ( + f"{ledger_name}: {both} sit in both _RECORDED_DIFFS and " + f"_WATCHED_DIFFS. A row is one kind or the other: a name " + f"with an argument behind it belongs in _RECORDED_DIFFS " + f"alone, since the argument is the stronger claim -- " + f"delete the _WATCHED_DIFFS row") + pinned = sorted(set(watched) + & set(_CROSS_RULE_WINNERS.get(ledger_name, {}))) + assert not pinned, ( + f"{ledger_name}: {pinned} carry a watched shape AND a " + f"pinned winner. A winner pin asserts an argument, and the " + f"day one is argued the row moves to _RECORDED_DIFFS -- it " + f"does not keep a _WATCHED_DIFFS row beside the pin") + _check_shape_rows(compare, "_WATCHED_DIFFS", ledger_name, watched) + assert any(compare._WATCHED_DIFFS.values()), ( + "every section of _WATCHED_DIFFS is empty, so every per-row " + "check above was vacuous. A ledger with no watched name is an " + "empty section beside filled ones; an all-empty dict is the " + "roster deleted, and its header's population says which rows " + "belong") + + +@pytest.mark.parametrize(("row", "sentence"), [ + ({"John Smith": ("famly",)}, "are not roles"), + ({"John Smith": ("family", "family")}, "repeats"), + ({"John Smith": ("_initials", "family")}, "beside"), + ({"John Smith": ()}, "is empty"), + ({"Nobody Here, Esq.": ("family",)}, "sit in no corpus"), +]) +def test_the_shape_row_checks_refuse_each_illegal_row( + row: dict[str, tuple[str, ...]], sentence: str) -> None: + """The negative control for _check_shape_rows, one case per + illegal row kind, under the _WATCHED_DIFFS label. Its sensitivity + was previously a hand measurement cited in + test_every_pinned_winner_has_a_recorded_shape's docstring -- an + injected row and a suite run -- which nothing re-ran; a helper + that stopped firing on one of these would have left both rosters' + guards green and said nothing.""" + compare = load_tool("compare") + with pytest.raises(AssertionError, match=sentence) as exc: + _check_shape_rows(compare, "_WATCHED_DIFFS", + "expected_since_1.4.0.toml", row) + assert "_WATCHED_DIFFS" in str(exc.value) + + def test_the_family_first_fold_is_not_explained_under_the_default_order( ) -> None: """The hazard the `orders` key exists for, pinned against the diff --git a/tools/differential/README.md b/tools/differential/README.md index 23c8eb11..384c7564 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -242,14 +242,26 @@ than a note about those five. A stays UNEXPLAINED and fails the run even when the name itself sits in a radar file. -A recorded diff shape (`MOVED SHAPE`, below) is the second thing that -outranks the tier: it feeds the exit code without asking what tier the -name sits in. Measured 2026-09-03, MOST of the rows in -`_RECORDED_DIFFS['expected_since_1.4.0.toml']` sit on radar-tier names --- 21 of 31 -- so radar PARSER DRIFT can fail the run wherever someone -has pinned a shape (RECOMPUTE: for each row, take the tier of the first -corpus file holding the name with contract files sorted first, as -`main()` loads them). Which of the two outranks the tier more WIDELY is +A CONTEST row -- a recorded diff shape with a winner pinned beside it +(`MOVED SHAPE`, below) -- is the second thing that outranks the tier, +and the rule is stated rather than inherited: it is fatal on both +tiers because it carries an argument, and a moved shape has made that +argument's premise false. A WATCHED row -- a shape recorded alone, in +`_WATCHED_DIFFS` -- does not outrank the tier: it follows the tier of +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 -- 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 @@ -605,16 +617,18 @@ and the run still exits 0 (#372). Two tiers ask: One limit worth knowing. Only the ledger for the baseline being run gets the dynamic check, so the release checklist runs `compare.py` at every baseline -that has a ledger with rules -- a ledger left out of the checklist gets no +that has a ledger with rules -- a ledger the checklist does not reach gets no dynamic dormancy check at all, and a `dormant` declaration in it goes -unaudited. Adding a new ledger means adding its baseline to the checklist. -Measured 2026-09-03, that has already slipped: the root `AGENTS.md` checklist -lists THREE commands -- 1.4.0, the bare default, and 2.0.0 -- against four -ledgers carrying rules, so `expected_since_2.1.0.toml` is the one no listed -command reaches. The over-declaration check (#452) and the recorded-shape -check (#497) are per-ledger for the same reason the dormancy one is -- the -latter reads `_RECORDED_DIFFS[ledger.name]` -- so a ledger left out of the -checklist gets none of the three. +unaudited. The root `AGENTS.md` checklist DERIVES its baselines from the +`expected_since_*.toml` glob, so a new ledger is reached at the next release +without a line being added; it enumerated three commands against four +ledgers until #503 replaced the list with the loop (the dated record is in +`AGENTS.md`, at the step). The over-declaration check (#452) and the +recorded-shape check (#497) are per-ledger for the same reason the dormancy +one is -- the latter reads `_RECORDED_DIFFS[ledger.name]` and +`_WATCHED_DIFFS[ledger.name]`, and refuses pre-worker a ledger either dict +has no section for -- so a ledger the loop did not reach would get none of +the three. The static tier still exempts a rule on the mere PRESENCE of `dormant`; it never asks whether the reason is still true. Only the dynamic check can @@ -753,19 +767,19 @@ would delete an exemption the full gate needs and then fail the full run for the undeclared contest that reappears. So a vacancy is a hard failure on a full run and a printed NOTE under `--corpus`. -Four checks now read the flag differently, and the differences are deliberate rather than untidy -- read them together before making any of them uniform. The corpus-floor roster is SKIPPED entirely under `--corpus`, because narrowing is the point of the flag. `over_declared_rules` still FAILS the run and appends a NOTE saying the union it computed is over a subset, so its repair advice is not followed blindly. The vacancy check does not fail at all, because its verdict INVERTS under narrowing rather than merely its evidence. The departed-name half of the recorded-shape check (below) inverts the same way and goes one step further, printing nothing at all under `--corpus`: a NOTE there would name most of the roster and tell the reader nothing, where the vacancy NOTE names a handful. +Four checks now read the flag differently, and the differences are deliberate rather than untidy -- read them together before making any of them uniform. The corpus-floor roster is SKIPPED entirely under `--corpus`, because narrowing is the point of the flag. `over_declared_rules` still FAILS the run and appends a NOTE saying the union it computed is over a subset, so its repair advice is not followed blindly. The vacancy check does not fail at all, because its verdict INVERTS under narrowing rather than merely its evidence. The departed-name half of the recorded-shape check (below) inverts the same way and goes one step further, printing nothing at all under `--corpus`: a NOTE there would name most of the roster and tell the reader nothing, where the vacancy NOTE names a handful. The shape blocks themselves -- `MOVED SHAPE` on either roster, and `MOVED SHAPE (radar)` -- read no flag at all, and the section below says why that is deliberate rather than a fifth difference. -### `MOVED SHAPE`: the roster's recorded diffs, checked against a run +### `MOVED SHAPE`: the two rosters' recorded diffs, checked against a run `tests/v2/test_ledger_guards.py` carries `_CROSS_RULE_WINNERS`, a roster pinning which rule should win a contested name. To ask that question it needs the name's diff SHAPE, which it feeds to `classify()` as an input -- so the shape is never itself checked, and a guessed one agrees with itself forever. That is how `田中さん II` sat recorded as `{given, suffix}` under a docstring promising the shapes were measured; the real diff is `{family, given, suffix}` (#497). The unit suite cannot catch it: it spawns no worker, deliberately, so it has no measured diff to compare against. -So the shapes live in `_RECORDED_DIFFS` in `compare.py`, keyed per ledger (a string moves a different set of roles against different baselines), and every run checks them. THREE things come out of it -- two findings and a note -- and they behave differently on purpose. +So the shapes live in `compare.py`, keyed per ledger (a string moves a different set of roles against different baselines), in TWO dicts under two contracts, and every run checks both. `_RECORDED_DIFFS` is the CONTEST roster: a shape beside a winner ADJUDICATES a contest -- it records what a name diffs so that `_CROSS_RULE_WINNERS` can ask `classify()` which rule wins it, and every row has a partner pin there (the guard holds `set(winners) == set(shapes)` per ledger, in both directions). `_WATCHED_DIFFS` is the WATCHED roster: a shape alone, for a name NO WINNER IS PINNED FOR. Most of its rows are also sole-watched -- radar names that no test names and no contract corpus holds, whose only other watcher is the classification rule explaining their diff, and a rule asserts that a diff is intended, not what it IS, so a shape moving inside the rule's `fields` moved silently before the row existed. The rest are the four #501 contests at 2.0.0, measured and unadjudicated: a `Case(...)` row in `tests/v2/cases.py` already pins each one's PARSE, and what nobody has argued is which RULE explains its diff, so read the case row before the roster. The two dicts are disjoint per ledger, and a name in both is refused pre-worker, beside the departed-name refusal, as the guard refuses it at pytest speed: a row is one kind or the other, and the run reads a row's severity and its repair text off the dict it sits in. The day a winner is argued for a watched name, the row MOVES to `_RECORDED_DIFFS` and the pin goes beside it there; nothing relaxes the equality. Out of the pair come two findings, a non-fatal block and a note, and they behave differently on purpose. -- **`MOVED SHAPE`** -- a recorded shape the run contradicts. Printed after the comparison, alongside `EXPLAINED NOTHING` and `OVER-DECLARED`, and it feeds the exit code the same way. It does not raise: a refusal there would land mid-report and take the `UNEXPLAINED` block down with it, and a stale roster row must never hide an unexplained diff. A moved shape is a FINDING, not a number to update, and it names no cause because it cannot: the parser may have changed what the name does, or the row may have been wrong when it was recorded -- which is what #497 found, four rows recording a shape no run makes under a roster promising the shapes were measured. Either way the winner pinned beside it in `_CROSS_RULE_WINNERS` was recorded against the OLD shape, so read both before editing either. Where the run measured no default-order diff at all, the report says so and names no cause: two states reach it (the parser stopped moving the name, or the name is compared only under a declared order) and the check cannot separate them. -- **A row naming a name no corpus holds** -- refused, before the worker runs, on a FULL run only. Nothing measures such a row, so it agrees with itself forever, which is the defect above in a second form. The repair is not mechanical: ask first whether the name left deliberately (`git log -S'' -- tools/differential/corpus*.jsonl`), then either delete the row and its `_CROSS_RULE_WINNERS` partner, or restore the name to the corpus that lost it. Under `--corpus` this prints nothing, since a narrowed run legitimately holds almost none of the roster. -- **`NOT CHECKED`** -- a NOTE rather than a finding, printed after the comparison and deliberately outside the exit code (#497, `9360919`). It names recorded rows whose corpus entry THIS BASELINE SKIPPED: an order-bearing entry a baseline with no `Policy` cannot honor is dropped before the comparison runs, so there is no measured diff to check the row against. Such a row falls between the two checks above -- `MOVED SHAPE` skips a name it did not compare, and the departed-name refusal passes it because the skip takes a name out of the RUN and out of no file -- and without the note it is checked by neither and reported by neither. So the note says which rows the shape report is silent about and claims nothing else. Do NOT delete a row over it: the name is in a corpus this run read. Re-run at a baseline that can honor its order to check it. Measured at 1.4.0, which is both the only baseline where the skip fires and the only ledger with rows, the window is `de Mesnil Jean, Dr.`, `de la Cruz Juan Carlos, Dr.` and `de la Cruz née Vega` -- none carrying a roster row today, so the note prints on no run yet. "Do not refuse" and "say nothing" are two decisions, and only the first was ever argued. +- **`MOVED SHAPE`** -- a recorded shape the run contradicts. Printed after the comparison, alongside `EXPLAINED NOTHING` and `OVER-DECLARED`, and it feeds the exit code the same way. It does not raise: a refusal there would land mid-report and take the `UNEXPLAINED` block down with it, and a stale roster row must never hide an unexplained diff. A moved shape is a FINDING, not a number to update, and it names no cause because it cannot: the parser may have changed what the name does, or the row may have been wrong when it was recorded -- which is what #497 found, four rows recording a shape no run makes under a roster promising the shapes were measured. SEVERITY follows the row's kind first and the tier second, and the run prints one block per case, contest first. A CONTEST row is fatal on both tiers: it carries an argument, and the winner pinned beside it in `_CROSS_RULE_WINNERS` was recorded against the OLD shape, so read both before editing either. A WATCHED row on a contract-tier name is fatal too, as an unexplained diff on that name would be; its block names no partner, because no winner is pinned for it, and the repair it gives is the only one a snapshot admits -- if the move is intended, re-record the shape in `_WATCHED_DIFFS` in the commit that moved it and say why there. A WATCHED row on a radar-tier name prints under **`MOVED SHAPE (radar)`**, parallel to `UNCLASSIFIED (radar)`, and feeds no exit code: fatal-on-radar is reserved for a per-name deliberate choice -- a `[[never]]` entry with its `why`, or a contest row with its pinned winner -- and a measured snapshot is neither; a gate whose repair is "record whatever it does now" is a changelog entry in a gate's clothing. The tier a watched row follows is the DEFAULT-ORDER ENTRY's where the name has one, since that is the comparison the shape was measured on, and the first-loaded (contract-first) entry's only where it has none. `'John Smith, Dr.'` is the case that separates the two readings: contract in `corpus_shapes.jsonl` only as shape 4 (`FAMILY_FIRST`) and radar in `corpus_issues.jsonl` under the default order. It carries no watched row today -- a test names it, so it is not sole-watched -- but a watched row on it would print and not fail, and the family-first promise would be untouched. Where the run measured no default-order diff at all, the report says so and names no cause: two states reach it (the parser stopped moving the name, or the name is compared only under a declared order) and the check cannot separate them. +- **A row naming a name no corpus holds** -- refused, before the worker runs, on a FULL run only, over the UNION of the two dicts: a watched row is measured by nothing else either. Nothing measures such a row, so it agrees with itself forever, which is the defect above in a second form. The repair is not mechanical: ask first whether the name left deliberately (`git log -S'' -- tools/differential/corpus*.jsonl`), then either delete the row -- and, for a contest row only, its `_CROSS_RULE_WINNERS` partner; the message lists the two kinds apart so that a watched-row reader is not sent to delete a partner that does not exist -- or restore the name to the corpus that lost it. Under `--corpus` this prints nothing, since a narrowed run legitimately holds almost none of either roster. +- **`NOT CHECKED`** -- a NOTE rather than a finding, printed after the comparison and deliberately outside the exit code (#497, `9360919`). It names rows in either dict whose corpus entry THIS BASELINE SKIPPED: an order-bearing entry a baseline with no `Policy` cannot honor is dropped before the comparison runs, so there is no measured diff to check the row against. Such a row falls between the two checks above -- `MOVED SHAPE` skips a name it did not compare, and the departed-name refusal passes it because the skip takes a name out of the RUN and out of no file -- and without the note it is checked by neither and reported by neither. So the note says which rows the shape report is silent about and claims nothing else. Do NOT delete a row over it: the name is in a corpus this run read. Re-run at a baseline that can honor its order to check it. Measured at 1.4.0, which is the only baseline where the skip fires, the window is `de Mesnil Jean, Dr.`, `de la Cruz Juan Carlos, Dr.` and `de la Cruz née Vega` -- none carrying a row in either roster today, so the note prints on no run yet. "Do not refuse" and "say nothing" are two decisions, and only the first was ever argued. -The two CHECKS read opposite name lists, and swapping them is the live hazard. "Was this name compared?" is about the RUN, so the shape check reads the list AFTER the baseline-minimum shape skip. "Does any corpus still hold this name?" is about the FILES, and the skip empties no file, so the departed-name check reads the list BEFORE it. The note reads both, being exactly the difference: the roster intersected with the pre-skip list, minus the post-skip one. TWO of the three consult `full_corpus` not at all, and the departed-name refusal is the only one that reads it: the note's intersection already narrows itself under `--corpus`, and `MOVED SHAPE` never asks the flag either -- it is silent under `--corpus` only about rows whose name this run did not compare, so a narrowed run holding a pinned name still reports and still FAILS. Measured 2026-09-03: `--corpus corpus_issues.jsonl` at 1.4.0 with a corrupted shape on `Carod i` prints `MOVED SHAPE` and exits 1 (RECOMPUTE by corrupting that row in `compare._RECORDED_DIFFS` in memory around `main()`). Do not read the departed-name suppression across to it -- a `MOVED SHAPE` from a narrowed run is a real finding, and a `full_corpus` gate added here would be a behavior change and not a restoration. `compare.py`'s comment at the note says the note parts "from the two checks that do read that flag", counting `vacant` and `gone` -- its own frame, and a different pair from the three listed here. `recorded_diff_mismatches`' docstring in `compare.py` carries the measurement and the recompute for the two lists. +The two CHECKS read opposite name lists, and swapping them is the live hazard. "Was this name compared?" is about the RUN, so the shape check reads the list AFTER the baseline-minimum shape skip. "Does any corpus still hold this name?" is about the FILES, and the skip empties no file, so the departed-name check reads the list BEFORE it. The note reads both, being exactly the difference: the union of the rosters intersected with the pre-skip list, minus the post-skip one. Of these, the departed-name refusal is the only one that reads `full_corpus`: the note's intersection already narrows itself under `--corpus`, and `MOVED SHAPE` never asks the flag, on either roster and in its `(radar)` form alike -- it is silent under `--corpus` only about rows whose name this run did not compare, so a narrowed run holding a pinned name still reports and, where the row is fatal, still FAILS. Measured 2026-09-03: `--corpus corpus_issues.jsonl` at 1.4.0 with a corrupted shape on `Carod i` prints `MOVED SHAPE` and exits 1 (RECOMPUTE by corrupting that row in `compare._RECORDED_DIFFS` in memory around `main()`). Do not read the departed-name suppression across to it -- a `MOVED SHAPE` from a narrowed run is a real finding, and a `full_corpus` gate added here would be a behavior change and not a restoration. `compare.py`'s comment at the note says the note parts "from the two checks that do read that flag", counting `vacant` and `gone` -- its own frame, and a different pair from the three listed here. `recorded_diff_mismatches`' docstring in `compare.py` carries the measurement and the recompute for the two lists. ### Shapes that must never be explained (`[[never]]`) diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 050fa061..7b4830ed 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -1651,6 +1651,12 @@ class _ShapeMismatch(NamedTuple): #: recorded for the OLD shape, so both want reading before either is #: edited. #: +#: Every row here HAS such a winner, and this dict and +#: _CROSS_RULE_WINNERS are held to name the same strings. A shape +#: measured for a name nothing pins a winner for does not go here: +#: _WATCHED_DIFFS below is the roster for a shape with no argument +#: behind it, and this dict and that one are kept disjoint. +#: #: HERE rather than beside the roster because this is where the #: measurement happens. main() computes every name's real diff, so a #: run can check these; the unit suite cannot, and deliberately -- it @@ -1739,6 +1745,264 @@ class _ShapeMismatch(NamedTuple): } +#: The recorded diff shape of a name NO WINNER IS PINNED FOR, per +#: ledger. A second roster under a different contract from the one +#: above, and the difference is the whole reason it is a second dict +#: rather than more rows in the first: a shape beside a winner +#: ADJUDICATES a contest -- _RECORDED_DIFFS records what a name diffs +#: so that _CROSS_RULE_WINNERS can ask classify() which rule wins it +#: -- where a shape alone WATCHES the name without adjudicating it. +#: "No winner pinned" is the property every row here has and two +#: checks enforce -- the disjointness guard in +#: tests/v2/test_ledger_guards.py and main()'s `both` refusal; it is +#: not "nothing else watches". MOST rows +#: are also sole-watched -- that is the population the sweep drew +#: (POPULATION below): a radar name that no test names and no +#: contract corpus holds is watched only by the classification rule +#: that explains its diff, and a rule is a weak watcher, asserting +#: that a diff here is intended and not what the diff IS, so a diff +#: that changes shape while staying inside the rule's `fields` moves +#: silently. A row here says what the diff is, and the move becomes +#: a finding. A CONTESTED row (the #501 block at 2.0.0) is the other +#: case: a Case(...) row in tests/v2/cases.py already adjudicates its +#: PARSE, and three of the four sit in contract corpora; what no one +#: has adjudicated is which RULE explains its diff. A reader of such +#: a row consults the case row before this roster. +#: +#: Four things follow from the split, and each is checked rather than +#: stated: +#: DISJOINT from _RECORDED_DIFFS, per ledger. A name is one kind of +#: row or the other. A name with BOTH an argument and no other +#: watcher belongs above, since the argument is the stronger claim. +#: test_the_watched_roster_is_disjoint_and_names_every_ledger +#: (tests/v2/test_ledger_guards.py) refuses an overlap at pytest +#: speed, and main() refuses it again pre-worker, because this tool +#: may not assume the suite ran. +#: NO WINNER, ever. A winner pin asserts an argument about which rule +#: should explain a name, and no row here carries one -- which is +#: what lets a row here be recorded from a measurement where a winner +#: cannot be. The day someone argues a winner, the row MOVES to +#: _RECORDED_DIFFS and the pin goes beside it there; it does not gain +#: a partner here. +#: MEASURED, from a run, and re-measured by every run. Everything +#: in main() that reads _RECORDED_DIFFS reads this dict too (the two +#: winner guards in tests/v2/test_ledger_guards.py read that dict +#: alone, correctly): the missing-section refusal pre-worker asks +#: each dict for a section, the departed-name refusal beside it and +#: the NOT CHECKED note read the literal UNION of the two, and the +#: shape comparison calls recorded_diff_mismatches once PER dict, +#: because the two halves carry different severities and the exit +#: code reads only one of them. A row in either dict that no run +#: can contradict is the defect #497 is about, whichever dict it +#: sits in. +#: SEVERITY follows the row's kind first and the name's tier second. +#: A contest row is fatal on either tier: it carries an argument, and +#: a moved shape has made that argument's premise false. A watched +#: row on a CONTRACT-tier name fails the run, as an unexplained diff +#: on that name would; on a RADAR-tier name it prints under MOVED +#: SHAPE (radar) and feeds no exit code. Fatal-on-radar is reserved +#: for a per-name deliberate choice -- a [[never]] entry with its +#: `why` (_CORPUS_TIERS), or a contest row with its pinned winner -- +#: and a measured snapshot is neither: the +#: only repair a fired snapshot admits is to re-snapshot, and a gate +#: whose repair is "record whatever it does now" is a changelog entry +#: in a gate's clothing. The tier is the DEFAULT-ORDER entry's when +#: the name has one, and the first-loaded entry's (contract whenever +#: any is) only when it does not -- a shape here is measured on the +#: default-order comparison, so that comparison's entry is the one +#: whose tier says whether the shape was promised, and a +#: baseline-scoped tier is a property of the ENTRY compared, not of +#: the files the string sits in (decisions.md, the rule-order arc). +#: So a name that is contract only under a declared order and radar +#: under the default reads radar here, and the declared-order promise +#: is untouched. +#: +#: POPULATION, measured 2026-09-05. A name has a row here where it is +#: SOLE-WATCHED at that baseline: held by a radar corpus and by no +#: contract corpus; named by no string literal anywhere under tests/ +#: outside test_ledger_guards.py -- every string ast.Constant, by exact +#: equality, since substring matching would score 'A. D.' a watcher of +#: every longer name containing it; diffing under the DEFAULT order at +#: that baseline AND explained by a ledger rule there -- a diff no +#: rule explains is already printed by every run as unclassified, so +#: a rule is the only weak watcher a row here is needed for (a +#: definition clause, not a live count: `radar unclassified` is 0 at +#: 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 +#: at 1.4.0 that carry contest rows there: 'Bob Jones, author', +#: 'Carod i', 'MD, PHD', 'van ma van'. Three of them return at a 2.x +#: baseline where they have no contest row; 'Carod i' diffs under the +#: default order at 1.4.0 only, so it has no row in this dict (its +#: contest row at 1.4.0 stands), which is +#: why the population is 51 names where the tests/-only scan says 52. +#: The counts: 41 / 32 + 4 / 31 / 5 rows, 113 in all, over the 51 +#: names plus the four #501 contests that close the 2.0.0 section. +#: 49 of the 51 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, +#: as the roster above's are, so no row here is a declared-order-only +#: diff for NOT CHECKED to name. +#: +#: RECOMPUTE: wrap classify() and drive main() unchanged at each +#: baseline, recording (name, order, diff, rule) per call; keep the +#: 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 / 32 / 31 / 5, since _CROSS_RULE_WINNERS' keys and +#: a few guard literals then score as watchers -- 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 +#: baseline-minimum skip and the tier stamp all happen inside main(), +#: and a name's membership is a property of the entry that run +#: compared. +#: +#: Two limits. The population is a LOWER bound: a literal in a test +#: that is not about parsing -- 'John Smith' in a TypeError test -- +#: scores as a watcher, so a name a test merely mentions has no row +#: here although nothing checks its parse. And nothing notices a NEW +#: sole-watched name arriving with no row. A completeness guard has +#: to answer "what else watches this name" mechanically, and its +#: other half, "diffs at some baseline", only a run with the wheel +#: knows -- so that guard is a run-time NOTE in the NOT CHECKED family +#: by necessity, not a pytest-speed roster check, and it is not here. +_WATCHED_DIFFS: dict[str, dict[str, tuple[str, ...]]] = { + "expected_since_1.4.0.toml": { + "1 & 2, 3 4 5, Mr.": ("_initials",), + "Aishwarya Rai": ("family", "suffix"), + "Anh do": ("_initials",), + "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",), + "Do Quang Minh": ("given", "middle", "title"), + "Donald mc": ("family", "suffix"), + "Dr 田中さん, V.": ("family", "given", "suffix"), + "Dr. Do Van Johnson, MD": ("family", "given"), + "Duke of Edinburgh": ("_initials",), + "Esq. van Gogh": ("family", "given"), + "Jack M.A.": ("family", "suffix"), + "Jane van der Berg 旧姓 Jones": ("family", "maiden"), + "Janey née Jones": ("family", "given", "maiden", "middle"), + "John V": ("family", "suffix"), + "John of the Doe": ("_initials",), + "Jong van der": ("_initials",), + "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"), + "QC MP": ("family", "suffix"), + "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"), + }, + "expected_since_2.0.0.toml": { + "Anh do": ("_initials",), + "Anna Müller (geb. Schmidt)": ("maiden", "nickname"), + "Bob Jones, author": ("family", "given"), + "Bob Jones, compositeur": ("family", "given"), + "Do Quang Minh": ("_ambiguities", "given", "middle", "title"), + "Dr 田中さん, V.": ("family", "given", "suffix"), + "Dr. Do Van Johnson, MD": ("family", "given"), + "E Anne D,Leonardo": ("_initials",), + "Esq. van Gogh": ("_ambiguities", "family", "given"), + "JOSE E MARIA SANTOS": ("_initials",), + "Jane van der Berg 旧姓 Jones": ("family", "maiden"), + "Janey née Jones": ("family", "given"), + "Joe E. Smith": ("_initials",), + "John, Smith, Dr.": ("_ambiguities",), + "Jong van der": ("_initials",), + "Jong, van der": ("_initials",), + "Jose E. Maria Santos": ("_initials",), + "MD, DO, DDS": ("given", "title"), + "MD, PHD": ("suffix", "title"), + "Mesnil Garcia van": ("_initials",), + "Ph. D., Jr.": ("family", "suffix", "title"), + "Sander van": ("_initials",), + "Smith Dr": ("family", "suffix"), + "Smith, John E, III, Jr": ("_initials",), + "Smith, Ph. D. Jr. MD": ("given", "suffix"), + "Smith, Ph. D. MD": ("given", "suffix"), + "Smith, Ph.D. Jr.": ("given", "suffix"), + "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"), + "van ma van": ("_initials",), + # The four #501 contests, measured and UNADJUDICATED: at 2.0.0 + # more than one rule admits each diff and file order alone + # picks the one that explains it, and nobody has argued which + # should -- a shape with no winner, which is the row kind this + # dict exists for. They move to _RECORDED_DIFFS the day #501 + # argues a winner, and the pin goes beside them there. Three + # are contract tier (corpus_cjk.jsonl; the first also + # corpus_rules.jsonl), so a move on them fails the run; the + # last is corpus_cjk_tolerated.jsonl, radar, and prints. + "田中さん 様.": ("family", "given", "suffix"), + "김민준 박사님": ("family", "given", "suffix"), + "선생님": ("family", "given"), + "田中さん, 様.": ("family", "given", "suffix"), + }, + "expected_since_2.1.0.toml": { + "Anh do": ("_initials",), + "Anna Müller (geb. Schmidt)": ("maiden", "nickname"), + "Bob Jones, author": ("family", "given"), + "Bob Jones, compositeur": ("family", "given"), + "Do Quang Minh": ("_ambiguities", "given", "middle", "title"), + "Dr. Do Van Johnson, MD": ("family", "given"), + "E Anne D,Leonardo": ("_initials",), + "Esq. van Gogh": ("_ambiguities", "family", "given"), + "JOSE E MARIA SANTOS": ("_initials",), + "Jane van der Berg 旧姓 Jones": ("family", "maiden"), + "Janey née Jones": ("family", "given"), + "Joe E. Smith": ("_initials",), + "John, Smith, Dr.": ("_ambiguities",), + "Jong van der": ("_initials",), + "Jong, van der": ("_initials",), + "Jose E. Maria Santos": ("_initials",), + "MD, DO, DDS": ("given", "title"), + "MD, PHD": ("suffix", "title"), + "Mesnil Garcia van": ("_initials",), + "Ph. D., Jr.": ("family", "suffix", "title"), + "Sander van": ("_initials",), + "Smith Dr": ("family", "suffix"), + "Smith, John E, III, Jr": ("_initials",), + "Smith, Ph. D. Jr. MD": ("given", "suffix"), + "Smith, Ph. D. MD": ("given", "suffix"), + "Smith, Ph.D. Jr.": ("given", "suffix"), + "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"), + "van ma van": ("_initials",), + }, + "expected_since_2.2.0.toml": { + "E Anne D,Leonardo": ("_initials",), + "JOSE E MARIA SANTOS": ("_initials",), + "Joe E. Smith": ("_initials",), + "Jose E. Maria Santos": ("_initials",), + "Smith, John E, III, Jr": ("_initials",), + }, +} + + def recorded_diff_mismatches( recorded: dict[str, tuple[str, ...]], diffing: list[tuple[str, set[str], str | None]], @@ -1751,6 +2015,11 @@ def recorded_diff_mismatches( an input. Nothing checks the shape itself, so a guessed one agrees with itself forever. This is the half only a run can do: main() has already measured every name's real diff by the time it calls this. + `recorded` is whichever roster the caller hands over -- nothing + here knows a contest row from a watched one, and main() calls this + once per dict and applies the severity to what comes back, since + the two kinds differ in what a mismatch means and not in how one is + found. Only the order-None comparison is read. The roster calls classify() with no order, so the shape it records is the default-order one; a @@ -1842,6 +2111,38 @@ def recorded_diff_mismatches( 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: + a block whose rows all carry a measured shape would otherwise print + repair advice for a case that did not occur, which is what + OVER-DECLARED's `--corpus` NOTE is conditional to avoid. Shared by + the three blocks because the two causes are a property of + recorded_diff_mismatches, not of the roster a row came from.""" + return ("\n A row reading 'measured no default-order diff' " + "names no cause because TWO reach it and this check " + "cannot separate them: the parser may have stopped " + "moving the name, or the name may be compared under a " + "declared order alone, leaving no default-order " + "comparison to have a shape. Read the corpus entry " + "before you read the parser." + if any(m.measured is None for m in rows) else "") + + +def _print_moved_rows(rows: list[_ShapeMismatch], roster: str) -> None: + """The rows under a MOVED SHAPE lead, naming the dict each lives in + so the reader edits the right one. Trailing blank line included: + the caller prints a lead only when it has rows, so the two are one + unit.""" + for m in rows: + measured = (f"measured {list(m.measured)}" + if m.measured is not None + else "measured no default-order diff of it") + print(f" {m.name!r}\n {roster} records " + f"{list(m.recorded)}; this run {measured}") + print() + + def _load_entries(path: Path) -> list[dict[str, object]]: """Corpus lines as entry dicts. A line is either a bare JSON string (the original format) or an object with a "name" plus @@ -1998,6 +2299,20 @@ def main() -> int: f"fails the run) or 'radar' (an unmatched diff is " f"reported and cannot fail). A default here would let " f"a new corpus pick one by accident") + # The VALUE too, not only the key's presence: every tier read + # downstream is a comparison against one of the two literals, + # and a misspelled value passes each of them on the side its + # `!=` happens to fall -- fatal in the comparison loop and in + # the watched-shape split, which is the safe direction there, + # but by accident rather than by choice, and nothing would + # ever name the misspelling. + if tier not in ("contract", "radar"): + raise SystemExit( + f"{path.name} has tier {tier!r} in _CORPUS_TIERS, which " + f"is neither 'contract' nor 'radar'. Every tier read " + f"below compares against those two literals, so a third " + f"value is not a third tier but whichever side of each " + f"comparison the misspelling falls on") file_entries = _load_entries(path) if not file_entries: raise SystemExit(f"{path.name} is empty; comparison aborted") @@ -2113,7 +2428,11 @@ def main() -> int: # Do not fold the two branches below back into one # shape, and do not level the four checks onto one strength: an # earlier draft of `vacant` refused under `--corpus` and told the - # contributor to delete legitimate exemptions. + # contributor to delete legitimate exemptions. The shape comparison + # after the worker -- MOVED SHAPE at either severity, and MOVED + # SHAPE (radar) -- reads the flag not at all: recorded_diff_mismatches + # skips a name outside `compared`, and that skip is the whole of + # its narrowing, in both modes. # # `full_corpus`, not `args.corpus`: the question the inversion # turns on is whether this run read every corpus, and `--corpus` is @@ -2216,25 +2535,90 @@ def main() -> int: # .glob('corpus*.jsonl'))]" # `ledger` is a Path, so the key is `ledger.name` -- as the line # below has it, and as an earlier draft of this recipe did not. - recorded = _RECORDED_DIFFS.get(ledger.name, {}) - gone = sorted(set(recorded) - set(corpus_names)) + # + # BOTH rosters, because a watched row (_WATCHED_DIFFS) is measured + # by nothing else either: the two differ in what a mismatch means, + # not in whether a departed name can be measured, so the union is + # the population every check here reads. + # + # FAIL-CLOSED on a missing section, and indexed rather than + # `.get(ledger.name, {})` below for that reason. An empty section + # 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 + # same 375 lines (differing only in the worker environment's path + # on the `baseline:` line, as any two runs do) and exits 0. 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 + # test_every_pinned_winner_has_a_recorded_shape through + # _CROSS_RULE_WINNERS); this refuses again because the tool may + # not assume the suite ran -- _CORPUS_TIERS' stance, applied to + # the rosters. + unsectioned = [dict_name for dict_name, roster in ( + ("_RECORDED_DIFFS", _RECORDED_DIFFS), + ("_WATCHED_DIFFS", _WATCHED_DIFFS)) if ledger.name not in roster] + if unsectioned: + raise SystemExit( + f"tools/differential/compare.py: {' and '.join(unsectioned)} " + f"carry no section for {ledger.name!r}. A ledger needs an " + f"explicit section in each shape roster, mapped to {{}} " + f"while it has no row of that kind: an empty section is a " + f"statement, a missing one is nobody having looked, and a " + f"default would read the two alike and check nothing for " + f"this ledger. tests/v2/test_ledger_guards.py holds every " + f"ledger on disk to the same key equality at pytest speed") + recorded = _RECORDED_DIFFS[ledger.name] + watched = _WATCHED_DIFFS[ledger.name] + # A name in BOTH dicts has not chosen which kind of row it is, and + # the two kinds carry different severities and different repairs, + # so the run cannot pick one for it. The pytest-speed guard in + # tests/v2/test_ledger_guards.py refuses the same overlap; this + # refuses it again because the tool may not assume the suite ran + # -- _CORPUS_TIERS is fail-closed here for the same reason. + both = sorted(set(recorded) & set(watched)) + if both: + raise SystemExit("\n".join( + [f"tools/differential/compare.py: {len(both)} name(s) sit in " + f"both _RECORDED_DIFFS[{ledger.name!r}] and " + f"_WATCHED_DIFFS[{ledger.name!r}]. A row is one kind or " + f"the other: a shape beside a winner adjudicates a " + f"contest, a shape alone watches a name no winner is " + f"pinned for. A name with an argument behind it belongs in " + f"_RECORDED_DIFFS alone -- delete its _WATCHED_DIFFS row:"] + + [f" {n!r}" for n in both])) + gone = sorted((set(recorded) | set(watched)) - set(corpus_names)) if gone and full_corpus: + # The repair differs by roster and the message says so per + # list rather than once: a contest row has a partner pin in + # _CROSS_RULE_WINNERS to delete with it, a watched row has no + # partner, and one sentence covering both would tell a + # watched-row reader to delete something that does not exist. + contest_gone = [n for n in gone if n in recorded] + watched_gone = [n for n in gone if n in watched] raise SystemExit("\n".join( - [f"tools/differential/compare.py: {len(gone)} row(s) in " - f"_RECORDED_DIFFS[{ledger.name!r}] name a string no corpus " + [f"tools/differential/compare.py: {len(gone)} recorded diff " + f"shape(s) for {ledger.name!r} name a string no corpus " f"holds any more, over the FULL corpus. Nothing measures " f"such a row, so it agrees with itself forever -- the shape " f"of the defect #497 is about. Settle the question that " f"picks the repair first: did the name leave DELIBERATELY? " f"`git log -S'' -- tools/differential/corpus*.jsonl` " - f"answers it. If it did, delete the row here AND its " - f"partner in _CROSS_RULE_WINNERS " - f"(tests/v2/test_ledger_guards.py), which pins a winner " - f"for a contest nothing raises now. If it did not, restore " - f"the name to a corpus -- the corpus is what regressed. " - f"Both can be true of one name, which is why the question " - f"comes before the edit:"] - + [f" {n!r}" for n in gone])) + f"answers it. If it did, delete the row. If it did not, " + f"restore the name to a corpus -- the corpus is what " + f"regressed. Both can be true of one name, which is why " + f"the question comes before the edit:"] + + ([" in _RECORDED_DIFFS, each with a partner in " + "_CROSS_RULE_WINNERS (tests/v2/test_ledger_guards.py) " + "that pins a winner for a contest nothing raises now " + "-- a deletion takes both:"] + + [f" {n!r}" for n in contest_gone] + if contest_gone else []) + + ([" in _WATCHED_DIFFS, pinning no winner, so the row is " + "the whole of the deletion:"] + + [f" {n!r}" for n in watched_gone] + if watched_gone else []))) # an ORDER-BEARING entry must never reach a worker whose baseline # cannot honor it (no Policy below 2.0.0) -- skip it and say so, # rather than shrink the comparison silently. An order-NONE @@ -2461,7 +2845,8 @@ def _tree_parse(name: str, order: str | None) -> object: print(f" {issue!r} explained {name!r}{_order_tag(tagged)}") print() # The measured half of the recorded-shape check (#497): the shape - # _CROSS_RULE_WINNERS pins against the diff this run actually made. + # _CROSS_RULE_WINNERS pins against the diff this run actually made, + # and the shape _WATCHED_DIFFS records against the same diff. # HERE because this is the only place both exist -- validate_rules # runs before any corpus is read, and the unit suite spawns no # worker, deliberately, so neither can ask it. Its absent-name half @@ -2521,7 +2906,13 @@ def _tree_parse(name: str, order: str | None) -> object: # both modes, and never the departed-name question the `gone` half # owns. Nothing else stands between the two lists: `entries` is # rebuilt exactly once between them, by that skip. - unchecked = sorted((set(recorded) & set(corpus_names)) - compared) + # + # The UNION of the two rosters, like `gone` above: a watched row on + # a skipped entry is checked by neither half for exactly the same + # reason a contest row is, and which dict it sits in changes what + # a mismatch would mean, not whether one could be measured. + unchecked = sorted(((set(recorded) | set(watched)) & set(corpus_names)) + - compared) if unchecked: print(f"NOT CHECKED {ledger.name}: {len(unchecked)} recorded " f"diff shape(s) name an entry this baseline skipped, so " @@ -2536,8 +2927,46 @@ def _tree_parse(name: str, order: str | None) -> object: for n in unchecked: print(f" {n!r}") print() - shape_bad = recorded_diff_mismatches(recorded, diffing, compared) - if shape_bad: + # The severity split for a WATCHED row reads the compared entry's + # tier, and the entry is the post-skip one -- the same list + # `compared` was built from, so every name in `compared` has a key + # here and the lookups below index rather than `.get`: a miss would + # mean `compared` and `entries` had parted, which nothing between + # them can do. Not the order-None entries alone: a name compared + # under a declared order ALONE is in `compared` with no + # default-order entry, and a watched row on it reports with + # `measured` None (recorded_diff_mismatches' second cause), which + # is a mismatch owed a tier like any other -- read off only its + # order-None entry it would fall out of both lists below and + # print nowhere. So an order-None entry decides when there is one, + # since the shape recorded is the default-order shape, and the + # first-loaded entry decides otherwise, which is a contract one + # whenever any is: the (name, order) dedup loaded contract files + # first, and at most one order-None entry survives per name (a + # declared-order-only name has none), so the tier is unambiguous + # in both cases. + tier_of: dict[str, str] = {} + for e in entries: + if e["order"] is None or str(e["name"]) not in tier_of: + tier_of[str(e["name"])] = str(e["tier"]) + contest_bad = recorded_diff_mismatches(recorded, diffing, compared) + watched_bad = recorded_diff_mismatches(watched, diffing, compared) + # `== "radar"` and its complement rather than `== "contract"`, the + # reading the comparison loop above uses: a tier that is neither + # fails, which is the fail-closed direction. Defense in depth + # only, since the corpus load refuses any value outside the two + # literals -- so every tier that reaches this line IS one of them, + # and the watched block's "because the name is contract tier" is + # true of every value that lands on its side. + watched_contract = [m for m in watched_bad + if tier_of[m.name] != "radar"] + watched_radar = [m for m in watched_bad if tier_of[m.name] == "radar"] + # What the exit code reads. A contest row is fatal on either tier + # because it carries an argument; a watched row follows its name's + # tier, and _WATCHED_DIFFS' header carries why the radar half does + # not fail. + shape_bad = contest_bad + watched_contract + if contest_bad: # The instruction and the disclaimer are properties of the # CHECK, not of a row, so they lead the block once instead of # riding every line -- the shape the Role-vocabulary legend @@ -2558,34 +2987,39 @@ def _tree_parse(name: str, order: str | None) -> object: # recorded_diff_mismatches' docstring says why they are # collapsed -- and naming one would send half the readers to # the wrong file. - print(f"MOVED SHAPE {ledger.name}: {len(shape_bad)} recorded " + print(f"MOVED SHAPE {ledger.name}: {len(contest_bad)} recorded " f"diff shape(s) disagree with this run. Each is a " f"FINDING, not a number to update: the winner pinned " f"beside the shape in _CROSS_RULE_WINNERS " f"(tests/v2/test_ledger_guards.py) was recorded for the " f"OLD shape, so read both before editing either." - # The disclaimer is one-time like the sentence above it, - # but conditional as well: a block whose rows all carry a - # measured shape would otherwise print repair advice for - # a case that did not occur, which is what - # OVER-DECLARED's `--corpus` NOTE is conditional to - # avoid. - + ("\n A row reading 'measured no default-order diff' " - "names no cause because TWO reach it and this check " - "cannot separate them: the parser may have stopped " - "moving the name, or the name may be compared under a " - "declared order alone, leaving no default-order " - "comparison to have a shape. Read the corpus entry " - "before you read the parser." - if any(m.measured is None for m in shape_bad) else "")) - for m in shape_bad: - measured = (f"measured {list(m.measured)}" - if m.measured is not None - else "measured no default-order diff of it") - print(f" {m.name!r}\n _RECORDED_DIFFS records " - f"{list(m.recorded)}; this run {measured}") - if shape_bad: - print() + + _two_causes(contest_bad)) + _print_moved_rows(contest_bad, "_RECORDED_DIFFS") + # The two watched blocks share a lead and differ in the last + # sentence, which is the severity and its reason. Neither names + # _CROSS_RULE_WINNERS: no row in them has a partner there, and a + # reader sent to that roster would find nothing to read. + if watched_contract: + print(f"MOVED SHAPE {ledger.name}: {len(watched_contract)} watched " + f"diff shape(s) disagree with this run. No winner is " + f"pinned for these names, so each is a FINDING, not a " + f"number to update: if the move is intended, re-record the shape " + f"in _WATCHED_DIFFS in the commit that moved it and say " + f"why there. This fails the run because the name is " + f"contract tier (_CORPUS_TIERS)." + + _two_causes(watched_contract)) + _print_moved_rows(watched_contract, "_WATCHED_DIFFS") + if watched_radar: + print(f"MOVED SHAPE (radar) {ledger.name}: {len(watched_radar)} " + f"watched diff shape(s) disagree with this run. No winner " + f"is pinned for these names, so each is a FINDING, not a " + f"number to update: if the move is intended, re-record " + f"the shape in _WATCHED_DIFFS in the commit that moved it " + f"and say why there. This does not fail the run because " + f"the name is radar tier (_CORPUS_TIERS), which watches " + f"without promising." + + _two_causes(watched_radar)) + _print_moved_rows(watched_radar, "_WATCHED_DIFFS") dormancy = dormant_rules(rules, set(by_issue), diffing, exclusions) for dormant in dormancy.undeclared: print(f"EXPLAINED NOTHING {dormant.issue!r}\n " @@ -2654,6 +3088,9 @@ def _tree_parse(name: str, order: str | None) -> object: # _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. + # `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