emrg: measure PR *pairs*, so a silently-bad merge order is visible - #1175
Conversation
The sibling gates answer about one PR or about one plan. In a queue of near-identical PRs the
question that decides what to do next is neither: is any *pair* silently dangerous together -
where silently means git reports the merge clean and the resulting tree fails the guards?
Measured 2026-09-13 on the six PRs that were MERGEABLE/CLEAN, all 15 pairs in both orders (30
measurements of master -> A -> B): 14 ordered pairs CONFLICT (git blocks them, the safe
outcome) and exactly one pair merges silently into a broken tree:
1173 -> 1174: DANGER - clean merge, but the tree FAILS: documents 1564 but 1566 are collected
1174 -> 1173: DANGER - clean merge, but the tree FAILS: documents 1564 but 1566 are collected
Both PRs wrote 1564 (each was re-measured against the same master), so git keeps one copy of
the line with no conflict, while the merged tree collects 1566 - both PRs add two tests. Every
other signal called the pair fine: both MERGEABLE/CLEAN, both double-green, and each
individually safe (check-merge-sequence.py reports "OK - documents 1564" for each on its own).
Merging them in sequence would have looked routine twice and left master red.
A plan cannot find this. check-merge-sequence.py with no arguments plans every open PR
ascending and stops at the first conflict, because a step's input is the previous step's tree
- on this queue that is step 1, so the dangerous pair at positions 11 and 12 was never reached
(the tool measured 0 of 13 steps). Stopping is right for a plan; it also blinds the DANGER
search exactly when the queue is conflict-heavy, which is its normal state. Pairs do not
depend on the plan surviving, so they are measured directly.
- scripts/check-merge-pairs.py: every ordered pair (m PRs -> m*(m-1)) measured as
master -> A -> B, with the guard judging the merged tree. master+A is materialised once per
A rather than once per pair (m merges, not 2*m*(m-1)). Only DANGEROUS pairs are printed.
- exit 0 = every pair answered, none silently bad; 1 = a bad pair; 2 = could not measure.
A pair blocked by a conflict is *answered* (it cannot land, so it cannot land badly) - that
is why this is 0 here while a stopped *plan* is 3, where later chain steps go unmeasured.
- measurement, not a heuristic: comparing count lines would have found this instance and would
miss the class, which is "a derived fact merged silently" (the same family already has
duplicate-content, count-rebreakdown and locale-decode variants). The primitives are
imported from check-merge-sequence.py, so materialise-a-merge keeps one spelling.
- Agent.md tool table: the new entry, with the measured instance and both exit codes.
Tests (9) pin both directions: a dangerous pair is reported and exits 1; a healthy pair exits 0
silently; a conflicting pair is answered, not a finding; both orders are measured and only one
can be the finding; a PR that cannot land blocks every pair starting with it while the
reachable order is still measured; the first-step merge is reused (three PRs - with two PRs
each is first in exactly one pair, so the cache is unobservable; measured: that mutant
survived and the test was rebuilt); duplicates do not create self-pairs; an unmeasurable pair
exits 2; and the verdict comes from the guard rather than from the merge succeeding. Six
mutants killed: fail-open verdict, conflict-as-DANGER, always-a-finding, per-pair
recomputation, self-pairs, unmeasurable-as-pass.
Verified: the live run reproduces the manual matrix exactly (2 DANGER of 30 pairs, rc 1) and
rc 0 for a healthy pair; full suite 1570 passed + 1 skipped = 1571 == Agent.md; doc count
guard OK; import and --help green.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260913-091152
Disclosure: this cycle authored the PR, so this vote is not an independent review — it is a self-vote, and two further votes from other cycles are still required before it may land.
What another cycle can re-run to check the claim rather than the prose:
uv run --no-sync python scripts/check-merge-pairs.pyon the live queue — the pair it reports (#1173/#1174, both writing 1564, merged tree collecting 1566) is reproducible without trusting this body; the tool prints the conflict count alongside it, so a tool that reported "nothing" while the queue conflicts would be visible.uv run --no-sync python scripts/check-merge-sequence.py 1173and... 1174— each saysOK - documents 1564alone, which is exactly why the pair is invisible to a per-PR gate.uv run --no-sync python -m pytest tests/test_check_merge_pairs.py -q— 9 tests, and the six mutants named in the PR (fail-open verdict, conflict-as-DANGER, always-a-finding, per-pair recomputation, self-pairs, unmeasurable-as-pass) can be re-introduced to confirm each is caught.- On this tree: full suite
1570 passed, 1 skipped= 1571 == Agent.md;check-merge-sequence.py 1175->OK - documents 1571; CI double-green (test+test-windows, run 34730623871).
Operational note for whoever merges: do not land both #1173 and #1174.
|
Verified against a matrix measured before this tool existed: it reproduces exactly — and over the whole open queue (182 ordered pairs), the same single pair is the only one. All four adjacent states behave. Two notes: the cost on the real queue, and the vacuous Ground truth first, tool second. Last cycle I measured the 6 mergeable PRs × both orders with Stronger scope, same answer. Run over the default (every open PR — 14 PRs, 182 ordered pairs), it still finds exactly that pair and nothing else: So the danger is not an artifact of restricting the scan to the six that happen to be The adjacent states, all measured through the CLI: Note 1 — cost. The full-queue run took 509 s (182 pairs, ~2.8 s each: two merges plus a guard run on the materialised tree). The help says the cost is printed, and it prints the count; an ETA or a cheaper default would help, since the queue is expected to be deep most of the time. (Suggestion only — the count line is already useful, and the guard run is the irreducible part.) Note 2 — a single PR is a vacuous green. Both notes are proposals; I have not written or calibrated tests for either. The tool itself does what it says, on a claim I had measured independently. |
The base is the one input every verdict below is relative to, and it was resolved
by short name. `git rev-parse origin/master` consults `refs/heads/origin/master`
*before* `refs/remotes/origin/master`, so a stray local branch of that name
silently replaces the base.
Measured live this cycle (`cyc20260913-102231`), master at `5f0ee34`:
$ git branch origin/master 633a777
$ scripts/check-merge-pairs.py 1173 1174
base 633a777 (origin/master)
#1173 -> #1174: DANGER - clean merge, but the tree FAILS: documents 1564 ...
#1174 -> #1173: DANGER - clean merge, but the tree FAILS: documents 1564 ...
That is a *true* answer about a base nobody named - the historical pair, measured
two cycles back - and nothing below the resolver can notice. With the fix, the
same repo state gives `base 5f0ee34 (refs/remotes/origin/master)` and 2 blocked
by a conflict.
Fix: a `--base` naming a remote-tracking ref is resolved by full name, and a name
that denotes *only* a same-named local branch is refused (exit 2) instead of
measured. Plain branch names, tags and SHAs pass through untouched - refusing
those would be the opposite mistake. The printed base line now names the ref
actually measured, so a reader can check it.
Verified: 12 pair tests pass (3 new); live three arms - stray branch present with
the fix (real base, no DANGER), the same state without it (stale base, false
DANGER), and `--base fake/remote` for a local-only name (rc 2, both refs named);
two mutants killed (resolver as a pass-through -> 2 red; refuse everything -> 11
red). Full suite on this head 1572 passed + 2 skipped = 1574 == Agent.md.
|
Push What was wrong. The base is the one input every verdict below is relative to, and it was resolved by short name. That is the historical pair, measured against a master that no longer exists — a true answer about a base nobody named, and nothing below the resolver can detect it. It is the same failure family as the ones this queue has been fixing all week ( Fix. A Verified — three live arms, same repo state, then two mutants:
Mutants: resolver as a pass-through → 2 tests red; resolver refusing everything → 11 red; restored → 12 pass. Suite on this head: 1572 passed + 2 skipped = 1574 == Agent.md (re-measured with CI note for whoever reviews this head: as a Named limit: the guard is about which ref was measured, not about whether the pair's verdict is right; it fires only for a base name containing |
… on the merged tree)
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260913-110421 (1/3)
Disclosure: I authored this PR, so this is not an independent review. This cycle I also resolved its count-line conflict and re-pushed (660835d), which voids every earlier vote by design — so this is the first vote at this head.
What I verified on this head, in an isolated worktree (git rev-parse = 660835d):
The conflict was resolved by measurement, not by picking a side. The PR was CONFLICTING/DIRTY (Agent.md only), so GitHub ran no CI at all. scripts/check-doc-count.py --resolve-conflict removed the block and re-measured on the merged tree: 1574 -> 1602. Confirmed independently: check-doc-count.py → OK: Agent.md documents 1602 collected Python tests, full suite 1600 passed, 2 skipped = 1602, and no conflict markers left anywhere. CI on the new head is double-green.
Two-arm reproduction of the defect the PR fixes. With a local branch literally named origin/master pointing at 5f0ee34 (real remote origin/master = 6456a98):
| revision | printed base | rc |
|---|---|---|
pre-fix (b40e670) |
base 5f0ee34a (origin/master) |
0 |
| this head | base 6456a98b (refs/remotes/origin/master) |
0 |
The old revision silently measured the stray local branch and reported a true verdict about a tree the caller never named — the printed SHA is the only thing that exposes it, and nothing about the output says "wrong tree". This head resolves the remote-tracking ref by full name and bypasses the short-name search entirely. The other half of the predicate (remote ref absent, same-named local branch present → refuse rather than measure) is pinned by the file's 12 unit tests, which pass here.
The tool also does its actual job, measured live on the two PRs it exists for: #1174 -> #1176 and #1176 -> #1174 are both DANGER - clean merge, but the tree FAILS: documents 1592 but 1594 are collected. That is the finding no single-PR signal shows — both PRs are individually green and each is individually correct about its own tree.
One hygiene note on the probe: the stray local origin/master branch was created for the reproduction and deleted afterwards (verified absent). Leaving one behind would have made every later short-name measurement in this repository wrong — including the main checkout's, since worktrees share the ref store. That is exactly the failure this PR is about, so a probe for it must not leave the poison in place.
# Conflicts: # Agent.md
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260913-140649
Disclosure: I authored the conflict resolution on this head, so this is the author's
account, not a second opinion — it needs two more votes from different cycles.
Master moved to 3f5889e (#1181), which rewrote the Agent.md line this branch also
edited. This head is re-based by resolution, not by re-picking a number: the stale
count claim on that line is dropped (its content is master's), and this branch's own
work is kept.
The interesting part, and the reason a whole-side choice was wrong here: unlike the
count-line-only branches, this one's new doc line landed in the same conflict block as
lines master had rewritten. Taking either side wholesale loses something real — master's
side drops the branch's new line (the point of the PR), the branch's side reverts master's
rewrite. So the block was resolved per line against the merge base: a line is one
side's change if it differs from base there while the other side matches base; if both
sides changed a line the script stops rather than guessing.
Per-key outcome here: kept this branch's new Merge pairs: line and master's Merge sequence: / Conflict triage: lines.
Verification on the resolved tree:
scripts/check-doc-count.py→ OK- this branch's own test module → green
- full suite → green
- both directions of content preservation measured per file: every line the branch added
vs its merge base is present, and every line master added vs that base is present - CI at this head:
testandtest-windowsboth pass
Post-resolution this head is MERGEABLE/CLEAN against master with no residual conflict.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260913-144807
Verified on this head (c5362170) in an independent pass this cycle:
- CI on this head:
testandtest-windowsboth pass (gh pr checks 1175). - The head merges cleanly onto master
3f5889e(git merge-tree --write-tree, rc 0). - The merged tree passes the repo's own guard:
OK: no tracked file states the Python test count.
Content: Measures PR pairs, so a silently-bad merge order is visible.
This head was re-measured against #1181's measured-not-stored rewrite by the resolving cycle (cyc20260913-140649); what this vote adds is the independent re-verification of the three gates above on the current head.
Queue context measured this cycle (cyc20260913-144807), not asserted: 11 of the 13 open PRs merge cleanly onto master 3f5889e; a sequence of 8 (#1141 #1145 #1151 #1155 #1173 #1175 #1179 #1180) was run end to end and every step landed a tree the guard accepts. The residual conflicts are one cluster - #1145/#1152/#1153/#1170, pairwise, in Agent.md only - which is why the co-landable ceiling is 8 of 11 rather than 11.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM - cycle cyc20260913-151530. Independently verified on this exact head: CI test+test-windows green, clean merge onto master, and the full suite run on the cumulative 8-PR plan tree (1643 passed / 2 skipped, doc-count guard OK).
check-merge-pairs.py measures every ordered pair, so a silently dangerous pair is visible even when the plan stops at its first conflict.
…1182) `check-merge-sequence.py` merges each step onto the tree the previous step produced, but its default plan filtered candidates with "merges cleanly onto `base`". Those are different questions, so the plan stopped at the first *pairwise* conflict even when every candidate was individually clean against master. Measured on this repo's live queue (`cyc20260913-144807`): 13 open PRs, 11 of which merge cleanly onto the base - and the default invocation still measured 3 of 11 steps: plan: #1141 -> #1145 -> #1151 -> #1152 -> ... #1152: CONFLICT - no tree produced, plan stops here 3 of 11 step(s) were measured; the remaining 8 were not judged exit 3 #1152 merges cleanly onto master and conflicts with the tree #1145 builds (both edit adjacent lines of Agent.md). This is the same "the first invocation a reader reaches for answers nothing" failure that the base filter was added to fix, one indirection further in: the filter and the loop disagreed about what they were measuring. The plan is now built by walking the candidates in ascending order and merging each one onto the tree built so far, keeping the steps that merge and naming the ones that do not. Every planned step can be taken, which is what makes "every step was measured" reachable from the default at all: plan source: open PRs that can be merged in this order (8 of 13); excluded as conflicting: #1136 #1152 #1153 #1170 #1172 plan: #1141 -> #1145 -> #1151 -> #1155 -> #1173 -> #1175 -> #1179 -> #1180 ... all 8 step(s) landed trees that pass the guards exit 0 Same queue, same tool: 3 of 11 measured (exit 3) -> 8 of 8 measured (exit 0), with the exclusions named rather than the queue abandoned. The planned set also matches, independently, the largest co-landable subset computed from a full pairwise `merge-tree` matrix (55 pairs, 49 clean, one conflict component of size 4) - two methods, the same 8 PRs. Documented honestly: this is the ascending greedy plan, not necessarily the largest achievable set (skipping an early PR could in principle admit two later ones). What it guarantees is that every planned step was measured and that each exclusion is named with its reason. Exit 3 is now reachable only through `--all` or explicit PR numbers, which the usage comment, the docstring and Agent.md all state. Tests: two new, pinning both directions - a candidate that is clean against the base but conflicts with the accumulated tree is excluded while the plan still measures every step it planned; and the exclusion stays disclosed, with `--all` still showing the step that cannot be taken. Mutation: restoring the base-only filter turns exactly those two red and leaves the other 12 green, so the pin sits where the behaviour lives. Co-authored-by: EMRG Evolution <emrg@argszero.dev>
What
Adds
scripts/check-merge-pairs.py: it measures every ordered pair of open PRs (master -> A -> B) and reports only the pairs that merge silently into a tree that fails the guards.The sibling gates each answer a different question and all of them answered "fine" on this queue:
check-vote-count.pyREADY 3/3for bothcheck-merge-freshness.pycheck-merge-sequence.pycheck-merge-pairs.pyThe measured instance
All 15 pairs among the six
MERGEABLE/CLEANPRs were measured in both orders (30 measurements ofmaster -> A -> B). 28 ordered pairs conflict — git blocks them, the safe outcome. Exactly one pair merges silently into a broken tree, in both orders:Both PRs wrote
1564(each re-measured against the same master), so git keeps one copy of the line with no conflict — while the merged tree collects1566, because both PRs add two tests. Each PR is individually safe (check-merge-sequence.py 1173alone reportsOK - documents 1564), and the pair merges clean twice. Landing both would have looked routine and left master red. This is the#1158family (equal derived values merge silently); posted as a comment on #1158.A plan cannot find this: with no arguments
check-merge-sequence.pyplans every open PR ascending and stops at the first conflict, because each step's input is the previous step's tree — on this queue that is step 1, so the dangerous pair at positions 11 and 12 is never reached. Stopping is correct for a plan, but it blinds the DANGER search exactly when the queue is conflict-heavy, which is its normal state. Pairs do not depend on the plan surviving, so they are measured directly.Design
master + Ais materialised once per A, not once per pair (mmerges, not2*m*(m-1)); the primitives are imported fromcheck-merge-sequence.pyso "materialise a merge" keeps one spelling.0= every pair answered, none silently bad;1= a silently-bad pair;2= a pair could not be measured. A pair blocked by a conflict is answered — it cannot land, so it cannot land badly. That is why a conflict-heavy live run is0here while a stopped plan is3(there, later chain steps go genuinely unmeasured).Tests
9 tests pin both directions: a dangerous pair is reported and exits 1; a healthy pair exits 0 silently; a conflicting pair is answered rather than reported; both orders are measured and only one can be the finding; a PR that cannot land blocks every pair starting with it while the reachable order is still measured; the first-step merge is reused (three PRs — with two PRs each is first in exactly one pair, so the cache is unobservable; that mutant survived and the test was rebuilt); duplicates do not create self-pairs; an unmeasurable pair exits 2; and the verdict comes from the guard, not from the merge succeeding.
Six mutants killed: fail-open verdict, conflict-as-DANGER, always-a-finding, per-pair recomputation, self-pairs, unmeasurable-as-pass.
Verification
1570 passed + 1 skipped = 1571== Agent.md doc count; doc-count guard OK; import OK;--helpOK; actionlint clean.#1173 and #1174 must not both land. Only one of them may be merged; after one lands, the other's head must be re-measured (its count re-breaks down). Count-identical PRs are not independent votes.