Skip to content

Add the line-chunked diff (CRDT step 7) - #138

Open
pedersen wants to merge 1 commit into
mainfrom
worktree-crdt-line-chunked-diff
Open

Add the line-chunked diff (CRDT step 7)#138
pedersen wants to merge 1 commit into
mainfrom
worktree-crdt-line-chunked-diff

Conversation

@pedersen

@pedersen pedersen commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Turns an externally edited file back into a minimal set of operations without
handing a whole note to myersDiff. The plan asks for this one to be read
closely, so the two things I'd most want checked are called out below.

The algorithm

Lines are aligned first; myersDiff is called only within a changed region.

The part that makes the motivating case cheap rather than merely smaller:
lines are keyed on their content with the terminator excluded. A CRLF
round-trip then aligns every line, and each refinement sees one line instead of
the note. If lines were keyed with their terminators, every line would differ,
alignment would find nothing, and we would be back to a whole-note diff — the
6.4 GB case.

Concretely: a 500-line file changing line endings produces a script that
touches 499 characters, one per carriage return.

Line keys are assigned below the surrogate block, so a key is never half a
pair, and they wrap rather than overflow. A collision costs alignment quality
and never correctness — every aligned pair is compared in full and refined if
it differs, which is the same path a terminator change already takes.

A removal immediately followed by an insertion is treated as one changed region
and refined together, so a modified block does not become delete-all +
insert-all at region scale.

Two things worth your attention

1. runInTransaction does not do what Decision 6 says it does.

The design states: "Without it, reconciliation is not atomic, and a crash
partway through leaves a half-applied edit that Decision 5's write ordering
assumes cannot exist."

That isn't what the library provides. TransactionManager.run calls commit()
in a finally, so a throw part-way through the script still flushes the
operations already registered — there is no rollback. It also does not merge
the script into a single Change; two replaced lines are still four
operations.

What it does provide is batching: the changes are created together at commit
and surface as one update notification, so nothing downstream observes a
note mid-reconcile. The tests now pin that, and the doc comment says it
plainly.

The "never half applied" property is real, but it comes from computing the
whole script before the first mutation
, not from the transaction. That's
tested directly.

I have not amended the design — it's accepted, and this is your call. If you
want Decision 6's wording corrected to match, say so and I'll do it as a
separate docs PR.

2. The two-replica test is the one that matters.

device B's insertion is not discarded by A's reconcile is the test a
single-replica suite cannot write, and the one a replace-all implementation
passes convergence on while silently failing. There are three variants: an
appended line, an edit inside the same line, and a line-ending reconcile
running concurrently with an edit.

Tests

41 tests. Beyond the plan's named cases:

  • Round-trip property test — 300 randomised before/after pairs, asserting
    the script applied to the old text reproduces the new one exactly. Offsets
    are the fragile part here: every edit is expressed against the old text, so a
    wrong shift produces a plausible-looking script that reconstructs the wrong
    string. This is what caught that class of bug during development.
  • Cheapness — line-ending changes in both directions, and
    trailing-whitespace stripping, asserted by counting the code units the script
    touches rather than by timing.
  • Astral-plane characters — an emoji edited beside, inserted, and removed;
    a note that is entirely astral characters; and a 200-line emoji file changing
    line endings, which is where a naive line key would split a surrogate pair.
  • Minimality — an untouched line is never rewritten, a one-character
    insertion costs one character.

line_chunked_diff.dart at 100% line coverage; tree at 98.61%.

No user-facing change, so docs/manual-test-plan.md is untouched: no cases
added, changed, or invalidated.

🤖 Generated with Claude Code

Turns an externally edited file back into a minimal set of operations
without handing a whole note to myersDiff.

Minimality is the rule this exists to keep. Deleting everything and
inserting the new text converges and passes a two-replica test, and is
catastrophically wrong: it tombstones every element another device might
concurrently be editing, so every concurrent remote insertion is discarded.
Nothing about it looks broken until a second device exists, which is why the
test that matters here builds two replicas and asserts B's insertion
survives A's reconcile.

But myersDiff cannot be handed the note either. It trims the common prefix
and suffix and then runs with no size guard, snapshotting the frontier once
per edit-distance step — memory proportional to the product of the edit
distance and the text length. The trigger is a dispersed edit rather than a
large note, and the ordinary one is a line-ending change: every line
differs, prefix trimming stops at the first terminator, and the edit
distance becomes the line count.

So lines are aligned first and myersDiff is called only within a changed
region. Lines are keyed on their content with the terminator excluded, which
is what makes the motivating case cheap rather than merely smaller: a CRLF
round-trip aligns every line, and each refinement then sees one line instead
of the note. A 500-line file changing line endings produces a script that
touches 499 characters.

Line keys are assigned below the surrogate block, so a key is never half a
pair, and they wrap rather than overflow — a collision costs alignment
quality and never correctness, because every aligned pair is still compared
in full and refined if it differs.

The script is computed in full before the first mutation, and that ordering
is what keeps a note from being left half edited. runInTransaction cannot
provide it: its commit runs in a finally, so a throw part-way still flushes
what was registered. What the transaction does provide is batching — the
changes are created together at commit and surface as one update
notification — and the tests now pin that rather than an atomicity the
library does not offer.

Attribution is honest by construction: synthesized operations carry this
device's peerID, because we genuinely do not know who made the external
edit.

No user-facing change; docs/manual-test-plan.md is untouched, with no cases
added, changed, or invalidated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Coverage after merging worktree-crdt-line-chunked-diff into main will be

98.51%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
lib
   app.dart96.61%100%100%96.61%142–143
   startup_options.dart100%100%100%100%
   cli_output_io.dart0%100%100%0%10–11, 9
   cli_output_stub.dart0%100%100%0%5
lib/about
   about_screen.dart98.66%100%100%98.66%94–95
lib/commands
   app_commands.dart100%100%100%100%
   app_menu_bar.dart100%100%100%100%
   app_shortcuts.dart97.22%100%100%97.22%161
   text_editing_commands.dart100%100%100%100%
   pending_saves.dart100%100%100%100%
lib/engram
   engram.dart100%100%100%100%
   engram_repository.dart98.33%100%100%98.33%311, 49
   engram_scope.dart100%100%100%100%
   engram_startup_gate.dart100%100%100%100%
   repository_scope.dart100%100%100%100%
   asset_engram_store.dart98.15%100%100%98.15%92
   engram_store.dart100%100%100%100%
   built_in_engrams.dart100%100%100%100%
   desktop_folder_adoption.dart90%100%100%90%70
   engram_file_ops.dart100%100%100%100%
   metadata.dart100%100%100%100%
   id.dart100%100%100%100%
   container_resolver.dart100%100%100%100%
lib/engram/crdt
   app_data_source.dart100%100%100%100%
   app_data_resolver_io.dart100%100%100%100%
   app_data_resolver_stub.dart100%100%100%100%
   metadata_db_stub.dart100%100%100%100%
   store_exceptions.dart100%100%100%100%
   catalog.dart100%100%100%100%
   metadata_db_io.dart100%100%100%100%
   catalog_io.dart100%100%100%100%
   line_chunked_diff.dart100%100%100%100%
   identity_map.dart100%100%100%100%
   identity_merge.dart100%100%100%100%
   note_document_io.dart100%100%100%100%
   identity_map_io.dart100%100%100%100%
   schema.dart100%100%100%100%
lib/engram/fs
   engram_location.dart100%100%100%100%
   fs_store_io.dart100%100%100%100%
   fs_store_stub.dart100%100%100%100%
lib/engram/ui
   engram_browser.dart98.06%100%100%98.06%195, 197, 199–200, 204–205, 399, 423, 917
   browser_preferences.dart92.31%100%100%92.31%26
   document_edit_controller.dart100%100%100%100%
   engram_switcher.dart93.81%100%100%93.81%102–105, 212, 216
   file_tree.dart100%100%100%100%
   file_tree_node.dart100%100%100%100%
   file_viewer.dart100%100%100%100%
   folder_picker.dart100%100%100%100%
   help_overlay.dart90.91%100%100%90.91%100–102, 72
   file_path_breadcrumb.dart100%100%100%100%
   markdown_editor_pane.dart100%100%100%100%
   markdown_reader.dart100%100%100%100%
   find_in_page.dart100%100%100%100%
   markdown_source_editor.dart100%100%100%100%
lib/settings
   app_settings_controller.dart100%100%100%100%
   settings_scope.dart75%100%100%75%45–46
   device_settings.dart100%100%100%100%
   settings_store.dart98.04%100%100%98.04%206
   settings_screen.dart100%100%100%100%
   engram_pane.dart100%100%100%100%
   housekeeping_pane.dart100%100%100%100%
   setting_control.dart76.47%100%100%76.47%100, 124, 86, 93
   settings_controls.dart100%100%100%100%
   settings_registry.dart85.09%100%100%85.09%215–216, 237, 246–247, 259, 31, 36–37, 41, 52–55, 64, 82–83
   settings_shell.dart99.44%100%100%99.44%244
lib/theme
   app_settings.dart100%100%100%100%
   app_theme.dart90.91%100%100%90.91%14
   design_language.dart100%100%100%100%
lib/widgets
   app_scaffold.dart100%100%100%100%
lib/window
   window_state_stub.dart66.67%100%100%66.67%13

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