@zakkster/lite-pick 0.8.0 → 1.0.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
@@ -4,6 +4,124 @@ All notable changes to `@zakkster/lite-pick` are documented here. The format fol
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.0.0] - 2026-09-23
8
+
9
+ The **roster-complete** release: ten selection strategies + the `/pool` request layer + the benchmark
10
+ suite + the docs/GUIDE capstone. Roster complete **for now, not closed** -- AZ-aware routing, the
11
+ lite-await hedging combinator, and subsetting are queued post-1.0 (see [ROADMAP.md](./ROADMAP.md)).
12
+
13
+ ### Added
14
+
15
+ - **`WeightedRandomBalancer` (M10)** -- O(1) weighted-random selection via an inline **Vose/Walker alias
16
+ table** (one column draw + one probability compare -> a candidate) with **rejection-sampling
17
+ eligibility** (retry an ineligible candidate up to a bounded 64, then a 0-B/op rotated linear eligible
18
+ scan) -- the ADR 0005 / P2C discipline. The alias table is built **cold** over the eligible-independent
19
+ weights, so a **weight-0 node is never a column** (never returned) and rejecting the ineligible draws
20
+ **renormalizes** the weight distribution over the surviving eligible mass (each eligible node's share
21
+ converges to `weight[i] / sum(eligible weights)`). `weights` is the caller's `Uint32Array` (the
22
+ SmoothWRR/SED seam); the balancer is the **sole writer** of its derived table (`_prob` / `_alias`) via
23
+ cold `setWeight` / `rebuild` -- an eligibility flap **never** rebuilds (anti-flap). Validates
24
+ typeof-first before allocating the table. `pick()` is **O(1)**, **0 B/op**, never throws; `PICK_NONE`
25
+ only when `live === 0` or no eligible node has a positive weight. It is the **stateless** O(1) weighted
26
+ sampler (no accumulator to desync) for very large pools where SmoothWRR's O(cap) scan hurts -- trading
27
+ smoothness for sampling variance. `peerDependencies` stays `{}` (the Vose build is inlined; a lite-o1
28
+ `AliasTable` and a lite-logn Fenwick tree are deferred optional peers, imported by nothing).
29
+ ([ADR 0012](./decisions/0012-weightedrandom.md)).
30
+ - **Fairness anchor** (`test/balance.mjs`) -- n=64, skewed weights [1..16], 8e6 seeded draws: every node's
31
+ observed share is within **2% relative** of `weight[i]/sum` (measured worst ~0.84%), and a cumsum-linear
32
+ O(n) foil matches the same fairness. The O(1) alias sample beats that O(n) foil by **~107x ops/ms** at
33
+ n=4096 (gate: >=3x). Under half the pool down (1e6 picks): **0 ineligible / 0 weight-0** returns,
34
+ survivor shares within **3% relative** of `weight[i]/sum(eligible)` (measured worst ~1.77%), and all-zero
35
+ weights -> `PICK_NONE`. Thresholds are the sampling-variance floor from a correct run (N sized so the
36
+ band holds with margin) -- the band is never widened to pass.
37
+ - **`GUIDE.md`** -- the "which of the ten strategies do I pick?" decision guide (a decision tree + table
38
+ keyed by keyed-vs-load-vs-latency-vs-weighted, O(1) vs O(cap), state owned, and when each wins),
39
+ distinct from `RECIPES.md` (how-to wiring). Added to the published `files[]` (the tarball is now 11 files).
40
+ - Gates extended for the new strategy: `test/WeightedRandom.test.js` boundary + behaviour suite;
41
+ `test/fuzz.mjs` keyed-agnostic subject + `checkWeightedRandom` (structural + no-weight-0-column + the
42
+ sum-reconstruction invariant, after every op) + a 1000-flap **0-rebuild** anti-flap assertion;
43
+ `test/torture.mjs` retention + a `pick()` 0 B/op phase (phase 14); `test/perf/PerfGate.test.mjs`
44
+ `weightedRandomPick` zero-alloc scenario + a boxed `mustFail` tooth; `test/witness.mjs` 'const'
45
+ flat-work subject; `benchmark/Matrix.mjs` throughput + fairness subject; `benchmark/Report.mjs`
46
+ Weighted-random parity row now times `WeightedRandomBalancer` vs `wrr` (both O(1) weighted-random --
47
+ previously a pending SKIP); `Pick.d.ts` + `test/types/pick.test-d.ts` typed surface.
48
+
49
+ ### Changed
50
+
51
+ - **`Pick.js` header roster/count** nine -> **ten**, and the `VERSION` stamp `0.9.0` -> `1.0.0` (the
52
+ three-place sync: `package.json`, the `VERSION` const, `llms.txt`). This session appends **one** class;
53
+ every other strategy in `Pick.js` is byte-identical.
54
+ - **`package.json`** version `1.0.0`, the description roster gains WeightedRandom + a GUIDE.md pointer,
55
+ keywords gain `alias-method` / `vose` (`weighted-random` was already present), and `files[]` gains
56
+ `GUIDE.md`. `peerDependencies` stays `{}`.
57
+ - Sibling boundary documented explicitly (so 1.0.0's WeightedRandom does not look duplicative with
58
+ `@zakkster/lite-random`): lite-random is a **game RNG** returning an item, not eligibility-aware, no
59
+ reusable table; lite-pick WeightedRandom returns an endpoint index, honours the shared eligibility
60
+ bitmap (fail-closed), and owns a persistent alias table -- different domain, not a peer (GUIDE.md,
61
+ llms.txt, ADR 0012).
62
+
63
+ ## [0.9.0] - 2026-09-23
64
+
65
+ ### Added
66
+
67
+ - **`BoundedLoadBalancer` (M9)** -- **Consistent Hashing with Bounded Loads** (CHBL: Mirrokni et al.,
68
+ Google Research; Vimeo's `eps = 0.25`). It **extends `ConsistentHashBalancer`** (the Maglev table) and
69
+ adds a per-backend occupancy cap `cap = (1 + eps) x _total / live`: `pick(keyHash)` sticks a key to its
70
+ hashed home **unless** that backend is over cap, in which case the request **overflows** along the same
71
+ bounded forward-probe to the next eligible, under-cap backend. If none in the probe window is under cap
72
+ it falls back to the first eligible seen (**sticky wins; the cap is a soft preference, never a dead
73
+ pick**); `_total === 0` skips the cap entirely, behaving as pure consistent hashing. This keeps
74
+ consistent hashing's stickiness + minimal disruption **and** adds the **hotspot protection** plain
75
+ consistent hashing lacks. The Maglev build, probe walk, and `setWeight` / `rebuild` / `tableSize` are
76
+ reused verbatim. The running occupancy sum `_total` is **balancer-owned** (starts at 0) and written
77
+ **solely** via the warm `note(i, delta)` seam (dispatch `+1` / settle `-1`), so the cap's mean stays
78
+ O(1)-current without a scan; `inflight` is the caller's `Uint32Array`, read **live** as the per-backend
79
+ occupancy. `pick()` and `note()` are both **O(1)** / **0 B/op**. `eps` is validated typeof-first
80
+ (TypeError non-number, RangeError non-finite / `<= 0`) before the table is allocated; `note()` validates
81
+ `i` in range and `delta` as an integer, and clamps `_total` at 0. `totalInflight` exposes `_total`.
82
+ `PICK_NONE` only when no eligible backend is reachable within the probe window -- **never** for
83
+ over-cap (fail open on overload). **Contract:** the mirrored inflight counter is mutated **only** through
84
+ `note()` / the `/pool` adapter -- direct mutation desyncs `_total` (UB, the SmoothWRR-weights asymmetry).
85
+ ([ADR 0011](./decisions/0011-boundedload.md)).
86
+ - **The pivot** (ADR 0011): the first M9 draft built the "overload" reading -- P2C-over-inflight with a
87
+ `(1 + eps) x mean` cap -- and it was proven **byte-identical to plain P2C** (an under-cap draw always
88
+ has lower inflight than an over-cap one, so "prefer under-cap" and "lower-of-two" pick the same node).
89
+ The cap is only *load-bearing* when the primary choice is a **hash**, so M9 is CHBL -- the algorithm
90
+ the roadmap cited. The P2C-with-cap reading is withdrawn as non-distinct.
91
+ - **Hotspot anchor** (`test/balance.mjs`) -- 64 backends, a Zipfian-skewed key stream (6 hot keys, 85% of
92
+ traffic), a fixed concurrency window: plain ConsistentHash pins a hot key on one backend -- measured
93
+ **max occupancy 129** vs a mean of **10** (a ~13x hotspot) -- while CHBL's cap holds **max occupancy 13**
94
+ (`cap = (1 + eps) x mean = 12.5`) by overflowing to neighbours, materially below ConsistentHash's max
95
+ (the foil FAILS the bounded-occupancy band). Both keep **~1.55%** minimal disruption on a scale event
96
+ (`<= 2/N`). Thresholds are measured from a correct run with a small margin and noted -- the impl is
97
+ never bent to a number.
98
+ - Gates extended for the new strategy: `test/BoundedLoad.test.js` boundary suite (ctor + eps validation,
99
+ sticky same-key routing, overflow when a home is over cap, fail-open, PICK_NONE only pool-down,
100
+ pure-ConsistentHash when `_total === 0`, `note()` validation + clamp, `totalInflight` tracking, minimal
101
+ disruption, a Pool `opts.key` net-zero round-trip); `test/fuzz.mjs` keyed note-driven subject +
102
+ `checkBoundedLoad` (`totalInflight === sum(inflight)` after every op) + the ConsistentHash structural
103
+ invariant; `test/torture.mjs` retention (small-instance CHBL loop) + `pick(keyHash)` and `note()` 0 B/op
104
+ phases; `test/perf/PerfGate.test.mjs` `boundedLoadPick` + `boundedLoadNote` zero-alloc scenarios + a
105
+ `mustFail` alloc tooth; `test/witness.mjs` O(1) const flat-work keyed subject; `benchmark/Matrix.mjs`
106
+ subject (dims throughput/balance/gc over the skewed-cost workload); `Pick.d.ts` +
107
+ `test/types/pick.test-d.ts` typed surface.
108
+
109
+ ### Changed
110
+
111
+ - `Pool.run` gains an **inert-unless-duck-typed `note` hook** (mirror each dispatch as `note(i, +1)` and
112
+ each settle as `note(i, -1)`, net-zero per run) paralleling the PeakEWMA `recordRtt` wiring, and an
113
+ **`opts.key`** option -- when supplied, Pool drives `pick(key)` (keyed / CHBL routing); failover
114
+ re-picks with the same key, and because the failed backend's occupancy stays elevated a CHBL re-pick
115
+ naturally overflows to the next backend. All hooks are inert when not applicable -- Pool stays generic,
116
+ in-flight stays net-zero, abort/failover unchanged.
117
+ - `Pick.js`: STRATEGY-APPEND only -- the other eight strategies are **byte-identical**; the sole changes
118
+ are the header roster/count (eight -> nine), the `VERSION` bump, and the appended `BoundedLoadBalancer`
119
+ (which **extends `ConsistentHashBalancer`**, reusing its Maglev build + probe verbatim; ConsistentHash
120
+ itself is unchanged).
121
+ - `VERSION` bumped 0.8.0 -> **0.9.0** across the three sync sites (package.json, `Pick.js`, llms.txt);
122
+ package `description` roster updated (keywords already carried `bounded-load`). `peerDependencies`
123
+ stays `{}` (CHBL reuses M8 -- imports nothing new).
124
+
7
125
  ## [0.8.0] - 2026-09-23
8
126
 
9
127
  ### Added
package/GUIDE.md ADDED
@@ -0,0 +1,93 @@
1
+ # Which strategy? -- the lite-pick decision guide
2
+
3
+ `@zakkster/lite-pick` ships **ten** selection strategies. They are not ranked; each wins a different
4
+ job. This guide is how you CHOOSE one. It is deliberately distinct from [RECIPES.md](./RECIPES.md),
5
+ which shows how to WIRE a chosen strategy (dispatch/settle counters, health, the `/pool` layer).
6
+
7
+ Every strategy shares the same contract: a hot `pick()` returning an endpoint **index** over a fixed
8
+ pool, **0 B/op** steady-state, **fail-closed** (`PICK_NONE` = -1 when nothing is pickable, never a
9
+ dead pick), reading a **shared read-only eligibility bitmap** it never writes.
10
+
11
+ ## The one question that splits everything: what fixes the primary choice?
12
+
13
+ ```
14
+ Is routing decided by a KEY (same key -> same backend, for cache/session affinity)?
15
+ |
16
+ +-- YES -> you want a CONSISTENT HASH.
17
+ | |
18
+ | +-- Do a few hot keys overload one backend?
19
+ | | NO -> ConsistentHash (sticky, minimal disruption, O(1))
20
+ | | YES -> BoundedLoad (sticky + an occupancy cap that overflows a hotspot, O(1))
21
+ |
22
+ +-- NO -> the choice is by LOAD / LATENCY / WEIGHT, not a key.
23
+ |
24
+ +-- Do you have a LATENCY signal (rtt) and want to steer around a slow-but-up node?
25
+ | YES -> PeakEWMA (latency-aware power-of-two-choices, O(1))
26
+ |
27
+ +-- Do you have live IN-FLIGHT counts (a closed dispatch/settle loop)?
28
+ | |
29
+ | +-- Want the EXACT least-loaded, and O(cap) is fine (dozens-hundreds of nodes)?
30
+ | | unweighted -> LeastConn (exact fewest-in-flight, O(cap))
31
+ | | weighted -> SED (minimizes (inflight+1)/weight, O(cap))
32
+ | | worker pool (idle-first) -> NQ (never-queue: idle node first, else SED, O(cap))
33
+ | |
34
+ | +-- Want O(1) at very large pools and can accept a tiny balance gap?
35
+ | -> P2C (power-of-two-choices, the ln ln n ceiling, O(1))
36
+ |
37
+ +-- No load signal -- just spread by a fixed WEIGHT (or evenly)?
38
+ |
39
+ +-- Equal weight, simple rotation -> RoundRobin (O(1) amortized)
40
+ +-- Weighted, want SMOOTH low-variance -> SmoothWRR (deterministic, O(cap))
41
+ +-- Weighted, want STATELESS O(1) at scale -> WeightedRandom (alias table, O(1))
42
+ ```
43
+
44
+ ## The table
45
+
46
+ | Strategy | Decides by | Bound / pick | State the balancer owns | Wins when |
47
+ | --- | --- | --- | --- | --- |
48
+ | **RoundRobin** | rotation | O(1) amortized | a cursor | equal weight, no load signal, simplest fair spread |
49
+ | **SmoothWRR** | fixed weight | O(cap) | smoothing accumulators (`_current`) | weighted **and** you want deterministic, smooth, low-variance interleaving |
50
+ | **WeightedRandom** | fixed weight | **O(1)** | a Vose alias table (`_prob`/`_alias`) | weighted at **very large** pools where SmoothWRR's O(cap) scan hurts; can accept sampling variance |
51
+ | **P2C** | in-flight load | **O(1)** | just a PRNG | O(1) load-balancing at scale; the `ln ln n` peak ceiling, a tiny gap vs exact |
52
+ | **LeastConn** | in-flight load | O(cap) | none (reads live) | the **exact** least-loaded in a feedback loop; dozens-hundreds of nodes |
53
+ | **SED** | (inflight+1)/weight | O(cap) | none (reads live) | **weighted** exact least-loaded (load settles proportional to weight) |
54
+ | **NQ** | idle-first, else SED | O(cap) | none (reads live) | **worker pools** -- never queue while a server is idle |
55
+ | **PeakEWMA** | (inflight+1) x decayed rtt | **O(1)** | EWMA rtt state (`_ewma`/`_stamp`) | you have latency and want to steer around a **slow-but-up** node |
56
+ | **ConsistentHash** | key hash (Maglev) | **O(1)** | a Maglev lookup table | **sticky** cache/session affinity; minimal disruption on scale events (~1/N keys move) |
57
+ | **BoundedLoad** | key hash + occupancy cap | **O(1)** | Maglev table + a running `_total` | sticky routing **and** a few hot keys would otherwise overload one backend |
58
+
59
+ ## SmoothWRR vs WeightedRandom -- the weighted fork, made explicit
60
+
61
+ Both send load proportional to a configured integer weight. They differ in HOW and in cost:
62
+
63
+ - **SmoothWRR** is DETERMINISTIC and SMOOTH: weights `[5,1,1]` yield `A A B A C A A`, not bursts. It
64
+ converges EXACTLY (counts == k x weight over a cycle) with the lowest variance. Cost: **O(cap) per
65
+ pick**, and it owns per-endpoint accumulator state that is maintained in lockstep.
66
+ - **WeightedRandom** is a STATELESS **O(1)** sample from a Vose alias table: one column draw + one
67
+ compare. It converges to the weight ratios by the law of large numbers (any single pick is random --
68
+ it pays SAMPLING VARIANCE). No accumulator to desync.
69
+
70
+ Rule of thumb: **small-to-medium pools or when smoothness matters -> SmoothWRR; very large pools where
71
+ the O(cap) scan hurts -> WeightedRandom.** For **frequently-changing** weights, a `@zakkster/lite-logn`
72
+ Fenwick tree (O(log n) update + sample) is the deferred dynamic-weight complement to WeightedRandom's
73
+ static alias table (rebuilt cold on reweight); see the roadmap.
74
+
75
+ ## Not sure you even want lite-pick? -- the sibling boundary
76
+
77
+ - **`@zakkster/lite-random` is NOT a load balancer.** It is a GAME RNG (Mulberry32) for loot tables,
78
+ particle systems, and gaussian sampling; its `weighted(items, weights) -> T` returns an **item**
79
+ one-shot, is not eligibility-aware, and holds no reusable table. For **game loot tables use
80
+ lite-random**; **lite-pick WeightedRandom is the eligibility-aware LB selector** (returns an endpoint
81
+ INDEX, honours the shared eligibility bitmap, owns a persistent alias table rebuilt only on reweight,
82
+ fail-closed). Different domain, different contract -- lite-random is not a peer or a substrate here.
83
+ - lite-pick is the **in-process** hop selector: it consumes health/circuit state and returns an index;
84
+ it is never a proxy. It is complementary to AWS NLB/ALB (which balance the network hop AWS sees) --
85
+ ALB's `weighted_random` + anomaly mitigation is lite-pick's WeightedRandom + BoundedLoad, and NLB's
86
+ flow-hash is ConsistentHash, at the hop AWS never sees.
87
+
88
+ ## Then wire it
89
+
90
+ Once you have picked a strategy, [RECIPES.md](./RECIPES.md) shows the real wiring: the shared
91
+ eligibility bitmap from a health source, the caller-owned in-flight / weight arrays, the `/pool`
92
+ dispatch/settle + failover layer, and the latency (`recordRtt`) / occupancy (`note`) / keyed
93
+ (`opts.key`) feedback hooks.
package/Pick.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- TypeScript declarations.
3
3
  *
4
- * M8 (0.8.0): substrate seams + RoundRobin + SmoothWRR + P2C + the exact LeastConn family
5
- * (LeastConn/SED/NQ) + PeakEWMA (latency-aware P2C) + ConsistentHash (Maglev table). The
6
- * remaining strategy classes (BoundedLoad, WeightedRandom) are added one per session.
4
+ * M10 (1.0.0): substrate seams + RoundRobin + SmoothWRR + P2C + the exact LeastConn family
5
+ * (LeastConn/SED/NQ) + PeakEWMA (latency-aware P2C) + ConsistentHash (Maglev table) +
6
+ * BoundedLoad (consistent hashing with bounded loads) + WeightedRandom (O(1) Vose alias-table
7
+ * sampling). Roster complete for now (NOT closed: AZ-aware routing, hedging, subsetting post-1.0).
7
8
  */
8
9
 
9
10
  /** The single source-of-truth version stamp. */
@@ -224,3 +225,80 @@ export class ConsistentHashBalancer extends BalancerBase {
224
225
  /** Map an integer `keyHash` to a backend index (bounded probe past down slots), or `PICK_NONE`. */
225
226
  pick(keyHash?: number): number;
226
227
  }
228
+
229
+ /**
230
+ * BoundedLoadBalancer -- Consistent Hashing with Bounded Loads (M9, CHBL: Mirrokni et al. / Google
231
+ * Research; Vimeo eps ~ 0.25). `ConsistentHashBalancer` (the Maglev table) PLUS an occupancy cap: a
232
+ * key sticks to its hashed home backend UNLESS that backend is over `cap = (1 + eps) * _total / live`,
233
+ * in which case the request OVERFLOWS along the same bounded forward-probe to the next eligible,
234
+ * under-cap backend -- keeping consistent hashing's stickiness + minimal disruption AND adding the
235
+ * HOTSPOT protection plain consistent hashing lacks. `pick(keyHash)` returns the first eligible,
236
+ * under-cap backend in the probe window, else falls back to the first eligible seen (sticky wins; the
237
+ * cap is a soft preference, never a dead pick); `_total === 0` skips the cap -> pure ConsistentHash.
238
+ * `inflight` is the caller-owned Uint32Array read LIVE as the per-backend OCCUPANCY; the running
239
+ * occupancy sum `_total` is BALANCER-OWNED and written ONLY by `note` (dispatch +1 / settle -1), so
240
+ * when using BoundedLoad the mirrored counter must be mutated exclusively through `note` / the /pool
241
+ * adapter (direct mutation desyncs `_total` -- UB). It inherits the Maglev table + `setWeight` /
242
+ * `rebuild` / `tableSize` from ConsistentHashBalancer (reused verbatim). `pick()` and `note()` are
243
+ * both 0 B/op / O(1). Fails closed (`PICK_NONE`) ONLY when no eligible backend is reachable within
244
+ * the probe window -- NEVER merely because backends are over cap. NOT the P2C-with-cap "overload"
245
+ * variant (that is byte-identical to P2C; the cap is only load-bearing on a sticky hash -- ADR 0011).
246
+ */
247
+ export class BoundedLoadBalancer extends ConsistentHashBalancer {
248
+ /**
249
+ * @param capacity backend count (fixed).
250
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
251
+ * @param inflight per-backend OCCUPANCY (length >= capacity), caller-owned, read live; mutated
252
+ * EXCLUSIVELY via `note` / the /pool adapter (direct mutation desyncs `_total` -- UB).
253
+ * @param eps the bounded-load slack over the mean (finite, > 0); default 0.25 (Vimeo).
254
+ * @param weights optional per-backend weights (length >= capacity), COPIED; null = equal weight.
255
+ * @param m the Maglev table size: a prime, > 1, and >= capacity (default 65537).
256
+ * @param seed deterministic salt for the permutation mix (default 0x9e3779b9); reproducible.
257
+ */
258
+ constructor(
259
+ capacity: number,
260
+ eligible: Uint8Array,
261
+ inflight: Uint32Array,
262
+ eps?: number,
263
+ weights?: Uint32Array | null,
264
+ m?: number,
265
+ seed?: number,
266
+ );
267
+ /** The balancer-owned running sum of in-flight the mean/cap is computed from. */
268
+ readonly totalInflight: number;
269
+ /** Warm feedback path: adjust the owned occupancy sum (dispatch +1 / settle -1). Clamps at 0. 0 B/op. */
270
+ note(i: number, delta: number): void;
271
+ /** Map an integer `keyHash` to a backend, honouring the occupancy cap (overflow past a hot home), or `PICK_NONE`. O(1). */
272
+ pick(keyHash?: number): number;
273
+ }
274
+
275
+ /**
276
+ * WeightedRandomBalancer -- O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (M10). `pick()`
277
+ * draws one column + one probability compare to return an endpoint proportional to its weight, with
278
+ * REJECTION-SAMPLING eligibility (retry an ineligible candidate up to a bounded count, then a 0-B/op
279
+ * rotated linear eligible scan). The alias table is built COLD over the eligible-INDEPENDENT weights
280
+ * (a weight-0 node is NEVER a column), so rejection renormalizes the weight distribution across the
281
+ * surviving eligible mass. `weights` is the caller-owned Uint32Array; the balancer is the SOLE writer of
282
+ * its derived table via cold `setWeight` / `rebuild` (direct weight mutation desyncs the table -- UB).
283
+ * An eligibility flap NEVER rebuilds. The stateless O(1) sample (no accumulators to desync) for VERY
284
+ * LARGE pools where SmoothWRR's O(cap) scan hurts -- trading smoothness for sampling variance. O(1),
285
+ * 0 B/op, never throws. Fails closed (`PICK_NONE`) IFF `live === 0` OR no eligible node has a positive
286
+ * weight. NOT `@zakkster/lite-random` (a game RNG returning an item; use lite-random for loot tables --
287
+ * this is the eligibility-aware LB index selector; see GUIDE.md / ADR 0012).
288
+ */
289
+ export class WeightedRandomBalancer extends BalancerBase {
290
+ /**
291
+ * @param capacity endpoint count (fixed).
292
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
293
+ * @param weights caller-owned per-endpoint weights (length >= capacity); mutate only via setWeight
294
+ * (the balancer is the sole writer of the derived alias table -- direct mutation is UB).
295
+ * @param seed deterministic PRNG seed (default 0x9e3779b9); reproducible benches.
296
+ */
297
+ constructor(capacity: number, eligible: Uint8Array, weights: Uint32Array, seed?: number);
298
+ /** Cold path: reconfigure endpoint `i`'s weight (uint32) and rebuild the alias table. */
299
+ setWeight(i: number, w: number): void;
300
+ /** Cold path: rebuild the alias table from the current caller weights (e.g. after a membership change). */
301
+ rebuild(): void;
302
+ /** Pick an endpoint index proportional to weight (eligibility by rejection sampling), or `PICK_NONE`. O(1). */
303
+ pick(): number;
304
+ }
package/Pick.js CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- zero-GC load-balancing SELECTION KERNEL.
3
3
  *
4
- * M8 (0.8.0): substrate seams + eight strategies -- RoundRobin, SmoothWRR, P2C, the exact
5
- * LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C), and ConsistentHash
6
- * (a Maglev lookup table). 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:
7
9
  *
8
10
  * - VERSION the single source-of-truth version stamp (3-place sync).
9
11
  * - PICK_NONE the fail-closed sentinel (-1): "no endpoint", never a dead pick.
@@ -38,6 +40,24 @@
38
40
  * caller-supplied INTEGER (no per-pick string hashing = the one zero-GC hazard);
39
41
  * the balancer OWNS the Uint32Array table + weights, rebuilt COLD on membership /
40
42
  * weight change (health flap is handled by the probe, never a rebuild).
43
+ * - BoundedLoadBalancer Consistent Hashing with Bounded Loads (CHBL, Mirrokni et al. / Google
44
+ * Research; Vimeo eps=0.25): ConsistentHash (the Maglev table) PLUS an occupancy
45
+ * cap. pick(keyHash) sticks a key to its hashed home UNLESS that backend is over
46
+ * cap = (1+eps) x _total / live, in which case the request OVERFLOWS along the same
47
+ * bounded probe to the next eligible under-cap backend -- consistent hashing's
48
+ * stickiness + minimal disruption PLUS the hotspot protection plain CH lacks. It
49
+ * extends ConsistentHashBalancer (reusing its Maglev build + probe VERBATIM) and
50
+ * OWNS a running `_total` (sole writer: the warm note(i, delta) seam). O(1), 0 B/op.
51
+ * - WeightedRandomBalancer O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (one
52
+ * column draw + one probability compare -> a candidate), with rejection-sampling
53
+ * eligibility (retry an ineligible candidate up to a bounded count, then a 0-B/op
54
+ * rotated linear eligible scan). The alias table is built COLD over the eligible-
55
+ * INDEPENDENT weights (a weight-0 node is NEVER a column), so rejection over the
56
+ * bitmap renormalizes the weight distribution across the SURVIVING eligible mass.
57
+ * The balancer OWNS its derived table (_prob/_alias) and is its SOLE writer via cold
58
+ * setWeight/rebuild (the SmoothWRR precedent); an eligibility flap never rebuilds.
59
+ * The stateless O(1) sample (no accumulators to desync) for VERY LARGE pools where
60
+ * SmoothWRR's O(cap) scan hurts. O(1), 0 B/op. (Vose 1991 / Walker alias method.)
41
61
  *
42
62
  * The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
43
63
  * It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
@@ -45,9 +65,12 @@
45
65
  * counters live OUTSIDE the kernel. The steady-state pick path allocates 0 B/op.
46
66
  *
47
67
  * Roster (one strategy per session -- see ROADMAP.md): RoundRobin [M1], SmoothWRR [M2],
48
- * P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA [M7], ConsistentHash [M8], BoundedLoad,
49
- * WeightedRandom [planned]. The EXACT-O(log n) fewest-in-flight variant is a deferred
50
- * @zakkster/lite-logn BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan.
68
+ * P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA [M7], ConsistentHash [M8], BoundedLoad [M9],
69
+ * WeightedRandom [M10] -- roster complete for now (NOT closed: AZ-aware routing, hedging, and
70
+ * subsetting are queued post-1.0). The EXACT-O(log n) fewest-in-flight variant is a deferred
71
+ * @zakkster/lite-logn BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan;
72
+ * a lite-logn Fenwick tree is the deferred DYNAMIC-weight complement to WeightedRandom's static
73
+ * alias table, and lite-o1 AliasTable a deferred duck-typed optional-peer upgrade for the build.
51
74
  *
52
75
  * M5 (0.5.0) adds the ergonomic request layer at the @zakkster/lite-pick/pool subpath (a
53
76
  * SEPARATE file, Pool.js -- the async dispatch/settle counter wrapper + distinct-endpoint
@@ -58,7 +81,7 @@
58
81
  */
59
82
 
60
83
  /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
61
- export const VERSION = '0.8.0';
84
+ export const VERSION = '1.0.0';
62
85
 
63
86
  /**
64
87
  * Fail-closed sentinel returned by pick() when no endpoint is eligible.
@@ -976,3 +999,310 @@ export class ConsistentHashBalancer extends BalancerBase {
976
999
  return PICK_NONE;
977
1000
  }
978
1001
  }
1002
+
1003
+ /**
1004
+ * BoundedLoadBalancer -- Consistent Hashing with Bounded Loads (M9, CHBL: Mirrokni-Thorup-
1005
+ * Zadimoghaddam, Google Research 2016; Vimeo's eps ~ 0.25). This is `ConsistentHashBalancer` (the M8
1006
+ * Maglev table) PLUS an occupancy CAP: a key sticks to its hashed home backend UNLESS that backend is
1007
+ * over the cap, in which case the request OVERFLOWS along the same bounded forward-probe to the next
1008
+ * eligible, under-cap backend. It keeps consistent hashing's stickiness + minimal disruption AND adds
1009
+ * the HOTSPOT protection plain consistent hashing lacks: a few very hot keys can pile unbounded load
1010
+ * on one backend, so the cap spreads the overflow to neighbours while everything else stays put.
1011
+ *
1012
+ * Why this is the REAL bounded-load strategy (ADR 0011): P2C-over-inflight with a `(1+eps) x mean` cap
1013
+ * is byte-identical to plain P2C (an under-cap draw ALWAYS has lower inflight than an over-cap one, so
1014
+ * "prefer under-cap" and "lower-of-two" pick the same node) -- the cap is a no-op there. The cap is
1015
+ * only LOAD-BEARING when the primary choice is fixed by something OTHER than load: a hash. CHBL is
1016
+ * that -- the hashed home is sticky, and the cap is what lets a hot home overflow.
1017
+ *
1018
+ * `pick(keyHash)` (HOT, 0 B/op, NEVER throws): k = keyHash >>> 0; slot = k % M; walk the M8 probe
1019
+ * window (home + CH_PROBE_LIMIT slots) and return the FIRST backend that is ELIGIBLE AND UNDER cap
1020
+ * (`inflight[b] < cap`). If none in the window is under cap, FALL BACK to the first eligible seen
1021
+ * (sticky wins; the cap is a soft preference, never a dead pick). When `_total === 0` the cap test is
1022
+ * skipped entirely -> behaves as pure ConsistentHash. `cap = (1 + eps) x _total / live`.
1023
+ *
1024
+ * Ownership (ADR 0001, ADR 0004, ADR 0010, ADR 0011): the Maglev lookup table + weights are
1025
+ * BALANCER-OWNED and built COLD (reused from ConsistentHashBalancer VERBATIM -- `_build`, `setWeight`,
1026
+ * `rebuild`, `tableSize`, the probe walk, `chMix32`, `CH_DEFAULT_M`, `CH_PROBE_LIMIT`). `inflight` is
1027
+ * the CALLER's Uint32Array, read LIVE as the per-backend OCCUPANCY source. The running occupancy sum
1028
+ * `_total` is BALANCER-OWNED and its SOLE writer is the warm `note(i, delta)` feedback path
1029
+ * (dispatch +1 / settle -1), so the cap's mean stays O(1)-current without a scan.
1030
+ *
1031
+ * CONTRACT (the SmoothWRR-weights asymmetry): when using BoundedLoad the mirrored inflight counter is
1032
+ * mutated ONLY through `note()` / the /pool adapter. Direct mutation desyncs `_total` from the true
1033
+ * sum, so the cap goes wrong -- UB. `note()` clamps `_total` at 0; `totalInflight` exposes it.
1034
+ *
1035
+ * Bound: O(1) per pick (modulo + table read + bounded cap-aware probe), 0 B/op on BOTH `pick()` and
1036
+ * `note()` (torture + PerfGate). Fails closed (PICK_NONE) ONLY when no eligible backend is reachable
1037
+ * within the probe window (M8's contract) -- NEVER merely because backends are over cap.
1038
+ */
1039
+ export class BoundedLoadBalancer extends ConsistentHashBalancer {
1040
+ /**
1041
+ * @param {number} capacity backend count (fixed; add/remove is a cold rebuild).
1042
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
1043
+ * @param {Uint32Array} inflight per-backend OCCUPANCY (length >= capacity), caller-owned and read
1044
+ * LIVE -- but mutated EXCLUSIVELY via note() / the /pool adapter (direct mutation desyncs the
1045
+ * owned _total -- UB).
1046
+ * @param {number} [eps=0.25] the bounded-load slack over the mean: finite, > 0. cap =
1047
+ * (1 + eps) x mean occupancy. Default 0.25 (Vimeo).
1048
+ * @param {Uint32Array|null} [weights=null] optional per-backend weights (COPIED); null = equal.
1049
+ * @param {number} [m=CH_DEFAULT_M] the Maglev table size: a prime, > 1, and >= capacity.
1050
+ * @param {number} [seed=0x9e3779b9] deterministic salt for the permutation mix (reproducible).
1051
+ */
1052
+ constructor(capacity, eligible, inflight, eps = 0.25, weights = null, m = CH_DEFAULT_M, seed = 0x9e3779b9) {
1053
+ // Validate inflight + eps typeof-first, BEFORE super() allocates the (cold, ~256KB) Maglev
1054
+ // table (fail closed early -- the PeakEWMA / ConsistentHash precedent). These read the args
1055
+ // only (no `this`), so they may run before super().
1056
+ if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
1057
+ throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
1058
+ }
1059
+ if (typeof eps !== 'number') {
1060
+ throw new TypeError('[lite-pick] eps must be a number');
1061
+ }
1062
+ if (!Number.isFinite(eps) || eps <= 0) {
1063
+ throw new RangeError('[lite-pick] eps must be a finite number > 0');
1064
+ }
1065
+ // super() validates capacity/eligible/weights/m, copies weights, and builds the Maglev table.
1066
+ super(capacity, eligible, weights, m, seed);
1067
+ this._inflight = inflight;
1068
+ this._eps = eps;
1069
+ // The running occupancy sum the balancer OWNS. Starts at 0: note() is its sole writer, so a
1070
+ // caller must drive dispatch/settle through note() (or /pool) -- inflight seeded non-zero
1071
+ // BEFORE construction would desync it (UB, as documented).
1072
+ this._total = 0;
1073
+ }
1074
+
1075
+ /** The balancer-owned running sum of in-flight the mean/cap is computed from. Readonly. */
1076
+ get totalInflight() {
1077
+ return this._total;
1078
+ }
1079
+
1080
+ /**
1081
+ * Warm feedback path (NOT the hot pick path): adjust the owned running occupancy sum by `delta`
1082
+ * for backend `i`. This is the SOLE writer of `_total`: dispatch is note(i, +1), settle is
1083
+ * note(i, -1), so the cap's mean stays O(1)-current without scanning inflight. `i` is validated in
1084
+ * range (like setEligible); `delta` is validated typeof-first as an integer. `_total` clamps at 0
1085
+ * (an over-decrement never drives the mean negative). Zero-alloc on the success path.
1086
+ * @param {number} i backend index (validated in range)
1087
+ * @param {number} delta integer occupancy change (+1 dispatch, -1 settle)
1088
+ */
1089
+ note(i, delta) {
1090
+ if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
1091
+ if (typeof delta !== 'number') throw new TypeError('[lite-pick] delta must be a number');
1092
+ if (!Number.isInteger(delta)) throw new RangeError('[lite-pick] delta must be an integer: ' + delta);
1093
+ const t = this._total + delta;
1094
+ this._total = t > 0 ? t : 0; // clamp: over-decrement never drives the mean negative
1095
+ }
1096
+
1097
+ /**
1098
+ * Map an INTEGER key to a backend, honouring the occupancy cap, or PICK_NONE (fail closed). O(1),
1099
+ * 0 B/op, never throws. slot = (keyHash >>> 0) % M; walk the M8 probe window (home + CH_PROBE_LIMIT
1100
+ * slots) and return the FIRST backend that is ELIGIBLE AND under cap = (1+eps) x _total / live. If
1101
+ * none in the window is under cap, fall back to the FIRST eligible seen (sticky wins -- the cap is
1102
+ * a soft preference, never a dead pick). `_total === 0` skips the cap test -> pure ConsistentHash.
1103
+ * PICK_NONE ONLY when no eligible backend is reachable within the window.
1104
+ * @param {number} keyHash a caller-supplied integer key hash (coerced to uint32)
1105
+ * @returns {number}
1106
+ */
1107
+ pick(keyHash) {
1108
+ if (this._live === 0) return PICK_NONE; // whole pool down: fail closed
1109
+ const M = this._m, el = this._eligible, lookup = this._lookup, inf = this._inflight;
1110
+ const total = this._total;
1111
+ // cap is only meaningful once occupancy is known; _total === 0 -> pure ConsistentHash.
1112
+ const capActive = total > 0;
1113
+ const cap = capActive ? (1 + this._eps) * total / this._live : 0; // finite: total>0, live>0
1114
+ let slot = (keyHash >>> 0) % M; // integer key; NaN >>> 0 = 0 (never throws)
1115
+ let firstEligible = -1; // the pure-ConsistentHash sticky fallback answer
1116
+ let i = lookup[slot];
1117
+ if (el[i]) {
1118
+ if (!capActive || inf[i] < cap) return i; // sticky home, under cap: the common fast path
1119
+ firstEligible = i;
1120
+ }
1121
+ // Bounded forward-probe (M8's exact walk): the first eligible AND under-cap backend wins; a hot
1122
+ // home OVERFLOWS to its neighbours. Past the window we fall back to the sticky first-eligible.
1123
+ for (let p = 0; p < CH_PROBE_LIMIT; p++) {
1124
+ slot++;
1125
+ if (slot >= M) slot = 0;
1126
+ i = lookup[slot];
1127
+ if (el[i]) {
1128
+ if (!capActive || inf[i] < cap) return i; // eligible + under cap: overflow target
1129
+ if (firstEligible < 0) firstEligible = i; // remember the first eligible (fallback)
1130
+ }
1131
+ }
1132
+ return firstEligible; // -1 (PICK_NONE) iff NO eligible backend was reachable in the window
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * WeightedRandomBalancer -- O(1) weighted-random selection via a Vose/Walker ALIAS TABLE (M10),
1138
+ * the roster-completing strategy.
1139
+ *
1140
+ * `pick()` draws ONE column uniformly (`prng.nextBelow(cap)`), compares one fresh uniform against
1141
+ * `_prob[col]`, and takes `col` or `_alias[col]` -- a constant handful of integer/float ops that
1142
+ * return an endpoint proportional to its weight. This is the STATELESS O(1) weighted selector: no
1143
+ * per-endpoint accumulator to desync (SmoothWRR's `_current`), just a static table sampled with a
1144
+ * PRNG -- the fit for VERY LARGE pools where SmoothWRR's O(cap)-per-pick scan hurts. It trades
1145
+ * SmoothWRR's deterministic low-variance smoothness for sampling variance (any single pick is
1146
+ * random; the LAW OF LARGE NUMBERS delivers the weight ratios over a run -- balance.mjs anchors it).
1147
+ *
1148
+ * ELIGIBILITY is REJECTION SAMPLING over the shared bitmap (the ADR 0005 / P2C discipline, not a
1149
+ * table rebuild): if the drawn candidate is ineligible, redraw up to a bounded 64 times, then fall
1150
+ * back to a 0-B/op rotated linear scan from a random start for the degenerate heavy-outage case.
1151
+ * Because the alias table is built over the ELIGIBLE-INDEPENDENT weights and a candidate is ALWAYS
1152
+ * a positive-weight node (a weight-0 node is never a column -- see _build), rejecting the ineligible
1153
+ * draws RENORMALIZES the weight distribution over the SURVIVING eligible mass: each eligible node's
1154
+ * long-run share converges to weight[i] / sum(eligible weights) (ADR 0012 Fork 1). The rare fallback
1155
+ * scan returns the first eligible positive-weight node from a random offset (unbiased first-after-
1156
+ * offset), a correctness net, not a proportional path.
1157
+ *
1158
+ * Ownership (ADR 0001, ADR 0004, ADR 0012): `weights` is the CALLER's Uint32Array (length >= capacity)
1159
+ * -- the SmoothWRR / SED weight seam -- and the balancer is the SOLE writer of its DERIVED alias table
1160
+ * (`_prob` Float64Array + `_alias` Int32Array, both balancer-owned) via the cold `setWeight` / `rebuild`
1161
+ * (which read `weights` and rebuild the table); mutating `weights` directly desyncs the table (UB, the
1162
+ * SmoothWRR asymmetry). The alias build reuses COLD scratch worklists allocated once in the ctor -- the
1163
+ * build allocates nothing per call, and pick() allocates nothing per call.
1164
+ *
1165
+ * Fail-closed (ADR 0012 Fork 2): `pick()` returns PICK_NONE (-1) IFF `live === 0` OR no eligible node
1166
+ * has a positive weight (all-zero weights, or every eligible node's weight is 0). NEVER a dead pick,
1167
+ * a weight-0 return, or an out-of-range index. `pick()` never throws.
1168
+ *
1169
+ * Bound: O(1) per pick (one column draw + one compare, expected O(1) rejection draws when eligibility
1170
+ * is dense), 0 B/op (integer/float locals only) -- proven by test/torture.mjs + test/perf/PerfGate.test.mjs.
1171
+ *
1172
+ * DEFERRED optional-peer seams (import NOTHING; peerDependencies STAYS `{}` until a shipped path imports
1173
+ * one): a `@zakkster/lite-o1` `AliasTable` as a duck-typed drop-in for the inline Vose build, and a
1174
+ * `@zakkster/lite-logn` Fenwick/BinaryIndexedTree for the DYNAMIC-weight case (O(log n) update + sample)
1175
+ * -- the mutable-weight complement to this static table's O(1) sample / O(cap) rebuild (ADR 0012).
1176
+ *
1177
+ * NOT `@zakkster/lite-random`: that sibling is a GAME RNG (Mulberry32; loot tables, particles, gaussian)
1178
+ * whose `weighted(items, weights)` returns an ITEM one-shot, is NOT eligibility-aware, holds no reusable
1179
+ * table, and uses a different PRNG. lite-pick's WeightedRandom returns an endpoint INDEX, honours the
1180
+ * shared eligibility bitmap (fail-closed), owns a persistent alias table rebuilt only on reweight, and
1181
+ * uses the in-repo xorshift32. Different domain + contract -- not a peer, not a substrate (ADR 0012 / GUIDE.md).
1182
+ */
1183
+ export class WeightedRandomBalancer extends BalancerBase {
1184
+ /**
1185
+ * @param {number} capacity endpoint count (fixed; add/remove is a cold rebuild).
1186
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
1187
+ * @param {Uint32Array} weights caller-owned per-endpoint weights (length >= capacity); the balancer
1188
+ * is the sole writer of the DERIVED alias table via setWeight (direct mutation desyncs it -- UB).
1189
+ * @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
1190
+ */
1191
+ constructor(capacity, eligible, weights, seed = 0x9e3779b9) {
1192
+ super(capacity, eligible);
1193
+ // Validate typeof-first, BEFORE allocating the owned table / scratch (fail closed early -- the
1194
+ // PeakEWMA / ConsistentHash / BoundedLoad discipline).
1195
+ if (!(weights instanceof Uint32Array) || weights.length < capacity) {
1196
+ throw new RangeError('[lite-pick] weights must be a Uint32Array of length >= capacity');
1197
+ }
1198
+ this._weights = weights;
1199
+ this._rng = new Prng(seed);
1200
+ // Balancer-owned derived table: _prob (the split probability per column) + _alias (the column's
1201
+ // alternate). A candidate is ALWAYS a positive-weight node (see _build), so pick() never returns
1202
+ // a weight-0 index.
1203
+ this._prob = new Float64Array(capacity);
1204
+ this._alias = new Int32Array(capacity);
1205
+ // COLD scratch worklists for the Vose build (small/large index stacks + the scaled probabilities),
1206
+ // allocated ONCE here and reused by every _build -- the build never allocates per call.
1207
+ this._small = new Int32Array(capacity);
1208
+ this._large = new Int32Array(capacity);
1209
+ this._scaled = new Float64Array(capacity);
1210
+ this._psum = 0; // sum of ALL weights (the eligible-independent normalizer); 0 => degenerate.
1211
+ this._builds = 0; // COLD rebuild counter (observability / the anti-flap gate: a flap adds 0).
1212
+ this._build();
1213
+ }
1214
+
1215
+ /**
1216
+ * COLD: (re)build the Vose/Walker alias table from the current caller weights. The standard
1217
+ * small/large worklist over `scaled[i] = weights[i] * cap / total` (mean-1 normalization): pair a
1218
+ * deficient (< 1) column with a surplus (>= 1) one until one worklist empties, then drain the
1219
+ * residue (numerically ~1 full columns) to prob 1. A weight-0 node has scaled 0, so it is popped
1220
+ * once, assigned prob 0 + a POSITIVE-weight alias, and NEVER reaches the prob-1 drain -- it can
1221
+ * never be returned as its own column. All-zero weights (total 0) leaves _psum 0 and pick() fails
1222
+ * closed. Reuses the cold scratch worklists -- allocates nothing. ~15 lines (do NOT re-implement
1223
+ * lite-o1's AliasTable; this is the inline standard build, ADR 0012 Fork 0).
1224
+ */
1225
+ _build() {
1226
+ this._builds++;
1227
+ const cap = this._cap, wt = this._weights, prob = this._prob, alias = this._alias;
1228
+ const scaled = this._scaled, small = this._small, large = this._large;
1229
+ let total = 0;
1230
+ for (let i = 0; i < cap; i++) total += wt[i];
1231
+ this._psum = total;
1232
+ if (total <= 0) {
1233
+ // Degenerate all-zero weights: no positive-weight column. pick() short-circuits on _psum===0
1234
+ // (PICK_NONE), so the table is never read -- fill it defensively (each column self-referential).
1235
+ for (let i = 0; i < cap; i++) { prob[i] = 0; alias[i] = i; }
1236
+ return;
1237
+ }
1238
+ const scale = cap / total;
1239
+ let ns = 0, nl = 0; // small / large stack heights (indices into the scratch)
1240
+ for (let i = 0; i < cap; i++) {
1241
+ const v = wt[i] * scale;
1242
+ scaled[i] = v;
1243
+ if (v < 1) small[ns++] = i; else large[nl++] = i;
1244
+ }
1245
+ while (ns > 0 && nl > 0) {
1246
+ const s = small[--ns];
1247
+ const l = large[--nl];
1248
+ prob[s] = scaled[s];
1249
+ alias[s] = l; // l is surplus (scaled >= 1) => positive weight
1250
+ const rem = (scaled[l] + scaled[s]) - 1;
1251
+ scaled[l] = rem;
1252
+ if (rem < 1) small[ns++] = l; else large[nl++] = l;
1253
+ }
1254
+ while (nl > 0) { const l = large[--nl]; prob[l] = 1; alias[l] = l; } // full columns
1255
+ while (ns > 0) { const s = small[--ns]; prob[s] = 1; alias[s] = s; } // float residue ~1
1256
+ }
1257
+
1258
+ /**
1259
+ * COLD: reconfigure endpoint i's weight (uint32) and REBUILD the alias table from the new weights.
1260
+ * The balancer is the sole writer of the derived table (the SmoothWRR / ConsistentHash precedent).
1261
+ * @param {number} i
1262
+ * @param {number} w new weight (uint32)
1263
+ */
1264
+ setWeight(i, w) {
1265
+ if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
1266
+ const nw = w >>> 0;
1267
+ if (nw !== w) throw new RangeError('[lite-pick] weight must be a uint32: ' + w);
1268
+ if (nw === this._weights[i]) return;
1269
+ this._weights[i] = nw;
1270
+ this._build();
1271
+ }
1272
+
1273
+ /** COLD: rebuild the alias table from the current caller weights (e.g. after a membership change). */
1274
+ rebuild() {
1275
+ this._build();
1276
+ }
1277
+
1278
+ /**
1279
+ * Pick an endpoint index proportional to weight, or PICK_NONE (fail closed). O(1), 0 B/op, never
1280
+ * throws. One column draw + one probability compare yields a positive-weight candidate; an
1281
+ * ineligible candidate is rejection-redrawn up to 64 times (renormalizing the weight distribution
1282
+ * over the eligible mass), then a rotated linear scan from a random start returns the first eligible
1283
+ * positive-weight node. PICK_NONE IFF live === 0 OR no eligible node has a positive weight.
1284
+ * @returns {number}
1285
+ */
1286
+ pick() {
1287
+ if (this._live === 0 || this._psum === 0) return PICK_NONE; // pool down / no positive weight
1288
+ const cap = this._cap, el = this._eligible, prob = this._prob, alias = this._alias, rng = this._rng;
1289
+ // Fast path: alias draw + rejection on eligibility. A candidate is always positive-weight, so
1290
+ // rejecting the ineligible ones renormalizes weight-proportionality over the surviving mass.
1291
+ for (let t = 0; t < 64; t++) {
1292
+ const col = rng.nextBelow(cap);
1293
+ const u = rng.next() / 4294967296; // fresh uniform in [0, 1)
1294
+ const cand = u < prob[col] ? col : alias[col];
1295
+ if (el[cand]) return cand;
1296
+ }
1297
+ // Degenerate (very sparse eligibility): scan from a random start for the first eligible,
1298
+ // positive-weight node. Zero-alloc; returns PICK_NONE only if none exists.
1299
+ const wt = this._weights;
1300
+ let i = rng.nextBelow(cap);
1301
+ for (let k = 0; k < cap; k++) {
1302
+ if (el[i] && wt[i] > 0) return i;
1303
+ i++;
1304
+ if (i >= cap) i = 0;
1305
+ }
1306
+ return PICK_NONE; // no eligible positive-weight node
1307
+ }
1308
+ }
package/Pool.d.ts CHANGED
@@ -10,11 +10,14 @@ export const VERSION: string;
10
10
 
11
11
  /** The minimal balancer shape Pool drives (any lite-pick strategy satisfies it). */
12
12
  export interface Balancer {
13
- pick(now?: number): number;
13
+ /** `now` (PeakEwma clock) or `keyHash` (ConsistentHash/BoundedLoad) when supplied via opts. */
14
+ pick(arg?: number): number;
14
15
  readonly capacity: number;
15
16
  readonly live: number;
16
17
  /** Optional latency-feedback sink (PeakEwmaBalancer); fed on settle when a clock is supplied. */
17
18
  recordRtt?(i: number, sampleNs: number, now: number): void;
19
+ /** Optional occupancy sink (BoundedLoadBalancer); fed +1 on dispatch, -1 on settle. */
20
+ note?(i: number, delta: number): void;
18
21
  }
19
22
 
20
23
  /** Options for `Pool.run`. */
@@ -28,6 +31,12 @@ export interface RunOptions {
28
31
  * `recordRtt` latency feedback for a latency-aware balancer (PeakEwma); otherwise inert.
29
32
  */
30
33
  clock?: () => number;
34
+ /**
35
+ * An integer routing key for a keyed balancer (ConsistentHash / BoundedLoad). When present,
36
+ * `run` calls `pick(key)`; the opt-in `note` occupancy hook is driven on dispatch/settle for
37
+ * a bounded-load balancer. Ignored by non-keyed strategies.
38
+ */
39
+ key?: number;
31
40
  }
32
41
 
33
42
  /**
package/Pool.js CHANGED
@@ -77,13 +77,27 @@ export class Pool {
77
77
  * Pool stays generic, the in-flight counter stays net-zero, and abort/failover are unaffected.
78
78
  * The kernel `pick()` remains 0 B/op; this wrapper is not held to that bar.
79
79
  *
80
+ * An OCCUPANCY-AWARE balancer (BoundedLoadBalancer -- anything duck-typing `note`) has each
81
+ * dispatch mirrored as `note(i, +1)` and each settle as `note(i, -1)`, so its owned running mean
82
+ * stays current; against a balancer with no `note` the hook is fully INERT (same net-zero,
83
+ * generic behaviour). The two hooks are independent -- a balancer may duck-type neither, one, or
84
+ * both.
85
+ *
86
+ * A KEYED balancer (ConsistentHashBalancer / BoundedLoadBalancer -- CHBL) routes by an INTEGER
87
+ * key. When `opts.key` is supplied, Pool drives `pick(key)` (sticky / bounded-load routing);
88
+ * failover re-picks with the SAME key, and because the failed backend's occupancy stays elevated
89
+ * (its `note(+1)` held across attempts) a CHBL re-pick naturally OVERFLOWS to the next backend.
90
+ * Without `opts.key`, `pick()` / `pick(now)` behaviour is unchanged.
91
+ *
80
92
  * @template T
81
93
  * @param {(endpoint: number, signal?: AbortSignal) => (Promise<T>|T)} fn the per-endpoint work.
82
- * @param {{ signal?: AbortSignal, tries?: number, clock?: () => number }} [opts] `tries`
83
- * (default 1 = no failover) is the max number of distinct-endpoint attempts; `signal` is
94
+ * @param {{ signal?: AbortSignal, tries?: number, clock?: () => number, key?: number }} [opts]
95
+ * `tries` (default 1 = no failover) is the max number of distinct-endpoint attempts; `signal` is
84
96
  * passed to `fn` and, when already aborted after a failure, stops failover (the abort
85
97
  * propagates, no re-pick); `clock` is a caller-owned nanosecond source that, when present,
86
- * drives `pick(now)` and the opt-in `recordRtt` latency feedback for a latency-aware balancer.
98
+ * drives `pick(now)` and the opt-in `recordRtt` latency feedback for a latency-aware balancer;
99
+ * `key` is a caller-supplied INTEGER key that, when present, drives `pick(key)` for a keyed
100
+ * balancer (sticky / CHBL routing).
87
101
  * @returns {Promise<T>}
88
102
  */
89
103
  async run(fn, opts) {
@@ -92,16 +106,27 @@ export class Pool {
92
106
  const tries = rawTries > 0 ? rawTries : 1;
93
107
  const signal = opts ? opts.signal : undefined;
94
108
  const clock = opts && typeof opts.clock === 'function' ? opts.clock : undefined;
109
+ // A keyed balancer (ConsistentHash / BoundedLoad -- CHBL) picks by an INTEGER key. When
110
+ // `opts.key` is supplied, pick(key) drives selection; otherwise the existing pick()/pick(now)
111
+ // behaviour is unchanged. `keyed` is true exactly when a key was passed.
112
+ const keyed = opts !== undefined && opts.key !== undefined;
113
+ const key = keyed ? opts.key : undefined;
95
114
  const inflight = this._inflight, b = this._b;
96
115
  // Opt-in latency feedback: only when BOTH a clock is supplied AND the balancer duck-types
97
116
  // recordRtt. Otherwise inert -- Pool stays generic and byte-for-byte behaviour is unchanged.
98
117
  const rtt = clock !== undefined && typeof b.recordRtt === 'function';
118
+ // Opt-in occupancy feedback (BoundedLoadBalancer): when the balancer duck-types note(), Pool
119
+ // mirrors each dispatch(+1)/settle(-1) into it so the balancer's owned _total mean stays
120
+ // O(1)-current. Otherwise inert -- Pool stays generic, in-flight stays net-zero, and
121
+ // abort/failover are unchanged. Follows the exact opt-in shape the recordRtt hook uses.
122
+ const notes = typeof b.note === 'function';
99
123
  const held = []; // endpoints incremented this run (kept elevated across failover)
100
124
  let lastErr;
101
125
  try {
102
126
  for (let attempt = 0; attempt < tries; attempt++) {
103
127
  const now = clock !== undefined ? clock() : undefined;
104
- const i = b.pick(now);
128
+ // A keyed pick (opts.key) takes precedence -- sticky/CHBL routing; else pick(now)/pick().
129
+ const i = keyed ? b.pick(key) : b.pick(now);
105
130
  if (i === PICK_NONE) {
106
131
  if (attempt === 0) {
107
132
  const e = new Error('[lite-pick] no eligible endpoint');
@@ -112,6 +137,7 @@ export class Pool {
112
137
  }
113
138
  inflight[i] = (inflight[i] + 1) >>> 0;
114
139
  held.push(i);
140
+ if (notes) b.note(i, 1); // mirror the dispatch into the balancer's occupancy sum
115
141
  try {
116
142
  const out = await fn(i, signal);
117
143
  if (rtt) { // successful settle: feed the measured rtt back to the balancer
@@ -130,6 +156,7 @@ export class Pool {
130
156
  for (let k = 0; k < held.length; k++) {
131
157
  const j = held[k];
132
158
  inflight[j] = inflight[j] > 0 ? inflight[j] - 1 : 0;
159
+ if (notes) b.note(j, -1); // net-zero settle -- keeps _total in lockstep per run
133
160
  }
134
161
  }
135
162
  }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- > Zero-GC load-balancing **selection kernel**: one hot `pick()` that returns an endpoint **index** over a fixed pool and allocates **0 B/op** on the steady-state path. A pure selector, never a proxy -- it consumes health and circuit state, it never owns them. **v0.8.0 ships eight strategies -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, the latency-aware `PeakEwmaBalancer`, and the sticky/affinity `ConsistentHashBalancer` (a Maglev lookup table)** -- on the substrate seams (`VERSION`, `PICK_NONE`, a deterministic `Prng`, and `BalancerBase`'s shared read-only eligibility view), plus a **`@zakkster/lite-pick/pool`** subpath: the async dispatch/settle counter layer with distinct-endpoint failover and a duck-typed query-cache fetcher. The rest of the roster -- BoundedLoad, WeightedRandom -- lands one per session.
3
+ > Zero-GC load-balancing **selection kernel**: one hot `pick()` that returns an endpoint **index** over a fixed pool and allocates **0 B/op** on the steady-state path. A pure selector, never a proxy -- it consumes health and circuit state, it never owns them. **v1.0.0 ships the complete ten-strategy roster -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, the latency-aware `PeakEwmaBalancer`, the sticky/affinity `ConsistentHashBalancer` (a Maglev lookup table), the hotspot-protecting `BoundedLoadBalancer` (consistent hashing with bounded loads -- sticky routing + an occupancy cap that overflows a hot backend to its neighbours), and `WeightedRandomBalancer` (O(1) Vose alias-table sampling with rejection-sampling eligibility)** -- on the substrate seams (`VERSION`, `PICK_NONE`, a deterministic `Prng`, and `BalancerBase`'s shared read-only eligibility view), plus a **`@zakkster/lite-pick/pool`** subpath: the async dispatch/settle counter layer with distinct-endpoint failover and a duck-typed query-cache fetcher. Not sure which strategy? See **[GUIDE.md](./GUIDE.md)**.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-pick.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-pick)
6
6
  [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
@@ -21,7 +21,7 @@ The npm landscape has old algorithm libraries (`load-balancers`, `loadbalance`,
21
21
  - **Two pieces of evidence, both shipped.** A **0 B/op** witness on the pick path (no object, closure, string, or array created per pick), and a measured **balance-quality anchor** -- peak-to-average load within the strategy's theoretical ceiling (for P2C, the Azar-Broder-Karlin-Upfal `ln ln n / ln 2` bound) and strictly better than a random foil.
22
22
  - **A pure selector, not a proxy.** It **consumes** health and circuit state; it never owns them. Health is a shared read-only bitmap written by [`@zakkster/lite-di-health`](https://www.npmjs.com/package/@zakkster/lite-di-health); circuit state comes from [`@zakkster/lite-statechart`](https://www.npmjs.com/package/@zakkster/lite-statechart); load counters are caller-owned typed arrays. `pick()` only reads.
23
23
 
24
- > **Status: M8 (v0.8.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, the latency-aware `PeakEwmaBalancer`, and the sticky/affinity `ConsistentHashBalancer` (a Maglev table)**, the **`@zakkster/lite-pick/pool`** request layer (now with an opt-in latency-feedback hook), and the **benchmark suite** -- the balance anchor + GC blast-radius headlines, a seeded/version-stamped `results.json`, a `bench:verify` drift check with teeth, and the vs-AWS positioning (see *Evidence* below). This session APPENDS one class: the other strategies in `Pick.js` are byte-identical, only the header roster/count and the `VERSION` stamp change. Every strategy is gated: `pick()` proven **0 B/op** (torture + PerfGate), RoundRobin **perfectly fair** with **zero dead picks** vs the naive `i++ % n` foil, SmoothWRR **exactly weighted** and **smooth**, **P2C proves the `ln ln n` balance ceiling** (peak-to-mean gap ~2 vs a random foil's ~21 at n=1024), **LeastConn is greedy-perfect** (max-minus-min load <= 1), **SED tracks weight within 1%**, **PeakEWMA steers around a 10x-slow node** (it takes <= 25% of P2C's share for it and cuts service p99), and **ConsistentHash remaps only ~1.6% of keys on a scale event** (vs ~98% for naive modulo) -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M8 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), the RoundRobin (0003), SmoothWRR (0004), P2C (0005), LeastConn-family (0006), pool-adapter (0007), benchmark-suite (0008), PeakEWMA (0009), and ConsistentHash (0010) design forks.
24
+ > **Status: M10 (v1.0.0) -- the roster-complete release.** Ships the substrate seams **plus all ten strategies: `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, the latency-aware `PeakEwmaBalancer`, the sticky/affinity `ConsistentHashBalancer` (a Maglev table), the hotspot-protecting `BoundedLoadBalancer` (consistent hashing with bounded loads), and `WeightedRandomBalancer` (O(1) Vose alias-table sampling with rejection-sampling eligibility)**, the **`@zakkster/lite-pick/pool`** request layer (with opt-in latency-feedback, occupancy-feedback, and keyed-routing hooks), the **benchmark suite** -- the balance anchor + GC blast-radius headlines, a seeded/version-stamped `results.json`, a `bench:verify` drift check with teeth, and the vs-AWS positioning (see *Evidence* below) -- and the **[GUIDE.md](./GUIDE.md)** strategy-selection capstone. This session APPENDS one class: the other strategies in `Pick.js` are byte-identical, only the header roster/count and the `VERSION` stamp change. Every strategy is gated: `pick()` proven **0 B/op** (torture + PerfGate), RoundRobin **perfectly fair** with **zero dead picks** vs the naive `i++ % n` foil, SmoothWRR **exactly weighted** and **smooth**, **P2C proves the `ln ln n` balance ceiling** (peak-to-mean gap ~2 vs a random foil's ~21 at n=1024), **LeastConn is greedy-perfect** (max-minus-min load <= 1), **SED tracks weight within 1%**, **PeakEWMA steers around a 10x-slow node** (it takes <= 25% of P2C's share for it and cuts service p99), **ConsistentHash remaps only ~1.6% of keys on a scale event** (vs ~98% for naive modulo), **BoundedLoad tames a hotspot plain consistent hashing can't** (under a skewed key stream ConsistentHash spikes a hot backend to ~13x the mean occupancy while BoundedLoad's `(1+eps)` cap holds it near the mean by overflowing to neighbours), and **WeightedRandom holds every node's share within 2% of its weight** while its O(1) alias sample beats an O(n) cumsum foil by >=3x ops/ms at n=4096 -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. Roster complete **for now, not closed** (AZ-aware routing, hedging, subsetting are post-1.0). See [ROADMAP.md](./ROADMAP.md), and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), the RoundRobin (0003), SmoothWRR (0004), P2C (0005), LeastConn-family (0006), pool-adapter (0007), benchmark-suite (0008), PeakEWMA (0009), ConsistentHash (0010), BoundedLoad/CHBL (0011), and WeightedRandom (0012) design forks.
25
25
 
26
26
  ```bash
27
27
  npm install @zakkster/lite-pick
@@ -204,6 +204,72 @@ function fnv1a(s) { let h = 0x811c9dc5; for (let k = 0; k < s.length; k++) { h ^
204
204
  - **Cost & bound.** The lookup table is `M x 4` bytes -- `~256KB` at the `65537` default -- a **cold, one-time** allocation (disclosed in the cost table below; `M` is configurable **down** for small pools). `pick()` is `O(1)`, `0 B/op`. Fail-closed: `PICK_NONE` when the pool is down or no eligible backend is reachable within the probe bound (a near-total outage may return `PICK_NONE` even if a far eligible slot exists -- safe, never a dead pick).
205
205
  - **Deferred seams (import nothing).** A `@zakkster/lite-filter` hot-key / known-key oracle at the key-routing layer (warm/cold only, never the pick path) and a `@zakkster/lite-o1` `EliasFano` ring alternative to the table are optional-peer seams -- `peerDependencies` **stays `{}`** until a shipped path imports one ([ADR 0010](./decisions/0010-consistenthash.md)).
206
206
 
207
+ ## BoundedLoad -- consistent hashing with bounded loads (v0.9.0)
208
+
209
+ Plain consistent hashing is sticky and minimally-disruptive, but it has one failure mode: a **hot key**. If a handful of keys carry most of the traffic, consistent hashing pins each one's *entire* load on its one hashed backend -- an unbounded **hotspot**. `BoundedLoadBalancer` is [`ConsistentHashBalancer`](#consistenthash--sticky--cache-affinity-routing-v080) (the Maglev table) **plus a per-backend occupancy cap** `cap = (1 + eps) x mean` (the mean occupancy `_total / live`, with slack `eps`): a key sticks to its hashed home **unless** that backend is over cap, in which case the request **overflows** along the same bounded probe to the next eligible, under-cap backend (Mirrokni et al. *Consistent Hashing with Bounded Loads*, Google Research; Vimeo's `eps = 0.25` -- [ADR 0011](./decisions/0011-boundedload.md)). You keep stickiness + minimal disruption **and** gain the hotspot protection consistent hashing lacks.
210
+
211
+ ```js
212
+ import { BoundedLoadBalancer } from '@zakkster/lite-pick';
213
+
214
+ const eligible = Uint8Array.from([1, 1, 1, 1]);
215
+ const inflight = new Uint32Array(4); // YOU own this; read live as per-backend OCCUPANCY
216
+
217
+ // eps = 0.25 -> a backend over 1.25x the mean occupancy overflows the key to a neighbour.
218
+ const bl = new BoundedLoadBalancer(4, eligible, inflight, 0.25);
219
+
220
+ const key = fnv1a(sessionId); // any integer hash (cold) -- lite-pick adds none
221
+ const i = bl.pick(key >>> 0); // sticky home, or the overflow target if it's hot
222
+ inflight[i]++; bl.note(i, +1); // dispatch: bump the counter AND tell the balancer
223
+ // ... await the request ...
224
+ inflight[i]--; bl.note(i, -1); // settle: net-zero on both
225
+ ```
226
+
227
+ - **Sticky + overflow.** `pick(keyHash)` maps the integer key to its Maglev home; if that backend is under cap it wins (the common, sticky path). If it is over cap, the request overflows along the bounded probe to the first eligible, under-cap backend. If nothing in the window is under cap, it falls back to the first eligible seen -- **sticky wins; the cap is a soft preference, never a dead pick**. When `_total === 0` the cap is skipped entirely, so it behaves as pure `ConsistentHashBalancer`.
228
+ - **`note()` is the sole writer of the mean.** BoundedLoad **owns** a running occupancy sum `_total` and keeps it O(1)-current through `note(i, +1)` on dispatch / `note(i, -1)` on settle -- so the cap's mean never needs a scan; `inflight` is your live-read per-backend occupancy. **Contract:** mutate the mirrored counter **only** through `note()` (or the `/pool` adapter, which does it for you) -- direct mutation desyncs `_total` (UB, the same asymmetry `SmoothWRRBalancer` has for its weights). `note()` clamps `_total` at 0, and `totalInflight` exposes it.
229
+ - **Inherits the Maglev table.** It extends `ConsistentHashBalancer`, so `setWeight(i, w)` / `rebuild()` / `tableSize` and the whole weighted-Maglev build + bounded-probe walk are reused verbatim; the `weights` / `m` / `seed` constructor args are the same. `pick()` and `note()` are both **0 B/op**, `O(1)`. `PICK_NONE` only when no eligible backend is reachable in the probe window -- never merely because backends are over cap.
230
+ - **Why not "P2C with a cap"?** A note on the design (the honest one): power-of-two-choices over in-flight *plus* a `(1+eps) x mean` cap is **byte-identical to plain P2C** -- an under-cap draw always has lower in-flight than an over-cap one, so "prefer under-cap" and "lower-of-two" pick the same node. The cap is a no-op there. It is only *load-bearing* when the primary choice is fixed by something other than load -- a **hash**. That is CHBL, and it is why BoundedLoad is built on consistent hashing ([ADR 0011](./decisions/0011-boundedload.md)).
231
+
232
+ The proof (from `test/balance.mjs`, a Zipfian-skewed key stream over 64 backends, one fixed concurrency window):
233
+
234
+ | lane | mean occupancy | max backend occupancy |
235
+ |---|---|---|
236
+ | **BoundedLoad (CHBL)** | 10 | **13** (cap = 12.5 -- overflow holds it near the mean) |
237
+ | ConsistentHash (no cap) | 10 | **129** (~13x -- the hotspot) |
238
+
239
+ BoundedLoad caps the hot backend near `(1 + eps) x mean` while plain consistent hashing lets it run away, and both reroute only **~1.6%** of keys on a scale event (`test/balance.mjs`). See [`ConsistentHashBalancer`](#consistenthash--sticky--cache-affinity-routing-v080) above for the integer-key contract and the FNV-1a helper.
240
+
241
+ ## WeightedRandom -- O(1) alias-table weighted selection (v1.0.0)
242
+
243
+ The weighted strategy for **very large pools**. Where `SmoothWRRBalancer` is deterministic and smooth but scans O(cap) per pick and owns per-endpoint accumulator state, `WeightedRandomBalancer` is a **stateless O(1) sample**: one draw from a precomputed **Vose/Walker alias table** (one column draw + one probability compare) returns an endpoint proportional to its weight. It converges to the weight ratios by the law of large numbers -- trading SmoothWRR's low-variance smoothness for sampling variance.
244
+
245
+ ```js
246
+ import { WeightedRandomBalancer, PICK_NONE } from '@zakkster/lite-pick';
247
+
248
+ const eligible = Uint8Array.from([1, 1, 1, 1]);
249
+ const weights = Uint32Array.from([1, 2, 3, 10]); // YOU own this; endpoint 3 gets ~10/16 of traffic
250
+ const wr = new WeightedRandomBalancer(4, eligible, weights);
251
+
252
+ wr.pick(); // -> a weighted-random eligible index (mostly 3, sometimes 0/1/2)
253
+
254
+ // Reweight is COLD (rebuilds the alias table); the balancer is the sole writer of its table.
255
+ wr.setWeight(3, 1); // now roughly uniform
256
+ wr.setEligible(1, false); // an eligibility flap is FREE -- it never rebuilds the table (anti-flap)
257
+ wr.pick(); // never returns endpoint 1 (down) or a weight-0 node
258
+
259
+ // Whole pool down, or every eligible node weight 0 -> fail closed.
260
+ for (let i = 0; i < 4; i++) wr.setEligible(i, false);
261
+ wr.pick() === PICK_NONE; // -> true
262
+ ```
263
+
264
+ - **O(1), 0 B/op, never throws.** One alias-column draw + one compare. The table is built **cold** in the constructor (and on `setWeight` / `rebuild`) with the standard Vose small/large worklist -- reusing scratch buffers, so a rebuild allocates nothing and `pick()` allocates nothing.
265
+ - **Eligibility by rejection sampling** (the same discipline as P2C, [ADR 0005](./decisions/0005-p2c-draw.md)): the table is built over the **eligible-independent** weights, so a **weight-0 node is never a column** (never returned). If a drawn candidate is ineligible, `pick()` redraws up to a bounded 64, then falls back to a 0-B/op rotated linear eligible scan. Because every candidate is a positive-weight node, rejecting the ineligible ones **renormalizes** the weight distribution over the surviving eligible mass -- each eligible node's share converges to `weight[i] / sum(eligible weights)`.
266
+ - **Sole writer of its table.** `weights` is your `Uint32Array` (the SmoothWRR/SED seam); the balancer owns the derived alias table and is its only writer via cold `setWeight` / `rebuild`. Mutate `weights` directly and the table desyncs (UB). An eligibility flap **never** rebuilds. `PICK_NONE` only when `live === 0` or no eligible node has a positive weight.
267
+ - **Not `@zakkster/lite-random`.** That is a *game RNG* (loot tables, particles) whose `weighted(items, weights)` returns an **item** one-shot and is not eligibility-aware. WeightedRandom returns an endpoint **index**, honours the shared eligibility bitmap, and owns a persistent table -- different domain (see [GUIDE.md](./GUIDE.md) / [ADR 0012](./decisions/0012-weightedrandom.md)).
268
+
269
+ The proof (from `test/balance.mjs`, n=64, skewed weights 1..16, 8e6 seeded draws): every node's observed share is within **2%** of `weight[i]/sum` (measured worst ~0.84%), a cumsum-linear O(n) foil matches the *same* fairness, and the O(1) alias sample beats that foil by **~107x ops/ms** at n=4096. Under half the pool down: **0 ineligible / 0 weight-0** returns and survivor shares within **3%** of the renormalized target.
270
+
271
+ > **Which weighted strategy?** Small-to-medium pools or when smoothness matters -> **SmoothWRR**; very large pools where the O(cap) scan hurts -> **WeightedRandom**. Full decision tree in **[GUIDE.md](./GUIDE.md)**.
272
+
207
273
  ## Evidence -- the two headlines (v0.6.0 benchmark suite)
208
274
 
209
275
  > **Framing: parity on speed, superiority on the contract + balance + tail.** A trivial `i++ % n` round-robin -- or `wrr` -- *matches* P2C on raw ops/sec, so `lite-pick` does **not** claim "N times faster." Throughput is claimed at **parity**; the wins are **zero-GC**, **balance quality**, **tail latency** (GC blast-radius), and **never a dead pick**. Every number below is **seeded** and regenerated by `npm run bench:report`; `npm run bench:verify` fails CI if a README number drifts from a fresh run (algorithmic exact, timing within +/-15%). Node / CPU / OS / every PRNG seed are stamped into `benchmark/results.json`.
@@ -216,9 +282,9 @@ The real pinned npm incumbents (`load-balancers`, `loadbalance`, `wrr`) run thro
216
282
 
217
283
  | family | lite-pick | lite-pick ops/ms | incumbent (npm) | incumbent ops/ms |
218
284
  | --- | --- | --- | --- | --- |
219
- | P2C (power-of-two-choices) | P2cBalancer | 53009 | load-balancers@1.3.52 | 59485 |
220
- | RoundRobin | RoundRobinBalancer | 246432 | loadbalance@1.0.0 | 303459 |
221
- | Weighted-random | WeightedRandom -- SKIP, ships M10 | -- | wrr@1.0.0 | 163481 |
285
+ | P2C (power-of-two-choices) | P2cBalancer | 57019 | load-balancers@1.3.52 | 59778 |
286
+ | RoundRobin | RoundRobinBalancer | 229911 | loadbalance@1.0.0 | 261301 |
287
+ | Weighted-random | WeightedRandomBalancer | 73673 | wrr@1.0.0 | 172137 |
222
288
 
223
289
  <!-- /bench:competitors -->
224
290
 
@@ -246,8 +312,8 @@ The point of zero-GC is **not** the pick's own latency -- a major GC pause freez
246
312
 
247
313
  | lane | major GC | pick B/op | max GC pause (ms) |
248
314
  | --- | --- | --- | --- |
249
- | lite-pick | 0 | 0 | 0.1 |
250
- | allocating foil | 13 | allocates | 2.9 |
315
+ | lite-pick | 0 | 0 | 0.2 |
316
+ | allocating foil | 13 | allocates | 1.8 |
251
317
 
252
318
  <!-- /bench:gc -->
253
319
 
@@ -287,7 +353,8 @@ On a scale event (add / remove a node), what fraction of keys keep their node? T
287
353
  | --- | --- |
288
354
  | ALB `least_outstanding_requests` (LOR) | `LeastConnBalancer` / `P2cBalancer` |
289
355
  | ALB anomaly mitigation / latency-aware shedding | `PeakEwmaBalancer` (latency-aware P2C) |
290
- | ALB `weighted_random` + anomaly mitigation | `WeightedRandom` + `BoundedLoad` (M9/M10) |
356
+ | ALB anomaly mitigation on a sticky/affinity hash | `BoundedLoadBalancer` (consistent hashing with bounded loads -- sticky + hotspot overflow, M9) |
357
+ | ALB `weighted_random` | `WeightedRandom` (M10) |
291
358
  | NLB flow-hash (5-tuple) | `ConsistentHashBalancer` (Maglev table -- the same family NLB flow-hash uses, at the in-process hop) |
292
359
 
293
360
  The composition: inbound traffic still enters through your **ALB/NLB -> service** (the edge hop AWS owns and bills); `lite-pick` governs the fan-out **after** that, the hop no AWS load balancer touches. Complementary, not a replacement -- "the hop your ALB/NLB never sees."
@@ -317,10 +384,10 @@ rng.nextBelow(4); // -> a uint32 in [0, 4)
317
384
  rng.reset(); // replays the exact stream
318
385
 
319
386
  PICK_NONE; // -> -1 (fail-closed sentinel: no endpoint, never a dead pick)
320
- VERSION; // -> '0.6.0'
387
+ VERSION; // -> '0.9.0'
321
388
  ```
322
389
 
323
- `BalancerBase.pick()` is **abstract** -- it throws, so an unfinished strategy fails loudly rather than returning a dead index. Every shipped strategy (`RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`, `PeakEwmaBalancer`) extends it and reads the same shared eligibility view; you subclass it the same way to add your own.
390
+ `BalancerBase.pick()` is **abstract** -- it throws, so an unfinished strategy fails loudly rather than returning a dead index. Every shipped strategy (`RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`, `PeakEwmaBalancer`, `ConsistentHashBalancer`, `BoundedLoadBalancer`) extends it and reads the same shared eligibility view; you subclass it the same way to add your own.
324
391
 
325
392
  ## Wiring it up -- `@zakkster/lite-pick/pool` (v0.5.0)
326
393
 
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- Version: 0.8.0
3
+ Version: 1.0.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.
@@ -14,17 +14,61 @@ reading pre-allocated views that siblings or the caller write, and returning an
14
14
  The complementary evidence lite-pick ships is a measured balance-quality anchor (peak-to-
15
15
  average load vs the strategy's theoretical ceiling) alongside the 0 B/op pick witness.
16
16
 
17
- 0.8.0 ships the substrate seams + eight strategies: RoundRobin, SmoothWRR (the weighted
18
- default), P2C (power-of-two-choices -- also the O(1) least-connections APPROXIMATION), the
19
- EXACT LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C), and ConsistentHash
20
- (a Maglev lookup table -- sticky/affinity routing). It exports `VERSION`, the fail-closed
17
+ 1.0.0 -- the ROSTER-COMPLETE release -- ships the substrate seams + TEN strategies: RoundRobin,
18
+ SmoothWRR (the weighted default), P2C (power-of-two-choices -- also the O(1) least-connections
19
+ APPROXIMATION), the EXACT LeastConn family (LeastConn, SED, NQ), PeakEWMA (latency-aware P2C),
20
+ ConsistentHash (a Maglev lookup table -- sticky/affinity routing), BoundedLoad (Consistent Hashing
21
+ with Bounded Loads -- sticky routing + a per-backend occupancy cap that overflows a hotspot to
22
+ neighbours), and WeightedRandom (O(1) Vose alias-table sampling with rejection-sampling eligibility).
23
+ It exports `VERSION`, the fail-closed
21
24
  sentinel `PICK_NONE` (-1), a deterministic `Prng` (xorshift32), `BalancerBase` (the shared
22
25
  read-only eligibility seam + O(1) live count), `RoundRobinBalancer`, `SmoothWRRBalancer`,
23
26
  `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`, `PeakEwmaBalancer`,
24
- `ConsistentHashBalancer`, and the ConsistentHash constants `CH_DEFAULT_M` (65537) / `CH_PROBE_LIMIT`
25
- (64). The remaining strategies land one per session (see ROADMAP.md): BoundedLoad, WeightedRandom.
26
- The EXACT-O(log n) fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap`
27
- optional-peer seam (decisions/0006), not this exact-O(cap) scan.
27
+ `ConsistentHashBalancer`, `BoundedLoadBalancer`, `WeightedRandomBalancer`, and the ConsistentHash
28
+ constants `CH_DEFAULT_M` (65537) / `CH_PROBE_LIMIT` (64). Roster complete FOR NOW, not closed:
29
+ AZ-aware routing, the lite-await hedging combinator, and subsetting are queued post-1.0 (ROADMAP.md).
30
+ GUIDE.md is the "which of the ten strategies do I pick?" decision guide. The EXACT-O(log n)
31
+ fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam
32
+ (decisions/0006), not this exact-O(cap) scan.
33
+
34
+ M10 (1.0.0) adds WeightedRandomBalancer (decisions/0012): O(1) weighted-random selection via an inline
35
+ Vose/Walker ALIAS TABLE (one column draw + one probability compare -> a candidate), with REJECTION-
36
+ SAMPLING eligibility (retry an ineligible candidate up to a bounded 64, then a 0-B/op rotated linear
37
+ eligible scan) -- the ADR 0005 / P2C discipline. The alias table is built COLD over the ELIGIBLE-
38
+ INDEPENDENT weights, so a weight-0 node is NEVER a column (never returned) and rejecting the ineligible
39
+ draws RENORMALIZES the weight distribution over the SURVIVING eligible mass (each eligible node's share
40
+ converges to weight[i] / sum(eligible weights)). `weights` is the caller's Uint32Array (the SmoothWRR/
41
+ SED seam); the balancer is the SOLE writer of its DERIVED table (`_prob`/`_alias`) via cold setWeight/
42
+ rebuild -- an eligibility flap NEVER rebuilds (anti-flap). Fail-closed (PICK_NONE) IFF live === 0 OR no
43
+ eligible node has a positive weight. O(1), 0 B/op, never throws. It is the STATELESS O(1) weighted
44
+ sampler (no accumulator to desync) for VERY LARGE pools where SmoothWRR's O(cap) scan hurts -- trading
45
+ smoothness for sampling variance. `peerDependencies` STAYS `{}`: the Vose build is inlined; a lite-o1
46
+ `AliasTable` (duck-typed drop-in for the build) and a lite-logn Fenwick tree (the DYNAMIC-weight
47
+ complement -- O(log n) update + sample) are DEFERRED optional peers, imported by NOTHING. NOT
48
+ @zakkster/lite-random: that is a GAME RNG (Mulberry32; loot tables, particles, gaussian) whose
49
+ `weighted(items, weights)` returns an ITEM one-shot, is not eligibility-aware, and holds no reusable
50
+ table -- lite-pick's WeightedRandom returns an endpoint INDEX, honours the shared eligibility bitmap
51
+ (fail-closed), and owns a persistent alias table rebuilt only on reweight. Different domain + contract
52
+ -- not a peer, not a substrate (GUIDE.md / ADR 0012).
53
+
54
+ M9 (0.9.0) adds BoundedLoadBalancer (decisions/0011): Consistent Hashing with Bounded Loads (CHBL --
55
+ Mirrokni et al. / Google Research; Vimeo eps=0.25). It is `ConsistentHashBalancer` (the Maglev table)
56
+ PLUS a per-backend occupancy cap `cap = (1 + eps) x _total / live`: `pick(keyHash)` sticks a key to its
57
+ hashed home UNLESS that backend is over cap, in which case the request OVERFLOWS along the same bounded
58
+ probe to the next eligible under-cap backend -- consistent hashing's stickiness + minimal disruption
59
+ PLUS the HOTSPOT protection plain CH lacks. If none in the window is under cap it FALLS BACK to the
60
+ first eligible (sticky wins; PICK_NONE is pool-down ONLY, never for over-cap); `_total === 0` skips the
61
+ cap -> pure ConsistentHash. It extends ConsistentHashBalancer (reusing the Maglev build + probe +
62
+ setWeight/rebuild/tableSize VERBATIM) and OWNS a running `_total` whose SOLE writer is the warm
63
+ `note(i, delta)` seam (dispatch +1 / settle -1); `inflight` is the caller's Uint32Array read LIVE as
64
+ the per-backend occupancy. `pick()` and `note()` are both O(1) / 0 B/op. CONTRACT: when using
65
+ BoundedLoad the mirrored counter is mutated ONLY through `note()` / /pool -- direct mutation desyncs
66
+ `_total` (UB, the SmoothWRR-weights asymmetry). THE PIVOT (decisions/0011): P2C-over-inflight with a
67
+ `(1+eps) x mean` cap is byte-identical to plain P2C (an under-cap draw always has lower inflight than an
68
+ over-cap one), so the cap is only LOAD-BEARING when the primary choice is a HASH -- CHBL is that. The
69
+ hotspot anchor (test/balance.mjs): under a skewed key stream, plain ConsistentHash spikes a hot backend
70
+ to ~13x the mean occupancy while CHBL caps it near (1+eps) x mean by overflow -- and both keep ~1/N
71
+ minimal disruption on a scale event.
28
72
 
29
73
  M8 (0.8.0) adds ConsistentHashBalancer (decisions/0010): a prebuilt Maglev lookup table (IPVS `mh`,
30
74
  Meta Katran, Cilium) mapping a caller-supplied INTEGER key to a backend, O(1) / 0 B/op, with minimal
@@ -184,6 +228,66 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
184
228
  in-process hop. DEFERRED optional-peer seams (import nothing, peerDependencies STAYS `{}`): a
185
229
  @zakkster/lite-filter hot-key oracle at the key-routing layer (warm/cold only), and a
186
230
  @zakkster/lite-o1 `EliasFano` ring alternative to the table (decisions/0010).
231
+ - `BoundedLoadBalancer extends ConsistentHashBalancer` -- class. Consistent Hashing with Bounded Loads
232
+ (M9, CHBL: Mirrokni et al. / Google Research; Vimeo eps=0.25). ConsistentHash (the Maglev table) PLUS
233
+ a per-backend occupancy cap that overflows a hot backend to its neighbours.
234
+ - `new BoundedLoadBalancer(capacity, eligible, inflight, eps?=0.25, weights?=null, m?=65537, seed?=0x9e3779b9)`
235
+ -- `inflight` is a caller-owned Uint32Array (length >= capacity) read LIVE as the per-backend
236
+ OCCUPANCY; `eps` is the bounded-load slack (finite, > 0). Validates inflight + eps typeof-first
237
+ BEFORE super() allocates the Maglev table (TypeError non-number eps, RangeError non-finite / <= 0).
238
+ `weights` / `m` / `seed` are the ConsistentHash args (copied weights, prime m >= capacity, COLD
239
+ build). The running occupancy sum `_total` is BALANCER-OWNED (starts at 0) and written SOLELY by
240
+ `note`; when using BoundedLoad, the mirrored inflight counter is mutated ONLY through `note` / /pool
241
+ (direct mutation desyncs `_total` -- UB, the SmoothWRR-weights asymmetry).
242
+ - `pick(keyHash)` -> number. slot = (keyHash >>> 0) % M; walk the probe window (home + CH_PROBE_LIMIT
243
+ slots) and return the FIRST backend that is ELIGIBLE AND under `cap = (1 + eps) * _total / live`
244
+ (a hot home OVERFLOWS to a neighbour). If none in the window is under cap, fall back to the FIRST
245
+ eligible seen (sticky wins; the cap is a soft preference). `_total === 0` skips the cap -> pure
246
+ ConsistentHash. O(1), 0 B/op, NEVER throws. `PICK_NONE` ONLY when no eligible backend is reachable
247
+ in the window -- NEVER merely because backends are over cap (fail OPEN on overload).
248
+ - `note(i, delta)` -> void. WARM feedback path (not the hot pick path): the SOLE writer of `_total`
249
+ (dispatch note(i,+1) / settle note(i,-1)), so the cap's mean stays O(1)-current without a scan.
250
+ `i` is validated in range (RangeError), `delta` typeof-first as an integer; `_total` clamps at 0.
251
+ 0 B/op on the success path.
252
+ - `totalInflight` -- readonly number. The owned running occupancy sum (for tests / observability).
253
+ - `setWeight(i, w)` / `rebuild()` / `tableSize` -- inherited from ConsistentHashBalancer (COLD
254
+ rebuild of the Maglev table; the readonly prime table size).
255
+ - THE PIVOT (decisions/0011): P2C-over-inflight with a `(1+eps) x mean` cap is byte-identical to
256
+ plain P2C (an under-cap draw always has lower inflight than an over-cap one), so the cap is only
257
+ LOAD-BEARING when the primary choice is a HASH -- CHBL is that. The hotspot anchor
258
+ (test/balance.mjs): under a skewed key stream, plain ConsistentHash spikes a hot backend to ~13x
259
+ the mean occupancy while CHBL caps it near (1+eps) x mean by overflow; both keep ~1/N minimal
260
+ disruption. `Pool.run` mirrors dispatch/settle into `note` when the balancer duck-types it, and
261
+ `Pool.run(fn, { key })` drives `pick(key)` for keyed CHBL routing (both inert otherwise).
262
+ - `WeightedRandomBalancer extends BalancerBase` -- class. O(1) weighted-random via a Vose/Walker ALIAS
263
+ TABLE (M10, decisions/0012). The roster-completing strategy.
264
+ - `new WeightedRandomBalancer(capacity, eligible, weights, seed?=0x9e3779b9)` -- `weights` is the
265
+ CALLER's Uint32Array (length >= capacity, the SmoothWRR/SED seam); the balancer is the SOLE writer
266
+ of its DERIVED alias table (`_prob` Float64Array + `_alias` Int32Array) via cold setWeight/rebuild
267
+ (direct weight mutation desyncs the table -- UB). Validates typeof-first (RangeError) BEFORE
268
+ allocating the table. Builds the table COLD in the ctor (the standard small/large Vose worklist over
269
+ the eligible-INDEPENDENT weights, ~15 lines -- NOT a re-implementation of lite-o1's AliasTable). The
270
+ build reuses cold scratch worklists -- it allocates nothing per rebuild.
271
+ - `pick()` -> number. Draws one column (`prng.nextBelow(cap)`) + one probability compare against a
272
+ fresh `prng.next()` uniform -> a candidate (`col` or `_alias[col]`), which is ALWAYS a positive-
273
+ weight node (a weight-0 node is NEVER a column). REJECTION SAMPLING on eligibility: if the candidate
274
+ is ineligible, redraw up to a bounded 64, then fall back to a 0-B/op rotated linear scan from a
275
+ random start for the first eligible positive-weight node. Because a candidate is always positive-
276
+ weight, rejecting the ineligible draws RENORMALIZES the weight distribution over the SURVIVING
277
+ eligible mass (each eligible node's share converges to weight[i] / sum(eligible weights)). O(1),
278
+ 0 B/op, NEVER throws. `PICK_NONE` IFF live === 0 OR no eligible node has a positive weight. NEVER a
279
+ dead pick, a weight-0 return, or an out-of-range index.
280
+ - `setWeight(i, w)` -> void. COLD. Set endpoint i's weight (uint32) and REBUILD the alias table (the
281
+ SmoothWRR sole-writer precedent). `rebuild()` -> void. COLD. Re-derive the table from the current
282
+ caller weights. An eligibility flap NEVER rebuilds (anti-flap; only setWeight/rebuild/membership).
283
+ - vs SmoothWRR: SmoothWRR is deterministic/smooth/low-variance but O(cap)/pick and owns accumulator
284
+ state; WeightedRandom is a STATELESS O(1) sample (no accumulator to desync) with sampling variance --
285
+ the fit for VERY LARGE pools where SmoothWRR's O(cap) scan hurts. vs @zakkster/lite-random: that is a
286
+ GAME RNG (Mulberry32; loot tables) returning an ITEM one-shot, not eligibility-aware, no reusable
287
+ table; WeightedRandom returns an endpoint INDEX, honours the shared eligibility bitmap (fail-closed),
288
+ and owns a persistent alias table -- different domain, NOT a peer (see GUIDE.md / ADR 0012). DEFERRED
289
+ optional-peer seams (import NOTHING; `peerDependencies` STAYS `{}`): a lite-o1 `AliasTable` duck-typed
290
+ drop-in for the Vose build, and a lite-logn Fenwick/BinaryIndexedTree for DYNAMIC weights.
187
291
 
188
292
  ## Subpath: @zakkster/lite-pick/pool -- the ergonomic request layer (M5, Pool.js)
189
293
 
@@ -212,7 +316,14 @@ duck-typed and imports NOTHING from lite-query.
212
316
  `opts.clock` (a caller-owned nanosecond source) is supplied AND the balancer duck-types
213
317
  `recordRtt` (PeakEwmaBalancer), Pool drives `pick(now)` and records the settled rtt on success;
214
318
  otherwise the hook is inert -- Pool stays generic, in-flight stays net-zero, abort/failover
215
- unchanged.
319
+ unchanged. OPT-IN occupancy feedback: when the balancer duck-types `note` (BoundedLoadBalancer),
320
+ Pool mirrors each dispatch as `note(i, +1)` and each settle as `note(i, -1)` so its owned mean
321
+ stays current; inert otherwise. The hooks are independent -- a balancer may duck-type neither,
322
+ one, or both; a BoundedLoad + Pool round is net-zero on BOTH the inflight array and `_total`.
323
+ OPT-IN keyed routing: `opts.key` (a caller INTEGER) drives `pick(key)` for a keyed balancer
324
+ (ConsistentHash / BoundedLoad -- CHBL); failover re-picks with the SAME key, and because the failed
325
+ backend's occupancy stays elevated a CHBL re-pick naturally OVERFLOWS to the next backend. Without
326
+ `opts.key`, `pick()` / `pick(now)` behaviour is unchanged.
216
327
  - `liteQueryFetcher(pool, perEndpoint, opts?)` -> a `({ key, signal }) => Promise` fetcher for a
217
328
  query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). `perEndpoint({ endpoint,
218
329
  key, signal })` -> the per-endpoint work. `opts.tries` (default 1) is the spatial failover count.
@@ -234,7 +345,10 @@ duck-typed and imports NOTHING from lite-query.
234
345
 
235
346
  @zakkster/lite-di-health (eligibility writer), lite-statechart (breaker), lite-o1
236
347
  (RandomSet / AliasTable / RingLog substrate), lite-logn (exact least-conn heap / Fenwick
237
- weights), lite-lru (sticky affinity), lite-fastbit32 (optional small-pool bitset peer),
348
+ dynamic weights -- the mutable-weight complement to WeightedRandom's static alias table),
349
+ lite-random (a SEPARATE domain -- a GAME RNG for loot tables / particles that returns an ITEM,
350
+ NOT an eligibility-aware LB index selector; use lite-pick WeightedRandom for load balancing),
351
+ lite-lru (sticky affinity), lite-fastbit32 (optional small-pool bitset peer),
238
352
  lite-query (the fetcher adapter), lite-await (hedging), lite-worker-pool (in-process
239
353
  consumer), lite-di-signal / lite-signal-decorators (observability). None is a HARD
240
354
  dependency -- each is an OPTIONAL PEER dep (peerDependenciesMeta.optional, the LiteQuery
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zakkster/lite-pick",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "0.8.0",
5
- "description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, PeakEWMA (latency-aware peak-EWMA), and ConsistentHash (Maglev sticky/affinity routing, minimal disruption); the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
4
+ "version": "1.0.0",
5
+ "description": "Zero-dependency, zero-GC load-balancing selection kernel: one hot pick() -> endpoint index over a fixed pool, 0 B/op steady-state. A pure selector (consumes health/circuit state, never a proxy) for the in-process hop, complementary to AWS NLB/ALB. Tree-shakeable ESM roster of ten strategies: RoundRobin, SmoothWRR, P2C, LeastConn, SED, NQ, PeakEWMA (latency-aware peak-EWMA), ConsistentHash (Maglev sticky/affinity routing, minimal disruption), BoundedLoad (consistent hashing with bounded loads -- sticky routing with a per-backend occupancy cap that overflows a hotspot to neighbours), and WeightedRandom (O(1) Vose alias-table sampling with rejection-sampling eligibility); the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher. See GUIDE.md to choose a strategy.",
6
6
  "type": "module",
7
7
  "main": "./Pick.js",
8
8
  "module": "./Pick.js",
@@ -29,6 +29,7 @@
29
29
  "llms.txt",
30
30
  "README.md",
31
31
  "RECIPES.md",
32
+ "GUIDE.md",
32
33
  "CHANGELOG.md",
33
34
  "LICENSE"
34
35
  ],
@@ -79,6 +80,8 @@
79
80
  "maglev",
80
81
  "bounded-load",
81
82
  "weighted-random",
83
+ "alias-method",
84
+ "vose",
82
85
  "sticky-routing",
83
86
  "client-side-load-balancing",
84
87
  "in-process",