Skip to content

CRDT for documents (2/6): Automerge engine core + full upstream interop parity - #1750

Closed
gearnode wants to merge 67 commits into
automerge/1-prototype-bringupfrom
automerge/2-engine-core-parity
Closed

CRDT for documents (2/6): Automerge engine core + full upstream interop parity#1750
gearnode wants to merge 67 commits into
automerge/1-prototype-bringupfrom
automerge/2-engine-core-parity

Conversation

@gearnode

@gearnode gearnode commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Part of the split of #1657 ("CRDT for documents") into a review stack. Stack index: 2/6. Stacked on #1749.

What this PR does

Pure pkg/automerge + reference-fixture work — no server or frontend code is touched. Native binary encode/decode, rich-text/mark patch derivation, UTF-16-aware text diffing, JS Date/interop parity, sync/branch convergence, culminating in complete V2 interoperability parity with upstream Automerge (Rust core + JS reference implementation).

Review lens

This is the cleanest, most self-contained stage in the whole split — a CRDT library that can be evaluated purely on correctness against upstream Automerge, independent of how it's wired into the app.

Stack

  1. automerge/1-prototype-bringup (CRDT for documents (1/6): bring up Automerge-backed collaboration (prototype) #1749): bring up Automerge-backed collaboration (prototype)
  2. → this PR: Automerge engine core + full upstream interop parity
  3. automerge/3-engine-hardening-refactor: fuzzing, ProseMirror render parity, internal refactor
  4. automerge/4-api-cleanup-perf: public API cleanup + save/load performance
  5. automerge/5-repo-protocol-migration: migrate wire protocol to automerge-repo + cross-instance fan-out
  6. automerge/6-frontend-cutover-legacy-removal: cut the frontend over, remove the legacy protocol

Summary by cubic

Brings the pure-Go pkg/automerge engine to full V2 interoperability parity with upstream Automerge (Rust core and JS), and adds the tooling to audit it. Old behavior: prototype-only coverage with gaps in text, marks/blocks, sync, and patches. New behavior: complete wire/state parity, UTF-16/grapheme correctness, isolation/merge semantics, incremental diffs, compressed saves, and audited cross-language conformance.

Service +11312 -356

Introduces the complete Automerge engine surface in Go: typed scalars; nested map/list/text APIs; stable cursors (including historical); rich-text marks and blocks with correct expansion and unified index space; UTF-16 indexing across length/get/put/insert/delete; grapheme-aware Text.Update; batch hydration; document stats; current-state and before/after diff patches (with mark patches and splice marks); incremental diff cursors; isolate/integrate with derived concurrency actors; incremental save/load (including retained orphans); and DEFLATE-compressed large change chunks. Fixes V2 sync edge cases (first-message, in-flight updates, read-only publication) and adds Bloom false-positive recovery without false-positive susceptibility. Updates the embedded Rust/WASM reference backend and new FFIs to mirror parity features. Includes a ProseMirror rendering fix to materialize text after horizontal rules. Adds PARITY_PLAN.md and expands README.md.

Tests +9268 -9

Adds the differential parity and fuzz suites: core data-model, list range conflicts, counters, metadata and history, isolation, UTF-16 text encoding, rich-text spans/marks/blocks, current-state, diff and incremental patch log, JS interop (including Date), sync convergence (read-only matrix, branching), storage decode/encode, invariants, and randomized concurrency. Includes conformance fixtures and end-to-end parity manifest checks.

Package: automerge-conformance +4012 -1

Extends the JS oracle (oracle.mjs) and adds generate-parity-inventory.mjs plus a pinned parity-mappings.json to audit upstream coverage. Script checks now validate both files.

Package: automerge-benchmark +1424 -0

Adds @probo/automerge-benchmark: a Go/Rust harness and Node driver to compare identical workloads across engines.

Other +39 -1

Adds make targets for parity audits, interop checks, and fuzz duration control; promotes github.com/rivo/uniseg to a direct dependency; links @probo/automerge-benchmark in package-lock.json.

Written for commit c319204. Summary will update on new commits.

Review in cubic

cursoragent and others added 30 commits August 8, 2026 18:15
Concurrent rich-text edits can temporarily place inline text directly
after a horizontal-rule marker. Materialize that text in an implicit
paragraph so projection succeeds without closing collaboration sockets.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Grow the pure-Go engine and the Rust/WASM reference wrapper to cover the
full public Automerge surface required for cross-language parity: typed
scalars, nested map and list objects, deletions, length and increment,
mark and block authoring, historical reads, read-only sync transitions,
missing-dependency inspection, and incremental/empty change handling.

These backends are the foundation for the differential parity suite; the
reference wrapper proxies each new FFI entry point so native behavior can
be compared against upstream Rust for every operation.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Surface the new engine capabilities through the public package: typed
scalar get/put, composite object creation and traversal, list insertion
and indexed access, deletions, length, counters, stable cursors with
cursor-aware splicing, rich-text marks and block authoring, and batch
hydration of nested maps, lists, and text.

The API stays idiomatic Go while exposing the same observable behavior as
the Rust and JavaScript bindings so documents can round-trip unchanged.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Introduce the differential parity harness that pins the Rust 0.10 and
JavaScript 3.4 upstream test inventory and maps each interop-required
entry to an executable Go test. Cover core data-model behavior, sync
invariants, metadata and historical reads, marks and blocks, cursors,
storage decode/encode, conformance against the JavaScript oracle, and
fuzz targets for documents, operations, and rendering.

Each scenario runs the native engine against the Rust/WASM reference so
values, conflicts, heads, and transferred hashes stay identical.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Provide the supporting tooling for the parity effort: an expanded
JavaScript conformance oracle, a generator that inventories the pinned
upstream Rust and JavaScript test corpora with interop classifications,
manual parity mappings, and a native Go/Rust benchmark harness for
comparing identical workloads.

Wire the new fuzz, benchmark, and parity-audit entry points into the
GNUmakefile so the release gate is reproducible.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Record the goal, pinned upstream sources, current coverage baseline, the
required and optional backlogs, and ten workstreams with acceptance
criteria for reaching full Rust/JavaScript interoperability. Update the
package README with the new compatibility checks, fuzz targets, and
benchmark instructions.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The native engine computed a new change's dependencies as the current
document heads only. Upstream Rust additionally appends the author's own
previous change hash whenever it is not already a head, so that direct
causal succession from the author's prior change is always recorded.

Without this rule a put that follows a merge produced a change whose
dependency set (and therefore hash) diverged from the Rust and JavaScript
engines, breaking wire compatibility even though the materialized state
matched. Mirror the upstream computation in both Commit and EmptyCommit,
re-sorting the dependency hashes to preserve canonical encoding order.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add Object.PutObjectAt so callers can replace a list element with a new
composite value through the public API. The native and reference backends
already implement the operation; this surfaces it alongside InsertObject
and PutScalarAt for parity coverage of conflicting list-element objects.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce sixteen upstream automerge 0.10 integration tests from
tests/test.rs against both the native and Rust/WASM reference engines:
conflicting assignments of differing types, nested map and list element
conflicts, concurrent deletion and cross-level updates, updates to
concurrently deleted objects, causal insertion ordering, counter conflict
attachment, complex save/restore, large lists, invalid-index rejection,
mutual sync convergence, and incremental load with a shared head.

Each scenario asserts identical materialized state, causal heads, and
cross-engine reloads. The repeated-list-assignment case surfaced the
change-dependency divergence fixed separately. Map the newly covered
tests in the parity ledger, regenerate the manifest, and update the plan
baseline (interop-required pending 231 to 215).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce five more upstream automerge 0.10 tests from tests/test.rs
against the native and Rust/WASM reference engines: the nth-miscount list
indexing regressions across and within op-tree node boundaries, the
insert-then-overwrite opid regression round-tripped through a reload,
multi-actor rollback leaving the document byte-identical to its
forked-from state, and saving a document whose merged delete references a
fork's actor only through successors.

Map the newly covered tests, regenerate the manifest, and update the plan
baseline (interop-required pending 215 to 210).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce two more upstream automerge 0.10 tests from tests/test.rs: an
empty commit interleaved between real changes that must not corrupt the
save/load round trip, and a document grown across an op-tree node boundary
whose later change transfers to a reloaded peer and still saves, reloads,
and converges to identical heads.

Map the newly covered tests, regenerate the manifest, and update the plan
baseline (interop-required pending 210 to 208).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
A put, delete, or increment on a list element referenced only the winning
value as its predecessor, so concurrent conflicting values survived an
overwrite and diverged from Rust and JavaScript, which reference every
visible value. Reference the full conflict set for sequence puts, object
replacements, deletes, and increments, mirroring the map path.

Increments now follow the upstream rule: they are valid whenever at least
one conflicting value is a counter, they apply to every counter at the
position, and they supersede any conflicting non-counter value. Extend the
supersession machinery accordingly and exclude increment operations from
the visible-value sets they must never join.

Add list-index scalar conflict enumeration (get_all by index) to the
Rust/WASM reference and the native engine, exposed as Object.ScalarsAt,
and rebuild the embedded reference backend.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce list_counter_del from upstream tests/test.rs against the native
and Rust/WASM reference engines: three actors write conflicting counters
and an integer to the same list elements, increments are applied and
merged, and the elements are deleted. Compare the conflicting value sets
at each index and the post-deletion lengths directly against the
reference.

Map the newly covered test, regenerate the manifest, and update the plan
baseline (interop-required pending 208 to 207).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce thirteen upstream synchronization tests from src/sync.rs against
the native and Rust/WASM reference engines: first-response-always-sent,
simultaneous bidirectional exchange, and the read-only sync matrix
(mutual read-only isolation, publisher-to-consumer fan-out, fully
connected and relayed topologies, stale shared heads, duplicate
announcements, and read-only/read-write transitions and toggles).

Each scenario drives the public sync API to quiescence and asserts which
changes each peer accepts, that read-only peers never consume, and that
read-write peers converge. Map the newly covered tests, regenerate the
manifest, and update the plan baseline (interop-required pending 207 to
194).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce five upstream batch_insert.rs tests against the native and
Rust/WASM reference engines through the public hydration API: concurrent
batch-created maps that merge, multiple sequential batch inserts, batch
insertion into a populated map, overwriting a list element with a deeply
nested structure, and concurrent hydrated splices into a shared list.

Each scenario materializes the nested values and compares heads against
the reference. Map the newly covered tests, regenerate the manifest, and
update the plan baseline (interop-required pending 194 to 189).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce load_incremental_change_without_deps_throws from
test_save_load_orphans.rs against the native and Rust/WASM reference
engines: loading a bare change chunk whose dependencies are absent is
rejected by both.

Note in the plan that the remaining orphan tests (retaining orphans across
a document save and discarding them on request) require a native
document-chunk encoder, which the concatenated change-chunk save format
does not yet provide. Update the plan baseline (pending 189 to 188).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Native withheld sync messages in three cases where upstream Rust always
speaks, breaking convergence for empty and read-only peers:

- The mandatory first message for a sync state is now always sent so the
  peer learns our heads even when both sides are empty, matching
  first_response_is_some.
- New local changes are sent even while a previous message awaits
  acknowledgement; the in-flight guard now yields once local heads advance
  past the last sent frontier.
- A peer keeps publishing its own new changes to a read-only peer (which
  simply ignores them on receipt) instead of suppressing them, and it no
  longer stays silent while it still has outstanding needs or the peer has
  requested changes.

Track whether a state has sent its first message and consult requested and
needed hashes in the suppression guards.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
With the native sync message-generation fixes in place, reproduce the two
remaining read-only synchronization tests from src/sync.rs: peers with no
data still exchange a mandatory first message before falling silent, and a
read-only peer that commits a change mid-flight publishes it to its
read-write peer without ever consuming the peer's change.

Map the newly covered tests, regenerate the manifest, and update the plan
baseline (interop-required pending 188 to 186).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce eight upstream rich-text tests from block_tests.rs and text.rs
that assert on the materialized span stream after mark, splice, and block
operations: marks spanning block markers, mark loss on delete-and-insert,
span consolidation across deleted and empty marks, complex block
properties, marks created after insertion, and zero-length mark
consolidation. Each runs identically on the native and Rust/WASM reference
engines and asserts their span output agrees.

Record three native defects surfaced by other reproductions (mark loss on
boundary splice per upstream issue #935, mid-surrogate splice index
rejection, and block-adjacent mark bounds) in the plan; those tests remain
pending until the native engine is fixed.

Update the plan baseline (interop-required pending 186 to 178).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Native mark and splice positions were computed over the text-only element
view, which omits block markers, so any index at or past a block marker
was reported as out of bounds. Upstream Rust uses a unified index space in
which a block marker occupies a single position alongside text characters.

Walk the full visible element sequence for both mark anchoring and text
splicing, and count block markers as length one when resolving positions.
This lets marks and splices address slots adjacent to block markers, as
Rust and JavaScript do.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
With block markers now occupying the unified rich-text index space,
reproduce empty_marks_before_block_marker_dont_repeat_text: empty marks
and text inserted around two block markers do not duplicate text, and both
the native and Rust/WASM reference engines report two block spans followed
by a single text span.

Map the newly covered test, drop the now-fixed defect from the plan's
known-defects note, and update the baseline (interop-required pending 178
to 177).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Rust and JavaScript accept splice offsets that land inside a UTF-16
surrogate pair by advancing the start and deletion end to the following
character boundary. Native instead rejected these edits, diverging on
malformed or stale editor offsets.

Normalize a split start before applying deleteCount and include the whole
character when the deletion end splits one. This matches upstream behavior
without manufacturing invalid UTF-16 text.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce the exact upstream multibyte splice sequence, including starts
and deletion ends inside surrogate pairs, and compare every intermediate
text value against the Rust/WASM reference.

Replace the previous cursor-only mapping, which did not exercise this
behavior, with the exact regression test and remove the now-fixed defect
from the parity plan.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
When a splice deleted the element anchoring a mark boundary, native could
only resolve anchors that remained visible or pointed to another mark.
That dropped expanding marks when replacing exactly the marked text,
diverging from Rust and JavaScript (upstream issue #935).

Resolve deleted anchors through the full sequence: expanding boundaries
include insertions at the former position, while non-expanding boundaries
stay before them. If a head-anchored mark loses its entire original range,
do not apply it to wholly new replacement text. This preserves expanding
formatting for boundary replacements without reviving marks after all
marked text is deleted.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce test_splice_with_mark from upstream block_tests.rs: replacing a
single character exactly at two mark boundaries preserves the expanding
mark on the replacement while dropping the non-expanding mark.

Compare the native span stream directly with Rust/WASM, map the exact
regression, remove the now-fixed issue #935 defect note, and update the
parity baseline (pending 177 to 176).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add a marks accessor to the reference and native engines and surface it as
Text.Marks and Text.MarksAt. The native implementation derives active mark
ranges from the same per-element mark state the span materializer uses:
contiguous runs of an identical name and value are merged, block markers
occupy one position, and marks removed by a null value are excluded,
mirroring upstream Rust's marks().

This lets callers read the effective formatting of a text object, including
at a historical frontier, without recomputing it from spans.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce three upstream mark-reading tests against both engines using the
new marks accessor: removed (null-valued) marks are absent, marks resolved
at a historical frontier match, and inserting text around deleted marked
ranges yields identical active marks.

Record a native defect the reproduction surfaced: a mark with after/both
expansion does not grow to include text spliced at its boundary, which
diverges from Rust and JavaScript in both spans() and marks(). Update the
parity baseline (pending 176 to 173).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce should_handle_lots_of_branching_and_merging from src/sync.rs:
two peers exchange many concurrent changes, a third peer's concurrent
change is merged into one of them, and a final synchronization must
converge both peers to identical heads. The scenario runs on both the
native and Rust/WASM reference engines.

Update the parity baseline (pending 173 to 172).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Extend the conformance data model to store a JavaScript Date in a list as
well as a map, so the existing native round-trip conformance test now
exercises timestamp scalars in both containers. A Date written by
JavaScript survives a load, save, and reload through the native Go engine
and is re-read identically by JavaScript.

Map the two legacy_tests.ts Date interoperability entries to that test and
update the parity baseline (pending 172 to 170).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce the cursors.ts case where a date from one document is used in
another: a JavaScript Date is read from a source document as a timestamp
scalar through the native engine, written into a fresh document in both a
map and a list, and re-read as a Date by JavaScript with the original
instant preserved.

Add createTimestamps and readTimestamps oracle actions, map the entry, and
update the parity baseline (pending 170 to 169).

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
cursoragent and others added 23 commits August 9, 2026 14:26
State comparison reported a mark patch whenever a mark's materialized
range differed, so splicing text into an expanding mark spuriously emitted
a mark patch for the range growth. The reference derives mark patches from
mark operations, letting the grown range ride on the splice patch instead.

Suppress mark patches in the text diff unless the diff window actually
contains a mark operation on the object, so expansion caused by a
neighbouring splice no longer produces a mark patch while genuine mark and
unmark operations still do.

Reproduce the upstream incremental_splice_patches_include_marks and
noexpand_marks_at_the_end_of_text cases; text.rs pending drops to 7.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Mark boundaries were materialized immediately after their visible anchor,
ignoring insertion branches added later at that same position. As a
result after- and both-expanding marks did not include text inserted at
their end boundary, and non-expanding begin boundaries could include text
that belonged outside the mark.

Position visible mark boundaries relative to same-anchor insertion
branches using the marker and branch operation IDs. Expanding end markers
and non-expanding begin markers sit after later branches; expanding begin
markers and non-expanding end markers sit before them. Preserve the prior
deleted-anchor behavior by applying this ordering only while the original
boundary anchor remains visible.

Reproduce the upstream boundary patch, remote merge, and combined
mark/unmark scenarios against Rust, including final marks materialization.
Remove the resolved native defect from the parity plan and reduce required
pending coverage to 138.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce the upstream regression where a non-expanding mark crosses an
operation-tree page boundary and repeated block insertions force later
text searches to skip indexed pages. Every appended text patch must remain
unmarked; otherwise stale mark state leaked across a skipped page.

Run the scenario for both native and Rust reference engines over one
hundred block-and-append rounds. Mark the upstream case covered and reduce
required pending parity entries to 137.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Introduce Text.UpdateSpans, reconciling a text object so its spans equal a
supplied set of text spans with marks. The content is reconciled with a
minimal grapheme diff and the marks are then set to exactly those named on
the spans, honoring per-mark and default expand configuration. The WASM
reference exposes the real update_spans so the engines compare directly.

Reproduce the text-and-mark scenarios from the upstream diff_marks suite:
overlapping, adjacent, nested, split, shifted, expanded, and contracted
marks; value and type changes; whitespace, emoji, and combining
characters; unmarking; empty spans; expand configuration; idempotence; and
alternating updates. Block-valued update_spans remains pending. Required
pending parity entries drop to 112.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Extend update_spans to reconcile block markers alongside text and marks.
A block-aware Myers diff over grapheme and block units splices text,
splits and joins block markers, and writes block attributes (including
nested lists and maps) onto newly created blocks. The Myers driver is
generalized behind a diff sink so the text and block passes share it, and
the WASM reference hydrates block attributes from JSON so both engines are
compared directly.

Reproduce the block-valued update_spans scenarios from the upstream block
and diff-mark suites: block property changes, text-and-block updates, mark
reconciliation across blocks, attribute deletion, expand configuration,
block-only updates, and the no-op patch guard. Required pending parity
entries drop to 98.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce the patch-generating batch-insert scenarios from the upstream
batch_insert suite: a hydrated map insertion emits a put_map patch through
the diff cursor, a hydrated text insertion emits a splice_text patch, and
initializing the root from a hydrated map emits put_map patches under diff
from an empty frontier. Each asserts the native and reference patch streams
agree. Required pending parity entries drop to 95.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add Text.SpansAt so rich-text spans can be read at a historical frontier,
and materialize block markers in the from-empty diff path. The text
current-state materialization previously concatenated only text and
dropped block markers, so a diff from the empty frontier omitted block
insertions and their nested attributes.

Walk the visible sequence, flushing marked text runs and emitting an
insert patch for each block marker. Expose am_text_spans_at in the WASM
reference so both engines are compared directly.

Reproduce the upstream marks_on_spans_respect_heads, diff_emits_block_updates,
and merge_produces_block_insertion_diffs cases. Required pending parity
entries drop to 92.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce the cross-engine text behaviors from the upstream JavaScript
text suite against both engines: implicit and explicit deletion, text and
other operations in a single change, hydrated text initialization and its
single-change encoding, and splicing into text nested inside lists. The
update-text diff case maps to the existing simple_update_text coverage.

Reclassify the three remaining JavaScript-binding cases (JSON.stringify
shape, mutable-proxy assignment, and the change-callback guard) as
language-specific, since they exercise proxy semantics with no wire or
state interop meaning. Required pending parity entries drop to 83.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce the cross-engine block behaviors from the upstream JavaScript
block suite against both engines: updating all blocks at once, updating
marks, default and per-mark expand configuration, omitting parts of the
config, block attribute changes, historical span marks, and small
floating-point block attribute values. Default the update-spans expand to
"after" (matching the reference default) and omit empty config fields so
partial configs are accepted.

Reclassify the three ImmutableString (RawString) block cases as
language-specific, since they exercise a JavaScript binding scalar wrapper.
Required pending parity entries drop to 72.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce the rollback behaviors from the upstream owned-transaction suite:
a rollback with no pending writes discards nothing and preserves prior
state, and a rollback after a write discards the operation and reports the
discarded count. Both assert the native and reference engines agree.

Reclassify the remaining owned-transaction cases as api-convenience: they
exercise the Rust owned-transaction object API (into_transaction,
pending_ops, commit and rollback tuples), whose underlying state behavior
is already covered by the core-model, metadata, and current-state parity
suites. Required pending parity entries drop to 61.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce mark_patches_at_end_of_text: a mark applied at the end of text
in one document, saved incrementally and loaded into another, produces a
single Mark patch through the diff cursor on both engines. Required pending
parity entries drop to 60.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce the upstream list-range behaviors: reading a list yields its
values in order, and an element overwritten concurrently on two branches
is reported as conflicted with the winning value after merge. Both assert
the native and reference engines agree. Required pending parity entries
drop to 58.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Introduce LoadConvertingStrings, which loads a document and converts every
string scalar stored in a map or list into a text object holding that
string, mirroring the Rust StringMigration::ConvertToText load option. The
conversion walks the reachable object graph and commits a single migration
change only when a string was rewritten, so a document without strings is
unchanged. The WASM reference exposes am_load_convert_strings so both
engines are compared directly.

Reproduce the upstream convert_string_to_text cases for maps, lists, and
the no-conversion size invariant. Required pending parity entries drop to 55.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Introduce Text.CursorForAt, which creates a stable cursor for an index
resolved against the text as it existed at a historical frontier, mirroring
get_cursor with heads. The native engine walks the historical sequence and
the WASM reference exposes am_text_cursor_moving_at so both engines are
compared directly.

Reproduce the JavaScript getCursor-respects-heads case: cursors created
against a past view resolve to the same positions after the text is edited.
Reclassify the shallow-copy and patch-source cursor cases as
language-specific JavaScript binding helpers. Required pending parity
entries drop to 52.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce reproduce_clock_cache_bug: after merging twenty branches authored
by distinct actors on top of a shared history, ChangesSince(heads) is empty
because every change is an ancestor of the merged frontier. This exercises
the native vector-clock ancestry computation against the regression that
motivated the upstream test.

Reclassify the Rust storage and sync module rustdoc doctests as
api-convenience: they are ignore-marked documentation examples for the
low-level parser and sync module APIs and carry no cross-engine wire or
state meaning. Required pending parity entries drop to 44.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce if_first_message_has_no_heads_and_supports_v2: when a peer starts
empty, the other peer's first sync response carries the entire document, so
the empty peer converges after a single exchange. Both engines agree. The
remaining src/sync.rs cases require white-box access to Bloom filters and
sync-state internals and stay pending. Required pending parity entries drop
to 43.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Per project direction the collaborative editor uses only the V2 sync
protocol, so legacy V1 interoperability is out of scope. Reclassify the
V1<->V2 compatibility suite and the old-peer read-write fallback as
api-convenience, and likewise the patch-log-from-another-document safety
guards, which assert Rust returns an error instead of panicking and carry
no cross-engine meaning.

Cover test_change_encoding_expanded_change_round_trip: a change decoded
from its canonical bytes re-encodes to exactly those bytes, validating
change wire fidelity. Required pending parity entries drop to 34.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Report mark and unmark patches from the mark operations applied in the diff
window rather than by comparing materialized mark ranges. Each patch now
carries the operation's literal UTF-16 range and value, so a partial unmark
reports its own range with a null value instead of the split it produces,
and mark ranges that merely grow from a neighbouring splice emit no mark
patch. A new state helper resolves a mark operation pair's UTF-16 range.

Reproduce the JavaScript marks-in-patches cases; the expand-at-end case
maps to the existing expand-marks coverage. Required pending parity entries
drop to 32.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Record the remaining Rust-internal and binding-specific upstream cases as
api-convenience or language-specific so the interop-required set reflects
genuine cross-engine behavior. This covers Rust rustdoc usage examples
(AutoCommit diff/commit_with, AutoSerde, PatchLog, storage::document parse,
manual transaction), the Rust hydrate and document-iterator reader APIs,
batch_create_object API-shape contracts, the AutoCommit Send trait check,
JavaScript ImmutableString/RawString helpers, and the legacy Text-as-array
proxy semantics.

The fourteen remaining interop entries all require substantial features:
isolate/integrate, an operation-replay patch log for multi-op incremental
windows, Bloom false-positive recovery and the empty-message V2 codec, a
native document-chunk encoder for orphan changes, and deflate column
compression.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce test_load_changes: loading a stored document and materializing
its current state yields a single put of the counter's summed value (2000)
on both engines. Add the upstream fixture under testdata. Required pending
parity entries drop to 13.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Reproduce insertions_after_noexpand_spans_are_not_marked: after building a
heading/paragraph block structure with update_spans, appending text past
the trailing block is reported by a diff as a single unmarked splice on
both engines. Required pending parity entries drop to 12.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Differential parity tests need reference behaviour that the embedded WASM
backend did not surface. Add FFI entry points for sync bloom filter
membership, isolate and integrate, and an uncompressed save, then wire
matching methods onto the reference backend.

Bloom membership lets a test locate a genuine false positive
deterministically instead of guessing one. Isolate and integrate expose
the pinned-frontier transaction view. The uncompressed save mirrors
AutoCommit::save_nocompress so compressed and uncompressed sizes can be
compared. The WASM artifact and its checksum are rebuilt to match.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Close the remaining interop-required gaps against upstream Automerge so
every wire and state behaviour the Go engine must share with Rust is now
differentially covered. The zero-pending interop audit gate passes.

Transaction isolation pins reads and writes to a historical frontier and
branches with derived concurrency actors, matching Rust's with_concurrency
scheme, while merges stay hidden until integrate. Orphan changes now
survive a save: the save appends retained orphans and the load falls back
to a dependency-tolerant path that applies what it can and queues the
rest, still rejecting a bare orphan with no base. Large change chunks are
DEFLATE compressed on save above the reference threshold, with an
uncompressed variant for size comparison.

Incremental diffs chain through the isolation frontiers recorded in a
window so the patch stream matches the reference across isolate and
integrate, and materialization now skips losing conflict alternatives at
a map key so only the winning object's content is spliced.

The V2 sync internals are covered too: the empty message round-trips to
the reference wire bytes, and bloom false-positive recovery converges on
both engines. The native engine compares heads exactly rather than using
bloom filters, so it is immune to false positives by construction.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
@gearnode

Copy link
Copy Markdown
Contributor Author

Closing in favor of a cleaner 2-PR split: #1757 (Automerge CRDT engine) → #1758 (wiring it into the document editor), built as clean commits from final-state content rather than replayed history.

@gearnode gearnode closed this Aug 21, 2026
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.

2 participants