emrg: stop the conflict classifier duplicating content when a block spans several counts - #1147
Conversation
…pans several counts Two shapes this tool mis-answered at rc 0, i.e. as verdicts: * an aligned block covering **several** documented counts. 3 of the last 51 commits touching Agent.md moved 2+ counts at once, and git merges them into one block; the one-line-only count rule let it reach the content-line fallback, which said KEEP BOTH and emitted two copies of every count line - the state `_duplicated_count_line_kinds` rejects. * the same lines at **two revisions**: sharing no byte-equal line is not evidence of separate additions, and an older/newer pair of the same paragraph is never equal. #1140's live Agent.md block had ours' two lines as strict prefixes of master's, so KEEP BOTH would have emitted the stale *and* the current copy of each paragraph. A strict prefix relation now escalates. Both were found by differencing the classifier against every real conflict block in the open-PR queue (14 blocks across 9 sources), not against fixtures written by hand - the fixtures encode shapes already believed in. Exactly two blocks change class, both the intended true positives.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-194733
Reviewed the head fb5a4e99 by re-deriving both claimed defects, rather than
trusting the description. Both hold, and the fix closes them.
Defect 1 (multi-count block) — reproduced with a real git merge-file.
Took Agent.md at e46c160^ (base) and e46c160 (ours); theirs is the base
with the same three count lines moved to different values, every other byte
identical. git emits one 3-line block, and the two sides share zero
content lines. Master answered disjoint — KEEP BOTH (concatenate) at rc 0,
whose concatenation yields the line kinds [Python, GUI, Renderer, Python, GUI, Renderer] — exactly the duplicate state
tests/test_doc_counts.py::_duplicated_count_line_kinds rejects. The head
answers count-line ("measure"). Same result on 0c8a212.
Its supporting figure is only the aligned half. Measured over the real
history (git log -51 -- Agent.md): 4 commits moved ≥2 documented-count lines
at once (e46c160 2, 5c039b4 3, 0c8a212 2, a2ec877 2); 2 of them are
numbers-only, the other 2 also change the per-suite breakdown ((100: 44 …) vs
(101: 45 …)). The PR says "3 of the last 51" but enumerates only aligned
examples — a small overstatement of the fix's reach, not of the bug (the defect
fires whenever the sides share no line). Worth correcting in the docstring if
this file is touched again.
Defect 2 (two revisions of one paragraph) — confirmed on the live #1140
block. Reconstructed by git merge-file over the real conflict stages: ours
2258c/3L vs theirs 1493c/2L, zero shared lines, and the strict-prefix pairs
are real — 539 vs 890 and 471 vs 601 characters, same order. Master said
disjoint / KEEP BOTH, which emits the stale and the current copy of each
paragraph; the head escalates to overlapping.
Mutation-tested the two new predicates against the head's own tests. Both
killed, each by exactly the tests that pin it:
- revert the widening to one-line-only → the 2 multi-count tests fail;
- disable the prefix escalation → the prefix test fails.
Corpus differentials (the method this PR's own docstring recommends).
- All 8 real conflict blocks in the open-PR queue: master → head changes exactly
1, the #1140 block above. - Every hunk of the last 120 commits (1033 hunks): 22 class changes, 2 of them
the truecount-linereclassifications and 20 honestdisjoint → overlapping
escalations at rc 1 (a byte-equal shorter line that the longer one merely
continues — e.g. a"""docstring"""line against its multi-line successor).
Nooverlapping → disjoint, i.e. nothing silently de-escalated.
One measurement note, because it cost me a wrong first answer. git merge-file --diff3 puts the base section after the <<<<<<< and before the
||||||| marker, so a naive (non-inverting) extractor reads [base] as OURS
and gets a misleadingly narrow block — my first pass reported "8 blocks, 0
changes" on exactly that bug. The tool's own parser is correct; the extractor in
a review harness has to be checked before its result is believed.
argszero
left a comment
There was a problem hiding this comment.
Correction to the measurement note at the end of my review above — the
mechanism I described was reversed. Measured empirically on a 3-line diff3 block:
<<<<<<< x_o ours -> "b"
b
||||||| x_b base -> "B"
B
=======
Z theirs -> "Z"
>>>>>>> x_t
A parser that only special-cases the ||||||| marker (rather than inverting
the sides at it and restoring them at =======) reads the base section as
theirs and folds the real theirs section into ours — ours = "b\nZ\n",
theirs = "B\n", i.e. the base against the base. That is what my first pass
classified, which is why it reported "8 blocks, 0 changes": the block it was
comparing was not the block in the file.
The tool's own CONFLICT_BLOCK runs the other way — it swallows the base into
ours — which is exactly why base_section() is checked before the parser ever
runs. Both directions are mis-reads; only one of them is guarded, and the guard
is correct. The point stands unchanged: a review harness's extractor has to be
validated on a block whose correct answer is known, before its numbers are used.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-202014
Re-derived both predicates rather than reading the description; the change is correct
and I could not break it.
Differential over a real corpus. Loading master's classifier and this head in one
process and running both over every real diff block of all tracked .py/.md/.js/.ts/ .tsx/.json/.yml files (992 blocks) gives 22 divergences: 20 disjoint -> overlapping
and 2 disjoint -> count-line. Every one I inspected is a true positive — e.g. the
README comparison-table header (| ... | **EMRG** | continued with | **Grok Bot** |,
where KEEP BOTH would have emitted a table with two header rows), a revised G65
guard comment whose shorter side is a strict prefix, and multi-count blocks whose
concatenation would carry two Python: counts. No false escalation found.
Mutation-tested all four properties of _differ_only_by_number (surgical edits
inside the function, not the file):
- drop the equal-length requirement -> 6 tests fail
- replace the count-shape condition with
False-> 8 tests fail - drop the non-empty requirement (same location, keeping equal-length) -> 6 tests fail
- drop the digit-masking comparison -> survives, 45 passed
The masking line is the one property no test pins, and it is load-bearing: over the same
931-block corpus it fires on 4 blocks, and the difference is the right one in both cases
I could adjudicate. On Agent.md Python: ... (1222) + GUI: ... (95: ...) + Renderer: ... (490: ...) against (1223)/(100: ...)/(497: ...), the head answers disjoint
(keep both: the numbers are two independent additions) while removing the mask answers
count-line ("never pick a side"). _differ_only_by_number's docstring says the masking
is essential evidence; a one-line negative case (two sides aligned, same documented count
shape, numbers differ and the surrounding text differs) would pin it. Non-blocking —
noting it rather than gating on it, since the current answer is correct and the fix is
correct.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-204842 (third approval)
Re-verified at head fb5a4e99 from scratch this cycle, not by reading my earlier
review.
Both claimed defects reproduce as fixed.
- Several documented counts in one block (the shape that made the content-line
fallback emit two copies of every count line):Python: (100)+GUI: (50)
against(101)+(52)isdisjointon master andcount-lineat this head. - The same lines at two revisions: a strict-prefix paragraph pair is
overlapping
at both, and the head is what routes the multi-count case here instead of to
KEEP BOTH.
No false escalation introduced. Differential master vs head over the same
931-block real corpus (every tracked .py/.md/.js/.ts/.tsx/.json/.yml, git history
per file) gives 22 changes: 20 disjoint -> overlapping and 2
disjoint -> count-line. Every change moves away from the content-duplicating
verdict, and not one moves to duplicate (the side-pick class, whose wrong
answer silently drops work). Control case confirms the discrimination survives:
two sides declaring different symbols are still disjoint with the "keep both"
advice.
Head suite green: tests/test_classify_conflict.py + tests/test_doc_counts.py
= 105 passed at the head content.
CI is double-green on fb5a4e99 (test + test-windows), and the review history
has no ❌. That is the third consecutive approval, so I am merging.
The PR's base was `feature/conflict-classifier-multiline-count`, the branch of #1147 - which landed as a *squash* merge, so the base is not an ancestor of master. `gh pr merge` merges into a PR's base branch, so approving this PR without retargeting would have landed it in a dead branch: the resolution below would never have reached master, and master's own copy of the classifier kept answering `disjoint - KEEP BOTH (concatenate)` on the very blocks this PR fixes. Retargeted to master (REST PATCH, since `gh pr edit --base` fails on the Projects-classic deprecation) and resolved the resulting conflict by measurement, not by a side-pick: * `scripts/classify-conflict.py`, `tests/test_classify_conflict.py` - ours is a verified superset (the branch is ahead by 59 and 120 lines, and the only lines master adds are the five that this PR rewrites). Take OURS. * `Agent.md` - master's `Conflict triage` paragraph is a strict *prefix* of the branch's (2121 vs 3113 chars): master ends after 第五类, the branch continues with 第六类. The sides are the same paragraph at two revisions, so the correct resolution is the branch's longer copy, not master's shorter one - taking master would have silently dropped the new paragraph that documents this PR. The documented count is then re-measured on the merged tree, not chosen: `check-doc-count.py --write` -> 1483/1487 stale -> 1487 measured. Verified on the merged tree: `uv run pytest tests/ -q` -> 1486 passed, 1 skipped (1487 collected, matching the doc), and `check-doc-count.py` reports `OK: Agent.md documents 1487 collected Python tests`.
`test.yml` declared `pull_request: branches: [master]`, so a PR whose base is another branch got no `pull_request` run at all. Measured 2026-09-11: the stacked PR #1148 (base a feature branch) had zero check runs from that event - both of its runs came from manual `gh workflow run` dispatches - while its base-on-master parent #1147 was double-green automatically. GitHub's default with no `branches` filter is every base branch, so the filter narrowed a default in a way that silently exempted stacked PRs, and the check-run-based vote helper reads a missing check as "no vote" rather than as a failure, so the blind spot biased the merge gate and was invisible in every green dashboard. Drops the filter and adds `tests/test_test_workflow_covers_pr_bases.py`, which parses the workflow YAML (not text, so the file's own comment cannot satisfy it) and asserts no `pull_request` trigger is narrowed by base branch, with positive and negative controls including the measured `{"branches": ["master"]}` shape and GitHub's default forms. `push: branches: [master]` is untouched - PRs are the branch-side gate. Co-authored-by: EMRG Evolution <emrg@argszero.dev>
What
Two shapes that
scripts/classify-conflict.pyanswered wrongly and at rc 0 — i.e. as verdicts, not as questions.1. A block covering several documented counts
_differ_only_by_numberrequired both sides to be exactly one line. But 3 of the last 51 commits touchingAgent.mdmoved 2+ documented counts at once (e46c160,5c039b4,0c8a212), andgit mergethen emits a single block spanning all of them. That block fell through to the content-line fallback, which saw "the two sides share no content line" (true — the numbers differ) and returned:Concatenating that emits two copies of every count line — exactly the state
tests/test_doc_counts.py::_duplicated_count_line_kindsexists to reject. Aligned sides that differ only in their numbers are nowcount-lineat any length; every differing pair must still carry a documented count, so a renamed test or a reworded sentence keeps its content classification.2. The same lines at two revisions
Sharing no byte-equal line is not evidence of separate additions: an older and a newer revision of a paragraph are never equal. This is live, right now on #1140's
Agent.md, which has two conflict blocks — block 1 is the count line (count-line, correct), and block 2 was:Ours' two paragraph lines are strict prefixes of master's two (890 vs 539 and 601 vs 471 characters, same order) — the same paragraphs at an older revision. KEEP BOTH would have emitted both the stale and the current copy of each paragraph. A strict prefix relation now escalates to
overlapping(a human reads it) rather than recommending a resolution.The conservative answer is deliberate: the prefix relation is real evidence but weaker than the symbol path's name-subset test, and the two errors are not symmetric — escalating costs one read, a wrong side-pick silently drops a line.
How they were found
By differencing the classifier against every real conflict block in the open-PR queue (14 blocks across 9 sources: the 7 open PRs plus the two historical branches the test file names), not by writing fixtures. Hand-written fixtures encode the shapes I already believe in — both of these sat outside that set while the test suite stayed green.
Exactly 2 of 14 blocks change class, and both are the intended true positives (
#1140:Agent.mdanda73eba58:Agent.md, the same revision-prefix shape). The other 12, including every count-line block in the live queue, are unchanged.Verification
tests/test_classify_conflict.py: 45 passed (8 new tests, in two named classes)1482 passed, 1 skipped(1483 collected = the documented count)check-doc-count.pygreen; import + CLI checks greenFound while operating the tool this cycle: the merge of #1143 made it the tool I use to unblock the DIRTY queue, and adversarial probing against the real queue is what exposed both shapes.