@zakkster/lite-pick 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,91 @@ 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
+ ## [0.4.0] - 2026-09-23
8
+
9
+ M4: the exact LeastConn family (IPVS `lc` / `sed` / `nq` made zero-GC) + the seeded invariant
10
+ fuzzer (ROADMAP.md M4).
11
+
12
+ ### Added
13
+
14
+ - `LeastConnBalancer extends BalancerBase` -- EXACT fewest-in-flight (IPVS `lc`). A full O(cap)
15
+ scan of the caller-owned in-flight view returning the eligible node with the lowest count
16
+ (lowest index on a tie); the deterministic complement to P2C's O(1) approximation. In-flight
17
+ is read LIVE (no `setWeight`, no derived aggregate -- the caller may mutate it directly).
18
+ 0 B/op. Fails closed (`PICK_NONE`) when the whole pool is down.
19
+ - `SedBalancer extends BalancerBase` -- shortest-expected-delay (IPVS `sed`). Returns the
20
+ eligible, positive-weight node minimizing `(inflight + 1) / weight`. BOTH inflight and weights
21
+ are caller-owned, read live. A weight-0 eligible node is not a candidate; all-zero-weight fails
22
+ closed even with the pool up. O(cap), 0 B/op.
23
+ - `NqBalancer extends BalancerBase` -- never-queue (IPVS `nq`). Returns the FIRST idle eligible
24
+ positive-weight node (in-flight 0) if one exists, else the SED minimum -- the worker-pool fit.
25
+ O(cap) worst case, O(1) when an early node is idle, 0 B/op.
26
+ - **The invariant fuzzer** (`test/fuzz.mjs` + the reusable `test/invariants.mjs` checker): a
27
+ seeded, property-based state-machine attack asserting STATE-SYNCHRONISATION invariants after
28
+ EVERY op (strict mode) per strategy -- `live` and (SmoothWRR) `_totalEligibleWeight` stay EXACT
29
+ vs a manual recompute, owned Float64 accumulators stay finite, `PICK_NONE` holds IFF the
30
+ pickable mass is 0, and LeastConn/SED/NQ return the true optimum (NQ its idle-first rule). Prints
31
+ the seed on failure for byte-for-byte replay; CI runs a fixed seed + a random seed + a regression
32
+ corpus + a pathological corpus (max-weight 0xFFFFFFFF summed, all-zero-weight while live>0,
33
+ single-node). Retrofits M2 SmoothWRR. Wired into `npm run fuzz` and `npm run verify`.
34
+ - `test/LeastConn.test.js` (10), `test/SED.test.js` (8), `test/NQ.test.js` (10) -- boundary +
35
+ behaviour suites (exact minimum, weight-0 exclusion, feedback-loop balance, idle-first fan-out,
36
+ never-a-down-index under 200k churned picks).
37
+ - Balance anchors (`test/balance.mjs`): LeastConn is greedy-perfect (max-minus-min <= 1, tighter
38
+ than P2C's gap; peak <= P2C's on the same run); SED converges to load proportional-to-weight
39
+ (< 1% drift; weighted-imbalance far below a random foil); NQ fans the first n dispatches out to
40
+ n distinct idle workers.
41
+ - Gates extended for all three: torture (retention + 0 B/op `pick()` phases 6-8), PerfGate
42
+ (three `zgcSuite` scenarios + three `mustFail` teeth-checks), witness (`linear` complexity ->
43
+ flat work-rate), benchmark matrix (LeastConn/SED/NQ subjects + a per-pick-allocating
44
+ `lc-array` foil).
45
+ - `decisions/0006-leastconn-family.md` -- P2C-is-already-least-conn (no redundant alias), exact-
46
+ O(cap)-scan-first, caller-owned live-read counters (the documented asymmetry with SmoothWRR),
47
+ and the confirmed-but-deferred lite-logn `BinaryHeap` exact-O(log n) peer seam.
48
+
49
+ ### Changed
50
+
51
+ - Version 0.3.0 -> 0.4.0 across `package.json`, `Pick.js` `VERSION`, and `llms.txt`.
52
+ - Folded the invariant-fuzz testing methodology (RESEARCH s3, ROADMAP s3/s0) into an adopted,
53
+ shipped gate: vectors 2 (flap chaos) + 3 (zero-GC soak) were already covered; the net-new
54
+ seeded state-synchronisation fuzzer is now `test/fuzz.mjs`.
55
+
56
+ ## [0.3.0] - 2026-09-23
57
+
58
+ M3: P2C (power-of-two-choices), the headline strategy -- and the balance-quality anchor
59
+ (ROADMAP.md M3).
60
+
61
+ ### Added
62
+
63
+ - `P2cBalancer extends BalancerBase` -- power-of-two-choices. Draws two DISTINCT eligible
64
+ endpoints uniformly at random (rejection sampling over the shared bitmap -- no peer, no
65
+ owned draw-set) and returns the one with the lower in-flight load; ties to the first draw.
66
+ In-flight counts are the caller's `Uint32Array` (read-only to `pick()`). O(1) per pick,
67
+ 0 B/op. Fails closed (`PICK_NONE`) when the whole pool is down. Owns only a seeded,
68
+ deterministic PRNG (reproducible benches).
69
+ - The distinct second choice uses a BOUNDED redraw (up to 32 tries), not a single nudge, so
70
+ the two-choices property holds even at tiny pool sizes (~2^-32 collision chance), while
71
+ staying expected-O(1) and 0 B/op.
72
+ - `test/P2C.test.js` -- 10 tests: n=2 always-lower-load, determinism by seed, fail-closed,
73
+ single-node, skips-down, a very-sparse-pool fallback path, a never-returns-a-down-index
74
+ proof under 200k churned picks, and an in-suite balance smoke.
75
+ - **The balance anchor** (`test/balance.mjs`): the balls-into-bins experiment now proves the
76
+ `ln ln n / ln 2` ceiling -- at n=1024, k=32 balls/bin, P2C peak-to-mean gap ~2 vs a random
77
+ single-draw foil's ~21, and P2C's gap stays ~2-3 as n grows to 4096 while random's grows.
78
+ - Gates extended for P2C: torture (retention + 0 B/op `pick()`), PerfGate (`zgcSuite` scenario
79
+ + a `mustFail` teeth-check), witness (`const` complexity -> flat throughput), benchmark
80
+ matrix (P2C subject + a random-draw foil).
81
+ - `decisions/0005-p2c-draw.md` -- rejection sampling (no peer, RandomSet deferred), the
82
+ bounded-distinct-redraw enrichment, and caller-owned in-flight counters.
83
+
84
+ ### Changed
85
+
86
+ - Version 0.2.0 -> 0.3.0 across `package.json`, `Pick.js` `VERSION`, and `llms.txt`.
87
+ - Folded lite-o1 v1.11.0's new members into the substrate map (RESEARCH s6, ROADMAP):
88
+ `Reservoir` -> the Subsetting substrate (post-1.0 #4); `EliasFano` -> a viable ring-with-
89
+ vnodes option for M8 ConsistentHash; `RankSelect` noted as static-only (not for the
90
+ mutating eligibility bitmap).
91
+
7
92
  ## [0.2.0] - 2026-09-23
8
93
 
9
94
  M2: SmoothWRR, the weighted default (ROADMAP.md M2).
@@ -105,6 +190,7 @@ ownership boundary in place before any `pick()` is written.
105
190
  - Next: **M1 RoundRobin** (0.1.0) -- the first strategy, landing the throughput witness
106
191
  and the balance gate.
107
192
 
193
+ [0.3.0]: https://github.com/PeshoVurtoleta/lite-pick/releases/tag/v0.3.0
108
194
  [0.2.0]: https://github.com/PeshoVurtoleta/lite-pick/releases/tag/v0.2.0
109
195
  [0.1.0]: https://github.com/PeshoVurtoleta/lite-pick/releases/tag/v0.1.0
110
196
  [0.0.1]: https://github.com/PeshoVurtoleta/lite-pick/releases/tag/v0.0.1
package/Pick.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- TypeScript declarations.
3
3
  *
4
- * M2 (0.2.0): substrate seams + RoundRobin + SmoothWRR (weighted). The remaining
5
- * strategy classes (P2C, LeastConn/SED/NQ, PeakEWMA, ConsistentHash, BoundedLoad,
4
+ * M4 (0.4.0): substrate seams + RoundRobin + SmoothWRR + P2C + the exact LeastConn family
5
+ * (LeastConn/SED/NQ). The remaining strategy classes (PeakEWMA, ConsistentHash, BoundedLoad,
6
6
  * WeightedRandom) are added one per session.
7
7
  */
8
8
 
@@ -87,3 +87,76 @@ export class SmoothWRRBalancer extends BalancerBase {
87
87
  /** Next endpoint by smooth weighting, or `PICK_NONE` when the eligible-weight sum is 0. */
88
88
  pick(): number;
89
89
  }
90
+
91
+ /**
92
+ * P2cBalancer -- power-of-two-choices (M3), the headline strategy. Draws two distinct
93
+ * eligible endpoints at random and returns the one with the lower in-flight load; the
94
+ * `ln ln n / ln 2` peak-load ceiling. In-flight counts are the caller's Uint32Array
95
+ * (read-only to `pick()`). O(1) per pick, 0 B/op. Fails closed (`PICK_NONE`) when down.
96
+ */
97
+ export class P2cBalancer extends BalancerBase {
98
+ /**
99
+ * @param capacity endpoint count (fixed).
100
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
101
+ * @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read-only.
102
+ * @param seed deterministic PRNG seed (default 0x9e3779b9); reproducible benches.
103
+ */
104
+ constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array, seed?: number);
105
+ /** Pick by power-of-two-choices (lower in-flight of two random eligibles), or `PICK_NONE`. */
106
+ pick(): number;
107
+ }
108
+
109
+ /**
110
+ * LeastConnBalancer -- EXACT fewest-in-flight (M4, IPVS `lc`). A full O(cap) scan of the
111
+ * caller-owned in-flight view returning the eligible node with the lowest count (lowest index
112
+ * on a tie) -- the deterministic complement to P2C's O(1) approximation. In-flight counts are
113
+ * caller-owned and read LIVE (no `setWeight`, no derived aggregate). 0 B/op. Fails closed
114
+ * (`PICK_NONE`) when the whole pool is down.
115
+ */
116
+ export class LeastConnBalancer extends BalancerBase {
117
+ /**
118
+ * @param capacity endpoint count (fixed).
119
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
120
+ * @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read live.
121
+ */
122
+ constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array);
123
+ /** The eligible node with the fewest in-flight requests, or `PICK_NONE`. O(cap). */
124
+ pick(): number;
125
+ }
126
+
127
+ /**
128
+ * SedBalancer -- shortest-expected-delay (M4, IPVS `sed`). Returns the eligible, positive-weight
129
+ * node minimizing `(inflight + 1) / weight`; converges to load proportional-to-weight. BOTH
130
+ * inflight and weights are caller-owned Uint32Arrays, read LIVE (no `setWeight`, no derived
131
+ * aggregate). A weight-0 eligible node is not a candidate. O(cap), 0 B/op. Fails closed
132
+ * (`PICK_NONE`) when no eligible node has a positive weight.
133
+ */
134
+ export class SedBalancer extends BalancerBase {
135
+ /**
136
+ * @param capacity endpoint count (fixed).
137
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
138
+ * @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read live.
139
+ * @param weights per-endpoint weights (length >= capacity), caller-owned, read live.
140
+ */
141
+ constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array, weights: Uint32Array);
142
+ /** The eligible node minimizing (inflight+1)/weight, or `PICK_NONE`. O(cap). */
143
+ pick(): number;
144
+ }
145
+
146
+ /**
147
+ * NqBalancer -- never-queue (M4, IPVS `nq`). Returns the first idle eligible positive-weight
148
+ * node (in-flight 0) if one exists, else the SED minimum -- the worker-pool fit. BOTH inflight
149
+ * and weights are caller-owned, read LIVE. O(cap) worst case, O(1) when an early node is idle,
150
+ * 0 B/op. Fails closed (`PICK_NONE`) when no eligible node has a positive weight.
151
+ */
152
+ export class NqBalancer extends BalancerBase {
153
+ /**
154
+ * @param capacity endpoint count (fixed).
155
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
156
+ * @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read live.
157
+ * @param weights per-endpoint weights (length >= capacity), caller-owned, read live.
158
+ */
159
+ constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array, weights: Uint32Array);
160
+ /** The first idle eligible node, else the SED minimum, or `PICK_NONE`. O(cap). */
161
+ pick(): number;
162
+ }
package/Pick.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- zero-GC load-balancing SELECTION KERNEL.
3
3
  *
4
- * M2 (0.2.0): substrate seams + RoundRobin + SmoothWRR (weighted). This file ships:
4
+ * M4 (0.4.0): substrate seams + six strategies -- RoundRobin, SmoothWRR, P2C, and the
5
+ * exact LeastConn family (LeastConn, SED, NQ). This file ships:
5
6
  *
6
7
  * - VERSION the single source-of-truth version stamp (3-place sync).
7
8
  * - PICK_NONE the fail-closed sentinel (-1): "no endpoint", never a dead pick.
@@ -15,6 +16,16 @@
15
16
  * the eligibility view, skipping down nodes, O(1) amortized, 0 B/op.
16
17
  * - SmoothWRRBalancer the weighted default: nginx smooth weighted round-robin over
17
18
  * caller-configured integer weights, O(cap)/pick, 0 B/op.
19
+ * - P2cBalancer power-of-two-choices over caller-owned in-flight counts; the ln ln n
20
+ * balance ceiling, O(1)/pick, 0 B/op. This IS the O(1) least-connections
21
+ * APPROXIMATION ("P2C-least-conn") -- the LeastConn family below is exact.
22
+ * - LeastConnBalancer EXACT fewest-in-flight (IPVS `lc`): a full O(cap) scan of the
23
+ * caller-owned in-flight view, 0 B/op. The deterministic complement to
24
+ * P2C's O(1) approximation.
25
+ * - SedBalancer shortest-expected-delay (IPVS `sed`): minimizes (inflight+1)/weight --
26
+ * charges the NEW request's marginal cost. O(cap)/pick, 0 B/op.
27
+ * - NqBalancer never-queue (IPVS `nq`): an IDLE eligible endpoint immediately if one
28
+ * exists, else SED. The worker-pool fit. O(cap)/pick, 0 B/op.
18
29
  *
19
30
  * The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
20
31
  * It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
@@ -22,13 +33,15 @@
22
33
  * counters live OUTSIDE the kernel. The steady-state pick path allocates 0 B/op.
23
34
  *
24
35
  * Roster (one strategy per session -- see ROADMAP.md): RoundRobin [M1], SmoothWRR [M2],
25
- * P2C, LeastConn/SED/NQ, PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom [planned].
36
+ * P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom
37
+ * [planned]. The EXACT-O(log n) fewest-in-flight variant is a deferred @zakkster/lite-logn
38
+ * BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan.
26
39
  *
27
40
  * Zero runtime dependencies. node:test only. ESM, single file, tree-shakeable.
28
41
  */
29
42
 
30
43
  /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
31
- export const VERSION = '0.2.0';
44
+ export const VERSION = '0.4.0';
32
45
 
33
46
  /**
34
47
  * Fail-closed sentinel returned by pick() when no endpoint is eligible.
@@ -298,3 +311,261 @@ export class SmoothWRRBalancer extends BalancerBase {
298
311
  return best;
299
312
  }
300
313
  }
314
+
315
+ /**
316
+ * P2cBalancer -- power-of-two-choices (M3), the headline strategy.
317
+ *
318
+ * `pick()` draws TWO distinct eligible endpoints uniformly at random and returns the one
319
+ * with the lower in-flight load. One extra probe over pure random buys an exponential drop
320
+ * in peak load: the max load stays within `ln ln n / ln 2 + O(1)` of the mean (Azar-Broder-
321
+ * Karlin-Upfal 1994), versus random's `ln n / ln ln n` gap. That additive `ln ln n` ceiling
322
+ * -- proven in test/balance.mjs against a random foil -- is the library's analytical anchor.
323
+ *
324
+ * Ownership (ADR 0001, ADR 0005): in-flight counts live in the CALLER's Uint32Array, read-
325
+ * only to `pick()` (the caller / the M5 lite-query adapter increments on dispatch, decrements
326
+ * on settle -- lite-pick holds no request state). The eligible draw is REJECTION SAMPLING
327
+ * over the shared bitmap: no peer, no owned draw-set, expected O(1) draws when eligibility is
328
+ * dense (the common case), a bounded retry + a zero-alloc rotated linear-scan fallback for the
329
+ * degenerate sparse case. A true worst-case-O(1) draw via lite-o1 `RandomSet` is a deferred
330
+ * optional-peer optimization (ADR 0005), added only if sparse-eligibility measurement demands.
331
+ *
332
+ * Bound: O(d) = O(1) with d = 2 (two expected-O(1) draws + one compare). Steady-state pick():
333
+ * a few PRNG steps + array reads, no object/closure/array created -- proven 0 B/op by
334
+ * test/torture.mjs and test/perf/PerfGate.test.mjs. Fails closed (PICK_NONE) when the whole
335
+ * pool is down.
336
+ */
337
+ export class P2cBalancer extends BalancerBase {
338
+ /**
339
+ * @param {number} capacity endpoint count (fixed).
340
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
341
+ * @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
342
+ * caller-owned and only READ here.
343
+ * @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
344
+ */
345
+ constructor(capacity, eligible, inflight, seed = 0x9e3779b9) {
346
+ super(capacity, eligible);
347
+ if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
348
+ throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
349
+ }
350
+ this._inflight = inflight;
351
+ this._rng = new Prng(seed);
352
+ }
353
+
354
+ /**
355
+ * A uniformly random ELIGIBLE index, or PICK_NONE if none. Expected O(1) (rejection
356
+ * sampling); a rotated linear scan from a random start is the zero-alloc fallback under
357
+ * degenerate sparsity (unbiased first-eligible-after-a-random-offset). Internal.
358
+ * @returns {number}
359
+ */
360
+ _draw() {
361
+ if (this._live === 0) return PICK_NONE;
362
+ const cap = this._cap, el = this._eligible;
363
+ for (let tries = 0; tries < 64; tries++) {
364
+ const i = this._rng.nextBelow(cap);
365
+ if (el[i]) return i;
366
+ }
367
+ // Degenerate (very sparse eligibility): scan from a random start, wrapping, and
368
+ // return the first eligible found. Zero-alloc; live > 0 guarantees a hit.
369
+ let i = this._rng.nextBelow(cap);
370
+ for (let k = 0; k < cap; k++) {
371
+ if (el[i]) return i;
372
+ i++;
373
+ if (i >= cap) i = 0;
374
+ }
375
+ return PICK_NONE;
376
+ }
377
+
378
+ /**
379
+ * Pick an endpoint by power-of-two-choices, or PICK_NONE (fail closed). O(1).
380
+ * @returns {number}
381
+ */
382
+ pick() {
383
+ const a = this._draw();
384
+ if (a < 0) return PICK_NONE; // whole pool down: fail closed
385
+ if (this._live === 1) return a; // only one eligible: it is both choices
386
+ // Draw a DISTINCT second choice. A bounded redraw (not a single nudge) keeps the
387
+ // two-choices property intact even at tiny pool sizes, where a single retry collides
388
+ // often: at live>=2 each redraw misses with probability <= 1/2, so 32 tries leaves a
389
+ // ~2^-32 collision chance -- while staying expected-O(1) (about two draws) and 0 B/op.
390
+ let b = this._draw();
391
+ for (let t = 0; b === a && t < 32; t++) b = this._draw();
392
+ if (b < 0 || b === a) return a; // astronomically rare: fall back to the first draw
393
+ // Lower in-flight wins; ties go to the first draw (unbiased over many picks).
394
+ return this._inflight[b] < this._inflight[a] ? b : a;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * LeastConnBalancer -- EXACT fewest-in-flight (M4), IPVS `lc` made zero-GC.
400
+ *
401
+ * `pick()` scans the whole pool and returns the eligible endpoint with the lowest in-flight
402
+ * count -- the deterministic, exact complement to P2cBalancer's O(1) two-choice APPROXIMATION
403
+ * of the same objective. Where P2C trades a tiny balance gap for O(1), LeastConn pays O(cap)
404
+ * for the exact minimum: in a closed feedback loop (the caller increments inflight on dispatch
405
+ * and decrements on settle) it is the greedy-optimal assignment -- max-minus-min load stays
406
+ * within 1 (test/balance.mjs proves the perfect balance, tighter than P2C's ln ln n gap).
407
+ *
408
+ * Ownership (ADR 0001, ADR 0006): in-flight counts live in the CALLER's Uint32Array, read-only
409
+ * to `pick()`. LeastConn owns NO derived state beyond the base `_live` -- it reads inflight
410
+ * live each scan, so (unlike SmoothWRR's weights) the caller may mutate the inflight view
411
+ * directly between picks; that is the whole point of the shared-counter seam.
412
+ *
413
+ * Bound: O(cap) per pick (one scan). Steady-state pick(): integer compares + one index write,
414
+ * no object/closure/array created -- 0 B/op. Tie-break is the lowest index (deterministic);
415
+ * the feedback loop breaks a startup all-zero tie by raising the picked node's count. Fails
416
+ * closed (PICK_NONE) when the whole pool is down.
417
+ *
418
+ * NOTE: without a feedback loop (inflight never changes) LeastConn returns the same lowest-load
419
+ * index every call -- correct by contract (it IS the least-loaded), but the caller must feed
420
+ * load back for it to distribute. The M5 lite-query adapter provides that increment/decrement.
421
+ */
422
+ export class LeastConnBalancer extends BalancerBase {
423
+ /**
424
+ * @param {number} capacity endpoint count (fixed).
425
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
426
+ * @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
427
+ * caller-owned and only READ here.
428
+ */
429
+ constructor(capacity, eligible, inflight) {
430
+ super(capacity, eligible);
431
+ if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
432
+ throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
433
+ }
434
+ this._inflight = inflight;
435
+ }
436
+
437
+ /**
438
+ * The eligible endpoint with the fewest in-flight requests, or PICK_NONE (fail closed).
439
+ * O(cap), zero-alloc. Lowest index on a tie.
440
+ * @returns {number}
441
+ */
442
+ pick() {
443
+ if (this._live === 0) return PICK_NONE; // whole pool down: fail closed
444
+ const cap = this._cap, el = this._eligible, inf = this._inflight;
445
+ let best = -1, bestLoad = 0;
446
+ for (let i = 0; i < cap; i++) {
447
+ if (el[i]) {
448
+ const c = inf[i];
449
+ if (best < 0 || c < bestLoad) { best = i; bestLoad = c; }
450
+ }
451
+ }
452
+ return best; // best >= 0 guaranteed while _live > 0
453
+ }
454
+ }
455
+
456
+ /**
457
+ * SedBalancer -- shortest-expected-delay (M4), IPVS `sed` made zero-GC.
458
+ *
459
+ * `pick()` returns the eligible endpoint that minimizes `(inflight + 1) / weight` -- the
460
+ * expected delay if the NEW request were placed there (the +1 charges the request itself).
461
+ * Higher-weight endpoints absorb proportionally more load; SED converges to inflight/weight
462
+ * equal across the pool (test/balance.mjs proves the weighted fairness). It is the weighted
463
+ * generalization of least-connections: with all weights equal, SED and LeastConn agree.
464
+ *
465
+ * Ownership (ADR 0001, ADR 0006): BOTH inflight AND weights are caller-owned Uint32Arrays,
466
+ * read-only to `pick()`. SED (like LeastConn, unlike SmoothWRR) owns NO derived weight
467
+ * aggregate -- it reads weights live each scan, so there is no `setWeight` and no total to
468
+ * desync: the caller may retune weights directly between picks. An eligible endpoint whose
469
+ * weight is 0 is NOT a candidate (its expected delay is infinite); if every eligible endpoint
470
+ * has weight 0, `pick()` fails closed.
471
+ *
472
+ * Bound: O(cap) per pick (one scan, one Float64 division per eligible node), 0 B/op. Lowest
473
+ * index on a tie. Fails closed (PICK_NONE) when no eligible endpoint has a positive weight.
474
+ */
475
+ export class SedBalancer extends BalancerBase {
476
+ /**
477
+ * @param {number} capacity endpoint count (fixed).
478
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
479
+ * @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity), caller-owned.
480
+ * @param {Uint32Array} weights per-endpoint weights (length >= capacity), caller-owned; read live.
481
+ */
482
+ constructor(capacity, eligible, inflight, weights) {
483
+ super(capacity, eligible);
484
+ if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
485
+ throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
486
+ }
487
+ if (!(weights instanceof Uint32Array) || weights.length < capacity) {
488
+ throw new RangeError('[lite-pick] weights must be a Uint32Array of length >= capacity');
489
+ }
490
+ this._inflight = inflight;
491
+ this._weights = weights;
492
+ }
493
+
494
+ /**
495
+ * The eligible endpoint minimizing (inflight + 1) / weight, or PICK_NONE (fail closed).
496
+ * O(cap), zero-alloc. Lowest index on a tie; weight-0 nodes are not candidates.
497
+ * @returns {number}
498
+ */
499
+ pick() {
500
+ if (this._live === 0) return PICK_NONE; // whole pool down: fail closed
501
+ const cap = this._cap, el = this._eligible, inf = this._inflight, wt = this._weights;
502
+ let best = -1, bestScore = Infinity;
503
+ for (let i = 0; i < cap; i++) {
504
+ if (el[i]) {
505
+ const w = wt[i];
506
+ if (w > 0) {
507
+ const score = (inf[i] + 1) / w;
508
+ if (score < bestScore) { bestScore = score; best = i; }
509
+ }
510
+ }
511
+ }
512
+ return best; // -1 when every eligible node has weight 0
513
+ }
514
+ }
515
+
516
+ /**
517
+ * NqBalancer -- never-queue (M4), IPVS `nq` made zero-GC.
518
+ *
519
+ * `pick()` returns an IDLE eligible endpoint (in-flight 0, positive weight) the instant one
520
+ * exists -- never leaving a free server idle while queueing elsewhere -- and otherwise falls
521
+ * back to SED (`(inflight + 1) / weight`). This is the best fit for the in-process worker-pool
522
+ * case: spin up idle capacity first, only weigh expected delay once everyone is busy.
523
+ *
524
+ * Ownership (ADR 0001, ADR 0006): identical to SED -- caller-owned inflight + weights, read
525
+ * live, no derived aggregate. The first idle eligible node (lowest index, in-flight 0, weight
526
+ * > 0) short-circuits the scan.
527
+ *
528
+ * Bound: O(cap) worst case (no idle node -> a full SED scan); O(1) when a low-index endpoint is
529
+ * idle. 0 B/op. Fails closed (PICK_NONE) when no eligible endpoint has a positive weight.
530
+ */
531
+ export class NqBalancer extends BalancerBase {
532
+ /**
533
+ * @param {number} capacity endpoint count (fixed).
534
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
535
+ * @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity), caller-owned.
536
+ * @param {Uint32Array} weights per-endpoint weights (length >= capacity), caller-owned; read live.
537
+ */
538
+ constructor(capacity, eligible, inflight, weights) {
539
+ super(capacity, eligible);
540
+ if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
541
+ throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
542
+ }
543
+ if (!(weights instanceof Uint32Array) || weights.length < capacity) {
544
+ throw new RangeError('[lite-pick] weights must be a Uint32Array of length >= capacity');
545
+ }
546
+ this._inflight = inflight;
547
+ this._weights = weights;
548
+ }
549
+
550
+ /**
551
+ * The first idle eligible endpoint (in-flight 0, weight > 0), else the SED minimum, else
552
+ * PICK_NONE (fail closed). O(cap) worst case, O(1) when an early node is idle. Zero-alloc.
553
+ * @returns {number}
554
+ */
555
+ pick() {
556
+ if (this._live === 0) return PICK_NONE; // whole pool down: fail closed
557
+ const cap = this._cap, el = this._eligible, inf = this._inflight, wt = this._weights;
558
+ let best = -1, bestScore = Infinity;
559
+ for (let i = 0; i < cap; i++) {
560
+ if (el[i]) {
561
+ const w = wt[i];
562
+ if (w > 0) {
563
+ if (inf[i] === 0) return i; // idle: never queue -- take it immediately
564
+ const score = (inf[i] + 1) / w;
565
+ if (score < bestScore) { bestScore = score; best = i; }
566
+ }
567
+ }
568
+ }
569
+ return best; // -1 when every eligible node has weight 0
570
+ }
571
+ }
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.2.0 ships two strategies -- `RoundRobinBalancer` and `SmoothWRRBalancer`** (the weighted default) -- on the substrate seams (`VERSION`, `PICK_NONE`, a deterministic `Prng`, and `BalancerBase`'s shared read-only eligibility view). The rest of the roster -- P2C, LeastConn/SED/NQ, PeakEWMA, ConsistentHash, 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. **v0.4.0 ships six strategies -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family** -- on the substrate seams (`VERSION`, `PICK_NONE`, a deterministic `Prng`, and `BalancerBase`'s shared read-only eligibility view). The rest of the roster -- PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom -- lands one per session.
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: M2 (v0.2.0).** Ships the substrate seams **plus `RoundRobinBalancer` and `SmoothWRRBalancer`**. 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** (max-run far below bursty weight-expansion WRR), and each scales as advertised (witness). See [ROADMAP.md](./ROADMAP.md) for the M2 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), and the RoundRobin (ADR 0003) and SmoothWRR (ADR 0004) design forks.
24
+ > **Status: M4 (v0.4.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family**. 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), and **SED tracks weight within 1%**. New in M4: a **seeded invariant fuzzer** (`test/fuzz.mjs`) that asserts each strategy's state-synchronisation invariants after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M4 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), and the RoundRobin (0003), SmoothWRR (0004), P2C (0005), and LeastConn-family (0006) design forks.
25
25
 
26
26
  ```bash
27
27
  npm install @zakkster/lite-pick
@@ -76,6 +76,69 @@ wrr.setWeight(1, 4); // B is now 4x
76
76
 
77
77
  `pick()` is **0 B/op** and **O(cap)** (one scan of the pool -- negligible at real endpoint counts). It owns its smoothing accumulators; weights live in your `Uint32Array` but you mutate them only through `setWeight`, which keeps the internal eligible-weight total exact. Marking a node down/up resets its accumulator, so a recovered node rejoins neutral -- no stale burst or starvation ([ADR 0004](./decisions/0004-smoothwrr-weight-ownership.md)).
78
78
 
79
+ ## P2C -- power-of-two-choices (v0.3.0, the headline)
80
+
81
+ Two random eligible draws, take the one with lower in-flight load. That single extra probe buys an **exponential** drop in peak load -- the max stays within an *additive* `ln ln n / ln 2` of the mean, versus random's `ln n / ln ln n` gap.
82
+
83
+ ```js
84
+ import { P2cBalancer } from '@zakkster/lite-pick';
85
+
86
+ const eligible = Uint8Array.from([1, 1, 1, 1]);
87
+ const inflight = new Uint32Array(4); // YOU own this; pick() only reads it
88
+
89
+ const p2c = new P2cBalancer(4, eligible, inflight);
90
+
91
+ const i = p2c.pick(); // the lower-loaded of two random eligibles
92
+ inflight[i]++; // you increment on dispatch...
93
+ // ...and inflight[i]-- when the request settles (the M5 lite-query adapter will do this)
94
+ ```
95
+
96
+ The proof (from `test/balance.mjs`, the library's analytical anchor):
97
+
98
+ | pool `n` | P2C peak/avg | random foil peak/avg |
99
+ |---|---|---|
100
+ | 1024 | **1.06** (gap 2) | 1.66 (gap 21) |
101
+ | 4096 | **1.06** (gap 2) | 1.78 (gap 25) |
102
+
103
+ P2C's gap stays a small `ln ln n` constant while the random foil's grows with the pool. `pick()` is **0 B/op** and **O(1)** (two expected-O(1) rejection draws + a compare); in-flight counts are your caller-owned `Uint32Array` ([ADR 0005](./decisions/0005-p2c-draw.md)).
104
+
105
+ ## LeastConn / SED / NQ -- the exact load-aware family (v0.4.0)
106
+
107
+ P2C above is the **O(1) approximation** of least-connections. When you want the **exact** least-loaded endpoint -- and the weighted (SED) and worker-pool (NQ) variants -- M4 ships the IPVS `lc` / `sed` / `nq` cohort, made zero-GC. All three read your caller-owned `inflight` (and, for SED/NQ, `weights`) **live** -- no `setWeight`, no derived total, so you mutate the counters directly in your feedback loop ([ADR 0006](./decisions/0006-leastconn-family.md)).
108
+
109
+ ```js
110
+ import { LeastConnBalancer, SedBalancer, NqBalancer } from '@zakkster/lite-pick';
111
+
112
+ const eligible = Uint8Array.from([1, 1, 1, 1]);
113
+ const inflight = new Uint32Array(4); // YOU own this; increment on dispatch, decrement on settle
114
+
115
+ // LeastConn: the EXACT fewest-in-flight endpoint (O(cap) scan). In a feedback loop it is
116
+ // greedy-optimal -- load spreads within 1 of the mean (tighter than P2C's ln ln n gap).
117
+ const lc = new LeastConnBalancer(4, eligible, inflight);
118
+ const a = lc.pick(); inflight[a]++;
119
+
120
+ // SED (shortest-expected-delay): minimizes (inflight + 1) / weight -- higher weight absorbs
121
+ // proportionally more load. A weight-0 eligible node is never a candidate.
122
+ const weights = Uint32Array.from([1, 2, 3, 4]);
123
+ const sed = new SedBalancer(4, eligible, inflight, weights);
124
+ const b = sed.pick(); inflight[b]++;
125
+
126
+ // NQ (never-queue): jump to an IDLE endpoint (in-flight 0) the instant one exists, else SED.
127
+ // The best fit for a worker pool -- fill free workers before queueing anywhere.
128
+ const nq = new NqBalancer(4, eligible, inflight, weights);
129
+ const c = nq.pick(); inflight[c]++;
130
+ ```
131
+
132
+ The proof (from `test/balance.mjs`):
133
+
134
+ | strategy | claim | measured |
135
+ |---|---|---|
136
+ | **LeastConn** | exact greedy balance | **max-minus-min load <= 1** (peak/avg 1.00), tighter than P2C; peak <= P2C's on the same run |
137
+ | **SED** | load proportional to weight | **< 1% share drift** from each node's weight fraction; weighted-imbalance far below a random foil |
138
+ | **NQ** | never queue while idle | first *n* dispatches hit **n distinct idle workers**, then falls back to SED |
139
+
140
+ Each `pick()` is **0 B/op** and **O(cap)** (NQ is O(1) when an early node is idle). Because these are the state-heaviest strategies so far, M4 also introduces the **invariant fuzzer** (`npm run fuzz`): a seeded state-machine attack that, after *every* `pick` / `setEligible` / weight / load op, asserts the chosen endpoint is the *exact* optimum, `live` stays exact, and `PICK_NONE` holds *iff* nothing is pickable -- printing the seed on any failure for byte-for-byte replay.
141
+
79
142
  ## The substrate (under every strategy)
80
143
 
81
144
  ```js
@@ -101,27 +164,10 @@ rng.nextBelow(4); // -> a uint32 in [0, 4)
101
164
  rng.reset(); // replays the exact stream
102
165
 
103
166
  PICK_NONE; // -> -1 (fail-closed sentinel: no endpoint, never a dead pick)
104
- VERSION; // -> '0.1.0'
167
+ VERSION; // -> '0.4.0'
105
168
  ```
106
169
 
107
- `BalancerBase.pick()` is **abstract** -- it throws, so an unfinished strategy fails loudly rather than returning a dead index; `RoundRobinBalancer` (above) overrides it. Here is the shape the headline **P2C** strategy will take (M3), for orientation:
108
-
109
- ```js
110
- // SHAPE ONLY -- not shipped until M3. Two random eligible draws, return the lower
111
- // in-flight load. One extra probe over random buys the ln ln n balance ceiling.
112
- class P2cBalancer extends BalancerBase {
113
- constructor(capacity, eligible, inflight, seed) {
114
- super(capacity, eligible);
115
- this._inflight = inflight; // caller-owned Uint32Array; pick() only reads it
116
- this._rng = new Prng(seed);
117
- }
118
- pick() {
119
- if (this.live === 0) return PICK_NONE; // fail closed
120
- const a = this._draw(), b = this._draw(); // two distinct eligible draws
121
- return this._inflight[b] < this._inflight[a] ? b : a;
122
- }
123
- }
124
- ```
170
+ `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`) extends it and reads the same shared eligibility view; you subclass it the same way to add your own.
125
171
 
126
172
  ## Design ownership (ratified before any strategy)
127
173
 
@@ -152,6 +198,7 @@ npm run torture # lite-leak retention + lite-gc-profiler 0 B/op (needs --expo
152
198
  npm run test:perf # lite-perf-gate HARD zero-alloc gate + a mustFail teeth-check
153
199
  npm run witness # pick throughput flatness across a pool-size sweep
154
200
  npm run balance # peak-to-average vs the strategy ceiling + random foil (the anchor)
201
+ npm run fuzz # seeded invariant fuzzer: state-sync invariants after every op
155
202
  npm run verify # all of the above
156
203
  ```
157
204
 
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  License: MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
5
5
  Runtime dependencies: none. ESM only. ASCII-only source. sideEffects: false.
6
6
  Node: >= 18.
@@ -14,11 +14,15 @@ 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.2.0 ships the substrate seams + two strategies: RoundRobin and SmoothWRR (the weighted
18
- default). It exports `VERSION`, the fail-closed sentinel `PICK_NONE` (-1), a deterministic
19
- `Prng` (xorshift32), `BalancerBase` (the shared read-only eligibility seam + O(1) live count),
20
- `RoundRobinBalancer`, and `SmoothWRRBalancer`. The remaining strategies land one per session
21
- (see ROADMAP.md): P2C, LeastConn/SED/NQ, PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom.
17
+ 0.4.0 ships the substrate seams + six strategies: RoundRobin, SmoothWRR (the weighted
18
+ default), P2C (power-of-two-choices -- also the O(1) least-connections APPROXIMATION), and the
19
+ EXACT LeastConn family (LeastConn, SED, NQ). It exports `VERSION`, the fail-closed sentinel
20
+ `PICK_NONE` (-1), a deterministic `Prng` (xorshift32), `BalancerBase` (the shared read-only
21
+ eligibility seam + O(1) live count), `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`,
22
+ `LeastConnBalancer`, `SedBalancer`, and `NqBalancer`. The remaining strategies land one per
23
+ session (see ROADMAP.md): PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom. The EXACT-O(log
24
+ n) fewest-in-flight variant is a deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam
25
+ (decisions/0006), not this exact-O(cap) scan.
22
26
 
23
27
  ## Design ownership (decisions/0001, 0002)
24
28
 
@@ -69,6 +73,39 @@ default). It exports `VERSION`, the fail-closed sentinel `PICK_NONE` (-1), a det
69
73
  weight; pick max; current -= total`), interleaved smoothly (weights [5,1,1] ->
70
74
  A,A,B,A,C,A,A), or `PICK_NONE` when the eligible-weight sum is 0. O(cap) per pick, 0 B/op.
71
75
  Owns its Float64Array smoothing accumulators (ADR 0004).
76
+ - `P2cBalancer extends BalancerBase` -- class. The headline strategy (M3): power-of-two-choices.
77
+ - `new P2cBalancer(capacity, eligible, inflight, seed?=0x9e3779b9)` -- `inflight` is a
78
+ caller-owned Uint32Array (length >= capacity), read-only to pick(): the caller / the M5
79
+ lite-query adapter increments on dispatch, decrements on settle. `seed` seeds the internal
80
+ deterministic PRNG (reproducible benches).
81
+ - `pick()` -> number. Draws two DISTINCT eligible endpoints uniformly at random (rejection
82
+ sampling over the bitmap, no peer) and returns the one with the lower in-flight load; ties
83
+ to the first draw. The `ln ln n / ln 2` peak-load ceiling (ADR 0005). O(1) per pick, 0 B/op.
84
+ `PICK_NONE` when the whole pool is down. This IS the O(1) least-connections APPROXIMATION;
85
+ `LeastConnBalancer` below is the exact-O(cap) complement.
86
+ - `LeastConnBalancer extends BalancerBase` -- class. EXACT fewest-in-flight (M4, IPVS `lc`).
87
+ - `new LeastConnBalancer(capacity, eligible, inflight)` -- `inflight` a caller-owned Uint32Array
88
+ (length >= capacity), read LIVE each pick. No `setWeight`, no derived aggregate: the caller may
89
+ mutate inflight directly between picks (that is the shared-counter seam).
90
+ - `pick()` -> number. Full O(cap) scan returning the eligible node with the fewest in-flight
91
+ requests (lowest index on a tie), or `PICK_NONE` when the whole pool is down. 0 B/op. In a
92
+ feedback loop (increment on dispatch, decrement on settle) it is greedy-optimal: max-minus-min
93
+ load stays within 1 (balance.mjs). Without feedback it returns the same lowest-load index --
94
+ correct by contract; the M5 lite-query adapter provides the increment/decrement.
95
+ - `SedBalancer extends BalancerBase` -- class. Shortest-expected-delay (M4, IPVS `sed`).
96
+ - `new SedBalancer(capacity, eligible, inflight, weights)` -- `inflight` AND `weights` are
97
+ caller-owned Uint32Arrays (length >= capacity), read LIVE (no `setWeight`, no derived total --
98
+ the caller may retune weights directly, UNLIKE SmoothWRR).
99
+ - `pick()` -> number. O(cap) scan returning the eligible, positive-weight node minimizing
100
+ `(inflight + 1) / weight` (the new request's marginal expected delay), lowest index on a tie,
101
+ or `PICK_NONE` when no eligible node has a positive weight. A weight-0 eligible node is NOT a
102
+ candidate. Converges to load proportional-to-weight (balance.mjs). 0 B/op.
103
+ - `NqBalancer extends BalancerBase` -- class. Never-queue (M4, IPVS `nq`); the worker-pool fit.
104
+ - `new NqBalancer(capacity, eligible, inflight, weights)` -- same caller-owned, read-live
105
+ inflight + weights contract as SED.
106
+ - `pick()` -> number. Returns the FIRST idle eligible positive-weight node (in-flight 0) if one
107
+ exists -- never queueing while a server is free -- else the SED minimum, else `PICK_NONE`.
108
+ O(cap) worst case, O(1) when an early node is idle. 0 B/op.
72
109
 
73
110
  ## Gates (every session)
74
111
 
@@ -78,6 +115,8 @@ default). It exports `VERSION`, the fail-closed sentinel `PICK_NONE` (-1), a det
78
115
  lite-perf-gate `zgcSuite` HARD zero-alloc gate + a `mustFail` teeth-check.
79
116
  - `npm run witness` -- pick throughput flatness across a pool-size sweep.
80
117
  - `npm run balance` -- peak-to-average load vs the strategy ceiling + random foil (the anchor).
118
+ - `npm run fuzz` -- the seeded invariant fuzzer (the state-machine attack): strict-mode
119
+ state-synchronisation invariants after every op, per strategy; prints the seed on failure.
81
120
  - `npm test` / `npm run test:types` -- node:test boundary suite + tsc type-surface check.
82
121
 
83
122
  ## Composes with
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.2.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, least-conn, PeakEWMA, consistent hashing.",
4
+ "version": "0.4.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, plus PeakEWMA and consistent hashing.",
6
6
  "type": "module",
7
7
  "main": "./Pick.js",
8
8
  "module": "./Pick.js",
@@ -29,10 +29,11 @@
29
29
  "torture": "node --expose-gc test/torture.mjs",
30
30
  "witness": "node test/witness.mjs",
31
31
  "balance": "node test/balance.mjs",
32
+ "fuzz": "node test/fuzz.mjs",
32
33
  "test:perf": "node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs",
33
34
  "bench": "node benchmark/Matrix.mjs",
34
35
  "bench:report": "node benchmark/Matrix.mjs && node benchmark/Report.mjs",
35
- "verify": "npm test && npm run test:types && npm run torture && npm run witness && npm run balance && npm run test:perf"
36
+ "verify": "npm test && npm run test:types && npm run torture && npm run witness && npm run balance && npm run fuzz && npm run test:perf"
36
37
  },
37
38
  "keywords": [
38
39
  "load-balancer",