diff --git a/extradoc/src/extradoc/diffmerge/content_align.py b/extradoc/src/extradoc/diffmerge/content_align.py index 74a6bf4..79a955b 100644 --- a/extradoc/src/extradoc/diffmerge/content_align.py +++ b/extradoc/src/extradoc/diffmerge/content_align.py @@ -967,8 +967,12 @@ def align_content( prefix_alignment = _dp_align(prefix_base, prefix_desired) # Table-flank pinning: force the paragraphs immediately adjacent to each - # matched table pair to be matched (see module docstring). - prefix_alignment = _pin_table_flanks(prefix_alignment, prefix_base, prefix_desired) + # matched table pair to be matched (see module docstring). ``pre_pins`` + # are threaded through so flank pins can never override an unambiguous + # exact-text anchor established above. + prefix_alignment = _pin_table_flanks( + prefix_alignment, prefix_base, prefix_desired, pre_pins + ) # Positional fallback: promote unmatched same-kind elements in 1:1 gaps prefix_alignment = _positional_fallback( @@ -1021,6 +1025,7 @@ def _pin_table_flanks( alignment: ContentAlignment, base: list[ContentNode], desired: list[ContentNode], + pre_pins: list[tuple[int, int]] | None = None, ) -> ContentAlignment: """Post-process a DP alignment to enforce the table-flanking invariant. @@ -1034,19 +1039,33 @@ def _pin_table_flanks( (the API rejects deletion of a table-adjacent paragraph) and unnecessary churn. + ``pre_pins`` are the unambiguous exact-text (and API-uncreatable-element) + anchors already established by ``_pre_pin_stable_anchors`` before the + main DP ran. They are strictly higher confidence than a table-flank + guess and must never be clobbered by one: a flank pin is only a + heuristic ("the paragraph next to a table is *probably* the same + structural slot"), whereas a pre-pin is a proven unique correspondence. + They are folded into the same anchor set used by the existing + conflict-resolution loop below, at top priority — conflicts are always + resolved by dropping the contributing *table pair* (and its flanks), + never a pre-pin. + This function: 1. Collects the existing table pairs from the DP alignment. 2. For each pair, forces pins on the pre- and post-flank paragraphs (when in range and both paragraphs). - 3. Resolves conflicts (pins disagreeing on the same base or desired - index) by dropping the lower-similarity table pair. - 4. Ensures anchors are monotonic in both base and desired indices. - 5. Re-runs the DP on each gap between consecutive anchors when an + 3. Adds the (immovable) ``pre_pins`` to the anchor set. + 4. Resolves conflicts (pins disagreeing on the same base or desired + index) by dropping the lower-similarity table pair — pre-pins are + never eligible for dropping. + 5. Ensures anchors are monotonic in both base and desired indices. + 6. Re-runs the DP on each gap between consecutive anchors when an existing match would straddle an anchor; otherwise keeps the existing gap matches. - 6. Rebuilds the final alignment (matches + deletes + inserts + cost). + 7. Rebuilds the final alignment (matches + deletes + inserts + cost). """ + pre_pins = pre_pins or [] # Fast path: no tables at all. table_pairs_all: list[tuple[int, int]] = [ (m.base_idx, m.desired_idx) @@ -1068,8 +1087,9 @@ def _pair_sim(bi: int, di: int) -> float: active_pairs: list[tuple[int, int]] = list(table_pairs_all) # Bounded by O(#tables) iterations. for _ in range(len(table_pairs_all) + 1): - # Compute anchors = table_pairs union flank_pins. - anchors_set: set[tuple[int, int]] = set() + # Compute anchors = pre_pins (immovable) union table_pairs union + # flank_pins. + anchors_set: set[tuple[int, int]] = set(pre_pins) for bi, di in active_pairs: anchors_set.add((bi, di)) # Pre-flank @@ -1165,7 +1185,7 @@ def _pair_sim(bi: int, di: int) -> float: return alignment # Final anchors, sorted by base index (monotonic in both axes by construction). - anchors_set = set() + anchors_set = set(pre_pins) for bi, di in active_pairs: anchors_set.add((bi, di)) if ( diff --git a/extradoc/tests/diffmerge/test_table_flank_pinning.py b/extradoc/tests/diffmerge/test_table_flank_pinning.py index 16cde4b..f2135f5 100644 --- a/extradoc/tests/diffmerge/test_table_flank_pinning.py +++ b/extradoc/tests/diffmerge/test_table_flank_pinning.py @@ -239,7 +239,13 @@ def test_adjacent_tables_sharing_flank() -> None: def test_unequal_table_counts_one_deleted() -> None: - """Base has 2 tables, desired has 1 — only the remaining pair gets pinned.""" + """Base has 2 tables, desired has 1 — only the remaining pair gets pinned. + + "Outro" is exact-text-unique in both base and desired, so the exact-text + pre-pin (base idx 4 <-> desired idx 2) wins over the table-flank + heuristic that would otherwise force "Middle" (base idx 2, unrelated + text) onto "Outro" — the pre-pin gives a strictly better alignment. + """ base_els = [ make_para_el("Intro"), make_table_el([["keep1", "a"]]), @@ -258,17 +264,22 @@ def test_unequal_table_counts_one_deleted() -> None: # The preserved table pair and its flanks should match. _assert_matched(a, 0, 0) # Intro _assert_matched(a, 1, 1) # table keep1 - # Middle+dropped table+Outro in base must resolve to Outro in desired. - # The post-flank of kept table in base is "Middle"; the post-flank in - # desired is "Outro". These will be pinned together (flank invariant). - _assert_matched(a, 2, 2) - # Dropped table (base idx 3) and base idx 4 (Outro) must be deleted. + # "Outro" (base idx 4) exact-matches "Outro" (desired idx 2) directly. + _assert_matched(a, 4, 2) + # "Middle" (base idx 2) and the dropped table (base idx 3) are deleted. + assert 2 in set(a.base_deletes) assert 3 in set(a.base_deletes) - assert 4 in set(a.base_deletes) def test_unequal_table_counts_one_inserted() -> None: - """Base has 1 table, desired has 2 — only existing pair is pinned.""" + """Base has 1 table, desired has 2 — only existing pair is pinned. + + "Outro" is exact-text-unique in both base and desired (at desired idx 4, + after the newly-inserted heading + table), so the exact-text pre-pin + wins over the table-flank heuristic that would otherwise force it onto + the new "Middle new" heading right after the kept table. The new + heading + table are simply inserts; "Outro" is left untouched. + """ base_els = [ make_para_el("Intro"), make_table_el([["keep", "a"]]), @@ -286,8 +297,10 @@ def test_unequal_table_counts_one_inserted() -> None: a = align_content(_nodes(base_els), _nodes(desired_els)) _assert_matched(a, 0, 0) _assert_matched(a, 1, 1) - # Post-flank of the kept table pins (2, 2). - _assert_matched(a, 2, 2) + # "Outro" (base idx 2) exact-matches "Outro" (desired idx 4) directly. + _assert_matched(a, 2, 4) + assert 2 in set(a.desired_inserts) # "Middle new" heading + assert 3 in set(a.desired_inserts) # new table def test_table_at_start_no_preflank() -> None: @@ -402,6 +415,48 @@ def test_no_tables_at_all_noop() -> None: _assert_matched(a, 2, 2) +def test_flank_pin_never_overrides_exact_text_pre_pin() -> None: + """A table-flank pin must never clobber an unambiguous exact-text pin. + + Regression test for the "III. FEJEZET" bug: a table's row count changes + AND a brand-new heading is inserted right after the table in `desired`, + while the *original* post-flank heading (unchanged text) is still present + further downstream in `desired`, unambiguously pre-pinned there by + ``_pre_pin_stable_anchors``. The naive table-flank post-processor used to + force the post-flank paragraph to match the new adjacent heading, + clobbering the correct far-away exact-text match. + """ + base_els = [ + make_para_el("Opening paragraph"), + make_table_el([["x", "y"], ["z", "w"], ["p", "q"]]), + make_para_el("STABLE HEADING TEXT UNIQUE"), + make_terminal_para(), + ] + desired_els = [ + make_para_el("Opening paragraph"), + make_table_el( + [["x", "y"], ["z", "w"], ["p", "q"], ["new1", "new2"], ["new3", "new4"]] + ), + make_para_el("BRAND NEW HEADING INSERTED"), + make_para_el("Some other stable filler text"), + make_para_el("STABLE HEADING TEXT UNIQUE"), + make_terminal_para(), + ] + a = align_content(_nodes(base_els), _nodes(desired_els)) + # The exact-text pre-pin must win: base idx 2 ("STABLE HEADING TEXT + # UNIQUE") must match desired idx 4, NOT be clobbered into desired idx 2 + # (the new heading) by the table-flank heuristic. + _assert_matched(a, 2, 4) + _assert_not_deleted(a, 2) + # The new heading must be a genuine insert, not a forced match. + assert 2 in set(a.desired_inserts), ( + "desired idx 2 (brand new heading) should be an insert, " + "not force-matched to base idx 2" + ) + # The table pair itself should still be pinned. + _assert_matched(a, 1, 1) + + def test_three_tables_all_flanks_rewritten() -> None: """Three tables with all flanks completely rewritten.""" base_els = [