@zakkster/lite-logn 0.6.0 → 0.7.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,58 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] - 2026-09-20
10
+
11
+ ### Added
12
+
13
+ - **MinMaxHeap** -- the seventh member and the family's DOUBLE-ENDED priority queue
14
+ (DEPQ): a single array-embedded binary heap whose levels ALTERNATE min / max (Atkinson,
15
+ Sack, Santoro & Strothotte 1986). Even depth (the root is depth 0) is a MIN level, odd
16
+ depth a MAX level, so the global minimum sits at the root and the global maximum is the
17
+ LARGER of the root's up-to-two children. `peekMin` / `peekMax` / `peekMinKey` /
18
+ `peekMaxKey` are O(1); `push` / `popMin` / `popMax` are all WORST-case O(log n) from that
19
+ ONE heap (no second heap, no paired-heap correspondence). Surface: `push(id, key)` /
20
+ `popMin` / `popMax` / `peekMin` / `peekMax` / `peekMinKey` / `peekMaxKey` / `clear` /
21
+ `forEach` / `[Symbol.iterator]`, and `size` / `capacity` getters, plus a Floyd O(n)
22
+ `MinMaxHeap.build(ids, keys, capacity)` (deepest-first, level-aware sift-down). Keys are
23
+ finite numbers (typeof-guarded before coercion -- Symbol / BigInt / NaN / +-Infinity fail
24
+ closed with a `[lite-logn]` throw, key checked FIRST, then the id, then a full heap).
25
+ - **id + key payload, NON-addressable (the asymmetry vs BinaryHeap).** Two parallel
26
+ pointer-free typed-array columns (`_id` Uint32Array + `_key` Float64Array), the BinaryHeap
27
+ id+key idiom -- but with NO `_pos` reverse map, and therefore deliberately NO `changeKey` /
28
+ `remove`. The id is an OPAQUE Uint32 payload (NOT unique; the full [0, 2^32) domain, wider
29
+ than BinaryHeap's [0, capacity)). A DEPQ's job is the two extremes; addressability is the
30
+ separable concern BinaryHeap already carries. There is also NO `kind` argument / getter (a
31
+ DEPQ has both ends; a kind getter would be a lie).
32
+ - **Classic one-element-per-node min-max heap only.** The interval-heap DEPQ (two elements
33
+ per node) is a deliberately deferred alternative -- named, never silently omitted (see
34
+ `decisions/0009-minmaxheap.md` and "What this is not").
35
+ - **Level parity, computed zero-alloc.** Slot i is a MIN level iff
36
+ `((31 - Math.clz32(i + 1)) & 1) === 0`. The sifts are hole-punching (one write per level,
37
+ only local scalar temporaries): `push` compares to the parent to pick the own-level vs
38
+ other-level chain then bubbles by grandparents; `popMin` / `popMax` trickle down over the
39
+ up-to-six descendants (children + grandchildren), bound-checking every grandchild index
40
+ against the live size (the classic min-max off-by-one, verified at n = 1, 2, 3, 4).
41
+
42
+ ### Verified
43
+
44
+ - **Witness (D-M5).** `MinMaxHeap.popMin` is the gated O(log n) witness op (the full-height
45
+ level-aware trickle-down). It inherits the FROZEN family R^2 floor 0.958 and calibrates its
46
+ OWN slope band by the shared ADR-0004 method: median-of-15 popMin fit-runs = 10.30 ns/level
47
+ (runs spanned 9.38..10.59, R^2 0.9897..0.9991), band = median x `[0.6, 1.4]` = `[6.18, 14.42]`.
48
+ A DEPQ whose push / popMin / popMax are ALL worst-case, so there is NO max-single-op line.
49
+ The O(n) foil is a linear min-scan-and-splice extract-min (R^2 ~ 0.77, OFF the line).
50
+ - **Zero-GC.** `node --expose-gc test/torture.mjs` -- 0 B/op on the popMin / popMax / mixed
51
+ push+popMin+popMax churn and the peek read lanes, gc major = 0, the deliberately-allocating
52
+ control lane still non-zero (teeth), arrayBuffers do not grow across fill/clear soak cycles.
53
+ `npm run test:perf` -- 0 scavenges + `grows === 0` on every MinMaxHeap scenario.
54
+
55
+ ### Notes
56
+
57
+ - `LogN.js` gains `MMH_MAX_CAPACITY` (`0x7FFFFFFF`) + the `MinMaxHeap` class, appended after
58
+ Scapegoat; the prior six classes stay BYTE-IDENTICAL (only the `VERSION` const changes above
59
+ the append point). Version bumped to 0.7.0 across `package.json`, `LogN.js`, and `llms.txt`.
60
+
9
61
  ## [0.6.0] - 2026-09-20
10
62
 
11
63
  ### Added
@@ -312,7 +364,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
312
364
  [`decisions/0003-pack.md`](./decisions/0003-pack.md) (D-07: `files[]` ships the
313
365
  six files only; `test/`, `benchmark/`, `decisions/`, `demo/` are repo-only).
314
366
 
315
- [Unreleased]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.4.0...HEAD
367
+ [Unreleased]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.7.0...HEAD
368
+ [0.7.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.6.0...v0.7.0
316
369
  [0.4.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.3.0...v0.4.0
317
370
  [0.3.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.2.0...v0.3.0
318
371
  [0.2.0]: https://github.com/PeshoVurtoleta/lite-logn/compare/v0.1.0...v0.2.0
package/LogN.d.ts CHANGED
@@ -280,3 +280,52 @@ export class Scapegoat {
280
280
  /** Empty the tree, keeping capacity. */
281
281
  clear(): this;
282
282
  }
283
+
284
+ /**
285
+ * A min-max heap: a DOUBLE-ENDED priority queue (DEPQ) in ONE array-embedded binary heap
286
+ * whose levels ALTERNATE min / max (Atkinson et al. 1986). The minimum is the root; the
287
+ * maximum is the larger of the root's up-to-two children. peekMin / peekMax are O(1);
288
+ * push / popMin / popMax are O(log n) WORST-case. Two parallel typed-array columns (_key
289
+ * Float64, _id Uint32); the id is an OPAQUE Uint32 payload in [0, 2^32) (not unique, no
290
+ * reverse map), so there is deliberately NO changeKey / remove (the asymmetry vs
291
+ * BinaryHeap). Keys are finite numbers (typeof-guarded before coercion; Symbol / BigInt /
292
+ * NaN / +-Infinity fail closed). Fixed capacity: a full heap throws. Every hot op
293
+ * allocates zero bytes.
294
+ */
295
+ export class MinMaxHeap {
296
+ /** @param capacity exact max live entries; integer in [1, 2^31-1]. */
297
+ constructor(capacity: number);
298
+
299
+ /** Live entry count. */
300
+ readonly size: number;
301
+ /** The fixed capacity this heap was sized for. */
302
+ readonly capacity: number;
303
+
304
+ /** Insert id with priority key. Throws on non-finite key, out-of-range id, or a full heap. */
305
+ push(id: number, key: number): void;
306
+ /** Remove and return the id at the minimum key, or undefined if empty. */
307
+ popMin(): number | undefined;
308
+ /** Remove and return the id at the maximum key, or undefined if empty. */
309
+ popMax(): number | undefined;
310
+ /** The id at the minimum key, or undefined if empty. */
311
+ peekMin(): number | undefined;
312
+ /** The id at the maximum key, or undefined if empty. */
313
+ peekMax(): number | undefined;
314
+ /** The minimum key, or undefined if empty. */
315
+ peekMinKey(): number | undefined;
316
+ /** The maximum key, or undefined if empty. */
317
+ peekMaxKey(): number | undefined;
318
+ /** Empty the heap, keeping capacity. */
319
+ clear(): void;
320
+ /** Visit every live (id, key) pair in unspecified (heap-array) order. */
321
+ forEach(fn: (id: number, key: number, heap: MinMaxHeap) => void): void;
322
+ /** Iterate live entity ids in unspecified (heap-array) order (NOT sorted). */
323
+ [Symbol.iterator](): IterableIterator<number>;
324
+
325
+ /** Floyd O(n) bulk build from parallel ids/keys arrays (level-aware sift-down). */
326
+ static build(
327
+ ids: ArrayLike<number>,
328
+ keys: ArrayLike<number>,
329
+ capacity: number,
330
+ ): MinMaxHeap;
331
+ }
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.6.0';
42
+ export const VERSION = '0.7.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)
@@ -2496,3 +2496,379 @@ export class Scapegoat {
2496
2496
  throw new RangeError('[lite-logn] Scapegoat full (capacity ' + this._cap + ')');
2497
2497
  }
2498
2498
  }
2499
+
2500
+ // MinMaxHeap (v0.7.0 session) -- a DEPQ (double-ended priority queue), array-embedded min-max heap (BELOW).
2501
+
2502
+ /**
2503
+ * Max MinMaxHeap capacity: slot indices 0..cap-1 index the two parallel, pointer-free
2504
+ * typed-array columns (`_key` Float64Array, `_id` Uint32Array). Children of slot i are
2505
+ * `2i+1` / `2i+2` and grandchildren `4i+3 .. 4i+6`, so the deepest index arithmetic a
2506
+ * hot op performs is `4*i + 6`; capping capacity at 2^31-1 keeps every derived index a
2507
+ * positive int32. The index arithmetic, not the byte count, is the hard ceiling -- the
2508
+ * same "the arithmetic caps it" reasoning as BinaryHeap's BH_MAX_CAPACITY.
2509
+ */
2510
+ const MMH_MAX_CAPACITY = 0x7FFFFFFF; // 2^31 - 1
2511
+
2512
+ /**
2513
+ * A MIN-MAX HEAP: a DOUBLE-ENDED priority queue (DEPQ) held in ONE array-embedded binary
2514
+ * heap whose levels ALTERNATE min / max (Atkinson, Sack, Santoro & Strothotte 1986). Even
2515
+ * depth (the root is depth 0) is a MIN level, odd depth is a MAX level, so the global
2516
+ * minimum sits at the root and the global maximum is the LARGER of the root's up-to-two
2517
+ * children. That single alternating heap answers BOTH ends: `peekMin` / `peekMax` are
2518
+ * O(1); `push` / `popMin` / `popMax` are O(log n) WORST-case. It is the family's DEPQ --
2519
+ * where BinaryHeap fixes one extreme at construction, a min-max heap serves both from one
2520
+ * structure without a second heap or a paired-heap correspondence to maintain.
2521
+ *
2522
+ * Storage (allocated once, sized to capacity), the BinaryHeap id+key idiom:
2523
+ * - `_key` Float64Array -- the priority key at each heap SLOT (min-max-ordered).
2524
+ * - `_id` Uint32Array -- the opaque payload id at each heap SLOT (moves with its key).
2525
+ * There is NO reverse-index map (`_pos`) and so NO addressable `changeKey` / `remove`: the
2526
+ * id is an OPAQUE Uint32 payload, NOT a unique handle -- duplicate ids are allowed, and the
2527
+ * id domain is the full Uint32 range [0, 2^32) (a wider domain than BinaryHeap's [0,
2528
+ * capacity), which only that member's `_pos` map constrains). See decisions/0009.
2529
+ *
2530
+ * Level parity is computed zero-alloc: slot i (0-based) is a MIN level iff
2531
+ * `((31 - Math.clz32(i + 1)) & 1) === 0` (the depth `31 - clz32(i+1)` is even). The sift
2532
+ * is HOLE-PUNCHING (not a 3-write swap chain): the moving element is cached in locals once
2533
+ * and the hole walks writing ONE slot per level.
2534
+ * - push: append at the tail, compare the new element to its PARENT to pick the own-level
2535
+ * vs other-level chain, then bubble by GRANDPARENT comparisons up the min-or-max chain.
2536
+ * - popMin / popMax: open a hole at the root (min) or at the max-of-{slot1,slot2} (max),
2537
+ * move the last element into it, and trickle DOWN over CHILDREN + GRANDCHILDREN (a min
2538
+ * level sinks toward the smallest of the up-to-six descendants, a max level toward the
2539
+ * largest); a GRANDCHILD move does the extra parent re-check that keeps the alternating
2540
+ * order intact. Every one of the four grandchild indices is bound-checked against the
2541
+ * live size (the classic min-max off-by-one). Zero bytes allocated after construction.
2542
+ *
2543
+ * Keys are FINITE numbers (typeof-guarded BEFORE coercion -- Symbol / BigInt / NaN /
2544
+ * +-Infinity fail closed with a `[lite-logn]` throw); ids are integers in [0, 2^32). A key
2545
+ * guard fires FIRST, then the id guard, then the full-heap guard -- any throw leaves `size`
2546
+ * unchanged. Every peek / pop on an EMPTY heap returns `undefined` and NEVER throws. Fixed
2547
+ * capacity: overflow throws, never silently drops.
2548
+ *
2549
+ * This is the classic ONE-element-per-node min-max heap ONLY; the interval-heap DEPQ (two
2550
+ * elements per node) is a deliberately deferred alternative -- see decisions/0009 + NOT FOR.
2551
+ */
2552
+ export class MinMaxHeap {
2553
+ /**
2554
+ * @param {number} capacity exact max live entries; integer in [1, 2^31-1].
2555
+ */
2556
+ constructor(capacity) {
2557
+ // typeof guard BEFORE coercion (Number.isInteger is Symbol/BigInt-safe).
2558
+ if (typeof capacity !== 'number' || !Number.isInteger(capacity) ||
2559
+ capacity < 1 || capacity > MMH_MAX_CAPACITY) {
2560
+ throw new RangeError(
2561
+ '[lite-logn] MinMaxHeap capacity must be an integer in [1, 2^31-1], got ' +
2562
+ String(capacity));
2563
+ }
2564
+ this._key = new Float64Array(capacity); // key at each heap slot
2565
+ this._id = new Uint32Array(capacity); // opaque payload id at each heap slot
2566
+ this._cap = capacity; // exact fixed capacity
2567
+ this._n = 0; // live entries (heap size)
2568
+ }
2569
+
2570
+ /** Live entry count. O(1). */
2571
+ get size() { return this._n; }
2572
+
2573
+ /** The fixed capacity this heap was sized for. O(1). */
2574
+ get capacity() { return this._cap; }
2575
+
2576
+ /**
2577
+ * Insert entity `id` with priority `key`. O(log n). Fails closed: a non-finite /
2578
+ * non-number key (checked FIRST), a non-integer / out-of-range id, or a full heap each
2579
+ * throw `[lite-logn]` as a no-op (size unchanged).
2580
+ * @param {number} id integer in [0, 2^32), an OPAQUE payload (not required unique)
2581
+ * @param {number} key a finite number
2582
+ */
2583
+ push(id, key) {
2584
+ if (typeof key !== 'number' || !Number.isFinite(key)) return this._badKey(key);
2585
+ if (typeof id !== 'number' || !Number.isInteger(id) || id < 0 || id > 0xFFFFFFFF) {
2586
+ return this._badId(id);
2587
+ }
2588
+ const n = this._n;
2589
+ if (n === this._cap) return this._full();
2590
+ this._n = n + 1;
2591
+ this._siftUp(n, key, id);
2592
+ }
2593
+
2594
+ /**
2595
+ * Remove and return the id at the MINIMUM key (the root), or `undefined` if empty
2596
+ * (never throws on empty). O(log n).
2597
+ * @returns {number|undefined}
2598
+ */
2599
+ popMin() {
2600
+ const n = this._n;
2601
+ if (n === 0) return undefined;
2602
+ const top = this._id[0];
2603
+ const last = n - 1;
2604
+ this._n = last;
2605
+ if (last > 0) this._siftDownMin(0, this._key[last], this._id[last]);
2606
+ return top;
2607
+ }
2608
+
2609
+ /**
2610
+ * Remove and return the id at the MAXIMUM key (the larger of the root's up-to-two
2611
+ * children, or the root itself when the heap holds one element), or `undefined` if
2612
+ * empty (never throws on empty). O(log n).
2613
+ * @returns {number|undefined}
2614
+ */
2615
+ popMax() {
2616
+ const n = this._n;
2617
+ if (n === 0) return undefined;
2618
+ if (n === 1) { this._n = 0; return this._id[0]; }
2619
+ const K = this._key;
2620
+ // Max is at slot 1, unless slot 2 exists (n > 2) and holds a larger key.
2621
+ let mi = 1;
2622
+ if (n > 2 && K[2] > K[1]) mi = 2;
2623
+ const top = this._id[mi];
2624
+ const last = n - 1;
2625
+ this._n = last;
2626
+ // If the max WAS the last element, dropping the tail already removed it.
2627
+ if (mi !== last) this._siftDownMax(mi, K[last], this._id[last]);
2628
+ return top;
2629
+ }
2630
+
2631
+ /** The id at the minimum key (root), or `undefined` if empty. Read-only. O(1). */
2632
+ peekMin() { return this._n === 0 ? undefined : this._id[0]; }
2633
+
2634
+ /** The minimum key (root), or `undefined` if empty. O(1). */
2635
+ peekMinKey() { return this._n === 0 ? undefined : this._key[0]; }
2636
+
2637
+ /** The id at the maximum key, or `undefined` if empty. Read-only. O(1). */
2638
+ peekMax() {
2639
+ const n = this._n;
2640
+ if (n === 0) return undefined;
2641
+ if (n === 1) return this._id[0];
2642
+ const K = this._key;
2643
+ return (n > 2 && K[2] > K[1]) ? this._id[2] : this._id[1];
2644
+ }
2645
+
2646
+ /** The maximum key, or `undefined` if empty. O(1). */
2647
+ peekMaxKey() {
2648
+ const n = this._n;
2649
+ if (n === 0) return undefined;
2650
+ if (n === 1) return this._key[0];
2651
+ const K = this._key;
2652
+ return (n > 2 && K[2] > K[1]) ? K[2] : K[1];
2653
+ }
2654
+
2655
+ /** Empty the heap. O(1) (resets size; the columns are kept). */
2656
+ clear() { this._n = 0; }
2657
+
2658
+ /**
2659
+ * Visit every live (id, key) pair in UNSPECIFIED (heap-array) order -- NOT sorted /
2660
+ * not pop order. O(n) cold scan, allocation-free (pass a hoisted callback).
2661
+ * @param {(id:number, key:number, heap:MinMaxHeap)=>void} fn
2662
+ */
2663
+ forEach(fn) {
2664
+ const id = this._id, key = this._key, n = this._n;
2665
+ for (let i = 0; i < n; i++) fn(id[i], key[i], this);
2666
+ }
2667
+
2668
+ /**
2669
+ * Iterate live entity ids in UNSPECIFIED (heap-array) order -- NOT sorted. The one
2670
+ * documented per-protocol allocator (a {value, done} per step); use forEach for the
2671
+ * alloc-free scan.
2672
+ */
2673
+ *[Symbol.iterator]() {
2674
+ const id = this._id, n = this._n;
2675
+ for (let i = 0; i < n; i++) yield id[i];
2676
+ }
2677
+
2678
+ /**
2679
+ * Floyd O(n) bulk build: load every (ids[i], keys[i]) pair then heapify bottom-up in
2680
+ * O(n) (deepest-first, level-aware sift-down), rather than n individual O(log n)
2681
+ * pushes. COLD path; fails closed on any violation (non-array-like / length mismatch,
2682
+ * count > capacity, non-integer / out-of-range id, non-finite key) before use.
2683
+ * @param {ArrayLike<number>} ids integers in [0, 2^32) (not required unique)
2684
+ * @param {ArrayLike<number>} keys finite numbers, keys.length === ids.length
2685
+ * @param {number} capacity
2686
+ * @returns {MinMaxHeap}
2687
+ */
2688
+ static build(ids, keys, capacity) {
2689
+ const heap = new MinMaxHeap(capacity);
2690
+ if (ids == null || keys == null ||
2691
+ typeof ids.length !== 'number' || typeof keys.length !== 'number') {
2692
+ throw new TypeError('[lite-logn] MinMaxHeap.build needs array-like ids and keys');
2693
+ }
2694
+ const count = ids.length;
2695
+ if (keys.length !== count) {
2696
+ throw new RangeError(
2697
+ '[lite-logn] MinMaxHeap.build ids/keys length mismatch (' +
2698
+ count + ' vs ' + keys.length + ')');
2699
+ }
2700
+ if (count > capacity) {
2701
+ throw new RangeError(
2702
+ '[lite-logn] MinMaxHeap.build count ' + count + ' exceeds capacity ' + capacity);
2703
+ }
2704
+ const K = heap._key, I = heap._id;
2705
+ for (let i = 0; i < count; i++) {
2706
+ const id = ids[i];
2707
+ if (typeof id !== 'number' || !Number.isInteger(id) || id < 0 || id > 0xFFFFFFFF) {
2708
+ throw new RangeError(
2709
+ '[lite-logn] MinMaxHeap.build id must be an integer in [0, 2^32), got ' +
2710
+ String(id));
2711
+ }
2712
+ const key = keys[i];
2713
+ if (typeof key !== 'number' || !Number.isFinite(key)) {
2714
+ throw new TypeError(
2715
+ '[lite-logn] MinMaxHeap.build key must be a finite number, got ' + String(key));
2716
+ }
2717
+ K[i] = key;
2718
+ I[i] = id;
2719
+ }
2720
+ heap._n = count;
2721
+ // Floyd: sift down every internal node, deepest-first, level-aware. O(n).
2722
+ for (let i = (count >> 1) - 1; i >= 0; i--) {
2723
+ if (((31 - Math.clz32(i + 1)) & 1) === 0) heap._siftDownMin(i, K[i], I[i]);
2724
+ else heap._siftDownMax(i, K[i], I[i]);
2725
+ }
2726
+ return heap;
2727
+ }
2728
+
2729
+ // ---- private hole-punching sifts (hot bodies) --------------------------
2730
+
2731
+ /**
2732
+ * Sift UP from `hole` after an append: compare to the PARENT to decide whether the new
2733
+ * element belongs on its own level's chain or the other level's, then bubble it up by
2734
+ * GRANDPARENT comparisons. One write per level; zero temporaries.
2735
+ * @private
2736
+ */
2737
+ _siftUp(hole, key, id) {
2738
+ const K = this._key, I = this._id;
2739
+ if (hole === 0) { K[0] = key; I[0] = id; return; }
2740
+ const parent = (hole - 1) >> 1;
2741
+ // Depth parity of `hole`: MIN level iff (31 - clz32(hole+1)) is even.
2742
+ if (((31 - Math.clz32(hole + 1)) & 1) === 0) { // hole is on a MIN level
2743
+ if (key > K[parent]) { // parent is a MAX node: element belongs above it
2744
+ K[hole] = K[parent]; I[hole] = I[parent];
2745
+ this._bubbleUpMax(parent, key, id);
2746
+ } else {
2747
+ this._bubbleUpMin(hole, key, id);
2748
+ }
2749
+ } else { // hole is on a MAX level
2750
+ if (key < K[parent]) { // parent is a MIN node: element belongs above it
2751
+ K[hole] = K[parent]; I[hole] = I[parent];
2752
+ this._bubbleUpMin(parent, key, id);
2753
+ } else {
2754
+ this._bubbleUpMax(hole, key, id);
2755
+ }
2756
+ }
2757
+ }
2758
+
2759
+ /** Bubble a MIN-level hole up by grandparents while the element is smaller. @private */
2760
+ _bubbleUpMin(hole, key, id) {
2761
+ const K = this._key, I = this._id;
2762
+ while (hole > 2) { // has a grandparent (hole >= 3)
2763
+ const gp = (hole - 3) >> 2; // (((hole-1)>>1)-1)>>1
2764
+ if (key < K[gp]) { K[hole] = K[gp]; I[hole] = I[gp]; hole = gp; }
2765
+ else break;
2766
+ }
2767
+ K[hole] = key; I[hole] = id;
2768
+ }
2769
+
2770
+ /** Bubble a MAX-level hole up by grandparents while the element is larger. @private */
2771
+ _bubbleUpMax(hole, key, id) {
2772
+ const K = this._key, I = this._id;
2773
+ while (hole > 2) { // has a grandparent (hole >= 3)
2774
+ const gp = (hole - 3) >> 2; // (((hole-1)>>1)-1)>>1
2775
+ if (key > K[gp]) { K[hole] = K[gp]; I[hole] = I[gp]; hole = gp; }
2776
+ else break;
2777
+ }
2778
+ K[hole] = key; I[hole] = id;
2779
+ }
2780
+
2781
+ /**
2782
+ * Trickle a MIN-level hole DOWN toward the SMALLEST of its up-to-six descendants
2783
+ * (children `2h+1`/`2h+2`, grandchildren `4h+3..4h+6`). A grandchild move does the
2784
+ * extra max-parent re-check that keeps the alternating order. Every grandchild index
2785
+ * is bound-checked against the live size. One write per level; zero temporaries.
2786
+ * @private
2787
+ */
2788
+ _siftDownMin(hole, key, id) {
2789
+ const K = this._key, I = this._id, n = this._n;
2790
+ for (;;) {
2791
+ const c1 = (hole << 1) + 1;
2792
+ if (c1 >= n) break; // leaf: no children -> settle here
2793
+ const c2 = c1 + 1;
2794
+ let m = c1, mGrand = false; // smallest descendant so far
2795
+ if (c2 < n && K[c2] < K[m]) m = c2;
2796
+ const gEnd = (c2 << 1) + 2; // 4h+6, the last grandchild index
2797
+ for (let g = (c1 << 1) + 1; g < n && g <= gEnd; g++) { // grandchildren 4h+3..4h+6
2798
+ if (K[g] < K[m]) { m = g; mGrand = true; }
2799
+ }
2800
+ if (K[m] >= key) break; // element is <= every descendant -> settle
2801
+ if (!mGrand) { // smallest is a direct child (a leaf) -> place, done
2802
+ K[hole] = K[m]; I[hole] = I[m];
2803
+ hole = m;
2804
+ break;
2805
+ }
2806
+ // smallest is a grandchild: pull it up, then reconcile with its MAX-level parent.
2807
+ K[hole] = K[m]; I[hole] = I[m];
2808
+ const p = (m - 1) >> 1;
2809
+ if (key > K[p]) { // element too big under max parent p: settle it at p,
2810
+ const pk = K[p], pid = I[p]; // carry p's (smaller) value on down from m.
2811
+ K[p] = key; I[p] = id;
2812
+ key = pk; id = pid;
2813
+ }
2814
+ hole = m;
2815
+ }
2816
+ K[hole] = key; I[hole] = id;
2817
+ }
2818
+
2819
+ /**
2820
+ * Trickle a MAX-level hole DOWN toward the LARGEST of its up-to-six descendants. Mirror
2821
+ * of `_siftDownMin`: a grandchild move re-checks the MIN-level parent. Every grandchild
2822
+ * index is bound-checked. One write per level; zero temporaries.
2823
+ * @private
2824
+ */
2825
+ _siftDownMax(hole, key, id) {
2826
+ const K = this._key, I = this._id, n = this._n;
2827
+ for (;;) {
2828
+ const c1 = (hole << 1) + 1;
2829
+ if (c1 >= n) break; // leaf: no children -> settle here
2830
+ const c2 = c1 + 1;
2831
+ let m = c1, mGrand = false; // largest descendant so far
2832
+ if (c2 < n && K[c2] > K[m]) m = c2;
2833
+ const gEnd = (c2 << 1) + 2; // 4h+6, the last grandchild index
2834
+ for (let g = (c1 << 1) + 1; g < n && g <= gEnd; g++) { // grandchildren 4h+3..4h+6
2835
+ if (K[g] > K[m]) { m = g; mGrand = true; }
2836
+ }
2837
+ if (K[m] <= key) break; // element is >= every descendant -> settle
2838
+ if (!mGrand) { // largest is a direct child (a leaf) -> place, done
2839
+ K[hole] = K[m]; I[hole] = I[m];
2840
+ hole = m;
2841
+ break;
2842
+ }
2843
+ // largest is a grandchild: pull it up, then reconcile with its MIN-level parent.
2844
+ K[hole] = K[m]; I[hole] = I[m];
2845
+ const p = (m - 1) >> 1;
2846
+ if (key < K[p]) { // element too small under min parent p: settle it at p,
2847
+ const pk = K[p], pid = I[p]; // carry p's (larger) value on down from m.
2848
+ K[p] = key; I[p] = id;
2849
+ key = pk; id = pid;
2850
+ }
2851
+ hole = m;
2852
+ }
2853
+ K[hole] = key; I[hole] = id;
2854
+ }
2855
+
2856
+ // ---- cold path only: throw builders (string concat off the hot body) ---
2857
+
2858
+ /** @private */
2859
+ _badId(id) {
2860
+ throw new RangeError(
2861
+ '[lite-logn] MinMaxHeap id must be an integer in [0, 2^32), got ' + String(id));
2862
+ }
2863
+
2864
+ /** @private */
2865
+ _badKey(key) {
2866
+ throw new TypeError(
2867
+ '[lite-logn] MinMaxHeap key must be a finite number, got ' + String(key));
2868
+ }
2869
+
2870
+ /** @private */
2871
+ _full() {
2872
+ throw new RangeError('[lite-logn] MinMaxHeap full (capacity ' + this._cap + ')');
2873
+ }
2874
+ }
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.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.
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.7.0 ships seven 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), Scapegoat (a DETERMINISTIC weight-balanced augmented ordered map: worst-case-O(log n) get, amortized-O(log n) set / delete, zero-GC rebuild), and MinMaxHeap (an array-embedded double-ended priority queue: O(1) peekMin / peekMax, O(log n) push / popMin / popMax) -- 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.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.
22
+ **v0.7.0 ships seven members: BinaryHeap, Fenwick, SegmentTree, SkipList, Treap, Scapegoat and MinMaxHeap.** 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
@@ -62,6 +62,7 @@ Every hot op allocates zero bytes after construction, and `npm run witness` prov
62
62
  - [SkipList](#skiplist)
63
63
  - [Treap](#treap)
64
64
  - [Scapegoat](#scapegoat)
65
+ - [MinMaxHeap](#minmaxheap)
65
66
  - [Zero-GC design notes](#zero-gc-design-notes)
66
67
  - [Testing](#testing)
67
68
  - [What this is not](#what-this-is-not)
@@ -86,7 +87,7 @@ lite-logn ships the O(log n) structures that matter with the allocation removed
86
87
 
87
88
  ## The roster
88
89
 
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.
90
+ One member per session, each landing append-only (prior members stay byte-identical). At v0.7.0, BinaryHeap, Fenwick, SegmentTree, SkipList, Treap, Scapegoat and MinMaxHeap are shipped.
90
91
 
91
92
  | Member | Version | Status | Shape | Hot ops |
92
93
  | --- | --- | --- | --- | --- |
@@ -96,6 +97,7 @@ One member per session, each landing append-only (prior members stay byte-identi
96
97
  | **SkipList** | 0.4.0 | shipped | pointer-free over a private free-list node pool; expected O(log n) | `get` / `set` / `delete` / `successor` / `predecessor` |
97
98
  | **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
99
  | **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) |
100
+ | **MinMaxHeap** | 0.7.0 | shipped | array-embedded double-ended PQ (DEPQ): one binary heap whose levels alternate min/max, two flat columns (`_key`/`_id`) | `push` / `popMin` / `popMax` O(log n), `peekMin` / `peekMax` / `peekMinKey` / `peekMaxKey` O(1) (non-addressable: NO changeKey/remove) |
99
101
 
100
102
  Later tiers (OrderStatTree, IndexedHeap, SortedArray, MinMaxHeap, SplayTree, and presets) are queued in [`ROADMAP.md`](./ROADMAP.md).
101
103
 
@@ -107,7 +109,7 @@ The family anchor. Time a fixed batch of the hot op at each `n` in a geometric s
107
109
  - `slope` inside the member's band (the per-level cost, ns/level), AND
108
110
  - the FOIL leaves the line (low `R^2` -- the O(n) default a working programmer reaches for, shown losing as `n` grows).
109
111
 
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.
112
+ 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.7.0 the witness gates ten 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]`), Scapegoat `get` (R^2 ~ 0.99, slope ~ 4 ns/level, band `[2.41, 5.63]`), and MinMaxHeap `popMin` (R^2 ~ 0.99, slope ~ 10.3 ns/level, band `[6.18, 14.42]`) 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.
111
113
 
112
114
  ## Benchmarks
113
115
 
@@ -148,6 +150,7 @@ Each op fits `nsPerOp = intercept + slope*log2(n)`. ON-LINE = `R^2 >= 0.958` (th
148
150
  | `SkipList.set` | 0.985 | 12.1 | `[8.36, 19.50]` | ON | sorted-array insert | 0.77 | off |
149
151
  | `Treap.get` | 0.988 | 4.0 | `[2.55, 5.95]` | ON | linear scan | 0.79 | off |
150
152
  | `Scapegoat.get` | 0.988 | 3.8 | `[2.41, 5.63]` | ON | linear scan | 0.79 | off |
153
+ | `MinMaxHeap.popMin` | 0.99 | 10.3 | `[6.18, 14.42]` | ON | linear min-scan-and-splice | 0.77 | off |
151
154
 
152
155
  **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`.
153
156
 
@@ -183,8 +186,8 @@ All seven gated op-rows report **0 B/op** across the `n = 1e3..1e6` sweep, with
183
186
 
184
187
  - **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).
185
188
  - **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.
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).
189
+ - **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 + MinMaxHeap, `n/a` for the index-addressed Fenwick + SegmentTree.
190
+ - **D8 workloads** -- churn (all members) + an ordered scan (`successor` + `rangeIter`, SkipList + Treap + Scapegoat; `n/a` elsewhere, including MinMaxHeap -- a DEPQ, not an ordered map).
188
191
 
189
192
  ## API reference
190
193
 
@@ -415,6 +418,41 @@ sg.successor(20); // -> 50 (smallest key strictly greater)
415
418
 
416
419
  Member signatures for later members are appended here as each ships.
417
420
 
421
+ ### MinMaxHeap
422
+
423
+ A **min-max heap**: a **double-ended priority queue (DEPQ)** held in ONE array-embedded binary heap whose levels **alternate min / max** (Atkinson, Sack, Santoro & Strothotte 1986). Even depth (the root is depth 0) is a **MIN** level, odd depth a **MAX** level, so the global minimum is the root and the global maximum is the **larger of the root's up-to-two children**. That single alternating heap answers BOTH ends: `peekMin` / `peekMax` / `peekMinKey` / `peekMaxKey` are **O(1)**; `push` / `popMin` / `popMax` are all **worst-case O(log n)** -- no second heap, no paired-heap correspondence to maintain. It uses the BinaryHeap **id + key** idiom (two parallel pointer-free columns: `_id` `Uint32Array`, `_key` `Float64Array`), so it carries an opaque payload per entry with no object nodes. Keys are finite numbers (typeof-guarded before coercion; Symbol / BigInt / NaN / +-Infinity fail closed -- the key is checked FIRST, then the id, then a full heap). Every hot op allocates zero bytes after construction.
424
+
425
+ The asymmetry vs BinaryHeap: MinMaxHeap is **non-addressable**. There is no `_pos` reverse map and therefore deliberately **no `changeKey` / `remove`**; the id is an OPAQUE `Uint32` payload (NOT unique -- duplicates allowed -- over the full `[0, 2^32)` domain, wider than BinaryHeap's `[0, capacity)`). A DEPQ's job is the two extremes; addressability is the separable concern BinaryHeap already carries. There is also **no `kind` argument / getter** (a DEPQ has both ends; a kind getter would be a lie). This is the classic **one-element-per-node** min-max heap; the interval-heap DEPQ (two elements per node) is a deliberately deferred alternative (see [`decisions/0009-minmaxheap.md`](./decisions/0009-minmaxheap.md)). Level parity is computed zero-alloc as `((31 - Math.clz32(i + 1)) & 1) === 0` (min iff even depth); the sifts are hole-punching (one write per level) and every grandchild index is bound-checked against the live size (the classic min-max off-by-one, verified at n = 1, 2, 3, 4).
426
+
427
+ ```js
428
+ import { MinMaxHeap } from '@zakkster/lite-logn';
429
+
430
+ const h = new MinMaxHeap(1000); // capacity 1000 (no kind: a DEPQ serves both ends)
431
+ h.push(1, 5.0); // push id 1 with key 5.0
432
+ h.push(2, 1.0);
433
+ h.push(3, 9.0);
434
+ h.peekMinKey(); // -> 1.0 -- O(1)
435
+ h.peekMaxKey(); // -> 9.0 -- O(1)
436
+ h.popMin(); // -> 2 (the id at the minimum key) -- worst-case O(log n)
437
+ h.popMax(); // -> 3 (the id at the maximum key) -- worst-case O(log n)
438
+ ```
439
+
440
+ | Member | Signature | Complexity | Notes |
441
+ | --- | --- | --- | --- |
442
+ | constructor | `new MinMaxHeap(capacity)` | O(capacity) | `capacity` integer in `[1, 2^31-1]`. Allocates two typed arrays (`_key` `Float64`, `_id` `Uint32`) once. NO `kind` argument. |
443
+ | `push` | `push(id, key) -> void` | worst-case O(log n) | `id` integer in `[0, 2^32)` (opaque, not required unique); `key` finite. Key checked FIRST, then id, then a full heap -- each throws `[lite-logn]` as a no-op (size unchanged). |
444
+ | `popMin` | `popMin() -> number \| undefined` | worst-case O(log n) | Removes and returns the id at the minimum key; `undefined` if empty (no throw). |
445
+ | `popMax` | `popMax() -> number \| undefined` | worst-case O(log n) | Removes and returns the id at the maximum key; `undefined` if empty (no throw). |
446
+ | `peekMin` / `peekMax` | `-> number \| undefined` | O(1) | The id at the minimum / maximum key; `undefined` if empty (never throw). |
447
+ | `peekMinKey` / `peekMaxKey` | `-> number \| undefined` | O(1) | The minimum / maximum key; `undefined` if empty (never throw). |
448
+ | `clear` | `clear() -> void` | O(1) | Empties the heap, keeps capacity. |
449
+ | `forEach` | `forEach(fn) -> void` | O(n) | Visits `(id, key, heap)` in UNSPECIFIED (heap-array) order -- NOT sorted / pop order. |
450
+ | `[Symbol.iterator]` | `-> IterableIterator<number>` | O(n) | Yields live ids in UNSPECIFIED (heap-array) order. |
451
+ | `size` / `capacity` | getters | O(1) | Live entry count / fixed capacity. |
452
+ | `MinMaxHeap.build` | `build(ids, keys, capacity) -> MinMaxHeap` | O(n) | Floyd bulk build from parallel arrays (deepest-first, level-aware sift-down); fails closed on non-array-like / length mismatch, count > capacity, out-of-range id, or non-finite key. |
453
+
454
+ Member signatures for later members are appended here as each ships.
455
+
418
456
  ## Zero-GC design notes
419
457
 
420
458
  - **Array-embedded members allocate no nodes.** BinaryHeap, Fenwick, and SegmentTree live in flat typed arrays; there is no `new Node` per op, so there is nothing to collect. The parent / child / sibling relationships are index arithmetic (`2i+1`, `i & -i`), not pointers.
@@ -444,8 +482,11 @@ Member signatures for later members are appended here as each ships.
444
482
  | `Scapegoat` constructor / `clear` | O(capacity) five columns + pool + two rebuild scratch buffers, once (cold) |
445
483
  | `Scapegoat` forEach | 0 B/op in the loop body (recursive in-order walk, hoisted callback) |
446
484
  | `Scapegoat` rangeIter | one iterator + `{value, done}` per step (the documented per-protocol allocator; transient, not retained) |
485
+ | `MinMaxHeap` push / popMin / popMax / peekMin / peekMax / peekMinKey / peekMaxKey | 0 B/op (two flat columns; the hole-punching sifts use only local scalar temporaries) |
486
+ | `MinMaxHeap` constructor / `build` / `clear` | O(capacity) two typed arrays, once (cold) |
487
+ | `MinMaxHeap` forEach | 0 B/op in the loop body (pass a hoisted callback) |
447
488
 
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.
489
+ 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; MinMaxHeap `popMin` R^2 ~ 0.99, slope ~ 10.3 ns/level (band `[6.18, 14.42]`, sweep `[1e4, 1e6]`) -- WORST-case (a DEPQ whose push / popMin / popMax are all worst-case, so no MAX-single-op line), a touch ABOVE BinaryHeap.pop because a min-max trickle-down compares against up to six descendants per level. The allocation table is extended per member as each lands.
449
490
 
450
491
  ## Testing
451
492
 
@@ -463,6 +504,7 @@ Gated witness numbers (this machine, shared R^2 floor 0.958): BinaryHeap `pop` R
463
504
  - **Not a bounded-integer priority queue.** If your priorities are small bounded integers, a heap's O(log n) is the wrong tool -- use `@zakkster/lite-o1`'s `BucketQueue` (Dial, O(1)) or `@zakkster/lite-scheduler`'s `FastBitScheduler`. lite-logn's heap is the GENERAL comparator PQ at O(log n).
464
505
  - **Not an approximate-membership library.** Bloom / cuckoo / binary-fuse filters live in `@zakkster/lite-filter`. lite-logn owns exact ordered structures.
465
506
  - **Not a cache.** `@zakkster/lite-lru` uses ordering internally for eviction but is a cache, not an ordered-collection library.
507
+ - **Not an addressable double-ended queue.** MinMaxHeap is a DEPQ, but it is NON-addressable: its id is an opaque, non-unique payload (no reverse map), so it has no `changeKey` / `remove`. For an addressable single-ended priority queue (reprioritize / remove by entity id) use **BinaryHeap**. MinMaxHeap also ships the classic one-element-per-node min-max heap only -- the interval-heap DEPQ (two elements per node) is a deliberately deferred alternative (see [`decisions/0009-minmaxheap.md`](./decisions/0009-minmaxheap.md)).
466
508
  - **Not a grow-on-demand collection.** Capacity is fixed at construction and overflow fails closed.
467
509
 
468
510
  ## Ecosystem
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-logn
2
2
 
3
- Version: 0.6.0
3
+ Version: 0.7.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; v0.6.0 adds Scapegoat. The roster (one member per
23
- session, each landing append-only):
22
+ adds SkipList; v0.5.0 adds Treap; v0.6.0 adds Scapegoat; v0.7.0 adds MinMaxHeap. The
23
+ roster (one member per 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,
@@ -75,6 +75,19 @@ session, each landing append-only):
75
75
  be O(n) rebuilds) -- the documented asymmetry vs Treap. set updates an existing key's
76
76
  value in place. The amortized-trace witness shows cumulative insert cost/op tracks log n
77
77
  despite the rebuild spikes. Zero allocation on every hot op.
78
+ - MinMaxHeap (v0.7.0) -- a DOUBLE-ENDED priority queue (DEPQ) held in ONE array-embedded
79
+ binary heap whose levels ALTERNATE min / max (Atkinson et al. 1986). Even depth is a MIN
80
+ level, odd depth a MAX level, so the minimum is the root and the maximum is the LARGER of
81
+ the root's up-to-two children: peekMin / peekMax are O(1); push / popMin / popMax are all
82
+ WORST-case O(log n) from that single heap (no second heap, no paired-heap correspondence).
83
+ Two parallel typed-array columns (`_key` Float64, `_id` Uint32) in the BinaryHeap id+key
84
+ idiom; the id is an OPAQUE Uint32 payload in [0, 2^32) (NOT unique, no reverse map), so
85
+ there is deliberately NO changeKey / remove -- the documented asymmetry vs BinaryHeap.
86
+ Level parity is `((31 - Math.clz32(i + 1)) & 1) === 0` (min iff even depth), computed
87
+ zero-alloc; the sifts are hole-punching (one write per level). Keys are finite numbers
88
+ (typeof-guarded before coercion; Symbol / BigInt / NaN / +-Infinity fail closed). This is
89
+ the classic one-element-per-node min-max heap only; the interval-heap DEPQ is deferred.
90
+ Zero allocation on every hot op.
78
91
 
79
92
  ## Exports (from the single main file LogN.js)
80
93
 
@@ -238,6 +251,26 @@ session, each landing append-only):
238
251
  - There is deliberately NO `split` / `merge` (the documented asymmetry vs Treap: no
239
252
  priority heap to merge by; an honest deterministic split/merge would be O(n) rebuilds).
240
253
 
254
+ - `MinMaxHeap` -- class. A DOUBLE-ENDED priority queue (DEPQ) in ONE array-embedded binary
255
+ heap whose levels ALTERNATE min / max (Atkinson et al. 1986). The minimum is the root; the
256
+ maximum is the larger of the root's up-to-two children. Two parallel typed-array columns
257
+ (`_key` Float64, `_id` Uint32); the id is an OPAQUE Uint32 payload in [0, 2^32) (not unique,
258
+ no reverse map). Keys are finite numbers (typeof-guarded before coercion; Symbol / BigInt /
259
+ NaN / +-Infinity fail closed). Non-addressable: deliberately NO changeKey / remove.
260
+ - `new MinMaxHeap(capacity)` -- capacity an integer in [1, 2^31-1]. Allocates the two typed
261
+ arrays once. NO `kind` argument (a DEPQ serves both ends).
262
+ - `push(id, key)` -> void. id integer in [0, 2^32); key finite. The key is checked FIRST,
263
+ then the id, then a full heap; each throws `[lite-logn]` as a no-op (size unchanged).
264
+ - `popMin()` -> id | undefined. Removes the minimum; undefined if empty (no throw).
265
+ - `popMax()` -> id | undefined. Removes the maximum; undefined if empty (no throw).
266
+ - `peekMin()` / `peekMax()` -> id | undefined. `peekMinKey()` / `peekMaxKey()` -> key |
267
+ undefined. All O(1), read-only, undefined if empty (never throw).
268
+ - `size` / `capacity` getters; `clear()`; `forEach(fn)` and `[Symbol.iterator]()` yield
269
+ live ids in UNSPECIFIED (heap-array) order -- NOT sorted / pop order.
270
+ - `MinMaxHeap.build(ids, keys, capacity)` -> MinMaxHeap. Floyd O(n) bulk build from parallel
271
+ arrays (deepest-first, level-aware sift-down); fails closed on non-array-like / length
272
+ mismatch, count > capacity, out-of-range id, or non-finite key.
273
+
241
274
  Member exports (one tree-shakeable class each) are appended here as each member
242
275
  ships.
243
276
 
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.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.",
4
+ "version": "0.7.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), Scapegoat (DETERMINISTIC weight-balanced augmented ordered map: worst-case-O(log n) get, amortized-O(log n) set/delete, zero-GC rebuild), and MinMaxHeap (array-embedded double-ended priority queue: O(1) peekMin/peekMax, O(log n) push/popMin/popMax) 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",
@@ -39,6 +39,9 @@
39
39
  "binary-heap",
40
40
  "heap",
41
41
  "priority-queue",
42
+ "min-max-heap",
43
+ "double-ended-priority-queue",
44
+ "depq",
42
45
  "d-ary-heap",
43
46
  "fenwick",
44
47
  "bit",