@lazily-hub/lazily-js 0.24.0 → 0.25.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.
@@ -0,0 +1,112 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ test:
12
+ name: Node
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v4
18
+
19
+ # lazily-spec is the canonical cross-language wire-protocol source. Clone
20
+ # it as a sibling BEFORE any test step so every test file that reads
21
+ # ../lazily-spec/conformance has the canonical fixtures — CI then
22
+ # validates lazily-js against canonical fixtures and fails on drift
23
+ # (#lzspecconf).
24
+ - name: Fetch canonical lazily-spec conformance fixtures (#lzspecconf)
25
+ run: git clone --depth 1 https://github.com/lazily-hub/lazily-spec.git ../lazily-spec
26
+
27
+ - name: Set up Node
28
+ uses: actions/setup-node@v4
29
+ with:
30
+ node-version: 24
31
+
32
+ - name: Install dependencies
33
+ run: npm ci || npm install
34
+
35
+ # `npm run build` is this repo's lint/typecheck equivalent: it syntax-checks
36
+ # every published entry point with `node --check` and regenerates the
37
+ # size-limit budgets. There is no separate lint script.
38
+ - name: Build (syntax check every entry point)
39
+ run: npm run build
40
+
41
+ - name: Assert size budgets are committed and current
42
+ run: npm run size:check
43
+
44
+ # Absence guards. `npm test` must not be reachable with the sibling
45
+ # missing or an area dark — a skipped fixture is not a passing fixture.
46
+ - name: Assert canonical conformance fixtures are present (#lzspecconf)
47
+ run: |
48
+ test -d ../lazily-spec/conformance || {
49
+ echo "::error::../lazily-spec/conformance missing — conformance replay would test nothing"; exit 1; }
50
+ test -d ../lazily-spec/schemas || {
51
+ echo "::error::../lazily-spec/schemas missing — schema-conformance tests cannot run"; exit 1; }
52
+ for area in agent-doc collections coordination crdt-tree lossless-tree materialization \
53
+ membership message-passing presence rateshape reactive-graph \
54
+ reliable-sync resilience service statechart temporal windowing; do
55
+ test -d "../lazily-spec/conformance/$area" || {
56
+ echo "::error::../lazily-spec/conformance/$area missing"; exit 1; }
57
+ done
58
+
59
+ # Shadowing guard. An absence guard cannot catch a bundled copy: the
60
+ # directory is present, the tests pass, and they are replaying a stale
61
+ # local fixture. js carried nine of these until #lzspecconf.
62
+ - name: Assert no bundled fixture copy shadows the canonical spec
63
+ run: |
64
+ if [ -d test/conformance ]; then
65
+ echo "::error::test/conformance/ reintroduced — bundled fixtures shadow ../lazily-spec and hide drift"
66
+ exit 1
67
+ fi
68
+
69
+ # Positive assertion. The guards above prove the fixtures are *available*;
70
+ # this proves they were *read*. Capture the exit code explicitly and grep
71
+ # the summary rather than trusting a clean tail — and require skipped == 0
72
+ # plus a nonzero pass count, so a suite that silently stops replaying
73
+ # fixtures fails instead of reporting green.
74
+ - name: Test (assert fixtures actually ran)
75
+ run: |
76
+ set -o pipefail
77
+ npm test 2>&1 | tee test-output.txt
78
+ status=$?
79
+ echo "npm test exit=$status"
80
+ [ "$status" -eq 0 ] || { echo "::error::test suite failed"; exit 1; }
81
+
82
+ grep -qE '^(ℹ|#) fail 0$' test-output.txt || {
83
+ echo "::error::test summary does not report 'fail 0'"; exit 1; }
84
+ grep -qE '^(ℹ|#) skipped 0$' test-output.txt || {
85
+ echo "::error::tests were skipped — a skipped conformance fixture is not a passing one"
86
+ grep -E '^(ℹ|#) skipped' test-output.txt; exit 1; }
87
+
88
+ pass="$(grep -oE '^(ℹ|#) pass [0-9]+$' test-output.txt | grep -oE '[0-9]+$' | head -1)"
89
+ [ -n "$pass" ] && [ "$pass" -gt 0 ] || {
90
+ echo "::error::no passing tests reported — the suite ran nothing"; exit 1; }
91
+ echo "passed $pass tests with 0 skipped"
92
+
93
+ # Reactive-graph replay must be observable in the output, per model.
94
+ # `fail 0 / skipped 0 / pass > 0` proves the suite ran; it does not
95
+ # prove the reactive-graph corpus was replayed, nor that it was
96
+ # replayed against every context. The dart/go cascade defect was
97
+ # sync-clean and async-broken, so a run covering only the default
98
+ # context is the exact hole this guard closes. Each model prints
99
+ # "reactive-graph[<model>]: replayed N fixtures, ..." and N must be
100
+ # nonzero.
101
+ for model in Context AsyncContext ThreadSafeContext; do
102
+ line="$(grep -oE "reactive-graph\[$model\]: replayed [0-9]+ fixtures" test-output.txt | head -1)"
103
+ [ -n "$line" ] || {
104
+ echo "::error::reactive-graph corpus was not replayed against $model"; exit 1; }
105
+ n="$(echo "$line" | grep -oE '[0-9]+')"
106
+ [ "$n" -gt 0 ] || {
107
+ echo "::error::reactive-graph[$model] replayed 0 fixtures — it tested nothing"; exit 1; }
108
+ echo "reactive-graph[$model]: $n fixtures replayed"
109
+ done
110
+
111
+ - name: Formal model check
112
+ run: npm run test:formal
package/.size-limit.json CHANGED
@@ -3,13 +3,13 @@
3
3
  "name": "reactive: Context",
4
4
  "path": "src/reactive.js",
5
5
  "import": "{ Context }",
6
- "limit": "2239 B"
6
+ "limit": "2900 B"
7
7
  },
8
8
  {
9
9
  "name": "reactive: Context + handles + defaultEqual",
10
10
  "path": "src/reactive.js",
11
11
  "import": "{ Context, CellHandle, SlotHandle, SignalHandle, EffectHandle, defaultEqual }",
12
- "limit": "2246 B"
12
+ "limit": "2920 B"
13
13
  },
14
14
  {
15
15
  "name": "state-machine: StateMachine",
package/README.md CHANGED
@@ -681,8 +681,8 @@ npm run test:size # gate: fails CI if any entry exceeds its budget
681
681
 
682
682
  | Import | Size | Budget |
683
683
  |---|---:|---:|
684
- | reactive: Context | 2.24 KB ✓ | 2.24 KB |
685
- | reactive: Context + handles + defaultEqual | 2.24 KB ✓ | 2.25 KB |
684
+ | reactive: Context | 2.88 KB ✓ | 2.90 KB |
685
+ | reactive: Context + handles + defaultEqual | 2.90 KB ✓ | 2.92 KB |
686
686
  | state-machine: StateMachine | 267 B ✓ | 267 B |
687
687
  | sem-tree: SemTree | 505 B ✓ | 505 B |
688
688
  | stable-id: contentHash | 152 B ✓ | 152 B |
@@ -0,0 +1,320 @@
1
+ // Width-ladder load test for the dependency-edge index (#lzspecedgeindex).
2
+ //
3
+ // WHY THIS EXISTS. The existing bench suites (`context.bench.mjs`,
4
+ // `scale.bench.mjs`) scale *node count* and hold fan-out fixed at 2, so the
5
+ // width of a single node's dependent list was never a variable — which is
6
+ // exactly why an O(n^2) edge dedup could sit in the hot path unnoticed. This
7
+ // test makes width the independent variable.
8
+ //
9
+ // SHAPE. One source cell with `width` subscriber slots reading it. Building
10
+ // that fan-out registers `width` dependent edges on one list, so a linear-scan
11
+ // dedup costs width^2/2 comparisons to build and the per-subscriber cost grows
12
+ // linearly with width. With the index it stays flat.
13
+ //
14
+ // METHOD: climb, project, refuse. Each rung measures bytes/subscriber, then
15
+ // projects the next rung's footprint from *that measurement* and refuses to
16
+ // climb if the projection would not leave MEMORY_FLOOR_MB of headroom. This is
17
+ // the lazily-rs `examples/pubsub_load.rs` protocol: never allocate a rung you
18
+ // have not first shown you can afford.
19
+ //
20
+ // MANUAL / ON-DEMAND — not part of `make check`. Wide rungs take minutes and
21
+ // need a raised V8 heap.
22
+ //
23
+ // node --expose-gc --max-old-space-size=8192 bench/edge-index-load.mjs
24
+ // node --expose-gc --max-old-space-size=16384 bench/edge-index-load.mjs --max-width=10000000
25
+ // node --expose-gc --max-old-space-size=8192 bench/edge-index-load.mjs --json
26
+ //
27
+ // `--expose-gc` is required: bytes/subscriber is meaningless without a
28
+ // deterministic collection before each sample.
29
+
30
+ import { performance } from "node:perf_hooks";
31
+ import { getHeapStatistics } from "node:v8";
32
+ import { Context } from "../src/reactive.js";
33
+
34
+ // The ladder. The cluster at 128/159/160/161/192 straddles the promote
35
+ // threshold (EDGE_INDEX_PROMOTE = 160): 159 is the last scan-only width, 160
36
+ // the first promoted one, 161 the width where a list that oscillates by one
37
+ // would thrash a naive promote/demote boundary. A thrash bug is invisible at
38
+ // every other rung, so the cluster is the point of the ladder, not padding.
39
+ const LADDER = [
40
+ 32, 64, 96, 128, 159, 160, 161, 192, 256, 1024, 4096, 65536, 262144, 1048576,
41
+ 4194304, 10000000,
42
+ ];
43
+
44
+ const MEMORY_FLOOR_MB = 512; // refuse a rung projected to leave less than this
45
+
46
+ function parseArgs(argv) {
47
+ const opts = { json: false, maxWidth: 1048576 };
48
+ for (const a of argv.slice(2)) {
49
+ if (a === "--json") opts.json = true;
50
+ else if (a.startsWith("--max-width=")) opts.maxWidth = Number(a.slice(12));
51
+ }
52
+ return opts;
53
+ }
54
+
55
+ function gc() {
56
+ if (typeof global.gc !== "function") {
57
+ throw new Error(
58
+ "edge-index-load requires --expose-gc (bytes/subscriber needs a deterministic GC)",
59
+ );
60
+ }
61
+ // Two passes: the first frees the previous rung's graph, the second collects
62
+ // anything the first promoted rather than freed.
63
+ global.gc();
64
+ global.gc();
65
+ }
66
+
67
+ function heapBytes() {
68
+ return process.memoryUsage().heapUsed;
69
+ }
70
+
71
+ // heap_size_limit reflects --max-old-space-size, so the refuse decision is made
72
+ // against the heap this process actually has, not a hardcoded budget.
73
+ function heapLimitBytes() {
74
+ return getHeapStatistics().heap_size_limit;
75
+ }
76
+
77
+ // Build a width-N fan-out and measure it.
78
+ //
79
+ // `build` covers creating the subscribers AND forcing their first read, because
80
+ // the first read is what runs the tracked compute that registers the dependency
81
+ // edge — creating a lazy slot alone registers nothing, so timing creation only
82
+ // would measure nothing relevant.
83
+ function measureRung(width) {
84
+ gc();
85
+ const before = heapBytes();
86
+
87
+ const ctx = Context();
88
+ const source = ctx.cell(0);
89
+ const subs = new Array(width);
90
+
91
+ const tBuild0 = performance.now();
92
+ for (let i = 0; i < width; i++) {
93
+ subs[i] = ctx.memo(() => ctx.getCell(source) + 1);
94
+ }
95
+ // Force the first read: this is the edge-registration pass.
96
+ for (let i = 0; i < width; i++) {
97
+ ctx.get(subs[i]);
98
+ }
99
+ const tBuild1 = performance.now();
100
+
101
+ gc();
102
+ const after = heapBytes();
103
+
104
+ // Notify: one publish, then read every subscriber. Exercises the removal +
105
+ // re-registration path (recomputeSlotNow clears and re-tracks each slot's
106
+ // dependency), which is where an O(n) *removal* would reintroduce O(n^2).
107
+ const tNotify0 = performance.now();
108
+ ctx.setCell(source, 1);
109
+ let observed = 0;
110
+ for (let i = 0; i < width; i++) {
111
+ if (ctx.get(subs[i]) === 2) observed++;
112
+ }
113
+ const tNotify1 = performance.now();
114
+
115
+ // Correctness: every survivor must observe the final publish.
116
+ if (observed !== width) {
117
+ throw new Error(
118
+ `width ${width}: only ${observed}/${width} subscribers observed the final publish`,
119
+ );
120
+ }
121
+
122
+ const buildNsPerSub = ((tBuild1 - tBuild0) * 1e6) / width;
123
+ const notifyNsPerSub = ((tNotify1 - tNotify0) * 1e6) / width;
124
+ const bytesPerSub = (after - before) / width;
125
+
126
+ return { width, buildNsPerSub, notifyNsPerSub, bytesPerSub, observed };
127
+ }
128
+
129
+ // Repeat a rung and take the median. Narrow rungs complete in microseconds, so
130
+ // a single run is dominated by timer resolution and by whichever V8 tier the
131
+ // code happens to be in; medians over repeated runs are the only stable figure.
132
+ // Wide rungs are measured once — they take seconds and cost gigabytes, and
133
+ // their per-subscriber cost is large enough to swamp timer noise anyway.
134
+ function measureRungMedian(width) {
135
+ const reps = width <= 65536 ? 7 : 1;
136
+ const samples = [];
137
+ for (let i = 0; i < reps; i++) samples.push(measureRung(width));
138
+ const pick = (key) => {
139
+ const vals = samples.map((s) => s[key]).sort((a, b) => a - b);
140
+ return vals[vals.length >> 1];
141
+ };
142
+ return {
143
+ width,
144
+ reps,
145
+ buildNsPerSub: pick("buildNsPerSub"),
146
+ notifyNsPerSub: pick("notifyNsPerSub"),
147
+ bytesPerSub: pick("bytesPerSub"),
148
+ observed: width,
149
+ };
150
+ }
151
+
152
+ // Tier the reactive core up to TurboFan before the first measured rung, so the
153
+ // narrow rungs are not measuring the interpreter. Uses a width above the
154
+ // promote threshold so BOTH the scan path and the indexed path are warm.
155
+ function warmup() {
156
+ for (let i = 0; i < 200; i++) {
157
+ measureRung(256);
158
+ }
159
+ }
160
+
161
+ function main() {
162
+ const opts = parseArgs(process.argv);
163
+ const results = [];
164
+ const refusals = [];
165
+
166
+ const limitMB = heapLimitBytes() / (1024 * 1024);
167
+ if (!opts.json) {
168
+ console.log(`node ${process.version}`);
169
+ console.log(`heap limit: ${limitMB.toFixed(0)} MB`);
170
+ console.log(`memory floor: ${MEMORY_FLOOR_MB} MB`);
171
+ console.log("warming up (200 x width-256 build+notify, discarded)...");
172
+ }
173
+ warmup();
174
+ if (!opts.json) console.log("");
175
+
176
+ let prev = null;
177
+ for (const width of LADDER) {
178
+ if (width > opts.maxWidth) {
179
+ refusals.push({ width, reason: `above --max-width=${opts.maxWidth}` });
180
+ break;
181
+ }
182
+ // Climb, project, refuse: project this rung from the previous rung's
183
+ // MEASURED bytes/subscriber, not from a constant.
184
+ if (prev !== null) {
185
+ const projectedMB = (prev.bytesPerSub * width) / (1024 * 1024);
186
+ const freeMB = limitMB - projectedMB;
187
+ if (freeMB < MEMORY_FLOOR_MB) {
188
+ refusals.push({
189
+ width,
190
+ reason:
191
+ `projected ${projectedMB.toFixed(0)} MB from measured ` +
192
+ `${prev.bytesPerSub.toFixed(1)} B/sub at width ${prev.width}; ` +
193
+ `would leave ${freeMB.toFixed(0)} MB < ${MEMORY_FLOOR_MB} MB floor`,
194
+ });
195
+ break;
196
+ }
197
+ }
198
+
199
+ let r;
200
+ try {
201
+ r = measureRungMedian(width);
202
+ } catch (err) {
203
+ refusals.push({ width, reason: `aborted: ${err.message}` });
204
+ break;
205
+ }
206
+ results.push(r);
207
+ prev = r;
208
+ if (!opts.json) {
209
+ console.log(
210
+ `width ${String(width).padStart(9)} ` +
211
+ `build ${r.buildNsPerSub.toFixed(1).padStart(9)} ns/sub ` +
212
+ `notify ${r.notifyNsPerSub.toFixed(1).padStart(8)} ns/sub ` +
213
+ `${r.bytesPerSub.toFixed(1).padStart(7)} B/sub`,
214
+ );
215
+ }
216
+ }
217
+
218
+ const report = assertLadder(results, refusals);
219
+
220
+ if (opts.json) {
221
+ console.log(JSON.stringify({ results, refusals, report }, null, 2));
222
+ } else {
223
+ console.log("");
224
+ for (const ref of refusals) {
225
+ console.log(`REFUSED width ${ref.width}: ${ref.reason}`);
226
+ }
227
+ console.log("");
228
+ for (const line of report.lines) console.log(line);
229
+ }
230
+
231
+ process.exitCode = report.ok ? 0 : 1;
232
+ }
233
+
234
+ // Assertions, not just prints. Each returns a pass/fail line so a run is
235
+ // self-judging.
236
+ function assertLadder(results, refusals) {
237
+ const lines = [];
238
+ let ok = true;
239
+ const at = (w) => results.find((r) => r.width === w);
240
+
241
+ const check = (label, pass, detail) => {
242
+ if (!pass) ok = false;
243
+ lines.push(`${pass ? "PASS" : "FAIL"} ${label}${detail ? ` — ${detail}` : ""}`);
244
+ };
245
+
246
+ // 1. build ns/sub grows < 2x from 1k -> 1M. This is THE assertion: under the
247
+ // linear scan this ratio is ~1000x, since per-subscriber cost is O(width).
248
+ const lo = at(1024);
249
+ const hi = at(1048576);
250
+ if (lo && hi) {
251
+ const ratio = hi.buildNsPerSub / lo.buildNsPerSub;
252
+ check(
253
+ "build ns/sub grows <2x from width 1k to 1M",
254
+ ratio < 2,
255
+ `${lo.buildNsPerSub.toFixed(1)} -> ${hi.buildNsPerSub.toFixed(1)} ns/sub = ${ratio.toFixed(2)}x`,
256
+ );
257
+ } else {
258
+ lines.push("SKIP build 1k->1M growth — ladder did not reach width 1M");
259
+ }
260
+
261
+ // 2. bytes/sub flat within ~20% across the ladder (ignore the smallest rungs,
262
+ // where fixed per-context overhead dominates the per-subscriber figure).
263
+ const wide = results.filter((r) => r.width >= 1024);
264
+ if (wide.length >= 2) {
265
+ const vals = wide.map((r) => r.bytesPerSub);
266
+ const min = Math.min(...vals);
267
+ const max = Math.max(...vals);
268
+ const spread = (max - min) / min;
269
+ check(
270
+ "bytes/sub flat within 20% across rungs >=1k",
271
+ spread <= 0.2,
272
+ `${min.toFixed(1)}..${max.toFixed(1)} B/sub = ${(spread * 100).toFixed(1)}% spread`,
273
+ );
274
+ } else {
275
+ lines.push("SKIP bytes/sub flatness — fewer than 2 rungs at width >=1k");
276
+ }
277
+
278
+ // 3. every survivor observed the final publish (measureRung throws otherwise,
279
+ // so reaching here with a full result set is the pass).
280
+ check(
281
+ "every survivor observed the final publish",
282
+ results.every((r) => r.observed === r.width),
283
+ `${results.length} rungs verified`,
284
+ );
285
+
286
+ // 4. notify unchanged by the edge-index change: notify ns/sub must not grow
287
+ // with width either (the removal path is indexed too).
288
+ if (lo && hi) {
289
+ const ratio = hi.notifyNsPerSub / lo.notifyNsPerSub;
290
+ check(
291
+ "notify ns/sub grows <2x from width 1k to 1M",
292
+ ratio < 2,
293
+ `${lo.notifyNsPerSub.toFixed(1)} -> ${hi.notifyNsPerSub.toFixed(1)} ns/sub = ${ratio.toFixed(2)}x`,
294
+ );
295
+ }
296
+
297
+ // 5. no regression at the promote boundary: width 161 (just promoted, and the
298
+ // width a naive promote/demote boundary would thrash) must not cost
299
+ // materially more per subscriber than width 159 (last scan-only width).
300
+ const below = at(159);
301
+ const above = at(161);
302
+ if (below && above) {
303
+ const ratio = above.notifyNsPerSub / below.notifyNsPerSub;
304
+ check(
305
+ "no demotion thrash at the promote boundary (notify 161 vs 159)",
306
+ ratio < 2,
307
+ `${below.notifyNsPerSub.toFixed(1)} -> ${above.notifyNsPerSub.toFixed(1)} ns/sub = ${ratio.toFixed(2)}x`,
308
+ );
309
+ }
310
+
311
+ if (refusals.length > 0) {
312
+ lines.push(
313
+ `NOTE ladder stopped at width ${results[results.length - 1]?.width}; ` +
314
+ `${refusals.length} rung(s) refused`,
315
+ );
316
+ }
317
+ return { ok, lines };
318
+ }
319
+
320
+ main();