diff --git a/docs/design/decisions.md b/docs/design/decisions.md index a6b8851e..c1ef4384 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -921,6 +921,25 @@ Declined: The measurement, and how to redo it. `_RECORDED_DIFFS` reads 45 / 5 / 1 / 0 = 51 rows at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0 and `_WATCHED_DIFFS` 36 / 31 / 30 / 5 = 102 over the same 50 distinct names — five names moved rosters at 1.4.0 and stayed watched at both 2.x baselines, where each has one admitter and no contest to own, so only the 1.4.0 row count fell and the population did not move. 32 of the 45 rows at 1.4.0 sit on radar-tier names and 13 have a single admitter at their recorded shape; none of the fourteen joins those 13, every one having two or more admitters at its recorded shape by the definition of the sweep that found it. THE POPULATION FIGURES, recomputed: drive `main()` at each baseline with `classify()` wrapped, keep the calls whose issue is not None, and for each list the rules satisfying `compare._entry_matches` at that call's measured shape and order — fewer than two admitters is no contest; for each admitter after the first, the winner's `fields` a strict subset of the loser's is narrow-first and justified, the loser's a strict subset of the winner's WITH the loser's issue in `compare._declared_over(winner)` is declared and justified, and anything else is owed a pin unless the name is a key of `_RECORDED_DIFFS` for that ledger. That gives 47 / 3 / 1 / 0 contested, 18 / 3 / 1 / 0 pinned and 14 / 0 / 0 / 0 owed on the tree this arc opened on, and 0 owed at every baseline since. Do NOT replay the corpus load by hand: the `(name, order)` dedup, the baseline-minimum skip and the tier stamp all happen inside `main()`, so a row's tier is read as `main()` reads it — the default-order entry's where there is one, the first-loaded entry's otherwise — by wrapping `_run_worker` for the entries it was handed. THE VERDICTS' values come off that same run — the old side from the worker's rows for the entries `main()` was handed, the new side from `HumanName` and `_tree_parse` as `main()` computes them — and only the facade is compared at a pre-2.0 baseline, `_surfaces_for` returning `{"facade"}` below 2.0.0, so every old and new value in the bullets above is `HumanName`'s. THE "WHAT THE PIN BUYS" FIGURES are measured the same way and their recipe sits beside the rows in tests/v2/test_ledger_guards.py: move the loser above the winner in a COPY of the run's own sorted rule list and re-classify the run's own 352 classified calls, counting the labels that change; the `fields` readings are that recipe with one role dropped from the winner instead of a rule moved, and nothing on disk moves for either. MUTATION CONTROL, in memory around `main()` at 1.4.0 with the worktree untouched: `'Smith, Ph. D. Jr.'` deleted from `_RECORDED_DIFFS['expected_since_1.4.0.toml']` — the contract-tier row of the six-name equal-`fields` pair — makes the run exit 1 and print `UNPINNED CONTEST expected_since_1.4.0.toml: 1 contested pair(s), one per name and losing rule, that no rule outranks by nesting or declaration and no row pins`, naming the name under its winner as `'Smith, Ph. D. Jr.' [contract] ['suffix', 'title']` with the reason "equal `fields`, file order the whole decision". Unmutated, no `UNPINNED CONTEST` block prints at any of the four baselines; a corruption that changed nothing would have meant an inert check, and this one did not. The arc moved no classification: 352 / 247 / 155 / 14 intentional diffs, 0 unexplained and 0 radar unclassified, exit 0, summary lines byte-identical at every commit of the branch — a pin moves no classification, and neither does a check that fires on nothing. RECOMPUTE the row counts with `{k: len(v) for k, v in compare._RECORDED_DIFFS.items()}` and the same over `_WATCHED_DIFFS`; the radar and one-runner splits by taking each row's tier as above and classifying it at its recorded shape against `_sorted_rules`. +### differential-ledger, a pin names one rule (2026-09-05, #508) + +The eleventh ledger arc, and a small one: it changes how a `_CROSS_RULE_WINNERS` pin is read, not what any pin says. The winner guard compared `classify()`'s answer to the pin with `startswith`, chosen without a recorded reason, so a pin was satisfied by any rule whose issue extended it. The #498 completeness check, earlier the same day, had borrowed that guard's meaning outright — a `_RECORDED_DIFFS` key counts as "a winner is pinned" because the two rosters' keys are equal — and its review found the case where the borrowed meaning was hollow. Mechanics are owned by `_pinned_rule`'s docstring in tests/v2/test_ledger_guards.py; these are the decisions. Every figure was measured 2026-09-05 on master at `449a7bc`. + +Decisions that landed: + +- 2026-09-05 #508 — **a pin names exactly one rule.** The guard resolves each pin to one rule's issue — exact where a rule's issue equals the pin, else the single rule whose issue starts with it — and refuses a pin that resolves to none or to several; the winner comparison is then EQUALITY against the resolved issue. Prefix pins stay: a family prefix that reaches one rule names it as well as the full string does, and 17 of the 51 rows are written that way. Measured over the 51 rows before the change: 31 full issue strings reaching only themselves; 17 short prefixes reaching one rule; 2 full strings that are also a strict prefix of a sibling's — `'Bob Jones, author'` and `'Bob Jones, compositeur'`, pinned to the all-titles rule whose issue `... keeps the given/family split, the C1 example` extends; 1 short prefix reaching three rules — `'Andrews, M.D.'` on the bare `fix(comma-family)`. All three ambiguous rows are in `expected_since_1.4.0.toml`, the 2.x rosters have none, and each was latent: `classify()` returned the intended rule. `'Andrews, M.D.'` is spelled out to the lone-post-comma rule that wins it; the Bob Jones rows are unchanged, exact comparison being what their comment already rested on. A second guard, `test_every_pinned_winner_names_exactly_one_rule`, refuses an ambiguous pin on its own at pytest speed, so the failure names the pin rather than a classification. RECOMPUTE: for each ledger and each pin, count the `[[change]]` issues that `startswith` the pin and whether one equals it (the loop is in the #508 issue body). + +Found rather than decided: + +- **The #498 arc's "a key in `_RECORDED_DIFFS` means a winner is pinned" was true of 48 rows and hollow for 3.** Nothing was wrong today, because `classify()` happened to return the intended rule for all three. What the prefix comparison could not do was FAIL when it stopped: the `'Bob Jones, compositeur'` roster comment rested its pin on the C1 rule's regex being unable to reach the string, an argument no test executed — measured 2026-09-05, forcing `classify()` to answer the C1 string for that name passed the old guard and fails the new one. + +Declined: + +- Exact match everywhere (2026-09-05) — rewriting the 18 short pins to full strings and ending the family-pin idiom. The idiom is harmless where the prefix reaches one rule, which is 17 of the 18, and the resolver makes the eighteenth loud; a reworded rule breaking its pins is the same outcome under either reading. +- Prefix plus an allowlist (2026-09-05, #508's option 2) — a second list to keep honest, for three rows the resolver handles without one. +- A word-boundary prefix (2026-09-05, #508's option 3) — answers the two Bob Jones rows and not `'Andrews, M.D.'`, and adds a tokenising rule to a string comparison. +- Refusing two rules in one ledger from sharing a prefix (2026-09-05) — a property of the ledger, not of a pin; the ledger's rule families are deliberate (`fix(comma-family)`, `fix(#296)`), and the resolver already makes the only case that matters loud. + ### comma-suffix-arc — #291/#296/#316 (2026-07-26 → 2026-08-01) #291 was filed 2026-07-26 out of the 2.0 vocabulary cleanup, with diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index e79cca75..701c0c3a 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -90,7 +90,7 @@ Problem shape. A test's input depends on two config sets intersecting (a word th ## CROSS-RULE-OUTCOME-PINS — pin who wins the contest -Problem shape. Every per-rule roster measures a rule alone and the gate total is per-corpus, but WHICH rule wins a contested name is neither — and it is exactly what a reorder or a narrowing changes. Contract statement. Contested outcomes are pinned as data: a roster records which rule classifies which contested name, so a change in the winner fails the suite even when every total is unchanged. How it works. A pure file reorder in the 1.4 ledger fails _CROSS_RULE_WINNERS and nothing else in the suite — the pin is the only guard at that granularity. A shape recorded ALONE pins no winner and lives in the other roster, `compare._WATCHED_DIFFS`, so a reader looking for who wins a name does not look there (#501). The entry criterion is that the boundary is ARGUED ANYWHERE and not that it is argued in the guard module, because a ledger comment's argument about which rule wins a name is exactly what no guard and no run can see — the #501 adjudication is the instance, three of its six boundaries having been argued in a ledger comment and one of those three wrongly, green everywhere for a month (decisions.md, the #501 adjudication). The roster's COMPLETENESS is checked at run time over the contests a run measures: `compare.unowned_contests` refuses a measured contest the winner takes by neither nesting nor a declaration and that no row pins, so "`_CROSS_RULE_WINNERS` stays the instrument" for non-nested pairs is now enforced rather than stated (#498). Lives in. tests/v2/test_ledger_guards.py (_CROSS_RULE_WINNERS). Reach for it when. Two rules can claim the same name and you are about to change either one, or their order. +Problem shape. Every per-rule roster measures a rule alone and the gate total is per-corpus, but WHICH rule wins a contested name is neither — and it is exactly what a reorder or a narrowing changes. Contract statement. Contested outcomes are pinned as data: a roster records which rule classifies which contested name, so a change in the winner fails the suite even when every total is unchanged. A pin names exactly one rule — the rule whose issue equals it, else the single rule whose issue starts with it — and the guard refuses a pin that names none or several before asking `classify()` anything (#508; a full issue string can be a strict prefix of a sibling's, so the comparison is equality against the resolved rule, not a prefix). How it works. A pure file reorder in the 1.4 ledger fails _CROSS_RULE_WINNERS and nothing else in the suite — the pin is the only guard at that granularity. A shape recorded ALONE pins no winner and lives in the other roster, `compare._WATCHED_DIFFS`, so a reader looking for who wins a name does not look there (#501). The entry criterion is that the boundary is ARGUED ANYWHERE and not that it is argued in the guard module, because a ledger comment's argument about which rule wins a name is exactly what no guard and no run can see — the #501 adjudication is the instance, three of its six boundaries having been argued in a ledger comment and one of those three wrongly, green everywhere for a month (decisions.md, the #501 adjudication). The roster's COMPLETENESS is checked at run time over the contests a run measures: `compare.unowned_contests` refuses a measured contest the winner takes by neither nesting nor a declaration and that no row pins, so "`_CROSS_RULE_WINNERS` stays the instrument" for non-nested pairs is now enforced rather than stated (#498). Lives in. tests/v2/test_ledger_guards.py (_CROSS_RULE_WINNERS). Reach for it when. Two rules can claim the same name and you are about to change either one, or their order. ## VOCABULARY-FEEDS-STRUCTURE — a wordlist edit can move the comma decision diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index 7c57f37c..c6e4cfab 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -2543,7 +2543,16 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: # open cycle: one rule, so nothing for a second one to contest "expected_since_2.2.0.toml": {}, "expected_since_1.4.0.toml": { - "Andrews, M.D.": "fix(comma-family)", + # Spelled out since #508: the bare `fix(comma-family)` this row + # carried is a prefix of THREE rules in this ledger (measured + # 2026-09-05 with tomllib over the [[change]] issues), and the + # guard now refuses a pin that names anything other than + # exactly one. The + # lone-post-comma rule is the one that wins the name and the + # only one that admits its recorded {given, suffix} shape. + "Andrews, M.D.": + "fix(comma-family) lone post-comma piece routes to " + "suffix/title, not first", "田中, 太郎さん": "fix(cjk-comma-honorific-peel)", "김, 민준씨": "fix(cjk-comma-honorific-peel)", "김, 민준씨 (Jimmy)": "fix(cjk-comma-honorific-peel)", @@ -2869,23 +2878,18 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: # the winner on this reasoning, and the winner's regex reaches # 'compositeur' by the same literal. Moving the loser ahead # moves 3 of the 352 -- this name, 'Bob Jones, author' and - # 'John Smith, Mr.'. The pin is spelled with the FULL issue - # string because THREE rules in this ledger begin - # `fix(comma-family)` (measured 2026-09-05 with tomllib over - # the ledger's `[[change]]` issues): under the `startswith` - # the winner guard compares with, a bare `fix(comma-family)` - # would be satisfied by any of the three, the lone-post-comma - # rule that loses PAIRs A and G included. The full string - # still does not separate the winner from the THIRD of them, - # though -- the winner's issue is a strict PREFIX of + # 'John Smith, Mr.'. The pin is the winner's FULL issue string, + # and since #508 that is what the guard compares EXACTLY: three + # rules in this ledger begin `fix(comma-family)` (measured + # 2026-09-05 with tomllib over the ledger's `[[change]]` + # issues), and this winner's issue is itself a strict PREFIX of # `... keeps the given/family split, the C1 example`, so no - # prefix comparison can tell those two apart. What holds the - # row against that rule is exact equality, which is what a run - # supplies: measured 2026-09-05, classify() returns the - # winner's issue EXACTLY, and the C1 rule cannot reach this - # string at all -- its name_regex is - # (?i)^john\s+smith,\s*mr\.?(\s+jr\.?)?$ whole, anchored at - # both ends on a literal this name shares no prefix with. + # prefix comparison could tell those two apart -- _pinned_rule + # resolves a pin that IS an issue string to that rule and to no + # other. The C1 rule cannot reach this string in any case: its + # name_regex is (?i)^john\s+smith,\s*mr\.?(\s+jr\.?)?$ whole, + # anchored at both ends on a literal this name shares no prefix + # with (measured 2026-09-05). "Bob Jones, compositeur": "fix(comma-family) a comma followed only by titles keeps " "the given/family split", @@ -3121,6 +3125,149 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: } +def _pinned_rule(pin: str, issues: list[str], where: str) -> str: + """The one rule a _CROSS_RULE_WINNERS pin names, or a refusal. + + A pin is a rule's issue string, or a prefix of exactly one. Exact + wins where a rule's issue IS the pin, because a full issue string + can be a strict prefix of a sibling's -- measured 2026-09-05 in + expected_since_1.4.0.toml, `fix(comma-family) a comma followed only + by titles keeps the given/family split` is a prefix of + `... split, the C1 example` -- and a prefix comparison alone could + not tell those two apart. The exact branch cannot itself be + ambiguous: a ledger holding two rules with the same `issue` string + is already refused by + test_every_rule_claims_the_recorded_share_of_the_corpus, which is + where that uniqueness is stated. A prefix that reaches no rule, or + more than one, is refused here rather than left for classify() to + answer: a pin that names a FAMILY of rules pins nothing, since the + roster's whole job is to say which rule wins (mechanisms.md + #CROSS-RULE-OUTCOME-PINS), and the #498 completeness check reads a + _RECORDED_DIFFS key as "a winner is pinned" on the strength of it. + + `issues` are the ledger's [[change]] issue strings -- file order + from `_rules`, classify()'s sorted order from the winner guard, + and the answer is the same either way, being exact-or-unique. + """ + # An empty pin is a prefix of every rule, so on a one-rule ledger + # -- expected_since_2.2.0.toml today -- it would resolve uniquely + # and pin nothing. Refused before either branch sees it. + assert pin, f"{where}: an empty pin names every rule" + if pin in issues: + return pin + hits = [issue for issue in issues if issue.startswith(pin)] + assert hits, ( + f"{where}: the pin {pin!r} names 0 rules. It reaches no rule in " + f"this ledger, so the rule it was written for has been reworded " + f"or removed. Re-pin the row to the current issue string of the " + f"rule that wins this name, or drop the row -- together with " + f"its shape in compare._RECORDED_DIFFS -- if the contest is " + f"gone with it.") + assert len(hits) == 1, ( + f"{where}: the pin {pin!r} names {len(hits)} rules: {hits}. A " + f"pin must name exactly one rule -- spell it out to the issue " + f"string of the rule that wins the name. Three rules in " + f"expected_since_1.4.0.toml begin `fix(comma-family)` (measured " + f"2026-09-05), which is the shape this refuses.") + return hits[0] + + +def test_every_pinned_winner_names_exactly_one_rule() -> None: + """A pin resolves to one rule before any shape is asked (#508). + + test_the_recorded_rule_still_wins_each_contested_name resolves the + pin through the same _pinned_rule, before it asks classify() + anything, so it would refuse an ambiguous pin identically -- but + only after loading compare and walking as far as that row. This + guard refuses it from the roster and the ledger's issue strings + alone, with compare never loaded, at pytest speed. + """ + by_name = {led.name: led for led in _LEDGERS} + resolved = 0 + for ledger_name, winners in _CROSS_RULE_WINNERS.items(): + issues = [r["issue"] for r in _rules(by_name[ledger_name])] + for name, pin in winners.items(): + _pinned_rule(pin, issues, f"{ledger_name}: {name!r}") + resolved += 1 + assert resolved, "no pin was resolved, so this guard is vacuous" + + +def test_a_pin_resolves_exactly_or_by_unique_prefix_or_is_refused() -> None: + """The whole of _pinned_rule's table, on hand-built issues. + + Both halves: the two that resolve -- an exact issue string wins + over the sibling it is a prefix of, and a family prefix reaching + exactly one rule resolves to that rule -- and the two that refuse, + a prefix reaching several and a prefix reaching none. Matching is + literal, so case is part of the pin and a miscased pin reaches + nothing rather than the rule it was aimed at. + """ + issues = [ + "fix(x) the first", + "fix(x) the first, extended", + "fix(x) the second", + "fix(y) alone", + ] + # a family prefix reaching one rule resolves to it + assert _pinned_rule("fix(y)", issues, "t") == "fix(y) alone" + # an exact issue that is also a prefix of a sibling resolves exactly + assert _pinned_rule("fix(x) the first", issues, "t") == "fix(x) the first" + # a prefix reaching several is refused, naming them + with pytest.raises(AssertionError) as several: + _pinned_rule("fix(x)", issues, "t") + assert "names 3 rules" in str(several.value) + assert "fix(x) the second" in str(several.value) + # a prefix reaching none is refused + with pytest.raises(AssertionError) as none: + _pinned_rule("fix(z)", issues, "t") + assert "names 0 rules" in str(none.value) + # a pin quotes the issue string literally, so case is not folded + with pytest.raises(AssertionError, match="names 0 rule"): + _pinned_rule("FIX(y)", issues, "t") + + +def test_the_winner_guard_refuses_a_longer_sibling_of_the_pin( + monkeypatch: pytest.MonkeyPatch) -> None: + """The regression #508 was opened for, run rather than described. + + 'Bob Jones, compositeur' is pinned to the all-titles rule, whose + full issue string is a strict PREFIX of the C1 rule's. Under the + `startswith` comparison the winner guard used before #508, a run in + which the C1 rule answered for that name satisfied the pin, and the + handover passed unseen; here classify() is made to answer exactly + that, and the guard must refuse it. Measured 2026-09-05: with `==` + put back to `startswith`, this test does not raise and so fails. + """ + compare = load_tool("compare") + ledger = {led.name: led for led in _LEDGERS}["expected_since_1.4.0.toml"] + sibling = ("fix(comma-family) a comma followed only by titles keeps " + "the given/family split, the C1 example") + assert sibling in [rule["issue"] for rule in _rules(ledger)], ( + f"{sibling!r} is no longer a rule in expected_since_1.4.0.toml, " + f"so this test impersonates an answer no run could produce. " + f"Re-pick a rule whose issue string extends another rule's") + + def _answer_the_longer_sibling( + name: str, diff_fields: set[str], + rules: list[dict[str, object]], + exclusions: list[dict[str, object]] | None = None, + order: str | None = None) -> str | None: + got: str | None = compare_classify( + name, diff_fields, rules, exclusions, order) + if name == "Bob Jones, compositeur" and got is not None: + return got + ", the C1 example" + return got + + compare_classify = compare.classify + monkeypatch.setattr(compare, "classify", _answer_the_longer_sibling) + # load_tool builds a FRESH module every call, so the guard under + # test would otherwise load an unpatched compare; hand it this one. + # It loads no other tool, so answering every stem with it is safe. + monkeypatch.setitem(globals(), "load_tool", lambda stem: compare) + with pytest.raises(AssertionError, match="is now explained by"): + test_the_recorded_rule_still_wins_each_contested_name() + + def test_the_recorded_rule_still_wins_each_contested_name() -> None: """Who explains what, which nothing else here asks. @@ -3138,6 +3285,12 @@ def test_the_recorded_rule_still_wins_each_contested_name() -> None: A failure here is not necessarily a regression: it can equally mean a rule was narrowed correctly and its names found a better home. It means someone has to look, which is the point. + + Since #508 the pin is resolved to one rule and compared exactly, + not by prefix: `startswith` was satisfied by any rule whose issue + extended the pin, and for three rows at 1.4.0 (measured + 2026-09-05) that was more than one rule, so the row pinned a family + where it claimed to pin a winner. """ compare = load_tool("compare") by_name = {led.name: led for led in _LEDGERS} @@ -3145,6 +3298,7 @@ def test_the_recorded_rule_still_wins_each_contested_name() -> None: for ledger_name, winners in _CROSS_RULE_WINNERS.items(): ledger = by_name[ledger_name] rules = compare._sorted_rules(_rules(ledger)) + issues = [r["issue"] for r in rules] never = _exclusions(ledger) shapes = compare._RECORDED_DIFFS[ledger_name] for name, expected in winners.items(): @@ -3160,14 +3314,26 @@ def test_the_recorded_rule_still_wins_each_contested_name() -> None: f"it, or drop the pin -- " f"test_every_pinned_winner_has_a_recorded_shape is the " f"guard that states this as its own subject") + # Resolved before classify() is asked, so a pin naming a + # family of rules fails as a PIN defect with the family + # listed (test_every_pinned_winner_names_exactly_one_rule + # states that as its own subject); and compared EXACTLY, + # because a full issue string can be a strict prefix of a + # sibling's and `startswith` could not tell them apart + # (#508; the PAIR F rows above are the case). + pinned = _pinned_rule(expected, issues, f"{ledger_name}: {name!r}") fields = shapes[name] got = compare.classify(name, set(fields), rules, never) - assert got is not None and got.startswith(expected), ( + assert got == pinned, ( f"{ledger_name}: {name!r} diffing {list(fields)} is now " - f"explained by {got!r}, not {expected!r}. Check the new " - f"rule's prose actually describes this name before " - f"recording it -- a rule claiming a diff it does not " - f"describe is #372, and it stays green everywhere else") + f"explained by {got!r}, not {pinned!r}" + # a prefix pin: the roster holds `expected`, not the + # resolved issue string, so name the text to edit. + + (f" (pinned as {expected!r})" if pinned != expected else "") + + ". Check the new " + "rule's prose actually describes this name before " + "recording it -- a rule claiming a diff it does not " + "describe is #372, and it stays green everywhere else") checked += 1 assert checked, "no contested name was checked, so this pin is vacuous" assert set(_CROSS_RULE_WINNERS) == {led.name for led in _LEDGERS}, (