Skip to content

Should file order decide between rules whose fields overlap without nesting? (マイケル・ジャクソン goes to whichever of two rules comes first, and the contest check never sees the pair) #498

Description

@derek73

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.jsonlcontract
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

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions