Skip to content

feat(diff): DocxDiff N-way composite / Consolidate#228

Merged
JSv4 merged 43 commits into
mainfrom
feat/diff-composite
Jun 14, 2026
Merged

feat(diff): DocxDiff N-way composite / Consolidate#228
JSv4 merged 43 commits into
mainfrom
feat/diff-composite

Conversation

@JSv4

@JSv4 JSv4 commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the last gap between the new IR-based comparison engine (DocxDiff) and the original WmlComparerN-way composite / Consolidate — and goes beyond it. Merges N reviewers (each diffed against one shared base) into a single tracked-changes document, with per-reviewer attribution, a composite edit-script-as-data, an attributed revision list, and a structured conflict report.

  • Public API (Docxodus/DocxDiff.cs, DocxDiffConsolidate.cs): Consolidate / GetConsolidatedRevisions / GetConsolidatedEditScriptJson / GetConflicts. N reviewers, no cap (supports >3-way / N sets of changed docs). Conflict policy BaseWins (default) / FirstReviewerWins / StackAll.
  • Token-granular sub-block merge (the differentiator): two reviewers editing different sentences of the same paragraph compose into one attributed paragraph; only genuinely overlapping token spans become structured, anchor-addressable conflicts. Exact (all reviewers share the base token coordinate space), not heuristic.
  • Pipeline: IrCompositeMergerIrCompositeScriptIrCompositeMarkupRenderer / IrCompositeRevisionRenderer / IrCompositeScriptJson. Rendering reuses IrMarkupRenderer via an additive RightSource + per-op AuthorOverride extension — two-way Compare output is byte-unchanged.
  • Rippled through the full stack: WASM (DocxDiffBridge), stdio host (Dispatcher.cs), npm (docxDiffConsolidate*), docx-scalpel (docx_diff_consolidate*); CHANGELOG, CLAUDE.md, and docs/architecture/ir_diff_engine.md updated.

Parity outcome (a notable finding for the D4 default-engine decision)

A scoreboard over the 84 legacy WmlComparer.Consolidate cases reports 84/84 reproduce-PASS, 0 deviations, 0 fails. It surfaced that legacy Consolidate is a juxtaposition/triage tool that never inline-merges — it appends each reviewer's labeled copy in colored boxes, even for a single reviewer. So reproduction is measured by sound semantics (single-reviewer accept ≡ reviewer char-exact on all 74; multi-reviewer added-token composition), and legacy's whole-corpus box shape is the deliberate supersession: the IR engine doesn't merely match Consolidate, it replaces it with true merging.

Documented v1 limitations (intentional, surfaced in docs)

  • Note-scope (footnote/endnote) diffs not yet merged in the composite (tripwire Debug.Assert guards against silent drop) — follow-on.
  • Cross-reviewer move/split-vs-edit collisions handled at block granularity (block-level conflict under the policy).
  • A reviewer changing both a paragraph's text and its properties (pPr) routes that block to the conflict path (no silent pPr drop).
  • Conflict spans reported as base token indices + anchor (not char offsets) in v1.

Test Plan

  • Full .NET suite: 2073 passed / 0 failed / 1 pre-existing skip
  • Release build (-c Release, warnings-as-errors): clean
  • WASM cross-compile (./scripts/build-wasm.sh): clean
  • npm tsc --noEmit + npm run build: clean
  • Consolidate parity scoreboard: 84/84 reproduce-PASS, ratchet has teeth (fault-injection verified)
  • Composite fuzzer (3/4/5-way) + apply-verifier own-oracle; determinism / reviewer-order-independence / N=5 / degenerate cases
  • Playwright consolidate spec added — run in CI / cd npm && npm test
  • Word/LibreOffice manual spot-check of a generated multi-reviewer redline (reviewer's call)

JSv4 added 30 commits June 13, 2026 10:37
…differ convention); real-differ tests + delete-conflict policy coverage
…ypes

Add ConflictResolution enum, DocxDiffReviewer/DocxDiffConsolidateSettings/
DocxDiffConflictCompetitor/DocxDiffConflict/DocxDiffConsolidatedRevision types,
and docx_diff_consolidate/get_conflicts/get_consolidated_revisions/
get_consolidated_edit_script module functions — mirroring the existing
docx_diff_* surface and the npm/WASM wrappers added in T6.3.
JSv4 added 9 commits June 13, 2026 15:00
…r fixtures

The spec passed the same revised doc for both reviewers (identical edits =
consensus, not a conflict), so GetConflicts correctly returned 0 and the
length>0 assertion failed. Add three minimal fixtures where two reviewers
change the same word differently (quick -> SLOW vs FAST), a real cross-reviewer
token-overlap conflict, and point the spec at them.
…==base); multi-reviewer table edits route to recorded conflict (no silent drop); doc table limitation
…perlink-internal edits round-trip (accept==right, reject==left)
…reserve adjacent distinct same-target links)
…placeText (B3)

ReplaceText replaces a whole block's visible text but already preserves
zero-width, semantically-significant inline markers (bookmark/comment/perm
ranges) across the replace. That preserved set covered only bare paragraph-
child markers; a footnote/endnote reference lives inside a <w:r>, so it slipped
through and was silently deleted — orphaning the note definition.

This surfaced via the consolidate footnotes-survive battery (two reviewers built
with ReplaceText lost the base footnote on accept). Diagnosis confirmed the
base-engine DocxDiff 2-way diff round-trips a note-bearing edit cleanly
(accept == right, reject == left, footnote survives), so the root cause is in
DocxSession.ReplaceText, not the diff engine.

Fix: recognize note-reference-only runs (a <w:r> whose only non-rPr content is a
footnote/endnote reference) as preserved markers. The accept path re-attaches
them around the replacement; the tracked path keeps them out of the w:del/w:ins
so they survive on both accept and reject, in their original leading/trailing
position. Bare-child markers are untouched.

Tests: DS314 (footnote accept), DS315 (footnote tracked: accept==edited,
reject==base, ref survives both), DS316 (endnote accept) — red before, green
after. Full suite 2094/0/1; 179/179 parity floors and consolidate/composite
suites unchanged.
…ite (no silent content loss on accept); doc v1 limitation
…tural-op fuzz coverage; contested-relocation tests + competitor text
@JSv4

JSv4 commented Jun 14, 2026

Copy link
Copy Markdown
Owner Author

LibreOffice manual verification + hardening pass

Ran an exhaustive LibreOffice render verification of the consolidate feature: a battery of multi-reviewer scenarios across DOCX features (headings, bullet/numbered lists, tables, bold/italic/color runs, hyperlinks, footnotes) × merge behaviors (disjoint compose, sub-sentence compose, consensus, all 3 conflict policies, block insert/delete, 5 authors, kitchen-sink), each rendered through LibreOffice and adversarially inspected. The happy path (incl. 3-author kitchen-sink and 5-author attribution) renders correctly with per-author colors and reject ≡ base. The pass also surfaced and fixed several real bugs:

Fixed in this PR (new composite regressions):

  • StackAll delete-vs-edit duplicated the paragraph on reject → emit one base-anchored op (reject ≡ base).
  • Multi-reviewer table edits were silently dropped (phantom consensus) → now a recorded block-level conflict; generalized to all non-paragraph modifies (section breaks/opaque).
  • Reviewer move/merge silently lost content on accept (move-destination & merge ops were dropped) → composite now lowers a reviewer's move/split/merge to insert/delete (content preserved, reject ≡ base, accept applies the change); two-reviewers-move-same-block records a placement conflict. Added structural-op composite fuzz coverage (180 cases).

Fixed in this PR (pre-existing base-engine bugs surfaced by the battery):

  • Editing text inside a w:hyperlink broke reject ≡ left (anchor doubled/tripled) — also affected plain 2-way Compare. Hyperlink now emitted once across overlapping token ops; adjacent distinct same-target links preserved (coalesce by source-wrapper identity).
  • DocxSession.ReplaceText dropped footnote/endnote references in the edited paragraph → note refs now preserved across accept/reject.

Documented v1 limitations (in ir_diff_engine.md + CHANGELOG): consolidate renders a reviewer's moves/merges as ins/del (not native w:moveFrom/w:moveTo); multi-reviewer same-table edits surface as a block conflict (per-cell composition is a follow-on); note-content merging across reviewers is a follow-on.

Gates: full .NET suite 2114/0/1, parity scoreboard 179/179 (unchanged), Release + WASM + tsc clean.

JSv4 added 4 commits June 14, 2026 04:11
…viewer moves render as w:moveFrom/w:moveTo (per-reviewer author, globally-namespaced move ids); collisions stay lowered conflicts
… move misclassified across a merge); two-reviewer move round-trip tests
…s-reviewer cell edits compose inline; same-cell edits conflict per policy; multi-author table rendering
…le-bucket whole-row re-registration); image-in-composed-cell test; BaseAnchor block-conflict doc
@JSv4

JSv4 commented Jun 14, 2026

Copy link
Copy Markdown
Owner Author

Pre-D4 follow-ons landed: native move + per-cell table composition

Both deferred composite limitations are now implemented (composite-path only; base 2-way engine untouched — parity scoreboard stays 179/179), each TDD'd + reviewed + LibreOffice-smoke-verified.

Native move composition — a single reviewer's non-colliding move now renders as a native w:moveFrom/w:moveTo (per-reviewer author, globally-namespaced move ids) instead of being lowered to del/ins. Colliding moves (move-vs-edit same block; two reviewers move the same block; a merge that consumes a moved paragraph) stay lowered to a recorded conflict — no silent loss, reject ≡ base. Gated on DetectMoves.

Per-cell table composition — disjoint cross-reviewer table-cell edits now compose inline into one table with correct per-cell author attribution; only same-cell edits by 2+ reviewers become a recorded conflict (per policy, at a cell-paragraph anchor). The cell-paragraph composition recurses into the existing token/block merge machinery. Fallbacks to a whole-table block conflict (no silent loss): MovedRow, column-count change. Multi-author cell media is registered per-cell (verified with a byte-level image-in-cell test).

Smoke renders (LibreOffice): native move shows as a green move pair + a separate blue edit; disjoint table cells show one grid with orange/blue per-cell tracked changes; accept applies both, reject restores base.

Gates: full .NET suite 2168/0/1, parity 179/179 + Consolidate parity 84/84, Release + WASM clean. Remaining D4-adjacent notes (documented): a paragraph move that crosses a table boundary can contest the table block (no loss; reject≡base); pure tcPr/gridSpan/vMerge-only table changes aren't modeled by the IR; footnote-content merging across reviewers remains a follow-on.

@JSv4

JSv4 commented Jun 14, 2026

Copy link
Copy Markdown
Owner Author

Tracking issues opened for the documented D4-adjacent follow-ons (all are no-silent-loss, reject≡base limitations, not blockers):

@JSv4

JSv4 commented Jun 14, 2026

Copy link
Copy Markdown
Owner Author

Two more follow-up issues opened (both no-silent-loss, reject≡base):

Full follow-on set: #229, #230, #231, #232, #233.

@JSv4
JSv4 merged commit 4ae3a5a into main Jun 14, 2026
11 of 12 checks passed
JSv4 added a commit that referenced this pull request Jul 12, 2026
…rlink (#232) (#272)

When a w:hyperlink-wrapped run was involved in a redline and its anchor text
was fully rewritten with no shared token (same href), the IR markup renderer
emitted the base's single w:hyperlink as two elements sharing the same r:id —
a pure w:del-link of the old text followed by a pure w:ins-link of the new
text. Text, target, and accept/reject round-trip were all correct, but the
raw markup diverged from the source's single link (1 -> N), churning any tool
that diffs the XML or counts w:hyperlink elements.

This was the residual normalization left by the B1 fix (#228): its coalescer
merged link fragments only when at least one carried a plain (Equal) run — a
proxy for "same target" that missed the no-shared-token case. The coalescer
now stamps each fragment with its RESOLVED target (external URI, or #anchor
for an internal link — resolved exactly as IrReader does, via the part
annotation on the retained source tree) and additively merges a same-source
run when every fragment resolves to the same target.

Keying on the resolved target (not the r:id string) is what keeps the WC019
whole-anchor RETARGET split into two links: text AND href change means the
del/ins fragments carry the same r:id string at coalesce time but different
resolved targets, so they must stay separate for the post-assembly r:id remap.

Scope: the base two-way IrMarkupRenderer only; WmlComparer is untouched. B1
invariants (accept == right, reject == left) and the adjacent-distinct-same-
target case are preserved; base two-way parity stays 179/179; full suite green
(2671 passed). Regression coverage: Hyperlink_fully_replaced_same_target_
renders_as_one_hyperlink, Hyperlink_whole_anchor_retarget_stays_two_links, and
an updated Hyperlink_single_token_anchor_no_equal_run_replaced_round_trips.

Co-authored-by: Claude <noreply@anthropic.com>
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