@lazily-hub/lazily-js 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BENCHMARKS.md CHANGED
@@ -89,3 +89,118 @@ node scripts/run-benchmarks.mjs # record "before" baseline in BENCHMA
89
89
  # apply the performance patch
90
90
  node scripts/run-benchmarks.mjs # compare against the new numbers
91
91
  ```
92
+
93
+ ## Scale (≥1M cells) — spreadsheet-shaped graph
94
+
95
+ A second suite ([`bench/scale.bench.mjs`](bench/scale.bench.mjs)) replicates the
96
+ lazily-rs [`scale`](https://github.com/lazily-hub/lazily-rs/blob/main/benches/scale.rs)
97
+ group, the lazily-go `scale` group, and lazily-py's
98
+ [`scale_bench.py`](https://github.com/lazily-hub/lazily-py/blob/main/src/lazily/scale_bench.py)
99
+ on a **spreadsheet-shaped** graph: `N` input cells + `N` formula slots where
100
+ `formula[i] = input[i] + input[i - 1]` (local fan-in, like a column of
101
+ `=A_i + A_{i-1}`). With the default `N = 1,000,000` that is **~2,000,000
102
+ reactive nodes**. Four scenarios cover the spreadsheet lifecycle:
103
+
104
+ - `build` — construct all `2N` nodes (formulas lazy, not yet computed).
105
+ - `cold_full_recalc` — first read of every formula (forces every compute + edge-tracking).
106
+ - `viewport_recalc` — edit one input, read only a 1,000-cell viewport (the
107
+ lazy-pull win: off-viewport formulas stay dirty and never recompute).
108
+ - `full_recalc_invalidate_all` — re-set every input, then read every formula
109
+ (worst-case full-sheet edit).
110
+
111
+ > **A "cell count" here counts two cells per row** — the graph models a column of
112
+ > formulas `=A_i + A_{i-1}`, so each row is **one input cell `A_i` plus one
113
+ > formula cell**. `N` rows ⇒ `N` inputs + `N` formulas = `2N` cells.
114
+
115
+ Timings use `performance.now()` (`node:perf_hooks`); single wall-clock run per
116
+ scenario. Lower is better. Treat the absolute numbers as indicative — the shapes
117
+ (relative costs, size-scaling behavior) are what transfer across runs and hosts.
118
+
119
+ ### Reproduce
120
+
121
+ ```bash
122
+ make bench-scale # scale suite at N = 1,000,000
123
+ node bench/scale.bench.mjs # same, directly
124
+
125
+ # scale at a specific size / viewport:
126
+ LAZILY_SCALE_N=1000000 node bench/scale.bench.mjs
127
+ LAZILY_SCALE_N=5000000 node bench/scale.bench.mjs # Google Sheets 10M-cell workbook
128
+ LAZILY_SCALE_VIEWPORT=1000 node bench/scale.bench.mjs
129
+ BENCH_FORMAT=json node bench/scale.bench.mjs # machine-readable output
130
+ ```
131
+
132
+ Large `N` needs headroom for the V8 heap — run with
133
+ `node --max-old-space-size=8192 bench/scale.bench.mjs` at 1M and `16384` at 5M.
134
+
135
+ ### Hardware / environment
136
+
137
+ | | |
138
+ |---|---|
139
+ | CPU | AMD Ryzen 9 9950X3D (16 cores / 32 threads) |
140
+ | RAM | 186 GiB |
141
+ | OS | Linux 7.1.3 (CachyOS), x86-64 |
142
+ | Node.js | 26.4.0 (V8) |
143
+
144
+ ### 1,000,000 rows (~2M cells / nodes)
145
+
146
+ Peak RSS ~1.0 GiB (~550 B/node).
147
+
148
+ | Benchmark | Time | Per cell | What it measures |
149
+ |-----------|-----:|---------:|------------------|
150
+ | `build` | ~0.90 s | ~450 ns | Construct all 2N nodes (each `Cell`/`Slot` allocates its own dependency structures — allocation- and GC-bound under V8). |
151
+ | `cold_full_recalc` | ~0.65 s | ~650 ns | First read of every formula — forces every compute + edge-tracking. |
152
+ | `viewport_recalc` | **~100 µs** | — | Edit one input, read only a 1,000-cell viewport. ~6,500× cheaper than a full cold recalc. |
153
+ | `full_recalc_invalidate_all` | ~1.30 s | ~1.30 µs | Re-set every input, then recompute the whole sheet (worst-case full-sheet edit). |
154
+
155
+ ### 5,000,000 rows (10M cells — a full Google Sheets workbook)
156
+
157
+ Google Sheets caps a workbook at **10,000,000 cells**. Modeled as 5,000,000
158
+ input cells + 5,000,000 formula cells (`LAZILY_SCALE_N=5000000`). This is the
159
+ **largest size actually measured** — no extrapolation. Peak RSS ~5.0 GiB.
160
+
161
+ | Benchmark | Time | Per cell | What it measures |
162
+ |-----------|-----:|---------:|------------------|
163
+ | `build` | ~6.56 s | ~656 ns | Build the full 10M-node workbook (allocation/GC-bound). |
164
+ | `cold_full_recalc` | ~3.37 s | ~674 ns | Compute all 5M formulas cold. |
165
+ | `viewport_recalc` | **~106 µs** | — | Edit one input, read a 1,000-cell viewport. ~31,800× cheaper than a full cold recalc. |
166
+ | `full_recalc_invalidate_all` | ~7.38 s | ~1.48 µs | Re-set every input, recompute the whole workbook. |
167
+
168
+ So lazily-js backs a **full-capacity Google Sheets workbook** on V8: building it
169
+ is the expensive part (~6.6 s, allocation/GC-bound — one JS object graph per
170
+ node), but once built, a full cold recompute is ~3.4 s, and a one-cell edit +
171
+ bounded-viewport read stays in the **~100 µs range**. The lazy pull-based model
172
+ leaves off-viewport formulas dirty and never recomputes them — only ~2 formulas
173
+ actually recompute per edit (the two that read the edited input), regardless of
174
+ sheet size, which is exactly the property a viewport-rendered spreadsheet needs.
175
+
176
+ ### Spreadsheet cell-count context
177
+
178
+ | Spreadsheet | Documented limit | Cells |
179
+ |-------------|------------------|------:|
180
+ | Google Sheets | 10,000,000 cells per workbook (18,278 columns max) | 10,000,000 |
181
+ | Microsoft Excel | 1,048,576 rows × 16,384 columns per worksheet | 17,179,869,184 |
182
+
183
+ The `LAZILY_SCALE_N=5000000` run above covers a full Google Sheets workbook. A
184
+ grid-complete Excel worksheet (17 billion cells) is unrepresentative — real
185
+ sheets populate a tiny fraction of the grid, and lazily only stores the cells you
186
+ create, so the `scale` group measures the populated-cell path that matters.
187
+
188
+ ### Viewport scaling — flat
189
+
190
+ lazily-js's viewport recalc is **effectively size-independent** (~100 µs at 2M
191
+ nodes, ~106 µs at 10M nodes). The value cache is keyed by node identity, so both
192
+ the ~1,000 viewport cache-hit reads and the ~2 actual recomputes are O(1) map
193
+ operations that don't scale with total sheet size. This matches lazily-rs's and
194
+ lazily-py's flat curves. At 10M cells a one-cell edit + 1,000-cell viewport read
195
+ is ~31,800× cheaper than a full cold recalc and never touches off-viewport
196
+ formulas.
197
+
198
+ ### Cross-language honesty
199
+
200
+ V8 is heavier per node than compiled Rust (lazily-rs builds the same 2M-node
201
+ graph in ~0.1 s vs ~0.9 s here) but far lighter than CPython (lazily-py's build
202
+ is ~10.6 s at 1M). The recompute paths (`cold_full_recalc` ~650 ns/formula) are
203
+ JIT-compiled and closer to the compiled bindings. These numbers are reported
204
+ honestly, not to claim parity — the transferable result is the **shape**: the
205
+ lazy-pull viewport property holds identically across all bindings (a one-cell
206
+ edit + bounded-viewport read is microseconds, independent of sheet size).
package/README.md CHANGED
@@ -39,6 +39,10 @@ notes and platform carve-outs lives in
39
39
  | Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
40
40
  | --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
41
41
  | Reactive graph — `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
42
+ | Reactive family (`ReactiveFamily`) — keyed cell/slot family + materialization mode (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
43
+ | Thread-safe reactive family (`ThreadSafeReactiveFamily`) — `Send + Sync` keyed family + materialization confluence (`#lzmatmode`) | ✅ | — | — | — | — | — | — | — |
44
+ | Async reactive family (`AsyncReactiveFamily`) — keyed family + eventual transparency (`#lzmatmode`) | ✅ | — | — | — | — | — | — | — |
45
+ | Reactive family sync — membership propagation + materialize-on-ingest + derived-aggregate transparency (`#lzfamilysync`) | ✅ | — | ✅ | ✅ | — | — | — | — |
42
46
  | Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
43
47
  | Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
44
48
  | Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -46,21 +50,22 @@ notes and platform carve-outs lives in
46
50
  | Keyed cell collections (`CellMap` / `CellTree`) + reconcile | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
47
51
  | Memoized semantic tree (`SemTree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
48
52
  | Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
49
- | Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | | ✅ | ✅ | | ✅ | | ✅ |
53
+ | Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | | ✅ | ✅ | | ✅ | | ✅ |
50
54
  | Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
51
55
  | `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
52
56
  | Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
53
- | Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
54
- | Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
55
- | Lossless tree — concurrent merge convergence | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
57
+ | Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
58
+ | Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
59
+ | Lossless tree — concurrent merge convergence | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
56
60
  | Registers (LWW / MV) + `PnCounter` + `CellCrdt` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
57
61
  | IPC wire — `Snapshot` + `Delta` + `CrdtSync` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
58
- | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ~ | ~ | ✅ | ✅ | ✅ |
62
+ | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | | ~ | ✅ | ✅ | ✅ |
63
+ | Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
59
64
  | Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
60
65
  | Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
61
66
  | State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
62
67
  | Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
63
- | Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
68
+ | Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ |
64
69
  | C-ABI FFI boundary | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
65
70
  | Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
66
71
  | Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -74,8 +79,10 @@ and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
74
79
  | Import | What it is |
75
80
  |--------|------------|
76
81
  | `@lazily-hub/lazily-js` | `lazily-spec` IPC wire types: `Snapshot`, `Delta`, `DeltaOp`, `IpcMessage` (`Snapshot` / `Delta` / `CrdtSync`), `NodeState`, `IpcValue`, `PeerPermissions`, `SessionHandshake`, `BINDING_CAPABILITIES` |
82
+ | `@lazily-hub/lazily-js/transport` | Cross-process zero-copy transport (`#lzzcpy`): `ShmBlobArena`, `InProcessBackend` / `ArrowBackend`, `BlobRouter`, `spillMessage` / `resolveValue`, and the FFI-gated `createShmBackend` (Node/Bun/Deno). Isomorphic — no FFI import; browser-safe |
77
83
  | `@lazily-hub/lazily-js/reactive` | Reactive dependency graph: `Context`, `Cell`, `Slot`, `Signal`, `Effect` |
78
84
  | `@lazily-hub/lazily-js/reactive-async` | Async reactive graph: `AsyncContext` — Promise-driven slots/effects with revision-guarded stale-completion discard, in-flight dedup, and cancellation |
85
+ | `@lazily-hub/lazily-js/reactive-family` | Unified keyed reactive family: `ReactiveFamily` (`EntryKind` cell/slot × `MaterializationMode` eager/lazy) + `cellFamily` input-cell specialization (`#lzmatmode`) |
79
86
  | `@lazily-hub/lazily-js/state-machine` | Flat finite-state-machine kernel backed by a reactive `Cell` |
80
87
  | `@lazily-hub/lazily-js/statechart` | Harel/SCXML chart interpreter plus `ChartBuilder`, `StateBuilder`, `TransitionBuilder` |
81
88
  | `@lazily-hub/lazily-js/collections` | `CellMap`, `CellTree`, keyed reconciliation, and LIS move minimization |
@@ -155,6 +162,44 @@ ctx.setCell(userId, 2); // supersedes any in-flight compute; slot re-resolves
155
162
  await ctx.getAsync(profile); // the profile for user 2
156
163
  ```
157
164
 
165
+ ## Reactive family and materialization mode
166
+
167
+ `ReactiveFamily` (from `@lazily-hub/lazily-js/reactive-family`) is the unified
168
+ **keyed reactive family** (`#lzmatmode`): it maps keys to per-entry reactive
169
+ nodes and abstracts over the entry's handle kind. Two orthogonal axes:
170
+
171
+ - **Entry kind** — `EntryKind.Cell` entries are input cells (**always
172
+ materialized**, any mode); `EntryKind.Slot` entries are derived slots (what
173
+ materialization governs). `cellFamily(...)` is the input-cell specialization.
174
+ - **Materialization mode** — `MaterializationMode.Eager` (**default**) allocates
175
+ every derived node up front; `MaterializationMode.Lazy` (opt-in) allocates a
176
+ derived node on its **first read** ("materialize on pull") and caches it.
177
+
178
+ Mode is **observationally transparent**: a read returns the same value under
179
+ either mode — it changes allocation timing and memory, never results. Lazy pays
180
+ off only for sparsely-touched large keyed address spaces.
181
+
182
+ ```js
183
+ import { Context } from "@lazily-hub/lazily-js/reactive";
184
+ import { ReactiveFamily } from "@lazily-hub/lazily-js/reactive-family";
185
+
186
+ const ctx = new Context();
187
+
188
+ // A derived (slot) family of key*3 over a large address space, built lazily:
189
+ // nothing is allocated until a key is read.
190
+ const fam = ReactiveFamily.lazy(ctx, range(0, 1_000_000), (k) => k * 3);
191
+ fam.presentCount(); // 0
192
+
193
+ fam.observe(5); // 15 — first read materializes just this entry
194
+ fam.presentCount(); // 1
195
+ fam.isPresent(5); // true
196
+ fam.isPresent(6); // false
197
+
198
+ // Eager builds the same values up front — observationally identical.
199
+ const eager = ReactiveFamily.eager(ctx, [0, 1, 2, 3], (k) => k * 3);
200
+ eager.observe(2) === fam.observe(2); // true
201
+ ```
202
+
158
203
  ## State machine and state charts
159
204
 
160
205
  `StateMachine` is the flat finite-state-machine kernel: a pure
@@ -344,6 +389,69 @@ signaling, and the WebRTC transport are shipped; C-ABI FFI is `none` because
344
389
  browser/Worker JS cannot host a native in-process ABI. The same payload types
345
390
  can still be carried by any transport a host application owns.
346
391
 
392
+ ## Cross-process zero-copy transport
393
+
394
+ `@lazily-hub/lazily-js/transport` implements the pluggable blob-backend
395
+ transport (`#lzzcpy`). A large payload is not copied through the wire codec: the
396
+ producer **spills** it to a backend (which mints a `ShmBlobRef` descriptor) and
397
+ ships only the descriptor; the receiver **routes** the descriptor by its
398
+ `backend` discriminator and **resolves** it zero-copy — reading the backend's own
399
+ bytes in place. `ShmBlobRef` gained an optional `backend` field (`shm` | `arrow`
400
+ | `in_process`) that defaults to `shm` and is omitted from the wire, so every
401
+ pre-transport descriptor round-trips byte-for-byte.
402
+
403
+ The module is **isomorphic**: it imports no FFI, so it bundles and runs in the
404
+ browser. `InProcessBackend`, `ArrowBackend`, the `BlobRouter`, and the
405
+ spill/resolve policy are pure JS and available everywhere (including a
406
+ main-thread ↔ Web Worker deployment).
407
+
408
+ ```js
409
+ import {
410
+ BlobRouter,
411
+ InProcessBackend,
412
+ ArrowBackend,
413
+ spillMessage,
414
+ } from "@lazily-hub/lazily-js/transport";
415
+ import { Delta, DeltaOp, IpcMessage, IpcValue } from "@lazily-hub/lazily-js";
416
+
417
+ const backend = new InProcessBackend(); // or ArrowBackend for columnar payloads
418
+ const big = IpcValue.inline(new Uint8Array(4096));
419
+ const { message, spilledBytes } = spillMessage(
420
+ IpcMessage.delta(new Delta({ baseEpoch: 0, epoch: 1, ops: [DeltaOp.slotValue(7, big)] })),
421
+ backend,
422
+ ); // message now carries a small SharedBlob descriptor; spilledBytes === 4096
423
+
424
+ const router = new BlobRouter().register(backend);
425
+ router.resolve(message.delta.ops[0].payload); // Uint8Array view, zero copy
426
+ ```
427
+
428
+ The genuine **cross-process** `shm` backend (POSIX `shm_open` + `mmap`) is loaded
429
+ lazily and only where a runtime provides FFI — Node (via `koffi`), Bun (`bun:ffi`),
430
+ or Deno (`Deno.dlopen`, needs `--allow-ffi --unstable-ffi`). A peer process on the
431
+ same host that attaches the same name resolves the descriptor without copying
432
+ across the process boundary. In the browser (or any runtime without FFI)
433
+ `createShmBackend` rejects with `ShmUnavailableError`; guard with `shmSupported()`
434
+ and fall back to `InProcessBackend` / `ArrowBackend`.
435
+
436
+ ```js
437
+ import { createShmBackend, shmSupported } from "@lazily-hub/lazily-js/transport";
438
+
439
+ if (shmSupported()) {
440
+ const shm = await createShmBackend("my-session", { capacity: 1 << 20 });
441
+ const ref = shm.write(new Uint8Array([1, 2, 3])); // ref.backend === "shm"
442
+ // ...ship `ref` to a peer; the peer attaches `createShmBackend("my-session",
443
+ // { capacity: 1 << 20, create: false })` and calls `shm.readView(ref)`.
444
+ shm.close();
445
+ }
446
+ ```
447
+
448
+ All three runtimes are verified end-to-end, including cross-runtime interop: a
449
+ Node process writes a region that a separate Deno process attaches and resolves,
450
+ proving the layout is byte-identical across FFI implementations. The `shm` region
451
+ is a bump-allocated arena with a fixed header (magic / version / capacity / epoch
452
+ / generation / cursor) and per-entry `{ generation, epoch, len, checksum }`
453
+ validation.
454
+
347
455
  ## Distributed plane
348
456
 
349
457
  The `@lazily-hub/lazily-js/signaling` and `@lazily-hub/lazily-js/distributed`
@@ -384,7 +492,11 @@ lazily-js replays the shared `lazily-spec` fixtures for IPC, agent-doc state,
384
492
  keyed collections (`CellMap`, `CellTree`, LIS reconciliation), semantic tree,
385
493
  sequence and text CRDTs (incl. `TextCrdt` delta sync, `#lztextsync`:
386
494
  `textcrdt_convergence.json` + `textcrdt_delta_sync.json`), manufactured text
387
- identity, Harel state charts, the signaling protocol (`signaling/frames.json`,
495
+ identity, the reactive family / materialization mode (`#lzmatmode`:
496
+ `materialization/observational_transparency.json`,
497
+ `materialization/deferral_not_deallocation.json`,
498
+ `materialization/entry_kind_orthogonal_to_mode.json`), Harel state charts, the
499
+ signaling protocol (`signaling/frames.json`,
388
500
  `signaling/anti_spoof_session.json`), and the distributed CRDT plane
389
501
  (`distributed/crdt_sync_frames.json`, `distributed/anti_entropy_converge.json`).
390
502
  It also validates generated wire values against the canonical JSON Schemas.
@@ -404,6 +516,7 @@ names the Lean theorems it mirrors:
404
516
  | `Reactive` | `reactive-properties.test.js` | `setCell_equal_preserves_graph`, `setCell_different_invalidates_dependents`, `recomputeSlot_equal_preserves_dependents`, `recomputeSlot_different_invalidates_dependents`, `signal_materialized_after_recompute` |
405
517
  | `Collection` | `collection-properties.test.js` | `setEntryValue_preserves_{membership,order,siblings}`, `moveKey_preserves_{membership,values}`, `moveKey_advances_order`, `addKey_advances_membership_and_order`, `Family.get_idempotent_after_first` |
406
518
  | `Tree` | `tree-properties.test.js` | `setNodeValue_preserves_{other_nodes,node_signals}`, `moveChild_preserves_{non_parent,parent_value}`, `moveChild_advances_order_signal_only` |
519
+ | `Materialization` | `reactive-family.test.js` | `observe_canonical`, `eager_lazy_observationally_equivalent`, `eager_materializes_all`, `lazy_defers_slots`, `materialize_present_monotone`, `lazy_present_subset_eager`, `materialize_preserves_observe`, `cell_entries_materialized_in_every_mode`, `slot_entries_deferred_under_lazy` |
407
520
  | `Reconciliation` | `reconciliation-properties.test.js` | `lisBy_longest`, `reconcile_move_minimized`, `reconcile_stable_not_invalidated` |
408
521
  | `AsyncSlotState` | `reactive-async.test.js` | `stale_completeOk_discarded`, `current_completeOk_publishes`, `current_completeErr_to_error` |
409
522
  | `AsyncEffect` | `reactive-async.test.js` | `fire_blocked_during_cleanup`, `invalidate_from_idle_schedules`, `cleanupDone_resumes_deferred`, `dispose_absorbing`, `disposed_terminal` |
@@ -439,6 +552,31 @@ make check # npm run build && npm test
439
552
  `lake` are present.
440
553
  - `npm test` runs the formal check and the Node test suite.
441
554
 
555
+ ## Benchmarks
556
+
557
+ Wall-clock benchmarks live in [`BENCHMARKS.md`](BENCHMARKS.md), with two suites
558
+ built on a zero-dependency `node:perf_hooks` harness:
559
+
560
+ - **Micro-benchmarks** ([`bench/context.bench.mjs`](bench/context.bench.mjs)) — a
561
+ 1:1 port of the single-threaded `Context` cases in lazily-rs's
562
+ `benches/context.rs` (cached reads, cold first get, dependency fan-out,
563
+ set-cell invalidation, memo equality suppression, effect flushing, batch
564
+ storms, typed cache reads) so JS and Rust numbers are directly comparable.
565
+ - **Scale** ([`bench/scale.bench.mjs`](bench/scale.bench.mjs)) — a
566
+ spreadsheet-shaped graph (`N` input cells + `N` formula slots,
567
+ `formula[i] = input[i] + input[i - 1]`) mirroring the lazily-rs/-go/-py `scale`
568
+ groups. At the default `N = 1,000,000` that is ~2M reactive nodes; the
569
+ `LAZILY_SCALE_N=5000000` run covers a full 10M-cell Google Sheets workbook. A
570
+ one-cell edit + 1,000-cell viewport read stays ~100 µs **independent of sheet
571
+ size** — the lazy-pull property a viewport-rendered spreadsheet needs.
572
+
573
+ ```bash
574
+ make bench # micro-suite (prints a markdown table)
575
+ make bench-scale # scale suite at N = 1,000,000
576
+ npm run benchmark-update # refresh BENCHMARKS.md's generated micro-bench table
577
+ npm run benchmark-check # CI gate: exit 1 if the micro-bench row set is stale
578
+ ```
579
+
442
580
  ## See also
443
581
 
444
582
  - [`lazily-spec`][spec] - language-agnostic wire protocol, schemas, and
@@ -0,0 +1,176 @@
1
+ // Large-graph scale benchmark for lazily-js.
2
+ //
3
+ // Replicates the lazily-rs `scale` group (`benches/scale.rs`), the lazily-go
4
+ // `scale` group, and lazily-py's `scale_bench.py` on a spreadsheet-shaped
5
+ // graph: `N` input cells plus `N` formula slots, where
6
+ // `formula[i] = input[i] + input[i - 1]` (local fan-in, like a column of
7
+ // `=A_i + A_{i-1}`). With the default `N = 1_000_000` that is
8
+ // ~2,000,000 reactive nodes. Four scenarios cover the spreadsheet lifecycle:
9
+ //
10
+ // - build — construct all 2N nodes (formulas lazy, not yet computed).
11
+ // - cold_full_recalc — first read of every formula (forces every compute + edge-tracking).
12
+ // - viewport_recalc — edit one input, read only a bounded viewport (the lazy-pull win:
13
+ // off-viewport formulas stay dirty and never recompute).
14
+ // - full_recalc_invalidate_all — touch every input, then read every formula (worst-case full edit).
15
+ //
16
+ // Run as a script:
17
+ //
18
+ // node bench/scale.bench.mjs
19
+ // LAZILY_SCALE_N=1000000 node bench/scale.bench.mjs
20
+ // LAZILY_SCALE_N=5000000 node bench/scale.bench.mjs # Google Sheets 10M-cell workbook
21
+ // LAZILY_SCALE_VIEWPORT=1000 node bench/scale.bench.mjs
22
+ // BENCH_FORMAT=json node bench/scale.bench.mjs # machine-readable output
23
+ //
24
+ // JS runs on a single event-loop thread; this is a single-threaded benchmark
25
+ // (the concurrency surfaces are correctness-tested, not benchmarked here).
26
+
27
+ import { performance } from "node:perf_hooks";
28
+ import { Context } from "../src/reactive.js";
29
+ import { blackBox } from "./harness.mjs";
30
+
31
+ export function scaleN() {
32
+ const raw = Number(process.env.LAZILY_SCALE_N);
33
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1_000_000;
34
+ }
35
+
36
+ export function scaleViewport(n) {
37
+ const raw = Number(process.env.LAZILY_SCALE_VIEWPORT);
38
+ const vp = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1_000;
39
+ return Math.min(vp, n);
40
+ }
41
+
42
+ // Construct N input cells + N formula slots (formulas lazy, not yet computed).
43
+ // formula[i] = input[i] + input[i - 1]; each read goes through ctx.getCell so
44
+ // the running slot is auto-tracked as a dependent (dynamic edge discovery).
45
+ export function buildScaleGraph(n) {
46
+ const ctx = new Context();
47
+ const inputs = new Array(n);
48
+ for (let i = 0; i < n; i++) inputs[i] = ctx.cell(i);
49
+ const formulas = new Array(n);
50
+ for (let i = 0; i < n; i++) {
51
+ const a = inputs[i];
52
+ const b = i > 0 ? inputs[i - 1] : inputs[0];
53
+ formulas[i] = ctx.computed(() => (ctx.getCell(a) + ctx.getCell(b)) >>> 0);
54
+ }
55
+ return { ctx, inputs, formulas };
56
+ }
57
+
58
+ function readAllFormulas(g) {
59
+ const { ctx, formulas } = g;
60
+ let acc = 0;
61
+ for (let i = 0; i < formulas.length; i++) acc = (acc + ctx.get(formulas[i])) >>> 0;
62
+ return acc;
63
+ }
64
+
65
+ function benchBuild(n) {
66
+ const start = performance.now();
67
+ const g = buildScaleGraph(n);
68
+ const elapsed = (performance.now() - start) / 1000;
69
+ const cells = 2 * n;
70
+ blackBox(g.formulas.length);
71
+ return {
72
+ name: "build",
73
+ n,
74
+ seconds: elapsed,
75
+ cells,
76
+ perCellNs: (elapsed / cells) * 1e9,
77
+ detail: `${cells.toLocaleString("en-US")} nodes`,
78
+ };
79
+ }
80
+
81
+ function benchColdFullRecalc(n) {
82
+ const g = buildScaleGraph(n);
83
+ const start = performance.now();
84
+ const sink = readAllFormulas(g);
85
+ const elapsed = (performance.now() - start) / 1000;
86
+ blackBox(sink);
87
+ return {
88
+ name: "cold_full_recalc",
89
+ n,
90
+ seconds: elapsed,
91
+ cells: n,
92
+ perCellNs: (elapsed / n) * 1e9,
93
+ detail: `${n.toLocaleString("en-US")} formulas`,
94
+ };
95
+ }
96
+
97
+ function benchViewportRecalc(n, samples = 1_000) {
98
+ const vp = scaleViewport(n);
99
+ const g = buildScaleGraph(n);
100
+ readAllFormulas(g); // warm the whole sheet once
101
+ const { ctx, inputs, formulas } = g;
102
+ const mid = Math.floor(n / 2);
103
+ const lo = Math.max(0, mid - Math.floor(vp / 2));
104
+ const hi = Math.min(n, lo + vp);
105
+ const window = formulas.slice(lo, hi);
106
+ const midInput = inputs[mid];
107
+ let acc = 0;
108
+ const start = performance.now();
109
+ for (let i = 0; i < samples; i++) {
110
+ ctx.setCell(midInput, i + 1); // edit one input (monotonic → passes PartialEq guard)
111
+ for (let j = 0; j < window.length; j++) acc = (acc + ctx.get(window[j])) >>> 0;
112
+ }
113
+ const elapsed = performance.now() - start; // ms total
114
+ blackBox(acc); // defeat dead-code elimination of the viewport reads
115
+ const perOpUs = (elapsed / samples) * 1000;
116
+ return {
117
+ name: "viewport_recalc",
118
+ n,
119
+ seconds: elapsed / 1000 / samples,
120
+ cells: vp,
121
+ perCellNs: null,
122
+ detail: `${perOpUs.toFixed(2)} us/edit, viewport=${vp}, ${samples} edits`,
123
+ };
124
+ }
125
+
126
+ function benchFullRecalcInvalidateAll(n) {
127
+ const g = buildScaleGraph(n);
128
+ readAllFormulas(g); // warm once
129
+ const { ctx, inputs } = g;
130
+ const start = performance.now();
131
+ for (let j = 0; j < inputs.length; j++) ctx.setCell(inputs[j], j + 1); // touch every input
132
+ const sink = readAllFormulas(g);
133
+ const elapsed = (performance.now() - start) / 1000;
134
+ blackBox(sink);
135
+ return {
136
+ name: "full_recalc_invalidate_all",
137
+ n,
138
+ seconds: elapsed,
139
+ cells: n,
140
+ perCellNs: (elapsed / n) * 1e9,
141
+ detail: `${n.toLocaleString("en-US")} inputs re-set + ${n.toLocaleString("en-US")} formulas recomputed`,
142
+ };
143
+ }
144
+
145
+ export function runScaleBenchmarks(n = scaleN()) {
146
+ return [
147
+ benchBuild(n),
148
+ benchColdFullRecalc(n),
149
+ benchViewportRecalc(n),
150
+ benchFullRecalcInvalidateAll(n),
151
+ ];
152
+ }
153
+
154
+ function fmtCell(perCellNs) {
155
+ return perCellNs == null ? "—" : `${perCellNs.toFixed(1).padStart(8)} ns/cell`;
156
+ }
157
+
158
+ function main() {
159
+ const n = scaleN();
160
+ const results = runScaleBenchmarks(n);
161
+ if (process.env.BENCH_FORMAT === "json") {
162
+ console.log(JSON.stringify({ n, nodes: 2 * n, results }, null, 2));
163
+ return;
164
+ }
165
+ console.log(
166
+ `lazily-js scale benchmarks (N=${n.toLocaleString("en-US")}, ${(2 * n).toLocaleString("en-US")} reactive nodes)`,
167
+ );
168
+ for (const r of results) {
169
+ const name = r.name.padEnd(28);
170
+ const size = `N=${n.toLocaleString("en-US").padStart(13)}`;
171
+ const ms = `${(r.seconds * 1000).toFixed(3).padStart(12)} ms`;
172
+ console.log(`${name} ${size} ${ms} ${fmtCell(r.perCellNs)} ${r.detail}`);
173
+ }
174
+ }
175
+
176
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.7.0",
3
+ "version": "0.10.0",
4
4
  "description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), the lazily-spec IPC wire types, keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, and an FFI state-projection consumer.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,6 +15,10 @@
15
15
  "types": "./src/index.d.ts",
16
16
  "default": "./src/index.js"
17
17
  },
18
+ "./transport": {
19
+ "types": "./src/transport.d.ts",
20
+ "default": "./src/transport.js"
21
+ },
18
22
  "./reactive": {
19
23
  "types": "./src/reactive.d.ts",
20
24
  "default": "./src/reactive.js"
@@ -23,6 +27,10 @@
23
27
  "types": "./src/reactive-async.d.ts",
24
28
  "default": "./src/reactive-async.js"
25
29
  },
30
+ "./reactive-family": {
31
+ "types": "./src/reactive-family.d.ts",
32
+ "default": "./src/reactive-family.js"
33
+ },
26
34
  "./state-machine": {
27
35
  "types": "./src/state-machine.d.ts",
28
36
  "default": "./src/state-machine.js"
@@ -73,9 +81,10 @@
73
81
  }
74
82
  },
75
83
  "scripts": {
76
- "build": "node --check src/index.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/signaling.js && node --check src/distributed.js",
84
+ "build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/signaling.js && node --check src/distributed.js",
77
85
  "test:formal": "node scripts/formal-check.mjs",
78
86
  "bench": "node bench/context.bench.mjs",
87
+ "bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
79
88
  "benchmark-update": "node scripts/run-benchmarks.mjs",
80
89
  "benchmark-check": "node scripts/run-benchmarks.mjs --check",
81
90
  "test": "npm run test:formal && node --test test/*.test.js"
@@ -103,6 +103,17 @@ export class CrdtPlaneRuntime {
103
103
  since: Iterable<FrontierEntry | [PeerId, WireStamp | unknown]>,
104
104
  ): CrdtSync;
105
105
  syncReply(request: CrdtSync): CrdtSync;
106
+ // Family sync (#lzfamilysync)
107
+ registerFamilyLww(namespace: string): this;
108
+ membershipEpoch(): number;
109
+ familyKeys(namespace: string): string[];
110
+ familyValueLww(namespace: string, keySuffix: string): boolean | undefined;
111
+ familySetLww(
112
+ namespace: string,
113
+ keySuffix: string,
114
+ value: boolean,
115
+ nowMicros: number,
116
+ ): CrdtOp | null;
106
117
  }
107
118
 
108
119
  // --- Browser WebRTC platform adapter ---