@zakkster/lite-logn 0.5.0 → 0.6.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 CHANGED
@@ -6,6 +6,68 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-09-20
10
+
11
+ ### Added
12
+
13
+ - **Scapegoat** -- the sixth member and the family's DETERMINISTIC balanced BST, the
14
+ honest PAIR to Treap: a weight-balanced binary search tree that is ALSO an
15
+ order-statistic tree (an AUGMENTED ordered map key -> value). Where a treap randomizes
16
+ its shape to be balanced IN EXPECTATION, a scapegoat keeps a hard WORST-CASE height
17
+ bound -- so `get` is O(log n) WORST-case (never merely expected) -- and pays for it with
18
+ AMORTIZED O(log n) `set` / `delete`, where an occasional subtree rebuild absorbs the
19
+ imbalance. A subtree-size column `_size` (maintained in the same pass as every link
20
+ rewrite and every rebuild) adds O(log n) order statistics. Surface: `get` / `has` /
21
+ `set` (updates the value in place on an existing key) / `delete` (idempotent) /
22
+ `rank(x)` (count of keys STRICTLY less than x) / `select(k)` (the k-th smallest key,
23
+ 0-based) / `successor` (strictly greater) / `predecessor` (strictly less) /
24
+ `rangeIter(lo, hi)` (a VERSION-STAMPED iterator over `[lo, hi]` inclusive, ascending;
25
+ `+-Infinity` bounds allowed, structural OR value mutation mid-iteration throws) /
26
+ `forEach` / `clear`, and `size` / `capacity` / `alpha` getters. Keys and values are
27
+ finite numbers (typeof-guarded before coercion -- Symbol / BigInt / NaN / +-Infinity
28
+ fail closed with a `[lite-logn]` throw).
29
+ - **NO priorities, NO RNG (fully deterministic).** Unlike Treap, Scapegoat draws no random
30
+ priority and uses no LCG / `Math.random` anywhere: the tree shape is a deterministic
31
+ function of the insert / delete order. `alpha` (the weight-balance factor) is validated
32
+ to the OPEN interval `(0.55, 0.75)` -- both ends throw -- and frozen at construction
33
+ (default `2/3`); the alpha-derived depth constant `_invAlpha = 1/alpha` is ctor-cached so
34
+ the hot insert path uses no per-op `Math.log` (the depth test is `_invAlpha^d > size`).
35
+ - **Zero-GC rebuild over preallocated scratch (the load-bearing design call).** No fresh
36
+ array per rebuild: ONE `_flat` (`Uint32Array(capacity)`) + ONE `_stack`
37
+ (`Uint32Array(capacity+1)`) are allocated at construction and reused every rebuild. An
38
+ ITERATIVE, Morris-free in-order flatten (via `_stack`) writes sorted slot indices into
39
+ `_flat`; a bounded log-depth balanced rebuild re-links `_left` / `_right` / `_size` on
40
+ the native call stack. Proven 0 B/op even under a rebuild-HEAVY ascending-insert trace by
41
+ the torture gate and a dedicated PerfGate scavenge-clean scenario
42
+ (decisions/0008-scapegoat.md).
43
+ - **Third bind of the shared NodePool.** Nodes are slot INDICES in five flat columns
44
+ (`_key` / `_value` Float64; `_left` / `_right` / `_size` Uint32, `NIL = 0`) over the SAME
45
+ private free-list (`NodePool`) SkipList and Treap ship -- design-parity, not a fork or a
46
+ runtime dep. The conservation invariant `activeSlots + freeListLength === capacity` holds
47
+ after every op, INCLUDING across rebuild storms. `SG_MAX_CAPACITY = 0x7FFFFFFF` (2^31 - 1:
48
+ slot indices + subtree counts fit a `Uint32`).
49
+ - **The documented asymmetry vs Treap: NO `split` / `merge`.** A scapegoat has no priority
50
+ heap to merge by, and an honest deterministic split/merge would be O(n) rebuilds
51
+ (forfeiting the sub-linear headline), so Scapegoat's surface is the ordered-map +
52
+ order-statistic core and split/merge are deliberately absent -- named on the public
53
+ surface (JSDoc + `llms.txt` + README + the `.d.ts`), not hidden.
54
+ - **Witness: `Scapegoat.get` gated + the amortized-trace assertion.** `get` (a
55
+ deterministic weight-balanced descent) is gated ON the O(log n) line in its own
56
+ calibrated band `SCAPEGOAT_GET_SLOPE_LO/HI = [2.41, 5.63]` (median-of-15 slope 4.02
57
+ ns/level * [0.6, 1.4], MEDIAN-centered per ADR-0004), inheriting the FROZEN shared R^2
58
+ floor 0.958; its O(n) linear-scan foil leaves the line. The rebuild spike lives on the
59
+ AMORTIZED `set` path and is NEVER gated as a per-op line; instead the amortized-trace
60
+ assertion proves the amortization -- the cumulative ascending-insert (rebuild-heavy)
61
+ cost/op tracks a LOG curve (last/first ratio ~1.5x, gated `< 4x`) where a rebuild-less
62
+ BST would blow to ~64x. The five prior members' bands are UNTOUCHED.
63
+
64
+ ### Notes
65
+
66
+ - Append-only: `LogN.js` gains `SG_MAX_CAPACITY` + the `Scapegoat` class after Treap;
67
+ BinaryHeap / Fenwick / SegmentTree / SkipList / Treap are BYTE-IDENTICAL (only the
68
+ `VERSION` const changes). The repo-only benchmark admits Scapegoat as the 6th SUBJECT
69
+ (matrix 5x8=40 -> 6x8=48, a new `OLOGN_AMORTIZED` honesty class for `set` / `delete`).
70
+
9
71
  ## [0.5.0] - 2026-09-20
10
72
 
11
73
  ### Added
package/LogN.d.ts CHANGED
@@ -226,3 +226,57 @@ export class Treap {
226
226
  * both. Non-Treap inputs, different arenas, or an overlapping range throw. */
227
227
  static merge(a: Treap, b: Treap): Treap;
228
228
  }
229
+
230
+ /**
231
+ * A scapegoat tree: a DETERMINISTIC, weight-balanced BST that is also an order-statistic
232
+ * tree (an AUGMENTED ordered map key -> value) -- the honest pair to Treap. `get` is
233
+ * WORST-case O(log n) (a hard height bound, never merely expected); `set` / `delete` are
234
+ * AMORTIZED O(log n) (an occasional subtree rebuild absorbs the imbalance). A subtree-size
235
+ * column adds O(log n) rank / select. No priorities, no RNG: the shape is a deterministic
236
+ * function of the insert / delete order. Nodes are slot INDICES in flat typed-array columns
237
+ * over a private free-list, and every rebuild reuses ONE preallocated scratch buffer + index
238
+ * stack (no heap object, no fresh array per op). Keys and values are finite numbers (typeof-
239
+ * guarded before coercion; Symbol / BigInt / NaN / +-Infinity fail closed). set on an existing
240
+ * key updates the value in place. Fixed capacity: a full pool throws. Every hot op allocates
241
+ * zero bytes. There is deliberately NO split / merge (the treap's arena-sharing surgery has no
242
+ * honest deterministic O(log n) analogue here) -- the documented asymmetry vs Treap.
243
+ */
244
+ export class Scapegoat {
245
+ /** @param capacity exact max live entries; integer in [1, 2^31-1].
246
+ * @param alpha weight-balance factor in the OPEN interval (0.55, 0.75); default 2/3.
247
+ * Both ends throw. Frozen after construction. */
248
+ constructor(capacity: number, alpha?: number);
249
+
250
+ /** Live entry count. */
251
+ readonly size: number;
252
+ /** The fixed capacity this tree was sized for. */
253
+ readonly capacity: number;
254
+ /** The frozen weight-balance factor. */
255
+ readonly alpha: number;
256
+
257
+ /** The value under key, or undefined if absent (no throw). Non-finite key throws. */
258
+ get(key: number): number | undefined;
259
+ /** True iff key is currently stored. Non-finite key throws. */
260
+ has(key: number): boolean;
261
+ /** Insert key -> value, or update the value in place if key exists. Non-finite
262
+ * key/value throws; a full pool throws. */
263
+ set(key: number, value: number): this;
264
+ /** Remove key; true if it was present, false if absent (idempotent). Non-finite key throws. */
265
+ delete(key: number): boolean;
266
+ /** Count of stored keys strictly less than x (its rank), in [0, size]. Non-finite x throws. */
267
+ rank(x: number): number;
268
+ /** The k-th smallest key (0-based), or undefined if k is out of [0, size). Non-integer k throws. */
269
+ select(k: number): number | undefined;
270
+ /** The smallest key strictly greater than key, or undefined. Non-finite key throws. */
271
+ successor(key: number): number | undefined;
272
+ /** The largest key strictly less than key, or undefined. Non-finite key throws. */
273
+ predecessor(key: number): number | undefined;
274
+ /** A version-stamped iterator over keys in [lo, hi] inclusive, ascending. Bounds
275
+ * may be +-Infinity (unbounded ends); NaN or lo > hi throws; mutation during
276
+ * iteration throws. */
277
+ rangeIter(lo: number, hi: number): IterableIterator<number>;
278
+ /** Visit every (key, value) pair in ascending key order. */
279
+ forEach(fn: (key: number, value: number, tree: Scapegoat) => void): void;
280
+ /** Empty the tree, keeping capacity. */
281
+ clear(): this;
282
+ }
package/LogN.js CHANGED
@@ -39,7 +39,7 @@
39
39
  */
40
40
 
41
41
  /** Package version. One of the three version sites (package.json / VERSION / llms.txt). */
42
- export const VERSION = '0.5.0';
42
+ export const VERSION = '0.6.0';
43
43
 
44
44
  // --- members land here, append-only, one tree-shakeable class each -----------
45
45
  // BinaryHeap (v0.1.0 session) -- indexed O(log n) min|max heap (BELOW)
@@ -1983,3 +1983,516 @@ export class Treap {
1983
1983
  throw new RangeError('[lite-logn] Treap full (capacity ' + this._cap + ')');
1984
1984
  }
1985
1985
  }
1986
+
1987
+ // Scapegoat (v0.6.0 session) -- a DETERMINISTIC weight-balanced augmented ordered map (BELOW).
1988
+
1989
+ /**
1990
+ * Max Scapegoat capacity: `0x7FFFFFFF` (2^31 - 1). Every node is addressed by a slot
1991
+ * INDEX stored in `Uint32Array` link columns (`_left` / `_right`), each subtree count
1992
+ * lives in a `Uint32Array` (`_size`), and the rebuild scratch (`_flat`) plus the
1993
+ * flatten index-stack (`_stack`) are `Uint32Array` slot buffers; an index and a count
1994
+ * must both fit an unsigned 32-bit word. `NIL = 0` reserves slot 0 as the empty-subtree
1995
+ * sentinel, so live slots run [1, capacity]. The index / count arithmetic (Uint32 slot
1996
+ * indices + `NIL = 0` + Uint32 subtree sizes), not the byte count, is the hard ceiling
1997
+ * -- the same "the arithmetic caps it" reasoning as the array-embedded members.
1998
+ */
1999
+ const SG_MAX_CAPACITY = 0x7FFFFFFF; // 2^31 - 1
2000
+
2001
+ /**
2002
+ * A SCAPEGOAT TREE: a DETERMINISTIC, weight-balanced BINARY SEARCH TREE that is ALSO an
2003
+ * order-statistic tree (an AUGMENTED ordered map key -> value). It is the honest PAIR to
2004
+ * Treap: where a treap randomizes its shape to be balanced IN EXPECTATION, a scapegoat
2005
+ * keeps a hard WORST-CASE height bound (`get` is O(log n) worst-case, never merely
2006
+ * expected) by paying for it with AMORTIZED O(log n) `set` / `delete` -- an occasional
2007
+ * subtree rebuild absorbs the imbalance. No priorities, no RNG anywhere: the tree shape
2008
+ * is a deterministic function of the insert / delete order. The trick that keeps it
2009
+ * zero-GC is the SkipList / Treap one: nodes are slot INDICES in flat typed-array columns
2010
+ * over the same private free-list (NodePool), never heap objects; AND the rebuild reuses
2011
+ * ONE preallocated scratch buffer (`_flat`) + ONE preallocated index-stack (`_stack`),
2012
+ * so even a rebuild-heavy trace allocates ZERO bytes after construction.
2013
+ *
2014
+ * Two invariants held at once:
2015
+ * - BST order on `_key` (an in-order walk is ascending by key); and
2016
+ * - alpha-WEIGHT-BALANCE: after every mutation the height stays <= log_{1/alpha}(n) + 1
2017
+ * (h_alpha), enforced by the dual trigger below. The augmentation is a third
2018
+ * invariant: `_size[x]` is the number of nodes in x's subtree, maintained in the SAME
2019
+ * pass as every link rewrite, so `rank` (keys < x) and `select` (k-th smallest key)
2020
+ * are O(log n) via subtree counts.
2021
+ *
2022
+ * The DUAL trigger (alpha frozen at construction, `alpha` in the OPEN interval
2023
+ * (0.55, 0.75); default 2/3):
2024
+ * - `set`: descend recording the path, link the new leaf at depth d, then if the node
2025
+ * is "too deep" (d > h_alpha(size)) walk the recorded path back up to the SCAPEGOAT
2026
+ * -- the lowest ancestor whose child subtree exceeds `alpha` of its own size -- and
2027
+ * rebuild THAT subtree perfectly balanced. `_maxCount` tracks the high-water size.
2028
+ * - `delete`: remove the node (standard BST delete, `_size` fixed on the unwind), then
2029
+ * when `size < alpha * _maxCount` rebuild the WHOLE tree and reset `_maxCount = size`.
2030
+ * The depth test uses NO per-op `Math.log`: since `_invAlpha = 1/alpha` is ctor-cached,
2031
+ * `d > h_alpha(n)` (= `d > floor(_invLog * log2(n))`, `_invLog = 1/log2(1/alpha)`) is
2032
+ * tested EXACTLY as `_invAlpha^d > n` (for integer d the strict `>` matches the floor),
2033
+ * accumulated with one float multiply per path level -- only on the `set` path.
2034
+ *
2035
+ * ZERO-GC rebuild (the load-bearing design call, decisions/0008-scapegoat.md): NO fresh
2036
+ * array per rebuild. `_flatten` walks the target subtree in-order ITERATIVELY (Morris-
2037
+ * free) using the preallocated `_stack` index-column, writing sorted slot indices into
2038
+ * the preallocated `_flat` buffer; `_buildBalanced` reads that sorted range and re-links
2039
+ * `_left` / `_right` / `_size` via bounded native recursion whose depth is O(log
2040
+ * subtree) <= ~31 (it produces a perfectly balanced subtree), so it runs on the native
2041
+ * call stack, never the GC heap. Both are 0 B/op -- proven by the torture gate's rebuild-
2042
+ * heavy ascending-insert lane. RECURSION: `delete` and `forEach` also recurse to a depth
2043
+ * equal to the tree height, which is O(log n) worst-case here (the weight balance bounds
2044
+ * it) -- strictly safer than Treap's expected bound, disclosed here + in the ADR, on the
2045
+ * native stack, so still 0 B/op.
2046
+ *
2047
+ * Keys and values are FINITE numbers (typeof-guarded BEFORE coercion -- Symbol / BigInt /
2048
+ * NaN / +-Infinity fail closed with a `[lite-logn]` throw). `set` on an EXISTING key
2049
+ * updates its value in place (no new node, no rebuild). A missing / empty query returns
2050
+ * `undefined` (never throws). Fixed capacity: a full pool throws, never silently drops.
2051
+ * `rangeIter` is a VERSION-STAMPED iterator -- any structural OR value mutation mid-
2052
+ * iteration throws `[lite-logn]` rather than yield stale data.
2053
+ *
2054
+ * Unlike Treap there is NO `split` / `merge`: those are the treap's arena-sharing set
2055
+ * surgery (they rewire a randomized heap in place); a scapegoat has no priority heap to
2056
+ * merge by, and an honest deterministic split/merge would be O(n) rebuilds, forfeiting
2057
+ * the sub-linear headline -- so the surface is deliberately the ordered-map + order-
2058
+ * statistic core (get / has / set / delete / rank / select / successor / predecessor /
2059
+ * rangeIter / forEach / clear), documented in the ADR as the asymmetry vs Treap.
2060
+ */
2061
+ export class Scapegoat {
2062
+ /**
2063
+ * @param {number} capacity exact max live entries; integer in [1, 2^31-1].
2064
+ * @param {number} [alpha] weight-balance factor in the OPEN interval (0.55, 0.75)
2065
+ * (both ends throw); default 2/3. Frozen after construction.
2066
+ */
2067
+ constructor(capacity, alpha = 2 / 3) {
2068
+ // typeof guard BEFORE coercion (Number.isInteger is Symbol/BigInt-safe).
2069
+ if (typeof capacity !== 'number' || !Number.isInteger(capacity) ||
2070
+ capacity < 1 || capacity > SG_MAX_CAPACITY) {
2071
+ throw new RangeError(
2072
+ '[lite-logn] Scapegoat capacity must be an integer in [1, 2^31-1], got ' +
2073
+ String(capacity));
2074
+ }
2075
+ // typeof guard BEFORE the range check; the interval is OPEN (both 0.55 and 0.75 throw).
2076
+ if (typeof alpha !== 'number' || !Number.isFinite(alpha) || alpha <= 0.55 || alpha >= 0.75) {
2077
+ throw new RangeError(
2078
+ '[lite-logn] Scapegoat alpha must be a number in the open interval (0.55, 0.75), got ' +
2079
+ String(alpha));
2080
+ }
2081
+ this._cap = capacity; // max live entries
2082
+ this._key = new Float64Array(capacity + 1); // key at each slot
2083
+ this._value = new Float64Array(capacity + 1); // value at each slot
2084
+ this._left = new Uint32Array(capacity + 1); // left child slot; NIL = 0
2085
+ this._right = new Uint32Array(capacity + 1); // right child slot; NIL = 0
2086
+ this._size = new Uint32Array(capacity + 1); // subtree node count; _size[0] = 0
2087
+ this._pool = new NodePool(capacity); // free-list over slots [1, capacity]
2088
+ this._flat = new Uint32Array(capacity); // rebuild scratch: sorted slot indices
2089
+ this._stack = new Uint32Array(capacity + 1); // flatten / descent index-stack (reused)
2090
+ this._root = 0; // NIL == empty tree
2091
+ this._maxCount = 0; // high-water size since the last full rebuild
2092
+ this._version = 0; // iterator invalidation stamp
2093
+ this._alpha = alpha; // ctor-frozen weight-balance factor
2094
+ this._invAlpha = 1 / alpha; // ctor-cached: no per-op Math.log
2095
+ }
2096
+
2097
+ /** Live entry count. O(1) (the root subtree count). */
2098
+ get size() { return this._root === 0 ? 0 : this._size[this._root]; }
2099
+
2100
+ /** The fixed capacity this tree was sized for. O(1). */
2101
+ get capacity() { return this._cap; }
2102
+
2103
+ /** The frozen weight-balance factor. O(1). */
2104
+ get alpha() { return this._alpha; }
2105
+
2106
+ /**
2107
+ * The value stored under `key`, or `undefined` if absent (never throws on a missing /
2108
+ * empty query). O(log n) WORST-case: a plain BST descent over a weight-balanced tree.
2109
+ * Fails closed on a non-number / non-finite key (typeof-guarded first).
2110
+ * @param {number} key a finite number
2111
+ * @returns {number|undefined}
2112
+ */
2113
+ get(key) {
2114
+ if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
2115
+ const L = this._left, R = this._right, K = this._key;
2116
+ let t = this._root;
2117
+ while (t !== 0) {
2118
+ if (key < K[t]) t = L[t];
2119
+ else if (key > K[t]) t = R[t];
2120
+ else return this._value[t];
2121
+ }
2122
+ return undefined;
2123
+ }
2124
+
2125
+ /**
2126
+ * True iff `key` is currently in the tree. O(log n) worst-case. Fails closed on a
2127
+ * non-finite key (typeof-guarded first).
2128
+ * @param {number} key a finite number
2129
+ * @returns {boolean}
2130
+ */
2131
+ has(key) {
2132
+ if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
2133
+ const L = this._left, R = this._right, K = this._key;
2134
+ let t = this._root;
2135
+ while (t !== 0) {
2136
+ if (key < K[t]) t = L[t];
2137
+ else if (key > K[t]) t = R[t];
2138
+ else return true;
2139
+ }
2140
+ return false;
2141
+ }
2142
+
2143
+ /**
2144
+ * Insert `key -> value`, or UPDATE the value in place if `key` already exists (no new
2145
+ * node, no rebuild). AMORTIZED O(log n): a descent recording the path, then (on
2146
+ * insert) an amortized-cheap weight-balance check that occasionally rebuilds the
2147
+ * scapegoat subtree. Fails closed: a non-finite key or value (typeof-guarded first),
2148
+ * or a full pool, each throw `[lite-logn]` as a no-op.
2149
+ * @param {number} key a finite number
2150
+ * @param {number} value a finite number
2151
+ * @returns {this}
2152
+ */
2153
+ set(key, value) {
2154
+ if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
2155
+ if (typeof value !== 'number' || !Number.isFinite(value)) return this._badValue(value);
2156
+ const K = this._key, L = this._left, R = this._right, S = this._size, stk = this._stack;
2157
+ let t = this._root, sp = 0;
2158
+ while (t !== 0) { // descend, recording the path; update in place if present
2159
+ stk[sp++] = t;
2160
+ if (key < K[t]) t = L[t];
2161
+ else if (key > K[t]) t = R[t];
2162
+ else { this._value[t] = value; this._version = (this._version + 1) | 0; return this; }
2163
+ }
2164
+ const slot = this._pool.alloc();
2165
+ if (slot === 0) return this._full();
2166
+ K[slot] = key; this._value[slot] = value; L[slot] = 0; R[slot] = 0; S[slot] = 1;
2167
+ if (sp === 0) this._root = slot; // first node
2168
+ else { const p = stk[sp - 1]; if (key < K[p]) L[p] = slot; else R[p] = slot; }
2169
+ for (let i = 0; i < sp; i++) S[stk[i]]++; // every ancestor gained one node
2170
+ const newSize = S[this._root]; // == old size + 1
2171
+ if (newSize > this._maxCount) this._maxCount = newSize;
2172
+ this._version = (this._version + 1) | 0;
2173
+ // Depth test with NO Math.log: the new node sits at depth d == sp; it is too deep
2174
+ // iff d > h_alpha(newSize) == floor(_invLog * log2(newSize)), tested EXACTLY as
2175
+ // _invAlpha^d > newSize (integer d, so strict > matches the floor). One float
2176
+ // multiply per level -- only on this insert path, never on get.
2177
+ let bound = 1;
2178
+ for (let i = 0; i < sp; i++) bound *= this._invAlpha;
2179
+ if (bound > newSize) {
2180
+ // Walk the recorded path up to the SCAPEGOAT: the lowest ancestor whose
2181
+ // path-child subtree exceeds alpha of its own (post-insert) size.
2182
+ let g = -1;
2183
+ for (let i = sp - 1; i >= 0; i--) {
2184
+ const node = stk[i];
2185
+ const child = i === sp - 1 ? slot : stk[i + 1];
2186
+ if (S[child] > this._alpha * S[node]) { g = i; break; }
2187
+ }
2188
+ if (g === -1) {
2189
+ this._rebuildSubtree(this._root, 0, false); // defensive: rebuild whole tree
2190
+ } else {
2191
+ const node = stk[g];
2192
+ const parent = g > 0 ? stk[g - 1] : 0;
2193
+ const wasLeft = parent !== 0 && L[parent] === node;
2194
+ this._rebuildSubtree(node, parent, wasLeft);
2195
+ }
2196
+ }
2197
+ return this;
2198
+ }
2199
+
2200
+ /**
2201
+ * Remove `key`. AMORTIZED O(log n). Idempotent: returns `false` if `key` is absent (no
2202
+ * throw), `true` if it was present and removed. A standard BST delete fixes `_size` on
2203
+ * the unwind; when the tree has shrunk below `alpha * _maxCount` the WHOLE tree is
2204
+ * rebuilt perfectly balanced and `_maxCount` reset. Fails closed on a non-finite key.
2205
+ * @param {number} key a finite number
2206
+ * @returns {boolean}
2207
+ */
2208
+ delete(key) {
2209
+ if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
2210
+ const L = this._left, R = this._right, K = this._key;
2211
+ let t = this._root, found = false;
2212
+ while (t !== 0) {
2213
+ if (key < K[t]) t = L[t];
2214
+ else if (key > K[t]) t = R[t];
2215
+ else { found = true; break; }
2216
+ }
2217
+ if (!found) return false; // absent (no throw)
2218
+ this._root = this._delete(this._root, key);
2219
+ this._version = (this._version + 1) | 0;
2220
+ const newSize = this._root === 0 ? 0 : this._size[this._root];
2221
+ if (newSize < this._alpha * this._maxCount) {
2222
+ this._rebuildSubtree(this._root, 0, false); // global rebuild
2223
+ this._maxCount = newSize;
2224
+ }
2225
+ return true;
2226
+ }
2227
+
2228
+ /**
2229
+ * The number of stored keys STRICTLY LESS than `x` (its rank / position). O(log n) via
2230
+ * subtree counts. `x` need not be present; `rank` of the smallest key is 0, of a key
2231
+ * past the max is `size`. Fails closed on a non-finite `x`.
2232
+ * @param {number} x a finite number
2233
+ * @returns {number} count of keys < x, in [0, size]
2234
+ */
2235
+ rank(x) {
2236
+ if (typeof x !== 'number' || !Number.isFinite(x)) return this._badKey(x);
2237
+ const L = this._left, R = this._right, K = this._key, S = this._size;
2238
+ let t = this._root, r = 0;
2239
+ while (t !== 0) {
2240
+ if (x <= K[t]) t = L[t]; // t (and its right) are >= x
2241
+ else { r += S[L[t]] + 1; t = R[t]; } // t's left subtree + t precede x
2242
+ }
2243
+ return r;
2244
+ }
2245
+
2246
+ /**
2247
+ * The k-th smallest KEY (0-based order statistic), or `undefined` if `k` is out of
2248
+ * range [0, size). O(log n) via subtree counts. Fails closed on a non-integer `k`
2249
+ * (typeof-guarded first); an in-type out-of-range `k` returns `undefined`.
2250
+ * @param {number} k integer in [0, size)
2251
+ * @returns {number|undefined} the k-th smallest key
2252
+ */
2253
+ select(k) {
2254
+ if (typeof k !== 'number' || !Number.isInteger(k)) return this._badRank(k);
2255
+ const sz = this._root === 0 ? 0 : this._size[this._root];
2256
+ if (k < 0 || k >= sz) return undefined;
2257
+ const L = this._left, R = this._right, K = this._key, S = this._size;
2258
+ let t = this._root;
2259
+ for (;;) {
2260
+ const ls = S[L[t]];
2261
+ if (k < ls) t = L[t];
2262
+ else if (k > ls) { k -= ls + 1; t = R[t]; }
2263
+ else return K[t];
2264
+ }
2265
+ }
2266
+
2267
+ /**
2268
+ * The smallest key STRICTLY greater than `key`, or `undefined` if none. O(log n).
2269
+ * `key` itself need not be present. Fails closed on a non-finite key.
2270
+ * @param {number} key a finite number
2271
+ * @returns {number|undefined}
2272
+ */
2273
+ successor(key) {
2274
+ if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
2275
+ const L = this._left, R = this._right, K = this._key;
2276
+ let t = this._root, best;
2277
+ while (t !== 0) {
2278
+ if (K[t] > key) { best = K[t]; t = L[t]; }
2279
+ else t = R[t];
2280
+ }
2281
+ return best;
2282
+ }
2283
+
2284
+ /**
2285
+ * The largest key STRICTLY less than `key`, or `undefined` if none. O(log n). `key`
2286
+ * itself need not be present. Fails closed on a non-finite key.
2287
+ * @param {number} key a finite number
2288
+ * @returns {number|undefined}
2289
+ */
2290
+ predecessor(key) {
2291
+ if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
2292
+ const L = this._left, R = this._right, K = this._key;
2293
+ let t = this._root, best;
2294
+ while (t !== 0) {
2295
+ if (K[t] < key) { best = K[t]; t = R[t]; }
2296
+ else t = L[t];
2297
+ }
2298
+ return best;
2299
+ }
2300
+
2301
+ /**
2302
+ * A VERSION-STAMPED iterator over the keys in `[lo, hi]` INCLUSIVE, ascending. Bounds
2303
+ * may be any number INCLUDING +-Infinity (an unbounded end); `NaN` fails closed, as
2304
+ * does `lo > hi`. The generator captures the tree's version and throws `[lite-logn]`
2305
+ * if any STRUCTURAL or VALUE mutation happens mid-iteration, rather than yield stale
2306
+ * data. It walks by repeated `successor` (each step a fresh O(log n) descent, so NO
2307
+ * scratch stack is allocated); the one documented per-protocol allocator is the
2308
+ * {value, done} per step.
2309
+ * @param {number} lo lower bound (inclusive); may be -Infinity
2310
+ * @param {number} hi upper bound (inclusive); may be +Infinity
2311
+ * @returns {IterableIterator<number>} the keys in [lo, hi], ascending
2312
+ */
2313
+ rangeIter(lo, hi) {
2314
+ if (typeof lo !== 'number' || Number.isNaN(lo)) return this._badBound(lo);
2315
+ if (typeof hi !== 'number' || Number.isNaN(hi)) return this._badBound(hi);
2316
+ if (lo > hi) return this._badRange(lo, hi);
2317
+ return this._rangeGen(lo, hi);
2318
+ }
2319
+
2320
+ /** @private version-stamped range generator (see rangeIter). */
2321
+ *_rangeGen(lo, hi) {
2322
+ const ver = this._version;
2323
+ let cur = this._ceil(lo); // smallest key >= lo, or undefined
2324
+ while (cur !== undefined && cur <= hi) {
2325
+ if (this._version !== ver) {
2326
+ throw new Error('[lite-logn] Scapegoat mutated during iteration');
2327
+ }
2328
+ yield cur;
2329
+ cur = this.successor(cur);
2330
+ }
2331
+ }
2332
+
2333
+ /**
2334
+ * Visit every live `(key, value)` pair in ASCENDING key order. O(n) cold in-order walk
2335
+ * (recursion depth = tree height, O(log n)), allocation-free in the loop body (pass a
2336
+ * hoisted callback). Unlike rangeIter this is NOT version-stamped -- mutating from
2337
+ * within the callback is the caller's responsibility (matching the other members).
2338
+ * @param {(key:number, value:number, tree:Scapegoat)=>void} fn
2339
+ */
2340
+ forEach(fn) {
2341
+ this._forEach(this._root, fn);
2342
+ }
2343
+
2344
+ /** @private recursive in-order walk. */
2345
+ _forEach(t, fn) {
2346
+ if (t === 0) return;
2347
+ this._forEach(this._left[t], fn);
2348
+ fn(this._key[t], this._value[t], this);
2349
+ this._forEach(this._right[t], fn);
2350
+ }
2351
+
2352
+ /**
2353
+ * Empty the tree, keeping the fixed capacity. O(capacity) cold path: returns every
2354
+ * node to the pool and points the root at NIL. @returns {this}
2355
+ */
2356
+ clear() {
2357
+ this._pool.clear();
2358
+ this._root = 0;
2359
+ this._maxCount = 0;
2360
+ this._version = (this._version + 1) | 0;
2361
+ return this;
2362
+ }
2363
+
2364
+ // ---- private structure (rebuild + recursive delete; hot bodies elsewhere) ----
2365
+
2366
+ /**
2367
+ * @private In-order flatten of subtree `root` into `_flat` using the preallocated
2368
+ * `_stack` index-column (Morris-free, ITERATIVE -- so a degenerate pre-rebuild chain
2369
+ * cannot overflow the native stack). Returns the node count written. 0 B/op.
2370
+ */
2371
+ _flatten(root) {
2372
+ const L = this._left, R = this._right, stk = this._stack, flat = this._flat;
2373
+ let node = root, sp = 0, c = 0;
2374
+ while (node !== 0 || sp > 0) {
2375
+ while (node !== 0) { stk[sp++] = node; node = L[node]; }
2376
+ node = stk[--sp];
2377
+ flat[c++] = node;
2378
+ node = R[node];
2379
+ }
2380
+ return c;
2381
+ }
2382
+
2383
+ /**
2384
+ * @private Build a perfectly balanced BST from the sorted slot range `_flat[lo..hi]`,
2385
+ * re-linking `_left` / `_right` / `_size`. Returns the subtree root (NIL if empty).
2386
+ * Bounded native recursion: depth is O(log(hi-lo+1)) <= ~31 (it produces a balanced
2387
+ * subtree), so it runs on the native call stack, never the GC heap. 0 B/op.
2388
+ */
2389
+ _buildBalanced(lo, hi) {
2390
+ if (lo > hi) return 0;
2391
+ const mid = (lo + hi) >> 1;
2392
+ const s = this._flat[mid];
2393
+ const l = this._buildBalanced(lo, mid - 1);
2394
+ const r = this._buildBalanced(mid + 1, hi);
2395
+ this._left[s] = l;
2396
+ this._right[s] = r;
2397
+ this._size[s] = this._size[l] + this._size[r] + 1; // _size[0] is a permanent 0
2398
+ return s;
2399
+ }
2400
+
2401
+ /**
2402
+ * @private Rebuild subtree `root` perfectly balanced and re-link it under `parent`
2403
+ * (or the tree root when `parent === 0`). `wasLeft` records which child link to
2404
+ * rewrite. Reuses the preallocated `_flat` + `_stack` scratch: 0 B/op.
2405
+ */
2406
+ _rebuildSubtree(root, parent, wasLeft) {
2407
+ const count = this._flatten(root);
2408
+ const nr = this._buildBalanced(0, count - 1);
2409
+ if (parent === 0) this._root = nr;
2410
+ else if (wasLeft) this._left[parent] = nr;
2411
+ else this._right[parent] = nr;
2412
+ }
2413
+
2414
+ /** @private recursive BST delete of `key` from subtree `t`; frees the removed slot,
2415
+ * fixing `_size` on the unwind. Depth = tree height = O(log n). */
2416
+ _delete(t, key) {
2417
+ const L = this._left, R = this._right, S = this._size, K = this._key;
2418
+ if (key < K[t]) {
2419
+ L[t] = this._delete(L[t], key);
2420
+ S[t] = S[L[t]] + S[R[t]] + 1;
2421
+ return t;
2422
+ }
2423
+ if (key > K[t]) {
2424
+ R[t] = this._delete(R[t], key);
2425
+ S[t] = S[L[t]] + S[R[t]] + 1;
2426
+ return t;
2427
+ }
2428
+ // found t
2429
+ const l = L[t], r = R[t];
2430
+ if (l === 0) { this._pool.free(t); return r; }
2431
+ if (r === 0) { this._pool.free(t); return l; }
2432
+ // two children: copy the in-order successor (min of the right subtree) into t,
2433
+ // then delete that successor from the right subtree (frees ITS slot).
2434
+ let m = r; while (L[m] !== 0) m = L[m];
2435
+ K[t] = K[m]; this._value[t] = this._value[m];
2436
+ R[t] = this._deleteMin(R[t]);
2437
+ S[t] = S[L[t]] + S[R[t]] + 1;
2438
+ return t;
2439
+ }
2440
+
2441
+ /** @private remove the minimum of subtree `t`, freeing its slot; return the new root. */
2442
+ _deleteMin(t) {
2443
+ const L = this._left, R = this._right, S = this._size;
2444
+ if (L[t] === 0) { const r = R[t]; this._pool.free(t); return r; }
2445
+ L[t] = this._deleteMin(L[t]);
2446
+ S[t] = S[L[t]] + S[R[t]] + 1;
2447
+ return t;
2448
+ }
2449
+
2450
+ /** @private smallest key >= `lo`, or undefined (the range-iter start). */
2451
+ _ceil(lo) {
2452
+ const L = this._left, R = this._right, K = this._key;
2453
+ let t = this._root, best;
2454
+ while (t !== 0) {
2455
+ if (K[t] >= lo) { best = K[t]; t = L[t]; }
2456
+ else t = R[t];
2457
+ }
2458
+ return best;
2459
+ }
2460
+
2461
+ // ---- cold path only: throw builders (string concat off the hot body) ----
2462
+
2463
+ /** @private */
2464
+ _badKey(key) {
2465
+ throw new TypeError(
2466
+ '[lite-logn] Scapegoat key must be a finite number, got ' + String(key));
2467
+ }
2468
+
2469
+ /** @private */
2470
+ _badValue(value) {
2471
+ throw new TypeError(
2472
+ '[lite-logn] Scapegoat value must be a finite number, got ' + String(value));
2473
+ }
2474
+
2475
+ /** @private */
2476
+ _badRank(k) {
2477
+ throw new TypeError(
2478
+ '[lite-logn] Scapegoat select index must be an integer, got ' + String(k));
2479
+ }
2480
+
2481
+ /** @private */
2482
+ _badBound(b) {
2483
+ throw new TypeError(
2484
+ '[lite-logn] Scapegoat rangeIter bound must be a number (not NaN), got ' + String(b));
2485
+ }
2486
+
2487
+ /** @private */
2488
+ _badRange(lo, hi) {
2489
+ throw new RangeError(
2490
+ '[lite-logn] Scapegoat rangeIter needs lo <= hi, got lo=' + String(lo) +
2491
+ ' hi=' + String(hi));
2492
+ }
2493
+
2494
+ /** @private */
2495
+ _full() {
2496
+ throw new RangeError('[lite-logn] Scapegoat full (capacity ' + this._cap + ')');
2497
+ }
2498
+ }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-logn
2
2
 
3
- > Zero-GC, O(log n) data structures that PROVE their logarithm. The O(log n) sibling of `@zakkster/lite-o1`: where lite-o1 holds the constant (a flat ops/ms line), lite-logn holds the logarithm (a straight line on a log-x axis -- one added level per doubling of n). v0.5.0 ships five members: BinaryHeap (array-embedded O(log n) push / pop min|max heap), Fenwick / BIT (O(log n) point-update AND prefix-sum via the `i & -i` walk), SegmentTree (O(log n) associative range-query -- min / max / sum / gcd -- plus point-update over a flat 2n array), SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool), and Treap (a randomized-balanced augmented ordered map with O(log n) rank / select / split / merge) -- each zero-GC, each shipped with a log-linear Witness that fits `nsPerOp = intercept + slope*log2(n)` and shows the straight log line while an O(n) foil leaves it.
3
+ > Zero-GC, O(log n) data structures that PROVE their logarithm. The O(log n) sibling of `@zakkster/lite-o1`: where lite-o1 holds the constant (a flat ops/ms line), lite-logn holds the logarithm (a straight line on a log-x axis -- one added level per doubling of n). v0.6.0 ships six members: BinaryHeap (array-embedded O(log n) push / pop min|max heap), Fenwick / BIT (O(log n) point-update AND prefix-sum via the `i & -i` walk), SegmentTree (O(log n) associative range-query -- min / max / sum / gcd -- plus point-update over a flat 2n array), SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool), Treap (a randomized-balanced augmented ordered map with O(log n) rank / select / split / merge), and Scapegoat (a DETERMINISTIC weight-balanced augmented ordered map: worst-case-O(log n) get, amortized-O(log n) set / delete, zero-GC rebuild) -- each zero-GC, each shipped with a log-linear Witness that fits `nsPerOp = intercept + slope*log2(n)` and shows the straight log line while an O(n) foil leaves it.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-logn.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-logn)
6
6
  [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
@@ -19,7 +19,7 @@ Almost no JavaScript data-structure library ships the evidence that its Big-O cl
19
19
 
20
20
  lite-logn is the O(log n) sibling of [`@zakkster/lite-o1`](https://www.npmjs.com/package/@zakkster/lite-o1). lite-o1 proves a FLAT ops/ms line on a log-x axis (the constant -- slope ~ 0); lite-logn proves a STRAIGHT line on that same axis (one added level per doubling of `n` -- slope > 0, within a per-member band). The gate SHAPE differs; the discipline is identical: zero allocation on every hot path and a witness that turns "trust me, it is O(log n)" into a straight line you can see, with a foil that leaves it.
21
21
 
22
- **v0.5.0 ships five members: BinaryHeap, Fenwick, SegmentTree, SkipList and Treap.** Members land one per session, each append-only so prior members stay byte-identical. The planned roster below fills in per release.
22
+ **v0.6.0 ships six members: BinaryHeap, Fenwick, SegmentTree, SkipList, Treap and Scapegoat.** Members land one per session, each append-only so prior members stay byte-identical. The planned roster below fills in per release.
23
23
 
24
24
  ```bash
25
25
  npm install @zakkster/lite-logn
@@ -61,6 +61,7 @@ Every hot op allocates zero bytes after construction, and `npm run witness` prov
61
61
  - [SegmentTree](#segmenttree)
62
62
  - [SkipList](#skiplist)
63
63
  - [Treap](#treap)
64
+ - [Scapegoat](#scapegoat)
64
65
  - [Zero-GC design notes](#zero-gc-design-notes)
65
66
  - [Testing](#testing)
66
67
  - [What this is not](#what-this-is-not)
@@ -85,7 +86,7 @@ lite-logn ships the O(log n) structures that matter with the allocation removed
85
86
 
86
87
  ## The roster
87
88
 
88
- One member per session, each landing append-only (prior members stay byte-identical). At v0.5.0, BinaryHeap, Fenwick, SegmentTree, SkipList and Treap are shipped.
89
+ One member per session, each landing append-only (prior members stay byte-identical). At v0.6.0, BinaryHeap, Fenwick, SegmentTree, SkipList, Treap and Scapegoat are shipped.
89
90
 
90
91
  | Member | Version | Status | Shape | Hot ops |
91
92
  | --- | --- | --- | --- | --- |
@@ -94,8 +95,9 @@ One member per session, each landing append-only (prior members stay byte-identi
94
95
  | **SegmentTree** | 0.3.0 | shipped | single flat `Float64Array(2n)` (leaves n..2n-1); associative fold (min/max/sum/gcd) chosen at construction | `query` / `update` O(log n), `at` O(1) |
95
96
  | **SkipList** | 0.4.0 | shipped | pointer-free over a private free-list node pool; expected O(log n) | `get` / `set` / `delete` / `successor` / `predecessor` |
96
97
  | **Treap** | 0.5.0 | shipped | randomized-balanced augmented BST over the same node pool; expected O(log n) | `get` / `has` / `set` / `delete` / `rank` / `select` / `successor` / `predecessor` / `forEach` / `rangeIter` / `split` + `merge` |
98
+ | **Scapegoat** | 0.6.0 | shipped | DETERMINISTIC weight-balanced augmented BST over the same node pool; worst-case O(log n) get, amortized O(log n) set/delete (zero-GC rebuild) | `get` / `has` / `set` / `delete` / `rank` / `select` / `successor` / `predecessor` / `forEach` / `rangeIter` (NO split/merge) |
97
99
 
98
- Later tiers (Scapegoat, OrderStatTree, IndexedHeap, SortedArray, MinMaxHeap, SplayTree, and presets) are queued in [`ROADMAP.md`](./ROADMAP.md).
100
+ Later tiers (OrderStatTree, IndexedHeap, SortedArray, MinMaxHeap, SplayTree, and presets) are queued in [`ROADMAP.md`](./ROADMAP.md).
99
101
 
100
102
  ## The O(log n) Witness
101
103
 
@@ -105,7 +107,7 @@ The family anchor. Time a fixed batch of the hot op at each `n` in a geometric s
105
107
  - `slope` inside the member's band (the per-level cost, ns/level), AND
106
108
  - the FOIL leaves the line (low `R^2` -- the O(n) default a working programmer reaches for, shown losing as `n` grows).
107
109
 
108
- For amortized / randomized members the witness also prints the MAX single-op time -- the honesty hook: a rebuild spike or a degenerate tail shows as a tall bar even when the mean still fits the line. The `R^2` floor (0.958) is frozen family-wide in BinaryHeap; each member then calibrates its OWN per-op slope band (median-of-15 fit-runs x `[0.6, 1.4]`), because a cheaper op honestly has a lower per-level slope (see [`decisions/0004-witness-band.md`](./decisions/0004-witness-band.md)). At v0.5.0 the witness gates eight ops: BinaryHeap `pop` (R^2 ~ 0.99, slope ~ 8-10 ns/level), Fenwick `update` (R^2 ~ 0.98-0.99, slope ~ 2.9-3.0 ns/level) and `prefix` (R^2 ~ 0.97, slope ~ 2.6-2.7 ns/level), SegmentTree `update` (R^2 ~ 0.99, slope ~ 3.2 ns/level, band `[2.29, 5.35]`) and `query` (R^2 ~ 0.99, slope ~ 7 ns/level, band `[4.30, 10.04]`), SkipList `get` (R^2 ~ 0.97-0.99, slope ~ 9 ns/level, band `[5.27, 12.30]`) and `set` (R^2 ~ 0.97-0.99, slope ~ 14 ns/level, band `[8.36, 19.50]`), and Treap `get` (R^2 ~ 0.99, slope ~ 4 ns/level, band `[2.55, 5.95]`) all ON the line. Treap's descent touches one node per level, so its per-level slope is lower than SkipList's tower search -- expected, which is why only the R^2 floor is shared and each op calibrates its own band. SkipList's two ops are gated over DIFFERENT sweeps -- each measured where its logarithm is visible, not where the cache wall is: `get` (a clean search with no per-op randomness) over `[2^11, 2^17]` for dynamic range; `set` (a heavier insert+delete churn whose per-insert tower height is random) over the smaller, fully cache-resident `[2^9, 2^14]` so the fit sees the structural level count, not DRAM latency. Because SkipList is EXPECTED (not worst-case) O(log n), the witness also prints the MAX single insert over a realistic randomized build trace -- the unlucky-tower tail a mean hides. Each op's O(n) foil fits well below the floor: the sorted-array insert (BinaryHeap / SkipList) foil runs R^2 ~ 0.77-0.87, the Fenwick foils (prefix-array rebuild, naive re-sum) and SkipList's linear-scan search foil hold at R^2 ~ 0.75-0.82, and SegmentTree's foils (whole-tree rebuild per update, scan-fold per query) fit at R^2 ~ 0.72-0.85 -- all foil families sit comfortably under the 0.958 floor.
110
+ For amortized / randomized members the witness also prints the MAX single-op time -- the honesty hook: a rebuild spike or a degenerate tail shows as a tall bar even when the mean still fits the line. The `R^2` floor (0.958) is frozen family-wide in BinaryHeap; each member then calibrates its OWN per-op slope band (median-of-15 fit-runs x `[0.6, 1.4]`), because a cheaper op honestly has a lower per-level slope (see [`decisions/0004-witness-band.md`](./decisions/0004-witness-band.md)). At v0.6.0 the witness gates nine ops: BinaryHeap `pop` (R^2 ~ 0.99, slope ~ 8-10 ns/level), Fenwick `update` (R^2 ~ 0.98-0.99, slope ~ 2.9-3.0 ns/level) and `prefix` (R^2 ~ 0.97, slope ~ 2.6-2.7 ns/level), SegmentTree `update` (R^2 ~ 0.99, slope ~ 3.2 ns/level, band `[2.29, 5.35]`) and `query` (R^2 ~ 0.99, slope ~ 7 ns/level, band `[4.30, 10.04]`), SkipList `get` (R^2 ~ 0.97-0.99, slope ~ 9 ns/level, band `[5.27, 12.30]`) and `set` (R^2 ~ 0.97-0.99, slope ~ 14 ns/level, band `[8.36, 19.50]`), Treap `get` (R^2 ~ 0.99, slope ~ 4 ns/level, band `[2.55, 5.95]`), and Scapegoat `get` (R^2 ~ 0.99, slope ~ 4 ns/level, band `[2.41, 5.63]`) all ON the line. Scapegoat is DETERMINISTIC, so its `get` is WORST-case (not expected) O(log n); its rebuild spike lives on the AMORTIZED `set` path and is proven not by a per-op line but by an amortized-trace assertion -- the cumulative ascending-insert (rebuild-heavy) cost/op tracks a LOG curve (last/first ratio ~1.5x over `[2^11, 2^17]`, gated `< 4x`) where a rebuild-less BST would degenerate to an O(n)-amortized chain and blow the ratio to ~64x. Treap's descent touches one node per level, so its per-level slope is lower than SkipList's tower search -- expected, which is why only the R^2 floor is shared and each op calibrates its own band. SkipList's two ops are gated over DIFFERENT sweeps -- each measured where its logarithm is visible, not where the cache wall is: `get` (a clean search with no per-op randomness) over `[2^11, 2^17]` for dynamic range; `set` (a heavier insert+delete churn whose per-insert tower height is random) over the smaller, fully cache-resident `[2^9, 2^14]` so the fit sees the structural level count, not DRAM latency. Because SkipList is EXPECTED (not worst-case) O(log n), the witness also prints the MAX single insert over a realistic randomized build trace -- the unlucky-tower tail a mean hides. Each op's O(n) foil fits well below the floor: the sorted-array insert (BinaryHeap / SkipList) foil runs R^2 ~ 0.77-0.87, the Fenwick foils (prefix-array rebuild, naive re-sum) and SkipList's linear-scan search foil hold at R^2 ~ 0.75-0.82, and SegmentTree's foils (whole-tree rebuild per update, scan-fold per query) fit at R^2 ~ 0.72-0.85 -- all foil families sit comfortably under the 0.958 floor.
109
111
 
110
112
  ## Benchmarks
111
113
 
@@ -145,6 +147,9 @@ Each op fits `nsPerOp = intercept + slope*log2(n)`. ON-LINE = `R^2 >= 0.958` (th
145
147
  | `SkipList.get` | 0.988 | 8.0 | `[5.27, 12.30]` | ON | linear scan | 0.79 | off |
146
148
  | `SkipList.set` | 0.985 | 12.1 | `[8.36, 19.50]` | ON | sorted-array insert | 0.77 | off |
147
149
  | `Treap.get` | 0.988 | 4.0 | `[2.55, 5.95]` | ON | linear scan | 0.79 | off |
150
+ | `Scapegoat.get` | 0.988 | 3.8 | `[2.41, 5.63]` | ON | linear scan | 0.79 | off |
151
+
152
+ **Scapegoat amortized-trace (the rebuild honesty).** Scapegoat's `get` is WORST-case O(log n) (a deterministic weight-balance height bound), so it carries no expected-op MAX-single-op disclosure. The rebuild spike lives on the AMORTIZED `set` path; D1 proves the amortization not with a per-op line but with an amortized-trace assertion -- the cumulative ascending-insert (rebuild-heavy) cost/op tracks a LOG curve (last/first ratio `~1.5x` over `[2^11, 2^17]`, gated `< 4x`) where a rebuild-less BST would blow to `~64x`.
148
153
 
149
154
  **SkipList counter-foil (the order tax).** A native `Map` is O(1) at get/set (`~27 ns/op`, FLATTER than any log line) but ORDER-BLIND: it cannot answer `successor` / `predecessor` / `rangeIter`. The log factor SkipList pays buys exactly the ordered queries Map cannot. SkipList is EXPECTED O(log n), so D1 also DISCLOSES its MAX single insert (an unlucky tall tower over a randomized build: `~18-130 us`, not gated).
150
155
 
@@ -178,8 +183,8 @@ All seven gated op-rows report **0 B/op** across the `n = 1e3..1e6` sweep, with
178
183
 
179
184
  - **D2 amortized cost** -- cumulative ns/op stays bounded over a `~1M`-op mixed trace (drift `< 1.0` here: the trace speeds up as the JIT warms, never degrades).
180
185
  - **D4 cache (PROXY, labelled)** -- dense `forEach` iteration vs random single-element lookup; the random/dense gap is `~1.9x` (SegmentTree) to `~2.9x` (SkipList). No native perf counters.
181
- - **D7 scalability** -- numeric substrates: string + object keys read `n/a`. Load factors `0.3/0.5/0.7/0.9`; insertion order (sorted / random / adversarial-reverse) applies to the comparison-ordered BinaryHeap + SkipList + Treap, `n/a` for the index-addressed Fenwick + SegmentTree.
182
- - **D8 workloads** -- churn (all members) + an ordered scan (`successor` + `rangeIter`, SkipList + Treap; `n/a` elsewhere).
186
+ - **D7 scalability** -- numeric substrates: string + object keys read `n/a`. Load factors `0.3/0.5/0.7/0.9`; insertion order (sorted / random / adversarial-reverse) applies to the comparison-ordered BinaryHeap + SkipList + Treap + Scapegoat, `n/a` for the index-addressed Fenwick + SegmentTree.
187
+ - **D8 workloads** -- churn (all members) + an ordered scan (`successor` + `rangeIter`, SkipList + Treap + Scapegoat; `n/a` elsewhere).
183
188
 
184
189
  ## API reference
185
190
 
@@ -187,7 +192,7 @@ All seven gated op-rows report **0 B/op** across the `n = 1e3..1e6` sweep, with
187
192
 
188
193
  | Export | Type | Value | Meaning |
189
194
  | --- | --- | --- | --- |
190
- | `VERSION` | `string` | `'0.5.0'` | The package version. One of the three version sites (package.json / `LogN.js` `VERSION` const / `llms.txt`), kept in lockstep and enforced in review. |
195
+ | `VERSION` | `string` | `'0.6.0'` | The package version. One of the three version sites (package.json / `LogN.js` `VERSION` const / `llms.txt`), kept in lockstep and enforced in review. |
191
196
 
192
197
  ### BinaryHeap
193
198
 
@@ -372,6 +377,42 @@ const whole = Treap.merge(lo, hi);// fuse back (all lo keys < all hi keys); both
372
377
  | `merge` (static) | `Treap.merge(a, b) -> Treap` | expected O(log n) | Fuse two arena-sharing treaps where every key of `a` < every key of `b`; CONSUMES both. Non-Treap inputs, cross-arena treaps, or an overlapping range throw. |
373
378
  | `size` / `capacity` | getters | O(1) | Live entry count / fixed capacity. |
374
379
 
380
+ ### Scapegoat
381
+
382
+ A **scapegoat tree**: a **DETERMINISTIC**, weight-balanced **binary search tree** that is also an **order-statistic tree** -- an AUGMENTED ordered map (key -> value) -- the honest **pair to Treap**. Where a treap randomizes its shape to be balanced *in expectation*, a scapegoat keeps a hard **worst-case height bound** (`height <= log_{1/alpha}(n) + 1`), so `get` is **worst-case O(log n)** (never merely expected). It pays for that with **amortized O(log n)** `set` / `delete`: after a mutation makes the tree too deep (or, on delete, too sparse), an occasional **subtree rebuild** restores balance in bulk. A subtree-size column (maintained in the same pass as every link rewrite and every rebuild) adds `rank(x)` / `select(k)`, O(log n). **No priorities, no RNG anywhere** -- the shape is a deterministic function of the insert / delete order. Nodes are slot **indices** in five flat columns (`_key` / `_value` `Float64`; `_left` / `_right` / `_size` `Uint32`, `NIL = 0`) over the SAME private free-list (`NodePool`) SkipList and Treap use. Keys and values are finite numbers (typeof-guarded before coercion; Symbol / BigInt / NaN / +-Infinity fail closed); `set` on an existing key updates the value in place. Every hot op allocates zero bytes after construction.
383
+
384
+ Zero-GC rebuild (the load-bearing design call): there is **no fresh array per rebuild**. One `_flat` (`Uint32Array(capacity)`) + one `_stack` (`Uint32Array(capacity+1)`) are allocated at construction and reused every rebuild -- an ITERATIVE, Morris-free in-order flatten (via `_stack`) writes sorted slot indices into `_flat`, and a bounded log-depth balanced rebuild re-links `_left` / `_right` / `_size` on the native call stack. Proven 0 B/op even under a rebuild-HEAVY ascending-insert trace (see [`decisions/0008-scapegoat.md`](./decisions/0008-scapegoat.md)). Unlike Treap there is **no `split` / `merge`**: a scapegoat has no priority heap to merge by, and an honest deterministic split/merge would be O(n) rebuilds -- the documented asymmetry vs Treap. `alpha` (the weight-balance factor) is validated to the OPEN interval `(0.55, 0.75)` -- both ends throw -- and frozen at construction (default `2/3`).
385
+
386
+ ```js
387
+ import { Scapegoat } from '@zakkster/lite-logn';
388
+
389
+ const sg = new Scapegoat(1000); // capacity 1000, alpha = 2/3 (deterministic, no seed)
390
+ sg.set(50, 500); // insert key 50 -> value 500
391
+ sg.set(20, 200);
392
+ sg.set(80, 800);
393
+ sg.get(20); // -> 200 -- worst-case O(log n)
394
+ sg.rank(50); // -> 1 (one key, 20, is strictly less than 50)
395
+ sg.select(0); // -> 20 (the smallest key)
396
+ sg.successor(20); // -> 50 (smallest key strictly greater)
397
+ [...sg.rangeIter(20, 80)]; // -> [20, 50, 80] (inclusive, ascending)
398
+ ```
399
+
400
+ | Member | Signature | Complexity | Notes |
401
+ | --- | --- | --- | --- |
402
+ | constructor | `new Scapegoat(capacity, alpha?)` | O(capacity) | `capacity` integer in `[1, 2^31-1]` (slot indices + subtree counts are `Uint32`, `NIL = 0` reserves slot 0); `alpha` in the OPEN interval `(0.55, 0.75)` (both ends throw), frozen at construction (default `2/3`). Allocates five columns + private pool + two rebuild scratch buffers once. |
403
+ | `get` | `get(key) -> number \| undefined` | worst-case O(log n) | The value under `key`, or `undefined` if absent (no throw). Non-finite key throws. |
404
+ | `has` | `has(key) -> boolean` | worst-case O(log n) | True iff `key` is stored. Non-finite key throws. |
405
+ | `set` | `set(key, value) -> this` | amortized O(log n) | Insert `key -> value`, or update the value in place if `key` exists (no rebuild). Non-finite key/value throws; a full pool throws. |
406
+ | `delete` | `delete(key) -> boolean` | amortized O(log n) | Idempotent: `false` if absent, `true` if removed. Non-finite key throws. |
407
+ | `rank` | `rank(x) -> number` | worst-case O(log n) | Count of stored keys STRICTLY less than `x`, in `[0, size]`. `x` need not be present. Non-finite `x` throws. |
408
+ | `select` | `select(k) -> number \| undefined` | worst-case O(log n) | The k-th smallest key (0-based), or `undefined` if `k` is out of `[0, size)`. Non-integer `k` throws. |
409
+ | `successor` | `successor(key) -> number \| undefined` | worst-case O(log n) | The smallest key STRICTLY greater than `key`, or `undefined`. |
410
+ | `predecessor` | `predecessor(key) -> number \| undefined` | worst-case O(log n) | The largest key STRICTLY less than `key`, or `undefined`. |
411
+ | `rangeIter` | `rangeIter(lo, hi) -> IterableIterator<number>` | O(k log n) | Version-stamped iterator over keys in `[lo, hi]` INCLUSIVE, ascending. Bounds may be `+-Infinity`; `NaN` or `lo > hi` throws; a structural OR value mutation mid-iteration throws. |
412
+ | `forEach` | `forEach(fn) -> void` | O(n) | Visits `(key, value, tree)` in ascending key order. |
413
+ | `clear` | `clear() -> this` | O(capacity) | Empties the tree, keeps capacity. |
414
+ | `size` / `capacity` / `alpha` | getters | O(1) | Live entry count / fixed capacity / frozen weight-balance factor. |
415
+
375
416
  Member signatures for later members are appended here as each ships.
376
417
 
377
418
  ## Zero-GC design notes
@@ -399,8 +440,12 @@ Member signatures for later members are appended here as each ships.
399
440
  | `Treap` forEach | 0 B/op in the loop body (recursive in-order walk, hoisted callback) |
400
441
  | `Treap` rangeIter | one iterator + `{value, done}` per step (the documented per-protocol allocator; transient, not retained) |
401
442
  | `Treap` split / merge | 0 B/op beyond the returned Treap view(s); rewire in place, share the arena, consume the input(s) |
443
+ | `Scapegoat` get / has / set / delete / rank / select / successor / predecessor | 0 B/op (nodes are slot indices; the rebuild reuses the preallocated `_flat` + `_stack` scratch and the native call stack, so even a rebuild storm is 0 B/op) |
444
+ | `Scapegoat` constructor / `clear` | O(capacity) five columns + pool + two rebuild scratch buffers, once (cold) |
445
+ | `Scapegoat` forEach | 0 B/op in the loop body (recursive in-order walk, hoisted callback) |
446
+ | `Scapegoat` rangeIter | one iterator + `{value, done}` per step (the documented per-protocol allocator; transient, not retained) |
402
447
 
403
- Gated witness numbers (this machine, shared R^2 floor 0.958): BinaryHeap `pop` R^2 ~ 0.99, slope ~ 8-10 ns/level; Fenwick `update` R^2 ~ 0.98-0.99, slope ~ 2.9-3.0 ns/level (band `[1.84, 4.30]`); Fenwick `prefix` R^2 ~ 0.97, slope ~ 2.6-2.7 ns/level (band `[1.76, 4.10]`); SegmentTree `update` R^2 ~ 0.99, slope ~ 3.2 ns/level (band `[2.29, 5.35]`); SegmentTree `query` R^2 ~ 0.99, slope ~ 7 ns/level (band `[4.30, 10.04]`); SkipList `get` R^2 ~ 0.97-0.99, slope ~ 9 ns/level (band `[5.27, 12.30]`, sweep `[2^11, 2^17]`); SkipList `set` R^2 ~ 0.97-0.99, slope ~ 14 ns/level (band `[8.36, 19.50]`, cache-resident sweep `[2^9, 2^14]`); Treap `get` R^2 ~ 0.99, slope ~ 4 ns/level (band `[2.55, 5.95]`, sweep `[2^11, 2^17]`). The allocation table is extended per member as each lands.
448
+ Gated witness numbers (this machine, shared R^2 floor 0.958): BinaryHeap `pop` R^2 ~ 0.99, slope ~ 8-10 ns/level; Fenwick `update` R^2 ~ 0.98-0.99, slope ~ 2.9-3.0 ns/level (band `[1.84, 4.30]`); Fenwick `prefix` R^2 ~ 0.97, slope ~ 2.6-2.7 ns/level (band `[1.76, 4.10]`); SegmentTree `update` R^2 ~ 0.99, slope ~ 3.2 ns/level (band `[2.29, 5.35]`); SegmentTree `query` R^2 ~ 0.99, slope ~ 7 ns/level (band `[4.30, 10.04]`); SkipList `get` R^2 ~ 0.97-0.99, slope ~ 9 ns/level (band `[5.27, 12.30]`, sweep `[2^11, 2^17]`); SkipList `set` R^2 ~ 0.97-0.99, slope ~ 14 ns/level (band `[8.36, 19.50]`, cache-resident sweep `[2^9, 2^14]`); Treap `get` R^2 ~ 0.99, slope ~ 4 ns/level (band `[2.55, 5.95]`, sweep `[2^11, 2^17]`); Scapegoat `get` R^2 ~ 0.99, slope ~ 3.8-4.0 ns/level (band `[2.41, 5.63]`, sweep `[2^11, 2^17]`) -- WORST-case (deterministic), with the AMORTIZED `set` rebuild spike proven by the amortized-trace assertion (ratio `< 4x`), not a per-op line. The allocation table is extended per member as each lands.
404
449
 
405
450
  ## Testing
406
451
 
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-logn
2
2
 
3
- Version: 0.5.0
3
+ Version: 0.6.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.
@@ -19,8 +19,8 @@ FLAT ops/ms line on a log-x axis), lite-logn holds the logarithm (a STRAIGHT
19
19
  line on that same axis, one added level per doubling of n).
20
20
 
21
21
  v0.1.0 shipped BinaryHeap; v0.2.0 adds Fenwick; v0.3.0 adds SegmentTree; v0.4.0
22
- adds SkipList; v0.5.0 adds Treap. The roster (one member per session, each landing
23
- append-only):
22
+ adds SkipList; v0.5.0 adds Treap; v0.6.0 adds Scapegoat. The roster (one member per
23
+ session, each landing append-only):
24
24
 
25
25
  - BinaryHeap (v0.1.0) -- an INDEXED binary heap (addressable priority queue): a
26
26
  min|max binary heap over three parallel typed arrays (`_key` Float64Array,
@@ -59,6 +59,22 @@ append-only):
59
59
  value in place. split / merge REWIRE in place (O(log n)) so the two treaps SHARE a
60
60
  backing arena and CONSUME their inputs. EXPECTED, not worst-case: the MAX single
61
61
  insert (rotation chain) is DISCLOSED, never gated. Zero allocation on every hot op.
62
+ - Scapegoat (v0.6.0) -- a DETERMINISTIC, weight-balanced AUGMENTED ordered map (key ->
63
+ value) that is also an order-statistic tree -- the honest PAIR to Treap. get is
64
+ WORST-case O(log n) (a hard height bound <= log_{1/alpha}(n) + 1, never merely
65
+ expected); set / delete are AMORTIZED O(log n) (an occasional subtree rebuild absorbs
66
+ the imbalance). A subtree-size column adds rank(x) / select(k), O(log n). NO priorities,
67
+ NO RNG anywhere: the shape is a deterministic function of the insert / delete order.
68
+ Nodes are slot INDICES in flat typed-array columns (_key / _value / _left / _right /
69
+ _size) over the SAME private free-list (NodePool); the ZERO-GC rebuild reuses ONE
70
+ preallocated scratch buffer (_flat) + ONE preallocated index-stack (_stack) -- an
71
+ iterative Morris-free flatten + a bounded log-depth balanced rebuild on the native
72
+ stack -- so even a rebuild-heavy trace is 0 B/op. alpha is frozen at construction in the
73
+ OPEN interval (0.55, 0.75) (both ends throw); default 2/3. Unlike Treap there is NO
74
+ split / merge (no priority heap to merge by; an honest deterministic split/merge would
75
+ be O(n) rebuilds) -- the documented asymmetry vs Treap. set updates an existing key's
76
+ value in place. The amortized-trace witness shows cumulative insert cost/op tracks log n
77
+ despite the rebuild spikes. Zero allocation on every hot op.
62
78
 
63
79
  ## Exports (from the single main file LogN.js)
64
80
 
@@ -189,6 +205,39 @@ append-only):
189
205
  < every key of b; O(log n) EXPECTED, CONSUMES both. Non-Treap inputs, treaps from
190
206
  different arenas, or an overlapping key range each throw.
191
207
 
208
+ - `Scapegoat` -- class. A DETERMINISTIC, weight-balanced AUGMENTED ordered map (key ->
209
+ value) that is also an order-statistic tree -- the honest PAIR to Treap. A BST on
210
+ `_key` kept alpha-weight-balanced (get WORST-case O(log n); set / delete AMORTIZED
211
+ O(log n) via an occasional subtree rebuild); a `_size` subtree-count column adds O(log n)
212
+ order statistics. NO priorities, NO RNG. Nodes are slot INDICES in flat typed-array
213
+ columns (`_key` / `_value` Float64, `_left` / `_right` / `_size` Uint32, `NIL = 0`) over
214
+ a PRIVATE free-list (NodePool); the zero-GC rebuild reuses ONE preallocated `_flat`
215
+ scratch + ONE `_stack` index-stack (iterative Morris-free flatten + bounded log-depth
216
+ balanced rebuild on the native stack) -- no heap object, no fresh array, 0 B/op even
217
+ under a rebuild storm. Keys and values are finite numbers (typeof-guarded before
218
+ coercion; Symbol / BigInt / NaN / +-Infinity fail closed).
219
+ - `new Scapegoat(capacity, alpha?)` -- capacity an integer in [1, 2^31-1] (slot indices
220
+ + subtree counts are Uint32, `NIL = 0` reserves slot 0); alpha the weight-balance
221
+ factor in the OPEN interval (0.55, 0.75) (both ends throw), frozen at construction;
222
+ default 2/3. Allocates five columns + a free-list + two rebuild scratch buffers once.
223
+ - `get(key)` -> value | undefined. Absent -> undefined (no throw).
224
+ - `has(key)` -> boolean.
225
+ - `set(key, value)` -> this. Insert, or update the value in place if key exists (no new
226
+ node, no rebuild). Non-finite key/value throws; a full pool throws.
227
+ - `delete(key)` -> boolean. Idempotent: false if absent, true if removed.
228
+ - `rank(x)` -> number. Count of stored keys STRICTLY LESS than x, in [0, size].
229
+ - `select(k)` -> key | undefined. The k-th smallest key (0-based); undefined if k is out
230
+ of [0, size). Non-integer k throws.
231
+ - `successor(key)` -> key | undefined. Smallest key STRICTLY greater than key.
232
+ - `predecessor(key)` -> key | undefined. Largest key STRICTLY less than key.
233
+ - `rangeIter(lo, hi)` -> iterator of keys in [lo, hi] INCLUSIVE, ascending; VERSION-
234
+ STAMPED (structural OR value mutation mid-iteration throws). Bounds may be +-Infinity;
235
+ NaN or lo > hi throws.
236
+ - `size` / `capacity` / `alpha` getters; `clear()` -> this (empty, keep capacity);
237
+ `forEach(fn)` visits (key, value, tree) ascending.
238
+ - There is deliberately NO `split` / `merge` (the documented asymmetry vs Treap: no
239
+ priority heap to merge by; an honest deterministic split/merge would be O(n) rebuilds).
240
+
192
241
  Member exports (one tree-shakeable class each) are appended here as each member
193
242
  ships.
194
243
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zakkster/lite-logn",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "0.5.0",
5
- "description": "Zero-dependency, zero-GC family of O(log n) data structures that proves its logarithm: BinaryHeap (array-embedded O(log n) push/pop min-heap), Fenwick/BIT (O(log n) point-update AND prefix-sum via the i & -i walk), SegmentTree (O(log n) associative range-query + point-update), SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool), and Treap (randomized-balanced augmented ordered map with O(log n) rank/select/split/merge) with a log-linear O(log n) Witness harness that fits nsPerOp = intercept + slope*log2(n) and shows the straight log line while an O(n) foil leaves it. Tree-shakeable named exports.",
4
+ "version": "0.6.0",
5
+ "description": "Zero-dependency, zero-GC family of O(log n) data structures that proves its logarithm: BinaryHeap (array-embedded O(log n) push/pop min-heap), Fenwick/BIT (O(log n) point-update AND prefix-sum via the i & -i walk), SegmentTree (O(log n) associative range-query + point-update), SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool), Treap (randomized-balanced augmented ordered map with O(log n) rank/select/split/merge), and Scapegoat (DETERMINISTIC weight-balanced augmented ordered map: worst-case-O(log n) get, amortized-O(log n) set/delete, zero-GC rebuild) with a log-linear O(log n) Witness harness that fits nsPerOp = intercept + slope*log2(n) and shows the straight log line while an O(n) foil leaves it. Tree-shakeable named exports.",
6
6
  "type": "module",
7
7
  "main": "./LogN.js",
8
8
  "module": "./LogN.js",
@@ -49,6 +49,9 @@
49
49
  "range-sum",
50
50
  "skip-list",
51
51
  "treap",
52
+ "scapegoat",
53
+ "scapegoat-tree",
54
+ "weight-balanced",
52
55
  "ordered-set",
53
56
  "ordered-map",
54
57
  "balanced-bst",