@zakkster/lite-o1 0.1.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.
Files changed (6) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/O1.d.ts +97 -0
  3. package/O1.js +377 -3
  4. package/README.md +265 -25
  5. package/llms.txt +116 -18
  6. package/package.json +25 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,120 @@ 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
+
74
+ ## [0.2.0] - 2026-09-15
75
+
76
+ The second member of the O(1) family: a fixed-capacity double-ended queue that
77
+ kills the `Array.prototype.shift` O(n) trap. Tree-shakeable alongside SparseSet
78
+ (the two share no mutable module state).
79
+
80
+ ### Added
81
+
82
+ - **`RingDeque(capacity)`** -- a zero-GC O(1) fixed-capacity double-ended queue
83
+ over ONE `Float64Array` (numeric values only), head + count representation:
84
+ - `pushFront(v)` / `pushBack(v)` / `popFront()` / `popBack()` / `peekFront()` /
85
+ `peekBack()` / `clear()` / `forEach(fn)` / `[Symbol.iterator]` -- all O(1)
86
+ worst-case, zero allocation after construction. `size` and `capacity` getters.
87
+ - Capacity ROUNDS UP to the next power of two (`>= requested`); the ring wraps
88
+ by a single `& (capacity - 1)`. The `capacity` getter reports the rounded
89
+ value. Ceiling: 2^31 elements (a 16 GiB `Float64Array`).
90
+ - `clear()` is O(1): resets head + count and zeroes NO store (numbers retain no
91
+ references, so there is nothing to reclaim).
92
+ - Fail closed: push on a FULL ring throws a `[lite-o1]` error as a
93
+ byte-identical no-op; a non-clean value (non-number or NaN; `+/-Infinity`
94
+ accepted) throws `[lite-o1]` (typeof-guarded before any coercion, so a Symbol
95
+ / BigInt never triggers a raw `TypeError`). `pop*` / `peek*` on an EMPTY ring
96
+ return `undefined` and never throw.
97
+ - **`O1.d.ts`** -- RingDeque ambient types added.
98
+ - **The O(1) Witness** (`test/witness.mjs`) -- a RingDeque FIFO-churn sweep
99
+ `[1e3, 1e4, 1e5]` vs an `Array.prototype.shift` foil (O(n)); RingDeque flatness
100
+ `>= 0.70`, shift foil `<= 0.55`, ratio `>= 1.5x`.
101
+ - **Torture gate** -- RingDeque fill/drain + both-ends interleave cycles at 0 B/op,
102
+ 0 major GC, tracker size 0, arrayBuffers delta 0.
103
+ - **Perf gate** (`test/perf/PerfGate.test.mjs`) -- RingDeque FIFO, LIFO, and
104
+ both-ends interleave scenarios at 0 scavenges / 0 old-gen / 0 arrayBuffers and a
105
+ 0-delta grows-counter on the `Float64Array` backing.
106
+ - **RingDeque `node:test` cases** -- contract + boundary + a 1,000,000-op
107
+ differential fuzz at both ends against a plain-`Array` reference deque (0
108
+ divergences; the full-throw and empty-undefined edges both exercised).
109
+ - ADRs [`0005`](./decisions/0005-ring-capacity-fail-closed.md) (fixed power-of-two
110
+ capacity, fail closed on full) and
111
+ [`0006`](./decisions/0006-numeric-ring-substrate.md) (numeric Float64Array
112
+ substrate, undefined-on-empty, clear-untouched).
113
+
114
+ ### Changed
115
+
116
+ - `VERSION` bumped to `'0.2.0'` (synced across `package.json`, the `VERSION` const
117
+ in `O1.js`, and `llms.txt`).
118
+
119
+ [0.2.0]: https://www.npmjs.com/package/@zakkster/lite-o1/v/0.2.0
120
+
7
121
  ## [0.1.0] - 2026-09-15
8
122
 
9
123
  Initial release. The headline member of the O(1) family, plus the analytical
package/O1.d.ts CHANGED
@@ -49,3 +49,100 @@ export class SparseSet {
49
49
  /** Iterate present keys in insertion order. */
50
50
  [Symbol.iterator](): IterableIterator<number>;
51
51
  }
52
+
53
+ /**
54
+ * A zero-GC O(1) fixed-capacity double-ended queue over ONE Float64Array
55
+ * (numeric values only). pushFront / pushBack / popFront / popBack / peekFront /
56
+ * peekBack / clear / iterate are all O(1) worst-case and allocate nothing after
57
+ * construction. The requested capacity rounds UP to the next power of two, so the
58
+ * ring wraps by a single `& (capacity - 1)`. Fail closed: push* on a full deque
59
+ * or of a non-clean number (non-number or NaN; +/-Infinity accepted) throws a
60
+ * [lite-o1] error; pop* / peek* on an empty deque return `undefined` and never
61
+ * throw. `clear()` is O(1) and touches no store.
62
+ */
63
+ export class RingDeque {
64
+ /**
65
+ * @param capacity requested max elements; an integer in [1, 2^31]. Rounded
66
+ * UP to the next power of two.
67
+ */
68
+ constructor(capacity: number);
69
+
70
+ /** Number of live elements. */
71
+ readonly size: number;
72
+
73
+ /** Max elements this ring holds (power-of-two, rounded up from requested). */
74
+ readonly capacity: number;
75
+
76
+ /** Push v onto the front. Throws a [lite-o1] error when full or on a bad value. */
77
+ pushFront(v: number): this;
78
+
79
+ /** Push v onto the back. Throws a [lite-o1] error when full or on a bad value. */
80
+ pushBack(v: number): this;
81
+
82
+ /** Remove and return the front element, or `undefined` when empty. Never throws. */
83
+ popFront(): number | undefined;
84
+
85
+ /** Remove and return the back element, or `undefined` when empty. Never throws. */
86
+ popBack(): number | undefined;
87
+
88
+ /** Peek the front element, or `undefined` when empty. Never throws. */
89
+ peekFront(): number | undefined;
90
+
91
+ /** Peek the back element, or `undefined` when empty. Never throws. */
92
+ peekBack(): number | undefined;
93
+
94
+ /** Empty the deque in O(1) (resets head + count; zeroes no store). */
95
+ clear(): void;
96
+
97
+ /** Iterate live elements front -> back, alloc-free. */
98
+ forEach(fn: (value: number, index: number, deque: RingDeque) => void): void;
99
+
100
+ /** Iterate live elements front -> back. */
101
+ [Symbol.iterator](): IterableIterator<number>;
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,18 +3,20 @@
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.1.0 ships the headline member, SparseSet, plus its `VERSION` const.
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`).
7
9
  *
8
10
  * The complexity class IS the product: every hot op below is O(1) worst-case and
9
11
  * allocates ZERO bytes after construction. The witness harness (never imported
10
12
  * here) proves the throughput stays FLAT from n=1e3 to n=1e7 while a native Set
11
- * decays -- that flat line is the theorem made visible.
13
+ * (or Array.prototype.shift) decays -- that flat line is the theorem made visible.
12
14
  *
13
15
  * @license MIT
14
16
  */
15
17
 
16
18
  /** Package version. One of the three version sites (package.json / VERSION / llms.txt). */
17
- export const VERSION = '0.1.0';
19
+ export const VERSION = '0.3.0';
18
20
 
19
21
  /** Largest universe the Uint32 substrate + the (k >>> 0) key check can honor. */
20
22
  const MAX_UNIVERSE = 0x100000000; // 2^32
@@ -146,3 +148,375 @@ export class SparseSet {
146
148
  throw new RangeError('[lite-o1] SparseSet full (capacity ' + this._cap + ')');
147
149
  }
148
150
  }
151
+
152
+ /** Largest element count a Float64Array ring can honor (2^31 slots = 16 GiB). */
153
+ const MAX_CAPACITY = 0x80000000; // 2^31
154
+
155
+ /**
156
+ * Round `c` (an integer >= 1) UP to the next power of two >= c. Cold path only
157
+ * (constructor), so a plain doubling loop -- not a bit trick -- is used: `1 << 31`
158
+ * would overflow int32 to a negative, but `p *= 2` walks the double cleanly up to
159
+ * 2^31 in at most 31 steps.
160
+ * @param {number} c
161
+ * @returns {number}
162
+ */
163
+ function _roundPow2(c) {
164
+ let p = 1;
165
+ while (p < c) p *= 2;
166
+ return p;
167
+ }
168
+
169
+ /**
170
+ * RingDeque -- a zero-GC O(1) fixed-capacity double-ended queue over ONE
171
+ * `Float64Array` (numeric values only).
172
+ *
173
+ * pushFront / pushBack / popFront / popBack / peekFront / peekBack / clear /
174
+ * iterate are ALL O(1) worst-case. The buffer is a CIRCULAR ring: the live window
175
+ * is described by `head` (the index of the front element) and `count` (how many
176
+ * are live). The physical slot for logical offset `i` from the front is:
177
+ *
178
+ * store[(head + i) & MASK]
179
+ *
180
+ * `MASK = capacity - 1`, and `capacity` is a power of two, so the modulo that
181
+ * wraps the index is a single `& MASK` -- no branch, no division. The requested
182
+ * capacity ROUNDS UP to the next power of two (>= requested), and the `capacity`
183
+ * getter reports that rounded value. A pushFront off slot 0 wraps to the top via
184
+ * `(head - 1) & MASK` (int32 `-1 & MASK === MASK`).
185
+ *
186
+ * Fail closed, mirroring SparseSet's discipline exactly:
187
+ * - push* on a FULL deque THROWS a [lite-o1] error, as a byte-identical no-op
188
+ * (store + head + count unchanged -- the throw precedes every write). Cold path.
189
+ * - push* of a value that is not a CLEAN number THROWS [lite-o1]. Rejected:
190
+ * `typeof v !== 'number'` (null, undefined, string, Symbol, BigInt, object)
191
+ * AND NaN (`v !== v`). The typeof guard runs FIRST so a Symbol / BigInt never
192
+ * reaches arithmetic (`+`/`>>>`/template literals THROW a raw TypeError on
193
+ * those -- the recurring cross-package footgun); the cold builder names the
194
+ * value via `String(v)`, which is Symbol/BigInt-safe. +Infinity / -Infinity
195
+ * are CLEAN numbers (typeof number, not NaN) and are ACCEPTED.
196
+ * - pop* / peek* on an EMPTY deque return `undefined`, NEVER throw (mirrors
197
+ * has()'s never-throw query contract). The sentinel is unambiguous because
198
+ * every stored value is a real number, never `undefined`.
199
+ *
200
+ * clear() is O(1) and touches NOTHING: `head = 0; count = 0`. The stale numbers
201
+ * left in the store are unreachable (every read is bounded by `count`), and being
202
+ * numbers they retain no references -- so there is no retention risk and no reason
203
+ * to zero the buffer (mirrors SparseSet.clear()).
204
+ *
205
+ * NOTE: a RingLog / overwrite-oldest preset (pushBack that evicts the front when
206
+ * full instead of throwing) is a DEFERRED future variant -- see decisions/0005.
207
+ */
208
+ export class RingDeque {
209
+ /**
210
+ * @param {number} capacity requested max elements; an integer in [1, 2^31].
211
+ * Rounded UP to the next power of two.
212
+ */
213
+ constructor(capacity) {
214
+ // typeof guard BEFORE any coercion: Number.isInteger never coerces (false
215
+ // on a Symbol/BigInt), and String(x) in the cold message is Symbol-safe.
216
+ if (typeof capacity !== 'number' || !Number.isInteger(capacity) ||
217
+ capacity < 1 || capacity > MAX_CAPACITY) {
218
+ throw new RangeError(
219
+ '[lite-o1] RingDeque capacity must be an integer in [1, 2^31], got ' + String(capacity));
220
+ }
221
+ const cap = _roundPow2(capacity);
222
+ this._store = new Float64Array(cap); // the ring buffer (numeric slots)
223
+ this._cap = cap; // power-of-two capacity (rounded)
224
+ this._mask = cap - 1; // wrap mask: (i & MASK) is the physical slot
225
+ this._head = 0; // index of the front element
226
+ this._count = 0; // number of live elements
227
+ }
228
+
229
+ /** Number of live elements. O(1). */
230
+ get size() { return this._count; }
231
+
232
+ /** Max elements this ring can hold (power-of-two, rounded up from requested). O(1). */
233
+ get capacity() { return this._cap; }
234
+
235
+ /**
236
+ * Push v onto the FRONT. O(1). Fails closed: a non-clean value throws via
237
+ * _bad; a full deque throws via _full (byte-identical no-op). Guard typeof
238
+ * FIRST so a Symbol / BigInt never reaches the arithmetic below.
239
+ * @param {number} v a clean number (not NaN; +/-Infinity accepted)
240
+ * @returns {RingDeque} this
241
+ */
242
+ pushFront(v) {
243
+ if (typeof v !== 'number' || v !== v) return this._bad(v); // v !== v -> NaN
244
+ if (this._count === this._cap) return this._full();
245
+ const h = (this._head - 1) & this._mask; // -1 & MASK wraps to the top slot
246
+ this._store[h] = v;
247
+ this._head = h;
248
+ this._count++;
249
+ return this;
250
+ }
251
+
252
+ /**
253
+ * Push v onto the BACK. O(1). Fail-closed identical to pushFront.
254
+ * @param {number} v a clean number (not NaN; +/-Infinity accepted)
255
+ * @returns {RingDeque} this
256
+ */
257
+ pushBack(v) {
258
+ if (typeof v !== 'number' || v !== v) return this._bad(v); // v !== v -> NaN
259
+ if (this._count === this._cap) return this._full();
260
+ this._store[(this._head + this._count) & this._mask] = v;
261
+ this._count++;
262
+ return this;
263
+ }
264
+
265
+ /**
266
+ * Remove and return the FRONT element. O(1). Returns `undefined` on an empty
267
+ * deque (never throws) -- unambiguous because every stored value is a number.
268
+ * @returns {number|undefined}
269
+ */
270
+ popFront() {
271
+ if (this._count === 0) return undefined;
272
+ const v = this._store[this._head];
273
+ this._head = (this._head + 1) & this._mask;
274
+ this._count--;
275
+ return v;
276
+ }
277
+
278
+ /**
279
+ * Remove and return the BACK element. O(1). Returns `undefined` on empty.
280
+ * @returns {number|undefined}
281
+ */
282
+ popBack() {
283
+ if (this._count === 0) return undefined;
284
+ this._count--;
285
+ return this._store[(this._head + this._count) & this._mask];
286
+ }
287
+
288
+ /** Peek the FRONT element without removing it. O(1). `undefined` on empty. */
289
+ peekFront() {
290
+ if (this._count === 0) return undefined;
291
+ return this._store[this._head];
292
+ }
293
+
294
+ /** Peek the BACK element without removing it. O(1). `undefined` on empty. */
295
+ peekBack() {
296
+ if (this._count === 0) return undefined;
297
+ return this._store[(this._head + this._count - 1) & this._mask];
298
+ }
299
+
300
+ /**
301
+ * Empty the deque in O(1). Resets head + count only -- the store is left
302
+ * UNTOUCHED. The stale numbers are unreachable (reads are bounded by count)
303
+ * and retain no references, so there is nothing to zero (mirrors SparseSet).
304
+ */
305
+ clear() { this._head = 0; this._count = 0; }
306
+
307
+ /**
308
+ * Iterate live elements FRONT -> BACK, alloc-free. O(size). A HOISTED callback
309
+ * makes this a zero-allocation drain.
310
+ * @param {(value:number, index:number, deque:RingDeque)=>void} fn
311
+ */
312
+ forEach(fn) {
313
+ const store = this._store;
314
+ const mask = this._mask;
315
+ const head = this._head;
316
+ const count = this._count;
317
+ for (let i = 0; i < count; i++) fn(store[(head + i) & mask], i, this);
318
+ }
319
+
320
+ /** Iterate live elements FRONT -> BACK. O(size). */
321
+ *[Symbol.iterator]() {
322
+ const store = this._store;
323
+ const mask = this._mask;
324
+ const head = this._head;
325
+ const count = this._count;
326
+ for (let i = 0; i < count; i++) yield store[(head + i) & mask];
327
+ }
328
+
329
+ // ---- cold path only: throw builders (string concat lives here, off the hot body) ----
330
+
331
+ /** @private */
332
+ _bad(v) {
333
+ // String(v) -- NOT '+ v' / a template literal: those THROW on a Symbol or
334
+ // BigInt, which would turn a fail-closed reject into a different crash.
335
+ throw new TypeError(
336
+ '[lite-o1] RingDeque value must be a number and not NaN, got ' + String(v));
337
+ }
338
+
339
+ /** @private */
340
+ _full() {
341
+ throw new RangeError('[lite-o1] RingDeque full (capacity ' + this._cap + ')');
342
+ }
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,12 +1,14 @@
1
1
  # @zakkster/lite-o1
2
2
 
3
- > Zero-GC, O(1) data structures that PROVE their constant. v0.1.0 ships SparseSet: an integer set with O(1) add / has / delete / iterate and an O(1) clear() that zeroes nothing -- plus a throughput-invariance witness that shows the flat cost curve while a native Set decays.
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
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-o1.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-o1)
6
6
  [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
7
  ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
8
8
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-o1?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-o1)
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-o1?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-o1)
10
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-o1?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-o1)
11
+ ![Tree-Shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)
10
12
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
11
13
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
12
14
  [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)
@@ -15,7 +17,7 @@
15
17
 
16
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.
17
19
 
18
- v0.1.0 is the headline member: **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.
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.
19
21
 
20
22
  ```bash
21
23
  npm install @zakkster/lite-o1
@@ -57,6 +59,12 @@ Every op above is O(1) worst-case and allocates zero bytes after construction. T
57
59
  - [SparseSet](#sparseset)
58
60
  - [Constants](#constants)
59
61
  - [The O(1) Witness](#the-o1-witness)
62
+ - [RingDeque](#ringdeque)
63
+ - [How RingDeque works](#how-ringdeque-works)
64
+ - [RingDeque API reference](#ringdeque-api-reference)
65
+ - [UnionFind](#unionfind)
66
+ - [How UnionFind works](#how-unionfind-works)
67
+ - [UnionFind API reference](#unionfind-api-reference)
60
68
  - [Composability with the ecosystem](#composability-with-the-ecosystem)
61
69
  - [Zero-GC design notes](#zero-gc-design-notes)
62
70
  - [Design decisions worth knowing](#design-decisions-worth-knowing)
@@ -88,8 +96,22 @@ Existing options: a native `Set` (arbitrary keys, but a hash table that decays a
88
96
  - **`clear()`** -- empty in O(1): resets the live count, zeroes no store.
89
97
  - **`forEach(fn)` / `[Symbol.iterator]`** -- iterate present keys in insertion order, alloc-free.
90
98
  - **`size` / `capacity`** -- getters.
99
+ - **`RingDeque(capacity)`** -- a zero-GC O(1) fixed-capacity double-ended queue of numbers over one circular `Float64Array`. Capacity rounds up to the next power of two. The hot surface is eight ops plus two getters:
100
+ - **`pushFront(v)` / `pushBack(v)`** -- push at either end. O(1). Throw a `[lite-o1]` error when full (a byte-identical no-op) or on a non-clean value.
101
+ - **`popFront()` / `popBack()`** -- remove + return from either end. O(1). Return `undefined` on empty -- never a throw.
102
+ - **`peekFront()` / `peekBack()`** -- read either end without removing. O(1). `undefined` on empty.
103
+ - **`clear()`** -- empty in O(1): resets head + count, zeroes no store.
104
+ - **`forEach(fn)` / `[Symbol.iterator]`** -- iterate live elements front -> back, alloc-free.
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.
91
113
  - **`VERSION`** -- the package version string.
92
- - **The O(1) Witness** (`npm run witness`) -- an offline harness that times a fixed batch of the membership op across an n-sweep, reports ops/ms + a flatness ratio for SparseSet against a native `Set` foil, and fails if the constant regressed.
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.
93
115
 
94
116
  Full types ship in [`O1.d.ts`](./O1.d.ts). Tree-shakeable named exports (`sideEffects: false`) -- import only what you use.
95
117
 
@@ -156,34 +178,219 @@ get capacity: number // max live members as constructed
156
178
 
157
179
  | Constant | Value | Meaning |
158
180
  | ---------- | --------- | -------------------------------------------------- |
159
- | `VERSION` | `'0.1.0'` | Package version string. |
181
+ | `VERSION` | `'0.3.0'` | Package version string. |
160
182
 
161
183
  Contract bounds (validated, not exported):
162
184
 
163
- | Bound | Rule |
164
- | ---------- | ------------------------------------------------ |
165
- | `universe` | integer in `[1, 2^32]` |
166
- | `capacity` | integer in `[1, universe]`, default `universe` |
167
- | valid key | integer in `[0, universe)` |
185
+ | Bound | Rule |
186
+ | ------------------- | ------------------------------------------------ |
187
+ | SparseSet `universe`| integer in `[1, 2^32]` |
188
+ | SparseSet `capacity`| integer in `[1, universe]`, default `universe` |
189
+ | SparseSet valid key | integer in `[0, universe)` |
190
+ | RingDeque `capacity`| integer in `[1, 2^31]`, rounded up to a power of two |
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)` |
168
194
 
169
195
  ---
170
196
 
171
197
  ## The O(1) Witness
172
198
 
173
- 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 a warm-up and the median of 5 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(n_max) / opsPerMs(n_min)`):
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)`):
174
200
 
175
201
  ```
176
202
  n SparseSet ops/ms Set ops/ms ratio
177
203
  -------- ---------------- ---------- -----
178
- 1e3 ~552753.40 ~178964.22 ~3.09x
179
- 1e7 ~443852.64 ~15834.00 ~28.03x
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)
212
+ ```
213
+
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.)
215
+
216
+ ---
217
+
218
+ ## RingDeque
219
+
220
+ The second member: a **fixed-capacity double-ended queue of numbers** over one circular `Float64Array`. Push and pop at BOTH ends are O(1) worst-case and allocate zero bytes -- the zero-GC answer to the `Array.prototype.shift` / `unshift` O(n) trap, where every element re-indexes on each end operation.
221
+
222
+ ```js
223
+ import { RingDeque } from '@zakkster/lite-o1';
224
+
225
+ // Requested 1000 -> capacity rounds UP to the next power of two (1024).
226
+ const q = new RingDeque(1000);
227
+ q.capacity; // -> 1024
228
+
229
+ q.pushBack(1);
230
+ q.pushBack(2);
231
+ q.pushFront(0); // [0, 1, 2]
232
+
233
+ q.peekFront(); // -> 0
234
+ q.peekBack(); // -> 2
235
+
236
+ q.popFront(); // -> 0 (FIFO with pushBack)
237
+ q.popBack(); // -> 2 (LIFO with pushBack)
238
+ q.size; // -> 1
239
+
240
+ for (const v of q) console.log(v); // 1 (front -> back, alloc-free)
241
+
242
+ q.pushBack(Infinity); // OK: +/-Infinity are clean numbers
243
+ // q.pushBack(NaN); // throws [lite-o1]: NaN is rejected
244
+ // q.pushBack('3'); // throws [lite-o1]: not a number
245
+
246
+ q.clear(); // O(1): resets head + count, touches NO store
247
+ q.popFront(); // -> undefined (empty never throws)
248
+ ```
249
+
250
+ Every op is O(1) worst-case and zero-allocation after construction. `pop*` / `peek*` on an empty ring return `undefined` (never throw); the sentinel is unambiguous because every stored value is a real number. A push on a full ring throws a `[lite-o1]` error as a byte-identical no-op -- fail closed, no silent drop or overwrite. The `witness` harness proves RingDeque's FIFO churn holds its ops/ms while `Array.prototype.shift` collapses as `n` grows.
251
+
252
+ ### How RingDeque works
253
+
254
+ <details>
255
+ <summary>The circular buffer, head + count, and why clear() is free.</summary>
256
+
257
+ A RingDeque holds one `Float64Array` (the ring), a `head` (the index of the front element), and a `count` (how many elements are live). The physical slot for logical offset `i` from the front is:
258
+
259
+ ```
260
+ store[(head + i) & MASK] MASK = capacity - 1
261
+ ```
262
+
263
+ Because `capacity` is a power of two, the modulo that wraps the index is a single bitwise `& MASK` -- no branch, no division. The requested capacity rounds UP to the next power of two (so `new RingDeque(1000)` gives capacity 1024), and the `capacity` getter reports that rounded value.
264
+
265
+ - **`pushBack(v)`** writes `store[(head + count) & MASK] = v; count++`.
266
+ - **`pushFront(v)`** moves the head back one slot (`head = (head - 1) & MASK`, where int32 `-1 & MASK === MASK` wraps off slot 0 to the top), writes `store[head] = v`, then `count++`.
267
+ - **`popFront()`** reads `store[head]`, advances `head = (head + 1) & MASK`, `count--`.
268
+ - **`popBack()`** does `count--` and reads `store[(head + count) & MASK]`.
269
+
270
+ Using **head + count** (not a head/tail pair) makes "full" a single test (`count === capacity`) and "empty" a single test (`count === 0`), with no ambiguous `head === tail` state to disambiguate.
271
+
272
+ - **`clear()`** is `head = 0; count = 0`. The store is left byte-identical. The stale numbers are unreachable (every read is bounded by `count`) and retain no references (they are numbers), so there is nothing to zero -- clearing a full ring costs the same as clearing an empty one. This is the same teachable gem as SparseSet's cross-checked clear.
273
+
274
+ The cost of the constant is the value domain: a `Float64Array` holds numbers only. To queue objects, queue their integer handles / indices and keep the payloads in a parallel column or `@zakkster/lite-arena`.
275
+
276
+ </details>
277
+
278
+ ### RingDeque API reference
279
+
280
+ ```ts
281
+ new RingDeque(capacity: number) // capacity rounds up to the next power of two
282
+ ```
283
+
284
+ - **`capacity`** -- the requested maximum number of live elements; an integer in `[1, 2^31]`. Rounded UP to the next power of two (`>= requested`); the `capacity` getter reports the rounded value. The constructor throws a `[lite-o1]`-tagged `RangeError` on a non-integer, out-of-range, or non-number argument (typeof-guarded before any coercion, so a Symbol / BigInt fails closed rather than crashing raw).
180
285
 
181
- SparseSet flatness (last/first): ~0.80 (gate >= 0.70)
182
- Set foil flatness (last/first): ~0.09 (gate <= 0.55)
183
- min SparseSet/Set ratio: ~3.09x (gate >= 1.50x)
286
+ ```ts
287
+ pushFront(v: number): this // push at the front; throws when full / on a bad value
288
+ pushBack(v: number): this // push at the back; throws when full / on a bad value
289
+ popFront(): number | undefined // remove + return the front; undefined on empty
290
+ popBack(): number | undefined // remove + return the back; undefined on empty
291
+ peekFront(): number | undefined // read the front; undefined on empty
292
+ peekBack(): number | undefined // read the back; undefined on empty
293
+ clear(): void // O(1) empty; zeroes no store
294
+ forEach(fn: (value: number, index: number, deque: RingDeque) => void): void // front -> back
295
+ [Symbol.iterator](): IterableIterator<number> // front -> back
296
+ get size: number // live element count
297
+ get capacity: number // max elements (power-of-two, rounded up)
184
298
  ```
185
299
 
186
- SparseSet's contiguous typed-array layout streams flat; the `Set`'s hash table scatters across an ever-larger backing store until each lookup is a cache miss, so its ops/ms falls ~11x across the sweep. The gate 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 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.)
300
+ - **`pushFront(v)` / `pushBack(v)`** throw `[lite-o1] RingDeque full ...` when the ring is at capacity (a byte-identical no-op -- store + head + count unchanged), and `[lite-o1] RingDeque value must be a number ...` on a value that is not a clean number. A value is clean iff `typeof v === 'number'` AND it is not `NaN`; `+Infinity` / `-Infinity` are accepted, while `null`, `undefined`, strings, Symbols, BigInts, objects, and `NaN` are rejected. The `typeof` guard runs first so a Symbol / BigInt never reaches arithmetic.
301
+ - **`popFront()` / `popBack()` / `peekFront()` / `peekBack()`** never throw: an empty ring returns `undefined`. Because every stored value is a real number, `undefined` unambiguously means "empty".
302
+
303
+ **Reach for RingDeque when** you need FIFO / LIFO / sliding-window push-pop at O(1) with zero per-op allocation over a bounded numeric domain (ring buffers, bounded work queues, rolling windows). **Avoid it when** you need to queue non-numbers (queue their handles instead), or need the queue to grow past a bound you cannot set up front (it fails closed on a full push rather than resizing). See [`GUIDE.md`](./GUIDE.md) for the full reach-for / avoid / measure-it.
304
+
305
+ ---
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.
187
394
 
188
395
  ---
189
396
 
@@ -244,6 +451,34 @@ The only cold branches are constructor validation and the `_oob` / `_full` throw
244
451
 
245
452
  The torture gate (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`, run under `--expose-gc`) proves it: **0 B/op** on the add/has/delete hot path (per-call allocation measured to the sampling floor), **0 major GCs** and a max pause `<= 2ms` across a 2,000,000-op run, and 100 fill/clear cycles that leave the leak tracker at `size() = 0` (every tracked instance reclaimed -- proven non-vacuously by asserting the tracker held them first) with zero arrayBuffers growth (`clear()` allocates nothing; the reused set grows no backing store). `[Symbol.iterator]` is the one op that allocates -- a single iterator object per `for...of`, not per element -- so a per-frame hot loop uses `forEach`, which is allocation-free.
246
453
 
454
+ **RingDeque** allocates its single `Float64Array` once, at construction:
455
+
456
+ | Operation | Steady-state allocations |
457
+ | -------------------------------- | ------------------------ |
458
+ | `pushFront(v)` / `pushBack(v)` | **0** |
459
+ | `popFront()` / `popBack()` | **0** |
460
+ | `peekFront()` / `peekBack()` | **0** |
461
+ | `clear()` | **0** (head + count = 0) |
462
+ | `forEach(fn)` | **0** |
463
+ | `new RingDeque(...)` | once, at construction (one typed array) |
464
+
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`.
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
+
247
482
  </details>
248
483
 
249
484
  ---
@@ -255,31 +490,36 @@ The torture gate (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`, run unde
255
490
  - **Fail closed on add, absent on query.** A bad key to `add` throws (you asked to store something invalid -- a bug). A bad key to `has` / `delete` is simply absent (a query about a non-member is a legitimate `false`). `null` is never coerced to `0`.
256
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).
257
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).
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).
258
495
 
259
496
  ---
260
497
 
261
498
  ## Testing
262
499
 
263
- **19 deterministic `node:test` cases**, plus a torture gate and the O(1) witness gate.
500
+ **83 deterministic `node:test` cases**, plus a torture gate, a hard perf gate, and the O(1) witness gate.
264
501
 
265
502
  ```bash
266
- npm test # 19 node:test cases (contract + boundary + differential fuzz)
503
+ npm test # 83 node:test cases (contract + boundary + differential fuzz)
267
504
  npm run test:types # tsc --noEmit against O1.d.ts
268
505
  npm run torture # @zakkster/lite-leak + lite-gc-profiler: 0 B/op + leak-free
269
- npm run witness # the O(1) throughput-invariance harness + Set foil + flatness gate
270
- npm run verify # all four, the publish gate
506
+ npm run witness # the O(1) throughput-invariance harness + foils + flatness gate
507
+ npm run test:perf # @zakkster/lite-perf-gate: hard zero-alloc scavenge-scaling gate
508
+ npm run verify # all five, the publish gate
271
509
  ```
272
510
 
273
- 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 (snapshot the raw bytes, clear, assert equality, confirm every prior key is absent and a stale pointer cannot masquerade as present), and a **1,000,000-op differential fuzz** of mixed add/delete/has against a native `Set` oracle with zero divergences. 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.
274
512
 
275
513
  ---
276
514
 
277
515
  ## What this is not
278
516
 
279
- - **Not a general-purpose set.** 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.
280
- - **Not a growable collection.** Capacity is fixed at construction; a new key past it throws. This is deliberate (worst-case O(1), fail closed), not a missing feature.
281
- - **Not a payload store.** SparseSet holds membership, not values. Store component data in a parallel SoA column or `@zakkster/lite-arena` keyed by the same ids.
282
- - **Not the full family yet.** v0.1.0 is SparseSet only. RingDeque, SlotPool, UnionFind, and the eight-dimension benchmark suite are on the roadmap, not in this release.
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.
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`.
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.
283
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.
284
524
 
285
525
  ---
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-o1
2
2
 
3
- Version: 0.1.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,12 +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.1.0 ships one member: SparseSet.
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).
16
17
 
17
18
  ## Exports (from the single main file O1.js)
18
19
 
19
20
  - `SparseSet` -- class. A zero-GC O(1) integer set over [0, universe).
20
- - `VERSION` -- string, '0.1.0'.
21
+ - `RingDeque` -- class. A zero-GC O(1) fixed-capacity numeric double-ended queue.
22
+ - `UnionFind` -- class. A zero-GC near-O(1) amortized disjoint-set forest.
23
+ - `VERSION` -- string, '0.3.0'.
21
24
 
22
25
  ## SparseSet
23
26
 
@@ -53,28 +56,123 @@ Membership invariant (the whole trick):
53
56
  The cross-check rejects stale sparse pointers, which is why clear() can be O(1)
54
57
  and never zero a store.
55
58
 
59
+ ## RingDeque
60
+
61
+ new RingDeque(capacity)
62
+
63
+ - capacity: requested max elements; integer in [1, 2^31]. ROUNDS UP to the next
64
+ power of two (>= requested); the capacity getter reports the rounded value.
65
+ - Constructor throws a [lite-o1] RangeError on a non-integer / out-of-range /
66
+ non-number arg (typeof-guarded before any coercion; message via String(x)).
67
+ - Substrate: ONE Float64Array (numeric values only), head + count representation.
68
+ The physical slot for logical offset i from the front is store[(head + i) & MASK],
69
+ MASK = capacity - 1; wrap is a single & (power-of-two modulo), no branch.
70
+
71
+ Hot surface (all O(1) worst-case, zero allocation):
72
+
73
+ - pushFront(v) -> this Push v onto the front. Throws [lite-o1] when FULL (a
74
+ byte-identical no-op) or on a non-clean value.
75
+ - pushBack(v) -> this Push v onto the back. Same fail-closed policy.
76
+ - popFront() -> number|undefined Remove+return the front; undefined on empty (never throws).
77
+ - popBack() -> number|undefined Remove+return the back; undefined on empty (never throws).
78
+ - peekFront() -> number|undefined Front without removing; undefined on empty.
79
+ - peekBack() -> number|undefined Back without removing; undefined on empty.
80
+ - clear() -> void Empty in O(1): resets head + count, touches NO store
81
+ (numbers retain no references, so nothing to zero).
82
+ - forEach(fn) -> void Iterate live elements front->back, alloc-free.
83
+ fn is (value, index, deque).
84
+ - [Symbol.iterator]() Iterate live elements front->back.
85
+ - size (getter) Live element count.
86
+ - capacity (getter) Max elements (power-of-two, rounded up from requested).
87
+
88
+ Value contract (LOCKED): a pushed value must be typeof 'number' AND not NaN.
89
+ Rejected (throws [lite-o1]): null, undefined, string, Symbol, BigInt, object, NaN.
90
+ Accepted: any finite number, and +Infinity / -Infinity (typeof number, not NaN).
91
+ The typeof guard runs FIRST so a Symbol / BigInt never reaches arithmetic.
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
+
56
133
  ## Design bounds
57
134
 
58
- - valid key: integer in [0, universe).
59
- - Fixed capacity: no silent growth; a new key past capacity throws (fail closed).
60
- - Fail closed on add, absent on query. null is never coerced to key 0.
135
+ - SparseSet valid key: integer in [0, universe).
136
+ - SparseSet fixed capacity: no silent growth; a new key past capacity throws.
137
+ - SparseSet fail closed on add, absent on query. null is never coerced to key 0.
138
+ - RingDeque fixed capacity (power-of-two, rounded up): push past full throws
139
+ (fail closed); pop/peek on empty returns undefined. Numeric values only.
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.
61
143
 
62
144
  ## Scripts
63
145
 
64
- - npm test -- 19 node:test cases (contract + boundary + differential fuzz).
146
+ - npm test -- 83 node:test cases (contract + boundary + differential fuzz).
65
147
  - npm run test:types -- tsc --noEmit against O1.d.ts.
66
148
  - npm run torture -- lite-leak + lite-gc-profiler: 0 B/op, 0 major GC, leak-free.
67
- - npm run witness -- O(1) throughput-invariance harness + Set foil + flatness gate.
68
- - npm run verify -- all four (publish gate).
149
+ - npm run witness -- O(1) throughput-invariance harness + foils + flatness gate.
150
+ - npm run test:perf -- lite-perf-gate hard zero-alloc scavenge-scaling gate.
151
+ - npm run verify -- all five (publish gate).
69
152
 
70
153
  ## Witness gate (locked)
71
154
 
72
- - n-sweep [1e3, 1e4, 1e5, 1e6, 1e7], batch 1e6, warm-up + median of 5.
73
- - SparseSet flatness (opsPerMs last/first) >= 0.70.
74
- - Native Set foil flatness <= 0.55 AND SparseSet/Set ops-per-ms ratio >= 1.5x.
75
-
76
- ## Not in v0.1.0
77
-
78
- RingDeque, SlotPool, UnionFind, MonoDeque, and the eight-dimension benchmark
79
- suite are on the roadmap, not this release. SparseSet holds membership, not
80
- payloads (store values in a parallel SoA column or @zakkster/lite-arena).
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).)
163
+ - RingDeque: n-sweep [1e3, 1e4, 1e5], FIFO churn vs Array.prototype.shift foil
164
+ (O(n)); ops/ms is a rate, so the ring (batch 5e5) and foil (batch 2e3) use
165
+ different batches yet compare directly. RingDeque flatness >= 0.70; shift foil
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.
171
+
172
+ ## Not yet shipped
173
+
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.1.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) with a throughput-invariance witness harness. Tree-shakeable named exports.",
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",
@@ -28,12 +28,20 @@
28
28
  "test:types": "tsc -p test/types/tsconfig.json",
29
29
  "torture": "node --expose-gc test/torture.mjs",
30
30
  "witness": "node test/witness.mjs",
31
- "verify": "npm test && npm run test:types && npm run torture && npm run witness"
31
+ "test:perf": "node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs",
32
+ "verify": "npm test && npm run test:types && npm run torture && npm run witness && npm run test:perf"
32
33
  },
33
34
  "keywords": [
34
35
  "o1",
35
36
  "big-o",
36
37
  "sparse-set",
38
+ "ring-buffer",
39
+ "circular-buffer",
40
+ "deque",
41
+ "ringdeque",
42
+ "union-find",
43
+ "disjoint-set",
44
+ "dsu",
37
45
  "data-structures",
38
46
  "zero-gc",
39
47
  "gc",
@@ -51,10 +59,24 @@
51
59
  "devDependencies": {
52
60
  "@zakkster/lite-gc-profiler": "^1.16.0",
53
61
  "@zakkster/lite-leak": "^1.10.0",
62
+ "@zakkster/lite-perf-gate": "^1.4.2",
54
63
  "typescript": "^7.0.2"
55
64
  },
65
+ "homepage": "https://github.com/PeshoVurtoleta/lite-o1#readme",
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://github.com/PeshoVurtoleta/lite-o1.git"
69
+ },
70
+ "bugs": {
71
+ "url": "https://github.com/PeshoVurtoleta/lite-o1/issues",
72
+ "email": "shinikchiev@yahoo.com"
73
+ },
56
74
  "engines": {
57
75
  "node": ">=18"
58
76
  },
77
+ "funding": {
78
+ "type": "github",
79
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
80
+ },
59
81
  "sideEffects": false
60
82
  }