Keyless-list diff: gap-based opcodes instead of positional matching - #103
Keyless-list diff: gap-based opcodes instead of positional matching#103SirHephaistos wants to merge 8 commits into
Conversation
Positional by-index diff of keyless inlined lists turned a row delete into shifted Updates + a wrong-row Remove (WI193). Replace the else branch with an LCS over structural equality: unchanged rows match by subsequence, leftovers pair (in-place field Updates) then Remove/Add. Keyed branch and patch-side index handling unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers WI193: delete-first-row -> single Remove (survivor not flagged), duplicate rows -> one Remove by multiplicity, in-place edit -> single field-level Update. Each round-trips through patch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…etion The index-based patcher can overwrite-by-index or append but has no insert-at-index, so emitting a clean Add for a mid-list insert would not round-trip. Restrict the LCS path to pure deletions (target is an ordered subsequence of source) where clean Removes round-trip via descending-index application; everything else (insert/reorder/edit/mixed) keeps the positional overwrite+append encoding the patcher can replay. Fixes WI193 (row delete no longer flags survivors) without regressing insert round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the pure-deletion-only hybrid with a full LCS gap diff: per gap between matched anchors, pair rows as replaces (field Updates), excess source rows as Removes, excess target rows as Adds. A combined delete+add (e.g. [P,Q]->[Q,R]) now yields remove P + add R instead of mis-attributed positional Updates. Stays round-trip-safe: clean opcodes only when inserts append (suffix); mid-list inserts fall back to positional overwrite+append. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gap pairing two leftover rows was always a field-level Update, so a deleted row replaced in place by an unrelated new row read as an edit (WI193 combined case). Pair as an Update only when the rows are similar (>= half their fields unchanged); dissimilar rows become Remove + Add. Such an Add must append, so a dissimilar pair outside the trailing gap falls back to the positional encoding for round-trip safety. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates linkml_runtime’s semantic diffing for keyless inlined-object lists (lists whose items have no key/identifier slot) to avoid index-based “shift” misattribution by switching to an LCS (longest common subsequence) gap-based diff strategy, while preserving patch round-trip correctness via a positional fallback when mid-list inserts would be required.
Changes:
- Implement LCS-based matching for keyless lists, emitting gap-derived Update/Add/Remove opcodes when patch-safe.
- Add a
rows_similarheuristic to decide when a gap pair should be treated as an in-place edit (field-level Update) vs Remove+Add. - Extend runtime diff/patch regression tests for keyless object lists (WI193) to cover deletes, duplicates, edits, mid-inserts, and dissimilar replacements.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/runtime/src/diff.rs | Rewrites keyless list diffing to use LCS + gap opcode emission with patch-safety fallback; adds row similarity heuristic. |
| src/runtime/tests/diff.rs | Adds/extends regression tests validating opcode shape and diff→patch round-trips for keyless object list scenarios. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address PR review: - rows_similar now counts a null-vs-missing field as equal when treat_missing_as_null, matching LinkMLInstance::equals, so that difference no longer flips a field edit into Remove+Add. - Cap the O(n*m) LCS table (flat usize grid, ~8MB) and fall back to the linear positional diff for lists above the cap, bounding memory/time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
TLDR Repeat-table rows have no unique key, so the diff matched rows by position — deleting or adding a row shifted everything, making the diff flag the wrong rows as edited/removed and lose the "Removed" |
|
Both Copilot points are addressed in a1aaa1d:
The two inline comments above are re-posts of the original review against the pre-fix revision. |
Edit-vs-replace is a UI concern, not the diff engine's: deciding whether a same-position content change is an in-place edit or a delete+add can't be done from row content without a key. Remove the rows_similar guess — the engine now reports a same-position change as a field-level Update; consumers resolve add/remove intent from tracked ops. LCS stays (correct deletes), clean opcodes only when inserts append (else positional). Tests updated: same-position replace is an Update; pure delete/append/reorder/combined unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keyless-list diff: gap-based opcodes instead of positional matching
Diffing a multivalued inlined list whose items have no key/identifier matched
elements by index. Deleting or inserting a row shifted the list, so the diff
mis-attributed the change: surviving rows became
Updates and the wrong row wasremoved; a delete combined with an add read as an edit. (Downstream this broke
asset360 repeat-table row coloring + the removed-rows summary — work item WI193.)
Change
Rewrites the keyless branch of
fn diff(src/runtime/src/diff.rs) into anLCS gap-diff:
equals) gives matched anchors; the runs betweenanchors are gaps.
Update)only when the two rows are similar (
rows_similar: objects sharing ≥ halftheir fields; scalars always). Dissimilar pairs and leftover rows become
Remove(source) +Add(target). This distinguishes a real field edit from adelete+add of unrelated rows.
applies index Removes in descending order — it has no insert-at-index. So clean
opcodes are emitted only when every insert appends (
clean_safe); a mid-listinsert falls back to the previous positional overwrite+append encoding. Removes
are emitted last (adds append first) so every index stays valid on apply.
The keyed-list branch is unchanged (still matches by identifier).
Behavior delta (keyless lists)
Tests
src/runtime/tests/diff.rsextendsdiff_and_patch_keyless_object_list_shiftswith delete-first, duplicate, in-place edit, mid-insert (positional fallback),
combined delete+append, and same-position dissimilar replace — each asserts the
opcode shape and round-trips through
patch.cargo test -p linkml_runtime,clippy -D warnings,fmt, andstub_gen --checkall clean.