Skip to content

fix(diff): keep tables anchored when a paragraph move is ambiguous (#229)#256

Merged
JSv4 merged 1 commit into
mainfrom
claude/issue-229-review-9dkzu3
Jul 4, 2026
Merged

fix(diff): keep tables anchored when a paragraph move is ambiguous (#229)#256
JSv4 merged 1 commit into
mainfrom
claude/issue-229-review-9dkzu3

Conversation

@JSv4

@JSv4 JSv4 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Closes #229.

Problem

When a reviewer moves a paragraph across a table boundary and a second reviewer edits a disjoint cell of that table, DocxDiff.Consolidate collapsed the whole table to a block-level conflict instead of composing the two edits independently.

Root cause is in the pairwise aligner, not the composite merger. A reorder like [A, table, B][A, B, table] is genuinely ambiguous:

  • "paragraph B moved up past the table", or
  • "the table moved down past B"

Both cost exactly one relocation and round-trip identically in a two-way Compare. IrBlockAligner's LIS spine has two equal-length solutions here and the patience-sort tie-break arbitrarily picked the one that relocates the table. That spurious table-move then collides — in the N-way merge — with the second reviewer's disjoint cell edit, and IrCompositeMerger falls back to a whole-table block conflict (BaseWins keeps the base table; the cell edit is recorded in the conflict rather than composed per-cell). No data loss, and reject ≡ base always held — it was a precision/quality limitation.

Proof it existed

IrCompositeTableMoveBoundaryTests reproduces the issue's exact sketch (base paragraph A + 2×2 table + paragraph B; Alice moves B before the table; Bob edits cell(1,1)). Before the fix the test failed on all three conflict policies with a tbl:…-anchored DocxDiffConflict (Alice's empty competitor + Bob's whole-table d BOB competitor) — matching the issue verbatim.

Fix

A structural-anchor tie-break in IrBlockAligner: among equal-length spines, keep the most non-paragraph blocks (tables / section breaks / opaque blocks) anchored and relocate the lighter paragraph instead.

  • Implemented as a maximum-weight longest-increasing-subsequence where each candidate weighs BIG + (structural ? 1 : 0) with BIG = candidates.Count + 1. Because BIG dominates any achievable bonus total, cardinality stays the primary key — the number of relocated blocks is unchanged; the bonus is a pure tie-break.
  • Runs O(k log k) via a Fenwick prefix-max and is fully deterministic.
  • Guarded so it fires only when the plain patience-sort LIS relocated a structural block — the paragraph-only common path is byte-identical (zero churn).

This fixes the root cause at the aligner (also yielding cleaner two-way markup — a paragraph moves, not a whole table) rather than patching the composite merger.

Result

The paragraph move and the disjoint cell edit now compose independently: conflicts == 0, a native/lowered paragraph move plus per-cell table compose, reject ≡ base, no content loss — the issue's acceptance criteria.

Testing

  • New IrCompositeTableMoveBoundaryTests (all three ConflictResolution policies) — red before, green after.
  • Base two-way parity holds at 179/179 (IrParityScoreboardTests).
  • Full suite green: 2606 passed / 0 failed / 3 skipped.
  • Release build (TreatWarningsAsErrors) and WASM_BUILD compile clean; no warnings from the changed file.

Files

  • Docxodus/Ir/Diff/IrBlockAligner.cs — the tie-break (guard + weighted LIS).
  • Docxodus.Tests/Ir/Diff/IrCompositeTableMoveBoundaryTests.cs — regression test.
  • CHANGELOG.md, docs/architecture/ir_diff_engine.md — documentation.

No public API change, so no WASM/npm/python wrapper ripple was needed.

🤖 Generated with Claude Code


Generated by Claude Code

)

A reorder can read equally as relocating a light paragraph or a heavy
structural block: [A, table, B] -> [A, B, table] is both "paragraph B moved
up past the table" and "the table moved down past B". IrBlockAligner's LIS
spine arbitrarily picked the reading that relocated the TABLE. A two-way
Compare round-trips either way, but in an N-way Consolidate the spurious
table-move collided with a second reviewer's disjoint table-cell edit and
collapsed the whole table to a block-level conflict — the cell edit was
surfaced in the conflict instead of composed per-cell (no data loss;
reject == base always held).

Add a structural-anchor tie-break: among equal-length spines, keep the most
non-paragraph blocks (tables / section breaks / opaque) anchored and relocate
the lighter paragraph. Implemented as a maximum-weight longest-increasing-
subsequence (cardinality stays the primary key, so the relocation count is
unchanged) that fires ONLY when the plain patience-sort LIS relocated a
structural block — the paragraph-only common path is byte-identical.

Now the paragraph move and the disjoint cell edit compose independently
(conflicts == 0, native/lowered paragraph move + per-cell table compose,
reject == base). Base two-way parity holds at 179/179; full suite green.

Adds IrCompositeTableMoveBoundaryTests (all three conflict policies).
@JSv4
JSv4 merged commit 4294870 into main Jul 4, 2026
12 checks passed
@JSv4
JSv4 deleted the claude/issue-229-review-9dkzu3 branch July 4, 2026 23:53
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.

feat(diff): composite — paragraph move crossing a table boundary contests the whole table block

2 participants