@zakkster/lite-o1 0.2.0 → 0.3.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/CHANGELOG.md +67 -0
- package/O1.d.ts +46 -0
- package/O1.js +183 -4
- package/README.md +139 -18
- package/llms.txt +65 -12
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,73 @@ All notable changes to `@zakkster/lite-o1` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-09-15
|
|
8
|
+
|
|
9
|
+
The third member of the O(1) family: a disjoint-set forest with near-O(1)
|
|
10
|
+
amortized find / union -- the family's amortized-honesty member. Tree-shakeable
|
|
11
|
+
alongside SparseSet and RingDeque (the three share no mutable module state).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`UnionFind(n)`** -- a zero-GC near-O(1) (amortized alpha(n)) disjoint-set
|
|
16
|
+
forest over TWO flat `Uint32Array` columns (parent + subtree size), fixed
|
|
17
|
+
element count `n` (elements are `[0, n)`):
|
|
18
|
+
- `find(x)` / `union(a, b)` / `connected(a, b)` / `componentSize(x)` -- all
|
|
19
|
+
O(1)-AMORTIZED, zero allocation after construction. `count` getter (live
|
|
20
|
+
component count, maintained in O(1) -- never scanned) and `capacity` getter
|
|
21
|
+
(the fixed universe `n`; there is deliberately NO `size` getter).
|
|
22
|
+
- PATH HALVING on `find` (iterative, no recursion / no stack array -- the tree
|
|
23
|
+
flattens as a side effect of querying it) + UNION BY SIZE (smaller root
|
|
24
|
+
attached under larger). `count` decrements EXACTLY once per real merge.
|
|
25
|
+
- `reset()` -- the HONEST O(n) exception: re-singleton every element in a single
|
|
26
|
+
bulk pass over the existing arrays (allocates nothing, but is O(n), NOT a
|
|
27
|
+
zero-alloc-per-op hot path; named `reset()`, not `clear()`, to flag the cost).
|
|
28
|
+
- `forEachRoots(fn)` -- an O(n) alloc-free full scan of the current roots
|
|
29
|
+
(documented exception, excluded from the zero-alloc-per-op claims). `roots()`
|
|
30
|
+
-- a convenience generator that ALLOCATES per protocol (like
|
|
31
|
+
`[Symbol.iterator]`), kept out of the zero-alloc claims.
|
|
32
|
+
- Ceiling: `n` in `[1, 2^32-1]`. Fail closed: a non-integer / out-of-range /
|
|
33
|
+
non-number `n` throws a `[lite-o1]` `RangeError`; a bad element to any op
|
|
34
|
+
throws `[lite-o1]` (typeof-guarded BEFORE the coercing `>>>`, so a Symbol /
|
|
35
|
+
BigInt never triggers a raw `TypeError`). `null` is not zero.
|
|
36
|
+
- **`O1.d.ts`** -- UnionFind ambient types added.
|
|
37
|
+
- **The O(1) Witness** (`test/witness.mjs`) -- a UnionFind amortized-find sweep
|
|
38
|
+
`[1e3, 1e4, 1e5]` vs a NAIVE disjoint-set foil (no path compression, no
|
|
39
|
+
union-by-size -> a degenerate chain, O(n) find); UnionFind flatness `>= 0.70`,
|
|
40
|
+
naive foil `<= 0.55`, ratio `>= 1.5x`.
|
|
41
|
+
- **Torture gate** -- UnionFind find / union / connected / componentSize cycles at
|
|
42
|
+
0 B/op, 0 major GC, tracker size 0, arrayBuffers delta 0 (a `ufBpc` metric
|
|
43
|
+
alongside the SparseSet / RingDeque per-op figures).
|
|
44
|
+
- **Perf gate** (`test/perf/PerfGate.test.mjs`) -- UnionFind find-heavy,
|
|
45
|
+
union-churn (real merges), connected, and componentSize scenarios at 0
|
|
46
|
+
scavenges / 0 old-gen / 0 arrayBuffers and a 0-delta grows-counter on the two
|
|
47
|
+
`Uint32Array` columns, plus a `roots()`-into-fresh-array must-fail teeth case.
|
|
48
|
+
- **UnionFind `node:test` cases** -- contract + boundary (reject Symbol / BigInt /
|
|
49
|
+
NaN / null / undefined / -1 / n / 1.5; ctor rejects a bad n) + a path-halving
|
|
50
|
+
depth-shrink proof (test-only `_parent` peek) + a >= 1e5-op mixed
|
|
51
|
+
union/find/connected differential fuzz against a trivial no-compression /
|
|
52
|
+
no-union-by-size oracle (0 divergences; count exact once per true merge).
|
|
53
|
+
- ADR [`0007`](./decisions/0007-unionfind-path-halving-union-by-size.md)
|
|
54
|
+
(path halving + union by size, the O(n) reset / forEachRoots honesty exception,
|
|
55
|
+
and the 2^32-1 ceiling).
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- `VERSION` bumped to `'0.3.0'` (synced across `package.json`, the `VERSION` const
|
|
60
|
+
in `O1.js`, and `llms.txt`).
|
|
61
|
+
- **Witness gate hardened (internal, `test/witness.mjs` -- not part of the
|
|
62
|
+
published surface).** The SparseSet flatness gate flaked ~15-20% of fresh runs;
|
|
63
|
+
the cause was the flatness DENOMINATOR `n=1e3`, a pure-L1 micro-case that
|
|
64
|
+
turbo-spikes (40% spread), not any O(1) violation. Fix: the full `[1e3..1e7]`
|
|
65
|
+
sweep is still DISPLAYED (both the `1e3` micro-case and the `1e7` memory wall
|
|
66
|
+
tagged), but the flatness + ratio gates are now computed over the steady,
|
|
67
|
+
cache-resident window `1e4 <= n <= 1e6`; measurement stiffened to two warm-ups +
|
|
68
|
+
median of 9. The `0.70` / `0.55` / `1.5x` thresholds are UNCHANGED (domain, not
|
|
69
|
+
floor). 30 consecutive fresh runs, 0 failures (min flatness 0.90). See the
|
|
70
|
+
amendment to ADR [`0004`](./decisions/0004-witness-flatness-gate.md).
|
|
71
|
+
|
|
72
|
+
[0.3.0]: https://www.npmjs.com/package/@zakkster/lite-o1/v/0.3.0
|
|
73
|
+
|
|
7
74
|
## [0.2.0] - 2026-09-15
|
|
8
75
|
|
|
9
76
|
The second member of the O(1) family: a fixed-capacity double-ended queue that
|
package/O1.d.ts
CHANGED
|
@@ -100,3 +100,49 @@ export class RingDeque {
|
|
|
100
100
|
/** Iterate live elements front -> back. */
|
|
101
101
|
[Symbol.iterator](): IterableIterator<number>;
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A zero-GC near-O(1) (amortized alpha(n)) disjoint-set forest over TWO
|
|
106
|
+
* Uint32Array columns (parent + subtree size), with a fixed element count `n`
|
|
107
|
+
* (elements are [0, n)). find / union / connected / componentSize are
|
|
108
|
+
* O(1)-amortized (path halving + union by size) and allocate nothing after
|
|
109
|
+
* construction. `count` is the live component count, maintained in O(1).
|
|
110
|
+
* `reset()` and `forEachRoots(fn)` are O(n) full-scan primitives (alloc-free but
|
|
111
|
+
* NOT per-op hot paths); `roots()` allocates a generator per protocol. Fail
|
|
112
|
+
* closed: a bad element (non-integer, NaN, null, negative, >= n, Symbol, BigInt)
|
|
113
|
+
* throws a [lite-o1] error.
|
|
114
|
+
*/
|
|
115
|
+
export class UnionFind {
|
|
116
|
+
/**
|
|
117
|
+
* @param n fixed element count; an integer in [1, 2^32-1]. Elements are [0, n).
|
|
118
|
+
*/
|
|
119
|
+
constructor(n: number);
|
|
120
|
+
|
|
121
|
+
/** Live component count (maintained in O(1); never scanned). */
|
|
122
|
+
readonly count: number;
|
|
123
|
+
|
|
124
|
+
/** Fixed element universe [0, n). (No `size` getter -- it would collide with
|
|
125
|
+
* the live-count meaning `size` has on SparseSet / RingDeque.) */
|
|
126
|
+
readonly capacity: number;
|
|
127
|
+
|
|
128
|
+
/** Return the root of x's component. O(1)-amortized. Throws on a bad element. */
|
|
129
|
+
find(x: number): number;
|
|
130
|
+
|
|
131
|
+
/** Merge a and b. Returns true iff they were merged this call. Throws on a bad element. */
|
|
132
|
+
union(a: number, b: number): boolean;
|
|
133
|
+
|
|
134
|
+
/** True iff a and b share a component. O(1)-amortized. Throws on a bad element. */
|
|
135
|
+
connected(a: number, b: number): boolean;
|
|
136
|
+
|
|
137
|
+
/** Size of x's component. O(1)-amortized. Throws on a bad element. */
|
|
138
|
+
componentSize(x: number): number;
|
|
139
|
+
|
|
140
|
+
/** Re-singleton every element (O(n) bulk pass; allocates nothing). */
|
|
141
|
+
reset(): void;
|
|
142
|
+
|
|
143
|
+
/** Invoke fn for every current root, alloc-free (O(n) full scan). */
|
|
144
|
+
forEachRoots(fn: (root: number, uf: UnionFind) => void): void;
|
|
145
|
+
|
|
146
|
+
/** Yield every current root (O(n) scan; allocates a generator per protocol). */
|
|
147
|
+
roots(): IterableIterator<number>;
|
|
148
|
+
}
|
package/O1.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* that doubles as a teachable textbook: each member solves a real problem AND
|
|
4
4
|
* proves its constant is real (the O(1) Witness -- see test/witness.mjs).
|
|
5
5
|
*
|
|
6
|
-
* v0.
|
|
7
|
-
* const. The
|
|
8
|
-
* that imports one drops the
|
|
6
|
+
* v0.3.0 ships three members -- SparseSet, RingDeque, and UnionFind -- plus its
|
|
7
|
+
* `VERSION` const. The three are independent (no shared mutable module state), so
|
|
8
|
+
* a bundler that imports one drops the others (`sideEffects: false`).
|
|
9
9
|
*
|
|
10
10
|
* The complexity class IS the product: every hot op below is O(1) worst-case and
|
|
11
11
|
* allocates ZERO bytes after construction. The witness harness (never imported
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
/** Package version. One of the three version sites (package.json / VERSION / llms.txt). */
|
|
19
|
-
export const VERSION = '0.
|
|
19
|
+
export const VERSION = '0.3.0';
|
|
20
20
|
|
|
21
21
|
/** Largest universe the Uint32 substrate + the (k >>> 0) key check can honor. */
|
|
22
22
|
const MAX_UNIVERSE = 0x100000000; // 2^32
|
|
@@ -341,3 +341,182 @@ export class RingDeque {
|
|
|
341
341
|
throw new RangeError('[lite-o1] RingDeque full (capacity ' + this._cap + ')');
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Largest disjoint-set universe UnionFind can honor. Every parent / root index
|
|
347
|
+
* is stored in a Uint32Array slot, so an element must fit a uint32; the fixed
|
|
348
|
+
* count `n` is an integer in [1, 2^32-1] (0xFFFFFFFF), leaving every legal
|
|
349
|
+
* element in [0, n) inside the uint32 range.
|
|
350
|
+
*/
|
|
351
|
+
const MAX_NODES = 0xFFFFFFFF; // 2^32 - 1
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* UnionFind -- a zero-GC near-O(1) disjoint-set forest over TWO flat
|
|
355
|
+
* `Uint32Array` columns (parent + subtree size), with a fixed element count `n`.
|
|
356
|
+
*
|
|
357
|
+
* find / union / connected / componentSize are ALL O(1)-AMORTIZED (inverse
|
|
358
|
+
* Ackermann alpha(n) <= 4 for any n that fits this universe -- effectively a
|
|
359
|
+
* small constant) and allocate ZERO bytes after construction. The two classic
|
|
360
|
+
* near-constant tricks are both applied:
|
|
361
|
+
*
|
|
362
|
+
* - PATH HALVING on find: every other node on the walk to the root is
|
|
363
|
+
* re-pointed at its grandparent (`parent[x] = parent[parent[x]]`), so the
|
|
364
|
+
* tree flattens as a side effect of querying it -- iterative, NO recursion
|
|
365
|
+
* and NO stack array, so the hot body allocates nothing.
|
|
366
|
+
* - UNION BY SIZE: the smaller-rooted tree is attached under the larger, so
|
|
367
|
+
* the forest never grows taller than log n before halving flattens it.
|
|
368
|
+
*
|
|
369
|
+
* Together these bound any single op at O(alpha(n)) amortized. HONESTY: a single
|
|
370
|
+
* find is NOT worst-case O(1) -- an adversarial pre-halving chain is O(depth);
|
|
371
|
+
* the guarantee is amortized. The witness reports the amortized throughput
|
|
372
|
+
* staying flat while a no-compression / no-union-by-size foil degrades.
|
|
373
|
+
*
|
|
374
|
+
* Elements are [0, n); `count` is the live component count, maintained in O(1)
|
|
375
|
+
* (decremented once per real merge -- NO scan). Fail closed, mirroring the other
|
|
376
|
+
* members: a non-integer / out-of-range / non-number element throws a
|
|
377
|
+
* [lite-o1]-tagged error via _oob (typeof-guarded BEFORE the coercing `>>>`, so a
|
|
378
|
+
* Symbol / BigInt never reaches arithmetic). `null` is not zero --
|
|
379
|
+
* `(null >>> 0) === null` is false, so null is rejected.
|
|
380
|
+
*
|
|
381
|
+
* `reset()` and `forEachRoots(fn)` are the documented O(n) full-scan exceptions
|
|
382
|
+
* (a single bulk pass over the existing arrays -- they still allocate NOTHING but
|
|
383
|
+
* are NOT per-op hot paths); `roots()` is a convenience generator that ALLOCATES
|
|
384
|
+
* per protocol (like `[Symbol.iterator]`) and is kept OUT of the zero-alloc claim.
|
|
385
|
+
*/
|
|
386
|
+
export class UnionFind {
|
|
387
|
+
/**
|
|
388
|
+
* @param {number} n fixed element count; an integer in [1, 2^32-1].
|
|
389
|
+
* Elements are [0, n).
|
|
390
|
+
*/
|
|
391
|
+
constructor(n) {
|
|
392
|
+
// Number.isInteger never coerces (false on a Symbol / BigInt), and
|
|
393
|
+
// String(n) in the cold message is Symbol/BigInt-safe -- so a bad type
|
|
394
|
+
// fails closed with a [lite-o1] error, never a raw TypeError.
|
|
395
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_NODES) {
|
|
396
|
+
throw new RangeError(
|
|
397
|
+
'[lite-o1] UnionFind n must be an integer in [1, 2^32-1], got ' + String(n));
|
|
398
|
+
}
|
|
399
|
+
const parent = new Uint32Array(n); // parent[i] = i's parent (i itself iff root)
|
|
400
|
+
for (let i = 0; i < n; i++) parent[i] = i;
|
|
401
|
+
this._parent = parent;
|
|
402
|
+
this._size = new Uint32Array(n).fill(1); // size[root] = elements in that tree
|
|
403
|
+
this._count = n; // live component count (O(1)-maintained)
|
|
404
|
+
this._n = n; // fixed universe (the capacity getter)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Live component count. O(1) -- maintained, never scanned. */
|
|
408
|
+
get count() { return this._count; }
|
|
409
|
+
|
|
410
|
+
/** Fixed element universe [0, n). O(1). (No `size` getter -- would collide
|
|
411
|
+
* with the "live element count" meaning the other members give `size`.) */
|
|
412
|
+
get capacity() { return this._n; }
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Return the root of x's component. O(1)-AMORTIZED. Path-halving flattens the
|
|
416
|
+
* walk in place (no recursion, no stack array -- zero allocation). Fails
|
|
417
|
+
* closed: a bad element throws via _oob. The `typeof` short-circuits BEFORE
|
|
418
|
+
* `>>>` runs, because `>>>` coerces its operand first and that coercion THROWS
|
|
419
|
+
* on a Symbol or BigInt; `(x >>> 0) !== x` then rejects every non-uint32
|
|
420
|
+
* number, and `x >= n` rejects an in-range uint32 past the universe.
|
|
421
|
+
* @param {number} x
|
|
422
|
+
* @returns {number} the component root
|
|
423
|
+
*/
|
|
424
|
+
find(x) {
|
|
425
|
+
if (typeof x !== 'number' || (x >>> 0) !== x || x >= this._n) return this._oob(x);
|
|
426
|
+
const parent = this._parent;
|
|
427
|
+
while (parent[x] !== x) {
|
|
428
|
+
parent[x] = parent[parent[x]]; // path halving: point x at its grandparent
|
|
429
|
+
x = parent[x];
|
|
430
|
+
}
|
|
431
|
+
return x;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Merge the components of a and b. O(1)-AMORTIZED. Returns `true` iff a real
|
|
436
|
+
* merge happened (they were in different components), `false` if already
|
|
437
|
+
* joined. Union by size: the smaller-rooted tree is attached under the larger.
|
|
438
|
+
* Fails closed on either bad element (typeof-guarded before any coercion).
|
|
439
|
+
* @param {number} a
|
|
440
|
+
* @param {number} b
|
|
441
|
+
* @returns {boolean} true iff a and b were merged this call
|
|
442
|
+
*/
|
|
443
|
+
union(a, b) {
|
|
444
|
+
if (typeof a !== 'number' || (a >>> 0) !== a || a >= this._n) return this._oob(a);
|
|
445
|
+
if (typeof b !== 'number' || (b >>> 0) !== b || b >= this._n) return this._oob(b);
|
|
446
|
+
let ra = this.find(a);
|
|
447
|
+
let rb = this.find(b);
|
|
448
|
+
if (ra === rb) return false;
|
|
449
|
+
const size = this._size;
|
|
450
|
+
if (size[ra] < size[rb]) { const t = ra; ra = rb; rb = t; } // attach smaller under larger
|
|
451
|
+
this._parent[rb] = ra;
|
|
452
|
+
size[ra] += size[rb];
|
|
453
|
+
this._count--; // exactly one component disappears per real merge
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* True iff a and b are in the same component. O(1)-AMORTIZED. Both elements
|
|
459
|
+
* are guarded via find (a bad element throws [lite-o1]).
|
|
460
|
+
* @param {number} a
|
|
461
|
+
* @param {number} b
|
|
462
|
+
* @returns {boolean}
|
|
463
|
+
*/
|
|
464
|
+
connected(a, b) {
|
|
465
|
+
return this.find(a) === this.find(b);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Size of the component containing x. O(1)-AMORTIZED. x is guarded via find.
|
|
470
|
+
* @param {number} x
|
|
471
|
+
* @returns {number}
|
|
472
|
+
*/
|
|
473
|
+
componentSize(x) {
|
|
474
|
+
return this._size[this.find(x)];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Re-singleton every element: parent[i] = i, size[i] = 1, count = n. This is
|
|
479
|
+
* the HONEST O(n) exception -- a single bulk pass over the existing arrays. It
|
|
480
|
+
* allocates NOTHING (no new store), but it is O(n), NOT a zero-alloc-per-op
|
|
481
|
+
* hot path; named reset() (not clear()) to flag that cost.
|
|
482
|
+
*/
|
|
483
|
+
reset() {
|
|
484
|
+
const parent = this._parent;
|
|
485
|
+
const size = this._size;
|
|
486
|
+
const n = this._n;
|
|
487
|
+
for (let i = 0; i < n; i++) { parent[i] = i; size[i] = 1; }
|
|
488
|
+
this._count = n;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Invoke fn(root, uf) for every current root, alloc-free. O(n) FULL SCAN --
|
|
493
|
+
* a documented exception, EXCLUDED from the zero-alloc-per-op claims (it is a
|
|
494
|
+
* bulk primitive, not a hot op). A HOISTED callback keeps it allocation-free.
|
|
495
|
+
* @param {(root:number, uf:UnionFind)=>void} fn
|
|
496
|
+
*/
|
|
497
|
+
forEachRoots(fn) {
|
|
498
|
+
const parent = this._parent;
|
|
499
|
+
const n = this._n;
|
|
500
|
+
for (let i = 0; i < n; i++) if (parent[i] === i) fn(i, this);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Yield every current root. O(n) scan. ALLOCATES a generator + a {value,done}
|
|
505
|
+
* object per step by protocol -- kept OUT of the zero-alloc claims (use
|
|
506
|
+
* forEachRoots for the alloc-free scan).
|
|
507
|
+
*/
|
|
508
|
+
*roots() {
|
|
509
|
+
const parent = this._parent;
|
|
510
|
+
const n = this._n;
|
|
511
|
+
for (let i = 0; i < n; i++) if (parent[i] === i) yield i;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---- cold path only: throw builder (string concat lives here, off the hot body) ----
|
|
515
|
+
|
|
516
|
+
/** @private */
|
|
517
|
+
_oob(x) {
|
|
518
|
+
// String(x) -- NOT '+ x' / a template literal: those THROW on a Symbol or
|
|
519
|
+
// BigInt, which would turn a fail-closed reject into a different crash.
|
|
520
|
+
throw new RangeError('[lite-o1] node out of range [0, ' + this._n + '): ' + String(x));
|
|
521
|
+
}
|
|
522
|
+
}
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-o1
|
|
2
2
|
|
|
3
|
-
> Zero-GC, O(1) data structures that PROVE their constant. v0.
|
|
3
|
+
> Zero-GC, O(1) data structures that PROVE their constant. v0.3.0 ships SparseSet (an integer set with O(1) add / has / delete / iterate and an O(1) clear() that zeroes nothing), RingDeque (a fixed-capacity numeric double-ended queue with O(1) push/pop at both ends), and UnionFind (a disjoint-set forest with near-O(1) amortized find / union) -- plus a throughput-invariance witness that shows the flat cost curve while a native Set, Array.prototype.shift, or a naive disjoint-set decays.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@zakkster/lite-o1)
|
|
6
6
|
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
Almost no JavaScript data-structure library ships the evidence that its Big-O claim survives contact with a real engine -- megamorphic call sites, GC pauses, cache misses, deopts. `lite-o1` is a curated, tree-shakeable family of the O(1) structures that actually matter, each zero-GC, each written to teach the trick that buys the constant, and each shipped with a harness that DEMONSTRATES the flat cost curve rather than asserting it. The complexity class IS the product.
|
|
19
19
|
|
|
20
|
-
v0.
|
|
20
|
+
v0.3.0 ships three members. **SparseSet**, the textbook O(1) integer set (a dense + sparse array pair) whose `clear()` runs in O(1) by resetting a count and zeroing nothing at all. **RingDeque**, a fixed-capacity double-ended queue of numbers over one circular `Float64Array` -- O(1) push/pop at both ends, the zero-GC answer to the `Array.prototype.shift` O(n) trap. And **UnionFind**, a disjoint-set forest over two `Uint32Array` columns -- near-O(1) amortized `find` / `union` via path halving + union by size, the family's amortized-honesty member. They share no mutable module state, so a bundler that imports one drops the others.
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
npm install @zakkster/lite-o1
|
|
@@ -62,6 +62,9 @@ Every op above is O(1) worst-case and allocates zero bytes after construction. T
|
|
|
62
62
|
- [RingDeque](#ringdeque)
|
|
63
63
|
- [How RingDeque works](#how-ringdeque-works)
|
|
64
64
|
- [RingDeque API reference](#ringdeque-api-reference)
|
|
65
|
+
- [UnionFind](#unionfind)
|
|
66
|
+
- [How UnionFind works](#how-unionfind-works)
|
|
67
|
+
- [UnionFind API reference](#unionfind-api-reference)
|
|
65
68
|
- [Composability with the ecosystem](#composability-with-the-ecosystem)
|
|
66
69
|
- [Zero-GC design notes](#zero-gc-design-notes)
|
|
67
70
|
- [Design decisions worth knowing](#design-decisions-worth-knowing)
|
|
@@ -100,8 +103,15 @@ Existing options: a native `Set` (arbitrary keys, but a hash table that decays a
|
|
|
100
103
|
- **`clear()`** -- empty in O(1): resets head + count, zeroes no store.
|
|
101
104
|
- **`forEach(fn)` / `[Symbol.iterator]`** -- iterate live elements front -> back, alloc-free.
|
|
102
105
|
- **`size` / `capacity`** -- getters (`capacity` reports the rounded power of two).
|
|
106
|
+
- **`UnionFind(n)`** -- a zero-GC near-O(1) (amortized alpha(n)) disjoint-set forest over two `Uint32Array` columns (parent + subtree size), fixed element count `n` (elements are `[0, n)`). The hot surface is four ops plus two getters and two O(n) scan primitives:
|
|
107
|
+
- **`find(x)`** -- the root of x's component. O(1)-amortized. Path halving flattens the walk in place. Throws a `[lite-o1]` error on a bad element.
|
|
108
|
+
- **`union(a, b)`** -- merge two components (union by size). O(1)-amortized. Returns `true` iff a real merge happened.
|
|
109
|
+
- **`connected(a, b)` / `componentSize(x)`** -- same-component test / component size. O(1)-amortized.
|
|
110
|
+
- **`count` / `capacity`** -- getters (`count` is the live component count, maintained in O(1); `capacity` is the fixed `n`).
|
|
111
|
+
- **`reset()`** -- re-singleton every element. O(n) (the honest exception; allocates nothing, but is a bulk op, not a per-op hot path).
|
|
112
|
+
- **`forEachRoots(fn)` / `roots()`** -- visit the current roots; `forEachRoots` is an O(n) alloc-free scan, `roots()` is an allocating generator.
|
|
103
113
|
- **`VERSION`** -- the package version string.
|
|
104
|
-
- **The O(1) Witness** (`npm run witness`) -- an offline harness that times a fixed batch of each member's hot op across an n-sweep, reports ops/ms + a flatness ratio (SparseSet vs a native `Set`, RingDeque vs `Array.prototype.shift
|
|
114
|
+
- **The O(1) Witness** (`npm run witness`) -- an offline harness that times a fixed batch of each member's hot op across an n-sweep, reports ops/ms + a flatness ratio (SparseSet vs a native `Set`, RingDeque vs `Array.prototype.shift`, UnionFind vs a naive disjoint-set), and fails if the constant regressed.
|
|
105
115
|
|
|
106
116
|
Full types ship in [`O1.d.ts`](./O1.d.ts). Tree-shakeable named exports (`sideEffects: false`) -- import only what you use.
|
|
107
117
|
|
|
@@ -168,7 +178,7 @@ get capacity: number // max live members as constructed
|
|
|
168
178
|
|
|
169
179
|
| Constant | Value | Meaning |
|
|
170
180
|
| ---------- | --------- | -------------------------------------------------- |
|
|
171
|
-
| `VERSION` | `'0.
|
|
181
|
+
| `VERSION` | `'0.3.0'` | Package version string. |
|
|
172
182
|
|
|
173
183
|
Contract bounds (validated, not exported):
|
|
174
184
|
|
|
@@ -179,25 +189,29 @@ Contract bounds (validated, not exported):
|
|
|
179
189
|
| SparseSet valid key | integer in `[0, universe)` |
|
|
180
190
|
| RingDeque `capacity`| integer in `[1, 2^31]`, rounded up to a power of two |
|
|
181
191
|
| RingDeque value | `typeof 'number'` and not `NaN` (`+/-Infinity` OK) |
|
|
192
|
+
| UnionFind `n` | integer in `[1, 2^32-1]` |
|
|
193
|
+
| UnionFind element | integer in `[0, n)` |
|
|
182
194
|
|
|
183
195
|
---
|
|
184
196
|
|
|
185
197
|
## The O(1) Witness
|
|
186
198
|
|
|
187
|
-
The analytical anchor: **ops/ms that stays flat as n grows is the proof of O(1).** `npm run witness` fills a SparseSet of size `n` and times a fixed batch (1e6) of the membership op at each `n` in a geometric sweep `[1e3, 1e4, 1e5, 1e6, 1e7]`, with
|
|
199
|
+
The analytical anchor: **ops/ms that stays flat as n grows is the proof of O(1).** `npm run witness` fills a SparseSet of size `n` and times a fixed batch (1e6) of the membership op at each `n` in a geometric sweep `[1e3, 1e4, 1e5, 1e6, 1e7]`, with two warm-ups and the median of 9 reps to reject a loaded-runner stall. It runs a native `Set` foil on the identical key sweep -- the thing a working programmer reaches for by default -- and reports both curves plus a flatness ratio (`opsPerMs(last) / opsPerMs(first)`):
|
|
188
200
|
|
|
189
201
|
```
|
|
190
202
|
n SparseSet ops/ms Set ops/ms ratio
|
|
191
203
|
-------- ---------------- ---------- -----
|
|
192
|
-
1e3 ~
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
204
|
+
1e3 ~378483.23 ~169062.91 ~2.24x <- L1 micro-case (shown, not gated)
|
|
205
|
+
1e4 ~401472.12 ~114038.09 ~3.52x
|
|
206
|
+
1e6 ~404626.17 ~44210.86 ~9.15x
|
|
207
|
+
1e7 ~402030.25 ~19032.79 ~21.12x <- memory wall (shown, not gated)
|
|
208
|
+
|
|
209
|
+
SparseSet flatness (n=1e4..1e6): ~1.00 (gate >= 0.70)
|
|
210
|
+
Set foil flatness (n=1e4..1e6): ~0.39 (gate <= 0.55)
|
|
211
|
+
min SparseSet/Set ratio (n=1e4..1e6): ~3.5x (gate >= 1.50x)
|
|
198
212
|
```
|
|
199
213
|
|
|
200
|
-
SparseSet's contiguous typed-array layout streams flat
|
|
214
|
+
SparseSet's contiguous typed-array layout streams flat -- its ops/ms barely moves from n=1e3 to n=1e7 -- while the `Set`'s hash table scatters across an ever-larger backing store until each lookup is a cache miss, so its ops/ms falls ~9x across the sweep and SparseSet's lead *grows* with n (2x to 21x). **Honest gate domain:** ops/ms is a hardware signal, so the two unrepresentative endpoints are displayed but excluded from the gate -- n=1e3 is a pure-L1 micro-case that turbo-spikes (an unstable flatness denominator), and n=1e7 is the memory wall, where the 8*n-byte arrays exceed cache and you measure DRAM latency rather than the algorithm. The gate is computed over the steady, cache-resident window `1e4 <= n <= 1e6` and fails the build if SparseSet flatness drops below `0.70`, the foil fails to decay below `0.55`, or the ratio falls under `1.5x` at any gated size -- so a regression that quietly ruins the constant fails as loudly as a broken test. (Absolute ops/ms is machine-specific; reproduce on your own hardware.)
|
|
201
215
|
|
|
202
216
|
---
|
|
203
217
|
|
|
@@ -290,6 +304,96 @@ get capacity: number // max elements (power-of-two, rounded up)
|
|
|
290
304
|
|
|
291
305
|
---
|
|
292
306
|
|
|
307
|
+
## UnionFind
|
|
308
|
+
|
|
309
|
+
The third member: a **disjoint-set (union-find) forest** over two `Uint32Array` columns (parent + subtree size), fixed element count `n`. `find` / `union` / `connected` / `componentSize` are near-O(1) **amortized** (inverse Ackermann alpha(n) <= ~4) and allocate zero bytes -- the family's amortized-honesty member, and the zero-GC answer to a naive disjoint-set whose `find` degrades to O(n) as its trees deepen.
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
import { UnionFind } from '@zakkster/lite-o1';
|
|
313
|
+
|
|
314
|
+
// A forest of 10 singletons: elements 0..9, each its own component.
|
|
315
|
+
const uf = new UnionFind(10);
|
|
316
|
+
uf.count; // -> 10 (live component count, maintained in O(1))
|
|
317
|
+
uf.capacity; // -> 10 (the fixed universe n)
|
|
318
|
+
|
|
319
|
+
uf.union(0, 1); // -> true (a real merge)
|
|
320
|
+
uf.union(1, 2); // -> true (2 joins {0,1})
|
|
321
|
+
uf.union(0, 2); // -> false (already connected -- no-op)
|
|
322
|
+
uf.count; // -> 8
|
|
323
|
+
|
|
324
|
+
uf.connected(0, 2); // -> true
|
|
325
|
+
uf.connected(0, 5); // -> false
|
|
326
|
+
uf.componentSize(1); // -> 3 ({0,1,2})
|
|
327
|
+
uf.find(2); // -> the component root (path-halved on the way)
|
|
328
|
+
|
|
329
|
+
// uf.find(10); // throws [lite-o1]: element out of [0, 10)
|
|
330
|
+
// uf.find(1.5); // throws [lite-o1]: not an integer element
|
|
331
|
+
// uf.union(0, Symbol());// throws [lite-o1]: fail-closed, never a raw TypeError
|
|
332
|
+
|
|
333
|
+
uf.reset(); // O(n): re-singleton every element (the honest exception)
|
|
334
|
+
uf.count; // -> 10
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Every query / merge above is O(1)-amortized and zero-allocation after construction. Fail closed: a bad element (non-integer, out of `[0, n)`, `NaN`, `null`, a Symbol, a BigInt) throws a `[lite-o1]` error -- never a raw `TypeError`, and `null` is never coerced to element `0`. The `witness` harness proves UnionFind's amortized `find` holds its ops/ms while a naive disjoint-set (no path compression, no union-by-size) collapses as `n` grows.
|
|
338
|
+
|
|
339
|
+
### How UnionFind works
|
|
340
|
+
|
|
341
|
+
<details>
|
|
342
|
+
<summary>Path halving, union by size, and why a single find is amortized -- not worst-case -- O(1).</summary>
|
|
343
|
+
|
|
344
|
+
A UnionFind holds two `Uint32Array`s and a live component count:
|
|
345
|
+
|
|
346
|
+
- **`parent`** -- `parent[i]` is `i`'s parent in its tree; `i` is a ROOT iff `parent[i] === i`. Two elements are in the same component iff they reach the same root.
|
|
347
|
+
- **`size`** -- `size[root]` is the number of elements in that tree. It drives union-by-size AND answers `componentSize` for free.
|
|
348
|
+
|
|
349
|
+
The two near-constant tricks are both applied:
|
|
350
|
+
|
|
351
|
+
- **Path halving on `find`.** Walking `x` up to its root, every other node is repointed at its grandparent:
|
|
352
|
+
|
|
353
|
+
```
|
|
354
|
+
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
The tree flattens as a side effect of querying it. This is ITERATIVE -- no recursion, no stack array -- so the hot body allocates nothing (full compression would need a second pass or a stack; halving gets the same amortized bound in one alloc-free pass).
|
|
358
|
+
|
|
359
|
+
- **Union by size.** `union` attaches the smaller-rooted tree under the larger (`if (size[ra] < size[rb]) swap; parent[rb] = ra; size[ra] += size[rb]`), so a tree never grows taller than log n before halving flattens it. `count` is decremented exactly once per REAL merge (never a scan), and `union` returns `true` iff it actually merged.
|
|
360
|
+
|
|
361
|
+
Together these bound any single op at O(alpha(n)) AMORTIZED. **Honesty:** a single `find` is NOT worst-case O(1) -- an adversarial chain that has not yet been halved is O(depth). The guarantee is amortized alpha(n) (effectively a small constant), and the [witness](#the-o1-witness) proves the amortized throughput stays flat while a naive disjoint-set foil (no compression, no union-by-size -> a degenerate chain) decays toward O(n).
|
|
362
|
+
|
|
363
|
+
`reset()` (re-singleton everything) and `forEachRoots(fn)` (visit every root) are the O(n) exceptions: there is no cross-check trick to make them O(1) because every element's parent must actually be read / rewritten. They still allocate nothing (a single bulk pass over the existing arrays), but they are bulk ops, not per-op hot paths -- `reset()` is named `reset()`, not `clear()`, precisely to flag that different cost class.
|
|
364
|
+
|
|
365
|
+
The cost of the constant is memory: two `n`-sized `Uint32Array` columns, allocated eagerly at construction. UnionFind is the right tool when elements are a known, bounded integer range and you merge groups incrementally -- not for a huge / unbounded or non-integer element domain.
|
|
366
|
+
|
|
367
|
+
</details>
|
|
368
|
+
|
|
369
|
+
### UnionFind API reference
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
new UnionFind(n: number) // n elements [0, n); n an integer in [1, 2^32-1]
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
- **`n`** -- the fixed element count; valid elements are integers in `[0, n)`. An integer in `[1, 2^32-1]`. Sizes both `Uint32Array` columns. The constructor throws a `[lite-o1]`-tagged `RangeError` on a non-integer / out-of-range / non-number argument (`Number.isInteger` never coerces, so a Symbol / BigInt fails closed rather than crashing raw). All scratch is allocated here; every method afterward allocates nothing (except `roots()`).
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
find(x: number): number // component root; O(1)-amortized (path halving)
|
|
379
|
+
union(a: number, b: number): boolean // merge (union by size); true iff a real merge
|
|
380
|
+
connected(a: number, b: number): boolean // same-component test; O(1)-amortized
|
|
381
|
+
componentSize(x: number): number // size of x's component; O(1)-amortized
|
|
382
|
+
reset(): void // O(n): re-singleton every element (allocates nothing)
|
|
383
|
+
forEachRoots(fn: (root: number, uf: UnionFind) => void): void // O(n) alloc-free scan
|
|
384
|
+
roots(): IterableIterator<number> // O(n) scan; ALLOCATES a generator per protocol
|
|
385
|
+
get count: number // live component count (maintained in O(1))
|
|
386
|
+
get capacity: number // the fixed element universe n
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- **`find` / `union` / `connected` / `componentSize`** throw `[lite-o1] node out of range [0, n): ...` for an element that is not an integer in `[0, n)` (this includes `-1`, `1.5`, `NaN`, `null`, `x === n`, a Symbol, and a BigInt). The `typeof` guard runs BEFORE the coercing `>>>`, so a Symbol / BigInt never reaches arithmetic. `null` is rejected as `null`, never coerced to element `0`.
|
|
390
|
+
- **`union(a, b)`** returns `true` iff a and b were in DIFFERENT components (a real merge, `count` drops by one); `false` if already joined (a no-op). `union(x, x)` is always `false`.
|
|
391
|
+
- **`reset()` / `forEachRoots()` / `roots()`** are O(n), NOT per-op hot paths. `reset()` and `forEachRoots()` allocate nothing; `roots()` allocates a generator + a `{value, done}` per step by protocol -- use `forEachRoots` for the alloc-free scan. There is no public `size` getter (it would collide with the live-element-count meaning `size` has on the other members); use `count` (live components) and `capacity` (fixed universe).
|
|
392
|
+
|
|
393
|
+
**Reach for UnionFind when** you track "which things are in the same group" over a fixed integer element set and merge groups incrementally (connected components, Kruskal MST, percolation, cycle detection, equivalence classes) at near-constant amortized cost with zero per-op allocation. **Avoid it when** you need to SPLIT / un-merge (union-find is merge-only; `reset()` re-singletons everything in O(n)), your elements are not a bounded integer range, or you are on a strict per-op WORST-CASE budget (a single `find` is amortized alpha(n), not worst-case O(1)). See [`GUIDE.md`](./GUIDE.md) for the full reach-for / avoid / measure-it.
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
293
397
|
## Composability with the ecosystem
|
|
294
398
|
|
|
295
399
|
SparseSet is the dense-integer membership primitive under an ECS-style loop. A common pattern: a `SparseSet` per component tracks which entity ids currently have that component; a `@zakkster/lite-arena` `Arena` owns the component payloads by generational handle. Membership and iteration are O(1) and alloc-free; the per-frame `clear()` of a scratch set (visited masks, this-frame-touched ids) is free.
|
|
@@ -360,6 +464,21 @@ The torture gate (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`, run unde
|
|
|
360
464
|
|
|
361
465
|
The value guard is a two-test branchless check on the hot body -- `typeof v !== 'number' || v !== v` (the second catches NaN once the type is known) -- with the message-building `_bad` / `_full` throw builders on the cold path (again using `String(v)`, never a template literal, so a Symbol / BigInt value fails closed rather than crashing raw). The torture and perf gates prove RingDeque at **0 B/op** across FIFO / LIFO / both-ends interleave churn, with a 0-delta on the `Float64Array` backing (fixed capacity -- no resize) and the leak tracker back at `size() = 0`.
|
|
362
466
|
|
|
467
|
+
**UnionFind** allocates its two `Uint32Array` columns once, at construction:
|
|
468
|
+
|
|
469
|
+
| Operation | Steady-state allocations |
|
|
470
|
+
| -------------------------------- | ------------------------ |
|
|
471
|
+
| `find(x)` | **0** (path halving, iterative) |
|
|
472
|
+
| `union(a, b)` | **0** |
|
|
473
|
+
| `connected(a, b)` | **0** |
|
|
474
|
+
| `componentSize(x)` | **0** |
|
|
475
|
+
| `reset()` | **0** (O(n) bulk pass, no new store) |
|
|
476
|
+
| `forEachRoots(fn)` | **0** (O(n) scan) |
|
|
477
|
+
| `roots()` | a generator + `{value,done}` per step (protocol) |
|
|
478
|
+
| `new UnionFind(...)` | once, at construction (both typed arrays) |
|
|
479
|
+
|
|
480
|
+
`find` is path-halving and ITERATIVE -- no recursion and no stack array -- so the flattening that buys the amortized constant costs zero allocation. The element guard is the same branchless typeof-first check as the other members (`typeof x !== 'number' || (x >>> 0) !== x || x >= n`), with the `_oob` throw builder (using `String(x)`) on the cold path. The torture gate proves UnionFind at **0 B/op** across `find` / `union` / `connected` / `componentSize` churn (with real merges and O(n) `reset` / `forEachRoots` cycles exercised), 0 major GCs, a 0-delta on the two-column backing, and the leak tracker back at `size() = 0`. `reset()` and `forEachRoots()` are O(n) bulk primitives (still alloc-free) and are excluded from the zero-alloc-**per-op** claim; `roots()` is the one op that allocates, by generator protocol.
|
|
481
|
+
|
|
363
482
|
</details>
|
|
364
483
|
|
|
365
484
|
---
|
|
@@ -372,15 +491,16 @@ The value guard is a two-test branchless check on the hot body -- `typeof v !==
|
|
|
372
491
|
- **Fixed capacity, no silent growth.** A new key past `capacity` throws rather than reallocating. A structure that advertises worst-case O(1) must not hide an amortized O(n) resize; growth, if ever offered, will be opt-in and labeled. See [`decisions/0003`](./decisions/0003-slotpool-deferred.md).
|
|
373
492
|
- **The witness is a first-class deliverable, with a gated floor.** SparseSet flatness `>= 0.70`, the `Set` foil `<= 0.55`, ratio `>= 1.5x` -- a regression in the constant fails the build. See [`decisions/0004`](./decisions/0004-witness-flatness-gate.md).
|
|
374
493
|
- **RingDeque is fixed-capacity (power-of-two), fail closed on full, and stores numbers only.** A power-of-two capacity buys the single-`& MASK` wrap; head + count makes full / empty single tests; a full push throws (no silent drop / overwrite); the numeric substrate keeps it zero-GC and makes `undefined`-on-empty unambiguous. See [`decisions/0005`](./decisions/0005-ring-capacity-fail-closed.md) and [`decisions/0006`](./decisions/0006-numeric-ring-substrate.md).
|
|
494
|
+
- **UnionFind is amortized, not worst-case, and honest about it.** Path halving (iterative, no stack -- so zero-alloc) plus union by size bound any single op at O(alpha(n)) amortized; a single `find` is O(depth) worst-case, and the witness proves the amortized line against a naive-disjoint-set foil. `reset()` and `forEachRoots()` are the O(n) exceptions (named `reset()`, not `clear()`, to flag the cost); `roots()` is the one allocating op. See [`decisions/0007`](./decisions/0007-unionfind-path-halving-union-by-size.md).
|
|
375
495
|
|
|
376
496
|
---
|
|
377
497
|
|
|
378
498
|
## Testing
|
|
379
499
|
|
|
380
|
-
**
|
|
500
|
+
**83 deterministic `node:test` cases**, plus a torture gate, a hard perf gate, and the O(1) witness gate.
|
|
381
501
|
|
|
382
502
|
```bash
|
|
383
|
-
npm test #
|
|
503
|
+
npm test # 83 node:test cases (contract + boundary + differential fuzz)
|
|
384
504
|
npm run test:types # tsc --noEmit against O1.d.ts
|
|
385
505
|
npm run torture # @zakkster/lite-leak + lite-gc-profiler: 0 B/op + leak-free
|
|
386
506
|
npm run witness # the O(1) throughput-invariance harness + foils + flatness gate
|
|
@@ -388,7 +508,7 @@ npm run test:perf # @zakkster/lite-perf-gate: hard zero-alloc scavenge-scaling
|
|
|
388
508
|
npm run verify # all five, the publish gate
|
|
389
509
|
```
|
|
390
510
|
|
|
391
|
-
For SparseSet the suite covers: constructor validation (every bad `universe` / `capacity`), the add/has/delete/clear/iterate surface, the delete-swap back-pointer, idempotent add, insertion-order iteration, the full fail-closed key surface (`add` throws `/^\[lite-o1\]/`, `has` never throws), `null is not zero`, a **byte-identical** proof that `clear()` leaves the dense + sparse `ArrayBuffer`s untouched, and a **1,000,000-op differential fuzz** of mixed add/delete/has against a native `Set` oracle. For RingDeque: power-of-two capacity rounding, push/pop/peek at both ends, wrap-around across the `& MASK` seam, the fail-closed surface (full push throws as a byte-identical no-op; a non-number or NaN throws; a Symbol / BigInt fails closed, not raw; `+/-Infinity` accepted; empty pop/peek returns `undefined`), a byte-identical `clear()` proof, and a **1,000,000-op both-ends differential fuzz** against a plain-`Array` reference deque (0 divergences, with the full-throw and empty-undefined edges both exercised). No gate output is a FAIL.
|
|
511
|
+
For SparseSet the suite covers: constructor validation (every bad `universe` / `capacity`), the add/has/delete/clear/iterate surface, the delete-swap back-pointer, idempotent add, insertion-order iteration, the full fail-closed key surface (`add` throws `/^\[lite-o1\]/`, `has` never throws), `null is not zero`, a **byte-identical** proof that `clear()` leaves the dense + sparse `ArrayBuffer`s untouched, and a **1,000,000-op differential fuzz** of mixed add/delete/has against a native `Set` oracle. For RingDeque: power-of-two capacity rounding, push/pop/peek at both ends, wrap-around across the `& MASK` seam, the fail-closed surface (full push throws as a byte-identical no-op; a non-number or NaN throws; a Symbol / BigInt fails closed, not raw; `+/-Infinity` accepted; empty pop/peek returns `undefined`), a byte-identical `clear()` proof, and a **1,000,000-op both-ends differential fuzz** against a plain-`Array` reference deque (0 divergences, with the full-throw and empty-undefined edges both exercised). For UnionFind: constructor validation (every bad `n`), the find/union/connected/componentSize/count/reset/forEachRoots/roots surface, `count` decrementing exactly once per true merge, a **path-halving depth-shrink proof** (a test-only peek at `_parent`), the full fail-closed element surface (a bad element -- including a Symbol / BigInt -- throws `/^\[lite-o1\]/`, never raw; `null is not zero`), and a **>= 100,000-op mixed union/find/connected differential fuzz** against a trivial no-compression / no-union-by-size oracle (0 divergences on connectivity, component size, and live count). No gate output is a FAIL.
|
|
392
512
|
|
|
393
513
|
---
|
|
394
514
|
|
|
@@ -396,9 +516,10 @@ For SparseSet the suite covers: constructor validation (every bad `universe` / `
|
|
|
396
516
|
|
|
397
517
|
- **Not a general-purpose set.** SparseSet keys are integers in a known, bounded `[0, universe)`. For arbitrary keys (strings, objects, huge sparse integer domains), use a native `Set` / `Map` -- SparseSet trades universe-sized memory for the flat constant and the O(1) clear.
|
|
398
518
|
- **Not a general-purpose queue.** RingDeque stores numbers only. To queue objects / strings, queue their integer handles and keep the payloads in a parallel column or `@zakkster/lite-arena`.
|
|
399
|
-
- **Not a
|
|
400
|
-
- **Not a
|
|
401
|
-
- **Not
|
|
519
|
+
- **Not a splittable disjoint-set.** UnionFind is merge-only: there is no per-element un-merge / undo. `reset()` re-singletons the whole forest in O(n); rollback means keeping your own edge log and rebuilding. It also eagerly allocates two `n`-sized `Uint32Array` columns at construction, so it is not for a huge / unbounded or non-integer element domain -- and a single `find` is amortized alpha(n), not worst-case O(1).
|
|
520
|
+
- **Not a growable collection.** All three members are fixed-capacity: a SparseSet key past capacity, or a RingDeque push on a full ring, throws; UnionFind's element universe `n` is fixed at construction. This is deliberate (worst-case / amortized bounds, fail closed -- no hidden resize), not a missing feature. An overwrite-oldest RingDeque preset (RingLog) is a deferred future variant, not the current default.
|
|
521
|
+
- **Not a payload store.** SparseSet holds membership, RingDeque holds numbers, UnionFind holds connectivity -- none holds object payloads. Store component data in a parallel SoA column or `@zakkster/lite-arena` keyed by the same ids / handles.
|
|
522
|
+
- **Not the full family yet.** v0.3.0 is SparseSet + RingDeque + UnionFind. SlotPool, MonoDeque, and the eight-dimension benchmark suite are on the roadmap, not in this release.
|
|
402
523
|
- **Not a benchmark suite.** The witness proves throughput invariance (one axis); the full latency/memory/cache/GC benchmark suite is a separate, planned deliverable.
|
|
403
524
|
|
|
404
525
|
---
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-o1
|
|
2
2
|
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
License: MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
5
5
|
Runtime dependencies: none. ESM only. ASCII-only source. sideEffects: false.
|
|
6
6
|
Node: >= 18.
|
|
@@ -12,14 +12,15 @@ constant instead of asserting it. The complexity class is the product: every hot
|
|
|
12
12
|
op is O(1) worst-case and allocates zero bytes after construction, and a shipped
|
|
13
13
|
witness harness demonstrates the flat throughput curve against a built-in foil.
|
|
14
14
|
|
|
15
|
-
v0.
|
|
16
|
-
state, so a bundler that imports one drops the
|
|
15
|
+
v0.3.0 ships three members: SparseSet, RingDeque, and UnionFind. They share no
|
|
16
|
+
mutable module state, so a bundler that imports one drops the others (tree-shakeable).
|
|
17
17
|
|
|
18
18
|
## Exports (from the single main file O1.js)
|
|
19
19
|
|
|
20
20
|
- `SparseSet` -- class. A zero-GC O(1) integer set over [0, universe).
|
|
21
21
|
- `RingDeque` -- class. A zero-GC O(1) fixed-capacity numeric double-ended queue.
|
|
22
|
-
- `
|
|
22
|
+
- `UnionFind` -- class. A zero-GC near-O(1) amortized disjoint-set forest.
|
|
23
|
+
- `VERSION` -- string, '0.3.0'.
|
|
23
24
|
|
|
24
25
|
## SparseSet
|
|
25
26
|
|
|
@@ -89,6 +90,46 @@ Rejected (throws [lite-o1]): null, undefined, string, Symbol, BigInt, object, Na
|
|
|
89
90
|
Accepted: any finite number, and +Infinity / -Infinity (typeof number, not NaN).
|
|
90
91
|
The typeof guard runs FIRST so a Symbol / BigInt never reaches arithmetic.
|
|
91
92
|
|
|
93
|
+
## UnionFind
|
|
94
|
+
|
|
95
|
+
new UnionFind(n)
|
|
96
|
+
|
|
97
|
+
- n: fixed element count; integer in [1, 2^32-1]. Elements are [0, n).
|
|
98
|
+
- Constructor throws a [lite-o1] RangeError on a non-integer / out-of-range arg
|
|
99
|
+
(Number.isInteger never coerces; message via String(n), Symbol/BigInt-safe).
|
|
100
|
+
- Substrate: TWO flat Uint32Array columns -- parent[i] (i's parent; i itself iff
|
|
101
|
+
root) and size[root] (elements in that tree). Path halving on find + union by
|
|
102
|
+
size bound any single op at O(alpha(n)) amortized (inverse Ackermann, <= ~4).
|
|
103
|
+
|
|
104
|
+
Hot surface (all O(1)-AMORTIZED, zero allocation):
|
|
105
|
+
|
|
106
|
+
- find(x) -> number Root of x's component. Path halving flattens the walk in
|
|
107
|
+
place (no recursion, no stack array). Throws [lite-o1] on
|
|
108
|
+
a bad element (typeof-guarded before the coercing >>>).
|
|
109
|
+
- union(a,b) -> boolean Merge a and b. true iff a real merge happened (they were
|
|
110
|
+
separate); false if already joined. Union by size. Guards
|
|
111
|
+
both endpoints. count-- exactly once per true merge.
|
|
112
|
+
- connected(a,b) -> boolean find(a) === find(b). Both guarded via find.
|
|
113
|
+
- componentSize(x) -> number size[find(x)]. Guarded via find.
|
|
114
|
+
- count (getter) Live component count. O(1), maintained (never scanned).
|
|
115
|
+
- capacity (getter) Fixed element universe n. (No `size` getter -- it would
|
|
116
|
+
collide with the live-count meaning size has elsewhere.)
|
|
117
|
+
- reset() -> void O(n) HONEST EXCEPTION: re-singleton every element
|
|
118
|
+
(parent[i]=i, size[i]=1, count=n). A single bulk pass over
|
|
119
|
+
the existing arrays -- allocates NOTHING, but is O(n), NOT
|
|
120
|
+
a zero-alloc-per-op hot path. Named reset(), not clear().
|
|
121
|
+
- forEachRoots(fn) -> void O(n) FULL SCAN, alloc-free: fn(root, uf) per current
|
|
122
|
+
root. Documented exception, EXCLUDED from the
|
|
123
|
+
zero-alloc-per-op claims. fn is hoisted for zero alloc.
|
|
124
|
+
- roots() -> generator O(n) scan; ALLOCATES a generator + {value,done} per step
|
|
125
|
+
by protocol (like [Symbol.iterator]) -- OUT of zero-alloc
|
|
126
|
+
claims. Use forEachRoots for the alloc-free scan.
|
|
127
|
+
|
|
128
|
+
Amortized honesty: a single find is NOT worst-case O(1) -- an adversarial
|
|
129
|
+
pre-halving chain is O(depth). The guarantee is AMORTIZED alpha(n); the witness
|
|
130
|
+
reports amortized throughput staying flat while a no-compression / no-union-by-size
|
|
131
|
+
foil (a degenerate chain) collapses.
|
|
132
|
+
|
|
92
133
|
## Design bounds
|
|
93
134
|
|
|
94
135
|
- SparseSet valid key: integer in [0, universe).
|
|
@@ -97,10 +138,12 @@ The typeof guard runs FIRST so a Symbol / BigInt never reaches arithmetic.
|
|
|
97
138
|
- RingDeque fixed capacity (power-of-two, rounded up): push past full throws
|
|
98
139
|
(fail closed); pop/peek on empty returns undefined. Numeric values only.
|
|
99
140
|
- RingDeque value: typeof 'number' and not NaN (+/-Infinity accepted).
|
|
141
|
+
- UnionFind element: integer in [0, n); fixed n, no growth. Fail closed on a bad
|
|
142
|
+
element (throws [lite-o1]); reset() and forEachRoots() are the O(n) exceptions.
|
|
100
143
|
|
|
101
144
|
## Scripts
|
|
102
145
|
|
|
103
|
-
- npm test --
|
|
146
|
+
- npm test -- 83 node:test cases (contract + boundary + differential fuzz).
|
|
104
147
|
- npm run test:types -- tsc --noEmit against O1.d.ts.
|
|
105
148
|
- npm run torture -- lite-leak + lite-gc-profiler: 0 B/op, 0 major GC, leak-free.
|
|
106
149
|
- npm run witness -- O(1) throughput-invariance harness + foils + flatness gate.
|
|
@@ -109,17 +152,27 @@ The typeof guard runs FIRST so a Symbol / BigInt never reaches arithmetic.
|
|
|
109
152
|
|
|
110
153
|
## Witness gate (locked)
|
|
111
154
|
|
|
112
|
-
- SparseSet: n-sweep [1e3..1e7], batch 1e6, warm-
|
|
113
|
-
|
|
114
|
-
|
|
155
|
+
- SparseSet: n-sweep [1e3..1e7] DISPLAYED, batch 1e6, two warm-ups + median of 9.
|
|
156
|
+
The gate is computed over the STEADY window 1e4 <= n <= 1e6: below it (n=1e3) is
|
|
157
|
+
an L1 micro-case that turbo-spikes, above it (n=1e7) is the memory wall where the
|
|
158
|
+
8*n-byte arrays exceed cache and ops/ms measures DRAM, not the algorithm -- both
|
|
159
|
+
are shown but excluded from the gate. SparseSet flatness (opsPerMs last/first over
|
|
160
|
+
the window) >= 0.70; native Set foil flatness <= 0.55 AND SparseSet/Set ops-per-ms
|
|
161
|
+
ratio >= 1.5x at every gated size. (The 0.70 floor is unchanged; only the gate's
|
|
162
|
+
domain is pinned to where ops/ms means O(1).)
|
|
115
163
|
- RingDeque: n-sweep [1e3, 1e4, 1e5], FIFO churn vs Array.prototype.shift foil
|
|
116
164
|
(O(n)); ops/ms is a rate, so the ring (batch 5e5) and foil (batch 2e3) use
|
|
117
165
|
different batches yet compare directly. RingDeque flatness >= 0.70; shift foil
|
|
118
166
|
flatness <= 0.55; RingDeque/shift ratio >= 1.5x.
|
|
167
|
+
- UnionFind: n-sweep [1e3, 1e4, 1e5], amortized find (coalesced + flattened) vs a
|
|
168
|
+
NAIVE disjoint-set foil (no path compression, no union-by-size -> a degenerate
|
|
169
|
+
chain, O(n) find). ops/ms is a rate (uf batch 5e5, naive batch 2e3). UnionFind
|
|
170
|
+
flatness >= 0.70; naive foil flatness <= 0.55; UnionFind/naive ratio >= 1.5x.
|
|
119
171
|
|
|
120
172
|
## Not yet shipped
|
|
121
173
|
|
|
122
|
-
SlotPool,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
174
|
+
SlotPool, MonoDeque, and the eight-dimension benchmark suite are on the roadmap,
|
|
175
|
+
not this release. A RingLog / overwrite-oldest RingDeque preset is a deferred
|
|
176
|
+
future variant. SparseSet holds membership, not payloads (store values in a
|
|
177
|
+
parallel SoA column or @zakkster/lite-arena); RingDeque holds numbers only;
|
|
178
|
+
UnionFind holds disjoint-set connectivity over [0, n), not payloads.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-o1",
|
|
3
3
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Zero-dependency, zero-GC family of O(1) data structures that proves its constant: SparseSet (O(1) add/has/delete/clear/iterate)
|
|
4
|
+
"version": "0.3.0",
|
|
5
|
+
"description": "Zero-dependency, zero-GC family of O(1) data structures that proves its constant: SparseSet (O(1) add/has/delete/clear/iterate), RingDeque (O(1) fixed-capacity numeric double-ended queue), and UnionFind (near-O(1) amortized disjoint-set) with a throughput-invariance witness harness. Tree-shakeable named exports.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./O1.js",
|
|
8
8
|
"module": "./O1.js",
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"circular-buffer",
|
|
40
40
|
"deque",
|
|
41
41
|
"ringdeque",
|
|
42
|
+
"union-find",
|
|
43
|
+
"disjoint-set",
|
|
44
|
+
"dsu",
|
|
42
45
|
"data-structures",
|
|
43
46
|
"zero-gc",
|
|
44
47
|
"gc",
|