Skip to content

emrg: classify merge-conflict blocks by the resolution the evidence supports - #1143

Open
argszero wants to merge 2 commits into
masterfrom
feature/classify-conflict
Open

emrg: classify merge-conflict blocks by the resolution the evidence supports#1143
argszero wants to merge 2 commits into
masterfrom
feature/classify-conflict

Conversation

@argszero

Copy link
Copy Markdown
Owner

Why

Cycle cyc20260911-112155 hit the same merge-conflict shape twice and the two
cases needed opposite resolutions:

PR conflict shape correct resolution what a naive --theirs does
#1136 branch carried an unmerged copy of #1134's work (the branch was based on #1134, but #1134 landed as a squash, so the ancestry is invisible) master is a strict superset → take master's side correct by luck
#1140 both sides added distinct new tests keep both silently drops the other side's 4 probes

A single git checkout --theirs on the second case destroys work with no signal,
and the first case looks identical at the marker level. Nothing in the repo
classified these conflicts, so each was resolved by reading by hand, and the
resolution that is wrong is the one that looks clean.

What

scripts/classify-conflict.py — a classifier only; it never edits files.

python3 scripts/classify-conflict.py <file>... [--all]

For every conflict block it prints the class, the evidence, and the resolution
the evidence supports:

class evidence resolution
count-line the two sides differ only by a number measure on the merged tree, never pick a side (both sides are stale by construction) — hand off to check-doc-count.py --resolve-conflict
disjoint the sides declare different symbols keep both (concatenate)
duplicate the sides declare the same symbols and one is a superset take the superset side
overlapping the sides declare the same symbols with different bodies a human must reconcile
identical the sides are byte-identical take either

Exit code 1 if any block is overlapping, so a resolution loop cannot
silently auto-resolve a real edit collision. Exit 0 otherwise (a count-line
or disjoint/duplicate verdict is machine-actionable). Output is ASCII in all
modes.

The predicate is declared symbols, not text lines

The first implementation compared content lines, and both real conflicts were
misclassified as overlapping: the two sides share boilerplate lines such as
""" and ), which look like overlap under a line-based test. The
shipped predicate extracts declared symbols (def / class names) from each
side, which is what actually distinguishes "two independent additions" from "the
same symbol edited twice". Both real cases are pinned as ground-truth regression
tests that reconstruct the actual #1140 (disjoint) and #1136 (duplicate)
merge states.

Verification

  • uv run pytest tests/ -q1430 passed, 1 skipped (21 new tests)
  • tests/test_classify_conflict.py → all green; reverting to the line-based
    predicate reds 4 tests, including both ground-truth ones
  • from emrg.client.app import run_client OK; python -m emrg --help OK
  • actionlint .github/workflows/*.yml clean
  • Runs under bare python3; auto-covered by tests/test_script_output_ascii.py
    (globs scripts/*.py)
  • scripts/check-doc-count.pyOK: Agent.md documents 1431 collected Python tests
  • All five verdict paths exercised live against hand-built conflict files
    (disjoint, count-line, overlapping incl. exit 1)

Scope

Documentation added to Agent.md under the tool list; the Agent.md Python test
count was re-measured on this tree (1410 → 1431).

EMRG Evolution added 2 commits September 11, 2026 11:54
…fects)

Adversarial probing of classify-conflict.py (#1143) found three ways it could
recommend a resolution that silently loses work. All three are latent in the
predicate, not the plumbing, so none was visible from the tool's own suite.

1. duplicate compared declared NAMES only. "theirs declares every name ours
   does" was read as "theirs contains ours", but when both sides declare
   test_alpha with different bodies, taking the superset discards ours' edit to
   it. That is the data loss this tool exists to prevent, hidden behind the one
   verdict that recommends a side-pick. Shared symbols' bodies are now compared;
   a mismatch escalates to overlapping instead of guessing.

2. count-line fired on any one-line-vs-one-line integer difference, so
   x = compute(1) vs x = compute(2) was answered "MEASURE ... never pick a side"
   with exit 0 - wrong advice, and it closed the only case a human must read.
   The rule now requires a parenthesised, non-call count on both lines, which is
   the Agent.md shape.

3. With no symbols and no count, a single differing line was called disjoint
   (KEEP BOTH), which concatenates into nonsense if it is really one line edited.
   Ambiguous now escalates.

Verification: both real historical cases still reproduce exactly against
reconstructed merges - #1140 -> disjoint=2 + count-line=1, #1136 -> duplicate=1
+ count-line=1, zero false escalations. Six new tests, all three defects
mutation-verified (disabling each fix reds exactly the tests meant to pin it).
Full suite 1435 passed / 2 skipped.
@argszero

Copy link
Copy Markdown
Owner Author

Follow-up: three latent defects found by adversarial probing at e09bcd6.

Before this cycle's review I probed the classifier with shapes the cascade can produce but its own suite did not model. All three let it recommend a resolution that silently loses work — the exact failure it exists to prevent, and none was visible from the tests or from the tool's output on the cases it was built for.

1. duplicate compared declared NAMES only. It read "theirs declares every name ours does" as "theirs contains ours". Those are different claims. When both sides declare test_alpha with different bodies, taking the superset discards ours' edit to it — with no signal, behind the one verdict that recommends a side-pick. Shared symbols' bodies are now compared; a mismatch escalates to overlapping instead of guessing.

2. count-line fired on any one-line-vs-one-line integer difference. So x = compute(1) vs x = compute(2) was answered "MEASURE … never pick a side" with exit 0 — wrong advice, and worse, it closed the only case a human must read. The rule now requires a parenthesised, non-call count on both lines, which is the Agent.md shape.

3. With no symbols and no count, a single differing line was called disjoint (KEEP BOTH) — which concatenates into nonsense if it is really one line edited. Ambiguous now escalates.

Verification. Both real historical cases still reproduce exactly against reconstructed merges: #1140disjoint=2 + count-line=1; #1136duplicate=1 + count-line=1; zero false escalations. Six new tests, and each fix is mutation-verified — disabling it reds exactly the tests meant to pin it. Full suite 1435 passed / 2 skipped.

The common thread is worth stating, because it is the same one that produced the tool: a predicate that is right on the case in front of you can be wrong on the case one step away, and the only way to find out is to construct the neighbouring case. Defect 1 is the same family as the line-vs-symbol bug the original PR fixed — subset tests keep getting read as containment claims.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant