@zakkster/lite-pick 0.9.0 → 1.0.1

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/Pick.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- zero-GC load-balancing SELECTION KERNEL.
3
3
  *
4
- * M9 (0.9.0): substrate seams + nine strategies -- RoundRobin, SmoothWRR, P2C, the exact
5
- * LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C), ConsistentHash
6
- * (a Maglev lookup table), and BoundedLoad (Consistent Hashing with Bounded Loads: the
7
- * Maglev table + an occupancy cap that overflows a hot backend). This file ships:
4
+ * M10 (1.0.0): substrate seams + TEN strategies (the roster-complete release) -- RoundRobin,
5
+ * SmoothWRR, P2C, the exact LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C),
6
+ * ConsistentHash (a Maglev lookup table), BoundedLoad (Consistent Hashing with Bounded Loads:
7
+ * the Maglev table + an occupancy cap that overflows a hot backend), and WeightedRandom (O(1)
8
+ * Vose alias-table sampling with rejection-sampling eligibility). This file ships:
8
9
  *
9
10
  * - VERSION the single source-of-truth version stamp (3-place sync).
10
11
  * - PICK_NONE the fail-closed sentinel (-1): "no endpoint", never a dead pick.
@@ -29,7 +30,9 @@
29
30
  * - NqBalancer never-queue (IPVS `nq`): an IDLE eligible endpoint immediately if one
30
31
  * exists, else SED. The worker-pool fit. O(cap)/pick, 0 B/op.
31
32
  * - PeakEwmaBalancer latency-aware P2C (Twitter Finagle's peak-EWMA): draws two distinct
32
- * eligible endpoints and takes the lower cost = (inflight+1) x decayed EWMA(rtt).
33
+ * eligible endpoints and takes the lower cost (three cases: idle-unsampled 0,
34
+ * busy-unsampled priced at the sample mean, sampled (inflight+1) x decayed EWMA
35
+ * floored by time-since-last-sample while busy -- see the class JSDoc).
33
36
  * Decay-on-READ (pick() never writes -> 0 B/op); the balancer OWNS the Float64
34
37
  * _ewma/_stamp state and is its SOLE writer via the warm recordRtt() feedback
35
38
  * path (also 0 B/op). Caller-supplied nanosecond clock. O(d)=O(1)/pick.
@@ -47,6 +50,16 @@
47
50
  * stickiness + minimal disruption PLUS the hotspot protection plain CH lacks. It
48
51
  * extends ConsistentHashBalancer (reusing its Maglev build + probe VERBATIM) and
49
52
  * OWNS a running `_total` (sole writer: the warm note(i, delta) seam). O(1), 0 B/op.
53
+ * - WeightedRandomBalancer O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (one
54
+ * column draw + one probability compare -> a candidate), with rejection-sampling
55
+ * eligibility (retry an ineligible candidate up to a bounded count, then a 0-B/op
56
+ * rotated linear eligible scan). The alias table is built COLD over the eligible-
57
+ * INDEPENDENT weights (a weight-0 node is NEVER a column), so rejection over the
58
+ * bitmap renormalizes the weight distribution across the SURVIVING eligible mass.
59
+ * The balancer OWNS its derived table (_prob/_alias) and is its SOLE writer via cold
60
+ * setWeight/rebuild (the SmoothWRR precedent); an eligibility flap never rebuilds.
61
+ * The stateless O(1) sample (no accumulators to desync) for VERY LARGE pools where
62
+ * SmoothWRR's O(cap) scan hurts. O(1), 0 B/op. (Vose 1991 / Walker alias method.)
50
63
  *
51
64
  * The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
52
65
  * It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
@@ -55,8 +68,11 @@
55
68
  *
56
69
  * Roster (one strategy per session -- see ROADMAP.md): RoundRobin [M1], SmoothWRR [M2],
57
70
  * P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA [M7], ConsistentHash [M8], BoundedLoad [M9],
58
- * WeightedRandom [planned]. The EXACT-O(log n) fewest-in-flight variant is a deferred
59
- * @zakkster/lite-logn BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan.
71
+ * WeightedRandom [M10] -- roster complete for now (NOT closed: AZ-aware routing, hedging, and
72
+ * subsetting are queued post-1.0). The EXACT-O(log n) fewest-in-flight variant is a deferred
73
+ * @zakkster/lite-logn BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan;
74
+ * a lite-logn Fenwick tree is the deferred DYNAMIC-weight complement to WeightedRandom's static
75
+ * alias table, and lite-o1 AliasTable a deferred duck-typed optional-peer upgrade for the build.
60
76
  *
61
77
  * M5 (0.5.0) adds the ergonomic request layer at the @zakkster/lite-pick/pool subpath (a
62
78
  * SEPARATE file, Pool.js -- the async dispatch/settle counter wrapper + distinct-endpoint
@@ -67,7 +83,7 @@
67
83
  */
68
84
 
69
85
  /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
70
- export const VERSION = '0.9.0';
86
+ export const VERSION = '1.0.1';
71
87
 
72
88
  /**
73
89
  * Fail-closed sentinel returned by pick() when no endpoint is eligible.
@@ -117,14 +133,33 @@ export class Prng {
117
133
  }
118
134
  }
119
135
 
136
+ /**
137
+ * Validate an endpoint index for a COLD/WARM mutator (never the hot pick path): it must be an
138
+ * in-range, non-negative INTEGER. `(i >>> 0) !== i` rejects NaN, fractions (1.5), negatives (-1),
139
+ * and non-numbers (a string like '2' coerces to a different value under `>>> 0`); `i >= cap`
140
+ * rejects out-of-range. Fails closed with the same RangeError style as the pre-existing range
141
+ * checks -- invalid input is an error, never a silent typed-array no-op that desyncs `_live`.
142
+ * @param {number} i
143
+ * @param {number} cap
144
+ */
145
+ function _vIdx(i, cap) {
146
+ if ((i >>> 0) !== i || i >= cap) {
147
+ throw new RangeError('[lite-pick] index out of range: ' + i);
148
+ }
149
+ }
150
+
120
151
  /**
121
152
  * BalancerBase -- the shared eligibility seam for every strategy.
122
153
  *
123
154
  * It owns ONLY: the fixed capacity, a reference to the caller/sibling-owned eligibility
124
155
  * Uint8Array (never copied), and an O(1) `_live` count maintained on the cold setEligible()
125
156
  * path so a strategy can fail closed in O(1). It never allocates after construction and
126
- * never calls into a health source -- writers mutate `eligible` at their own cadence; pick()
127
- * only reads it.
157
+ * never calls into a health source. The eligibility view is flipped ONLY through setEligible()
158
+ * (the sole supported writer, which keeps `_live` -- and SmoothWRR's eligible-weight total --
159
+ * exact); a health source / breaker drives that call. A direct `eligible[i]` write bypasses the
160
+ * cache and desyncs `_live` (fail-closed picks, wrong ratios) -- UB (1.0.1 contract). Each
161
+ * balancer needs its OWN eligibility array (a shared `Eligibility` object is deferred to 2.0).
162
+ * pick() only reads.
128
163
  *
129
164
  * Subclasses (M1+) implement pick(). BalancerBase.pick() throws, so an unfinished strategy
130
165
  * fails loudly rather than silently returning a dead index.
@@ -158,9 +193,10 @@ export class BalancerBase {
158
193
  return this._live;
159
194
  }
160
195
 
161
- /** True iff endpoint i is currently pickable. O(1), zero-alloc. */
196
+ /** True iff endpoint i is currently pickable. O(1), zero-alloc. A non-integer (1.5, NaN)
197
+ * is never pickable -> false (isEligible NEVER throws; it is a pure predicate). */
162
198
  isEligible(i) {
163
- return i >= 0 && i < this._cap && this._eligible[i] !== 0;
199
+ return (i >>> 0) === i && i < this._cap && this._eligible[i] !== 0;
164
200
  }
165
201
 
166
202
  /**
@@ -170,9 +206,7 @@ export class BalancerBase {
170
206
  * @param {boolean} up
171
207
  */
172
208
  setEligible(i, up) {
173
- if (i < 0 || i >= this._cap) {
174
- throw new RangeError('[lite-pick] index out of range: ' + i);
175
- }
209
+ _vIdx(i, this._cap);
176
210
  const was = this._eligible[i];
177
211
  const now = up ? 1 : 0;
178
212
  if (was !== now) {
@@ -308,12 +342,13 @@ export class SmoothWRRBalancer extends BalancerBase {
308
342
  * @param {number} w new weight (uint32)
309
343
  */
310
344
  setWeight(i, w) {
311
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
345
+ _vIdx(i, this._cap);
312
346
  const nw = w >>> 0;
313
347
  if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
314
348
  const old = this._weights[i];
315
349
  if (nw === old) return;
316
350
  this._weights[i] = nw;
351
+ this._current[i] = 0; // reset credit: a reweighted node holds no stale accumulator
317
352
  if (this._eligible[i]) this._totalEligibleWeight += nw - old;
318
353
  }
319
354
 
@@ -327,13 +362,15 @@ export class SmoothWRRBalancer extends BalancerBase {
327
362
  const cap = this._cap, el = this._eligible, wt = this._weights, cur = this._current;
328
363
  let best = -1, bestCur = -Infinity;
329
364
  for (let i = 0; i < cap; i++) {
330
- if (el[i]) {
365
+ if (el[i] && wt[i] > 0) { // eligible AND positive weight: a weight-0 node is never a candidate
331
366
  const c = cur[i] + wt[i];
332
367
  cur[i] = c;
333
368
  if (c > bestCur) { bestCur = c; best = i; }
334
369
  }
335
370
  }
336
- cur[best] -= total; // best >= 0 guaranteed while total > 0
371
+ // best >= 0 while total > 0 -- provided eligibility is written ONLY through setEligible (the
372
+ // 1.0.1 contract that keeps _totalEligibleWeight in lockstep); a direct eligible[] write desyncs it.
373
+ cur[best] -= total;
337
374
  return best;
338
375
  }
339
376
  }
@@ -437,7 +474,8 @@ export class P2cBalancer extends BalancerBase {
437
474
  * directly between picks; that is the whole point of the shared-counter seam.
438
475
  *
439
476
  * Bound: O(cap) per pick (one scan). Steady-state pick(): integer compares + one index write,
440
- * no object/closure/array created -- 0 B/op. Tie-break is the lowest index (deterministic);
477
+ * no object/closure/array created -- 0 B/op. Tie order is UNSPECIFIED in 1.0.1 (deterministic,
478
+ * but callers must not depend on which tied node wins; a rotating tie-break is planned for 1.1.0);
441
479
  * the feedback loop breaks a startup all-zero tie by raising the picked node's count. Fails
442
480
  * closed (PICK_NONE) when the whole pool is down.
443
481
  *
@@ -462,7 +500,7 @@ export class LeastConnBalancer extends BalancerBase {
462
500
 
463
501
  /**
464
502
  * The eligible endpoint with the fewest in-flight requests, or PICK_NONE (fail closed).
465
- * O(cap), zero-alloc. Lowest index on a tie.
503
+ * O(cap), zero-alloc. Tie order unspecified in 1.0.1 (deterministic; rotating tie-break 1.1.0).
466
504
  * @returns {number}
467
505
  */
468
506
  pick() {
@@ -519,7 +557,8 @@ export class SedBalancer extends BalancerBase {
519
557
 
520
558
  /**
521
559
  * The eligible endpoint minimizing (inflight + 1) / weight, or PICK_NONE (fail closed).
522
- * O(cap), zero-alloc. Lowest index on a tie; weight-0 nodes are not candidates.
560
+ * O(cap), zero-alloc. Tie order unspecified in 1.0.1 (deterministic; rotating tie-break 1.1.0);
561
+ * weight-0 nodes are not candidates.
523
562
  * @returns {number}
524
563
  */
525
564
  pick() {
@@ -548,8 +587,9 @@ export class SedBalancer extends BalancerBase {
548
587
  * case: spin up idle capacity first, only weigh expected delay once everyone is busy.
549
588
  *
550
589
  * Ownership (ADR 0001, ADR 0006): identical to SED -- caller-owned inflight + weights, read
551
- * live, no derived aggregate. The first idle eligible node (lowest index, in-flight 0, weight
552
- * > 0) short-circuits the scan.
590
+ * live, no derived aggregate. The first idle eligible node found in the scan (in-flight 0, weight
591
+ * > 0) short-circuits it; when several are idle the one returned is unspecified in 1.0.1
592
+ * (deterministic; a rotating tie-break is planned for 1.1.0).
553
593
  *
554
594
  * Bound: O(cap) worst case (no idle node -> a full SED scan); O(1) when a low-index endpoint is
555
595
  * idle. 0 B/op. Fails closed (PICK_NONE) when no eligible endpoint has a positive weight.
@@ -600,11 +640,25 @@ export class NqBalancer extends BalancerBase {
600
640
  * PeakEwmaBalancer -- latency-aware power-of-two-choices (M7), Twitter Finagle's peak-EWMA.
601
641
  *
602
642
  * `pick(now)` draws TWO distinct eligible endpoints (the same rejection-sampling machinery as
603
- * P2cBalancer -- reused verbatim, not re-implemented) and returns the one with the lower COST,
604
- * where cost(i) = (inflight[i] + 1) x ewmaAt(i, now). It is P2C over a LATENCY signal instead of
605
- * raw in-flight count: a slow endpoint (high EWMA rtt) is avoided even when its queue is short,
606
- * so the pool steers around a degraded-but-up node -- the strategy the multi-region FE case wants.
607
- * O(d) = O(1) per pick.
643
+ * P2cBalancer -- reused verbatim, not re-implemented) and returns the one with the lower COST. It
644
+ * is P2C over a LATENCY signal instead of raw in-flight count: a slow endpoint (high EWMA rtt) is
645
+ * avoided even when its queue is short, so the pool steers around a degraded-but-up node -- the
646
+ * strategy the multi-region FE case wants. O(d) = O(1) per pick.
647
+ *
648
+ * Cost, per candidate i (a pure READ -- scalar-only, no write, no clock call, 0 B/op):
649
+ * - unsampled (`_stamp < 0`) AND idle (`inflight === 0`) -> cost 0. This is NOT a one-shot probe the
650
+ * kernel can enforce: an idle unsampled node costs 0 EVERY time it is idle, so it holds exactly one
651
+ * request in flight at a time (the next pick sees inflight > 0) until its FIRST recordRtt. A node
652
+ * that never gets a sample -- e.g. one that fails fast so the caller records nothing -- stays at
653
+ * cost 0 whenever idle and keeps winning. Callers MUST record failures too (@zakkster/lite-pick/pool
654
+ * does this from 1.0.1) or a fast-failing endpoint is a black hole the kernel alone cannot see.
655
+ * - unsampled AND busy (`inflight > 0`) -> `(inflight + 1) x mean`, where `mean` is the pool's
656
+ * LIFETIME mean sampled rtt (`_samp[0] / _samp[1]` = sum / count, or 1.0 before ANY sample). A
657
+ * cold-but-busy node is priced at the pool mean, NOT the old 1.0 ns that made it a black hole (H1).
658
+ * - sampled -> `(inflight + 1) x base`, `base = max(decayedEWMA, dt)` WHILE BUSY else `decayedEWMA`
659
+ * (`decayedEWMA = ewma x exp(-dt/tau)`, `dt = max(now - stamp, 0)`). The busy floor means a hung
660
+ * node -- inflight > 0 and no completion, so `dt` grows without bound -- gets MORE expensive over
661
+ * time instead of decaying toward 0 and becoming the most attractive pick (H1/L6).
608
662
  *
609
663
  * Ownership (ADR 0001, ADR 0009): `inflight` is the CALLER's Uint32Array, read LIVE (the P2C /
610
664
  * LeastConn seam). The EWMA state -- `_ewma` (the decayed rtt estimate) and `_stamp` (the ns
@@ -613,25 +667,34 @@ export class NqBalancer extends BalancerBase {
613
667
  * warm `recordRtt()` feedback path. `pick()` NEVER writes: it decays ON READ, so the hot path
614
668
  * stays a pure read -> 0 B/op.
615
669
  *
616
- * Decay-on-read: ewmaAt(i, now) = _ewma[i] x exp(-(now - _stamp[i]) / tau). No write, no clock
670
+ * Decay-on-read: ewmaAt(i, now) = _ewma[i] x exp(-max(now - _stamp[i], 0) / tau). No write, no clock
617
671
  * call on the gated path -- `now` (and the rtt sample) are CALLER-supplied nanoseconds, consistent
618
672
  * between `pick(now)` and `recordRtt(i, sampleNs, now)`, so the whole strategy is deterministic
619
- * and testable and allocates nothing.
673
+ * and testable and allocates nothing. `dt` is clamped at 0 (L6) so a non-monotonic clock can never
674
+ * inflate the estimate via `exp(+x)`.
675
+ *
676
+ * Cold start: `_ewma` seeds to 1.0 and `_stamp` to a NEGATIVE "unsampled" sentinel (-1). An unsampled
677
+ * node costs 0 WHILE IDLE (so it holds one request in flight at a time until its first recordRtt) and
678
+ * the pool's lifetime mean ONCE BUSY (1.0 only before the very first sample), so a cold node that took
679
+ * work is never mistaken for a 1.0 ns node and cannot become a black hole once samples flow (H1). The
680
+ * first `recordRtt` initializes the EWMA EXACTLY to the sample (clock-independent); the peak rule
681
+ * applies only from the second sample on. It is never NaN.
620
682
  *
621
- * Cold start: `_ewma` seeds to 1.0 and `_stamp` to a NEGATIVE "unsampled" sentinel (-1). While a
622
- * node is unsampled `ewmaAt` returns the baseline 1.0 UNDECAYED, so before any sample cost(i) =
623
- * (inflight[i] + 1) x 1 and PeakEWMA degrades GRACEFULLY to plain least-connections (P2C-over-
624
- * inflight) REGARDLESS of the caller's clock magnitude -- a plain `_stamp = 0` would decay as
625
- * exp(-now/tau) -> 0 under a real large clock and collapse a cold pool to random. The first
626
- * `recordRtt` initializes the EWMA EXACTLY to the sample (clock-independent); the peak rule applies
627
- * only from the second sample on. It is never NaN.
683
+ * ACCEPTED CAVEATS (1.1 refinements): (a) a node that was idle and then receives a request is priced by
684
+ * the time since its LAST response (the `dt` floor / the mean) until that in-flight request completes --
685
+ * there is no precise per-dispatch "busy since" stamp yet, so the busy floor uses time-since-last-sample
686
+ * as its proxy, over-pricing a node that has just started a fresh (not hung) request. (b) `mean` is a
687
+ * LIFETIME mean over every sample ever recorded -- it never forgets a latency-regime change; decaying it
688
+ * is a 1.1 item. `_samp[0]` (the running sum) saturates to +Infinity after ~1.8e308 of summed rtt and
689
+ * stays Infinity (a cold-but-busy node then prices at Infinity) -- never NaN.
628
690
  *
629
691
  * Contract: `now` (in `pick(now)` / `recordRtt`) and `sampleNs` MUST be FINITE numbers. `recordRtt`
630
692
  * throws on a non-finite argument (the warm path); `pick(now)` never throws (the fail-closed
631
693
  * contract), so a non-finite `now` yields P2C-random selection rather than an error.
632
694
  *
633
- * Anti-flap (ADR 0002, ADR 0009): the EWMA half-life IS the smoothing -- a single slow sample
634
- * snaps the cost up instantly and it decays back over ~tau, so there is NO extra dwell/hysteresis.
695
+ * Anti-flap (ADR 0002, ADR 0009): the EWMA time constant IS the smoothing -- a single slow sample
696
+ * snaps the cost up instantly and it decays back over ~tau (half-life = tau x ln2), so there is NO
697
+ * extra dwell/hysteresis.
635
698
  *
636
699
  * Deferred (ADR 0009 / llms.txt): a p99-aware variant scoring inflight x p99Rtt via a per-node
637
700
  * @zakkster/lite-sketch `DDSketch` (optional peer, 0 B/op `add`). EWMA-mean is the shipped,
@@ -642,13 +705,20 @@ export class NqBalancer extends BalancerBase {
642
705
  * the whole pool is down.
643
706
  */
644
707
  export class PeakEwmaBalancer extends BalancerBase {
708
+ /**
709
+ * Marker: this is a LATENCY-AWARE strategy -- pick() consumes a clock reading (`now`), so
710
+ * @zakkster/lite-pick/pool REQUIRES an `opts.clock` and feeds recordRtt() from it. Read via
711
+ * `balancer.constructor.LATENCY` so Pool stays duck-typed (imports nothing new).
712
+ */
713
+ static LATENCY = true;
714
+
645
715
  /**
646
716
  * @param {number} capacity endpoint count (fixed).
647
717
  * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
648
718
  * @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
649
719
  * caller-owned and only READ here.
650
- * @param {number} tauNs the EWMA time-constant / half-life in nanoseconds (> 0, finite):
651
- * larger tau = slower decay = longer memory of a latency spike.
720
+ * @param {number} tauNs the EWMA TIME CONSTANT in nanoseconds (> 0, finite; the half-life is
721
+ * tauNs x ln2): larger tau = slower decay = longer memory of a latency spike.
652
722
  * @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
653
723
  */
654
724
  constructor(capacity, eligible, inflight, tauNs, seed = 0x9e3779b9) {
@@ -668,6 +738,12 @@ export class PeakEwmaBalancer extends BalancerBase {
668
738
  this._rng = new Prng(seed);
669
739
  this._ewma = new Float64Array(capacity);
670
740
  this._stamp = new Float64Array(capacity);
741
+ // Lifetime running mean of ALL rtt samples (O(1), warm-path maintained in recordRtt): the price
742
+ // an unsampled-but-BUSY node pays, so a cold node is not mistaken for a 1.0 ns node once it has
743
+ // work in flight. mean = _samp[0] / _samp[1] (sum / count); count 0 (no sample yet) -> 1.0. Held
744
+ // in a pre-allocated Float64Array (the hot-path law: pre-allocate typed-array scalars, never a
745
+ // per-op object). _samp[0] saturates to +Infinity past ~1.8e308 of summed rtt -- never NaN.
746
+ this._samp = new Float64Array(2);
671
747
  // Cold start: _ewma seeds to 1.0 and _stamp to a NEGATIVE "unsampled" sentinel (-1). The
672
748
  // sentinel makes ewmaAt read the baseline UNDECAYED (graceful LeastConn) regardless of the
673
749
  // caller's clock magnitude -- a plain _stamp=0 would decay as exp(-now/tau) -> 0 under a
@@ -697,7 +773,9 @@ export class PeakEwmaBalancer extends BalancerBase {
697
773
  ewmaAt(i, now) {
698
774
  const s = this._stamp[i];
699
775
  if (s < 0) return this._ewma[i]; // unsampled: undecayed baseline, clock-magnitude-independent
700
- return this._ewma[i] * Math.exp(-(now - s) / this._tau);
776
+ let dt = now - s;
777
+ if (dt < 0) dt = 0; // L6: clamp a non-monotonic clock -- exp(+x) must never inflate
778
+ return this._ewma[i] * Math.exp(-dt / this._tau);
701
779
  }
702
780
 
703
781
  /**
@@ -712,10 +790,10 @@ export class PeakEwmaBalancer extends BalancerBase {
712
790
  * @param {number} now caller-supplied nanoseconds (finite), consistent with pick(now)
713
791
  */
714
792
  recordRtt(i, sampleNs, now) {
715
- if (typeof i !== 'number' || typeof sampleNs !== 'number' || typeof now !== 'number') {
793
+ _vIdx(i, this._cap);
794
+ if (typeof sampleNs !== 'number' || typeof now !== 'number') {
716
795
  throw new TypeError('[lite-pick] recordRtt(i, sampleNs, now) requires numbers');
717
796
  }
718
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
719
797
  if (!Number.isFinite(sampleNs) || sampleNs < 0) {
720
798
  throw new RangeError('[lite-pick] sampleNs must be a finite number >= 0');
721
799
  }
@@ -723,17 +801,26 @@ export class PeakEwmaBalancer extends BalancerBase {
723
801
  if (this._stamp[i] < 0) {
724
802
  this._ewma[i] = sampleNs; // first sample: exact init, no decay (clock-independent)
725
803
  } else {
726
- const w = Math.exp(-(now - this._stamp[i]) / this._tau);
804
+ let dt = now - this._stamp[i];
805
+ if (dt < 0) dt = 0; // L6: clamp a non-monotonic clock -- exp(+x) must never inflate
806
+ const w = Math.exp(-dt / this._tau);
727
807
  const e = this._ewma[i] * w;
728
808
  this._ewma[i] = sampleNs > e ? sampleNs : e + (sampleNs - e) * (1 - w);
729
809
  }
730
810
  this._stamp[i] = now;
811
+ // O(1) warm running lifetime mean over ALL samples: the price a cold-but-busy node pays in
812
+ // pick(). Kept in an unboxed Float64Array (see the ctor); _samp[0] saturates to +Infinity, never NaN.
813
+ this._samp[0] += sampleNs;
814
+ this._samp[1] += 1;
731
815
  }
732
816
 
733
817
  /**
734
- * Pick by latency-aware power-of-two-choices: two distinct eligible draws, lower cost =
735
- * (inflight+1) x ewmaAt(now) wins; a tie goes to the first draw. PICK_NONE (fail closed) iff
736
- * the whole pool is down. O(d)=O(1), 0 B/op (pure read -- no write, no clock call).
818
+ * Pick by latency-aware power-of-two-choices: two distinct eligible draws, LOWER COST wins (a tie
819
+ * goes to the first draw). Cost is the three-case function documented on the class (unsampled+idle
820
+ * -> 0; unsampled+busy -> (inflight+1) x lifetime mean; sampled -> (inflight+1) x max(decayedEWMA,
821
+ * dt-while-busy)), NOT a plain (inflight+1) x ewmaAt. PICK_NONE (fail closed) iff the whole pool is
822
+ * down. O(d)=O(1), 0 B/op (pure read -- no write, no clock call; the mean division runs only in the
823
+ * unsampled-and-busy arm, never in the both-sampled steady state).
737
824
  * @param {number} now caller-supplied nanoseconds (consistent with recordRtt)
738
825
  * @returns {number}
739
826
  */
@@ -747,11 +834,34 @@ export class PeakEwmaBalancer extends BalancerBase {
747
834
  for (let t = 0; b === a && t < 32; t++) b = this._draw();
748
835
  if (b < 0 || b === a) return a; // astronomically rare: fall back to the first draw
749
836
  const inf = this._inflight, ewma = this._ewma, stamp = this._stamp, tau = this._tau;
750
- // Decay-on-read with the unsampled sentinel: `_stamp < 0` reads the undecayed baseline
751
- // (graceful LeastConn), else exponential decay. A cheap per-candidate compare, no alloc.
752
- const sa = stamp[a], sb = stamp[b];
753
- const costA = (inf[a] + 1) * (sa < 0 ? ewma[a] : ewma[a] * Math.exp(-(now - sa) / tau));
754
- const costB = (inf[b] + 1) * (sb < 0 ? ewma[b] : ewma[b] * Math.exp(-(now - sb) / tau));
837
+ // Per-candidate cost (pure READ, scalar-only, 0 B/op). See the class JSDoc for the three cases.
838
+ // unsampled + idle -> 0 (costs 0 while idle until its first recordRtt; NOT a one-shot probe).
839
+ // unsampled + busy -> (inf+1) x lifetime mean (the mean DIVISION runs ONLY here -- never in
840
+ // the both-sampled steady state -- priced at the pool mean, not 1.0 ns).
841
+ // sampled -> (inf+1) x base, base = decayed EWMA, floored at dt WHILE BUSY so a hung
842
+ // node (dt grows, no completion) gets MORE expensive, not less.
843
+ const sa = stamp[a];
844
+ let costA;
845
+ if (sa < 0) {
846
+ costA = inf[a] === 0 ? 0 : (inf[a] + 1) * (this._samp[1] > 0 ? this._samp[0] / this._samp[1] : 1.0);
847
+ } else {
848
+ let dtA = now - sa;
849
+ if (dtA < 0) dtA = 0; // L6: clamp non-monotonic clock
850
+ const decA = ewma[a] * Math.exp(-dtA / tau);
851
+ const baseA = inf[a] > 0 ? (decA > dtA ? decA : dtA) : decA; // busy floor: >= time since last sample
852
+ costA = (inf[a] + 1) * baseA;
853
+ }
854
+ const sb = stamp[b];
855
+ let costB;
856
+ if (sb < 0) {
857
+ costB = inf[b] === 0 ? 0 : (inf[b] + 1) * (this._samp[1] > 0 ? this._samp[0] / this._samp[1] : 1.0);
858
+ } else {
859
+ let dtB = now - sb;
860
+ if (dtB < 0) dtB = 0; // L6: clamp non-monotonic clock
861
+ const decB = ewma[b] * Math.exp(-dtB / tau);
862
+ const baseB = inf[b] > 0 ? (decB > dtB ? decB : dtB) : decB; // busy floor
863
+ costB = (inf[b] + 1) * baseB;
864
+ }
755
865
  return costB < costA ? b : a; // lower cost wins; tie -> the first draw
756
866
  }
757
867
  }
@@ -833,6 +943,13 @@ function chIsPrime(n) {
833
943
  * pick, over-conservative only under mass outage; ADR 0010).
834
944
  */
835
945
  export class ConsistentHashBalancer extends BalancerBase {
946
+ /**
947
+ * Marker: this is a KEYED strategy -- pick(keyHash) routes by an integer key, so
948
+ * @zakkster/lite-pick/pool REQUIRES a numeric `opts.key`. Inherited by BoundedLoadBalancer.
949
+ * Read via `balancer.constructor.KEYED` so Pool stays duck-typed (imports nothing new).
950
+ */
951
+ static KEYED = true;
952
+
836
953
  /**
837
954
  * @param {number} capacity backend count (fixed; add/remove is a cold rebuild).
838
955
  * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
@@ -945,7 +1062,7 @@ export class ConsistentHashBalancer extends BalancerBase {
945
1062
  * @param {number} w new weight (uint32)
946
1063
  */
947
1064
  setWeight(i, w) {
948
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
1065
+ _vIdx(i, this._cap);
949
1066
  const nw = w >>> 0;
950
1067
  if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
951
1068
  if (nw === this._weights[i]) return;
@@ -1005,7 +1122,13 @@ export class ConsistentHashBalancer extends BalancerBase {
1005
1122
  * window (home + CH_PROBE_LIMIT slots) and return the FIRST backend that is ELIGIBLE AND UNDER cap
1006
1123
  * (`inflight[b] < cap`). If none in the window is under cap, FALL BACK to the first eligible seen
1007
1124
  * (sticky wins; the cap is a soft preference, never a dead pick). When `_total === 0` the cap test is
1008
- * skipped entirely -> behaves as pure ConsistentHash. `cap = (1 + eps) x _total / live`.
1125
+ * skipped entirely -> behaves as pure ConsistentHash. `cap = ceil((1 + eps) x (_total + 1) / live)`
1126
+ * -- the load-bearing change from the old `(1 + eps) x _total / live` is the +1 that counts the
1127
+ * INCOMING request (Mirrokni-Thorup-Zadimoghaddam per-bin capacity); the Math.ceil matches the
1128
+ * paper's integer capacity but is a no-op for the `inf < cap` test (for integer inf,
1129
+ * `inf < ceil(x)` == `inf < x`). A second concurrent same-key request correctly overflows the home
1130
+ * until `(1+eps)(_total+1)/live > 1`. HAProxy's `hash-balance-factor` shares the +1 but distributes
1131
+ * ONE global `ceil((m+1)F/100)` slot budget across servers by weight (min 1), which is stricter.
1009
1132
  *
1010
1133
  * Ownership (ADR 0001, ADR 0004, ADR 0010, ADR 0011): the Maglev lookup table + weights are
1011
1134
  * BALANCER-OWNED and built COLD (reused from ConsistentHashBalancer VERBATIM -- `_build`, `setWeight`,
@@ -1014,9 +1137,11 @@ export class ConsistentHashBalancer extends BalancerBase {
1014
1137
  * `_total` is BALANCER-OWNED and its SOLE writer is the warm `note(i, delta)` feedback path
1015
1138
  * (dispatch +1 / settle -1), so the cap's mean stays O(1)-current without a scan.
1016
1139
  *
1017
- * CONTRACT (the SmoothWRR-weights asymmetry): when using BoundedLoad the mirrored inflight counter is
1018
- * mutated ONLY through `note()` / the /pool adapter. Direct mutation desyncs `_total` from the true
1019
- * sum, so the cap goes wrong -- UB. `note()` clamps `_total` at 0; `totalInflight` exposes it.
1140
+ * CONTRACT (the SmoothWRR-weights asymmetry): when using BoundedLoad, update `inflight[i]` AND call
1141
+ * `note(i, +/-1)` in LOCKSTEP (or drive it through the /pool adapter, which does both). `note`
1142
+ * maintains `_total`; it does NOT write `inflight`. A direct `inflight` write without the matching
1143
+ * `note` desyncs `_total` from the true sum, so the cap goes wrong -- UB. `note()` clamps `_total`
1144
+ * at 0; `totalInflight` exposes it.
1020
1145
  *
1021
1146
  * Bound: O(1) per pick (modulo + table read + bounded cap-aware probe), 0 B/op on BOTH `pick()` and
1022
1147
  * `note()` (torture + PerfGate). Fails closed (PICK_NONE) ONLY when no eligible backend is reachable
@@ -1073,7 +1198,7 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
1073
1198
  * @param {number} delta integer occupancy change (+1 dispatch, -1 settle)
1074
1199
  */
1075
1200
  note(i, delta) {
1076
- if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
1201
+ _vIdx(i, this._cap);
1077
1202
  if (typeof delta !== 'number') throw new TypeError('[lite-pick] delta must be a number');
1078
1203
  if (!Number.isInteger(delta)) throw new RangeError('[lite-pick] delta must be an integer: ' + delta);
1079
1204
  const t = this._total + delta;
@@ -1083,8 +1208,8 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
1083
1208
  /**
1084
1209
  * Map an INTEGER key to a backend, honouring the occupancy cap, or PICK_NONE (fail closed). O(1),
1085
1210
  * 0 B/op, never throws. slot = (keyHash >>> 0) % M; walk the M8 probe window (home + CH_PROBE_LIMIT
1086
- * slots) and return the FIRST backend that is ELIGIBLE AND under cap = (1+eps) x _total / live. If
1087
- * none in the window is under cap, fall back to the FIRST eligible seen (sticky wins -- the cap is
1211
+ * slots) and return the FIRST backend that is ELIGIBLE AND under cap = ceil((1+eps) x (_total+1) /
1212
+ * live). If none in the window is under cap, fall back to the FIRST eligible seen (sticky wins -- the cap is
1088
1213
  * a soft preference, never a dead pick). `_total === 0` skips the cap test -> pure ConsistentHash.
1089
1214
  * PICK_NONE ONLY when no eligible backend is reachable within the window.
1090
1215
  * @param {number} keyHash a caller-supplied integer key hash (coerced to uint32)
@@ -1096,7 +1221,10 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
1096
1221
  const total = this._total;
1097
1222
  // cap is only meaningful once occupancy is known; _total === 0 -> pure ConsistentHash.
1098
1223
  const capActive = total > 0;
1099
- const cap = capActive ? (1 + this._eps) * total / this._live : 0; // finite: total>0, live>0
1224
+ // CHBL cap (Mirrokni-Thorup-Zadimoghaddam per-bin capacity). The load-bearing part is the +1
1225
+ // that counts the INCOMING request; the Math.ceil matches the paper's integer capacity but is a
1226
+ // no-op for the `inf < cap` test (integer inf: `inf < ceil(x)` == `inf < x`).
1227
+ const cap = capActive ? Math.ceil((1 + this._eps) * (total + 1) / this._live) : 0; // >= 1: total>0, live>0
1100
1228
  let slot = (keyHash >>> 0) % M; // integer key; NaN >>> 0 = 0 (never throws)
1101
1229
  let firstEligible = -1; // the pure-ConsistentHash sticky fallback answer
1102
1230
  let i = lookup[slot];
@@ -1118,3 +1246,177 @@ export class BoundedLoadBalancer extends ConsistentHashBalancer {
1118
1246
  return firstEligible; // -1 (PICK_NONE) iff NO eligible backend was reachable in the window
1119
1247
  }
1120
1248
  }
1249
+
1250
+ /**
1251
+ * WeightedRandomBalancer -- O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (M10),
1252
+ * the roster-completing strategy.
1253
+ *
1254
+ * `pick()` draws ONE column uniformly (`prng.nextBelow(cap)`), compares one fresh uniform against
1255
+ * `_prob[col]`, and takes `col` or `_alias[col]` -- a constant handful of integer/float ops that
1256
+ * return an endpoint proportional to its weight. This is the STATELESS O(1) weighted selector: no
1257
+ * per-endpoint accumulator to desync (SmoothWRR's `_current`), just a static table sampled with a
1258
+ * PRNG -- the fit for VERY LARGE pools where SmoothWRR's O(cap)-per-pick scan hurts. It trades
1259
+ * SmoothWRR's deterministic low-variance smoothness for sampling variance (any single pick is
1260
+ * random; the LAW OF LARGE NUMBERS delivers the weight ratios over a run -- balance.mjs anchors it).
1261
+ *
1262
+ * ELIGIBILITY is REJECTION SAMPLING over the shared bitmap (the ADR 0005 / P2C discipline, not a
1263
+ * table rebuild): if the drawn candidate is ineligible, redraw up to a bounded 64 times, then fall
1264
+ * back to a 0-B/op rotated linear scan from a random start for the degenerate heavy-outage case.
1265
+ * Because the alias table is built over the ELIGIBLE-INDEPENDENT weights and a candidate is ALWAYS
1266
+ * a positive-weight node (a weight-0 node is never a column -- see _build), rejecting the ineligible
1267
+ * draws RENORMALIZES the weight distribution over the SURVIVING eligible mass: each eligible node's
1268
+ * long-run share converges to weight[i] / sum(eligible weights) (ADR 0012 Fork 1). The rare fallback
1269
+ * scan returns the first eligible positive-weight node from a random offset (unbiased first-after-
1270
+ * offset), a correctness net, not a proportional path.
1271
+ *
1272
+ * Ownership (ADR 0001, ADR 0004, ADR 0012): `weights` is the CALLER's Uint32Array (length >= capacity)
1273
+ * -- the SmoothWRR / SED weight seam -- and the balancer is the SOLE writer of its DERIVED alias table
1274
+ * (`_prob` Float64Array + `_alias` Int32Array, both balancer-owned) via the cold `setWeight` / `rebuild`
1275
+ * (which read `weights` and rebuild the table); mutating `weights` directly desyncs the table (UB, the
1276
+ * SmoothWRR asymmetry). The alias build reuses COLD scratch worklists allocated once in the ctor -- the
1277
+ * build allocates nothing per call, and pick() allocates nothing per call.
1278
+ *
1279
+ * Fail-closed (ADR 0012 Fork 2): `pick()` returns PICK_NONE (-1) IFF `live === 0` OR no eligible node
1280
+ * has a positive weight (all-zero weights, or every eligible node's weight is 0). NEVER a dead pick,
1281
+ * a weight-0 return, or an out-of-range index. `pick()` never throws.
1282
+ *
1283
+ * Bound: O(1) per pick (one column draw + one compare, expected O(1) rejection draws when eligibility
1284
+ * is dense), 0 B/op (integer/float locals only) -- proven by test/torture.mjs + test/perf/PerfGate.test.mjs.
1285
+ *
1286
+ * DEFERRED optional-peer seams (import NOTHING; peerDependencies STAYS `{}` until a shipped path imports
1287
+ * one): a `@zakkster/lite-o1` `AliasTable` as a duck-typed drop-in for the inline Vose build, and a
1288
+ * `@zakkster/lite-logn` Fenwick/BinaryIndexedTree for the DYNAMIC-weight case (O(log n) update + sample)
1289
+ * -- the mutable-weight complement to this static table's O(1) sample / O(cap) rebuild (ADR 0012).
1290
+ *
1291
+ * NOT `@zakkster/lite-random`: that sibling is a GAME RNG (Mulberry32; loot tables, particles, gaussian)
1292
+ * whose `weighted(items, weights)` returns an ITEM one-shot, is NOT eligibility-aware, holds no reusable
1293
+ * table, and uses a different PRNG. lite-pick's WeightedRandom returns an endpoint INDEX, honours the
1294
+ * shared eligibility bitmap (fail-closed), owns a persistent alias table rebuilt only on reweight, and
1295
+ * uses the in-repo xorshift32. Different domain + contract -- not a peer, not a substrate (ADR 0012 / GUIDE.md).
1296
+ */
1297
+ export class WeightedRandomBalancer extends BalancerBase {
1298
+ /**
1299
+ * @param {number} capacity endpoint count (fixed; add/remove is a cold rebuild).
1300
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
1301
+ * @param {Uint32Array} weights caller-owned per-endpoint weights (length >= capacity); the balancer
1302
+ * is the sole writer of the DERIVED alias table via setWeight (direct mutation desyncs it -- UB).
1303
+ * @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
1304
+ */
1305
+ constructor(capacity, eligible, weights, seed = 0x9e3779b9) {
1306
+ super(capacity, eligible);
1307
+ // Validate typeof-first, BEFORE allocating the owned table / scratch (fail closed early -- the
1308
+ // PeakEWMA / ConsistentHash / BoundedLoad discipline).
1309
+ if (!(weights instanceof Uint32Array) || weights.length < capacity) {
1310
+ throw new RangeError('[lite-pick] weights must be a Uint32Array of length >= capacity');
1311
+ }
1312
+ this._weights = weights;
1313
+ this._rng = new Prng(seed);
1314
+ // Balancer-owned derived table: _prob (the split probability per column) + _alias (the column's
1315
+ // alternate). A candidate is ALWAYS a positive-weight node (see _build), so pick() never returns
1316
+ // a weight-0 index.
1317
+ this._prob = new Float64Array(capacity);
1318
+ this._alias = new Int32Array(capacity);
1319
+ // COLD scratch worklists for the Vose build (small/large index stacks + the scaled probabilities),
1320
+ // allocated ONCE here and reused by every _build -- the build never allocates per call.
1321
+ this._small = new Int32Array(capacity);
1322
+ this._large = new Int32Array(capacity);
1323
+ this._scaled = new Float64Array(capacity);
1324
+ this._psum = 0; // sum of ALL weights (the eligible-independent normalizer); 0 => degenerate.
1325
+ this._builds = 0; // COLD rebuild counter (observability / the anti-flap gate: a flap adds 0).
1326
+ this._build();
1327
+ }
1328
+
1329
+ /**
1330
+ * COLD: (re)build the Vose/Walker alias table from the current caller weights. The standard
1331
+ * small/large worklist over `scaled[i] = weights[i] * cap / total` (mean-1 normalization): pair a
1332
+ * deficient (< 1) column with a surplus (>= 1) one until one worklist empties, then drain the
1333
+ * residue (numerically ~1 full columns) to prob 1. A weight-0 node has scaled 0, so it is popped
1334
+ * once, assigned prob 0 + a POSITIVE-weight alias, and NEVER reaches the prob-1 drain -- it can
1335
+ * never be returned as its own column. All-zero weights (total 0) leaves _psum 0 and pick() fails
1336
+ * closed. Reuses the cold scratch worklists -- allocates nothing. ~15 lines (do NOT re-implement
1337
+ * lite-o1's AliasTable; this is the inline standard build, ADR 0012 Fork 0).
1338
+ */
1339
+ _build() {
1340
+ this._builds++;
1341
+ const cap = this._cap, wt = this._weights, prob = this._prob, alias = this._alias;
1342
+ const scaled = this._scaled, small = this._small, large = this._large;
1343
+ let total = 0;
1344
+ for (let i = 0; i < cap; i++) total += wt[i];
1345
+ this._psum = total;
1346
+ if (total <= 0) {
1347
+ // Degenerate all-zero weights: no positive-weight column. pick() short-circuits on _psum===0
1348
+ // (PICK_NONE), so the table is never read -- fill it defensively (each column self-referential).
1349
+ for (let i = 0; i < cap; i++) { prob[i] = 0; alias[i] = i; }
1350
+ return;
1351
+ }
1352
+ const scale = cap / total;
1353
+ let ns = 0, nl = 0; // small / large stack heights (indices into the scratch)
1354
+ for (let i = 0; i < cap; i++) {
1355
+ const v = wt[i] * scale;
1356
+ scaled[i] = v;
1357
+ if (v < 1) small[ns++] = i; else large[nl++] = i;
1358
+ }
1359
+ while (ns > 0 && nl > 0) {
1360
+ const s = small[--ns];
1361
+ const l = large[--nl];
1362
+ prob[s] = scaled[s];
1363
+ alias[s] = l; // l is surplus (scaled >= 1) => positive weight
1364
+ const rem = (scaled[l] + scaled[s]) - 1;
1365
+ scaled[l] = rem;
1366
+ if (rem < 1) small[ns++] = l; else large[nl++] = l;
1367
+ }
1368
+ while (nl > 0) { const l = large[--nl]; prob[l] = 1; alias[l] = l; } // full columns
1369
+ while (ns > 0) { const s = small[--ns]; prob[s] = 1; alias[s] = s; } // float residue ~1
1370
+ }
1371
+
1372
+ /**
1373
+ * COLD: reconfigure endpoint i's weight (uint32) and REBUILD the alias table from the new weights.
1374
+ * The balancer is the sole writer of the derived table (the SmoothWRR / ConsistentHash precedent).
1375
+ * @param {number} i
1376
+ * @param {number} w new weight (uint32)
1377
+ */
1378
+ setWeight(i, w) {
1379
+ _vIdx(i, this._cap);
1380
+ const nw = w >>> 0;
1381
+ if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
1382
+ if (nw === this._weights[i]) return;
1383
+ this._weights[i] = nw;
1384
+ this._build();
1385
+ }
1386
+
1387
+ /** COLD: rebuild the alias table from the current caller weights (e.g. after a membership change). */
1388
+ rebuild() {
1389
+ this._build();
1390
+ }
1391
+
1392
+ /**
1393
+ * Pick an endpoint index proportional to weight, or PICK_NONE (fail closed). O(1), 0 B/op, never
1394
+ * throws. One column draw + one probability compare yields a positive-weight candidate; an
1395
+ * ineligible candidate is rejection-redrawn up to 64 times (renormalizing the weight distribution
1396
+ * over the eligible mass), then a rotated linear scan from a random start returns the first eligible
1397
+ * positive-weight node. PICK_NONE IFF live === 0 OR no eligible node has a positive weight.
1398
+ * @returns {number}
1399
+ */
1400
+ pick() {
1401
+ if (this._live === 0 || this._psum === 0) return PICK_NONE; // pool down / no positive weight
1402
+ const cap = this._cap, el = this._eligible, prob = this._prob, alias = this._alias, rng = this._rng;
1403
+ // Fast path: alias draw + rejection on eligibility. A candidate is always positive-weight, so
1404
+ // rejecting the ineligible ones renormalizes weight-proportionality over the surviving mass.
1405
+ for (let t = 0; t < 64; t++) {
1406
+ const col = rng.nextBelow(cap);
1407
+ const u = rng.next() / 4294967296; // fresh uniform in [0, 1)
1408
+ const cand = u < prob[col] ? col : alias[col];
1409
+ if (el[cand]) return cand;
1410
+ }
1411
+ // Degenerate (very sparse eligibility): scan from a random start for the first eligible,
1412
+ // positive-weight node. Zero-alloc; returns PICK_NONE only if none exists.
1413
+ const wt = this._weights;
1414
+ let i = rng.nextBelow(cap);
1415
+ for (let k = 0; k < cap; k++) {
1416
+ if (el[i] && wt[i] > 0) return i;
1417
+ i++;
1418
+ if (i >= cap) i = 0;
1419
+ }
1420
+ return PICK_NONE; // no eligible positive-weight node
1421
+ }
1422
+ }