@zakkster/lite-pick 0.1.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 ADDED
@@ -0,0 +1,76 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@zakkster/lite-pick` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-09-23
8
+
9
+ M1: the first strategy, RoundRobin (ROADMAP.md M1). The M0 harness stubs become real,
10
+ strategy-bearing gates.
11
+
12
+ ### Added
13
+
14
+ - `RoundRobinBalancer extends BalancerBase` -- the baseline strategy. A wrapping cursor
15
+ that forward-scans the shared eligibility view, skipping down nodes, to hand each LIVE
16
+ endpoint an equal share in index order (true round-robin over the live set, not the raw
17
+ index space). Owns only its cursor; O(1) amortized (O(cap) worst case under sparse
18
+ eligibility); 0 B/op on `pick()`; fail-closed `PICK_NONE` when the pool is down.
19
+ - `test/RoundRobin.test.js` -- boundary + behaviour suite (11 tests): perfect fairness,
20
+ round-robin order, skips down nodes, fail-closed, single-node, recovery, and a
21
+ never-returns-a-down-index proof under 200k picks of adversarial eligibility churn.
22
+ - Gates wired for RoundRobin: `torture.mjs` (retention + 0 B/op `pick()` phase),
23
+ `test/perf/PerfGate.test.mjs` (`zgcSuite` scenario + a `mustFail` teeth-check),
24
+ `test/witness.mjs` (throughput flatness across the pool sweep), `test/balance.mjs`
25
+ (imbalance 1.0000 on all-up, zero dead picks under partial eligibility vs the
26
+ `i++ % n` foil's dead-pick trap).
27
+ - `benchmark/Matrix.mjs` -- the SUBJECTS registration point (RoundRobin + the `i++ % n`
28
+ foil), a parity-check throughput slice ahead of the full M6 suite.
29
+ - `decisions/0003-roundrobin-bitmap-scan.md` -- the stateless bitmap-scan (Option A) vs
30
+ maintained eligible-set (Option B) fork; A now, B revisited at M3 with the lite-o1 peer.
31
+
32
+ ### Changed
33
+
34
+ - Version 0.0.1 -> 0.1.0 across `package.json`, `Pick.js` `VERSION`, and `llms.txt`.
35
+ - Documented the composition model as OPTIONAL PEER dependencies (`peerDependenciesMeta`,
36
+ the LiteQuery model) -- zero HARD deps, never inlined/forked; kernel runs with zero peers.
37
+
38
+ ## [0.0.1] - 2026-09-22
39
+
40
+ M0 scaffold: the substrate seams only, no strategy yet (ROADMAP.md M0). This release
41
+ establishes the shared machinery every strategy (M1+) will ride and locks the ratified
42
+ ownership boundary in place before any `pick()` is written.
43
+
44
+ ### Added
45
+
46
+ - `Pick.js` single-file ESM kernel with:
47
+ - `VERSION` -- the source-of-truth version stamp (three-place sync with package.json + llms.txt).
48
+ - `PICK_NONE` (-1) -- the fail-closed sentinel: no endpoint is ever a dead pick.
49
+ - `Prng` -- an instance-local, deterministic xorshift32 (`next` / `nextBelow` / `reset`),
50
+ so a strategy can draw on the hot path without `Math.random` and the balance
51
+ benchmark stays reproducible.
52
+ - `BalancerBase` -- the shared eligibility seam: a fixed-capacity pool over a SHARED,
53
+ read-only `Uint8Array` eligibility view (written by `@zakkster/lite-di-health` /
54
+ circuit breakers, read by `pick()`), an O(1) `live` count, `isEligible` / `setEligible`,
55
+ and an abstract `pick()` that throws until a strategy overrides it.
56
+ - `Pick.d.ts` -- TypeScript declarations for the substrate surface.
57
+ - `llms.txt` -- LLM-oriented API + design summary.
58
+ - Test harness: `test/Base.test.js` (substrate boundary suite), `test/torture.mjs`
59
+ (lite-leak retention + lite-gc-profiler 0 B/op on the substrate hot path),
60
+ `test/perf/PerfGate.test.mjs` (lite-perf-gate `zgcSuite` hard gate + a `mustFail`
61
+ teeth-check), `test/witness.mjs` (throughput flatness), `test/balance.mjs` (the
62
+ random-foil baseline the strategy ceilings assert against from M1), and the
63
+ `test/types` tsc surface check.
64
+ - `decisions/0001-selection-kernel-boundary.md` -- the five ratified ownership forks.
65
+ - `decisions/0002-anti-flapping.md` -- hysteresis/dwell/backoff on routing-state transitions.
66
+ - `RESEARCH.md`, `ROADMAP.md`, `README.md`, `LICENSE`.
67
+
68
+ ### Notes
69
+
70
+ - Zero runtime dependencies. ESM only. `sideEffects: false`. Node >= 18.
71
+ - Published metadata points at `PeshoVurtoleta/lite-pick`.
72
+ - Next: **M1 RoundRobin** (0.1.0) -- the first strategy, landing the throughput witness
73
+ and the balance gate.
74
+
75
+ [0.1.0]: https://github.com/PeshoVurtoleta/lite-pick/releases/tag/v0.1.0
76
+ [0.0.1]: https://github.com/PeshoVurtoleta/lite-pick/releases/tag/v0.0.1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/Pick.d.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @zakkster/lite-pick -- TypeScript declarations.
3
+ *
4
+ * M1 (0.1.0): the substrate seams + the first strategy, RoundRobin. The remaining
5
+ * strategy classes (SmoothWRR, P2C, LeastConn/SED/NQ, PeakEWMA, ConsistentHash,
6
+ * BoundedLoad, WeightedRandom) are added one per session.
7
+ */
8
+
9
+ /** The single source-of-truth version stamp. */
10
+ export const VERSION: string;
11
+
12
+ /**
13
+ * Fail-closed sentinel returned by `pick()` when no endpoint is eligible.
14
+ * A strategy never returns a down index; `PICK_NONE` (-1) means "no endpoint".
15
+ */
16
+ export const PICK_NONE: -1;
17
+
18
+ /**
19
+ * Instance-local, deterministic xorshift32 PRNG. Zero-alloc per step; seedable and
20
+ * `reset()`-able so the balance benchmark stays reproducible.
21
+ */
22
+ export class Prng {
23
+ /** @param seed 32-bit seed; 0 is remapped to the default. */
24
+ constructor(seed?: number);
25
+ /** One xorshift32 step -> a uint32 in [1, 2^32). */
26
+ next(): number;
27
+ /** A uint32 in [0, n). */
28
+ nextBelow(n: number): number;
29
+ /** Restore the original seed. */
30
+ reset(): void;
31
+ }
32
+
33
+ /**
34
+ * The shared eligibility seam for every strategy. Owns the fixed capacity, a reference
35
+ * to a shared read-only eligibility `Uint8Array` (written by @zakkster/lite-di-health /
36
+ * circuit breakers, read by `pick()`), and an O(1) live count. Subclasses implement
37
+ * `pick()`; the base `pick()` throws.
38
+ */
39
+ export class BalancerBase {
40
+ /**
41
+ * @param capacity endpoint count (fixed).
42
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
43
+ */
44
+ constructor(capacity: number, eligible: Uint8Array);
45
+ /** Endpoint count (fixed at construction). */
46
+ readonly capacity: number;
47
+ /** Number of currently eligible endpoints (O(1)). */
48
+ readonly live: number;
49
+ /** True iff endpoint `i` is currently pickable. */
50
+ isEligible(i: number): boolean;
51
+ /** Cold path: mark endpoint `i` up/down, keeping the live count exact. */
52
+ setEligible(i: number, up: boolean): void;
53
+ /** Choose an endpoint index, or `PICK_NONE`. Abstract in the base (throws). */
54
+ pick(): number;
55
+ }
56
+
57
+ /**
58
+ * RoundRobinBalancer -- the baseline strategy (M1). A wrapping cursor that forward-scans
59
+ * the shared eligibility view, skipping down nodes, to hand each LIVE endpoint an equal
60
+ * share in index order. Owns only its cursor; O(1) amortized, 0 B/op on `pick()`.
61
+ */
62
+ export class RoundRobinBalancer extends BalancerBase {
63
+ /**
64
+ * @param capacity endpoint count (fixed).
65
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
66
+ */
67
+ constructor(capacity: number, eligible: Uint8Array);
68
+ /** Next eligible index in round-robin order, or `PICK_NONE` when the pool is down. */
69
+ pick(): number;
70
+ }
package/Pick.js ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @zakkster/lite-pick -- zero-GC load-balancing SELECTION KERNEL.
3
+ *
4
+ * M1 (0.1.0): the substrate seams + the FIRST strategy, RoundRobin. This file ships:
5
+ *
6
+ * - VERSION the single source-of-truth version stamp (3-place sync).
7
+ * - PICK_NONE the fail-closed sentinel (-1): "no endpoint", never a dead pick.
8
+ * - Prng an instance-local, deterministic xorshift32 (seeded, reset()).
9
+ * - BalancerBase the eligibility seam: a fixed-capacity pool over a SHARED,
10
+ * read-only Uint8Array eligibility view (1 = pickable, 0 = down)
11
+ * written by @zakkster/lite-di-health / circuit breakers and only
12
+ * READ here, plus an O(1) live count and a cold-path setEligible().
13
+ * It does NOT implement pick() -- strategies subclass it.
14
+ * - RoundRobinBalancer the baseline strategy: a wrapping cursor that forward-scans
15
+ * the eligibility view, skipping down nodes, O(1) amortized, 0 B/op.
16
+ *
17
+ * The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
18
+ * It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
19
+ * the caller write, and returns an integer index. Health, circuit state, and load
20
+ * counters live OUTSIDE the kernel. The steady-state pick path allocates 0 B/op.
21
+ *
22
+ * Roster (one strategy per session -- see ROADMAP.md): RoundRobin [shipped M1],
23
+ * SmoothWRR, P2C, LeastConn/SED/NQ, PeakEWMA, ConsistentHash, BoundedLoad,
24
+ * WeightedRandom [planned].
25
+ *
26
+ * Zero runtime dependencies. node:test only. ESM, single file, tree-shakeable.
27
+ */
28
+
29
+ /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
30
+ export const VERSION = '0.1.0';
31
+
32
+ /**
33
+ * Fail-closed sentinel returned by pick() when no endpoint is eligible.
34
+ * null is not zero: a strategy never picks a down node "to be safe".
35
+ */
36
+ export const PICK_NONE = -1;
37
+
38
+ /**
39
+ * Prng -- instance-local, deterministic xorshift32.
40
+ *
41
+ * One PRNG step is a few integer ops and allocates nothing, so a strategy can draw
42
+ * on the hot path without touching Math.random (which is neither seedable nor
43
+ * gate-friendly) and the balance benchmark (the anchor) stays reproducible.
44
+ *
45
+ * Marsaglia's xorshift32: full period 2^32 - 1, never yields 0 once seeded non-zero.
46
+ */
47
+ export class Prng {
48
+ /**
49
+ * @param {number} [seed=0x9e3779b9] 32-bit seed (0 is remapped to the default,
50
+ * since xorshift stuck at 0 stays 0).
51
+ */
52
+ constructor(seed = 0x9e3779b9) {
53
+ const s = seed >>> 0;
54
+ this._seed = s === 0 ? 0x9e3779b9 : s;
55
+ this._s = this._seed;
56
+ }
57
+
58
+ /** One xorshift32 step -> a uint32 in [1, 2^32). Zero-alloc, deterministic. */
59
+ next() {
60
+ let x = this._s;
61
+ x ^= x << 13;
62
+ x ^= x >>> 17;
63
+ x ^= x << 5;
64
+ this._s = x >>> 0;
65
+ return this._s;
66
+ }
67
+
68
+ /** A uint32 in [0, n) via multiply-shift (no modulo bias for the balance gate). */
69
+ nextBelow(n) {
70
+ // (rand * n) >>> 32 -- unbiased enough for selection, one Math.imul-free mul.
71
+ return Math.floor((this.next() / 4294967296) * n);
72
+ }
73
+
74
+ /** Restore the original seed, so a benchmark run is byte-for-byte repeatable. */
75
+ reset() {
76
+ this._s = this._seed;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * BalancerBase -- the shared eligibility seam for every strategy.
82
+ *
83
+ * It owns ONLY: the fixed capacity, a reference to the caller/sibling-owned eligibility
84
+ * Uint8Array (never copied), and an O(1) `_live` count maintained on the cold setEligible()
85
+ * path so a strategy can fail closed in O(1). It never allocates after construction and
86
+ * never calls into a health source -- writers mutate `eligible` at their own cadence; pick()
87
+ * only reads it.
88
+ *
89
+ * Subclasses (M1+) implement pick(). BalancerBase.pick() throws, so an unfinished strategy
90
+ * fails loudly rather than silently returning a dead index.
91
+ */
92
+ export class BalancerBase {
93
+ /**
94
+ * @param {number} capacity endpoint count (fixed; add/remove is a cold rebuild).
95
+ * @param {Uint8Array} eligible 1 = pickable, 0 = down. SHARED, read-only to pick().
96
+ * Written by @zakkster/lite-di-health probes / circuit breakers / admin.
97
+ */
98
+ constructor(capacity, eligible) {
99
+ if (!Number.isInteger(capacity) || capacity < 1) {
100
+ throw new RangeError('[lite-pick] capacity must be an integer >= 1');
101
+ }
102
+ if (!(eligible instanceof Uint8Array) || eligible.length < capacity) {
103
+ throw new RangeError('[lite-pick] eligible must be a Uint8Array of length >= capacity');
104
+ }
105
+ this._cap = capacity;
106
+ this._eligible = eligible;
107
+ this._live = 0;
108
+ for (let i = 0; i < capacity; i++) if (eligible[i]) this._live++;
109
+ }
110
+
111
+ /** Endpoint count (fixed at construction). */
112
+ get capacity() {
113
+ return this._cap;
114
+ }
115
+
116
+ /** Number of currently eligible endpoints (O(1), cold-path maintained). */
117
+ get live() {
118
+ return this._live;
119
+ }
120
+
121
+ /** True iff endpoint i is currently pickable. O(1), zero-alloc. */
122
+ isEligible(i) {
123
+ return i >= 0 && i < this._cap && this._eligible[i] !== 0;
124
+ }
125
+
126
+ /**
127
+ * Cold path: mark endpoint i up/down (delegated FROM lite-di-health), keeping the
128
+ * shared view and the O(1) `_live` count in lockstep. Idempotent. Zero-alloc.
129
+ * @param {number} i
130
+ * @param {boolean} up
131
+ */
132
+ setEligible(i, up) {
133
+ if (i < 0 || i >= this._cap) {
134
+ throw new RangeError('[lite-pick] index out of range: ' + i);
135
+ }
136
+ const was = this._eligible[i];
137
+ const now = up ? 1 : 0;
138
+ if (was !== now) {
139
+ this._eligible[i] = now;
140
+ this._live += now ? 1 : -1;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Choose an endpoint index, or PICK_NONE when the whole pool is down (fail closed).
146
+ * Not implemented in the base -- M1+ strategies override this.
147
+ * @returns {number}
148
+ */
149
+ pick() {
150
+ throw new Error('[lite-pick] BalancerBase.pick() is abstract -- use a strategy (M1+)');
151
+ }
152
+ }
153
+
154
+ /**
155
+ * RoundRobinBalancer -- the baseline strategy (M1).
156
+ *
157
+ * A single wrapping cursor over the shared eligibility view. `pick()` advances the
158
+ * cursor and forward-scans, skipping down nodes (`eligible[i] === 0`), until it lands
159
+ * on the next pickable endpoint. Over a run of picks this hands each eligible endpoint
160
+ * an equal share, in index order -- true round-robin over the LIVE set, not the raw
161
+ * index space (the distinction from a naive `i++ % n`, which would return down nodes).
162
+ *
163
+ * Ownership (ADR 0001): it owns ONLY the integer cursor. Eligibility is the shared,
164
+ * read-only Uint8Array from BalancerBase; `pick()` reads it and returns an index. No
165
+ * SparseSet of eligibles is maintained -- ADR 0003 chose the stateless bitmap-scan path
166
+ * (Option A) for M1; the lite-o1 RandomSet/SparseSet substrate (Option B, an optional
167
+ * PEER dep) arrives at M3 when P2C needs a random eligible draw.
168
+ *
169
+ * Bound: O(1) amortized (one step when the next index is eligible), worst case O(cap)
170
+ * when eligibility is sparse (bounded by a single wrap -- `_live > 0` guarantees a hit
171
+ * within `cap` steps). Steady-state pick(): a compare-wrap loop over the view, one
172
+ * cursor write. No object, closure, string, or array is created -- proven 0 B/op by
173
+ * test/torture.mjs and test/perf/PerfGate.test.mjs.
174
+ */
175
+ export class RoundRobinBalancer extends BalancerBase {
176
+ /**
177
+ * @param {number} capacity endpoint count (fixed; add/remove is a cold rebuild).
178
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
179
+ */
180
+ constructor(capacity, eligible) {
181
+ super(capacity, eligible);
182
+ // Last index returned. -1 so the first pick starts the scan at index 0.
183
+ this._cursor = -1;
184
+ }
185
+
186
+ /**
187
+ * Next eligible endpoint index in round-robin order, or PICK_NONE when the whole
188
+ * pool is down (fail closed). O(1) amortized, O(cap) worst case, zero-alloc.
189
+ * @returns {number}
190
+ */
191
+ pick() {
192
+ if (this._live === 0) return PICK_NONE; // whole pool down: fail closed
193
+ const cap = this._cap, el = this._eligible;
194
+ let i = this._cursor;
195
+ for (let steps = 0; steps < cap; steps++) {
196
+ i++;
197
+ if (i >= cap) i = 0; // wrap (cap is caller-given, not power-of-2)
198
+ if (el[i]) { this._cursor = i; return i; }
199
+ }
200
+ // Unreachable while `_live` is exact (setEligible maintains it): a positive live
201
+ // count guarantees a set bit within one wrap. Fail closed rather than loop.
202
+ return PICK_NONE;
203
+ }
204
+ }
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # @zakkster/lite-pick
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.1.0 ships the first strategy, `RoundRobinBalancer`**, on the substrate seams (`VERSION`, `PICK_NONE`, a deterministic `Prng`, and `BalancerBase`'s shared read-only eligibility view). The rest of the roster -- SmoothWRR, P2C, LeastConn/SED/NQ, PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom -- lands one per session.
4
+
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
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
8
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-pick?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-pick)
9
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-pick?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-pick)
10
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-pick?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-pick)
11
+ ![Tree-Shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)
12
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
13
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
14
+ [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)
15
+
16
+ ## The load balancer the ecosystem was missing
17
+
18
+ The npm landscape has old algorithm libraries (`load-balancers`, `loadbalance`, `wrr`) and heavy full proxies -- but **no package that ships a provably zero-GC `pick()` path with a measured balance-quality anchor.** Most algorithm libraries use ordinary objects and arrays and quietly allocate under sustained high call rates (millions of picks/sec in worker fan-out, high-QPS internal services, client-side routing). `lite-pick` fills that gap: a small, dependency-free, ESM-first selection **kernel** you drop into an HTTP client, a worker pool, or a custom proxy -- and it proves its two claims instead of asserting them.
19
+
20
+ - **The `pick()` is the product.** One hot-path primitive: given a pool of endpoints, return the index of the one to use. Every strategy (round-robin, weighted, power-of-two-choices, least-connections, latency-aware, consistent-hash, bounded-load) is a different `pick()` over the same substrate.
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
+ - **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
+
24
+ > **Status: M1 (v0.1.0).** Ships the substrate seams **plus the first strategy, `RoundRobinBalancer`**. The M0 harness stubs are now real gates: `pick()` is proven **0 B/op** (torture + PerfGate), **perfectly fair** (balance: imbalance 1.0000, and zero dead picks vs the naive `i++ % n` foil), and **flat** across the pool sweep (witness). See [ROADMAP.md](./ROADMAP.md) for the M1 -> M10 path to 1.0.0, and [decisions/](./decisions) for the ownership boundary (ADR 0001), anti-flapping (ADR 0002), and the RoundRobin design fork (ADR 0003).
25
+
26
+ ```bash
27
+ npm install @zakkster/lite-pick
28
+ ```
29
+
30
+ ## RoundRobin (v0.1.0)
31
+
32
+ ```js
33
+ import { RoundRobinBalancer, PICK_NONE } from '@zakkster/lite-pick';
34
+
35
+ // A pool of 4 endpoints. The eligibility view is SHARED and read-only to pick():
36
+ // lite-di-health probes / circuit breakers write it; the balancer only reads it.
37
+ const eligible = Uint8Array.from([1, 1, 0, 1]); // endpoint 2 is down
38
+
39
+ const rr = new RoundRobinBalancer(4, eligible);
40
+
41
+ rr.pick(); // -> 0
42
+ rr.pick(); // -> 1
43
+ rr.pick(); // -> 3 (skips the down endpoint 2, never returns it)
44
+ rr.pick(); // -> 0 (wraps)
45
+
46
+ // A health source marks endpoint 2 back up (cold path; live count stays exact).
47
+ rr.setEligible(2, true);
48
+ rr.pick(); // -> 1, then 2, then 3, ... now that 2 is eligible
49
+
50
+ // Whole pool down -> fail closed, never a dead pick.
51
+ for (let i = 0; i < 4; i++) rr.setEligible(i, false);
52
+ rr.pick() === PICK_NONE; // -> true (-1)
53
+ ```
54
+
55
+ Every `pick()` above allocates **0 bytes**, owns only an integer cursor, and reads the one shared eligibility view (no second copy to drift). Over a run it hands each *live* endpoint an equal share -- true round-robin over the eligible set, not the raw index space.
56
+
57
+ ## The substrate (under every strategy)
58
+
59
+ ```js
60
+ import { BalancerBase, Prng, PICK_NONE, VERSION } from '@zakkster/lite-pick';
61
+
62
+ // A pool of 4 endpoints. The eligibility view is SHARED and read-only to pick():
63
+ // lite-di-health probes / circuit breakers write it; the balancer only reads it.
64
+ const eligible = Uint8Array.from([1, 1, 0, 1]); // endpoint 2 is down
65
+
66
+ const base = new BalancerBase(4, eligible);
67
+ base.capacity; // -> 4
68
+ base.live; // -> 3 (O(1), cold-path maintained)
69
+ base.isEligible(2); // -> false (out-of-range is false too, never a throw)
70
+
71
+ // Cold path: a health source marks endpoint 2 back up. live stays exact.
72
+ base.setEligible(2, true);
73
+ base.live; // -> 4
74
+
75
+ // A deterministic PRNG so the balance benchmark is reproducible (no Math.random
76
+ // on a gated path). Strategies draw from this on the hot path, zero-alloc.
77
+ const rng = new Prng(0x1234abcd);
78
+ rng.nextBelow(4); // -> a uint32 in [0, 4)
79
+ rng.reset(); // replays the exact stream
80
+
81
+ PICK_NONE; // -> -1 (fail-closed sentinel: no endpoint, never a dead pick)
82
+ VERSION; // -> '0.1.0'
83
+ ```
84
+
85
+ `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:
86
+
87
+ ```js
88
+ // SHAPE ONLY -- not shipped until M3. Two random eligible draws, return the lower
89
+ // in-flight load. One extra probe over random buys the ln ln n balance ceiling.
90
+ class P2cBalancer extends BalancerBase {
91
+ constructor(capacity, eligible, inflight, seed) {
92
+ super(capacity, eligible);
93
+ this._inflight = inflight; // caller-owned Uint32Array; pick() only reads it
94
+ this._rng = new Prng(seed);
95
+ }
96
+ pick() {
97
+ if (this.live === 0) return PICK_NONE; // fail closed
98
+ const a = this._draw(), b = this._draw(); // two distinct eligible draws
99
+ return this._inflight[b] < this._inflight[a] ? b : a;
100
+ }
101
+ }
102
+ ```
103
+
104
+ ## Design ownership (ratified before any strategy)
105
+
106
+ lite-pick owns **no mutable state it can avoid owning** ([ADR 0001](./decisions/0001-selection-kernel-boundary.md)):
107
+
108
+ | Concern | Owner | lite-pick's role |
109
+ | --- | --- | --- |
110
+ | Liveness / eligibility | `@zakkster/lite-di-health` writes a shared `Uint8Array` | **reads** it, zero-copy |
111
+ | Circuit state | `@zakkster/lite-statechart` (consumed) | never built in; sees only the bit |
112
+ | In-flight / rtt counters | caller-owned `Uint32Array` / `Float64Array` | **reads** them; pure `pick()` |
113
+ | Whole pool down | -- | fail-closed: returns `PICK_NONE` (-1) |
114
+ | Routing flap | the layer that writes the shared view | hysteresis/dwell ([ADR 0002](./decisions/0002-anti-flapping.md)); `pick()` stays greedy |
115
+
116
+ **In-process first** (workers, DI services, ECS-style systems); remote HTTP is served by a thin optional adapter that owns the observation loop and writes the same shared views. The zero-GC contract stays strict because the kernel never touches the request lifecycle.
117
+
118
+ ## Composes with
119
+
120
+ The moat is not the algorithms -- it is that lite-pick wires already-proven zero-GC parts of the suite: [`lite-di-health`](https://www.npmjs.com/package/@zakkster/lite-di-health) (liveness), [`lite-o1`](https://www.npmjs.com/package/@zakkster/lite-o1) (`RandomSet` / `AliasTable` / `RingLog` substrate), [`lite-logn`](https://www.npmjs.com/package/@zakkster/lite-logn) (exact least-conn heap / Fenwick weights), [`lite-lru`](https://www.npmjs.com/package/@zakkster/lite-lru) (sticky affinity), [`lite-statechart`](https://www.npmjs.com/package/@zakkster/lite-statechart) (breaker), [`lite-query`](https://www.npmjs.com/package/@zakkster/lite-query) (the fetcher adapter), and [`lite-await`](https://www.npmjs.com/package/@zakkster/lite-await) (hedging). **None is a hard dependency** -- each is an *optional peer* (`peerDependenciesMeta.optional`), every seam is duck-typed over a shared TypedArray, and the kernel runs with zero peers installed.
121
+
122
+ ## Gates
123
+
124
+ Every strategy session must pass, no exceptions:
125
+
126
+ ```bash
127
+ npm test # node:test boundary suite
128
+ npm run test:types # tsc type-surface check (Pick.d.ts vs runtime)
129
+ npm run torture # lite-leak retention + lite-gc-profiler 0 B/op (needs --expose-gc)
130
+ npm run test:perf # lite-perf-gate HARD zero-alloc gate + a mustFail teeth-check
131
+ npm run witness # pick throughput flatness across a pool-size sweep
132
+ npm run balance # peak-to-average vs the strategy ceiling + random foil (the anchor)
133
+ npm run verify # all of the above
134
+ ```
135
+
136
+ The zero-GC proof is two complementary tools kept separate (the suite's torture-harness discipline): a soak tester (`torture.mjs`, [`@zakkster/lite-leak`](https://www.npmjs.com/package/@zakkster/lite-leak) + [`@zakkster/lite-gc-profiler`](https://www.npmjs.com/package/@zakkster/lite-gc-profiler)) and a node:test-native hard gate (`test/perf/PerfGate.test.mjs`, [`@zakkster/lite-perf-gate`](https://www.npmjs.com/package/@zakkster/lite-perf-gate)), which includes a `mustFail` scenario proving the instrument has teeth.
137
+
138
+ ## License
139
+
140
+ MIT (c) Zahary Shinikchiev
package/llms.txt ADDED
@@ -0,0 +1,80 @@
1
+ # @zakkster/lite-pick
2
+
3
+ Version: 0.1.0
4
+ License: MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
5
+ Runtime dependencies: none. ESM only. ASCII-only source. sideEffects: false.
6
+ Node: >= 18.
7
+
8
+ ## What it is
9
+
10
+ A zero-GC load-balancing SELECTION KERNEL: one hot `pick()` that returns an endpoint
11
+ INDEX over a fixed pool and allocates zero bytes on the steady-state path. It is a pure
12
+ selector, never a proxy -- it CONSUMES health and circuit state (it never owns them),
13
+ reading pre-allocated views that siblings or the caller write, and returning an integer.
14
+ The complementary evidence lite-pick ships is a measured balance-quality anchor (peak-to-
15
+ average load vs the strategy's theoretical ceiling) alongside the 0 B/op pick witness.
16
+
17
+ 0.1.0 ships the substrate seams + the FIRST strategy, RoundRobin. It exports `VERSION`, the
18
+ fail-closed sentinel `PICK_NONE` (-1), a deterministic `Prng` (xorshift32), `BalancerBase`
19
+ (the shared read-only eligibility seam + O(1) live count), and `RoundRobinBalancer`. The
20
+ remaining strategies land one per session (see ROADMAP.md): SmoothWRR, P2C, LeastConn/SED/NQ,
21
+ PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom.
22
+
23
+ ## Design ownership (decisions/0001, 0002)
24
+
25
+ - IN-PROCESS first (workers, DI services); remote HTTP is a thin optional adapter.
26
+ - Eligibility is a SHARED read-only `Uint8Array` (1 = pickable, 0 = down), WRITTEN by
27
+ @zakkster/lite-di-health / circuit breakers, only READ by `pick()`. Zero-copy.
28
+ - Load counters are CALLER-OWNED typed arrays (`Uint32Array` inflight, `Float64Array`
29
+ rtt/EWMA); the kernel holds no request state. `pick()` is pure-read.
30
+ - The circuit breaker is CONSUMED (@zakkster/lite-statechart), never built in.
31
+ - Fail-closed: whole pool down -> `pick()` returns `PICK_NONE` (-1), never a dead pick.
32
+ - Anti-flapping is a first-class rule: routing-STATE transitions apply hysteresis/dwell;
33
+ `pick()` itself stays greedy and stateless.
34
+
35
+ ## Exports (from the single main file Pick.js)
36
+
37
+ - `VERSION` -- string. The source-of-truth version stamp (synced with package.json + this file).
38
+ - `PICK_NONE` -- the -1 fail-closed sentinel returned by `pick()` when no endpoint is eligible.
39
+ - `Prng` -- class. Instance-local deterministic xorshift32.
40
+ - `new Prng(seed = 0x9e3779b9)` -- 0 is remapped to the default.
41
+ - `next()` -> uint32 in [1, 2^32). One xorshift32 step, zero-alloc.
42
+ - `nextBelow(n)` -> uint32 in [0, n).
43
+ - `reset()` -> void. Replays the original seed's stream (reproducible benches).
44
+ - `BalancerBase` -- class. The shared eligibility seam; strategies (M1+) subclass it.
45
+ - `new BalancerBase(capacity, eligible)` -- `eligible` is a shared `Uint8Array`
46
+ (length >= capacity) owned/written externally; throws `RangeError` on a bad capacity
47
+ or an undersized / non-Uint8Array view.
48
+ - `capacity` -- readonly number. Fixed endpoint count.
49
+ - `live` -- readonly number. Currently eligible endpoints (O(1), cold-path maintained).
50
+ - `isEligible(i)` -> boolean. O(1); out-of-range is `false`, never a throw.
51
+ - `setEligible(i, up)` -> void. Cold path; keeps `live` exact and is idempotent; throws
52
+ `RangeError` on an out-of-range index.
53
+ - `pick()` -> number. ABSTRACT in the base (throws); overridden by each strategy.
54
+ - `RoundRobinBalancer extends BalancerBase` -- class. The baseline strategy (M1).
55
+ - `new RoundRobinBalancer(capacity, eligible)` -- same shared-eligibility contract as the base.
56
+ - `pick()` -> number. Next eligible index in round-robin order (a wrapping cursor that
57
+ forward-scans the eligibility view, skipping down nodes), or `PICK_NONE` when the pool is
58
+ down. O(1) amortized, O(cap) worst case under sparse eligibility, 0 B/op. Owns only its
59
+ cursor; no eligible-set structure (ADR 0003 chose the stateless bitmap-scan path for M1).
60
+
61
+ ## Gates (every session)
62
+
63
+ - `npm run torture` -- `node --expose-gc test/torture.mjs`: lite-leak retention +
64
+ lite-gc-profiler 0 B/op on the hot path.
65
+ - `npm run test:perf` -- `node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs`:
66
+ lite-perf-gate `zgcSuite` HARD zero-alloc gate + a `mustFail` teeth-check.
67
+ - `npm run witness` -- pick throughput flatness across a pool-size sweep.
68
+ - `npm run balance` -- peak-to-average load vs the strategy ceiling + random foil (the anchor).
69
+ - `npm test` / `npm run test:types` -- node:test boundary suite + tsc type-surface check.
70
+
71
+ ## Composes with
72
+
73
+ @zakkster/lite-di-health (eligibility writer), lite-statechart (breaker), lite-o1
74
+ (RandomSet / AliasTable / RingLog substrate), lite-logn (exact least-conn heap / Fenwick
75
+ weights), lite-lru (sticky affinity), lite-fastbit32 (optional small-pool bitset peer),
76
+ lite-query (the fetcher adapter), lite-await (hedging), lite-worker-pool (in-process
77
+ consumer), lite-di-signal / lite-signal-decorators (observability). None is a HARD
78
+ dependency -- each is an OPTIONAL PEER dep (peerDependenciesMeta.optional, the LiteQuery
79
+ model), every seam is duck-typed over a shared TypedArray, and the kernel runs with zero
80
+ peers installed. A peer is declared only when a shipped code path imports it.
package/package.json ADDED
@@ -0,0 +1,117 @@
1
+ {
2
+ "name": "@zakkster/lite-pick",
3
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
+ "version": "0.1.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.",
6
+ "type": "module",
7
+ "main": "./Pick.js",
8
+ "module": "./Pick.js",
9
+ "types": "./Pick.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./Pick.d.ts",
13
+ "node": "./Pick.js",
14
+ "import": "./Pick.js",
15
+ "default": "./Pick.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "Pick.js",
20
+ "Pick.d.ts",
21
+ "llms.txt",
22
+ "README.md",
23
+ "CHANGELOG.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "test": "node --test test/*.test.js",
28
+ "test:types": "tsc -p test/types/tsconfig.json",
29
+ "torture": "node --expose-gc test/torture.mjs",
30
+ "witness": "node test/witness.mjs",
31
+ "balance": "node test/balance.mjs",
32
+ "test:perf": "node --expose-gc --max-semi-space-size=4 --test test/perf/PerfGate.test.mjs",
33
+ "bench": "node benchmark/Matrix.mjs",
34
+ "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
+ },
37
+ "keywords": [
38
+ "load-balancer",
39
+ "load-balancing",
40
+ "load-balance",
41
+ "balancer",
42
+ "pick",
43
+ "selection",
44
+ "endpoint-selection",
45
+ "server-selection",
46
+ "round-robin",
47
+ "weighted-round-robin",
48
+ "smooth-weighted-round-robin",
49
+ "wrr",
50
+ "power-of-two-choices",
51
+ "p2c",
52
+ "two-random-choices",
53
+ "least-connections",
54
+ "least-conn",
55
+ "shortest-expected-delay",
56
+ "sed",
57
+ "never-queue",
58
+ "peak-ewma",
59
+ "latency-aware",
60
+ "consistent-hashing",
61
+ "maglev",
62
+ "bounded-load",
63
+ "weighted-random",
64
+ "sticky-routing",
65
+ "client-side-load-balancing",
66
+ "in-process",
67
+ "worker-pool",
68
+ "health-check",
69
+ "circuit-breaker",
70
+ "fail-closed",
71
+ "anti-flapping",
72
+ "hysteresis",
73
+ "deterministic",
74
+ "xorshift32",
75
+ "prng",
76
+ "zero-gc",
77
+ "zero-allocation",
78
+ "gc",
79
+ "garbage-collection",
80
+ "typed-array",
81
+ "performance",
82
+ "high-throughput",
83
+ "lightweight",
84
+ "tree-shakeable",
85
+ "esm",
86
+ "zero-dependency"
87
+ ],
88
+ "license": "MIT",
89
+ "publishConfig": {
90
+ "access": "public"
91
+ },
92
+ "devDependencies": {
93
+ "@zakkster/lite-gc-profiler": "^1.16.0",
94
+ "@zakkster/lite-leak": "^1.10.0",
95
+ "@zakkster/lite-perf-gate": "^1.4.2",
96
+ "typescript": "^7.0.2"
97
+ },
98
+ "peerDependencies": {},
99
+ "peerDependenciesMeta": {},
100
+ "homepage": "https://github.com/PeshoVurtoleta/lite-pick#readme",
101
+ "repository": {
102
+ "type": "git",
103
+ "url": "git+https://github.com/PeshoVurtoleta/lite-pick.git"
104
+ },
105
+ "bugs": {
106
+ "url": "https://github.com/PeshoVurtoleta/lite-pick/issues",
107
+ "email": "shinikchiev@yahoo.com"
108
+ },
109
+ "engines": {
110
+ "node": ">=18"
111
+ },
112
+ "funding": {
113
+ "type": "github",
114
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
115
+ },
116
+ "sideEffects": false
117
+ }