Rationale
#382 landed a check that refuses an undeclared order-decided contest: where a
later ledger rule's fields are a strict subset of an earlier one's, both
name_regexes reach a common corpus name, and their orders are not disjoint,
classify() returns the first match and file order alone picks the winner —
so the earlier rule must carry a [[change.precedes_narrower]] block.
Nesting is sufficient for that hazard. It is not necessary. Two rules
whose fields merely intersect both admit any diff that fits inside the
intersection, so classify() hands such a name to whichever of them is written
first, exactly as it does for a nested pair — and order_contests cannot see it,
because its fields test is b.fields < a.fields.
The worked case (measured 2026-09-02 against the real 1.4.0 wheel)
In expected_since_1.4.0.toml:
| rule |
position |
fields |
fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots |
0 |
{family, given, middle} |
fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip |
30 |
{family, given, nickname} |
Neither set contains the other. The intersection is {family, given}, and both
regexes reach six corpus names, all of them in corpus_cjk.jsonl — contract
tier. Three of the six have a real 1.4.0 diff of exactly {family, given},
which sits inside the intersection and so passes both rules' subset test:
| name |
diff at 1.4.0 |
as shipped |
with the two rules swapped |
マイケル・ジャクソン |
{family, given} |
fix(#271/#272/#298) |
fix(cjk-delimited-nickname) |
威廉・莎士比亚 |
{family, given} |
fix(#271/#272/#298) |
fix(cjk-delimited-nickname) |
高橋・一郎 |
{family, given} |
fix(#271/#272/#298) |
fix(cjk-delimited-nickname) |
A pure reorder of those two rules — no regex change, no fields change —
reattributes all three. None of the three appears in _CROSS_RULE_WINNERS
(33 pinned names today), so nothing in the suite fails. And
order_contests does not list this pair in either arrangement: it reports
11 contests as shipped and 13 after the swap, and the pair itself is in neither
list, both times for the same reason — neither rule is narrower than the other.
This is #382's own crux name-shape (fix(cjk-delimited-nickname) describes the
nickname extraction plus the order flip; fix(#271/#272/#298) describes the
order flip alone), reached from the non-nested direction, where the arc's new
check is silent.
Scope, measured over all four ledgers (2026-09-02)
Pairs sharing at least one corpus name with non-disjoint orders, over the 1116
distinct corpus names:
| class |
pairs |
strictly nested, wide-first — what order_contests refuses |
11 |
| nested in either direction |
40 |
equal fields |
11 |
any non-empty fields intersection — the general predicate |
111 |
| overlap without nesting or equality — the blind spot |
60 |
(40 + 11 + 60 = 111.) All 11 wide-first pairs are in
expected_since_1.4.0.toml; the three 2.x ledgers have none.
Why the nested case was the one #382 closed, and why that was right
precedes_narrower says "this rule outranks a NARROWER one". That sentence has
a referent only where one rule genuinely is narrower — narrow-first is
meaningful only there, and the check exists to make the wide-first exceptions to
it argue for themselves. Where neither fields set contains the other, no rule
is the narrower one, so "narrow-first" says nothing about the pair and there is
nothing for a precedes_narrower block to name. tools/differential/README.md
already draws exactly that carve-out for equal fields — "neither rule is
narrower, so 'narrow-first' says nothing about the pair and
_CROSS_RULE_WINNERS stays the instrument there" — and the reasoning is the
same one step out: the non-nested overlap class is outside the check by that
argument rather than by oversight.
So this is not a bug report against #382's check. It is the question of what, if
anything, should watch the other 60.
Options
- Widen the predicate to any non-empty
fields intersection. Correct, and
unaffordable as a declaration requirement: 111 blocks where the real one is
eleven, each needing a hand-written why. decisions.md#differential-ledger
already makes this argument about the 657 wide-first pairs that
fields-nesting alone would report — the roster nobody writes and nobody
reads — and 111 is the same shape of answer, one order of magnitude down.
- Report without refusing. Print the non-nested overlaps as a NOTE and
never fail on them, the way a --corpus vacancy prints today. Cheap, needs
no declarations, and puts the 60 in front of a reader who is editing one of
the rules involved. Cost: a standing 60-line note is a note nobody reads,
so it wants a threshold or a per-rule filter to be worth anything.
- A
_CROSS_RULE_WINNERS completeness check. The roster is the instrument
the docs already name for the equal-fields case, and it is the right
granularity for this class too — it pins which rule wins which name. What
it lacks is any guarantee of coverage: 33 names are pinned because somebody
hand-added them. A check that every co-matched name whose real diff fits both
rules' fields is either pinned or deliberately exempted would close the
class without inventing a second declaration key. Cost: it needs real diffs,
so it runs behind the pinned-wheel worker pass, not at pytest speed — the
trade decisions.md records for per-name contest detection generally.
Recompute
import importlib.util, itertools, tomllib
from pathlib import Path
HERE = Path("tools/differential")
spec = importlib.util.spec_from_file_location("cmp", HERE / "compare.py")
cmp = importlib.util.module_from_spec(spec); spec.loader.exec_module(cmp)
names = sorted({str(e["name"]) for p in sorted(HERE.glob("corpus*.jsonl"))
for e in cmp._load_entries(p)}) # 1116 today
for path in sorted(HERE.glob("expected_since_*.toml")):
rules = cmp._sorted_rules(tomllib.loads(path.read_text())["change"])
reach = cmp._rule_reach(rules, names)
for i, j in itertools.combinations(range(len(rules)), 2):
a, b = reach[i], reach[j]
if a is None or b is None: continue
if a.orders and b.orders and not a.orders & b.orders: continue
if not (a.names & b.names) or not (a.fields & b.fields): continue
# a.fields & b.fields non-empty -> both admit any diff inside it;
# classify() returns the FIRST match, so position decides.
# b.fields < a.fields is the wide-first 11 order_contests reports.
...
For the worked case, take the six names both regexes reach, run
cmp._run_worker("1.4.0", False, entries) over them, compute each diff the way
main() does, then call cmp.classify(name, diff, rules, exclusions, order)
once with the shipped rule list and once with the two rules' positions swapped.
Where this is written down
docs/design/decisions.md#differential-ledger (the rule-order arc) and
tools/differential/README.md's "What counts as a contest" both now say that
the check covers nested pairs and that the non-nested overlap class is outside
it by the same reasoning the equal-fields carve-out rests on, and both cite
this issue.
Rationale
#382 landed a check that refuses an undeclared order-decided contest: where a
later ledger rule's
fieldsare a strict subset of an earlier one's, bothname_regexes reach a common corpus name, and theirordersare not disjoint,classify()returns the first match and file order alone picks the winner —so the earlier rule must carry a
[[change.precedes_narrower]]block.Nesting is sufficient for that hazard. It is not necessary. Two rules
whose
fieldsmerely intersect both admit any diff that fits inside theintersection, so
classify()hands such a name to whichever of them is writtenfirst, exactly as it does for a nested pair — and
order_contestscannot see it,because its
fieldstest isb.fields < a.fields.The worked case (measured 2026-09-02 against the real 1.4.0 wheel)
In
expected_since_1.4.0.toml:fieldsfix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots{family, given, middle}fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip{family, given, nickname}Neither set contains the other. The intersection is
{family, given}, and bothregexes reach six corpus names, all of them in
corpus_cjk.jsonl— contracttier. Three of the six have a real 1.4.0 diff of exactly
{family, given},which sits inside the intersection and so passes both rules' subset test:
マイケル・ジャクソン{family, given}fix(#271/#272/#298)fix(cjk-delimited-nickname)威廉・莎士比亚{family, given}fix(#271/#272/#298)fix(cjk-delimited-nickname)高橋・一郎{family, given}fix(#271/#272/#298)fix(cjk-delimited-nickname)A pure reorder of those two rules — no regex change, no
fieldschange —reattributes all three. None of the three appears in
_CROSS_RULE_WINNERS(33 pinned names today), so nothing in the suite fails. And
order_contestsdoes not list this pair in either arrangement: it reports11 contests as shipped and 13 after the swap, and the pair itself is in neither
list, both times for the same reason — neither rule is narrower than the other.
This is #382's own crux name-shape (
fix(cjk-delimited-nickname)describes thenickname extraction plus the order flip;
fix(#271/#272/#298)describes theorder flip alone), reached from the non-nested direction, where the arc's new
check is silent.
Scope, measured over all four ledgers (2026-09-02)
Pairs sharing at least one corpus name with non-disjoint
orders, over the 1116distinct corpus names:
order_contestsrefusesfieldsfieldsintersection — the general predicate(40 + 11 + 60 = 111.) All 11 wide-first pairs are in
expected_since_1.4.0.toml; the three 2.x ledgers have none.Why the nested case was the one #382 closed, and why that was right
precedes_narrowersays "this rule outranks a NARROWER one". That sentence hasa referent only where one rule genuinely is narrower — narrow-first is
meaningful only there, and the check exists to make the wide-first exceptions to
it argue for themselves. Where neither
fieldsset contains the other, no ruleis the narrower one, so "narrow-first" says nothing about the pair and there is
nothing for a
precedes_narrowerblock to name.tools/differential/README.mdalready draws exactly that carve-out for equal
fields— "neither rule isnarrower, so 'narrow-first' says nothing about the pair and
_CROSS_RULE_WINNERSstays the instrument there" — and the reasoning is thesame one step out: the non-nested overlap class is outside the check by that
argument rather than by oversight.
So this is not a bug report against #382's check. It is the question of what, if
anything, should watch the other 60.
Options
fieldsintersection. Correct, andunaffordable as a declaration requirement: 111 blocks where the real one is
eleven, each needing a hand-written
why.decisions.md#differential-ledgeralready makes this argument about the 657 wide-first pairs that
fields-nesting alone would report — the roster nobody writes and nobodyreads — and 111 is the same shape of answer, one order of magnitude down.
never fail on them, the way a
--corpusvacancy prints today. Cheap, needsno declarations, and puts the 60 in front of a reader who is editing one of
the rules involved. Cost: a standing 60-line note is a note nobody reads,
so it wants a threshold or a per-rule filter to be worth anything.
_CROSS_RULE_WINNERScompleteness check. The roster is the instrumentthe docs already name for the equal-
fieldscase, and it is the rightgranularity for this class too — it pins which rule wins which name. What
it lacks is any guarantee of coverage: 33 names are pinned because somebody
hand-added them. A check that every co-matched name whose real diff fits both
rules'
fieldsis either pinned or deliberately exempted would close theclass without inventing a second declaration key. Cost: it needs real diffs,
so it runs behind the pinned-wheel worker pass, not at pytest speed — the
trade
decisions.mdrecords for per-name contest detection generally.Recompute
For the worked case, take the six names both regexes reach, run
cmp._run_worker("1.4.0", False, entries)over them, compute each diff the waymain()does, then callcmp.classify(name, diff, rules, exclusions, order)once with the shipped rule list and once with the two rules' positions swapped.
Where this is written down
docs/design/decisions.md#differential-ledger(the rule-order arc) andtools/differential/README.md's "What counts as a contest" both now say thatthe check covers nested pairs and that the non-nested overlap class is outside
it by the same reasoning the equal-
fieldscarve-out rests on, and both citethis issue.