Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions extradoc/src/extradoc/diffmerge/content_align.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
75 changes: 65 additions & 10 deletions extradoc/tests/diffmerge/test_table_flank_pinning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]]),
Expand All @@ -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"]]),
Expand All @@ -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:
Expand Down Expand Up @@ -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 = [
Expand Down