Skip to content

emrg: stop the conflict classifier duplicating content when a block spans several counts - #1147

Merged
argszero merged 1 commit into
masterfrom
feature/conflict-classifier-multiline-count
Sep 11, 2026
Merged

emrg: stop the conflict classifier duplicating content when a block spans several counts#1147
argszero merged 1 commit into
masterfrom
feature/conflict-classifier-multiline-count

Conversation

@argszero

Copy link
Copy Markdown
Owner

What

Two shapes that scripts/classify-conflict.py answered wrongly and at rc 0 — i.e. as verdicts, not as questions.

1. A block covering several documented counts

_differ_only_by_number required both sides to be exactly one line. But 3 of the last 51 commits touching Agent.md moved 2+ documented counts at once (e46c160, 5c039b4, 0c8a212), and git merge then 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:

disjoint - the two sides share no content line - KEEP BOTH (concatenate)   rc 0

Concatenating that emits two copies of every count line — exactly the state tests/test_doc_counts.py::_duplicated_count_line_kinds exists to reject. Aligned sides that differ only in their numbers are now count-line at 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   2 lines, theirs 3, shared lines 0
disjoint - the two sides share no content line - KEEP BOTH (concatenate)

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.md and a73eba58: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)
  • Mutation-verified: reverting either half (restore one-line-only; restore KEEP BOTH on prefix revisions) turns the suite red — both mutants killed
  • An earlier version of the prefix test survived that mutation, because its fixture happened to contain one shared line, routing it through the older partial-overlap branch. Fixed by asserting the fixture has zero shared lines, so the test exercises the new rule; that failure mode is now pinned in the test's docstring.
  • Full suite: 1482 passed, 1 skipped (1483 collected = the documented count)
  • check-doc-count.py green; import + CLI checks green

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

…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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 true count-line reclassifications and 20 honest disjoint → 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).
    No overlapping → 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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 oursours = "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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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) is disjoint on master and count-line at 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.

@argszero
argszero merged commit 25904b6 into master Sep 11, 2026
2 checks passed
argszero pushed a commit that referenced this pull request Sep 11, 2026
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`.
argszero added a commit that referenced this pull request Sep 11, 2026
`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>
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