From a6a82d6b711b0f15c3a318a449425b8feecd5c79 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 5 Sep 2026 14:33:42 -0700 Subject: [PATCH 1/5] tooling(differential): the glued-peel rule stops matching inside a longer honorific MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ledgers ship the same glued-peel `name_regex`, and both matched a listed honorific sitting INSIDE a longer listed one. 박사님, 선생님 and 교수님 are the only entries of that alternation containing another entry of it, so the interior 님 was the one way a SPACED honorific could reach a rule whose title says a glued one was peeled off the name. It reached two corpus names, and on one of them the label was simply false: '김민준 박사님' has nothing peeled off it at all -- rules.md#W2's second sentence says a part that is not name text is never the name's end, and its witness line for this very string is suffix="박사님", the honorific whole and standing as its own word. That is a FALSE label on a contract-tier name, held green by every guard in the suite. The other, '선생님', moves no `suffix` whatsoever: its diff is the order flip's {given, family} and the CJK order rule claims it either way. `(? _Claim(35, ('family', 'given', 'suffix'), "9a1b4c202a65", None), the two names lost from reach being the two above. Ledger prose that the change made stale, re-measured rather than adjusted: the 1.4 peel rule explains 16 corpus names, not 17; twelve of those are contract tier, where three `precedes_narrower` blocks said fourteen -- one of which the period-class demotion had already taken and one the narrowing takes. The claim that deleting the rule leaves TWELVE contract names unexplained still holds, before and after, and says so now. A hermetic test pins the property rather than the pattern: over both ledgers read off disk, `_entry_matches` is False for '김민준 박사님' and '선생님' and True for the glued '김민준씨', so a narrowing that overshoots fails as loudly as the accident did not. Co-Authored-By: Claude Fable 5.1 --- tests/v2/test_ledger_guards.py | 66 ++++++++++++++++++- tools/differential/expected_since_1.4.0.toml | 48 ++++++++++---- tools/differential/expected_since_2.0.0.toml | 67 ++++++++++++++------ 3 files changed, 145 insertions(+), 36 deletions(-) diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index 13be8ab6..2c7eb4b3 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -1277,6 +1277,55 @@ def test_differential_honorific_rules_match_their_vocabulary() -> None: f"pin.") +@pytest.mark.parametrize(("ledger_name", "issue"), [ + ("expected_since_1.4.0.toml", + "fix(cjk-glued-honorific-peel) glued honorific peels into suffix"), + ("expected_since_2.0.0.toml", + "fix(#308/#312/#319/#320) glued CJK honorific peeled off the name " + "into suffix"), +]) +@pytest.mark.parametrize(("name", "glued"), [ + ("김민준씨", True), + ("김민준 박사님", False), + ("선생님", False), +]) +def test_the_glued_peel_rules_decline_an_interior_honorific( + ledger_name: str, issue: str, name: str, glued: bool) -> None: + """The 2026-09-05 narrowing, stated as the property rather than as + a regex both ledgers happen to spell the same way. + + A rule titled "glued honorific peeled off the name" may claim a + name whose honorific is GLUED and no other. 박사님, 선생님 and 교수님 + are the only listed entries containing another listed one, so a + match on the interior 님 was the one way a spaced form could reach + these rules -- and it reached two corpus names, '김민준 박사님' where + the honorific stands as its own whole word (rules.md#W2's second + sentence, witnessed there by suffix="박사님") and '선생님' where no + `suffix` moves at all. The three negative lookbehinds refuse the + interior position; '김민준씨' is the glued form they must leave + alone, and is here so a narrowing that went too far fails rather + than passing as a tightening. + + Hermetic on purpose: the shipped ledgers are read off disk and + _entry_matches is asked directly, so this holds at pytest speed + with no baseline wheel. _CORPUS_CLAIMS records the reach the same + narrowing produced (37 -> 35 in both), and the gate is what checks + where the name that changed hands landed. + """ + compare = load_tool("compare") + ledger = next(led for led in _LEDGERS if led.name == ledger_name) + rule = next(r for r in _rules(ledger) if r["issue"] == issue) + # the roles the rule declares, so `fields` cannot be what decides + # the answer: the question here is the name_regex alone + shape = set(rule["fields"]) + assert compare._entry_matches(rule, name, shape, None) is glued, ( + f"{ledger_name}: {issue!r} " + f"{'no longer claims' if glued else 'claims'} {name!r}. The " + f"rule's title says a GLUED honorific was peeled off the name; " + f"a spaced one reaching it is a false label, and a glued one " + f"escaping it is a narrowing that overshot") + + class _LatinCopy(NamedTuple): """A ledger alternation that hand-copies a Latin-script vocabulary. @@ -1989,8 +2038,17 @@ def _claim(rule: dict) -> _Claim: _Claim(23, ('given', 'suffix'), "344de804e2c6", None), "fix(cjk-comma-compound) comma routing compounds with the CJK order flip": _Claim(23, ('family', 'given', 'suffix', 'title'), "344de804e2c6", None), + # 37 -> 35 with the 2026-09-05 narrowing, which is a rule + # NARROWING and not corpus movement: the three negative + # lookbehinds stop the regex matching a listed honorific + # interior to a longer one, so '김민준 박사님' and '선생님' left + # the reach and nothing else did. The 2.0 twin below carries + # the identical regex and moves by the same two names, to the + # same digest. Only '김민준 박사님' changed hands -- it goes to + # fix(cjk-honorific-suffix) here -- '선생님' having been the + # order rule's all along. "fix(cjk-glued-honorific-peel) glued honorific peels into suffix": - _Claim(37, ('family', 'given', 'suffix'), "719c31233502", None), + _Claim(35, ('family', 'given', 'suffix'), "9a1b4c202a65", None), "fix(cjk-honorific-suffix) postnominal honorifics recognized, compounding with the CJK order flip": _Claim(19, ('family', 'given', 'middle', 'suffix'), "aa475ddd4745", None), "feat(#269) non-Latin titles/conjunctions recognized": @@ -2117,8 +2175,12 @@ def _claim(rule: dict) -> _Claim: _Claim(13, ('_ambiguities', 'family', 'middle'), "973617235cda", None), "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots": _Claim(108, ('_ambiguities', 'family', 'given', 'middle'), "9a814f70c2dc", None), + # 37 -> 35 with the same 2026-09-05 narrowing as the 1.4 twin, + # whose entry carries the reason. Here the one name that + # changed hands, '김민준 박사님', goes to the spaced rule + # fix(#307/#308/#320) -- the label its title states. "fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix": - _Claim(37, ('family', 'given', 'suffix'), "719c31233502", None), + _Claim(35, ('family', 'given', 'suffix'), "9a1b4c202a65", None), "fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix": _Claim(16, ('family', 'given', 'middle', 'suffix'), "6d390e518bd2", None), "fix(#309) 旧姓 maiden marker consumed, compounding with the CJK order flip": diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index d316da79..ecda4b19 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -1377,9 +1377,15 @@ rule and re-classifying the corpus -- without fix(cjk-comma-compound) the run still reports 0 unexplained, so it really is radar-only and it was the candidate #495 weighed; without the peel rule TWELVE contract-tier names go UNEXPLAINED ('Andersonさん', '王先生' and -'김민준씨' among them) and the run fails. Fourteen of the seventeen -names the peel rule explains are contract, which is why #495 recorded -it as a rule to keep rather than a deletion candidate. #495 closed as +'김민준씨' among them) and the run fails. TWELVE remains the figure +after the 2026-09-05 narrowing of the peel rule, re-measured: the one +name that narrowing moved goes to fix(cjk-honorific-suffix) with the +peel rule present and without it alike. Twelve of the SIXTEEN names the +peel rule explains are contract (measured 2026-09-05; #495 read +fourteen of seventeen, one of which the period-class demotion took to +radar and one of which the narrowing took to another rule), which is +why #495 recorded it as a rule to keep rather than a deletion +candidate. #495 closed as not planned, so the compound rule keeps its place as well -- and the reason is this contest read from the other side: measured, deleting the compound rule does not leave its eleven diffs UNCLASSIFIED, it @@ -1390,9 +1396,11 @@ different shape. Two are left unclassified.""" issue = "fix(cjk-glued-honorific-peel) glued honorific peels into suffix" # '김민준씨' -> 김/민준/씨, '田中さん' -> 田中/さん, 'Andersonさん' -> # Anderson/さん: #312's peel, on a name with no space before the -# honorific and no comma anywhere. 17 corpus names, which fell to the +# honorific and no comma anywhere. 16 corpus names, which fell to the # fields-only fix(suffix-routing) catch-all (deleted in #451) until -# #372 gave them a rule that names them. +# #372 gave them a rule that names them. 17 until the 2026-09-05 +# narrowing below, which is not one of them leaving the corpus: the +# name it lost was never glued. # # The regex is this file's first hand copy of GLUED_HONORIFICS rather # than SUFFIX_WORDS, and that narrower vocabulary is the whole reason @@ -1411,13 +1419,23 @@ issue = "fix(cjk-glued-honorific-peel) glued honorific peels into suffix" # necessarily the string. '田中さん II' and '김민준씨 Jr.' carry a # Latin suffix after it, '김민준씨 (Jimmy)' a nickname. # -# One spaced name lands here and is left that way, exactly as the 2.0 -# twin records: '김민준 박사님' matches on its 님, glued to 사 inside -# 박사님. The label stays true of it -- the peel is what routes it to -# `suffix` -- and closing the overlap needs a lookbehind asserting the -# match is not interior to a longer listed honorific, which is more -# machinery than a correct label is worth. -name_regex = "(?<=[^\\s,])(?:박사님|선생님|교수님|박사|씨|님|先生|女士|小姐|教授|様|さん|さま|くん|ちゃん)\\.?(?=$|[ ,])" +# The three negative lookbehinds are the 2026-09-05 narrowing, and the +# 2.0 twin's comment carries the argument for them in full. 박사님, +# 선생님 and 교수님 are the only entries of this alternation containing +# another entry of it, so a match on the interior 님 was the one way a +# SPACED honorific could reach a rule whose title says a glued one was +# peeled off the name. It reached two: '김민준 박사님', where nothing is +# peeled at all -- rules.md#W2's second sentence, whose witness line for +# this string is suffix="박사님", the honorific whole and its own word -- +# and '선생님', where `suffix` moves nowhere and the order rule claims +# the diff regardless. The lookbehinds refuse the interior position +# only, so a glued '김민준박사님' still matches on 박사님 itself. +# +# Measured consequence here, 2026-09-05: exactly one name changes hands +# and it is '김민준 박사님', which fix(cjk-honorific-suffix) below takes. +# Summary lines byte-identical, no pin in this file moves, +# order_contests unchanged, recorded reach 37 -> 35. +name_regex = "(?<=[^\\s,])(? 35, for the two names +# above and nothing else. +name_regex = "(?<=[^\\s,])(? Date: Sat, 5 Sep 2026 14:43:33 -0700 Subject: [PATCH 2/5] tooling(differential): the six 2.x contests get their winners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two 2.x sections of _CROSS_RULE_WINNERS and of _RECORDED_DIFFS stood empty on a position: contests were MEASURED there -- 5 of the 247 diffs at 2.0.0 and 1 of the 155 at 2.1.0 move a shape two or more rules admit -- and the rule was that a row is owed when somebody ARGUES a boundary, not when one is merely measured. #501 argued all six (2026-09-05). The five rows at 2.0.0 and the one at 2.1.0 move from compare._WATCHED_DIFFS to _RECORDED_DIFFS with their shapes unchanged, which is what that dict's NO WINNER clause says happens the day a winner is argued, and the six winners go in beside them. WHAT THE POSITION GOT WRONG was WHERE an argument counts. Three of the six boundaries were already argued -- in the 2.0.0 ledger's own comment on fix(#308/#312/#319/#320), which named '田中さん 様.', '田中さん, 様.' and '김민준 박사님' one by one and said for each why the glued rule should win. One of those three arguments was FALSE, and it stood for a month with every guard green because no row here made classify() answer for it. That is the case for the criterion being ARGUED SOMEWHERE rather than argued in this file: a ledger comment's claim about which rule wins a name is checked by nothing, and this roster is the only thing in the tree that can check it. THE SIX, with the argument each rests on: '田中さん 様.' and '田中さん, 様.' -> fix(#308/#312/#319/#320). The only live two-runner contest of the six. A compound of two mechanisms with one rule per half and fields that do not nest, so no precedes_narrower block can name the pair and file order is the whole decision. #312/#319/#320 are the glued rule's clauses and they name both strings; no clause of the spaced rule mentions either. '김민준 박사님' -> fix(#307/#308/#320), the SPACED rule, which only wins after the narrowing in the commit before this one. '선생님' -> fix(#271/#272/#298). The whole diff is the order flip's {given, family}; the glued rule's claim declared a `suffix` that moves nowhere. The pair is the #498 class -- intersecting `fields`, neither nested -- so undeclared_contests looks past it. 'MD, PHD' at 2.0.0 and at 2.1.0 -> fix(#296) a credential-only comma string reads a name and its postnominal. Both #296 rules declare EQUAL `fields` here, so only file order decides; the distinguishing clause is a property of the STRING, which holds no name. MUTATION CONTROLS, in memory around the guard, per row so that a failure on one cannot mask the rest: (i) glued <-> spaced at 2.0.0 -- fails on both '様.' names: "expected_since_2.0.0.toml: '田中さん 様.' diffing ['family', 'given', 'suffix'] is now explained by 'fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix', not 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix'." (ii) the two fix(#296) credential rules swapped -- fails at BOTH baselines: "expected_since_2.0.0.toml: 'MD, PHD' diffing ['suffix', 'title'] is now explained by 'fix(#296) a lone post-comma credential is a suffix', not 'fix(#296) a credential-only comma string reads a name and its postnominal'." and the same sentence under expected_since_2.1.0.toml. (iii) the pre-narrowing glued regex restored AND the CJK order rule swapped with it -- fails on both remaining rows: "expected_since_2.0.0.toml: '김민준 박사님' ... is now explained by 'fix(#308/#312/#319/#320) ...', not 'fix(#307/#308/#320) ...'." and "expected_since_2.0.0.toml: '선생님' diffing ['family', 'given'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#271/#272/#298) native-script CJK: ...'." Every one of the six rows is moved by some control; no pin is inert. The regex restore is part of (iii) because the reorder ALONE is now inert -- 0 of the 247 classifications move -- which the narrowing did and which the '선생님' row's comment records: after it, that row's work is against a re-widening rather than against a reorder. The widening alone suffices for '김민준 박사님', the glued rule already sitting ahead of the spaced one. Each control leaves `unexplained` at 0, so the gate's summary line reports none of them. COUNTS, recomputed with the script in the dict's own header rather than adjusted: _WATCHED_DIFFS goes 41 / 32 + 4 / 31 / 5 = 113 rows to 41 / 31 / 30 / 5 = 107, over 50 names where it was 51 plus the four contests -- and the roster is now exactly the sole-watched population the sweep draws, the rows beyond it having left. 48 of the 50 sit in corpus_issues.jsonl and 3 in corpus.jsonl, overlapping on 'dr Vincent van Gogh dr'. The tests/-only scan still says 52 names; the two it counts that this dict does not are 'Carod i' and, since this commit, 'MD, PHD', both carrying a contest row at every baseline they diff at. THREE of the six pinned names are RADAR tier ('田中さん 様.', '田中さん, 様.', 'MD, PHD'), so a moved shape on them is now fatal where a watched row would have printed. Accepted as the SEVERITY rule written: a contest row is fatal on either tier because it carries an argument. 22 of the 31 rows at 1.4.0 are radar names on those same terms. Gate x4 byte-identical to the previous commit's, line for line, apart from the baseline worker's temp path: 352 / 247 / 155 / 14 intentional, 0 unexplained, 0 radar unclassified, exit 0, no MOVED SHAPE and no NOT CHECKED. Co-Authored-By: Claude Fable 5.1 --- tests/v2/test_differential.py | 9 +- tests/v2/test_ledger_guards.py | 195 +++++++++++++++++++++++++++------ tools/differential/README.md | 2 +- tools/differential/compare.py | 124 ++++++++++++--------- 4 files changed, 242 insertions(+), 88 deletions(-) diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 349d3b35..026dc577 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -2180,10 +2180,11 @@ def test_the_shape_check_reads_the_section_for_the_LEDGER_it_ran( `_RECORDED_DIFFS['expected_since_1.4.0.toml']` survived the whole suite: every other main() test here runs at the default 1.4.0 baseline, so the right answer and the hardcoded one are the same - dict. Harmless only while the 2.x sections stay empty, which is - open (#501) -- and the shapes are baseline-relative by - construction, so reading the wrong section is reading measurements - of a different comparison. + dict. That was harmless only while the 2.x sections stayed empty, + and since #501 filled them it is not: 'MD, PHD' is keyed in three + sections at two different shapes, so the hardcoded lookup would now + read measurements of a different comparison and say the parser + moved. A correct row in the 2.0.0 section and a DECOY in the 1.4.0 one: dispatched right, the run is quiet; dispatched to 1.4.0, it reports diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index 2c7eb4b3..07431599 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -2521,9 +2521,11 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: #: same tier -- 301 in the 1.4 tier as of the rules-doc corpus (#414), #: up from 42, which is why the count is described rather than pinned: #: it moves with every corpus addition and pinning it would only ever -#: be re-recorded. The rows below are the ones whose boundaries this -#: file argues about, and pinning the argument is cheaper than -#: re-deriving it. Note +#: be re-recorded. The rows below are the ones whose boundaries +#: somebody has ARGUED -- in the comment beside the row, or in a +#: ledger's own comment on the rule, which #501 established counts the +#: same and why (the 2.x sections' comment) -- and pinning the argument +#: is cheaper than re-deriving it. Note #: 'Andrews, M.D.' diffs on the SAME {given, suffix} shape as the seven #: peels -- only the CJK lookahead separates them, so it is the row #: that fails if that lookahead is ever dropped. @@ -2698,39 +2700,58 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: }, # The two 2.x ledgers had NO section here until #452, and the # coverage assertion below was `<=`, so their absence read as "no - # contest to pin" rather than "nobody looked". They are back to - # empty, and it is now the other thing: a stated position, which is - # what the equality assertion below makes sayable. Do not restore a - # row to fill them. - # - # #452 gave each two rows -- 'Nguyen, Van' and 'Jane née and Jones - # Smith', the handovers its narrowings caused -- and #497 deleted - # all four rather than correct them. Each recorded a diff shape a - # run contradicts (compare._RECORDED_DIFFS' provenance note has the + # contest to pin" rather than "nobody looked". #452 gave each two + # rows -- 'Nguyen, Van' and 'Jane née and Jones Smith', the + # handovers its narrowings caused -- and #497 deleted all four + # rather than correct them. Each recorded a diff shape a run + # contradicts (compare._RECORDED_DIFFS' provenance note has the # measurements and the recompute, in one copy, including why the one # correctable shape was not corrected), and at the shape each name # really produces, exactly one rule admits it. That is the defect # under the wrong shapes rather than beside them: a row pinning a # race with one runner is never exercised as a contest, so its shape - # only ever had to agree with itself. + # only ever had to agree with itself. Those four are not coming + # back, and the six rows below are not them: every one is a shape a + # run measured, carried in from compare._WATCHED_DIFFS where a run + # had been re-measuring it since 2026-09-03. + # + # WHAT #501 DECIDED, 2026-09-05. The sections stood empty on a + # position, not an omission: 5 of the 247 diffs at 2.0.0 and 1 of + # the 155 at 2.1.0 were measured to move a shape two or more rules + # admit, file order picking the winner ('MD, PHD' is the one both + # baselines have), and the position was that a row is owed when + # somebody ARGUES a boundary, not when one is merely measured. + # Recompute the six: drive compare.main() at the baseline, capture + # its `diffing` by wrapping dormant_rules, and count the names for + # which more than one rule satisfies compare._entry_matches at the + # measured shape. + # + # What the position got wrong was WHERE an argument counts. Three + # of the six boundaries were already argued -- in the 2.0.0 + # ledger's own comment on fix(#308/#312/#319/#320), which named + # '田中さん 様.', '田中さん, 様.' and '김민준 박사님' one by one and + # said for each why the glued rule should win it. "Argued in THIS + # file" is the wrong criterion, and the reason is not tidiness: a + # ledger comment's claim about which rule wins a name is checked by + # nothing at all, and this roster is the only thing in the tree + # that CAN check it. The proof is the third of those three. Its + # argument was FALSE -- '김민준 박사님' has no glued honorific, + # matching that rule on the 님 interior to a spaced 박사님 -- and it + # sat in the ledger for a month with every guard green, because no + # row here made classify() answer for it. The narrowing that closed + # it is the commit before this one; the winner pinned for that name + # below is the SPACED rule, which only wins after it. # - # EMPTY IS NOT "these ledgers hold no contested name". Measured - # 2026-09-03 over the diffs each baseline's own run produces, 5 of - # the 247 at 2.0.0 and 1 of the 155 at 2.1.0 move a shape two or - # more rules admit, file order picking the winner ('MD, PHD' is the - # one both have). Recompute: drive compare.main() at the baseline, - # capture its `diffing` by wrapping dormant_rules, and count the - # names for which more than one rule satisfies - # compare._entry_matches at the measured shape. None of those - # boundaries is argued about in this file, and this roster pins the - # arguments this file makes -- so a row is owed when someone argues - # one, not before. Whether that position should change now that the - # six are measured rather than merely unexamined is #501; the answer - # there decides whether these two sections stay empty, and nothing - # in this file presumes it. + # So the criterion is ARGUED SOMEWHERE. A boundary argued in a + # ledger comment earns a row here exactly as one argued in this + # file does, and the row is what turns the argument into something + # a run can contradict. # # Nor is "only one rule admits it" grounds on its own to delete a - # row: 13 of the 31 above are in that position too. SIX of the 13 + # row: 13 of the 31 at 1.4.0 are in that position too, and two of + # the six below joined them with the narrowing -- '김민준 박사님' + # and '선생님' have one admitter each now, the glued rule's regex + # having stopped reaching them. SIX of the 13 # say so where they sit -- the jr rule's surplus and the # bound-given trio, three rows each -- and the other seven do not, # so take the count from the recompute rather than from the @@ -2746,8 +2767,116 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: # reason counting admitters is the wrong instrument for the # question. The four deleted rows could not do that work at any # edit. - "expected_since_2.0.0.toml": {}, - "expected_since_2.1.0.toml": {}, + "expected_since_2.0.0.toml": { + # The '様.' pair, and the one boundary of the six that is a + # live two-runner contest decided by nothing but file order. + # Both names move {family, given, suffix} through TWO + # mechanisms at once: the glued さん peels off 田中さん (#308, + # and #320 is what stops the trailing ASCII period vetoing it) + # while the spaced 様 routes to `suffix`. One rule per half, and + # the two sit in the same name_regex tier with fields that do + # not nest -- {given, family, suffix} against {given, middle, + # family, suffix} -- so neither is the narrower and no + # `precedes_narrower` block can name the pair. + # + # The prose test lands on the glued rule and the file order + # already does: #312/#319/#320 are the glued rule's clauses, + # they name both strings outright, and no clause of the spaced + # rule mentions either. It is a PARTIAL label in #382's + # accepted sense -- the glued rule describes the peel and not + # the routing -- and the narrow-first justification that + # usually settles such a pair, that the narrower rule describes + # the whole diff, does not apply: neither rule describes both + # halves. Narrow-first picks, and this row is where that shows. + # + # What the pin buys, measured 2026-09-05: swapping the two + # rules in memory hands BOTH names to the spaced rule and + # touches nothing else -- 2 of the 247 classifications move, + # `unexplained` stays 0, so the gate's summary line does not + # move, and neither rule's _CORPUS_CLAIMS reach can see a + # reorder at all. + "田中さん 様.": + "fix(#308/#312/#319/#320) glued CJK honorific peeled off " + "the name into suffix", + "田中さん, 様.": + "fix(#308/#312/#319/#320) glued CJK honorific peeled off " + "the name into suffix", + # The name the glued rule should never have had. '김민준 박사님' + # is SPACED: 박사님 stands as its own word and nothing is peeled + # off the name at all, which rules.md#W2's second sentence + # states and its witness line for this very string shows + # (suffix="박사님", the honorific whole). The glued rule reached + # it on the 님 interior to 박사님 and, sitting first, took it -- + # a false label on a contract-tier name, green everywhere. The + # 2026-09-05 narrowing (`(? None: 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 + pinned for -- every row sole-watched again since #501 took the + five contested ones to _RECORDED_DIFFS, which is a fact about + today's rows and not a second contract the checks below enforce. + 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 diff --git a/tools/differential/README.md b/tools/differential/README.md index 384c7564..44359ed3 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -773,7 +773,7 @@ Four checks now read the flag differently, and the differences are deliberate ra `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 `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. +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. Every row there is also sole-watched today -- a radar name that no test names and no contract corpus holds, whose only other watcher is the classification rule explaining its 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. That is a property of today's rows and not a second entry condition: the four #501 contests at 2.0.0 and `'MD, PHD'` beside them sat there, measured and unadjudicated, with a `Case(...)` row in `tests/v2/cases.py` already pinning each one's PARSE, until #501 argued which RULE explains each diff and the five rows moved. 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. 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. diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 30fe95b8..e14b7c45 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -1674,9 +1674,11 @@ class _ShapeMismatch(NamedTuple): #: ledger rather than once by name: a string moves a different set of #: roles against different baselines, and decisions.md (2026-08-28 #: #452) records `fix(#296) a lone post-comma credential is a suffix` -#: moving four roles at 1.4.0 and two at both 2.x baselines. No name -#: sits in two sections today -- the three non-1.4.0 ones are empty -- -#: so the keying is held by that argument alone. +#: moving four roles at 1.4.0 and two at both 2.x baselines. Since #501 +#: the keying carries its own witness rather than resting on that +#: argument alone: 'MD, PHD' has a row in three sections, diffing +#: {family, given, suffix, title} at 1.4.0 and {suffix, title} at both +#: 2.x baselines, so one by-name keying could not hold what it does. #: #: A recorded shape that MOVES is a FINDING, not a number to update, #: and it names no cause because it cannot: the parser may have changed @@ -1715,11 +1717,18 @@ class _ShapeMismatch(NamedTuple): #: capture the post-skip entries and dormant_rules to capture `diffing`, #: since both receive exactly what main() built. #: -#: Both 2.x sections are EMPTY, and that is a position rather than an -#: omission -- _CROSS_RULE_WINNERS carries why no 2.x contest is pinned, -#: including the measured fact that contests DO exist there. Each held -#: two rows, added by #452, until #497 ran that recompute against them. -#: All four recorded a shape no run makes: +#: The 2.x sections were EMPTY until #501, which is the question the +#: position recorded there was waiting on: contests were measured to +#: exist and nobody had argued a winner for one. Six are argued now +#: (2026-09-05), five at 2.0.0 and one at 2.1.0, and their shapes moved +#: in from _WATCHED_DIFFS unchanged -- a watched row is measured by a +#: run in the first place, which is what makes the move a move rather +#: than a fresh recording. _CROSS_RULE_WINNERS carries the argument per +#: row. +#: +#: The four rows #452 put in these sections, two per 2.x ledger, are a +#: different story and stay deleted. #497 ran that recompute against +#: them and all four recorded a shape no run makes: #: 'Nguyen, Van' produces no diff at ANY of the four baselines. It is #: compared under the default order out of corpus_rules.jsonl every #: time, and the tree agrees with all four wheels on it, so {family} @@ -1775,8 +1784,32 @@ class _ShapeMismatch(NamedTuple): "abu bakr al baghdadi": ("_initials",), "Berg, abdul van": ("_initials",), }, - "expected_since_2.0.0.toml": {}, - "expected_since_2.1.0.toml": {}, + # #501's six, moved here from _WATCHED_DIFFS with their shapes + # unchanged. The four CJK rows sit at 2.0.0 alone: the honorific + # and order rules that contest them are 2.1 behavior, so those + # names diff at 1.4.0 and 2.0.0 and not against the 2.1.0 wheel at + # all. 'MD, PHD' diffs at all three and now carries a row at each. + # + # THREE of the six are RADAR-tier names -- '田中さん 様.' and + # '田中さん, 様.' in corpus_cjk_tolerated.jsonl, 'MD, PHD' in + # corpus_issues.jsonl -- so a moved shape on them is fatal where a + # watched row would only have printed. That is the SEVERITY rule + # below applied as written and accepted deliberately (#501): a + # contest row is fatal on either tier because it carries an + # argument, and a moved shape has made that argument's premise + # false. The precedent is the section above, where 22 of the 31 + # rows are radar-tier names on exactly those terms (measured + # 2026-09-05). + "expected_since_2.0.0.toml": { + "田中さん 様.": ("family", "given", "suffix"), + "田中さん, 様.": ("family", "given", "suffix"), + "김민준 박사님": ("family", "given", "suffix"), + "선생님": ("family", "given"), + "MD, PHD": ("suffix", "title"), + }, + "expected_since_2.1.0.toml": { + "MD, PHD": ("suffix", "title"), + }, } @@ -1790,19 +1823,21 @@ class _ShapeMismatch(NamedTuple): #: "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 +#: not "nothing else watches" -- though since #501 took the five +#: contested rows to _RECORDED_DIFFS, every row here is in fact +#: sole-watched, and the roster and the population the sweep drew +#: (POPULATION below) are the same set. That is a fact about today's +#: rows and NOT a second contract: a name whose parse a Case(...) row +#: already pins is welcome here the day it is measured and no winner +#: is argued for it, which is the position all five held from +#: 2026-09-03 until #501 argued one. What sole-watched means: 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. +#: a finding. #: #: Four things follow from the split, and each is checked rather than #: stated: @@ -1866,15 +1901,18 @@ class _ShapeMismatch(NamedTuple): #: 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 +#: that carry contest rows: 'Bob Jones, author', 'Carod i', 'MD, PHD', +#: 'van ma van'. TWO of them return at a 2.x baseline where they have +#: no contest row. The other two have no row in this dict at all: +#: 'Carod i' diffs under the default order at 1.4.0 only, where its +#: contest row stands, and 'MD, PHD' carries a contest row at every one +#: of the three baselines it diffs at since #501 pinned its 2.x pair. +#: That is why the population is 50 names where the tests/-only scan +#: says 52. +#: The counts: 41 / 31 / 30 / 5 rows, 107 in all, over those 50 names +#: -- and the roster is now exactly the population, the five contest +#: rows beyond it having gone to _RECORDED_DIFFS with #501. +#: 48 of the 50 sit in corpus_issues.jsonl and 3 in corpus.jsonl, with #: 'dr Vincent van Gogh dr' in both, so the per-file counts overlap by #: one and are not a partition. Every row is a default-order shape, #: as the roster above's are, so no row here is a declared-order-only @@ -1886,7 +1924,7 @@ class _ShapeMismatch(NamedTuple): #: 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 +#: rather than 41 / 31 / 30 / 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 @@ -1967,7 +2005,6 @@ class _ShapeMismatch(NamedTuple): "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",), @@ -1981,27 +2018,13 @@ class _ShapeMismatch(NamedTuple): "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. TWO are - # contract tier (corpus_cjk.jsonl) -- '김민준 박사님' and - # '선생님' -- so a move on those fails the run; the other two - # sit in corpus_cjk_tolerated.jsonl, radar, and print. - # Re-measured 2026-09-05: '田中さん 様.' was the third contract - # one, in corpus_cjk.jsonl and corpus_rules.jsonl both, and the - # period-class demotion (decisions.md#cjk-comma-demotion) moved - # it to the radar half of the CJK projection and took W2's - # example line with it. A row's fatality follows the tier its - # NAME reads at, which no roster in this file records -- so a - # demotion changes what a row here costs without touching the - # row, and this comment is the only thing that goes stale. - "田中さん 様.": ("family", "given", "suffix"), - "김민준 박사님": ("family", "given", "suffix"), - "선생님": ("family", "given"), - "田中さん, 様.": ("family", "given", "suffix"), + # The four #501 contests that closed this section, and 'MD, PHD' + # beside them, are GONE from it: #501 argued a winner for each + # on 2026-09-05 and the five rows moved to _RECORDED_DIFFS with + # their shapes unchanged, which is what this dict's NO WINNER + # clause says happens the day one is argued. Nothing about them + # is left here to go stale, the tier note included -- what a + # contest row costs is _RECORDED_DIFFS' subject now. }, "expected_since_2.1.0.toml": { "Anh do": ("_initials",), @@ -2021,7 +2044,6 @@ class _ShapeMismatch(NamedTuple): "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",), From e80df726843ae1de36eec5fee7645234e38e8a8c Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 5 Sep 2026 15:42:52 -0700 Subject: [PATCH 3/5] docs(design): the #501 adjudication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dated entry in decisions.md for the ninth ledger arc, the amendments the two commits before it made owed to the arc above, and one sentence each in the two mechanisms entries the arc moves. THE ENTRY records the six verdicts one at a time -- the admitting rules with their file positions, `fields` and pair class, the measured old -> new field values at the pinned wheel, the ledger or rules.md sentence each verdict rests on quoted, and the in-memory swap the pin now catches -- then the narrowing and its per-ledger consequence, four findings, three declines and the recompute for all of it. MEASURED THIS SESSION at ddd8b2b, from one instrumented run of main() at all four baselines with classify() and _run_worker spied. 352 / 247 / 155 / 14 intentional, 0 unexplained, 0 radar unclassified, exit 0, no MOVED SHAPE and no NOT CHECKED. The controls reproduce ddd8b2b's captures byte for byte. THE SHARPEST FINDING is that the narrowing dissolved two of the six contests. At 2.0.0 only three of the 247 classifications now have more than one admitting rule; '김민준 박사님' and '선생님' are one-runner rows, of the kind 13 of the 31 at 1.4.0 are, and they stay because the test is mechanisms.md#RECORDED-ROSTERS' -- would the consumer answer differently if the row were wrong. Measured: '김민준 박사님' routes to fix(#271/#272/#298) under other shapes as 11 of those 13 do, and '선생님' routes nowhere else, its whole work being against a re-widening. The plan's control (iii) as written was therefore INERT -- the reorder alone moves 0 of the 247 -- and the honest control restores the wide regex AND swaps. TWO CORRECTIONS to figures this branch made stale, both as dated clauses rather than rewrites: 'MD, PHD' at 2.0.0 was itself a _WATCHED_DIFFS row, so FIVE rows left that section and not four and six in all; and the pair class of the '様.' contest is strictly NESTED and narrow-first ({family, given, suffix} inside {family, given, middle, suffix}), not non-nesting -- what survives is the conclusion, that no precedes_narrower block is owed, and order_contests reports the pair only in the arrangement nobody ships (measured both ways). THE WATCHED-SHAPES ARC gets seven dated amendments, in the form line 811 already uses: 41 / 36 / 31 / 5 = 113 over 51 names became 41 / 31 / 30 / 5 = 107 over 50, the roster now being exactly the sole-watched population; the exposure bullet's figures re-derived over the 107 as 363 / 66 gross, 310 / 58 net, 35 other-rule, with both of the figures its argument rests on reproducing unchanged; the per-file split 48 + 3 over 50; and its `Declined:` bullet on pinning the six marked REVERSED the same day, its text untouched. ONE FIGURE IS LEFT UNRECONCILED and says so: 22 of the 31 rows at 1.4.0 sit on radar names today against the 21 measured 2026-09-03. No corpus file and no _CORPUS_TIERS value differs between the two trees and the section held the same 31 rows on both days, so it is a recipe difference; the entry gives the recompute and enumerates the nine contract rows so the next reader can settle it by hand. Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 46 +++++++++++++++++++++++++++++------- docs/design/mechanisms.md | 4 ++-- tools/differential/README.md | 2 +- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 632c8c4d..4d39dd60 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -834,17 +834,17 @@ The eighth ledger arc, and the one that lets a diff shape be recorded WITHOUT a 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 — 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; 48 of the 50 since the #501 adjudication below, and the burden argument is unmoved by either count). 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. +- 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. (The four moved to `_RECORDED_DIFFS` with the #501 adjudication below, 2026-09-05, so every row in `_WATCHED_DIFFS` is sole-watched today; the decision is about the roster's DEFINING property, which is unchanged — no row there pins a winner.) +- 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. (AMENDED 2026-09-05, later the same day: the #501 adjudication below took six rows to `_RECORDED_DIFFS` — the four contests and `'MD, PHD'`'s two, which this count read as sole-watched and which were not — so the roster is 107 rows over 50 names and is now exactly the sole-watched population. The decision is about WHICH diffs get a row and survives the totals moving.) 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. +- **The spec's 385 / 71 was GROSS of #452 and measured on a population the arc had already retracted; the net figure over the roster is the one to argue from.** `over_declared_rules` requires a rule's `fields` to EQUAL the union of the diffs it explains, so on a rule explaining only one name any shrink fires OVER-DECLARED and is not silent at all. Measured 2026-09-05 over the 113 rows `_WATCHED_DIFFS` holds: 390 alternative shapes classify to the same rule and 72 diffs are exposed by that count; net of #452 — keeping only the subsets `S` for which the union of the rule's OTHER explained diffs at that baseline, joined with `S`, still equals the rule's `fields` — 337 alternatives over 64 exposed diffs, and 44 further alternatives route to a different rule. Per baseline the gross / net / other-rule split reads 105 / 96 / 23 at 1.4.0, 158 / 137 / 20 at 2.0.0, 127 / 104 / 1 at 2.1.0 and 0 / 0 / 0 at 2.2.0. Over the 109 sole-watched rows alone — the roster minus the four #501 contests, which is the population the label "sole-watched" names — 367 gross over 68 exposed diffs, 314 net over 60, 35 other-rule, the whole difference sitting at 2.0.0 where the four contested rows are. The spec's first draft measured 385 / 71 gross, 330 / 62 net and 35 other-rule, with 123 / 112 / 23 at 1.4.0, over the population the next bullet retracts: 52 names and 113 diffs, holding the four names with 1.4.0 contest rows (45 rows there, `'MD, PHD'` alone contributing 14 alternatives) and not the four #501 rows — the same total by coincidence, so those figures read as the roster's and were not. The 8 diffs that leave the exposed count under the net test — `'Esq. van Gogh'` at all three baselines where it diffs, `'Dr. Do Van Johnson, MD'` at both 2.x, `'Aishwarya Rai'`, `'Jack M.A.'`, `'Jane van der Berg 旧姓 Jones'` — are exactly the diffs that sit ALONE on their rule (the 72 → 64 drop; the spec's population had 9, `'Carod i'` the ninth, its 71 → 62), but they do NOT account for all the alternatives #452 catches: they carry 24 of the 53 caught over the roster (26 of 55 over the spec's population), and the other 29 come from diffs whose rule explains other names that do not between them cover its `fields`, so the diff stays exposed while some of its alternatives are caught. Two figures are robust across all three populations, and they are what the argument rests on: the worst case, `'Ph. D., Jr.'` at 2.0.0 and 2.1.0, 30 alternatives against a five-field rule, all 30 surviving #452; and the 35 other-rule alternatives over the sole-watched rows, equally unpinned and equally silent, a handover nobody would see — the metric UNDERCOUNTS in that direction whatever it counts in the other. Both corrections leave the conclusion where it was. AMENDED 2026-09-05, later the same day: the #501 adjudication below moved six of these rows out of `_WATCHED_DIFFS`, so the population every figure in this bullet is over went from 113 rows to 107 and the roster figures above are a snapshot of the tree this arc's own commits left. Re-run by the RECOMPUTE below over the 107: 363 gross alternatives over 66 exposed diffs, 310 net of #452 over 58, 35 other-rule, with 105 / 96 / 23 at 1.4.0, 133 / 112 / 11 at 2.0.0, 125 / 102 / 1 at 2.1.0 and 0 / 0 / 0 at 2.2.0, and 8 diffs alone on their rule. Both of the figures the argument actually rests on reproduce unchanged, which is the point of having named them: `'Ph. D., Jr.'` at 2.0.0 and at 2.1.0 is still 30 alternatives against a five-field rule with all 30 surviving #452, and the other-rule count is still 35 — the two rows that left carried no other-rule alternative between them. RECOMPUTE: spy on `compare.classify` through a real `main()` run at each baseline, keep the default-order calls on the names keyed in `_WATCHED_DIFFS` for that ledger (the roster's own keys, not a scan of `tests/`; drop the four #501 names for the sole-watched figures), and for each enumerate the non-empty subsets of the winning rule's `fields`, sorting each by whether `classify()` routes it to the winner, to another rule, or to nothing; the #452-aware count applies the union test above, and a diff is "alone on its rule" when no other `(name, order)` call at that baseline classified to the same rule. +- **A "where is this named" population must include the tool.** The `tests/` scan cannot see `compare.py`, so four names carrying 1.4.0 contest rows — `'Bob Jones, author'`, `'Carod i'`, `'MD, PHD'`, `'van ma van'` — scored as sole-watched there, watched by exactly the mechanism the arc extends. The definition gained its last clause (not already keyed in `_RECORDED_DIFFS` for that ledger); three of the four return at a 2.x baseline where they have no contest row, and `'Carod i'` diffs under the default order at 1.4.0 ONLY, so it leaves the population entirely: 51 names, not the 52 the `tests/`-only scan gave, and 41 rows at 1.4.0 where the spec's first table said 45. (AMENDED 2026-09-05, later the same day: `'MD, PHD'` leaves entirely too once the #501 adjudication below pins its 2.x pair, since it then carries a contest row at every baseline it diffs at — so TWO of the four return at a 2.x baseline and two leave, and the population is 50 names against the same scan's 52. Re-measured by the recipe: 41 / 31 / 30 / 5 = 107 rows over 50 names, the scan's own 45 / 32 / 31 / 5 = 113 over 52 unchanged, and the roster is exactly the 50-name set.) One more clause the recipe carried and the definition did not until the design-docs review: explained by a ledger rule at that baseline — a diff no rule explains is already printed by every run as unclassified, so a rule is the only weak watcher a row is needed for; a definition clause and not a live count, since `radar unclassified` reads 0 at every baseline today and the two sets coincide. RECOMPUTE: the population recipe is in `_WATCHED_DIFFS`' header; print the names its last clause removes per ledger, and the four appear at 1.4.0 and none elsewhere. - **`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. +- **The per-file split is not a partition.** 49 of the 51 sole-watched names sit in `corpus_issues.jsonl` and 3 in `corpus.jsonl`, with `'dr Vincent van Gogh dr'` in both, so the per-file counts overlap by one and 49 + 3 reads 52 for a 51-name population. (AMENDED 2026-09-05, later the same day: 48 and 3 over the 50 the #501 adjudication below leaves, the same name overlapping, so 48 + 3 reads 51 for a 50-name population — the shape of the finding, which is that the split is not a partition, is what survives the digits.) RECOMPUTE from the population script's JSON against `_load_entries` over the two radar files. - **A missing roster section was read as an empty one (found 2026-09-05, in the PR's review round).** `main()` read both shape rosters with `.get(ledger.name, {})`, so a ledger with no section checked nothing and said nothing: with the 1.4.0 key deleted from `_WATCHED_DIFFS` in memory, a full run at that baseline checked its 41 rows against nothing, printed the same 375 lines (differing only in the worker environment's path on the `baseline:` line, which differs between any two runs) and exited 0 — the guards would have caught the deletion at pytest speed, but the run may not assume the suite ran, which is `_CORPUS_TIERS`' own ground. Now refused pre-worker, beside the `both` refusal, naming the dict and the ledger; `_CORPUS_TIERS` refuses a VALUE outside `contract`/`radar` at load on the same principle, since every downstream tier read is an `!=` against one literal and a misspelling would land on whichever side it happened to fall. RECOMPUTE on a tree before the refusal: `del compare._WATCHED_DIFFS['expected_since_1.4.0.toml']` around `main()` at `--baseline 1.4.0`, and diff stdout and the exit code against an unmodified run, with two unmodified runs diffed as the noise control. - **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. @@ -852,10 +852,40 @@ 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. +- Pinning winners for the six #501 contests (2026-09-05) — stays #501's. The four 2.0.0 rows here and `'MD, PHD'`'s two record SHAPES, not winners: a shape move is now a finding on them, while a handover to another rule is not — file order still decides the winner, and only a winner pin sees it. #501's own objection to pinning a boundary nobody has argued stands, and the issue is scoped down by that comment, not closed. (REVERSED the same day, 2026-09-05, by the #501 adjudication below, which argued all six and moved the rows — read this bullet as what THIS ARC declined to do in its own commits and not as a standing position. What it got right is kept there: the rows recorded shapes and not winners for as long as nobody had argued the boundaries. What it got wrong is that three of the six had already been argued, in the 2.0.0 ledger's own comment, so the objection was about where the argument was written rather than about whether one existed.) - A checked-in generator for the rows (2026-09-05) — the rows are a snapshot, and a generator that re-derives them on demand is the re-derivation hazard with a command line (mechanisms.md#RECORDED-ROSTERS). A scratch script and a recipe in the roster comment, as `_CORPUS_FLOORS` and `_CORPUS_CLAIMS` do. -The measurement, and how to redo it. `_WATCHED_DIFFS` holds 41 / 36 / 31 / 5 rows at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0, 113 in all; the 2.0.0 section is 32 sole-watched rows plus the four #501 contests (`'김민준 박사님'` and `'선생님'`, contract tier and fatal; `'田中さん 様.'` and `'田中さん, 様.'`, `corpus_cjk_tolerated.jsonl`, radar and printed — the first of that pair read contract when this was measured earlier the same day, and the period-class demotion under #cjk-comma-demotion above moved it hours later, which is the point the snapshot makes better than the prose could: a row's fatality is a property of the tier its NAME reads at, and no roster in `compare.py` records it). 51 distinct sole-watched names; every row a default-order shape, so `NOT CHECKED` names none of them. Four mutation controls, in memory around `main()` with the worktree untouched, re-run 2026-09-05 against the amended data commit: `'QC MP'` corrupted at 1.4.0 (its only row — it does not diff at 2.0.0) prints `MOVED SHAPE (radar) expected_since_1.4.0.toml: 1 watched diff shape(s) disagree with this run` and exits 0, `'Smith Dr'` at 2.0.0 likewise; `'선생님'` corrupted at 2.0.0 prints `MOVED SHAPE expected_since_2.0.0.toml: 1 watched diff shape(s) disagree with this run` ending "This fails the run because the name is contract tier" and exits 1; `'Ph. D., Jr.'` corrupted at 2.1.0 prints the `(radar)` block and exits 0; and `'MD, PHD'` inserted into the 1.4.0 watched section beside its contest row is refused pre-worker, before any `baseline:` line, as "1 name(s) sit in both `_RECORDED_DIFFS['expected_since_1.4.0.toml']` and `_WATCHED_DIFFS['expected_since_1.4.0.toml']`", and fails `test_the_watched_roster_is_disjoint_and_names_every_ledger` naming it — the guard half wrapping `load_tool`, since that helper executes a fresh `compare` module per call and a mutation on an imported one never reaches it. A control whose corruption changed nothing would have meant an inert row; none did. The arc moved no classification: 352 / 247 / 155 / 14 intentional diffs, 0 unexplained and 0 radar-unclassified at every baseline, byte-identical summary lines before and after every commit. The quiet-tree claim that made this the moment to snapshot: measured 2026-09-05 at `4dd776a`, `git rev-list --count a965272..4dd776a -- nameparser/` is 5 commits since the tier split, moving 38 inserted and 15 deleted lines across two files, `_facade.py` (the #462 initials fix and its review rounds) and `_pipeline/_script_segment.py` (`f046d5d`, the tier-split day's own W3 demotion). PINNED TO THAT COMMIT deliberately, and NO LIVE HEAD FIGURE STANDS BESIDE IT — a first draft of this paragraph put one here as a contrast, and that number was already wrong at the commit that wrote it, which is precisely the defect the pin exists to avoid rather than an illustration of it. What can be said without a number that rots: the period-class bundle's commits after `4dd776a` touched `nameparser/_pipeline/_script_segment.py`'s comments and its W2/W3 citations — and the commit correcting this very sentence was one of them, after a draft that named two by hash had been made stale by the third — so the same range recomputed at HEAD is LARGER than the pinned one on all three counts, and it moves again with every further comment-only commit. Neither the count of such commits nor their hashes belongs here: `git log --oneline 4dd776a..HEAD -- nameparser/` lists them, and a figure that would have to be rewritten by each commit that changes it is not a figure prose can hold. So this is a POINT measurement of the tree the snapshot was taken on, and rereading it as a live one is the error to avoid — the figure goes stale by design, since the next feature is what the snapshot exists to make legible. RECOMPUTE the row counts with `{k: len(v) for k, v in compare._WATCHED_DIFFS.items()}`, the population with the script in the dict's header, the controls with `compare._WATCHED_DIFFS[ledger][name] = (...)` around `main()`, and the quiet tree with the command above plus `git diff --shortstat a965272..4dd776a -- nameparser/` — with the PINNED endpoint and not `HEAD`. A `HEAD` substitution answers a different question, and this paragraph deliberately does not state its answer. +The measurement, and how to redo it. `_WATCHED_DIFFS` holds 41 / 36 / 31 / 5 rows at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0, 113 in all; the 2.0.0 section is 32 sole-watched rows plus the four #501 contests (`'김민준 박사님'` and `'선생님'`, contract tier and fatal; `'田中さん 様.'` and `'田中さん, 様.'`, `corpus_cjk_tolerated.jsonl`, radar and printed — the first of that pair read contract when this was measured earlier the same day, and the period-class demotion under #cjk-comma-demotion above moved it hours later, which is the point the snapshot makes better than the prose could: a row's fatality is a property of the tier its NAME reads at, and no roster in `compare.py` records it). 51 distinct sole-watched names; every row a default-order shape, so `NOT CHECKED` names none of them. (AMENDED 2026-09-05, hours later: those four rows and `'MD, PHD'`'s two — at 2.0.0 and 2.1.0 — left for `_RECORDED_DIFFS` with the #501 adjudication below, so the roster now reads 41 / 31 / 30 / 5 = 107 over 50 names and the 2.0.0 section is 31 sole-watched rows and nothing else. The figures in this paragraph were true of the tree this arc's commits left; run the RECOMPUTE at the end of it for today's.) Four mutation controls, in memory around `main()` with the worktree untouched, re-run 2026-09-05 against the amended data commit: `'QC MP'` corrupted at 1.4.0 (its only row — it does not diff at 2.0.0) prints `MOVED SHAPE (radar) expected_since_1.4.0.toml: 1 watched diff shape(s) disagree with this run` and exits 0, `'Smith Dr'` at 2.0.0 likewise; `'선생님'` corrupted at 2.0.0 prints `MOVED SHAPE expected_since_2.0.0.toml: 1 watched diff shape(s) disagree with this run` ending "This fails the run because the name is contract tier" and exits 1; `'Ph. D., Jr.'` corrupted at 2.1.0 prints the `(radar)` block and exits 0; and `'MD, PHD'` inserted into the 1.4.0 watched section beside its contest row is refused pre-worker, before any `baseline:` line, as "1 name(s) sit in both `_RECORDED_DIFFS['expected_since_1.4.0.toml']` and `_WATCHED_DIFFS['expected_since_1.4.0.toml']`", and fails `test_the_watched_roster_is_disjoint_and_names_every_ledger` naming it — the guard half wrapping `load_tool`, since that helper executes a fresh `compare` module per call and a mutation on an imported one never reaches it. A control whose corruption changed nothing would have meant an inert row; none did. The arc moved no classification: 352 / 247 / 155 / 14 intentional diffs, 0 unexplained and 0 radar-unclassified at every baseline, byte-identical summary lines before and after every commit. The quiet-tree claim that made this the moment to snapshot: measured 2026-09-05 at `4dd776a`, `git rev-list --count a965272..4dd776a -- nameparser/` is 5 commits since the tier split, moving 38 inserted and 15 deleted lines across two files, `_facade.py` (the #462 initials fix and its review rounds) and `_pipeline/_script_segment.py` (`f046d5d`, the tier-split day's own W3 demotion). PINNED TO THAT COMMIT deliberately, and NO LIVE HEAD FIGURE STANDS BESIDE IT — a first draft of this paragraph put one here as a contrast, and that number was already wrong at the commit that wrote it, which is precisely the defect the pin exists to avoid rather than an illustration of it. What can be said without a number that rots: the period-class bundle's commits after `4dd776a` touched `nameparser/_pipeline/_script_segment.py`'s comments and its W2/W3 citations — and the commit correcting this very sentence was one of them, after a draft that named two by hash had been made stale by the third — so the same range recomputed at HEAD is LARGER than the pinned one on all three counts, and it moves again with every further comment-only commit. Neither the count of such commits nor their hashes belongs here: `git log --oneline 4dd776a..HEAD -- nameparser/` lists them, and a figure that would have to be rewritten by each commit that changes it is not a figure prose can hold. So this is a POINT measurement of the tree the snapshot was taken on, and rereading it as a live one is the error to avoid — the figure goes stale by design, since the next feature is what the snapshot exists to make legible. RECOMPUTE the row counts with `{k: len(v) for k, v in compare._WATCHED_DIFFS.items()}`, the population with the script in the dict's header, the controls with `compare._WATCHED_DIFFS[ledger][name] = (...)` around `main()`, and the quiet tree with the command above plus `git diff --shortstat a965272..4dd776a -- nameparser/` — with the PINNED endpoint and not `HEAD`. A `HEAD` substitution answers a different question, and this paragraph deliberately does not state its answer. + +### differential-ledger, the #501 adjudication (2026-09-05) + +The ninth ledger arc, and the one that answers the question the arc above left open hours earlier in its own `Declined:` list — whether a contest that is MEASURED but unargued is owed a pinned winner. Six were measured across the two 2.x ledgers as this branch opened — five of the 247 diffs at 2.0.0 and one of the 155 at 2.1.0 moving a shape two or more rules admit — and all six are owed one: three of the six boundaries had ALREADY been argued, in a ledger comment, which is a place nothing in the tree can check, and one of those three arguments was false. Closing that one took a regex narrowing rather than a pin, and the narrowing then dissolved two of the six contests, which is this arc's sharpest finding about what a pin is for. Mechanics — the two rosters and their contracts, the severity rule, the population recipe, the six rows' own arguments — are owned by `tools/differential/compare.py` (`_RECORDED_DIFFS`' and `_WATCHED_DIFFS`' headers), by `tests/v2/test_ledger_guards.py` (the comment over `_CROSS_RULE_WINNERS`' two 2.x sections, and the per-row comments) and by tools/differential/README.md; these are the decisions. Every figure below was measured 2026-09-05 on this branch at `ddd8b2b`, from real `main()` runs at all four baselines with `classify()` and `_run_worker` spied, and each carries its recipe. Closes [#501](https://github.com/derek73/python-nameparser/issues/501). + +Decisions that landed: + +- 2026-09-05 #501 — a row is owed where the boundary is ARGUED ANYWHERE, not where it is argued in the guard module. The position the arc above recorded — "a row is owed when somebody ARGUES a boundary, not when one is merely measured" — is kept whole; what moves is WHERE an argument counts. Three of the six boundaries were already argued in the 2.0.0 ledger's own comment on `fix(#308/#312/#319/#320)`, which named `'田中さん 様.'`, `'田中さん, 様.'` and `'김민준 박사님'` one by one and said for each why the glued rule should win. That is a claim about which rule wins a name, and it is checked by NOTHING: `_CORPUS_CLAIMS` measures a rule's regex reach alone and cannot see a name changing hands inside it, the gate's totals are per-corpus, `order_contests` reports only wide-first nested pairs, and `classify()` is never asked the question unless a roster row asks it. `_CROSS_RULE_WINNERS` is the only thing in the tree that can check a ledger comment's adjudication, so scoping its entry criterion to arguments made in its own file left exactly the arguments it exists to check outside it. The proof is the third of the three, below. +- 2026-09-05 #501 — `'田中さん 様.'` and `'田中さん, 様.'` → `fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix`, at 2.0.0. The only one of the six that is a live two-runner contest after the narrowing: both names are admitted by the glued rule and by `fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix`, and the glued rule is written first (sorted indices 1 and 2; lines 71 and 149 of the ledger on this branch). Measured against the 2.0.0 wheel, both diff `{family, given, suffix}` — `'田中さん 様.'` reads first `'田中さん'`, last `'様.'` on the wheel and family `'田中'`, suffix `'さん, 様.'` on the tree; `'田中さん, 様.'` reads first `'様.'`, last `'田中さん'` on the wheel and the same family `'田中'`, suffix `'さん, 様.'` on the tree. Both names move through TWO mechanisms at once — the glued さん peels off 田中さん while the spaced 様 routes to `suffix` — with one rule per half, so neither rule describes the whole diff and the label is PARTIAL in #382's accepted sense. The prose test lands where the file order already does: the glued rule's own #320 clause names both strings outright — "#320 stops an ASCII period vetoing the honorific it finds there ('田中さん 様.', '田中さん, 様.')" — and no clause of the spaced rule mentions either, its examples being `'王小明 先生'`, `'김민준 씨'`, `'田中 太郎 様'`, `'田中 殿'`, `'김민준 씨.'` and `'김민준 양.'`. What the pin buys, measured: swapping the two rules in memory hands BOTH names to the spaced rule and touches nothing else — 2 of the 247 classifications move, `unexplained` stays 0 so the gate's summary line does not move, and neither rule's `_CORPUS_CLAIMS` reach can see a reorder at all. The guard fails on both names, first line "expected_since_2.0.0.toml: '田中さん 様.' diffing ['family', 'given', 'suffix'] is now explained by 'fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix', not 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix'." +- 2026-09-05 #501 — `'김민준 박사님'` → `fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix`, at 2.0.0, and the pin holds only because of the narrowing in the commit before it. Before the narrowing the glued rule reached the name on the 님 interior to 박사님 and, sitting first, took it. Measured against the 2.0.0 wheel the diff is `{family, given, suffix}`: the wheel reads first `'김민준'`, last `'박사님'`, and the tree reads given `'민준'`, family `'김'`, suffix `'박사님'` — the honorific WHOLE and standing as its own word, with nothing peeled off the name. The prose test is rules.md#W2's second sentence, "A part that is not name text — a post-nominal word standing on its own — is never the name's end: the split-off steps past it to the name word behind, and never dissects it", and that rule's witness line for this very string, `"김민준 박사님" → suffix="박사님"`. So the glued rule's title was FALSE of the name, on a contract-tier string (`corpus_cjk.jsonl`), held green by every guard in the suite from the date the comment's own parenthetical carries (2026-08-05) until this arc. What the pin buys, measured: restoring the pre-narrowing regex ALONE — one edit, no reorder, the glued rule still sitting ahead of the spaced one — hands the name straight back, 1 of the 247 classifications moving with `unexplained` still 0, and this row is the only thing that says so. The guard's first line is "expected_since_2.0.0.toml: '김민준 박사님' diffing ['family', 'given', 'suffix'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix'." +- 2026-09-05 #501 — `'선생님'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, at 2.0.0. Measured against the wheel the whole diff is `{family, given}` — the wheel reads first `'선생님'` and the tree reads family `'선생님'`, the 2.1 order flip on a hangul string and nothing else — which is exactly the shape that rule's comment claims for itself, "these are one diff shape -- pieces moving between given/middle/family on a native-script CJK name", and which rules.md#W2's own boundary line `"선생님" → family="선생님"` says is name text rather than a peel. The glued rule's claim on it was the same regex accident, the interior 님, and worse than a partial label: it declares `suffix` and NO `suffix` moves here at all. It never won, the order rule being written first (sorted index 0 against the glued rule's 1), and the narrowing has since taken its admission away. Recorded because the pair is the #498 class and nothing else can see it: the two rules' `fields` INTERSECT on `{family, given}` — `{family, given, suffix}` against `{_ambiguities, family, given, middle}` — with neither nested, so `undeclared_contests` looks past them in either arrangement and no `precedes_narrower` block is available to declare them. What the pin buys, measured: a reorder ALONE now moves nothing here (0 of the 247), so the row's whole work is against a RE-WIDENING — restore the wide regex and swap the two rules and 2 classifications move, `unexplained` staying 0, with the guard's first line "expected_since_2.0.0.toml: '선생님' diffing ['family', 'given'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#271/#272/#298) native-script CJK: …'". +- 2026-09-05 #501 — `'MD, PHD'` → `fix(#296) a credential-only comma string reads a name and its postnominal`, at 2.0.0 AND at 2.1.0, as a row apiece rather than one shared row. Both #296 rules reach the string and declare EQUAL `fields` at each 2.x baseline, `["title", "suffix"]`, so `fields` cannot separate them at all and file order is the whole decision (sorted indices 25 and 26 at 2.0.0, 12 and 13 at 2.1.0). Measured against both wheels the diff is `{suffix, title}` — title `'PHD'` on the wheel becomes suffix `'PHD'` on the tree, family `'MD'` on both sides. The distinguishing clause is a property of the STRING and not of the rules' `fields`: the credential-only rule opens "'Jr., PhD' / 'MD, PHD': a string that is nothing BUT credentials", while the lone-post-comma rule is written for "A one-word name, a comma, one short word". `'MD, PHD'` holds no name, so the first rule's prose is true of it and the second's is not. Two rows and not one because the roster is keyed per ledger and a shape is baseline-relative — the 1.4.0 row on the same string and the same winner rests on a different measurement, `{family, given, suffix, title}` there, the comma-family move not yet having landed. What the pin buys, measured: swapping the two rules moves 1 of the 247 at 2.0.0 and 1 of the 155 at 2.1.0, `unexplained` staying 0 at both, and the guard fails at BOTH baselines, first line "expected_since_2.0.0.toml: 'MD, PHD' diffing ['suffix', 'title'] is now explained by 'fix(#296) a lone post-comma credential is a suffix', not 'fix(#296) a credential-only comma string reads a name and its postnominal'." and the same sentence under `expected_since_2.1.0.toml`. +- 2026-09-05 #501 — the glued-peel `name_regex` is NARROWED so it stops matching a listed honorific INTERIOR to a longer listed one, in BOTH ledgers that ship it (`expected_since_1.4.0.toml` and `expected_since_2.0.0.toml` carry it byte-identically). `(?' -- 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. From e5f7dc665a6d053d8f470f1dd80295d24a6fa921 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 5 Sep 2026 15:51:59 -0700 Subject: [PATCH 4/5] review round: #501 pins -- five prose claims and one count reconciled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prose only; no rule, roster row, regex or assertion moves, and every figure below was measured on this tree this session. FIVE CLAIMS. (1) _sorted_rules' docstring said three names match both honorific rules; two do since the narrowing (measured over the corpus*.jsonl union: '田中さん 様.' and '田中さん, 様.'; three at master, e004ccf, where '김민준 박사님' was the third). It is the twin of the ledger sentence a6a82d6 already fixed. (2) The '様.' row's comment called the pair one whose fields do not nest. They nest strictly -- the glued rule's {given, family, suffix} inside the spaced rule's {given, middle, family, suffix} -- and the glued rule is written FIRST, so the pair is nested NARROW-FIRST and no `precedes_narrower` is owed because the narrower rule already wins. Measured over the corpus union, order_contests reports nothing for this ledger as it stands and reports this pair, both names, only with the two rules swapped; undeclared_contests likewise. What is unusual about the pair survives the correction and is now stated as itself: narrow-first's JUSTIFICATION does not apply, both mechanisms firing at once with each rule describing one half, so the row pins a default that is correct without being justified. (3) '5 of the 247 at 2.0.0 and 1 of the 155 at 2.1.0' is the pre-narrowing figure and now says so; the same recipe here gives 3 and 1, the narrowing having dissolved two of the six contests, which the paragraph below it already said. (4) 'THREE of the six are RADAR-tier names' counted names and said rows: four of the six ROWS are radar, three distinct NAMES, 'MD, PHD' carrying a row at each 2.x baseline. (5) 'the commit before this one' is now the 2026-09-05 narrowing of the glued-peel `name_regex` named outright. THE 21/22 COUNT is a tier move, not a recipe difference, and the row is '김민준 씨.': eb2d0d2, the period-class demotion of PR #505, moved it from corpus_cjk.jsonl (contract) to corpus_cjk_tolerated.jsonl (radar), so it counted contract on 2026-09-03 and counts radar now. The 31 rows are byte-identical between the two trees. The #501 entry's "no corpus file changed" check ran `git show` at `88b025b^` = d0faed4, three commits AFTER the move -- the one thing the window could not see. Both candidate recipes were run on both trees and at baselines 1.4.0 and 2.0.0: first-corpus-file-with-contract-first, and main()'s own tier_of, give 21/10 at 17a9e66 and 22/9 here, name for name. They can differ in principle -- a string in files of both tiers whose contract copy is order-bearing loses that copy to the baseline-minimum skip -- and no row of this section is in that position today. The "NOT reconciled" sentence goes; README.md's tier paragraph, which carries the live recompute, says what the 21 became and why. VERIFIED: pytest tests/v2/ -k "doc or rules or decisions or mechanisms or guard or citation" 704 passed 1 skipped; test_ledger_guards.py + test_differential.py 298 passed; ruff clean; mypy clean over 111 files; gate exit 0 at 2.0.0 (247 intentional, 0 unexplained, 0 radar unclassified) and at 1.4.0 (352, 0, 0), no MOVED SHAPE, no NOT CHECKED. Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 4 +-- tests/v2/test_ledger_guards.py | 54 ++++++++++++++++++++++++---------- tools/differential/README.md | 6 +++- tools/differential/compare.py | 18 ++++++++---- 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 4d39dd60..e635fa35 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -869,14 +869,14 @@ Decisions that landed: - 2026-09-05 #501 — `'선생님'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, at 2.0.0. Measured against the wheel the whole diff is `{family, given}` — the wheel reads first `'선생님'` and the tree reads family `'선생님'`, the 2.1 order flip on a hangul string and nothing else — which is exactly the shape that rule's comment claims for itself, "these are one diff shape -- pieces moving between given/middle/family on a native-script CJK name", and which rules.md#W2's own boundary line `"선생님" → family="선생님"` says is name text rather than a peel. The glued rule's claim on it was the same regex accident, the interior 님, and worse than a partial label: it declares `suffix` and NO `suffix` moves here at all. It never won, the order rule being written first (sorted index 0 against the glued rule's 1), and the narrowing has since taken its admission away. Recorded because the pair is the #498 class and nothing else can see it: the two rules' `fields` INTERSECT on `{family, given}` — `{family, given, suffix}` against `{_ambiguities, family, given, middle}` — with neither nested, so `undeclared_contests` looks past them in either arrangement and no `precedes_narrower` block is available to declare them. What the pin buys, measured: a reorder ALONE now moves nothing here (0 of the 247), so the row's whole work is against a RE-WIDENING — restore the wide regex and swap the two rules and 2 classifications move, `unexplained` staying 0, with the guard's first line "expected_since_2.0.0.toml: '선생님' diffing ['family', 'given'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#271/#272/#298) native-script CJK: …'". - 2026-09-05 #501 — `'MD, PHD'` → `fix(#296) a credential-only comma string reads a name and its postnominal`, at 2.0.0 AND at 2.1.0, as a row apiece rather than one shared row. Both #296 rules reach the string and declare EQUAL `fields` at each 2.x baseline, `["title", "suffix"]`, so `fields` cannot separate them at all and file order is the whole decision (sorted indices 25 and 26 at 2.0.0, 12 and 13 at 2.1.0). Measured against both wheels the diff is `{suffix, title}` — title `'PHD'` on the wheel becomes suffix `'PHD'` on the tree, family `'MD'` on both sides. The distinguishing clause is a property of the STRING and not of the rules' `fields`: the credential-only rule opens "'Jr., PhD' / 'MD, PHD': a string that is nothing BUT credentials", while the lone-post-comma rule is written for "A one-word name, a comma, one short word". `'MD, PHD'` holds no name, so the first rule's prose is true of it and the second's is not. Two rows and not one because the roster is keyed per ledger and a shape is baseline-relative — the 1.4.0 row on the same string and the same winner rests on a different measurement, `{family, given, suffix, title}` there, the comma-family move not yet having landed. What the pin buys, measured: swapping the two rules moves 1 of the 247 at 2.0.0 and 1 of the 155 at 2.1.0, `unexplained` staying 0 at both, and the guard fails at BOTH baselines, first line "expected_since_2.0.0.toml: 'MD, PHD' diffing ['suffix', 'title'] is now explained by 'fix(#296) a lone post-comma credential is a suffix', not 'fix(#296) a credential-only comma string reads a name and its postnominal'." and the same sentence under `expected_since_2.1.0.toml`. - 2026-09-05 #501 — the glued-peel `name_regex` is NARROWED so it stops matching a listed honorific INTERIOR to a longer listed one, in BOTH ledgers that ship it (`expected_since_1.4.0.toml` and `expected_since_2.0.0.toml` carry it byte-identically). `(? None: # admit, file order picking the winner ('MD, PHD' is the one both # baselines have), and the position was that a row is owed when # somebody ARGUES a boundary, not when one is merely measured. - # Recompute the six: drive compare.main() at the baseline, capture - # its `diffing` by wrapping dormant_rules, and count the names for - # which more than one rule satisfies compare._entry_matches at the - # measured shape. + # That 5 is the PRE-NARROWING figure (measured 2026-09-03, + # re-measured 2026-09-05 before the narrowing landed). The same + # recipe over THIS tree gives 3 at 2.0.0 and the same 1 at 2.1.0 + # (measured 2026-09-05). 2.1.0 does not move because the narrowing + # reaches no name it counts; 2.0.0 falls by two because the + # narrowing DISSOLVED two of the six contests -- '김민준 박사님' and + # '선생님' have one admitter each now, which the paragraph below + # says as well. Both figures are the same recipe on different + # trees, not two recipes. RECOMPUTE (either one): drive + # compare.main() at the baseline, capture its `diffing` by + # wrapping dormant_rules, and count the names for which more than + # one rule satisfies compare._entry_matches at the measured shape. # # What the position got wrong was WHERE an argument counts. Three # of the six boundaries were already argued -- in the 2.0.0 @@ -2739,8 +2747,9 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: # matching that rule on the 님 interior to a spaced 박사님 -- and it # sat in the ledger for a month with every guard green, because no # row here made classify() answer for it. The narrowing that closed - # it is the commit before this one; the winner pinned for that name - # below is the SPACED rule, which only wins after it. + # it is the 2026-09-05 narrowing of the glued-peel `name_regex` + # (both ledgers); the winner pinned for that name below is the + # SPACED rule, which only wins after it. # # So the criterion is ARGUED SOMEWHERE. A boundary argued in a # ledger comment earns a row here exactly as one argued in this @@ -2773,21 +2782,36 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: # Both names move {family, given, suffix} through TWO # mechanisms at once: the glued さん peels off 田中さん (#308, # and #320 is what stops the trailing ASCII period vetoing it) - # while the spaced 様 routes to `suffix`. One rule per half, and - # the two sit in the same name_regex tier with fields that do - # not nest -- {given, family, suffix} against {given, middle, - # family, suffix} -- so neither is the narrower and no - # `precedes_narrower` block can name the pair. + # while the spaced 様 routes to `suffix`. One rule per half, + # and the two sit in the same name_regex tier with fields that + # DO nest: the glued rule's {given, family, suffix} is a + # STRICT SUBSET of the spaced rule's {given, middle, family, + # suffix}, so the glued rule is the narrower of the two -- and + # it is the one written first. The pair is therefore nested + # NARROW-FIRST, #382's declaration-free default, and no + # `precedes_narrower` block is owed for the plain reason that + # the narrower rule already wins: that block sits on the + # earlier rule only where the earlier rule is the wider one. + # Measured 2026-09-05 over the corpus*.jsonl union, + # order_contests reports nothing for this ledger as it stands + # and reports this pair -- both names -- only with the two + # rules swapped, where undeclared_contests refuses the ledger + # too. The check can see the arrangement that would be wrong + # and not the one shipped. # # The prose test lands on the glued rule and the file order # already does: #312/#319/#320 are the glued rule's clauses, # they name both strings outright, and no clause of the spaced # rule mentions either. It is a PARTIAL label in #382's # accepted sense -- the glued rule describes the peel and not - # the routing -- and the narrow-first justification that - # usually settles such a pair, that the narrower rule describes - # the whole diff, does not apply: neither rule describes both - # halves. Narrow-first picks, and this row is where that shows. + # the routing -- and what makes the pair unusual is that + # narrow-first's JUSTIFICATION does not apply to it. That + # justification is that the narrower rule describes the name + # more precisely; here both mechanisms fire at once and each + # rule describes one half, so neither describes the whole and + # the label is partial whichever rule wins. The default picks + # the right rule for a reason that is not available, and this + # row is where an unjustified-but-correct default is pinned. # # What the pin buys, measured 2026-09-05: swapping the two # rules in memory hands BOTH names to the spaced rule and diff --git a/tools/differential/README.md b/tools/differential/README.md index 770fe51c..e2ede0fb 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -252,7 +252,11 @@ 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 +-- 21 of 31, and 22 of the same 31 today, the 2026-09-05 period-class +demotion having moved `'김민준 씨.'` from the contract CJK corpus to the +tolerated one (measured 2026-09-05; decisions.md's #501 adjudication +carries the reconciliation, and the recipe below is unchanged) -- so +radar PARSER DRIFT could already fail the run wherever someone had pinned a winner (RECOMPUTE: for each row, take the tier of the first corpus file holding the name with contract files sorted first, as `main()` loads them -- a NAME's tier, which is the diff --git a/tools/differential/compare.py b/tools/differential/compare.py index e14b7c45..5d99afd9 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -141,8 +141,11 @@ def _sorted_rules(rules: list[dict[str, object]]) -> list[dict[str, object]]: stable. That is not a footnote: every rule in expected_since_2.0.0.toml carries a name_regex, so they all sit in one tier and the order they are written in settles every tie among - them -- three names match both honorific rules and are labelled by - whichever comes first. + them -- two names match both honorific rules and are labelled by + whichever comes first (three until the 2026-09-05 narrowing of the + glued-peel name_regex took '김민준 박사님' off the glued rule; + measured 2026-09-05 over the corpus*.jsonl union, and the twin + sentence over that rule in the ledger itself says the same). SINCE #451 THAT IS EVERY LEDGER, and this function is the identity on all of them. validate_rules now rejects a rule carrying `fields` @@ -1790,10 +1793,13 @@ class _ShapeMismatch(NamedTuple): # names diff at 1.4.0 and 2.0.0 and not against the 2.1.0 wheel at # all. 'MD, PHD' diffs at all three and now carries a row at each. # - # THREE of the six are RADAR-tier names -- '田中さん 様.' and - # '田中さん, 様.' in corpus_cjk_tolerated.jsonl, 'MD, PHD' in - # corpus_issues.jsonl -- so a moved shape on them is fatal where a - # watched row would only have printed. That is the SEVERITY rule + # FOUR of the six ROWS sit on RADAR-tier names, and they are THREE + # distinct names -- '田中さん 様.' and '田中さん, 様.' in + # corpus_cjk_tolerated.jsonl and 'MD, PHD' in corpus_issues.jsonl, + # the last carrying a row at each 2.x baseline and so counting + # twice among the rows and once among the names (measured + # 2026-09-05) -- so a moved shape on them is fatal where a watched + # row would only have printed. That is the SEVERITY rule # below applied as written and accepted deliberately (#501): a # contest row is fatal on either tier because it carries an # argument, and a moved shape has made that argument's premise From f776a48a7b7f224c68d7088496c1329b4a7f34ed Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 5 Sep 2026 16:10:53 -0700 Subject: [PATCH 5/5] docs(design): five sentences the #501 branch made stale or wrote wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five sit in docs/design/decisions.md and were made false, or written wrong, by this branch's own commits. Every figure below measured 2026-09-05 on this tree. (1) The #501 entry's lead counted NAMES where the roster counts ROWS. _RECORDED_DIFFS' 2.x sections hold SIX rows over FIVE distinct names: radar is 4 rows / 3 names ('田中さん 様.' and '田中さん, 様.' in corpus_cjk_tolerated.jsonl, 'MD, PHD' in corpus_issues.jsonl with a row at each 2.x baseline), contract 2 rows / 2 names ('김민준 박사님', '선생님', corpus_cjk.jsonl). "THREE of the six pinned names are radar tier" was neither reading. Now says both, as compare.py's own 2.x comment does. (2) The recorded-shapes arc (2026-09-03, #497) still described the two 2.x sections as empty and #501 as open. _CROSS_RULE_WINNERS reads 31 / 5 / 1 / 0 on this tree and #501 closes with this PR. A dated superseding parenthetical goes in beside the original sentences, in the style that arc already uses one bullet earlier, and the header's pointer loses "empty". The criterion those sentences state is what FILLED the sections and is not superseded: a row is owed when someone argues a boundary, and three of the six were found already argued, in the 2.0.0 ledger's own comment. (3) The watched-shapes arc said three of the four #501 rows sit in contract corpora. TWO do: '김민준 박사님' and '선생님' are in corpus_cjk.jsonl and corpus_rules.jsonl, the two '様.' strings in corpus_cjk_tolerated.jsonl only -- the same four files at HEAD and at master (e004ccf), so this is not a move the branch made. The same sentence pointed at a line in _WATCHED_DIFFS' header ("a row consults the case row before this roster") that this branch DELETED, e004ccf's compare.py:1805 having no counterpart here; past-tensed, with why the header no longer needs it. (4) Two relative pointers replaced by what they point at: "the narrowing in the commit before it" -> the 2026-09-05 narrowing of the glued-peel name_regex in both ledgers (a6a82d6); "each of the branch's two commits" -> every commit of the branch, the branch having four, with the review round's own two-baseline record named rather than implied. (5) The declined fatal-on-radar bullet's 51 now carries its tree, as its siblings in the same arc already do: the 51 as measured before the #501 adjudication, 50 after. The arc's three counts (50 / 51 / 52) each say which tree they are on. MEASURED with the scratch scripts, against compare.py and test_ledger_guards.py loaded from this worktree: row and name counts per ledger section, the tier of each pinned name by the first corpus file holding it with contract files sorted first, and the corpus membership of the four #501 names on both trees by git show. VERIFIED: pytest tests/v2/ -k "doc or rules or decisions or mechanisms or guard or citation" 704 passed 1 skipped; ruff clean. Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index e635fa35..98d26c9d 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -803,14 +803,14 @@ Declined: ### differential-ledger, the recorded-shapes arc (2026-09-03, #497) -The seventh ledger arc, and the one that makes a recorded INPUT measurable. The arc above closed on the finding that `test_the_recorded_rule_still_wins_each_contested_name` feeds `classify()` a diff shape nothing ever checks, so a shape that is wrong but still routes to the same rule passes forever; this is what was done about it, and about the second thing that search turned up — that the cost the same arc declined a check on had never been measured at all. Mechanics — where the shapes live, which name list each half of the check reads, what a `MOVED SHAPE` block may and may not claim — are owned by `tools/differential/compare.py` (`_RECORDED_DIFFS`' provenance note, `recorded_diff_mismatches`' docstring, and the two placement comments in `main()`), by `tests/v2/test_ledger_guards.py` (`_CROSS_RULE_WINNERS`' header and the note over its two empty 2.x sections) and by tools/differential/README.md; these are the decisions. Every figure below was measured or recomputed 2026-09-03 on this branch; each one that can drift carries its recipe, and the two that cannot be re-derived from the tree at all — the sweep scan's own hit counts — say so where they appear. +The seventh ledger arc, and the one that makes a recorded INPUT measurable. The arc above closed on the finding that `test_the_recorded_rule_still_wins_each_contested_name` feeds `classify()` a diff shape nothing ever checks, so a shape that is wrong but still routes to the same rule passes forever; this is what was done about it, and about the second thing that search turned up — that the cost the same arc declined a check on had never been measured at all. Mechanics — where the shapes live, which name list each half of the check reads, what a `MOVED SHAPE` block may and may not claim — are owned by `tools/differential/compare.py` (`_RECORDED_DIFFS`' provenance note, `recorded_diff_mismatches`' docstring, and the two placement comments in `main()`), by `tests/v2/test_ledger_guards.py` (`_CROSS_RULE_WINNERS`' header and the note over its 2.x sections) and by tools/differential/README.md; these are the decisions. Every figure below was measured or recomputed 2026-09-03 on this branch; each one that can drift carries its recipe, and the two that cannot be re-derived from the tree at all — the sweep scan's own hit counts — say so where they appear. Decisions that landed: - 2026-09-03 [#497](https://github.com/derek73/python-nameparser/issues/497) — the recorded shapes live in the TOOL, as `compare._RECORDED_DIFFS`, and the RUN verifies them; `_CROSS_RULE_WINNERS` keeps only which rule wins a contested name and reads the shape from there. There because it is the one place both halves exist: `main()` has measured every name's real diff by the time it asks, where `validate_rules` runs before any corpus is read and the unit suite installs no wheel at all — `tests/v2/test_differential.py`'s own header says "nothing here spawns `uv` or the network", and the tests that need a baseline fake `_run_worker` or fake `Popen` under it. State it that way and not as "every test monkeypatches `_run_worker`", which is false: measured 2026-09-03 on this branch, of 171 `def test_*` functions in that module 20 mention `_run_worker` at all and six reach the real one with `Popen` faked beneath them by `_fake_popen`. RECOMPUTE by walking the module's `def test_*` bodies for the name. That short form is what compare.py's `_RECORDED_DIFFS` provenance note SAID when this entry was written; `f70a0ad`, the very next commit, withdrew it there, and the note now rejects the paraphrase rather than carrying it — so follow that pointer to the history and not to the text. The denominator has moved once already inside this arc: `f70a0ad`'s own message says 168, which is why the argument here is "most never mention it" and the digits carry a date. The hermeticity claim is unharmed and the loose paraphrase of it is what fails, which is this arc's subject arriving inside its own evidence. Read HERMETICITY as the reason and not speed. The rival placement is the unit suite, and the reason it is refused is that a check there would have to install a wheel — not that installing one is slow. The timing finding below retires every cost argument in this file and moves this decision not at all, which is the test such a placement argument has to pass and the reason to say out loud which ground carries it. `_CORPUS_FLOORS` is the precedent — tool-owned, corpus-shaped data that the guards read and monkeypatch — and this is the same direction. - 2026-09-03 #497 — ONE roster, TWO checks, TWO PLACEMENTS — and, since `9360919`, a NOTE over the gap between them, recorded at the end of this entry. (Superseded in its first word 2026-09-05: TWO rosters since the watched-shapes arc below, `_WATCHED_DIFFS` beside `_RECORDED_DIFFS`, both placements unchanged; the departed-name refusal and the `NOT CHECKED` note read the union of the two, while the shape comparison runs once per roster, because the two halves carry different severities — a contest row's mismatch feeds the exit code on either tier, a watched row's only where its default-order entry is contract tier — and one call could not tell them apart.) The asymmetry of the two checks is the decision rather than where the code happened to fit. The ABSENT-NAME half — a recorded row naming a string no corpus holds any more, which nothing else would ever report — refuses PRE-worker, beside `vacant`, because it reads the ledger and the loaded names and nothing the worker produces. It takes the PLACEMENT #382 chose for its own pre-worker refusal and NOT #382's reason for it, which was the cost claim below — `compare.py`'s comment at that placement is one of the sites the timing finding retires. The ground it stands on was rewritten on this branch: a refusal raised after the wheel is installed and the corpus compared prints below the run's own published `baseline:` header, for a comparison it will never report. Not "disowning a comparison it has just published" — measured print order is `baseline:` at the tell, then the comparison loop, then `corpus: … intentional diffs:`, so at that position the header has printed and no line of the comparison has. The ORDER is the argument, and that corrected wording is what `compare.py` now carries at all three of its placement comments. Recorded because it is the second decision the false number was under, and the two came apart differently — the declined check below kept its conclusion on a new argument, and here the ORDER was right all along while the reason for it was not. Only re-deriving a claim says which of its supports was doing the work; a placement that reads unchanged is not thereby unaffected. The MOVED-SHAPE half is the opposite case — it needs the diffs, so it stays after the comparison — and there it may not RAISE. It PRINTS and feeds the exit code, like `over_declared_rules`, its structural sibling and the other post-worker check on recorded data: a raise at that point lands MID-report and takes dormancy, OVER-DECLARED, UNEXPLAINED and the radar block with it, which compare.py measures on a narrowed 1.4.0 run beside the check. A stale roster row must never hide an unexplained diff, that being the gate's primary output. The two pre-worker refusals may raise because nothing has printed yet; this one cannot, and the difference is PRINT ORDER rather than severity. The two halves also read opposite name lists, which is `recorded_diff_mismatches`' docstring's to state and is not restated here. What IS decided here is the THIRD thing this roster produces, added by `9360919` once the gap between those two lists was measured and found not empty: an order-bearing entry the baseline cannot honor sits in a corpus file and outside the compared set at once, so a row on such a name is checked by neither half and reported by neither. `main()` prints a `NOT CHECKED` note over it. A NOTE and not a refusal — the docstring's argument against refusing off the post-skip list stands, and it is an argument against REFUSING and not against SAYING, which are two decisions of which only the first was ever made. Informational, outside the exit code, and gated on neither `full_corpus` nor `args.corpus`, the intersection narrowing itself. Measured 2026-09-03 at 1.4.0 — both the only baseline where the skip fires and the only ledger with rows — the window is three names, none of which carries a roster row today, so the note prints on no run yet. The measurement that motivated it put a deliberately wrong shape on `'de la Cruz née Vega'` over the full corpus at 1.4.0: before `9360919` that exited 0 in 375 stdout lines naming the name in none of them, and it now exits 0 in 378 with the `NOT CHECKED` note naming it — re-measured 2026-09-03 by corrupting the row in memory around `main()`, which leaves the worktree alone. - 2026-09-03 #497 — a SHAPE may be pinned where a COUNT may not, and that line is the whole of what keeps this roster affordable. A diff shape is one name under two parser versions, so adding a name to a corpus cannot move it; an explained-name count moves on every corpus edit. Shapes drift only when the PARSER changes those names — so the drift and the reason to re-read the roster are the same event, which is exactly what a recorded number usually fails to be. That is why #452's churn objection to `explains = N` (the fields-only arc above, 2026-08-28) does not reach this roster, and why pinning shapes here does not weaken it: the four ledgers carry 201 rules today, one `_CORPUS_CLAIMS` entry each, every one liable to move whenever a corpus does, against 31 recorded shapes that move only with the parser. Read the two CAUSES and not the two totals — the argument is that one set is coupled to corpus edits and the other is not, and it survives either number moving. RECOMPUTE: `sum(len(v) for v in _CORPUS_CLAIMS.values())` in tests/v2/test_ledger_guards.py, and `sum(len(v) for v in _RECORDED_DIFFS.values())` in compare.py. -- 2026-09-03 #497 — four of the 35 rows the roster carried were DELETED rather than corrected. Two names across the two 2.x ledgers, so two rows each, and one of the two NAMES had a correctable shape — count rows or names deliberately, since the provenance note that holds the measurements speaks of the name. Deleted because neither name pinned a CONTEST, which is what the roster is for: at the shape each really produces exactly one rule admits it, so `classify()` was never adjudicating anything. `'Nguyen, Van'` diffs at no baseline at all, so no run ever asks `classify()` about it; `'Jane née and Jones Smith'` is a malformed harvest from a radar corpus whose right parse nobody can state, so a pin on it defends no boundary anyone would argue for — which is why the correctable one went too. compare.py's provenance note holds the per-name, per-baseline measurements behind both. The deletion emptied both 2.x sections, which stay present as explicit empty mappings — the completeness guards require it, and it is what makes emptiness sayable as a POSITION: a row is owed when someone argues a boundary, not before. What the emptiness is NOT — that these ledgers hold no contested diff — is measured and stated beside those sections, and whether the position should change now that those contests are measured rather than merely unexamined is [#501](https://github.com/derek73/python-nameparser/issues/501), open. 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 — four of the 35 rows the roster carried were DELETED rather than corrected. Two names across the two 2.x ledgers, so two rows each, and one of the two NAMES had a correctable shape — count rows or names deliberately, since the provenance note that holds the measurements speaks of the name. Deleted because neither name pinned a CONTEST, which is what the roster is for: at the shape each really produces exactly one rule admits it, so `classify()` was never adjudicating anything. `'Nguyen, Van'` diffs at no baseline at all, so no run ever asks `classify()` about it; `'Jane née and Jones Smith'` is a malformed harvest from a radar corpus whose right parse nobody can state, so a pin on it defends no boundary anyone would argue for — which is why the correctable one went too. compare.py's provenance note holds the per-name, per-baseline measurements behind both. The deletion emptied both 2.x sections, which stay present as explicit empty mappings — the completeness guards require it, and it is what makes emptiness sayable as a POSITION: a row is owed when someone argues a boundary, not before. What the emptiness is NOT — that these ledgers hold no contested diff — is measured and stated beside those sections, and whether the position should change now that those contests are measured rather than merely unexamined is [#501](https://github.com/derek73/python-nameparser/issues/501), open. (Superseded in its emptiness 2026-09-05: the two 2.x sections are no longer empty, the #501 adjudication below having pinned FIVE rows at 2.0.0 and one at 2.1.0, so `_CROSS_RULE_WINNERS` reads 31 / 5 / 1 / 0 — measured 2026-09-05 — and #501 is closed. The criterion is not superseded and is what filled them: a row is owed when someone argues a boundary, and three of the six were found already argued, in the 2.0.0 ledger's own comment. Read the sentences above as the position emptiness stood on for as long as it stood.) Recorded with the deletion because it is the half a later sweep will get wrong: "only one rule admits it" is NOT on its own grounds to delete a row. A substantial minority of the surviving 1.4.0 rows are in that position and stay — the count and its recompute are beside them in test_ledger_guards.py, where six of the thirteen say so in their own comments and the other seven do not, which is why the count is recomputed rather than read off the roster — because the shapes they pin are shapes runs actually make, so a widened `fields` or a moved rule hands the name over and the test says so. Counting admitters is not the test at all: 11 of those 13 route to a DIFFERENT rule under some other shape, and mechanisms.md#RECORDED-ROSTERS now carries that measurement in place of the criterion it stated first. The four deleted rows could not do that work at any edit, which is the difference. - 2026-09-03 #497 — the prose sweep is sorted by this file's own criterion (#451, "past-tense counts stay in a ledger comment; live ones do not") into three treatments, and the treatment follows from what the count is DOING rather than from how wrong it is. A count that IS the finding and reads past tense stays. A count standing beside the enumeration it counts loses the digit — the list is self-checking and the digit is the only part that can rot. A live exhaustiveness claim with nothing recomputing it is REGROUNDED on the property it was reaching for, so that the sentence stops depending on a number — saying a widening claims all but a handful of the whole population the assertion is over says what a fraction said and cannot go stale. One judgement goes with it and is the arc's own: a figure that could NOT be reproduced was not replaced with a fresh one. `_reaches_non_vocabulary`'s docstring now carries the shape of its finding, the instruction for measuring it, and no LIVE figure for the magnitude the old figures asserted — a replacement digit nobody can re-derive is the defect rather than the repair, and this file has already been burned by that shape (the #414 roster comment under the fields-only arc's `explains = N` bullet). The past-tense digits explaining why the live ones are gone stay, which is the first treatment applied inside the third, and a later sweep should not read them as survivors of the pass. Found rather than decided, and worth as much: @@ -836,7 +836,7 @@ 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; 48 of the 50 since the #501 adjudication below, and the burden argument is unmoved by either count). 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. (The four moved to `_RECORDED_DIFFS` with the #501 adjudication below, 2026-09-05, so every row in `_WATCHED_DIFFS` is sole-watched today; the decision is about the roster's DEFINING property, which is unchanged — no row there pins a winner.) +- 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 two sit in contract corpora (`'김민준 박사님'` and `'선생님'`, in `corpus_cjk.jsonl` and `corpus_rules.jsonl` alike; the two `様.` strings are in `corpus_cjk_tolerated.jsonl` only — measured 2026-09-05 on this tree and at `e004ccf`, where the four sit in exactly the same files), 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 said until this branch that a contested row's reader consults the case row before the roster — a line the header no longer needs and no longer carries, the four rows having moved to `_RECORDED_DIFFS` with winners pinned beside them. (The four moved to `_RECORDED_DIFFS` with the #501 adjudication below, 2026-09-05, so every row in `_WATCHED_DIFFS` is sole-watched today; the decision is about the roster's DEFINING property, which is unchanged — no row there pins a winner.) - 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. (AMENDED 2026-09-05, later the same day: the #501 adjudication below took six rows to `_RECORDED_DIFFS` — the four contests and `'MD, PHD'`'s two, which this count read as sole-watched and which were not — so the roster is 107 rows over 50 names and is now exactly the sole-watched population. The decision is about WHICH diffs get a row and survives the totals moving.) 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: @@ -851,7 +851,7 @@ Found rather than decided, and worth as much: 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. +- 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 — as measured 2026-09-05 before the #501 adjudication below, 50 after — 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. (REVERSED the same day, 2026-09-05, by the #501 adjudication below, which argued all six and moved the rows — read this bullet as what THIS ARC declined to do in its own commits and not as a standing position. What it got right is kept there: the rows recorded shapes and not winners for as long as nobody had argued the boundaries. What it got wrong is that three of the six had already been argued, in the 2.0.0 ledger's own comment, so the objection was about where the argument was written rather than about whether one existed.) - A checked-in generator for the rows (2026-09-05) — the rows are a snapshot, and a generator that re-derives them on demand is the re-derivation hazard with a command line (mechanisms.md#RECORDED-ROSTERS). A scratch script and a recipe in the roster comment, as `_CORPUS_FLOORS` and `_CORPUS_CLAIMS` do. @@ -865,11 +865,11 @@ Decisions that landed: - 2026-09-05 #501 — a row is owed where the boundary is ARGUED ANYWHERE, not where it is argued in the guard module. The position the arc above recorded — "a row is owed when somebody ARGUES a boundary, not when one is merely measured" — is kept whole; what moves is WHERE an argument counts. Three of the six boundaries were already argued in the 2.0.0 ledger's own comment on `fix(#308/#312/#319/#320)`, which named `'田中さん 様.'`, `'田中さん, 様.'` and `'김민준 박사님'` one by one and said for each why the glued rule should win. That is a claim about which rule wins a name, and it is checked by NOTHING: `_CORPUS_CLAIMS` measures a rule's regex reach alone and cannot see a name changing hands inside it, the gate's totals are per-corpus, `order_contests` reports only wide-first nested pairs, and `classify()` is never asked the question unless a roster row asks it. `_CROSS_RULE_WINNERS` is the only thing in the tree that can check a ledger comment's adjudication, so scoping its entry criterion to arguments made in its own file left exactly the arguments it exists to check outside it. The proof is the third of the three, below. - 2026-09-05 #501 — `'田中さん 様.'` and `'田中さん, 様.'` → `fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix`, at 2.0.0. The only one of the six that is a live two-runner contest after the narrowing: both names are admitted by the glued rule and by `fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix`, and the glued rule is written first (sorted indices 1 and 2; lines 71 and 149 of the ledger on this branch). Measured against the 2.0.0 wheel, both diff `{family, given, suffix}` — `'田中さん 様.'` reads first `'田中さん'`, last `'様.'` on the wheel and family `'田中'`, suffix `'さん, 様.'` on the tree; `'田中さん, 様.'` reads first `'様.'`, last `'田中さん'` on the wheel and the same family `'田中'`, suffix `'さん, 様.'` on the tree. Both names move through TWO mechanisms at once — the glued さん peels off 田中さん while the spaced 様 routes to `suffix` — with one rule per half, so neither rule describes the whole diff and the label is PARTIAL in #382's accepted sense. The prose test lands where the file order already does: the glued rule's own #320 clause names both strings outright — "#320 stops an ASCII period vetoing the honorific it finds there ('田中さん 様.', '田中さん, 様.')" — and no clause of the spaced rule mentions either, its examples being `'王小明 先生'`, `'김민준 씨'`, `'田中 太郎 様'`, `'田中 殿'`, `'김민준 씨.'` and `'김민준 양.'`. What the pin buys, measured: swapping the two rules in memory hands BOTH names to the spaced rule and touches nothing else — 2 of the 247 classifications move, `unexplained` stays 0 so the gate's summary line does not move, and neither rule's `_CORPUS_CLAIMS` reach can see a reorder at all. The guard fails on both names, first line "expected_since_2.0.0.toml: '田中さん 様.' diffing ['family', 'given', 'suffix'] is now explained by 'fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix', not 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix'." -- 2026-09-05 #501 — `'김민준 박사님'` → `fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix`, at 2.0.0, and the pin holds only because of the narrowing in the commit before it. Before the narrowing the glued rule reached the name on the 님 interior to 박사님 and, sitting first, took it. Measured against the 2.0.0 wheel the diff is `{family, given, suffix}`: the wheel reads first `'김민준'`, last `'박사님'`, and the tree reads given `'민준'`, family `'김'`, suffix `'박사님'` — the honorific WHOLE and standing as its own word, with nothing peeled off the name. The prose test is rules.md#W2's second sentence, "A part that is not name text — a post-nominal word standing on its own — is never the name's end: the split-off steps past it to the name word behind, and never dissects it", and that rule's witness line for this very string, `"김민준 박사님" → suffix="박사님"`. So the glued rule's title was FALSE of the name, on a contract-tier string (`corpus_cjk.jsonl`), held green by every guard in the suite from the date the comment's own parenthetical carries (2026-08-05) until this arc. What the pin buys, measured: restoring the pre-narrowing regex ALONE — one edit, no reorder, the glued rule still sitting ahead of the spaced one — hands the name straight back, 1 of the 247 classifications moving with `unexplained` still 0, and this row is the only thing that says so. The guard's first line is "expected_since_2.0.0.toml: '김민준 박사님' diffing ['family', 'given', 'suffix'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix'." +- 2026-09-05 #501 — `'김민준 박사님'` → `fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix`, at 2.0.0, and the pin holds only because of the 2026-09-05 narrowing of the glued-peel `name_regex` in both ledgers (`a6a82d6`). Before the narrowing the glued rule reached the name on the 님 interior to 박사님 and, sitting first, took it. Measured against the 2.0.0 wheel the diff is `{family, given, suffix}`: the wheel reads first `'김민준'`, last `'박사님'`, and the tree reads given `'민준'`, family `'김'`, suffix `'박사님'` — the honorific WHOLE and standing as its own word, with nothing peeled off the name. The prose test is rules.md#W2's second sentence, "A part that is not name text — a post-nominal word standing on its own — is never the name's end: the split-off steps past it to the name word behind, and never dissects it", and that rule's witness line for this very string, `"김민준 박사님" → suffix="박사님"`. So the glued rule's title was FALSE of the name, on a contract-tier string (`corpus_cjk.jsonl`), held green by every guard in the suite from the date the comment's own parenthetical carries (2026-08-05) until this arc. What the pin buys, measured: restoring the pre-narrowing regex ALONE — one edit, no reorder, the glued rule still sitting ahead of the spaced one — hands the name straight back, 1 of the 247 classifications moving with `unexplained` still 0, and this row is the only thing that says so. The guard's first line is "expected_since_2.0.0.toml: '김민준 박사님' diffing ['family', 'given', 'suffix'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix'." - 2026-09-05 #501 — `'선생님'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, at 2.0.0. Measured against the wheel the whole diff is `{family, given}` — the wheel reads first `'선생님'` and the tree reads family `'선생님'`, the 2.1 order flip on a hangul string and nothing else — which is exactly the shape that rule's comment claims for itself, "these are one diff shape -- pieces moving between given/middle/family on a native-script CJK name", and which rules.md#W2's own boundary line `"선생님" → family="선생님"` says is name text rather than a peel. The glued rule's claim on it was the same regex accident, the interior 님, and worse than a partial label: it declares `suffix` and NO `suffix` moves here at all. It never won, the order rule being written first (sorted index 0 against the glued rule's 1), and the narrowing has since taken its admission away. Recorded because the pair is the #498 class and nothing else can see it: the two rules' `fields` INTERSECT on `{family, given}` — `{family, given, suffix}` against `{_ambiguities, family, given, middle}` — with neither nested, so `undeclared_contests` looks past them in either arrangement and no `precedes_narrower` block is available to declare them. What the pin buys, measured: a reorder ALONE now moves nothing here (0 of the 247), so the row's whole work is against a RE-WIDENING — restore the wide regex and swap the two rules and 2 classifications move, `unexplained` staying 0, with the guard's first line "expected_since_2.0.0.toml: '선생님' diffing ['family', 'given'] is now explained by 'fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix', not 'fix(#271/#272/#298) native-script CJK: …'". - 2026-09-05 #501 — `'MD, PHD'` → `fix(#296) a credential-only comma string reads a name and its postnominal`, at 2.0.0 AND at 2.1.0, as a row apiece rather than one shared row. Both #296 rules reach the string and declare EQUAL `fields` at each 2.x baseline, `["title", "suffix"]`, so `fields` cannot separate them at all and file order is the whole decision (sorted indices 25 and 26 at 2.0.0, 12 and 13 at 2.1.0). Measured against both wheels the diff is `{suffix, title}` — title `'PHD'` on the wheel becomes suffix `'PHD'` on the tree, family `'MD'` on both sides. The distinguishing clause is a property of the STRING and not of the rules' `fields`: the credential-only rule opens "'Jr., PhD' / 'MD, PHD': a string that is nothing BUT credentials", while the lone-post-comma rule is written for "A one-word name, a comma, one short word". `'MD, PHD'` holds no name, so the first rule's prose is true of it and the second's is not. Two rows and not one because the roster is keyed per ledger and a shape is baseline-relative — the 1.4.0 row on the same string and the same winner rests on a different measurement, `{family, given, suffix, title}` there, the comma-family move not yet having landed. What the pin buys, measured: swapping the two rules moves 1 of the 247 at 2.0.0 and 1 of the 155 at 2.1.0, `unexplained` staying 0 at both, and the guard fails at BOTH baselines, first line "expected_since_2.0.0.toml: 'MD, PHD' diffing ['suffix', 'title'] is now explained by 'fix(#296) a lone post-comma credential is a suffix', not 'fix(#296) a credential-only comma string reads a name and its postnominal'." and the same sentence under `expected_since_2.1.0.toml`. - 2026-09-05 #501 — the glued-peel `name_regex` is NARROWED so it stops matching a listed honorific INTERIOR to a longer listed one, in BOTH ledgers that ship it (`expected_since_1.4.0.toml` and `expected_since_2.0.0.toml` carry it byte-identically). `(?