@zakkster/lite-logn 0.3.0 → 0.4.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 +59 -1
- package/LogN.d.ts +41 -0
- package/LogN.js +489 -11
- package/README.md +46 -7
- package/llms.txt +35 -6
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,63 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-09-17
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **SkipList** -- the fourth member and the family's FIRST randomized, pointer-based
|
|
14
|
+
member: a pointer-free ordered map (key -> value) whose `get` / `set` / `delete` /
|
|
15
|
+
`successor` / `predecessor` are EXPECTED O(log n) via a probabilistic tower of
|
|
16
|
+
forward links stored as slot INDICES in a SINGLE flat `Uint32Array` of
|
|
17
|
+
`columns * (capacity + 1)` cells (stride-indexed `lvl*(capacity + 1) + slot`,
|
|
18
|
+
`NIL = 0`, slot 0 the head sentinel) over a private free-list (`NodePool`) -- never
|
|
19
|
+
a heap object per op. Surface: `get` / `set` (updates the value in place on an
|
|
20
|
+
existing key) / `delete` (idempotent) / `successor` (strictly greater) /
|
|
21
|
+
`predecessor` (strictly less) / `rangeIter(lo, hi)` (a VERSION-STAMPED iterator
|
|
22
|
+
over `[lo, hi]` inclusive, ascending; `+-Infinity` bounds allowed, mutation
|
|
23
|
+
mid-iteration throws) / `forEach` / `clear`, and `size` / `capacity` getters. Keys
|
|
24
|
+
are finite numbers (typeof-guarded before coercion -- Symbol / BigInt / NaN /
|
|
25
|
+
+-Infinity fail closed with a `[lite-logn]` throw); values are finite numbers.
|
|
26
|
+
Level generation is ONE step of the repo's Numerical-Recipes LCG whose HIGH bits
|
|
27
|
+
draw a geometric height (`1 + clz32(word)`), instance-local seed, deterministic (a
|
|
28
|
+
fixed seed replays an identical structure; the low bits of the LCG are periodic, so
|
|
29
|
+
the high bits are used -- validated by a deterministic chi-square test, df = 15,
|
|
30
|
+
p > 0.001, over ~1e6 levels). `SL_MAX_CAPACITY = 0x03FFFFFF` (2^26 - 1: slot
|
|
31
|
+
indices fit a `Uint32`, `NIL = 0` reserves slot 0, and the column stride stays an
|
|
32
|
+
addressable length). Level columns are sized to `ceil(log2 cap) + 1` up front (NOT
|
|
33
|
+
grown lazily), so `_next` never reallocates -- trivially 0 B/op. `get` / `set` /
|
|
34
|
+
`delete` / `successor` / `predecessor` allocate zero bytes after construction.
|
|
35
|
+
Verified: torture 0 B/op on every hot lane (+ a 32 B/op control lane proving the
|
|
36
|
+
instrument has teeth), the private-pool conservation invariant `activeSlots +
|
|
37
|
+
freeListLength === capacity` after every soak cycle, leak `size 0/0`,
|
|
38
|
+
`gc major = 0`.
|
|
39
|
+
- **Witness: two more log lines.** `test/witness.mjs` gains SkipList's `get` and
|
|
40
|
+
`set` entries. Measured on this machine (shared R^2 floor 0.958): `get` R^2 ~
|
|
41
|
+
0.97-0.99, slope ~ 9 ns/level (band `[5.27, 12.30]`, median 8.78 x [0.6, 1.4]),
|
|
42
|
+
gated over `[2^11, 2^17]` for dynamic range; `set` R^2 ~ 0.97-0.99, slope ~ 14
|
|
43
|
+
ns/level (band `[8.36, 19.50]`, median 13.93 x [0.6, 1.4]), gated over the
|
|
44
|
+
cache-resident `[2^9, 2^14]` so the fit sees the structural level count, not DRAM
|
|
45
|
+
latency (each op is measured where its logarithm is visible, not where the cache
|
|
46
|
+
wall is). Both O(n) foils leave the line: the linear-scan search foil (O(n) per
|
|
47
|
+
search) and the sorted-array insert foil (O(n) shift), each below the floor.
|
|
48
|
+
Because the member is EXPECTED (not worst-case) O(log n), the witness ALSO prints
|
|
49
|
+
the MAX single insert over a realistic randomized build trace -- the unlucky-tower
|
|
50
|
+
tail a mean hides.
|
|
51
|
+
- **ADR.** [`decisions/0006-skiplist.md`](./decisions/0006-skiplist.md) (D-06):
|
|
52
|
+
binds D-01 to an IN-FILE PRIVATE `NodePool` as DESIGN-PARITY with lite-o1's private
|
|
53
|
+
pools (identical free-list contract + conservation invariant), NOT a runtime dep on
|
|
54
|
+
lite-o1 (rejected: zero-runtime-deps law; SlotPool never shipped); records the PRNG
|
|
55
|
+
choice (NR LCG, high bits for the level), the randomized-honesty note (MAX
|
|
56
|
+
single-op + chi-square df), and the level-column memory decision (size up front,
|
|
57
|
+
never grow lazily, to protect the 0-B/op gate).
|
|
58
|
+
|
|
59
|
+
### Unchanged
|
|
60
|
+
|
|
61
|
+
- **BinaryHeap, Fenwick and SegmentTree are byte-identical.** The v0.1.0 / v0.2.0 /
|
|
62
|
+
v0.3.0 member class bodies are untouched; only the file header roster, the
|
|
63
|
+
`VERSION` const, and the appended SkipList block (plus the `_lcgNext` / `NodePool`
|
|
64
|
+
/ `_levelCap` helpers) changed in `LogN.js`.
|
|
65
|
+
|
|
9
66
|
## [0.3.0] - 2026-09-17
|
|
10
67
|
|
|
11
68
|
### Added
|
|
@@ -125,7 +182,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
125
182
|
[`decisions/0003-pack.md`](./decisions/0003-pack.md) (D-07: `files[]` ships the
|
|
126
183
|
six files only; `test/`, `benchmark/`, `decisions/`, `demo/` are repo-only).
|
|
127
184
|
|
|
128
|
-
[Unreleased]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.
|
|
185
|
+
[Unreleased]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.4.0...HEAD
|
|
186
|
+
[0.4.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.3.0...v0.4.0
|
|
129
187
|
[0.3.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.2.0...v0.3.0
|
|
130
188
|
[0.2.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.1.0...v0.2.0
|
|
131
189
|
[0.1.0]: https://github.com/PeshoVurtoleta/lite-logn/releases/tag/v0.1.0
|
package/LogN.d.ts
CHANGED
|
@@ -130,3 +130,44 @@ export class SegmentTree {
|
|
|
130
130
|
/** O(n) bottom-up bulk build from a finite-number array-like and a fold kind. */
|
|
131
131
|
static build(values: ArrayLike<number>, kind: 'min' | 'max' | 'sum' | 'gcd'): SegmentTree;
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A skip list: a pointer-free ordered map (key -> value) whose get / set / delete /
|
|
136
|
+
* successor / predecessor are EXPECTED O(log n) via a probabilistic tower of forward
|
|
137
|
+
* links stored as slot INDICES in flat Uint32Array columns over a private free-list
|
|
138
|
+
* (no heap objects per op). Keys are finite numbers (typeof-guarded before coercion;
|
|
139
|
+
* Symbol / BigInt / NaN / +-Infinity fail closed); values are finite numbers. set on
|
|
140
|
+
* an existing key updates the value in place. An unsupplied seed defaults to a fixed
|
|
141
|
+
* constant; a fixed seed replays an identical structure. Fixed capacity: a full pool
|
|
142
|
+
* throws. Every hot op allocates zero bytes.
|
|
143
|
+
*/
|
|
144
|
+
export class SkipList {
|
|
145
|
+
/** @param capacity exact max live entries; integer in [1, 2^26-1].
|
|
146
|
+
* @param seed PRNG seed; unsigned 32-bit integer (default fixed). */
|
|
147
|
+
constructor(capacity: number, seed?: number);
|
|
148
|
+
|
|
149
|
+
/** Live entry count. */
|
|
150
|
+
readonly size: number;
|
|
151
|
+
/** The fixed capacity this list was sized for. */
|
|
152
|
+
readonly capacity: number;
|
|
153
|
+
|
|
154
|
+
/** The value under key, or undefined if absent (no throw). Non-finite key throws. */
|
|
155
|
+
get(key: number): number | undefined;
|
|
156
|
+
/** Insert key -> value, or update the value in place if key exists. Non-finite
|
|
157
|
+
* key/value throws; a full pool throws. */
|
|
158
|
+
set(key: number, value: number): this;
|
|
159
|
+
/** Remove key; true if it was present, false if absent (idempotent). Non-finite key throws. */
|
|
160
|
+
delete(key: number): boolean;
|
|
161
|
+
/** The smallest key strictly greater than key, or undefined. Non-finite key throws. */
|
|
162
|
+
successor(key: number): number | undefined;
|
|
163
|
+
/** The largest key strictly less than key, or undefined. Non-finite key throws. */
|
|
164
|
+
predecessor(key: number): number | undefined;
|
|
165
|
+
/** A version-stamped iterator over keys in [lo, hi] inclusive, ascending. Bounds
|
|
166
|
+
* may be +-Infinity (unbounded ends); NaN or lo > hi throws; mutation during
|
|
167
|
+
* iteration throws. */
|
|
168
|
+
rangeIter(lo: number, hi: number): IterableIterator<number>;
|
|
169
|
+
/** Visit every (key, value) pair in ascending key order. */
|
|
170
|
+
forEach(fn: (key: number, value: number, list: SkipList) => void): void;
|
|
171
|
+
/** Empty the list, keeping capacity (resets the PRNG to its initial seed). */
|
|
172
|
+
clear(): this;
|
|
173
|
+
}
|
package/LogN.js
CHANGED
|
@@ -12,15 +12,19 @@
|
|
|
12
12
|
* the lowest-set-bit walk (`i & -i`). v0.3.0 adds the THIRD member: SegmentTree,
|
|
13
13
|
* a flat `Float64Array(2n)` (leaves at n..2n-1) whose range-query AND point-update
|
|
14
14
|
* are BOTH O(log n) via iterative bottom-up walks, with the associative fold
|
|
15
|
-
* (min / max / sum / gcd) chosen ONCE at construction.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
15
|
+
* (min / max / sum / gcd) chosen ONCE at construction. v0.4.0 adds the FOURTH
|
|
16
|
+
* member: SkipList, a pointer-free ordered map (get / set / delete / successor /
|
|
17
|
+
* predecessor / rangeIter) whose links are slot INDICES in flat `Uint32Array`
|
|
18
|
+
* columns over a private free-list (NodePool), giving EXPECTED O(log n) with zero
|
|
19
|
+
* per-op allocation and a deterministic instance-local PRNG. Members land
|
|
20
|
+
* append-only, leaving this header and the `VERSION` const the only prior lines
|
|
21
|
+
* that ever change. Roster: BinaryHeap (v0.1.0, array-embedded O(log n) push / pop
|
|
22
|
+
* min|max heap), Fenwick / BIT (v0.2.0, O(log n) point-update AND prefix-sum via
|
|
23
|
+
* the `i & -i` walk), SegmentTree (v0.3.0, O(log n) associative range-query +
|
|
24
|
+
* point-update over a flat 2n array, fold chosen at construction), and SkipList
|
|
25
|
+
* (v0.4.0, pointer-free expected-O(log n) ordered map over a private free-list
|
|
26
|
+
* node pool). Members are independent (no shared mutable module state), so a
|
|
27
|
+
* bundler that imports one drops the others (`sideEffects: false`).
|
|
24
28
|
*
|
|
25
29
|
* The family delta: lite-o1 proves a FLAT ops/ms line on a log-x axis (the
|
|
26
30
|
* constant, slope ~ 0); lite-logn proves a STRAIGHT line on that same axis (one
|
|
@@ -35,13 +39,13 @@
|
|
|
35
39
|
*/
|
|
36
40
|
|
|
37
41
|
/** Package version. One of the three version sites (package.json / VERSION / llms.txt). */
|
|
38
|
-
export const VERSION = '0.
|
|
42
|
+
export const VERSION = '0.4.0';
|
|
39
43
|
|
|
40
44
|
// --- members land here, append-only, one tree-shakeable class each -----------
|
|
41
45
|
// BinaryHeap (v0.1.0 session) -- indexed O(log n) min|max heap (BELOW)
|
|
42
46
|
// Fenwick (v0.2.0 session) -- O(log n) point-update + prefix-sum (BELOW)
|
|
43
47
|
// SegmentTree (v0.3.0 session) -- O(log n) associative range-query + point-update (BELOW)
|
|
44
|
-
// SkipList (v0.4.0)
|
|
48
|
+
// SkipList (v0.4.0 session) -- pointer-free expected-O(log n) ordered map (BELOW)
|
|
45
49
|
|
|
46
50
|
/** Max heap capacity: slot indices 0..cap-1 must fit the Int32Array _pos map. */
|
|
47
51
|
const BH_MAX_CAPACITY = 0x7FFFFFFF; // 2^31 - 1
|
|
@@ -932,3 +936,477 @@ export class SegmentTree {
|
|
|
932
936
|
'[lite-logn] SegmentTree gcd value must be a nonnegative integer, got ' + String(value));
|
|
933
937
|
}
|
|
934
938
|
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Advance a 32-bit Numerical-Recipes LCG one step: `s' = (s*1664525 + 1013904223)
|
|
942
|
+
* mod 2^32`, kept as a SIGNED int32. The repo's single PRNG -- deterministic,
|
|
943
|
+
* instance-local, no Math.random, no module state, no new generator. Two integer
|
|
944
|
+
* disciplines keep it zero-alloc:
|
|
945
|
+
* - `Math.imul` does the multiply as a 32-bit integer op (the low 32 bits of the
|
|
946
|
+
* product), so no large intermediate double is ever formed (`s * 1664525` would
|
|
947
|
+
* reach ~7.1e15); and
|
|
948
|
+
* - the result is folded with `| 0` (a SIGNED int32), NOT `>>> 0`: a `>>> 0`
|
|
949
|
+
* Uint32 exceeds the Smi range (2^31) about half the time and would box as a
|
|
950
|
+
* transient HeapNumber every step (the perf-gate scavenge counter catches it),
|
|
951
|
+
* whereas an `| 0` signed int32 always stays an unboxed Smi.
|
|
952
|
+
* The 32-BIT WORD is identical either way, so the sequence is unchanged: `Math.imul`
|
|
953
|
+
* gives the same low 32 bits as the plain multiply, and mod-2^32 addition is
|
|
954
|
+
* sign-agnostic. SkipList draws a node level from the HIGH bits via `Math.clz32`,
|
|
955
|
+
* which does ToUint32 internally -- so the signed int32 and its Uint32 twin yield
|
|
956
|
+
* the IDENTICAL level. The LOW bits of any power-of-two-modulus LCG are periodic
|
|
957
|
+
* (here the lowest bit strictly alternates, since a is odd and c is odd), so
|
|
958
|
+
* counting halvings from the low end would be non-random -- the high bits are the
|
|
959
|
+
* well-mixed ones.
|
|
960
|
+
* @param {number} s current state, a signed 32-bit integer
|
|
961
|
+
* @returns {number} the next state, a signed 32-bit integer (same 32-bit word)
|
|
962
|
+
*/
|
|
963
|
+
function _lcgNext(s) {
|
|
964
|
+
return (Math.imul(s, 1664525) + 1013904223) | 0;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** SkipList tower-height ceiling: at most this many parallel `_next` columns. */
|
|
968
|
+
const SL_MAXLEVEL = 32;
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Column count actually allocated for a `capacity`-slot SkipList: `ceil(log2 cap)`
|
|
972
|
+
* plus one column of headroom for the geometric tail, clamped to SL_MAXLEVEL.
|
|
973
|
+
* Fixed at construction so `_next` NEVER reallocates -- the memory risk (a full
|
|
974
|
+
* cap * 32 column set) is avoided by sizing to the capacity's real need, and level
|
|
975
|
+
* generation clamps to it, so there is no lazy grow to threaten the 0-B/op gate
|
|
976
|
+
* (decisions/0006-skiplist.md). The clamp only ever bites the extreme geometric
|
|
977
|
+
* tail (probability ~ 2^-log2(cap)), which cannot change ordering or membership --
|
|
978
|
+
* a node merely stops gaining express lanes above the ceiling.
|
|
979
|
+
* @param {number} capacity data-slot count
|
|
980
|
+
* @returns {number} column count in [1, SL_MAXLEVEL]
|
|
981
|
+
*/
|
|
982
|
+
function _levelCap(capacity) {
|
|
983
|
+
let l = 1;
|
|
984
|
+
while ((1 << l) < capacity && l < SL_MAXLEVEL) l++;
|
|
985
|
+
l += 1; // one column of headroom above ceil(log2 cap)
|
|
986
|
+
return l > SL_MAXLEVEL ? SL_MAXLEVEL : l;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** SkipList default PRNG seed when the caller does not supply one. */
|
|
990
|
+
const SL_DEFAULT_SEED = 0x9E3779B9;
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* A private, pointer-free slot allocator: a free-list (a LIFO free-stack over a
|
|
994
|
+
* `Uint32Array`) that hands out a slot INDEX in [1, capacity] rather than a heap
|
|
995
|
+
* object, so nothing is collected per insert. `NIL = 0`; slot 0 is RESERVED (the
|
|
996
|
+
* SkipList head sentinel) and is never allocatable. `alloc()` returns 0 when the
|
|
997
|
+
* pool is exhausted so the caller can fail closed. The conservation invariant
|
|
998
|
+
* `activeSlots + freeListLength === capacity` holds after every operation.
|
|
999
|
+
*
|
|
1000
|
+
* D-01 bind (decisions/0006-skiplist.md): this is DESIGN-PARITY with
|
|
1001
|
+
* `@zakkster/lite-o1`'s private pools (FreqO1 / BucketQueue / TimerWheel) and its
|
|
1002
|
+
* deferred `SlotPool` -- the identical free-list contract (allocate an index,
|
|
1003
|
+
* `NIL = 0`, slot 0 reserved) and the same conservation invariant -- NOT shared
|
|
1004
|
+
* code. A runtime dependency on lite-o1 was REJECTED: the suite's zero-runtime-deps
|
|
1005
|
+
* law forbids it, and lite-o1's `SlotPool` was never made public. Shaped so a later
|
|
1006
|
+
* pointer member (Treap) can reuse it, without over-engineering it now.
|
|
1007
|
+
*/
|
|
1008
|
+
class NodePool {
|
|
1009
|
+
/** @param {number} capacity allocatable data-slot count (excludes slot 0). */
|
|
1010
|
+
constructor(capacity) {
|
|
1011
|
+
this._cap = capacity;
|
|
1012
|
+
this._free = new Uint32Array(capacity); // free-stack of slot indices
|
|
1013
|
+
this._freeLen = capacity;
|
|
1014
|
+
for (let i = 0; i < capacity; i++) this._free[i] = capacity - i; // top = slot 1
|
|
1015
|
+
this._active = 0;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Allocatable data-slot count (excludes the reserved slot 0). O(1). */
|
|
1019
|
+
get capacity() { return this._cap; }
|
|
1020
|
+
/** Slots currently handed out and not yet freed. O(1). */
|
|
1021
|
+
get activeSlots() { return this._active; }
|
|
1022
|
+
/** Slots currently on the free-stack. O(1). */
|
|
1023
|
+
get freeListLength() { return this._freeLen; }
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Hand out a free slot INDEX in [1, capacity], or 0 (NIL) if exhausted. O(1),
|
|
1027
|
+
* zero allocation.
|
|
1028
|
+
* @returns {number}
|
|
1029
|
+
*/
|
|
1030
|
+
alloc() {
|
|
1031
|
+
const n = this._freeLen;
|
|
1032
|
+
if (n === 0) return 0; // NIL: exhausted -> caller fails closed
|
|
1033
|
+
const slot = this._free[n - 1];
|
|
1034
|
+
this._freeLen = n - 1;
|
|
1035
|
+
this._active++;
|
|
1036
|
+
return slot;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Return a slot INDEX to the free-stack. O(1), zero allocation. The caller owns
|
|
1041
|
+
* correctness: a slot must be live and freed at most once (the SkipList only
|
|
1042
|
+
* frees a node it just unlinked).
|
|
1043
|
+
* @param {number} slot a slot previously returned by alloc()
|
|
1044
|
+
*/
|
|
1045
|
+
free(slot) {
|
|
1046
|
+
const n = this._freeLen;
|
|
1047
|
+
this._free[n] = slot;
|
|
1048
|
+
this._freeLen = n + 1;
|
|
1049
|
+
this._active--;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Reset to all-free (O(capacity) cold path), restoring the conservation invariant. */
|
|
1053
|
+
clear() {
|
|
1054
|
+
const cap = this._cap, free = this._free;
|
|
1055
|
+
for (let i = 0; i < cap; i++) free[i] = cap - i;
|
|
1056
|
+
this._freeLen = cap;
|
|
1057
|
+
this._active = 0;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Max SkipList capacity: `0x03FFFFFF` (2^26 - 1). Every node is addressed by a slot
|
|
1063
|
+
* INDEX stored in `Uint32Array` link columns, so an index must fit an unsigned
|
|
1064
|
+
* 32-bit word; `NIL = 0` reserves slot 0 as the head sentinel, so live slots run
|
|
1065
|
+
* [1, capacity]. The backing `_next` is a SINGLE flat `Uint32Array` of
|
|
1066
|
+
* `columns * (capacity + 1)` cells, stride-indexed `lvl*(capacity + 1) + slot`; the
|
|
1067
|
+
* 2^26 ceiling keeps `columns * (capacity + 1)` an addressable typed-array length
|
|
1068
|
+
* (at most ~27 columns for the largest capacity) and every stride offset an exact
|
|
1069
|
+
* integer. The index arithmetic (Uint32 slot indices + `NIL = 0` + the MAXLEVEL
|
|
1070
|
+
* column width), not the byte count, is the hard ceiling -- the same "the
|
|
1071
|
+
* arithmetic caps it" reasoning as the array-embedded members, one column-set wider.
|
|
1072
|
+
*/
|
|
1073
|
+
const SL_MAX_CAPACITY = 0x03FFFFFF; // 2^26 - 1
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* A SKIP LIST: a pointer-free ordered map (key -> value) whose get / set / delete /
|
|
1077
|
+
* successor / predecessor are EXPECTED O(log n) via a probabilistic tower of
|
|
1078
|
+
* forward links -- the family's first randomized member and its first pointer-based
|
|
1079
|
+
* one. Where BinaryHeap / Fenwick / SegmentTree embed a FIXED-shape tree in index
|
|
1080
|
+
* arithmetic, a skip list's shape is random, so it needs real per-node links; the
|
|
1081
|
+
* trick that keeps it zero-GC is storing those links as slot INDICES in flat
|
|
1082
|
+
* `Uint32Array` columns over a private free-list (NodePool), never as heap objects.
|
|
1083
|
+
*
|
|
1084
|
+
* Storage (allocated once, sized to capacity):
|
|
1085
|
+
* - `_key` / `_val` `Float64Array(capacity + 1)` -- key and value at each slot.
|
|
1086
|
+
* - `_next` a SINGLE flat `Uint32Array(columns * (capacity + 1))`, stride-indexed
|
|
1087
|
+
* `lvl*(capacity + 1) + slot`: the forward link of `slot` at level `lvl`, or
|
|
1088
|
+
* `NIL = 0` for end-of-list. Slot 0 is the HEAD sentinel (its links are the
|
|
1089
|
+
* first node at each level); no real node's link ever points AT the head, so a
|
|
1090
|
+
* link value of 0 unambiguously means NIL.
|
|
1091
|
+
* - `_pool` NodePool -- the free-list handing out slot indices [1, capacity].
|
|
1092
|
+
* - `_update` `Uint32Array(columns)` -- reused predecessor scratch for the ONE
|
|
1093
|
+
* structural descent (`_find`); preallocated so set / delete allocate nothing.
|
|
1094
|
+
*
|
|
1095
|
+
* Level generation is one LCG step (the repo's NR generator) whose HIGH bits pick a
|
|
1096
|
+
* geometric height: `level = 1 + clz32(word)`, clamped to the allocated column
|
|
1097
|
+
* count (the low bits of a power-of-two LCG are periodic -- see `_lcgNext`). The
|
|
1098
|
+
* seed is instance-local, so a fixed seed replays an IDENTICAL structure and a
|
|
1099
|
+
* different seed diverges -- deterministic, never Math.random.
|
|
1100
|
+
*
|
|
1101
|
+
* Honesty (randomized member): a hot op is EXPECTED O(log n), not worst-case. An
|
|
1102
|
+
* unlucky seed can build a tall thin tower and spike a single op; the witness prints
|
|
1103
|
+
* that MAX single-op alongside the fitted line so the expectation never masquerades
|
|
1104
|
+
* as a worst-case guarantee (decisions/0006-skiplist.md).
|
|
1105
|
+
*
|
|
1106
|
+
* Keys are FINITE numbers (typeof-guarded BEFORE coercion -- Symbol / BigInt / NaN /
|
|
1107
|
+
* +-Infinity fail closed with a `[lite-logn]` throw); values are Float64 (zero-GC).
|
|
1108
|
+
* `set` on an EXISTING key updates its value in place (no new node). An empty or
|
|
1109
|
+
* missing query returns `undefined` (never throws). Fixed capacity: a full pool
|
|
1110
|
+
* throws, never silently drops. `rangeIter` is a VERSION-STAMPED iterator -- any
|
|
1111
|
+
* structural mutation mid-iteration throws `[lite-logn]` rather than yield garbage.
|
|
1112
|
+
*/
|
|
1113
|
+
export class SkipList {
|
|
1114
|
+
/**
|
|
1115
|
+
* @param {number} capacity exact max live entries; integer in [1, 2^26-1].
|
|
1116
|
+
* @param {number} [seed] PRNG seed; unsigned 32-bit integer (default fixed).
|
|
1117
|
+
*/
|
|
1118
|
+
constructor(capacity, seed) {
|
|
1119
|
+
// typeof guard BEFORE coercion (Number.isInteger is Symbol/BigInt-safe).
|
|
1120
|
+
if (typeof capacity !== 'number' || !Number.isInteger(capacity) ||
|
|
1121
|
+
capacity < 1 || capacity > SL_MAX_CAPACITY) {
|
|
1122
|
+
throw new RangeError(
|
|
1123
|
+
'[lite-logn] SkipList capacity must be an integer in [1, 2^26-1], got ' +
|
|
1124
|
+
String(capacity));
|
|
1125
|
+
}
|
|
1126
|
+
let s;
|
|
1127
|
+
if (seed === undefined) {
|
|
1128
|
+
s = SL_DEFAULT_SEED;
|
|
1129
|
+
} else if (typeof seed !== 'number' || !Number.isInteger(seed) ||
|
|
1130
|
+
seed < 0 || seed > 0xFFFFFFFF) {
|
|
1131
|
+
throw new RangeError(
|
|
1132
|
+
'[lite-logn] SkipList seed must be an unsigned 32-bit integer, got ' +
|
|
1133
|
+
String(seed));
|
|
1134
|
+
} else {
|
|
1135
|
+
s = seed >>> 0;
|
|
1136
|
+
}
|
|
1137
|
+
s = s | 0; // store the LCG state as a SIGNED int32 (an unboxed Smi) -- see _lcgNext
|
|
1138
|
+
const cols = _levelCap(capacity);
|
|
1139
|
+
this._cap = capacity; // max live entries
|
|
1140
|
+
this._stride = capacity + 1; // slots 0..capacity (0 = head)
|
|
1141
|
+
this._maxLevel = cols; // allocated column count
|
|
1142
|
+
this._key = new Float64Array(capacity + 1); // key at each slot
|
|
1143
|
+
this._val = new Float64Array(capacity + 1); // value at each slot
|
|
1144
|
+
this._next = new Uint32Array(cols * (capacity + 1)); // links; all NIL (0)
|
|
1145
|
+
this._update = new Uint32Array(cols); // reused predecessor scratch
|
|
1146
|
+
this._pool = new NodePool(capacity); // free-list over slots [1, capacity]
|
|
1147
|
+
this._level = 1; // current live tower height
|
|
1148
|
+
this._size = 0; // live entries
|
|
1149
|
+
this._version = 0; // iterator invalidation stamp
|
|
1150
|
+
this._seed0 = s; // initial seed (clear resets to it)
|
|
1151
|
+
this._seed = s; // live LCG state
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Live entry count. O(1). */
|
|
1155
|
+
get size() { return this._size; }
|
|
1156
|
+
|
|
1157
|
+
/** The fixed capacity this list was sized for. O(1). */
|
|
1158
|
+
get capacity() { return this._cap; }
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* The value stored under `key`, or `undefined` if absent (never throws on a
|
|
1162
|
+
* missing / empty query). EXPECTED O(log n): a top-down descent that at each
|
|
1163
|
+
* level advances while the next key is strictly less than `key`. Fails closed:
|
|
1164
|
+
* a non-number / non-finite key (typeof-guarded first) throws `[lite-logn]`.
|
|
1165
|
+
* @param {number} key a finite number
|
|
1166
|
+
* @returns {number|undefined}
|
|
1167
|
+
*/
|
|
1168
|
+
get(key) {
|
|
1169
|
+
if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
|
|
1170
|
+
const next = this._next, K = this._key, stride = this._stride;
|
|
1171
|
+
let slot = 0; // head
|
|
1172
|
+
for (let lvl = this._level - 1; lvl >= 0; lvl--) {
|
|
1173
|
+
const base = lvl * stride;
|
|
1174
|
+
let nx = next[base + slot];
|
|
1175
|
+
while (nx !== 0 && K[nx] < key) { slot = nx; nx = next[base + slot]; }
|
|
1176
|
+
}
|
|
1177
|
+
const cand = next[slot]; // level-0 next (base 0)
|
|
1178
|
+
return (cand !== 0 && K[cand] === key) ? this._val[cand] : undefined;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Insert `key -> value`, or UPDATE the value in place if `key` already exists
|
|
1183
|
+
* (no new node). EXPECTED O(log n). Fails closed: a non-finite key or value
|
|
1184
|
+
* (typeof-guarded first), or a full pool, each throw `[lite-logn]` as a no-op.
|
|
1185
|
+
* @param {number} key a finite number
|
|
1186
|
+
* @param {number} value a finite number
|
|
1187
|
+
* @returns {this}
|
|
1188
|
+
*/
|
|
1189
|
+
set(key, value) {
|
|
1190
|
+
if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
|
|
1191
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return this._badValue(value);
|
|
1192
|
+
const cand = this._find(key); // fills _update with per-level predecessors
|
|
1193
|
+
const K = this._key;
|
|
1194
|
+
if (cand !== 0 && K[cand] === key) { // existing key: update value in place
|
|
1195
|
+
this._val[cand] = value;
|
|
1196
|
+
this._version = (this._version + 1) | 0;
|
|
1197
|
+
return this;
|
|
1198
|
+
}
|
|
1199
|
+
const slot = this._pool.alloc();
|
|
1200
|
+
if (slot === 0) return this._full();
|
|
1201
|
+
// One LCG step; HIGH bits pick a geometric height, clamped to the columns.
|
|
1202
|
+
const word = this._seed = _lcgNext(this._seed);
|
|
1203
|
+
let nl = 1 + Math.clz32(word);
|
|
1204
|
+
if (nl > this._maxLevel) nl = this._maxLevel;
|
|
1205
|
+
const upd = this._update, next = this._next, stride = this._stride;
|
|
1206
|
+
if (nl > this._level) {
|
|
1207
|
+
for (let lvl = this._level; lvl < nl; lvl++) upd[lvl] = 0; // head is predecessor
|
|
1208
|
+
this._level = nl;
|
|
1209
|
+
}
|
|
1210
|
+
K[slot] = key;
|
|
1211
|
+
this._val[slot] = value;
|
|
1212
|
+
for (let lvl = 0; lvl < nl; lvl++) {
|
|
1213
|
+
const base = lvl * stride;
|
|
1214
|
+
const p = upd[lvl];
|
|
1215
|
+
next[base + slot] = next[base + p]; // splice slot after predecessor p
|
|
1216
|
+
next[base + p] = slot;
|
|
1217
|
+
}
|
|
1218
|
+
this._size++;
|
|
1219
|
+
this._version = (this._version + 1) | 0;
|
|
1220
|
+
return this;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Remove `key`. EXPECTED O(log n). Idempotent: returns `false` if `key` is
|
|
1225
|
+
* absent (no throw), `true` if it was present and removed. Fails closed on a
|
|
1226
|
+
* non-finite key (typeof-guarded first) with a `[lite-logn]` throw.
|
|
1227
|
+
* @param {number} key a finite number
|
|
1228
|
+
* @returns {boolean}
|
|
1229
|
+
*/
|
|
1230
|
+
delete(key) {
|
|
1231
|
+
if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
|
|
1232
|
+
const cand = this._find(key); // fills _update
|
|
1233
|
+
const K = this._key;
|
|
1234
|
+
if (cand === 0 || K[cand] !== key) return false; // absent (no throw)
|
|
1235
|
+
const next = this._next, stride = this._stride, upd = this._update;
|
|
1236
|
+
for (let lvl = 0; lvl < this._level; lvl++) {
|
|
1237
|
+
const base = lvl * stride;
|
|
1238
|
+
const p = upd[lvl];
|
|
1239
|
+
if (next[base + p] === cand) next[base + p] = next[base + cand];
|
|
1240
|
+
}
|
|
1241
|
+
// Shrink the live height while the top levels are empty (head link == NIL).
|
|
1242
|
+
let lv = this._level;
|
|
1243
|
+
while (lv > 1 && next[(lv - 1) * stride] === 0) lv--;
|
|
1244
|
+
this._level = lv;
|
|
1245
|
+
this._pool.free(cand);
|
|
1246
|
+
this._size--;
|
|
1247
|
+
this._version = (this._version + 1) | 0;
|
|
1248
|
+
return true;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* The smallest key STRICTLY greater than `key`, or `undefined` if none. EXPECTED
|
|
1253
|
+
* O(log n). `key` itself need not be present. Fails closed on a non-finite key.
|
|
1254
|
+
* @param {number} key a finite number
|
|
1255
|
+
* @returns {number|undefined}
|
|
1256
|
+
*/
|
|
1257
|
+
successor(key) {
|
|
1258
|
+
if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
|
|
1259
|
+
const next = this._next, K = this._key, stride = this._stride;
|
|
1260
|
+
let slot = 0;
|
|
1261
|
+
for (let lvl = this._level - 1; lvl >= 0; lvl--) {
|
|
1262
|
+
const base = lvl * stride;
|
|
1263
|
+
let nx = next[base + slot];
|
|
1264
|
+
while (nx !== 0 && K[nx] <= key) { slot = nx; nx = next[base + slot]; }
|
|
1265
|
+
}
|
|
1266
|
+
const cand = next[slot];
|
|
1267
|
+
return cand !== 0 ? K[cand] : undefined;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* The largest key STRICTLY less than `key`, or `undefined` if none. EXPECTED
|
|
1272
|
+
* O(log n). `key` itself need not be present. Fails closed on a non-finite key.
|
|
1273
|
+
* @param {number} key a finite number
|
|
1274
|
+
* @returns {number|undefined}
|
|
1275
|
+
*/
|
|
1276
|
+
predecessor(key) {
|
|
1277
|
+
if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
|
|
1278
|
+
const next = this._next, K = this._key, stride = this._stride;
|
|
1279
|
+
let slot = 0;
|
|
1280
|
+
for (let lvl = this._level - 1; lvl >= 0; lvl--) {
|
|
1281
|
+
const base = lvl * stride;
|
|
1282
|
+
let nx = next[base + slot];
|
|
1283
|
+
while (nx !== 0 && K[nx] < key) { slot = nx; nx = next[base + slot]; }
|
|
1284
|
+
}
|
|
1285
|
+
return slot !== 0 ? K[slot] : undefined; // slot = largest key < key, or head
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* A VERSION-STAMPED iterator over the keys in `[lo, hi]` INCLUSIVE, in ascending
|
|
1290
|
+
* order. Bounds may be any number INCLUDING +-Infinity (an unbounded end);
|
|
1291
|
+
* `NaN` (unordered) fails closed, as does `lo > hi`. The generator captures the
|
|
1292
|
+
* list's version and throws `[lite-logn]` if any STRUCTURAL mutation (set of a
|
|
1293
|
+
* new key, delete, clear -- or any value update) happens mid-iteration, rather
|
|
1294
|
+
* than yield stale / recycled data. The one documented per-protocol allocator
|
|
1295
|
+
* (a {value, done} per step); the loop body itself allocates nothing.
|
|
1296
|
+
* @param {number} lo lower bound (inclusive); may be -Infinity
|
|
1297
|
+
* @param {number} hi upper bound (inclusive); may be +Infinity
|
|
1298
|
+
* @returns {IterableIterator<number>} the keys in [lo, hi], ascending
|
|
1299
|
+
*/
|
|
1300
|
+
rangeIter(lo, hi) {
|
|
1301
|
+
if (typeof lo !== 'number' || Number.isNaN(lo)) return this._badBound(lo);
|
|
1302
|
+
if (typeof hi !== 'number' || Number.isNaN(hi)) return this._badBound(hi);
|
|
1303
|
+
if (lo > hi) return this._badRange(lo, hi);
|
|
1304
|
+
return this._rangeGen(lo, hi);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** @private version-stamped range generator (see rangeIter). */
|
|
1308
|
+
*_rangeGen(lo, hi) {
|
|
1309
|
+
const ver = this._version;
|
|
1310
|
+
const next = this._next, K = this._key, stride = this._stride;
|
|
1311
|
+
let slot = 0;
|
|
1312
|
+
for (let lvl = this._level - 1; lvl >= 0; lvl--) {
|
|
1313
|
+
const base = lvl * stride;
|
|
1314
|
+
let nx = next[base + slot];
|
|
1315
|
+
while (nx !== 0 && K[nx] < lo) { slot = nx; nx = next[base + slot]; }
|
|
1316
|
+
}
|
|
1317
|
+
slot = next[slot]; // first slot with key >= lo
|
|
1318
|
+
while (slot !== 0 && K[slot] <= hi) {
|
|
1319
|
+
if (this._version !== ver) {
|
|
1320
|
+
throw new Error('[lite-logn] SkipList mutated during iteration');
|
|
1321
|
+
}
|
|
1322
|
+
yield K[slot];
|
|
1323
|
+
slot = next[slot]; // level-0 next (base 0)
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Visit every live `(key, value)` pair in ASCENDING key order. O(n) cold scan,
|
|
1329
|
+
* allocation-free in the loop body (pass a hoisted callback). Unlike rangeIter
|
|
1330
|
+
* this is NOT version-stamped -- mutating from within the callback is the
|
|
1331
|
+
* caller's responsibility (matching the other members' forEach).
|
|
1332
|
+
* @param {(key:number, value:number, list:SkipList)=>void} fn
|
|
1333
|
+
*/
|
|
1334
|
+
forEach(fn) {
|
|
1335
|
+
const next = this._next, K = this._key, V = this._val;
|
|
1336
|
+
let slot = next[0]; // level-0 first (base 0, head)
|
|
1337
|
+
while (slot !== 0) {
|
|
1338
|
+
fn(K[slot], V[slot], this);
|
|
1339
|
+
slot = next[slot];
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Empty the list, keeping the fixed capacity. O(capacity) cold path: returns
|
|
1345
|
+
* every node to the pool, points the head's links at NIL, resets the live
|
|
1346
|
+
* height, and restores the PRNG to its initial seed (a cleared list replays a
|
|
1347
|
+
* fresh one). @returns {this}
|
|
1348
|
+
*/
|
|
1349
|
+
clear() {
|
|
1350
|
+
this._pool.clear();
|
|
1351
|
+
const next = this._next, stride = this._stride, cols = this._maxLevel;
|
|
1352
|
+
for (let lvl = 0; lvl < cols; lvl++) next[lvl * stride] = 0; // head links -> NIL
|
|
1353
|
+
this._level = 1;
|
|
1354
|
+
this._size = 0;
|
|
1355
|
+
this._seed = this._seed0;
|
|
1356
|
+
this._version = (this._version + 1) | 0;
|
|
1357
|
+
return this;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ---- private structural descent (hot body) -----------------------------
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* The ONE structural descent (set / delete): walk top-down, at each level
|
|
1364
|
+
* advancing while the next key is strictly less than `key`, recording the
|
|
1365
|
+
* predecessor per level in the reused `_update` scratch. Returns the level-0
|
|
1366
|
+
* candidate (first slot with key >= `key`, or NIL). Zero allocation.
|
|
1367
|
+
* @private
|
|
1368
|
+
*/
|
|
1369
|
+
_find(key) {
|
|
1370
|
+
const next = this._next, K = this._key, stride = this._stride, upd = this._update;
|
|
1371
|
+
let slot = 0; // head
|
|
1372
|
+
for (let lvl = this._level - 1; lvl >= 0; lvl--) {
|
|
1373
|
+
const base = lvl * stride;
|
|
1374
|
+
let nx = next[base + slot];
|
|
1375
|
+
while (nx !== 0 && K[nx] < key) { slot = nx; nx = next[base + slot]; }
|
|
1376
|
+
upd[lvl] = slot;
|
|
1377
|
+
}
|
|
1378
|
+
return next[slot]; // level-0 next (base 0)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ---- cold path only: throw builders (string concat off the hot body) ----
|
|
1382
|
+
|
|
1383
|
+
/** @private */
|
|
1384
|
+
_badKey(key) {
|
|
1385
|
+
throw new TypeError(
|
|
1386
|
+
'[lite-logn] SkipList key must be a finite number, got ' + String(key));
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** @private */
|
|
1390
|
+
_badValue(value) {
|
|
1391
|
+
throw new TypeError(
|
|
1392
|
+
'[lite-logn] SkipList value must be a finite number, got ' + String(value));
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** @private */
|
|
1396
|
+
_badBound(b) {
|
|
1397
|
+
throw new TypeError(
|
|
1398
|
+
'[lite-logn] SkipList rangeIter bound must be a number (not NaN), got ' + String(b));
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** @private */
|
|
1402
|
+
_badRange(lo, hi) {
|
|
1403
|
+
throw new RangeError(
|
|
1404
|
+
'[lite-logn] SkipList rangeIter needs lo <= hi, got lo=' + String(lo) +
|
|
1405
|
+
' hi=' + String(hi));
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** @private */
|
|
1409
|
+
_full() {
|
|
1410
|
+
throw new RangeError('[lite-logn] SkipList full (capacity ' + this._cap + ')');
|
|
1411
|
+
}
|
|
1412
|
+
}
|
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.
|
|
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.4.0 ships four 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), and SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool) -- 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
|
[](https://www.npmjs.com/package/@zakkster/lite-logn)
|
|
6
6
|
[](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.
|
|
22
|
+
**v0.4.0 ships four members: BinaryHeap, Fenwick, SegmentTree and SkipList.** 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
|
|
@@ -58,6 +58,7 @@ Every hot op allocates zero bytes after construction, and `npm run witness` prov
|
|
|
58
58
|
- [BinaryHeap](#binaryheap)
|
|
59
59
|
- [Fenwick](#fenwick)
|
|
60
60
|
- [SegmentTree](#segmenttree)
|
|
61
|
+
- [SkipList](#skiplist)
|
|
61
62
|
- [Zero-GC design notes](#zero-gc-design-notes)
|
|
62
63
|
- [Testing](#testing)
|
|
63
64
|
- [What this is not](#what-this-is-not)
|
|
@@ -82,14 +83,14 @@ lite-logn ships the O(log n) structures that matter with the allocation removed
|
|
|
82
83
|
|
|
83
84
|
## The roster
|
|
84
85
|
|
|
85
|
-
One member per session, each landing append-only (prior members stay byte-identical). At v0.
|
|
86
|
+
One member per session, each landing append-only (prior members stay byte-identical). At v0.4.0, BinaryHeap, Fenwick, SegmentTree and SkipList are shipped.
|
|
86
87
|
|
|
87
88
|
| Member | Version | Status | Shape | Hot ops |
|
|
88
89
|
| --- | --- | --- | --- | --- |
|
|
89
90
|
| **BinaryHeap** | 0.1.0 | shipped | array-embedded complete binary min|max heap over a flat `Float64Array` | `push` / `pop` O(log n), `peek` O(1) |
|
|
90
91
|
| **Fenwick** (BIT) | 0.2.0 | shipped | flat `Float64Array`, lowest-set-bit walk (`i & -i`) | `update` / `prefix` / `rangeSum` / `at` / `set` O(log n) |
|
|
91
92
|
| **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) |
|
|
92
|
-
| **SkipList** | 0.4.0 |
|
|
93
|
+
| **SkipList** | 0.4.0 | shipped | pointer-free over a private free-list node pool; expected O(log n) | `get` / `set` / `delete` / `successor` / `predecessor` |
|
|
93
94
|
|
|
94
95
|
Later tiers (Treap / Scapegoat, OrderStatTree, IndexedHeap, SortedArray, MinMaxHeap, SplayTree, and presets) are queued in [`ROADMAP.md`](./ROADMAP.md).
|
|
95
96
|
|
|
@@ -101,7 +102,7 @@ The family anchor. Time a fixed batch of the hot op at each `n` in a geometric s
|
|
|
101
102
|
- `slope` inside the member's band (the per-level cost, ns/level), AND
|
|
102
103
|
- the FOIL leaves the line (low `R^2` -- the O(n) default a working programmer reaches for, shown losing as `n` grows).
|
|
103
104
|
|
|
104
|
-
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.
|
|
105
|
+
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.4.0 the witness gates seven 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]`), and 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]`) all ON the line. 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.
|
|
105
106
|
|
|
106
107
|
## API reference
|
|
107
108
|
|
|
@@ -109,7 +110,7 @@ For amortized / randomized members the witness also prints the MAX single-op tim
|
|
|
109
110
|
|
|
110
111
|
| Export | Type | Value | Meaning |
|
|
111
112
|
| --- | --- | --- | --- |
|
|
112
|
-
| `VERSION` | `string` | `'0.
|
|
113
|
+
| `VERSION` | `string` | `'0.4.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. |
|
|
113
114
|
|
|
114
115
|
### BinaryHeap
|
|
115
116
|
|
|
@@ -221,6 +222,40 @@ The iterative `2n` layout is **order-agnostic** -- `query` mixes left- and right
|
|
|
221
222
|
| `length` / `kind` | getters | O(1) | Element count / the frozen fold `'min'` \| `'max'` \| `'sum'` \| `'gcd'`. |
|
|
222
223
|
| `SegmentTree.build` | `build(values, kind) -> SegmentTree` | O(n) | Bottom-up bulk build (seed leaves, then fold each internal node once deepest-first -- NOT n incremental updates); fails closed on a non-array-like, any non-finite value, or (gcd) any negative / non-integer. |
|
|
223
224
|
|
|
225
|
+
### SkipList
|
|
226
|
+
|
|
227
|
+
A **skip list**: a pointer-free **ordered map** (key -> value) whose `get` / `set` / `delete` / `successor` / `predecessor` are **expected O(log n)** via a probabilistic tower of forward links -- the family's first randomized member and its first pointer-based one. Where the array-embedded members bury a fixed-shape tree in index arithmetic, a skip list's shape is random, so it needs real per-node links; the trick that keeps it zero-GC is storing those links as slot **indices** in flat `Uint32Array` columns over a private free-list (`NodePool`), never as heap objects. `NIL = 0`, slot 0 is the head sentinel, and level generation is one step of the repo's Numerical-Recipes LCG whose high bits draw a geometric height (`1 + clz32(word)`) -- deterministic from an instance-local seed, no `Math.random`. Keys are finite numbers (typeof-guarded before coercion; Symbol / BigInt / NaN / +-Infinity fail closed); values are finite numbers; `set` on an existing key updates the value in place (no new node). Every hot op allocates zero bytes after construction.
|
|
228
|
+
|
|
229
|
+
Honesty note: the hot ops are **expected** O(log n), not worst-case -- an unlucky seed can build a tall thin tower and spike a single op. The witness fits the clean average line **and** separately prints the MAX single insert over a realistic randomized build trace, so the expectation is never sold as a guarantee.
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
import { SkipList } from '@zakkster/lite-logn';
|
|
233
|
+
|
|
234
|
+
const sl = new SkipList(1000, 42); // capacity 1000, seed 42 (deterministic)
|
|
235
|
+
sl.set(50, 500); // insert key 50 -> value 500
|
|
236
|
+
sl.set(20, 200);
|
|
237
|
+
sl.set(80, 800);
|
|
238
|
+
sl.get(20); // -> 200
|
|
239
|
+
sl.set(20, 222); // update value in place (no new node)
|
|
240
|
+
sl.successor(20); // -> 50 (smallest key strictly greater)
|
|
241
|
+
sl.predecessor(80); // -> 50 (largest key strictly less)
|
|
242
|
+
[...sl.rangeIter(20, 60)]; // -> [20, 50] (keys in [lo, hi], ascending)
|
|
243
|
+
sl.delete(50); // -> true (idempotent: false if absent)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| Member | Signature | Complexity | Notes |
|
|
247
|
+
| --- | --- | --- | --- |
|
|
248
|
+
| constructor | `new SkipList(capacity, seed?)` | O(capacity) | `capacity` integer in `[1, 2^26-1]` (slot indices are `Uint32`, `NIL = 0` reserves slot 0, the `MAXLEVEL`-column stride must stay addressable); `seed` an unsigned 32-bit integer (default fixed). Allocates the typed-array columns + private pool once. |
|
|
249
|
+
| `get` | `get(key) -> number \| undefined` | expected O(log n) | The value under `key`, or `undefined` if absent (no throw). Non-finite key throws. |
|
|
250
|
+
| `set` | `set(key, value) -> this` | expected O(log n) | Insert `key -> value`, or update the value in place if `key` exists. Non-finite key/value throws; a full pool throws. |
|
|
251
|
+
| `delete` | `delete(key) -> boolean` | expected O(log n) | Idempotent: `false` if absent, `true` if removed. Non-finite key throws. |
|
|
252
|
+
| `successor` | `successor(key) -> number \| undefined` | expected O(log n) | The smallest key STRICTLY greater than `key`, or `undefined`. `key` need not be present. |
|
|
253
|
+
| `predecessor` | `predecessor(key) -> number \| undefined` | expected O(log n) | The largest key STRICTLY less than `key`, or `undefined`. `key` need not be present. |
|
|
254
|
+
| `rangeIter` | `rangeIter(lo, hi) -> IterableIterator<number>` | O(log n + k) | Version-stamped iterator over keys in `[lo, hi]` INCLUSIVE, ascending. Bounds may be `+-Infinity` (unbounded ends); `NaN` or `lo > hi` throws; a structural mutation mid-iteration throws. |
|
|
255
|
+
| `forEach` | `forEach(fn) -> void` | O(n) | Visits `(key, value, list)` in ascending key order. |
|
|
256
|
+
| `clear` | `clear() -> this` | O(capacity) | Empties the list, keeps capacity, resets the PRNG to its initial seed. |
|
|
257
|
+
| `size` / `capacity` | getters | O(1) | Live entry count / fixed capacity. |
|
|
258
|
+
|
|
224
259
|
Member signatures for later members are appended here as each ships.
|
|
225
260
|
|
|
226
261
|
## Zero-GC design notes
|
|
@@ -239,8 +274,12 @@ Member signatures for later members are appended here as each ships.
|
|
|
239
274
|
| `SegmentTree` query / update / at | 0 B/op |
|
|
240
275
|
| `SegmentTree` constructor / `build` / `clear` | O(length) typed array (`2n` cells), once (cold) |
|
|
241
276
|
| `SegmentTree` forEach | 0 B/op in the loop body (pass a hoisted callback) |
|
|
277
|
+
| `SkipList` get / set / delete / successor / predecessor | 0 B/op (links are slot indices from a private free-list, never heap objects) |
|
|
278
|
+
| `SkipList` constructor / `clear` | O(capacity) typed arrays + pool, once (cold) |
|
|
279
|
+
| `SkipList` forEach | 0 B/op in the loop body (pass a hoisted callback) |
|
|
280
|
+
| `SkipList` rangeIter | one iterator + `{value, done}` per step (the documented per-protocol allocator; transient, not retained) |
|
|
242
281
|
|
|
243
|
-
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]`). The allocation table is extended per member as each lands.
|
|
282
|
+
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]`). The allocation table is extended per member as each lands.
|
|
244
283
|
|
|
245
284
|
## Testing
|
|
246
285
|
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-logn
|
|
2
2
|
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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.
|
|
@@ -18,8 +18,8 @@ It is the O(log n) sibling of @zakkster/lite-o1: lite-o1 holds the constant (a
|
|
|
18
18
|
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
|
-
v0.1.0 shipped BinaryHeap; v0.2.0 adds Fenwick; v0.3.0 adds SegmentTree.
|
|
22
|
-
roster (one member per session, each landing append-only):
|
|
21
|
+
v0.1.0 shipped BinaryHeap; v0.2.0 adds Fenwick; v0.3.0 adds SegmentTree; v0.4.0
|
|
22
|
+
adds SkipList. The roster (one member per session, each landing append-only):
|
|
23
23
|
|
|
24
24
|
- BinaryHeap (v0.1.0) -- an INDEXED binary heap (addressable priority queue): a
|
|
25
25
|
min|max binary heap over three parallel typed arrays (`_key` Float64Array,
|
|
@@ -38,9 +38,15 @@ roster (one member per session, each landing append-only):
|
|
|
38
38
|
construction (ctor-cached small-int `_k` combined by an inline switch). The
|
|
39
39
|
complement to Fenwick: Fenwick's rangeSum needs an INVERSE (subtraction), so it
|
|
40
40
|
is sum-only; SegmentTree folds any associative + commutative op over a range.
|
|
41
|
-
- SkipList (v0.4.0) --
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
- SkipList (v0.4.0) -- a pointer-free ordered map (key -> value): get / set /
|
|
42
|
+
delete / successor / predecessor / rangeIter, EXPECTED O(log n) via a
|
|
43
|
+
probabilistic tower of forward links stored as slot INDICES in flat Uint32Array
|
|
44
|
+
columns (`NIL = 0`, slot 0 the head sentinel) over a PRIVATE free-list (NodePool,
|
|
45
|
+
design-parity with lite-o1's private pools, NOT a runtime dep). Level generation
|
|
46
|
+
is one NR-LCG step, HIGH bits -> geometric height (`1 + clz32`), instance-local
|
|
47
|
+
seed, deterministic. set updates an existing key's value in place. EXPECTED (not
|
|
48
|
+
worst-case) O(log n): the witness fits the average line AND prints the MAX single
|
|
49
|
+
insert. Zero allocation on every hot op.
|
|
44
50
|
|
|
45
51
|
## Exports (from the single main file LogN.js)
|
|
46
52
|
|
|
@@ -112,6 +118,29 @@ roster (one member per session, each landing append-only):
|
|
|
112
118
|
incremental updates); fails closed on a non-array-like, any non-finite value,
|
|
113
119
|
or (gcd) any negative / non-integer.
|
|
114
120
|
|
|
121
|
+
- `SkipList` -- class. A pointer-free ordered map (key -> value) whose get / set /
|
|
122
|
+
delete / successor / predecessor are EXPECTED O(log n) via a probabilistic tower
|
|
123
|
+
of forward links stored as slot INDICES in a single flat `Uint32Array` of
|
|
124
|
+
`columns * (capacity + 1)` cells (`NIL = 0`, slot 0 the head sentinel) over a
|
|
125
|
+
PRIVATE free-list (NodePool) -- no heap object per op. Keys are finite numbers
|
|
126
|
+
(typeof-guarded before coercion; Symbol / BigInt / NaN / +-Infinity fail closed);
|
|
127
|
+
values are finite numbers. EXPECTED, not worst-case (an unlucky seed can spike one
|
|
128
|
+
op; the witness prints the MAX single insert).
|
|
129
|
+
- `new SkipList(capacity, seed?)` -- capacity an integer in [1, 2^26-1] (slot
|
|
130
|
+
indices are Uint32, `NIL = 0` reserves slot 0); seed an unsigned 32-bit integer
|
|
131
|
+
(default fixed). Level columns sized to ceil(log2 cap)+1 up front (never grown).
|
|
132
|
+
- `get(key)` -> value | undefined. Absent -> undefined (no throw).
|
|
133
|
+
- `set(key, value)` -> this. Insert, or update the value in place if key exists
|
|
134
|
+
(no new node). Non-finite key/value throws; a full pool throws.
|
|
135
|
+
- `delete(key)` -> boolean. Idempotent: false if absent, true if removed.
|
|
136
|
+
- `successor(key)` -> key | undefined. Smallest key STRICTLY greater than key.
|
|
137
|
+
- `predecessor(key)` -> key | undefined. Largest key STRICTLY less than key.
|
|
138
|
+
- `rangeIter(lo, hi)` -> iterator of keys in [lo, hi] INCLUSIVE, ascending;
|
|
139
|
+
VERSION-STAMPED (mutation mid-iteration throws). Bounds may be +-Infinity;
|
|
140
|
+
NaN or lo > hi throws.
|
|
141
|
+
- `size` / `capacity` getters; `clear()` -> this (empty, keep capacity, reset the
|
|
142
|
+
PRNG to its initial seed); `forEach(fn)` visits (key, value, list) ascending.
|
|
143
|
+
|
|
115
144
|
Member exports (one tree-shakeable class each) are appended here as each member
|
|
116
145
|
ships.
|
|
117
146
|
|
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
|
-
"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), and SkipList (pointer-free expected-O(log n) ordered map
|
|
4
|
+
"version": "0.4.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), and SkipList (pointer-free expected-O(log n) ordered map over a private free-list node pool) 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",
|