@lazily-hub/lazily-js 0.5.0 → 0.7.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.
@@ -39,9 +39,13 @@ jobs:
39
39
  node-version: 24
40
40
  registry-url: https://registry.npmjs.org
41
41
 
42
- # Trusted publishing needs npm CLI >= 11.5.1; Node 24's bundled npm may lag.
43
- - name: Upgrade npm for OIDC trusted publishing
44
- run: npm install -g npm@latest
42
+ # OIDC trusted publishing (provenance) needs npm >= 9.5.0; Node 24's
43
+ # bundled npm 11.x already supports it. Do NOT `npm install -g npm@latest`
44
+ # that global self-upgrade drops the `sigstore` dependency that
45
+ # libnpmpublish requires for provenance, producing
46
+ # `Cannot find module 'sigstore'` at publish time.
47
+ - name: Verify npm supports OIDC provenance
48
+ run: npm --version
45
49
 
46
50
  - name: Install dependencies
47
51
  working-directory: lazily-js
@@ -51,15 +55,19 @@ jobs:
51
55
  working-directory: lazily-js
52
56
  run: npm test --if-present
53
57
 
54
- # Published under the `prev` dist-tag so this v0.x line does not steal
55
- # `latest` from a higher already-published version; move `latest` explicitly
56
- # (`npm dist-tag add @lazily-hub/lazily-js@<v> latest`) once the accidental
57
- # v1.x releases are removed.
58
+ # Publish as `latest`. The accidental v1.x versions that forced the old
59
+ # `prev` dist-tag workaround have been removed (npm now shows only the
60
+ # 0.4.0 / 0.5.0 / 0.6.x line, latest = 0.6.1), so the v0.x line is the
61
+ # canonical latest again.
58
62
  - name: Publish
59
63
  working-directory: lazily-js
60
64
  run: |
61
65
  version="$(node -p "require('./package.json').version")"
62
- if npm publish --access public --tag prev; then
66
+ # --provenance triggers the OIDC trusted-publishing exchange (the
67
+ # `id-token: write` permission minted a short-lived publish token).
68
+ # The bundled npm 11.x does not auto-enable provenance the way a
69
+ # freshly-upgraded npm@latest does, so it must be explicit.
70
+ if npm publish --provenance --access public; then
63
71
  echo "published @lazily-hub/lazily-js@${version}"
64
72
  elif npm view "@lazily-hub/lazily-js@${version}" version >/dev/null 2>&1; then
65
73
  echo "@lazily-hub/lazily-js@${version} already published — idempotent no-op"
package/BENCHMARKS.md ADDED
@@ -0,0 +1,91 @@
1
+ # lazily-js Benchmark Results
2
+
3
+ Generated benchmark data for the
4
+ [`@lazily-hub/lazily-js`](https://www.npmjs.com/package/@lazily-hub/lazily-js)
5
+ reactive primitives library.
6
+
7
+ ## Benchmark Results
8
+
9
+ The suite is a 1:1 port of the single-threaded `Context` benchmarks in
10
+ lazily-rs's `benches/context.rs` — the same group/case names and the same
11
+ widths/depths (`FAN_OUT_WIDTHS=[32,256]`, `MEMO_CHAIN_DEPTH=32`,
12
+ `BATCH_STORM_CELLS=64`, `SET_CELL_INVALIDATION_FAN_OUT=512`) so JS and Rust
13
+ numbers are directly comparable.
14
+
15
+ JS runs on a single event-loop thread, so the lazily-rs `ThreadSafeContext`
16
+ multi-worker contention benchmarks (`thread_safe_contention`,
17
+ `thread_safe_effect_contention`, `thread_safe_graph_propagation`) have no
18
+ like-for-like single-process counterpart here and are intentionally omitted.
19
+
20
+ <!-- benchmark-results:start -->
21
+
22
+ Generated for package `@lazily-hub/lazily-js` version `0.6.0`.
23
+
24
+ Environment: Node.js `26.4.0` on `linux x64`.
25
+
26
+ Refresh command:
27
+
28
+ ```bash
29
+ node scripts/run-benchmarks.mjs
30
+ ```
31
+
32
+ Mean wall-clock time per iteration; 95% CI half-width from the standard error.
33
+
34
+ | Group | Case | Mean | 95% CI | p75 | p99 | Samples |
35
+ |---|---|---:|---:|---:|---:|---:|
36
+ | cached_reads | context | 57.798 ns | ± 20.941 ns | 46.398 ns | 124.744 ns | 100 |
37
+ | cold_first_get | context | 669.220 ns | ± 8.630 ns | 670.000 ns | 802.610 ns | 100 |
38
+ | dependency_fan_out | context / 32 | 7.448 us | ± 824.968 ns | 6.783 us | 34.205 us | 100 |
39
+ | dependency_fan_out | context / 256 | 43.766 us | ± 1.064 us | 43.270 us | 49.513 us | 100 |
40
+ | set_cell_invalidation | high_fan_out / 512 | 10.737 us | ± 376.524 ns | 10.113 us | 17.141 us | 100 |
41
+ | memo_equality_suppression | context | 2.387 us | ± 193.801 ns | 2.310 us | 2.864 us | 100 |
42
+ | effect_flushing | context | 211.187 ns | ± 20.150 ns | 221.482 ns | 524.491 ns | 100 |
43
+ | batch_storms | context / 64 | 30.430 us | ± 1.295 us | 30.815 us | 49.775 us | 99 |
44
+ | typed_cache_reads | context_cell | 31.325 ns | ± 1.052 ns | 30.255 ns | 58.102 ns | 100 |
45
+ | typed_cache_reads | context_slot | 120.262 ns | ± 4.805 ns | 118.099 ns | 220.409 ns | 100 |
46
+
47
+ <!-- benchmark-results:end -->
48
+
49
+ ## Suite
50
+
51
+ | Group | Case | Parity with lazily-rs | What it measures |
52
+ |-------|------|-----------------------|------------------|
53
+ | `cached_reads` | `context` | `bench_cached_reads` / context | Steady-state cached `ctx.get(slot)` (no recompute). |
54
+ | `cold_first_get` | `context` | `bench_cold_first_get` / context | First (uncached) read of a freshly built slot. |
55
+ | `dependency_fan_out` | `context / {32,256}` | `bench_dependency_fan_out` / context | One root cell invalidates N dependents, then all are re-read. |
56
+ | `set_cell_invalidation` | `high_fan_out / 512` | `bench_set_cell_invalidation` / high_fan_out | Cost of `setCell` invalidating a 512-wide fan-out (no recompute). |
57
+ | `memo_equality_suppression` | `context` | `bench_memo_equality_suppression` / context | Memo chain that stays equal downstream (guard suppresses recompute). |
58
+ | `effect_flushing` | `context` | `bench_effect_flushing` / context | Effect re-runs on every cell change. |
59
+ | `batch_storms` | `context / 64` | `bench_batch_storms` / context | Coalesced batched writes to 64 cells (one effect flush). |
60
+ | `typed_cache_reads` | `context_slot`, `context_cell` | `bench_typed_cache_reads` / context_* | Direct `ctx.get(slot)` vs `ctx.getCell(cell)`. |
61
+
62
+ ### Running
63
+
64
+ ```bash
65
+ npm run bench # run the suite, print a markdown table
66
+ node scripts/run-benchmarks.mjs # run + refresh this file
67
+ node scripts/run-benchmarks.mjs --check # CI gate: exit 1 if stale
68
+ make bench # via the Makefile
69
+ ```
70
+
71
+ ### Harness
72
+
73
+ `bench/harness.mjs` is a zero-dependency criterion-style harness
74
+ (`node:perf_hooks` only). It offers two measurement modes:
75
+
76
+ - **`bench(group, case, fn)`** — auto-batched timing for *idempotent* routines
77
+ (each sample times N calls, N auto-scaled so one sample lasts ~5 ms). Use for
78
+ steady-state reads / repeated-equal-cost work.
79
+ - **`bench.batched(group, case, setup, routine)`** — `iter_batched` timing
80
+ (criterion `BatchSize::SmallInput`): fresh `setup()` before every single
81
+ measured call, setup excluded from timing. Use when the operation mutates graph
82
+ state a second call would skip — e.g. the `==` guard on `setCell`, or a cold
83
+ first read that caches on first access.
84
+
85
+ ### Regression workflow
86
+
87
+ ```bash
88
+ node scripts/run-benchmarks.mjs # record "before" baseline in BENCHMARKS.md
89
+ # apply the performance patch
90
+ node scripts/run-benchmarks.mjs # compare against the new numbers
91
+ ```
package/README.md CHANGED
@@ -35,29 +35,37 @@ The full `lazily` capability set across every binding. Legend: ✅ shipped ·
35
35
  notes and platform carve-outs lives in
36
36
  [`lazily-spec` § Cross-Language Coverage](../lazily-spec/docs/coverage.md).
37
37
 
38
- | Feature | Rust | Python | Kotlin | JS | Dart | Zig |
39
- |---------|:----:|:------:|:------:|:--:|:----:|:---:|
40
- | Reactive graph `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | | ~ | | | ~ | ~ |
41
- | Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | | | ✅ |
42
- | Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | |
43
- | Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
44
- | Harel state charts | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
45
- | Keyed cell collections (`CellMap` / `CellTree`) + reconcile | ✅ | ✅ | ✅ | ✅ | ✅ | ~ |
46
- | Memoized semantic tree (`SemTree`) | ✅ | | ✅ | ✅ | | |
47
- | Stable-id alignment (manufactured identity) | ✅ | | ✅ | ✅ | | |
48
- | Free-text character CRDT (`TextCrdt`) | ✅ | | ✅ | ✅ | | |
49
- | `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | — | ✅ | ✅ | — | — |
50
- | Move-aware sequence CRDT (`SeqCrdt`) | ✅ | | ✅ | ✅ | | |
51
- | Registers (LWW / MV) + `PnCounter` + `CellCrdt` | ✅ | | ✅ | ✅ | | |
52
- | IPC wire `Snapshot` + `Delta` + `CrdtSync` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
53
- | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ~ | ~ | ✅ |
54
- | Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | | ✅ | ✅ | ~ | — |
55
- | Distributed planeWebRTC transport + signaling | ✅ | | ✅ | ✅ | — | |
56
- | State projection / mirror | ✅ | | ✅ | ✅ | | |
57
- | C-ABI FFI boundary | ✅ | ✅ | ✅ | | ✅ | ✅ |
58
- | Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
59
- | Capability negotiation (`SessionHandshake`) | ✅ | | ✅ | ✅ | ✅ | ✅ |
60
- | Instrumentation / benchmarks | ✅ | | | | | |
38
+ <!-- coverage-table:start -->
39
+ | Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
40
+ | --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
41
+ | Reactive graph `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ |
42
+ | Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | |
43
+ | Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
44
+ | Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
45
+ | Harel state charts | | | ✅ | ✅ | ✅ | ✅ | ✅ | |
46
+ | Keyed cell collections (`CellMap` / `CellTree`) + reconcile | ✅ | | ✅ | ✅ | | | ✅ | ✅ |
47
+ | Memoized semantic tree (`SemTree`) | ✅ | | ✅ | ✅ | | | ✅ | ✅ |
48
+ | Stable-id alignment (manufactured identity) | ✅ | | ✅ | ✅ | | | ✅ | ✅ |
49
+ | Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | — | ✅ | ✅ | — | ✅ | — | ✅ |
50
+ | Free-text character CRDT (`TextCrdt`) | ✅ | | ✅ | ✅ | | | ✅ | ✅ |
51
+ | `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | | ✅ | ✅ | | | ✅ | ✅ |
52
+ | Move-aware sequence CRDT (`SeqCrdt`) | | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
53
+ | Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | | | — | ✅ | ✅ |
54
+ | Lossless tree dotted-frontier anti-entropy | ✅ | | ✅ | ✅ | | — | ✅ | ✅ |
55
+ | Lossless treeconcurrent merge convergence | | ✅ | | ✅ | ✅ | — | | ✅ |
56
+ | Registers (LWW / MV) + `PnCounter` + `CellCrdt` | ✅ | | ✅ | ✅ | | | ✅ | ✅ |
57
+ | IPC wire `Snapshot` + `Delta` + `CrdtSync` | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ |
58
+ | Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ~ | ~ | ✅ | ✅ | ✅ |
59
+ | Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
60
+ | Distributed plane WebRTC transport + signaling | ✅ | | | | | | ✅ | ✅ |
61
+ | State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
62
+ | Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
63
+ | Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
64
+ | C-ABI FFI boundary | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
65
+ | Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
66
+ | Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
67
+ | Instrumentation / benchmarks | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
68
+ <!-- coverage-table:end -->
61
69
 
62
70
  CRDT convergence and the wire protocol are pinned by the shared conformance fixtures
63
71
  and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
@@ -374,11 +382,12 @@ bob.ingest(alice.syncFrame(), Date.now() * 1000); // 1 op applied; re-ingest app
374
382
 
375
383
  lazily-js replays the shared `lazily-spec` fixtures for IPC, agent-doc state,
376
384
  keyed collections (`CellMap`, `CellTree`, LIS reconciliation), semantic tree,
377
- sequence and text CRDTs, manufactured text identity, Harel state charts, the
378
- signaling protocol (`signaling/frames.json`, `signaling/anti_spoof_session.json`),
379
- and the distributed CRDT plane (`distributed/crdt_sync_frames.json`,
380
- `distributed/anti_entropy_converge.json`). It also validates generated wire
381
- values against the canonical JSON Schemas.
385
+ sequence and text CRDTs (incl. `TextCrdt` delta sync, `#lztextsync`:
386
+ `textcrdt_convergence.json` + `textcrdt_delta_sync.json`), manufactured text
387
+ identity, Harel state charts, the signaling protocol (`signaling/frames.json`,
388
+ `signaling/anti_spoof_session.json`), and the distributed CRDT plane
389
+ (`distributed/crdt_sync_frames.json`, `distributed/anti_entropy_converge.json`).
390
+ It also validates generated wire values against the canonical JSON Schemas.
382
391
 
383
392
  `npm test` builds the [`lazily-formal`][formal] Lean 4 model when that sibling
384
393
  checkout and the `lake` toolchain are present. The script exits successfully
@@ -0,0 +1,179 @@
1
+ // Reactive-graph benchmarks for lazily-js — parity port of lazily-rs
2
+ // `benches/context.rs` (the single-threaded `Context` variants).
3
+ //
4
+ // JS runs on a single event-loop thread, so the lazily-rs `ThreadSafeContext`
5
+ // multi-worker contention benchmarks (thread_safe_contention,
6
+ // thread_safe_effect_contention, thread_safe_graph_propagation) have no
7
+ // like-for-like single-process counterpart here and are intentionally omitted.
8
+ // Every group/case below is a 1:1 port of the matching `context`-variant in
9
+ // lazily-rs, using the same widths/depths so numbers are cross-language
10
+ // comparable. Constants mirror `benches/context.rs` exactly.
11
+
12
+ import { bench, blackBox, run } from "./harness.mjs";
13
+ import { Context } from "../src/reactive.js";
14
+
15
+ const FAN_OUT_WIDTHS = [32, 256];
16
+ const MEMO_CHAIN_DEPTH = 32;
17
+ const BATCH_STORM_CELLS = 64;
18
+ const SET_CELL_INVALIDATION_FAN_OUT = 512;
19
+
20
+ // --- setup helpers (ports of setup_context_* in benches/context.rs) ---------
21
+
22
+ function setupFanOut(width) {
23
+ const ctx = new Context();
24
+ const root = ctx.cell(0);
25
+ const slots = [];
26
+ for (let offset = 0; offset < width; offset++) {
27
+ slots.push(ctx.computed(() => (ctx.getCell(root) + offset) >>> 0));
28
+ }
29
+ for (const slot of slots) blackBox(ctx.get(slot));
30
+ return { ctx, root, slots };
31
+ }
32
+
33
+ function setupMemoChain(depth) {
34
+ const ctx = new Context();
35
+ const root = ctx.cell(0);
36
+ let tail = ctx.memo(() => ctx.getCell(root) % 2);
37
+ for (let i = 0; i < depth; i++) {
38
+ const previous = tail;
39
+ tail = ctx.computed(() => (ctx.get(previous) + 1) >>> 0);
40
+ }
41
+ blackBox(ctx.get(tail));
42
+ return { ctx, root, tail };
43
+ }
44
+
45
+ function setupBatchStorm(cellsLen) {
46
+ const ctx = new Context();
47
+ const cells = [];
48
+ for (let idx = 0; idx < cellsLen; idx++) cells.push(ctx.cell(idx));
49
+ let sink = 0;
50
+ const effectCells = cells.slice();
51
+ ctx.effect(() => {
52
+ let total = 0;
53
+ for (const cell of effectCells) total = (total + ctx.getCell(cell)) >>> 0;
54
+ sink = total;
55
+ });
56
+ return { ctx, cells, getSink: () => sink };
57
+ }
58
+
59
+ // --- cached_reads: steady-state cached slot read ----------------------------
60
+ // Parity: bench_cached_reads "context" in lazily-rs/context.rs.
61
+ // Setup runs once before registration; only the cached `get` is timed.
62
+ {
63
+ const ctx = new Context();
64
+ const root = ctx.cell(21);
65
+ const doubled = ctx.computed(() => ctx.getCell(root) * 2);
66
+ blackBox(ctx.get(doubled)); // prime the cache
67
+ bench("cached_reads", "context", () => blackBox(ctx.get(blackBox(doubled))));
68
+ }
69
+
70
+ // --- cold_first_get: first (uncached) read of a freshly built slot ---------
71
+ // Parity: bench_cold_first_get "context" (iter_batched — fresh ctx each call).
72
+ bench.batched(
73
+ "cold_first_get",
74
+ "context",
75
+ () => {
76
+ const ctx = new Context();
77
+ const root = ctx.cell(21);
78
+ const doubled = ctx.computed(() => ctx.getCell(root) * 2);
79
+ return { ctx, doubled };
80
+ },
81
+ ({ ctx, doubled }) => blackBox(ctx.get(blackBox(doubled))),
82
+ );
83
+
84
+ // --- dependency_fan_out: one root cell invalidates N dependents ------------
85
+ // Parity: bench_dependency_fan_out "context / {32,256}" (iter_batched).
86
+ for (const width of FAN_OUT_WIDTHS) {
87
+ bench.batched(
88
+ "dependency_fan_out",
89
+ `context / ${width}`,
90
+ () => setupFanOut(width),
91
+ ({ ctx, root, slots }) => {
92
+ ctx.setCell(root, blackBox(1));
93
+ let total = 0;
94
+ for (const slot of slots) total = (total + ctx.get(slot)) >>> 0;
95
+ blackBox(total);
96
+ },
97
+ );
98
+ }
99
+
100
+ // --- set_cell_invalidation: cost of invalidating a wide fan-out ------------
101
+ // Parity: bench_set_cell_invalidation "high_fan_out / 512" (iter_batched).
102
+ // Only the setCell invalidation is timed (not the downstream recompute) —
103
+ // matches the lazily-rs routine which times `set_cell` + `black_box(slots.len())`.
104
+ bench.batched(
105
+ "set_cell_invalidation",
106
+ "high_fan_out / 512",
107
+ () => setupFanOut(SET_CELL_INVALIDATION_FAN_OUT),
108
+ ({ ctx, root, slots }) => {
109
+ ctx.setCell(root, blackBox(1));
110
+ blackBox(slots.length);
111
+ },
112
+ );
113
+
114
+ // --- memo_equality_suppression: memo chain that stays equal downstream -----
115
+ // Parity: bench_memo_equality_suppression "context" (iter_batched).
116
+ // root%2 memo head; setting root to an even value keeps the head's value equal
117
+ // (0) so downstream caches survive — the memo guard path.
118
+ bench.batched(
119
+ "memo_equality_suppression",
120
+ "context",
121
+ () => setupMemoChain(MEMO_CHAIN_DEPTH),
122
+ ({ ctx, root, tail }) => {
123
+ ctx.setCell(root, blackBox(2));
124
+ blackBox(ctx.get(blackBox(tail)));
125
+ },
126
+ );
127
+
128
+ // --- effect_flushing: effect re-runs on every cell change ------------------
129
+ // Parity: bench_effect_flushing "context" (idempotent: advances next each call
130
+ // so each setCell is a real 1->2->3 ... change, not a ==-guarded no-op).
131
+ {
132
+ const ctx = new Context();
133
+ const root = ctx.cell(0);
134
+ let seen = 0;
135
+ ctx.effect(() => {
136
+ seen = (seen + ctx.getCell(root)) >>> 0;
137
+ });
138
+ let next = 0;
139
+ bench("effect_flushing", "context", () => {
140
+ next = (next + 1) >>> 0;
141
+ ctx.setCell(root, blackBox(next));
142
+ blackBox(seen);
143
+ });
144
+ }
145
+
146
+ // --- batch_storms: coalesced batched writes to many cells -----------------
147
+ // Parity: bench_batch_storms "context / 64".
148
+ {
149
+ const { ctx, cells, getSink } = setupBatchStorm(BATCH_STORM_CELLS);
150
+ let base = BATCH_STORM_CELLS;
151
+ bench("batch_storms", `context / ${BATCH_STORM_CELLS}`, () => {
152
+ base = (base + BATCH_STORM_CELLS) >>> 0;
153
+ ctx.batch(() => {
154
+ for (let i = 0; i < cells.length; i++) {
155
+ ctx.setCell(cells[i], blackBox((base + i) >>> 0));
156
+ }
157
+ });
158
+ blackBox(getSink());
159
+ });
160
+ }
161
+
162
+ // --- typed_cache_reads: direct slot/cell reads ----------------------------
163
+ // Parity: bench_typed_cache_reads "context_slot" / "context_cell".
164
+ {
165
+ const ctx = new Context();
166
+ const cell = ctx.cell(42);
167
+ const slot = ctx.computed(() => ctx.getCell(cell));
168
+ blackBox(ctx.get(slot)); // prime
169
+ bench("typed_cache_reads", "context_slot", () => blackBox(ctx.get(blackBox(slot))));
170
+ }
171
+ {
172
+ const ctx = new Context();
173
+ const cell = ctx.cell(99);
174
+ bench("typed_cache_reads", "context_cell", () => blackBox(ctx.getCell(blackBox(cell))));
175
+ }
176
+
177
+ await run({
178
+ format: process.env.BENCH_FORMAT === "json" ? "json" : "markdown",
179
+ });
@@ -0,0 +1,144 @@
1
+ // Criterion-style micro-benchmark harness for lazily-js.
2
+ //
3
+ // Mirrors the lazily-rs `benches/context.rs` measurement model (criterion 0.8):
4
+ // - auto-batched timing for *idempotent* routines (each sample times N calls,
5
+ // N auto-scaled so one sample lasts ~TARGET_SAMPLE_MS), and
6
+ // - iter_batched timing for *non-idempotent* routines (fresh `setup()` before
7
+ // every single measured call, setup excluded from the timed region), so a
8
+ // case like `setCell(root, 1)` is always a real 0->1 invalidation rather
9
+ // than a `==`-guarded no-op on the second call.
10
+ //
11
+ // Zero dependencies (node:perf_hooks only) — keeps the published package lean.
12
+ // Emits a Group/Case/Mean/95% CI/p75/p99/Samples markdown table matching
13
+ // lazily-rs's BENCHMARKS.md so cross-language numbers are directly comparable.
14
+
15
+ import { performance } from "node:perf_hooks";
16
+
17
+ // Sink the optimizer cannot eliminate — black_box analog (force a read+write
18
+ // through a module-level mutable captured by an exported function).
19
+ let __blackHole = undefined;
20
+ export function blackBox(value) {
21
+ __blackHole = value;
22
+ return value;
23
+ }
24
+ export function doNotOptimize(value) {
25
+ return blackBox(value);
26
+ }
27
+
28
+ const TARGET_SAMPLE_MS = 5; // ~criterion measurement_time per sample
29
+ const MIN_SAMPLES = 20;
30
+ const MAX_SAMPLES = 100;
31
+ const WARMUP_ITERS = 64;
32
+ const NS_PER_MS = 1e6;
33
+ const MIN_RUN_MS = 500;
34
+
35
+ function quantile(sorted, q) {
36
+ if (sorted.length === 0) return 0;
37
+ const pos = q * (sorted.length - 1);
38
+ const lo = Math.floor(pos);
39
+ const hi = Math.ceil(pos);
40
+ if (lo === hi) return sorted[lo];
41
+ const frac = pos - lo;
42
+ return sorted[lo] * (1 - frac) + sorted[hi] * frac;
43
+ }
44
+
45
+ function summarize(samplesNs) {
46
+ const sorted = [...samplesNs].sort((a, b) => a - b);
47
+ const mean = sorted.reduce((a, v) => a + v, 0) / sorted.length;
48
+ const variance =
49
+ sorted.reduce((a, v) => a + (v - mean) ** 2, 0) / sorted.length;
50
+ const sem = Math.sqrt(variance / sorted.length);
51
+ return {
52
+ mean,
53
+ p50: quantile(sorted, 0.5),
54
+ p75: quantile(sorted, 0.75),
55
+ p99: quantile(sorted, 0.99),
56
+ samples: sorted.length,
57
+ ci: 1.96 * sem, // 95% CI half-width (t≈1.96 for n≥20)
58
+ };
59
+ }
60
+
61
+ export function format(ns) {
62
+ if (ns >= 1e6) return `${(ns / 1e6).toFixed(3)} ms`;
63
+ if (ns >= 1e3) return `${(ns / 1e3).toFixed(3)} us`;
64
+ return `${ns.toFixed(3)} ns`;
65
+ }
66
+
67
+ // Auto-batched measurement for an idempotent routine (criterion `iter`).
68
+ function measure(fn, { minMs = MIN_RUN_MS } = {}) {
69
+ for (let i = 0; i < WARMUP_ITERS; i++) fn();
70
+ // Calibrate iteration count so one sample hits TARGET_SAMPLE_MS.
71
+ const t0 = performance.now();
72
+ fn();
73
+ const oneNs = (performance.now() - t0) * NS_PER_MS;
74
+ let iters = oneNs > 0 ? Math.max(1, Math.ceil((TARGET_SAMPLE_MS * NS_PER_MS) / oneNs)) : 1;
75
+
76
+ const samples = [];
77
+ let elapsed = 0;
78
+ while ((samples.length < MIN_SAMPLES || elapsed < minMs) && samples.length < MAX_SAMPLES) {
79
+ const start = performance.now();
80
+ for (let i = 0; i < iters; i++) fn();
81
+ const dtMs = performance.now() - start;
82
+ samples.push((dtMs * NS_PER_MS) / iters);
83
+ elapsed += dtMs;
84
+ }
85
+ return summarize(samples);
86
+ }
87
+
88
+ // iter_batched measurement: fresh setup() before every single measured call,
89
+ // setup excluded from timing (criterion `iter_batched`, BatchSize::SmallInput).
90
+ function measureBatched(setup, routine, { minMs = MIN_RUN_MS, minSamples = MIN_SAMPLES } = {}) {
91
+ for (let i = 0; i < WARMUP_ITERS; i++) routine(setup());
92
+ const samples = [];
93
+ let elapsed = 0;
94
+ while ((samples.length < minSamples || elapsed < minMs) && samples.length < MAX_SAMPLES) {
95
+ const ctx = setup();
96
+ const start = performance.now();
97
+ routine(ctx);
98
+ const ns = (performance.now() - start) * NS_PER_MS;
99
+ samples.push(ns);
100
+ elapsed += ns / NS_PER_MS;
101
+ }
102
+ return summarize(samples);
103
+ }
104
+
105
+ const registry = [];
106
+
107
+ // Idempotent routine: the function is called many times per sample, so it must
108
+ // be safe to repeat (each call performs the same amount of real work).
109
+ export function bench(group, caseLabel, fn, opts = {}) {
110
+ registry.push({ group, case: caseLabel, kind: "fn", fn, opts });
111
+ }
112
+
113
+ // Non-idempotent routine: setup() runs before every single measured call and is
114
+ // excluded from timing; routine(ctx) runs once per sample. Use this whenever the
115
+ // operation mutates graph state that a second call would skip (e.g. the `==`
116
+ // guard on setCell, or a cold first read that caches on first access).
117
+ bench.batched = function batched(group, caseLabel, setup, routine, opts = {}) {
118
+ registry.push({ group, case: caseLabel, kind: "batched", setup, routine, opts });
119
+ };
120
+
121
+ export async function run({ print = true, format = "markdown" } = {}) {
122
+ const results = [];
123
+ for (const entry of registry) {
124
+ let stats;
125
+ if (entry.kind === "fn") stats = measure(entry.fn, entry.opts);
126
+ else stats = measureBatched(entry.setup, entry.routine, entry.opts);
127
+ results.push({ group: entry.group, case: entry.case, ...stats });
128
+ }
129
+ if (print) {
130
+ if (format === "json") console.log(JSON.stringify({ results }, null, 2));
131
+ else printMarkdown(results);
132
+ }
133
+ return results;
134
+ }
135
+
136
+ function printMarkdown(results) {
137
+ console.log("| Group | Case | Mean | 95% CI | p75 | p99 | Samples |");
138
+ console.log("|---|---|---:|---:|---:|---:|---:|");
139
+ for (const r of results) {
140
+ console.log(
141
+ `| ${r.group} | ${r.case} | ${format(r.mean)} | ± ${format(r.ci)} | ${format(r.p75)} | ${format(r.p99)} | ${r.samples} |`,
142
+ );
143
+ }
144
+ }
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.5.0",
3
+ "version": "0.7.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
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/lazily-hub/lazily-js.git"
9
+ },
5
10
  "type": "module",
6
11
  "main": "src/index.js",
7
12
  "types": "src/index.d.ts",
@@ -30,6 +35,10 @@
30
35
  "types": "./src/collections.d.ts",
31
36
  "default": "./src/collections.js"
32
37
  },
38
+ "./queue": {
39
+ "types": "./src/queue.d.ts",
40
+ "default": "./src/queue.js"
41
+ },
33
42
  "./sem-tree": {
34
43
  "types": "./src/sem-tree.d.ts",
35
44
  "default": "./src/sem-tree.js"
@@ -46,6 +55,10 @@
46
55
  "types": "./src/text-crdt.d.ts",
47
56
  "default": "./src/text-crdt.js"
48
57
  },
58
+ "./lossless-tree-crdt": {
59
+ "types": "./src/lossless-tree-crdt.d.ts",
60
+ "default": "./src/lossless-tree-crdt.js"
61
+ },
49
62
  "./state-projection": {
50
63
  "types": "./src/state-projection.d.ts",
51
64
  "default": "./src/state-projection.js"
@@ -60,8 +73,11 @@
60
73
  }
61
74
  },
62
75
  "scripts": {
63
- "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/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/state-projection.js && node --check src/signaling.js && node --check src/distributed.js",
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",
64
77
  "test:formal": "node scripts/formal-check.mjs",
78
+ "bench": "node bench/context.bench.mjs",
79
+ "benchmark-update": "node scripts/run-benchmarks.mjs",
80
+ "benchmark-check": "node scripts/run-benchmarks.mjs --check",
65
81
  "test": "npm run test:formal && node --test test/*.test.js"
66
82
  },
67
83
  "dependencies": {