Repository navigation
fix(loro.js): converge plain-text edits with Rust, add a Rust differential suite - #1136
Conversation
A seeded differential suite now runs the same Text edits, syncs, checkouts, snapshots, cursors, undo, and revertTo in loro.js and loro-crdt (nodejs WASM) and compares every step; it skips when no WASM build exists. It found these divergences, fixed here: Fugue placement after a sibling subtree, deletes applied by recorded ID instead of position, doubly deleted text restored twice by checkout events, and cursor semantics. Ops are also written in Rust's shape (merged inserts, last delete run first). See context/loro-js-rust-differential.md and context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WASM Size Report
|
…tree The indexed Fugue path jumped from the last direct child of `originLeft` to the origin-right element. Rust's scan stops at the first concurrent element whose origin is further left, so the insert could land after that element. Replicas then disagreed depending on import order, and all of them could disagree with loro-crdt. Check whether the interval's last element descends from `originLeft` and otherwise binary-search the boundary. The descent test skips implicit runs through per-peer sorted explicit counters. Rationale and cost: context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An element right after its ID predecessor has it as origin-left, so only run starts can end a sibling's subtree (context/loro-js-performance.md). Importing a concurrent character after a 512k-character run drops from ~9.5 ms to ~0.2 ms. Adds text-concurrent-insert-after-long-run to bench:complexity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Decision and rationale: context/loro-js-rust-differential.md ("Data
written by loro.js 0.2"). The changeset becomes major; README "Upgrading
from 0.2" lists what changes and how to migrate. Fixtures written by
loro.js 0.2.1 pin how loro.js and Rust read its updates, snapshots, and
cursors.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Visits two versions per checkout, including versions right after a random op, so transitions between historical versions and mid-change versions are compared with Rust. README "Upgrading from 0.2" now reflects what a document loaded from a 0.2 snapshot does after its history is used. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Review follow-up (head P2 Fugue subtree scan:
Compatibility (B2), decided as option A: loro.js reads all data with Rust's semantics and no version marker.
Differential suite: checkout now visits two versions in a row, each a commit version or the version right after a random op (mid-change/mid-op versions). 150 plain-text seeds × 120 steps pass against Validation: |
Both branches fixed the same misordering after the last sibling. Keep #1139's binary search over origin-left descent and drop this branch's run-start walk (SequenceIndex.nextPhysicalIdRunStart); the measured trade-off is in context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Follow-up: this PR now uses #1139's Fugue fix (head #1139 and the first version of this PR fixed the same misordering: after the last sibling, the insert jumped to the origin-right bound. The two computed the same boundary in different ways:
I compared them on a concurrent insert that must stop at the end of a sibling subtree, with a later concurrent root element (Node 22, 1-minute load 8–17). They give identical text in every case (hash-equal on the B4 trace);
#1139 is faster on long runs and on the real trace, and Merge order: #1139 → this PR → #1137. If #1139 lands first, this PR's diff loses those lines. The changeset keeps the ordering change as breaking (major; reading 0.2 data, option A) and points to #1139; #1139's own changeset is a patch. Also still open: #1126 and this PR both add an identical Validation:
Benchmark rerun (main / #1136 review head
|
…ow export With #1126 on main, a checkout keeps a 0.2 snapshot's state while a fork or shallow export replays its history (Rust's reading); the test pins the fork difference from Rust. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#canTransitionRecords walked the whole sequence once per insert op, so checking out across scattered edits was O(ops * elements): 668 ms at 8k characters with 2k edits on main, 4 ms now. Adds text-scattered-edits-checkout to bench:complexity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Merged the latest Integration
Checkout performance fix (new)
0.2 snapshots after #1126:
Validation
Performance (Node 22, alternating
The per-round numbers are in |
Cursors move for side 1 and end-of-text, not astral text; reading the final state with 1.0 needs the snapshot to be the final version; a shallow export switches a 0.2-snapshot document to Rust's reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndent The second checkout version came from `imul(roll, 0x9e3779b1)`, whose low bits repeat the roll's (the multiplier is 1 modulo 16), so it usually picked the first version again. It now uses the product's high bits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Second review, #1136 part.
🤖 Generated with Claude Code |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…parity Main already writes multi-run text deletes right to left (#1136); keep its version and drop the duplicate test and changeset bullet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Adds a randomized differential suite that runs the same Text edits in loro.js and in Rust (
loro-crdt, nodejs WASM) and compares every step, and fixes the plain-text divergences it found. Rich text (marks) is covered by the follow-up #1137; this PR runs the suite without marks.Breaking (major changeset,
loro.js0.2.1 → 1.0.0): loro.js reads all data with Rust's semantics and no version marker (decision: option A). Documents edited only with loro.js 0.2 that have the concurrent-insert pattern below read differently, and cursors encoded by 0.2 with side 1, or at the end of the text, resolve one character earlier (a cursor reports its target's own offset, as in Rust; other cursors are unchanged, whatever the characters). A document loaded from a 0.2 snapshot keeps its 0.2 text only while it holds that state: history imported after the snapshot, and its ownshallow-snapshotexport, read like Rust. README "Upgrading from 0.2" gives measured readings and migration paths (reading with 1.0 right after importing a 0.2 snapshot is only valid when the snapshot is the final version); the decision is recorded incontext/loro-js-rust-differential.md.The suite (
loro-js/tests/richtext-differential.test.ts)tests/support/richtext-differential.ts): insert/delete at UTF-16 and UTF-8 boundaries (BMP, CJK, astral, multi-scalar emoji); sync by binary or JSON updates, within a runtime or crossed, so each runtime reads the other's positions; checkout to two versions in a row (commit versions or the version right after a random op) and back; full and shallow snapshots from either runtime imported into both; cursors;revertTo; undo/redo. Marks, unmarks, and all four expand modes are generated when enabled (fix(loro.js): keep rich-text style anchors in the text sequence #1137).shrinkActions).pnpm test-loro-js(new, added totest-allafterrelease-wasm) requires the build, so CI now runs the loro.js tests too.context/loro-js-rust-differential.md. Newloro-js/AGENTS.mdpoints agents at it and at the interop fixture pipeline (closing the CONTEXT-GAPS entry).Fixes (all plain text)
indexedFugueInsertionjumped to the origin-right bound, but that bound is only the next causally included element, so an ancestor's later concurrent subtree can sit before it. Peers 1/2/3:b,9,ébetween them, concurrentaafterb→ Rustbéa9, loro.jsbé9a.originLeft's subtree ends (descent test that skips implicit runs). This PR first walked the sibling subtree's ID runs; #1139 is faster on long runs and real traces, slower on deep explicit-link chains (measurements in the PR comment andcontext/loro-js-performance.md).start_id. Rust's tracker deletes by position; its WASM build can record a wrongstart_idaround astral text (#1135), e.g.revertTo([])over👍é🏽.LoroText._deleteTargetsresolves by position in the op's causal view; the recorded IDs are only a fallback. Local deletes skip the lookup.checkout/diff/revertTorestored a character twice when two concurrent deletes removed it (packed spans hand out a new element view per lookup, so aSetdid not dedupe).getCursorPosadded the target length for side 1; deleted targets used the next element; the encoded origin was UTF-16.#textCursorPos); encoded cursors now match Rust byte for byte.#canTransitionRecordswalked the whole sequence once per insert op (698 ms at 8k characters with 2k edits onmain).text-scattered-edits-checkout).Rationale
The previous interop checks were fixed fixtures. The suite found a convergence bug in plain concurrent text (the Fugue one) that fixtures had not, and three Rust bugs (#1135). Resolving deletes by position is what makes loro.js agree with Rust on documents Rust has already written with mismatched IDs.
Validation
pnpm --dir loro-js checkon the merge withmain(fix(loro.js): correct checkout after a snapshot import #1126–fix(loro.js): drop containers deleted before the shallow root, keep tree node metadata #1128, fix: resolve a container's parent from the change that created it #1159): 24 files, 368 tests incl. fix(loro.js): correct checkout after a snapshot import #1126's snapshot-checkout suite and fix(loro.js): stop a concurrent insert at the end of its origin's subtree #1139'sfugue-index.test.ts; format, lint, typecheck, build.tests/text-interop.test.ts(regression tests per fix, incl. a Rust-written fixture with a mismatchedstart_id) fails on the previousmain;tests/legacy-data.test.tspins how loro.js andloro-crdtread fixtures written by the publishedloro.js@0.2.1.maince6498b5: 300 seeds × 120 steps (13001–13150, 14001–14150) with per-op two-version checkouts, plus 600+ earlier seeds of 80–160 steps (withsnapshotCheckoutoff, below). After the second review: 200 seeds × 120 steps with the second checkout version made independent of the first (84431ba; it used to repeat the first version for most history lengths).bench:b4, 259,778 actions), alternating withmainat 1-minute load 6–9 (September 29): apply 233–235 vs 238–240 ms; update export 9 vs 72–73 ms (274,574 vs 1,153,540 B) because consecutive inserts merge into one op as in Rust; update import 109 vs 165–184 ms; snapshot 214,928 vs 309,780 B; heap after apply 91.3 vs 107.0 MB.bench:complexity 1000,2000,4000,8000: no entry grows with size, including the newtext-concurrent-insert-after-long-run(0.15–0.21 ms from 64k to 512k characters; the review head took 2.3 → 12.0 ms) andtext-scattered-edits-checkout(1.0 → 4.2 ms;main13 → 698 ms). Median at 8k vsmain: 0.91 (0.97 over entries above 1 ms).Not included / known gaps
snapshotCheckoutis off by default here (fix(loro.js): keep rich-text style anchors in the text sequence #1137 turns full-snapshot checkout on). Checkout into the retained range of a shallow snapshot that loro.js exported diverges, in plain text too: loro.js uses the requested frontiers as the root, while Rust moves it back to a critical version (#calculateShallowStart). The suite samples only a few versions per export (15 of 80 seeds); an exhaustive check in the second review found most multi-peer histories affected (8,484/17,746 non-critical-root versions onmain, 10,980/17,746 here; 0 with a critical root). It is pre-existing, and fix(loro.js): root shallow snapshots at the latest critical version #1144 roots shallow snapshots at the latest critical version.#mergeTrailingTextDeletes), so the same calls can write more delete ops. State and IDs of inserted text are the same; fix(loro.js): record absent-key map deletes and zero counter increments like loro-crdt #1145 works on local op parity.diff()from a checked-out version can throwduplicate sequence idon a document imported from a snapshot (it replays elements the state already holds). It is pre-existing onmain(e.g. with aList); fix(loro.js): keep rich-text style anchors in the text sequence #1137 fixes it together with the anchor case.UndoManagerdoes not transform against remote edits like Rust's; the suite runs undo in loro.js and checks that Rust reads the resulting ops identically (undoParitycompares both, off by default).Merge order / rebase plan
main, merged in atce6498b5(fix(loro.js): correct checkout after a snapshot import #1126–fix(loro.js): drop containers deleted before the shallow root, keep tree node metadata #1128 and fix: resolve a container's parent from the change that created it #1159). The only conflict was theSequenceElementSetthat fix(loro.js): correct checkout after a snapshot import #1126 and this PR both added;main's copy is kept. fix(loro.js): include reattached child containers' state in diff() and revertTo() #1131 still conflicts textually (resolved in an earlier trial merge).🤖 Generated with Claude Code