@zakkster/lite-pick 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ 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.7.1] - 2026-09-23
8
+
9
+ ### Fixed
10
+
11
+ - PeakEWMA cold-start / unsampled-node scoring under real large-magnitude clocks. An unsampled node
12
+ now scores at its UNDECAYED baseline (`_stamp` initialized to a negative sentinel, read as the
13
+ 1.0 baseline) = graceful least-connections, instead of `exp(-now/tau)` underflowing to 0 and
14
+ collapsing a cold pool to random selection. The FIRST `recordRtt` sample now initializes the EWMA
15
+ EXACTLY to the sample (clock-magnitude-independent); the Finagle peak rule applies from the second
16
+ sample on. `pick()` stays a pure 0 B/op read (a per-candidate sentinel compare, no allocation).
17
+
18
+ ### Changed
19
+
20
+ - Completes the 0.7.0 packaging: synced the `llms.txt` version stamp, added the PeakEWMA
21
+ README / CHANGELOG sections + `decisions/0009-peakewma.md`, and regenerated the benchmark
22
+ `results.json`. Documented that `pick(now)` / `recordRtt` require a FINITE `now` -- `recordRtt`
23
+ throws on a non-finite argument; `pick(now)` never throws (fail-closed) and degrades a non-finite
24
+ `now` to P2C-random selection. No API or behavior change beyond the cold-start fix.
25
+
26
+ ## [0.7.0] - 2026-09-23
27
+
28
+ M7: `PeakEwmaBalancer` -- latency-aware power-of-two-choices (Twitter Finagle's peak-EWMA). A
29
+ STRATEGY-APPEND session: one class is added to `Pick.js`; the other strategies are byte-identical,
30
+ only the header roster/count and the `VERSION` stamp change. `peerDependencies` stays `{}`.
31
+
32
+ ### Added
33
+
34
+ - `PeakEwmaBalancer extends BalancerBase` (`Pick.js`, `Pick.d.ts`) -- `new PeakEwmaBalancer(capacity,
35
+ eligible, inflight, tauNs, seed?)`. `pick(now)` draws two distinct eligible endpoints (reusing
36
+ `P2cBalancer`'s rejection-sampling `_draw`) and returns the lower `cost = (inflight + 1) x
37
+ ewmaAt(now)`, tie to the first draw; `O(d)=O(1)`. `ewmaAt(i, now)` decays ON READ
38
+ (`_ewma[i] * exp(-(now - _stamp[i]) / tau)`), so `pick()` never writes and is **0 B/op**.
39
+ `recordRtt(i, sampleNs, now)` is the warm feedback path (the Finagle peak rule: snap up to a
40
+ larger sample, decay down over `~tau`), also **0 B/op** on the success path. `now` / `sampleNs`
41
+ are caller-supplied nanoseconds. The EWMA state (`_ewma` / `_stamp`, `Float64Array`) is
42
+ balancer-owned; `inflight` is the caller's `Uint32Array` read live. Cold start seeds the EWMA to
43
+ `1.0` -> graceful least-connections, never `NaN`. Constructor and `recordRtt` validate
44
+ typeof-first, before allocation. Anti-flap = the half-life, no extra dwell.
45
+ - `Pool.run` opt-in latency feedback (`Pool.js`, `Pool.d.ts`): when `opts.clock` (a caller-owned
46
+ nanosecond source) is supplied AND the balancer duck-types `recordRtt`, Pool drives `pick(now)`
47
+ and records the settled rtt on success. Otherwise the hook is inert -- Pool stays generic, the
48
+ in-flight counter stays net-zero, and abort/failover are unchanged.
49
+ - `test/PeakEWMA.test.js` -- the boundary suite (cold-start valid + never-NaN, snap-up, decay to
50
+ sample/e at dt=tau within ~1%, slow-node avoidance, fail-closed, tie-break to the first draw =
51
+ identical to P2C on the same seed, constructor + recordRtt validation throws, flap churn).
52
+ - `test/balance.mjs` -- the LATENCY ANCHOR: a closed-loop single-server-per-node queue with one node
53
+ at 10x service time. Measured: PeakEWMA slow-node share ~0.007% vs P2C ~1.47% (<= 25% of P2C);
54
+ PeakEWMA service p99 ~1950ns vs P2C ~14500ns (>= 20% lower); random foil worse than both.
55
+ - `test/torture.mjs` -- PeakEWMA retention + `pick(now)` and `recordRtt()` 0 B/op phases.
56
+ - `test/perf/PerfGate.test.mjs` -- `PeakEwmaBalancer.pick(now)` + `recordRtt()` zero-alloc scenarios
57
+ and a `pick(now)`-boxed-into-a-fresh-array `mustFail` tooth.
58
+ - `test/witness.mjs` -- PeakEWMA subject, `const` (O(d)=O(1)) flat flag; work-rate flatness ~0.89
59
+ (the `Math.exp` runs ~2x/pick and stays flat -- the cached 2^-k decay-table fallback was NOT
60
+ needed).
61
+ - `test/fuzz.mjs` -- PeakEWMA subject: `_ewma` / `_stamp` stay finite and `PICK_NONE` holds iff the
62
+ pickable mass is 0 under a `recordRtt` / `pick(now)` / `setEligible` barrage.
63
+ - `test/types/pick.test-d.ts` -- PeakEwmaBalancer type-surface smoke.
64
+ - `benchmark/Matrix.mjs` PeakEWMA throughput subject; PeakEWMA lanes in `benchmark/GcBlastRadius.mjs`
65
+ (same maxMajor 0 / 0 B/op / bounded-pause contract) and `benchmark/Fairness.mjs` (latency
66
+ steering); `benchmark/results.json` regenerated (version 0.7.0), `bench:verify` green.
67
+ - `decisions/0009-peakewma.md` -- the ADR (latency-aware P2C, decay-on-read, the Finagle peak rule,
68
+ caller-supplied clock, balancer-owned state, deferred DDSketch-p99, anti-flap = half-life).
69
+
70
+ ### Changed
71
+
72
+ - `Pick.js` header roster/count (six -> seven strategies), `VERSION` 0.6.0 -> 0.7.0; `package.json`
73
+ version + description; `llms.txt` version + PeakEWMA surface + the FE-profile note + the deferred
74
+ DDSketch-p99 note; `README.md` PeakEWMA section + FE profile + the AWS anomaly-mitigation mapping
75
+ row.
76
+
7
77
  ## [0.6.0] - 2026-09-23
8
78
 
9
79
  M6: the benchmark suite (ROADMAP.md M6). An EVIDENCE session -- no API change. `Pick.js` and
package/Pick.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- TypeScript declarations.
3
3
  *
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
- * WeightedRandom) are added one per session.
4
+ * M7 (0.7.0): substrate seams + RoundRobin + SmoothWRR + P2C + the exact LeastConn family
5
+ * (LeastConn/SED/NQ) + PeakEWMA (latency-aware P2C). The remaining strategy classes
6
+ * (ConsistentHash, BoundedLoad, WeightedRandom) are added one per session.
7
7
  */
8
8
 
9
9
  /** The single source-of-truth version stamp. */
@@ -160,3 +160,30 @@ export class NqBalancer extends BalancerBase {
160
160
  /** The first idle eligible node, else the SED minimum, or `PICK_NONE`. O(cap). */
161
161
  pick(): number;
162
162
  }
163
+
164
+ /**
165
+ * PeakEwmaBalancer -- latency-aware power-of-two-choices (M7, Twitter Finagle's peak-EWMA).
166
+ * Draws two distinct eligible endpoints and returns the lower cost = `(inflight + 1) * ewmaAt(now)`;
167
+ * a slow endpoint (high decayed EWMA rtt) is avoided even with a short queue. `inflight` is the
168
+ * caller-owned Uint32Array read LIVE; the EWMA state (`_ewma` / `_stamp`, Float64) is BALANCER-OWNED
169
+ * and written ONLY by `recordRtt` (the warm feedback path). `pick(now)` decays on READ -- never
170
+ * writes -- so it is 0 B/op, as is `recordRtt`. `now` / `sampleNs` are caller-supplied nanoseconds.
171
+ * Cold start seeds the EWMA to 1.0 -> graceful least-connections, never NaN. O(d)=O(1). Fails
172
+ * closed (`PICK_NONE`) when the whole pool is down.
173
+ */
174
+ export class PeakEwmaBalancer extends BalancerBase {
175
+ /**
176
+ * @param capacity endpoint count (fixed).
177
+ * @param eligible shared view: 1 = pickable, 0 = down (length >= capacity).
178
+ * @param inflight per-endpoint in-flight counts (length >= capacity), caller-owned, read live.
179
+ * @param tauNs the EWMA time-constant / half-life in nanoseconds (finite, > 0).
180
+ * @param seed deterministic PRNG seed (default 0x9e3779b9); reproducible benches.
181
+ */
182
+ constructor(capacity: number, eligible: Uint8Array, inflight: Uint32Array, tauNs: number, seed?: number);
183
+ /** The decayed EWMA rtt estimate for endpoint `i` at time `now` (ns). Pure read, zero-alloc. */
184
+ ewmaAt(i: number, now: number): number;
185
+ /** Warm feedback path: record an rtt sample (ns) for endpoint `i` at time `now` (ns). 0 B/op. */
186
+ recordRtt(i: number, sampleNs: number, now: number): void;
187
+ /** Pick by latency-aware power-of-two-choices at time `now` (ns), or `PICK_NONE`. O(d)=O(1). */
188
+ pick(now?: number): number;
189
+ }
package/Pick.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * @zakkster/lite-pick -- zero-GC load-balancing SELECTION KERNEL.
3
3
  *
4
- * M4 (0.4.0): substrate seams + six strategies -- RoundRobin, SmoothWRR, P2C, and the
5
- * exact LeastConn family (LeastConn, SED, NQ). This file ships:
4
+ * M7 (0.7.0): substrate seams + seven strategies -- RoundRobin, SmoothWRR, P2C, the exact
5
+ * LeastConn family (LeastConn, SED, NQ), and PeakEWMA (latency-aware P2C). This file ships:
6
6
  *
7
7
  * - VERSION the single source-of-truth version stamp (3-place sync).
8
8
  * - PICK_NONE the fail-closed sentinel (-1): "no endpoint", never a dead pick.
@@ -26,6 +26,11 @@
26
26
  * charges the NEW request's marginal cost. O(cap)/pick, 0 B/op.
27
27
  * - NqBalancer never-queue (IPVS `nq`): an IDLE eligible endpoint immediately if one
28
28
  * exists, else SED. The worker-pool fit. O(cap)/pick, 0 B/op.
29
+ * - PeakEwmaBalancer latency-aware P2C (Twitter Finagle's peak-EWMA): draws two distinct
30
+ * eligible endpoints and takes the lower cost = (inflight+1) x decayed EWMA(rtt).
31
+ * Decay-on-READ (pick() never writes -> 0 B/op); the balancer OWNS the Float64
32
+ * _ewma/_stamp state and is its SOLE writer via the warm recordRtt() feedback
33
+ * path (also 0 B/op). Caller-supplied nanosecond clock. O(d)=O(1)/pick.
29
34
  *
30
35
  * The identity (decisions/0001): lite-pick OWNS NO mutable state it can avoid owning.
31
36
  * It reads pre-allocated views (eligibility, inflight, weights, scores) that siblings or
@@ -33,7 +38,7 @@
33
38
  * counters live OUTSIDE the kernel. The steady-state pick path allocates 0 B/op.
34
39
  *
35
40
  * Roster (one strategy per session -- see ROADMAP.md): RoundRobin [M1], SmoothWRR [M2],
36
- * P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA, ConsistentHash, BoundedLoad, WeightedRandom
41
+ * P2C [M3], LeastConn/SED/NQ [M4], PeakEWMA [M7], ConsistentHash, BoundedLoad, WeightedRandom
37
42
  * [planned]. The EXACT-O(log n) fewest-in-flight variant is a deferred @zakkster/lite-logn
38
43
  * BinaryHeap optional-peer seam (decisions/0006), not this exact-O(cap) scan.
39
44
  *
@@ -46,7 +51,7 @@
46
51
  */
47
52
 
48
53
  /** Version stamp. Synced across package.json and llms.txt (three-place rule). */
49
- export const VERSION = '0.6.0';
54
+ export const VERSION = '0.7.1';
50
55
 
51
56
  /**
52
57
  * Fail-closed sentinel returned by pick() when no endpoint is eligible.
@@ -574,3 +579,163 @@ export class NqBalancer extends BalancerBase {
574
579
  return best; // -1 when every eligible node has weight 0
575
580
  }
576
581
  }
582
+
583
+ /**
584
+ * PeakEwmaBalancer -- latency-aware power-of-two-choices (M7), Twitter Finagle's peak-EWMA.
585
+ *
586
+ * `pick(now)` draws TWO distinct eligible endpoints (the same rejection-sampling machinery as
587
+ * P2cBalancer -- reused verbatim, not re-implemented) and returns the one with the lower COST,
588
+ * where cost(i) = (inflight[i] + 1) x ewmaAt(i, now). It is P2C over a LATENCY signal instead of
589
+ * raw in-flight count: a slow endpoint (high EWMA rtt) is avoided even when its queue is short,
590
+ * so the pool steers around a degraded-but-up node -- the strategy the multi-region FE case wants.
591
+ * O(d) = O(1) per pick.
592
+ *
593
+ * Ownership (ADR 0001, ADR 0009): `inflight` is the CALLER's Uint32Array, read LIVE (the P2C /
594
+ * LeastConn seam). The EWMA state -- `_ewma` (the decayed rtt estimate) and `_stamp` (the ns
595
+ * timestamp of each node's last update), both Float64Array -- is BALANCER-OWNED (the SmoothWRR
596
+ * precedent: a strategy may own algorithm state), and the balancer is its SOLE writer, via the
597
+ * warm `recordRtt()` feedback path. `pick()` NEVER writes: it decays ON READ, so the hot path
598
+ * stays a pure read -> 0 B/op.
599
+ *
600
+ * Decay-on-read: ewmaAt(i, now) = _ewma[i] x exp(-(now - _stamp[i]) / tau). No write, no clock
601
+ * call on the gated path -- `now` (and the rtt sample) are CALLER-supplied nanoseconds, consistent
602
+ * between `pick(now)` and `recordRtt(i, sampleNs, now)`, so the whole strategy is deterministic
603
+ * and testable and allocates nothing.
604
+ *
605
+ * Cold start: `_ewma` seeds to 1.0 and `_stamp` to a NEGATIVE "unsampled" sentinel (-1). While a
606
+ * node is unsampled `ewmaAt` returns the baseline 1.0 UNDECAYED, so before any sample cost(i) =
607
+ * (inflight[i] + 1) x 1 and PeakEWMA degrades GRACEFULLY to plain least-connections (P2C-over-
608
+ * inflight) REGARDLESS of the caller's clock magnitude -- a plain `_stamp = 0` would decay as
609
+ * exp(-now/tau) -> 0 under a real large clock and collapse a cold pool to random. The first
610
+ * `recordRtt` initializes the EWMA EXACTLY to the sample (clock-independent); the peak rule applies
611
+ * only from the second sample on. It is never NaN.
612
+ *
613
+ * Contract: `now` (in `pick(now)` / `recordRtt`) and `sampleNs` MUST be FINITE numbers. `recordRtt`
614
+ * throws on a non-finite argument (the warm path); `pick(now)` never throws (the fail-closed
615
+ * contract), so a non-finite `now` yields P2C-random selection rather than an error.
616
+ *
617
+ * Anti-flap (ADR 0002, ADR 0009): the EWMA half-life IS the smoothing -- a single slow sample
618
+ * snaps the cost up instantly and it decays back over ~tau, so there is NO extra dwell/hysteresis.
619
+ *
620
+ * Deferred (ADR 0009 / llms.txt): a p99-aware variant scoring inflight x p99Rtt via a per-node
621
+ * @zakkster/lite-sketch `DDSketch` (optional peer, 0 B/op `add`). EWMA-mean is the shipped,
622
+ * zero-peer default; `peerDependencies` stays empty until a shipped path imports the sketch.
623
+ *
624
+ * Bound: O(d) = O(1) per pick (two expected-O(1) rejection draws + two exp() + a compare),
625
+ * 0 B/op on BOTH `pick()` and `recordRtt()` (torture + PerfGate). Fails closed (PICK_NONE) when
626
+ * the whole pool is down.
627
+ */
628
+ export class PeakEwmaBalancer extends BalancerBase {
629
+ /**
630
+ * @param {number} capacity endpoint count (fixed).
631
+ * @param {Uint8Array} eligible shared view: 1 = pickable, 0 = down (length >= capacity).
632
+ * @param {Uint32Array} inflight per-endpoint in-flight counts (length >= capacity),
633
+ * caller-owned and only READ here.
634
+ * @param {number} tauNs the EWMA time-constant / half-life in nanoseconds (> 0, finite):
635
+ * larger tau = slower decay = longer memory of a latency spike.
636
+ * @param {number} [seed=0x9e3779b9] deterministic PRNG seed (reproducible benches).
637
+ */
638
+ constructor(capacity, eligible, inflight, tauNs, seed = 0x9e3779b9) {
639
+ super(capacity, eligible);
640
+ // Validate typeof-first, BEFORE allocating the owned Float64 state (fail closed early).
641
+ if (!(inflight instanceof Uint32Array) || inflight.length < capacity) {
642
+ throw new RangeError('[lite-pick] inflight must be a Uint32Array of length >= capacity');
643
+ }
644
+ if (typeof tauNs !== 'number') {
645
+ throw new TypeError('[lite-pick] tauNs must be a number');
646
+ }
647
+ if (!Number.isFinite(tauNs) || tauNs <= 0) {
648
+ throw new RangeError('[lite-pick] tauNs must be a finite number > 0');
649
+ }
650
+ this._inflight = inflight;
651
+ this._tau = tauNs;
652
+ this._rng = new Prng(seed);
653
+ this._ewma = new Float64Array(capacity);
654
+ this._stamp = new Float64Array(capacity);
655
+ // Cold start: _ewma seeds to 1.0 and _stamp to a NEGATIVE "unsampled" sentinel (-1). The
656
+ // sentinel makes ewmaAt read the baseline UNDECAYED (graceful LeastConn) regardless of the
657
+ // caller's clock magnitude -- a plain _stamp=0 would decay as exp(-now/tau) -> 0 under a
658
+ // real large-magnitude clock and collapse a cold pool to random selection.
659
+ for (let i = 0; i < capacity; i++) { this._ewma[i] = 1.0; this._stamp[i] = -1; }
660
+ }
661
+
662
+ /**
663
+ * A uniformly random ELIGIBLE index, or PICK_NONE if none. Reuses P2cBalancer's exact
664
+ * rejection-sampling draw (ADR 0005) verbatim -- same `_rng` / `_eligible` / `_live` fields,
665
+ * no re-implementation, no owned draw-set. Internal, zero-alloc.
666
+ * @returns {number}
667
+ */
668
+ _draw() {
669
+ return P2cBalancer.prototype._draw.call(this);
670
+ }
671
+
672
+ /**
673
+ * The decayed EWMA rtt estimate for endpoint i at time `now` (ns). Pure READ -- exponential
674
+ * decay applied on read, never written. An UNSAMPLED node (`_stamp < 0`) reads its baseline
675
+ * 1.0 UNDECAYED (graceful LeastConn), so a cold pool never underflows to 0 under a real
676
+ * large-magnitude clock. `now` must be finite. Zero-alloc.
677
+ * @param {number} i
678
+ * @param {number} now caller-supplied nanoseconds
679
+ * @returns {number}
680
+ */
681
+ ewmaAt(i, now) {
682
+ const s = this._stamp[i];
683
+ if (s < 0) return this._ewma[i]; // unsampled: undecayed baseline, clock-magnitude-independent
684
+ return this._ewma[i] * Math.exp(-(now - s) / this._tau);
685
+ }
686
+
687
+ /**
688
+ * Record an rtt SAMPLE for endpoint i at time `now` (the warm feedback path -- NOT the hot
689
+ * pick path). The FIRST sample (an unsampled node, `_stamp < 0`) initializes the EWMA EXACTLY
690
+ * to the sample, clock-magnitude-independent. Thereafter the Finagle peak rule applies: decay
691
+ * the stored estimate to `now`, then SNAP UP to the sample if it is larger (a spike is felt
692
+ * instantly) else ease toward it (it decays back over ~tau). The balancer is the SOLE writer of
693
+ * `_ewma` / `_stamp`. Zero-alloc on the success path.
694
+ * @param {number} i endpoint index
695
+ * @param {number} sampleNs observed rtt in nanoseconds (finite, >= 0)
696
+ * @param {number} now caller-supplied nanoseconds (finite), consistent with pick(now)
697
+ */
698
+ recordRtt(i, sampleNs, now) {
699
+ if (typeof i !== 'number' || typeof sampleNs !== 'number' || typeof now !== 'number') {
700
+ throw new TypeError('[lite-pick] recordRtt(i, sampleNs, now) requires numbers');
701
+ }
702
+ if (i < 0 || i >= this._cap) throw new RangeError('[lite-pick] index out of range: ' + i);
703
+ if (!Number.isFinite(sampleNs) || sampleNs < 0) {
704
+ throw new RangeError('[lite-pick] sampleNs must be a finite number >= 0');
705
+ }
706
+ if (!Number.isFinite(now)) throw new RangeError('[lite-pick] now must be a finite number');
707
+ if (this._stamp[i] < 0) {
708
+ this._ewma[i] = sampleNs; // first sample: exact init, no decay (clock-independent)
709
+ } else {
710
+ const w = Math.exp(-(now - this._stamp[i]) / this._tau);
711
+ const e = this._ewma[i] * w;
712
+ this._ewma[i] = sampleNs > e ? sampleNs : e + (sampleNs - e) * (1 - w);
713
+ }
714
+ this._stamp[i] = now;
715
+ }
716
+
717
+ /**
718
+ * Pick by latency-aware power-of-two-choices: two distinct eligible draws, lower cost =
719
+ * (inflight+1) x ewmaAt(now) wins; a tie goes to the first draw. PICK_NONE (fail closed) iff
720
+ * the whole pool is down. O(d)=O(1), 0 B/op (pure read -- no write, no clock call).
721
+ * @param {number} now caller-supplied nanoseconds (consistent with recordRtt)
722
+ * @returns {number}
723
+ */
724
+ pick(now) {
725
+ const a = this._draw();
726
+ if (a < 0) return PICK_NONE; // whole pool down: fail closed
727
+ if (this._live === 1) return a; // only one eligible: it is both choices
728
+ // A DISTINCT second draw, bounded (the ADR 0005 rationale): at live>=2 each redraw misses
729
+ // with probability <= 1/2, so 32 tries leaves a ~2^-32 collision chance, expected-O(1), 0 B/op.
730
+ let b = this._draw();
731
+ for (let t = 0; b === a && t < 32; t++) b = this._draw();
732
+ if (b < 0 || b === a) return a; // astronomically rare: fall back to the first draw
733
+ const inf = this._inflight, ewma = this._ewma, stamp = this._stamp, tau = this._tau;
734
+ // Decay-on-read with the unsampled sentinel: `_stamp < 0` reads the undecayed baseline
735
+ // (graceful LeastConn), else exponential decay. A cheap per-candidate compare, no alloc.
736
+ const sa = stamp[a], sb = stamp[b];
737
+ const costA = (inf[a] + 1) * (sa < 0 ? ewma[a] : ewma[a] * Math.exp(-(now - sa) / tau));
738
+ const costB = (inf[b] + 1) * (sb < 0 ? ewma[b] : ewma[b] * Math.exp(-(now - sb) / tau));
739
+ return costB < costA ? b : a; // lower cost wins; tie -> the first draw
740
+ }
741
+ }
package/Pool.d.ts CHANGED
@@ -10,9 +10,11 @@ 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(): number;
13
+ pick(now?: number): number;
14
14
  readonly capacity: number;
15
15
  readonly live: number;
16
+ /** Optional latency-feedback sink (PeakEwmaBalancer); fed on settle when a clock is supplied. */
17
+ recordRtt?(i: number, sampleNs: number, now: number): void;
16
18
  }
17
19
 
18
20
  /** Options for `Pool.run`. */
@@ -21,6 +23,11 @@ export interface RunOptions {
21
23
  signal?: AbortSignal;
22
24
  /** Max distinct-endpoint attempts (default 1 = no failover). */
23
25
  tries?: number;
26
+ /**
27
+ * A caller-owned nanosecond clock. When present it drives `pick(now)` and the opt-in
28
+ * `recordRtt` latency feedback for a latency-aware balancer (PeakEwma); otherwise inert.
29
+ */
30
+ clock?: () => number;
24
31
  }
25
32
 
26
33
  /**
package/Pool.js CHANGED
@@ -70,11 +70,20 @@ export class Pool {
70
70
  * DIFFERENT endpoint -- up to `tries` attempts, then throw the last error. All counts this run
71
71
  * raised are released before returning or throwing (net-zero per run).
72
72
  *
73
+ * A LATENCY-AWARE balancer (PeakEwmaBalancer -- anything duck-typing `recordRtt`) is fed on
74
+ * settle WHEN a `clock` is supplied: Pool reads `clock()` (caller-owned nanoseconds) before the
75
+ * attempt, passes it to `pick(now)`, and on a SUCCESSFUL settle records `recordRtt(i, elapsed,
76
+ * now2)`. Without a `clock`, or against a balancer with no `recordRtt`, the hook is INERT --
77
+ * Pool stays generic, the in-flight counter stays net-zero, and abort/failover are unaffected.
78
+ * The kernel `pick()` remains 0 B/op; this wrapper is not held to that bar.
79
+ *
73
80
  * @template T
74
81
  * @param {(endpoint: number, signal?: AbortSignal) => (Promise<T>|T)} fn the per-endpoint work.
75
- * @param {{ signal?: AbortSignal, tries?: number }} [opts] `tries` (default 1 = no failover)
76
- * is the max number of distinct-endpoint attempts; `signal` is passed to `fn` and, when
77
- * already aborted after a failure, stops failover (the abort propagates, no re-pick).
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
84
+ * passed to `fn` and, when already aborted after a failure, stops failover (the abort
85
+ * 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.
78
87
  * @returns {Promise<T>}
79
88
  */
80
89
  async run(fn, opts) {
@@ -82,12 +91,17 @@ export class Pool {
82
91
  const rawTries = opts && opts.tries != null ? (opts.tries | 0) : 1;
83
92
  const tries = rawTries > 0 ? rawTries : 1;
84
93
  const signal = opts ? opts.signal : undefined;
94
+ const clock = opts && typeof opts.clock === 'function' ? opts.clock : undefined;
85
95
  const inflight = this._inflight, b = this._b;
96
+ // Opt-in latency feedback: only when BOTH a clock is supplied AND the balancer duck-types
97
+ // recordRtt. Otherwise inert -- Pool stays generic and byte-for-byte behaviour is unchanged.
98
+ const rtt = clock !== undefined && typeof b.recordRtt === 'function';
86
99
  const held = []; // endpoints incremented this run (kept elevated across failover)
87
100
  let lastErr;
88
101
  try {
89
102
  for (let attempt = 0; attempt < tries; attempt++) {
90
- const i = b.pick();
103
+ const now = clock !== undefined ? clock() : undefined;
104
+ const i = b.pick(now);
91
105
  if (i === PICK_NONE) {
92
106
  if (attempt === 0) {
93
107
  const e = new Error('[lite-pick] no eligible endpoint');
@@ -99,7 +113,12 @@ export class Pool {
99
113
  inflight[i] = (inflight[i] + 1) >>> 0;
100
114
  held.push(i);
101
115
  try {
102
- return await fn(i, signal);
116
+ const out = await fn(i, signal);
117
+ if (rtt) { // successful settle: feed the measured rtt back to the balancer
118
+ const done = clock();
119
+ b.recordRtt(i, done > now ? done - now : 0, done);
120
+ }
121
+ return out;
103
122
  } catch (err) {
104
123
  lastErr = err;
105
124
  if (signal && signal.aborted) throw err; // abort: stop failover, propagate
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.5.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), 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 -- 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.7.0 ships seven strategies -- `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, and the latency-aware `PeakEwmaBalancer`** -- 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 -- 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: M6 (v0.6.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, and the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family**, the **`@zakkster/lite-pick/pool`** request layer, and now 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). The kernel `Pick.js` / `Pool.js` are byte-identical to v0.5.0 apart from the version stamp. 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%** -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M6 -> 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), and benchmark-suite (0008) design forks.
24
+ > **Status: M7 (v0.7.0).** Ships the substrate seams **plus `RoundRobinBalancer`, `SmoothWRRBalancer`, `P2cBalancer`, the exact `LeastConnBalancer` / `SedBalancer` / `NqBalancer` family, and the latency-aware `PeakEwmaBalancer`**, 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%**, and **PeakEWMA steers around a 10x-slow node** (it takes <= 25% of P2C's share for it and cuts service p99) -- all held under a **seeded invariant fuzzer** (`test/fuzz.mjs`) that checks state-synchronisation after *every* op. See [ROADMAP.md](./ROADMAP.md) for the M7 -> 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), and PeakEWMA (0009) design forks.
25
25
 
26
26
  ```bash
27
27
  npm install @zakkster/lite-pick
@@ -139,6 +139,47 @@ The proof (from `test/balance.mjs`):
139
139
 
140
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
141
 
142
+ ## PeakEWMA -- latency-aware P2C (v0.7.0)
143
+
144
+ When endpoints differ in **latency**, not just queue depth, count-based strategies keep re-probing a slow-but-up node: it drains its queue between visits, so its in-flight looks attractive again. `PeakEwmaBalancer` (Twitter Finagle's *peak-EWMA*) is power-of-two-choices over a **latency cost** -- `cost = (inflight + 1) x decayed-EWMA(rtt)` -- so a degraded node is avoided **even while idle** ([ADR 0009](./decisions/0009-peakewma.md)). It is `O(d) = O(1)` per pick and **0 B/op** on both the pick path and the feedback path.
145
+
146
+ ```js
147
+ import { PeakEwmaBalancer } from '@zakkster/lite-pick';
148
+
149
+ const eligible = Uint8Array.from([1, 1, 1, 1]);
150
+ const inflight = new Uint32Array(4); // YOU own this; read live by pick()
151
+ const TAU_NS = 30e6; // EWMA half-life: 30ms of latency memory
152
+
153
+ // `now` and rtt samples are CALLER-supplied nanoseconds -- deterministic, testable, zero-GC.
154
+ const pe = new PeakEwmaBalancer(4, eligible, inflight, TAU_NS);
155
+
156
+ const now = perfNs(); // your monotonic ns clock
157
+ const i = pe.pick(now); // two random eligibles, lower latency-cost wins
158
+ inflight[i]++; // dispatch
159
+ // ... await the request ...
160
+ inflight[i]--; // settle
161
+ pe.recordRtt(i, perfNs() - now, perfNs()); // feed the observed rtt back (the warm path)
162
+ ```
163
+
164
+ - **Decay-on-read.** `pick()` *never writes* -- it applies exponential decay when it reads (`ewmaAt(i, now) = _ewma[i] x exp(-(now - _stamp[i]) / tau)`), so the hot path is a pure read and allocates nothing.
165
+ - **The peak rule.** `recordRtt` *snaps the cost up* to a larger sample instantly (a spike is felt on the next pick) and *decays it down* over `~tau`. The half-life **is** the anti-flap smoothing -- no extra dwell ([ADR 0002](./decisions/0002-anti-flapping.md)).
166
+ - **Balancer-owned state.** `inflight` is your live-read `Uint32Array`; the EWMA arrays are owned by the balancer and written *only* by `recordRtt`. Cold start seeds the EWMA to `1.0` with an *unsampled* sentinel (`_stamp = -1`): an unsampled node scores at its undecayed baseline, so before any sample PeakEWMA degrades gracefully to least-connections **regardless of your clock's magnitude** -- never underflowing to `0` (which a plain `_stamp = 0` would, as `exp(-now/tau)`, under a real large clock) and never `NaN`. The first `recordRtt` initializes the EWMA *exactly* to the sample; the peak rule applies from the second sample on.
167
+ - **`now` must be finite.** `now` (for `pick(now)` / `recordRtt`) and `sampleNs` must be finite numbers. `recordRtt` throws on a non-finite argument; `pick(now)` never throws (the fail-closed contract), so a non-finite `now` yields P2C-random selection rather than an error.
168
+
169
+ The proof (from `test/balance.mjs`, a closed-loop queue sim with one node at 10x service time):
170
+
171
+ | lane | slow-node share | service p99 |
172
+ |---|---|---|
173
+ | **PeakEWMA** | **~0.01%** (learns and avoids it) | **lowest** |
174
+ | P2C (in-flight only) | ~1.5% (keeps re-probing) | ~7x PeakEWMA's |
175
+ | random foil | ~6% (blind) | saturates the slow node |
176
+
177
+ PeakEWMA sends the slow node **<= 25% of P2C's share** for it and cuts service p99 **>= 20% below** P2C-over-inflight; the random foil is worse than both.
178
+
179
+ ### FE profile -- PeakEWMA + health, nothing else
180
+
181
+ For a **front-end / browser client** -- a handful of picks per second across origins/regions, not a zero-GC hot loop -- the recommended profile is **PeakEWMA + the eligibility bitmap only**: latency-aware choice with a fail-closed health view, and **none** of the server-side bounded-load / availability-zone / occupancy machinery. It is the smallest honest latency-aware client balancer. *(Deferred: a tail-aware `inflight x p99Rtt` variant via an optional-peer `@zakkster/lite-sketch` `DDSketch`; the EWMA-mean score is the shipped zero-peer default, and `peerDependencies` stays `{}` -- [ADR 0009](./decisions/0009-peakewma.md).)*
182
+
142
183
  ## Evidence -- the two headlines (v0.6.0 benchmark suite)
143
184
 
144
185
  > **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`.
@@ -151,9 +192,9 @@ The real pinned npm incumbents (`load-balancers`, `loadbalance`, `wrr`) run thro
151
192
 
152
193
  | family | lite-pick | lite-pick ops/ms | incumbent (npm) | incumbent ops/ms |
153
194
  | --- | --- | --- | --- | --- |
154
- | P2C (power-of-two-choices) | P2cBalancer | 59591 | load-balancers@1.3.52 | 60956 |
155
- | RoundRobin | RoundRobinBalancer | 260168 | loadbalance@1.0.0 | 334541 |
156
- | Weighted-random | WeightedRandom -- SKIP, ships M10 | -- | wrr@1.0.0 | 158395 |
195
+ | P2C (power-of-two-choices) | P2cBalancer | 53426 | load-balancers@1.3.52 | 61749 |
196
+ | RoundRobin | RoundRobinBalancer | 246108 | loadbalance@1.0.0 | 282939 |
197
+ | Weighted-random | WeightedRandom -- SKIP, ships M10 | -- | wrr@1.0.0 | 151557 |
157
198
 
158
199
  <!-- /bench:competitors -->
159
200
 
@@ -181,8 +222,8 @@ The point of zero-GC is **not** the pick's own latency -- a major GC pause freez
181
222
 
182
223
  | lane | major GC | pick B/op | max GC pause (ms) |
183
224
  | --- | --- | --- | --- |
184
- | lite-pick | 0 | 0 | 0.1 |
185
- | allocating foil | 13 | allocates | 2.9 |
225
+ | lite-pick | 0 | 0 | 0.3 |
226
+ | allocating foil | 13 | allocates | 1.9 |
186
227
 
187
228
  <!-- /bench:gc -->
188
229
 
@@ -221,6 +262,7 @@ On a scale event (add / remove a node), what fraction of keys keep their node? T
221
262
  | AWS edge feature (managed, billed) | `lite-pick` equivalent (in-process, zero-GC) |
222
263
  | --- | --- |
223
264
  | ALB `least_outstanding_requests` (LOR) | `LeastConnBalancer` / `P2cBalancer` |
265
+ | ALB anomaly mitigation / latency-aware shedding | `PeakEwmaBalancer` (latency-aware P2C) |
224
266
  | ALB `weighted_random` + anomaly mitigation | `WeightedRandom` + `BoundedLoad` (M9/M10) |
225
267
  | NLB flow-hash (5-tuple) | `ConsistentHash` (Maglev, M8) |
226
268
 
@@ -254,7 +296,7 @@ PICK_NONE; // -> -1 (fail-closed sentinel: no endpoint, never a dead
254
296
  VERSION; // -> '0.6.0'
255
297
  ```
256
298
 
257
- `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.
299
+ `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.
258
300
 
259
301
  ## Wiring it up -- `@zakkster/lite-pick/pool` (v0.5.0)
260
302
 
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zakkster/lite-pick
2
2
 
3
- Version: 0.6.0
3
+ Version: 0.7.1
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,15 +14,25 @@ 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.6.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.
17
+ 0.7.0 ships the substrate seams + seven 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), and PeakEWMA (latency-aware P2C). It exports
20
+ `VERSION`, the fail-closed sentinel `PICK_NONE` (-1), a deterministic `Prng` (xorshift32),
21
+ `BalancerBase` (the shared read-only eligibility seam + O(1) live count), `RoundRobinBalancer`,
22
+ `SmoothWRRBalancer`, `P2cBalancer`, `LeastConnBalancer`, `SedBalancer`, `NqBalancer`, and
23
+ `PeakEwmaBalancer`. The remaining strategies land one per session (see ROADMAP.md):
24
+ ConsistentHash, BoundedLoad, WeightedRandom. The EXACT-O(log n) fewest-in-flight variant is a
25
+ deferred @zakkster/lite-logn `BinaryHeap` optional-peer seam (decisions/0006), not this
26
+ exact-O(cap) scan.
27
+
28
+ M7 (0.7.0) adds PeakEwmaBalancer (decisions/0009). The FE PROFILE: for a browser / front-end
29
+ client (a handful of picks per second, not a zero-GC hot loop), the recommended profile is
30
+ PeakEWMA + health/eligibility ONLY -- no bounded-load / AZ / occupancy machinery -- latency-aware
31
+ choice across origins with a fail-closed eligibility view. DEFERRED tail-aware variant: a p99-aware
32
+ score (inflight x p99Rtt) via a per-node @zakkster/lite-sketch `DDSketch` (published 0.3.0; `add`
33
+ is O(1) / 0 B/op, `quantile` carries a hard relative-error bound) is an OPTIONAL PEER complement;
34
+ the EWMA-mean score is the shipped, zero-peer default and `peerDependencies` STAYS `{}` until a
35
+ shipped code path imports the sketch.
26
36
 
27
37
  M6 (0.6.0) adds the BENCHMARK SUITE -- an evidence session, no API change: Pick.js / Pool.js are
28
38
  byte-identical to 0.5.0 apart from the VERSION stamp. It lives entirely under benchmark/ (NOT in
@@ -116,6 +126,30 @@ the contract + balance + tail -- never an "N times faster" headline (decisions/0
116
126
  - `pick()` -> number. Returns the FIRST idle eligible positive-weight node (in-flight 0) if one
117
127
  exists -- never queueing while a server is free -- else the SED minimum, else `PICK_NONE`.
118
128
  O(cap) worst case, O(1) when an early node is idle. 0 B/op.
129
+ - `PeakEwmaBalancer extends BalancerBase` -- class. Latency-aware P2C (M7, Finagle peak-EWMA).
130
+ - `new PeakEwmaBalancer(capacity, eligible, inflight, tauNs, seed?=0x9e3779b9)` -- `inflight` is a
131
+ caller-owned Uint32Array (length >= capacity) read LIVE; `tauNs` is the EWMA time-constant /
132
+ half-life in nanoseconds (finite, > 0). The per-endpoint EWMA state (`_ewma` / `_stamp`,
133
+ Float64) is BALANCER-OWNED and written ONLY by `recordRtt`. Validates typeof-first BEFORE
134
+ allocating (RangeError/TypeError). Cold start seeds the EWMA to 1.0 with an UNSAMPLED sentinel
135
+ (`_stamp = -1`), so an unsampled node scores at its undecayed baseline -> graceful
136
+ least-connections regardless of clock magnitude (never underflows to 0).
137
+ - `pick(now)` -> number. Draws two DISTINCT eligible endpoints (ADR 0005's rejection sampling,
138
+ reused) and returns the lower `cost = (inflight + 1) * ewmaAt(now)`; a tie goes to the first
139
+ draw. `now` is caller-supplied nanoseconds. Decays ON READ (never writes) -> O(d)=O(1), 0 B/op.
140
+ `PICK_NONE` when the whole pool is down. A slow-but-up node (high EWMA rtt) is avoided even while
141
+ idle -- the difference from P2C-over-inflight (latency anchor in balance.mjs).
142
+ - `ewmaAt(i, now)` -> number. The EWMA rtt estimate for endpoint i at `now`. Pure read. An
143
+ unsampled node (`_stamp < 0`) returns the baseline 1.0 undecayed; otherwise exponential decay.
144
+ - `recordRtt(i, sampleNs, now)` -> void. WARM feedback path (not the hot pick path): the FIRST
145
+ sample initializes the EWMA EXACTLY to `sampleNs` (clock-magnitude-independent); thereafter the
146
+ Finagle peak rule -- the cost SNAPS UP to a larger sample instantly and DECAYS DOWN over ~tau.
147
+ `now` / `sampleNs` are caller-supplied nanoseconds, consistent with `pick(now)`. Validates
148
+ typeof-first; 0 B/op on the success path. Anti-flap = the half-life, no extra dwell (ADR 0002,
149
+ ADR 0009).
150
+ - CONTRACT: `now` and `sampleNs` MUST be FINITE numbers. `recordRtt` THROWS on a non-finite
151
+ argument (warm path); `pick(now)` NEVER throws (fail-closed contract), so a non-finite `now`
152
+ yields P2C-random selection, not an error.
119
153
 
120
154
  ## Subpath: @zakkster/lite-pick/pool -- the ergonomic request layer (M5, Pool.js)
121
155
 
@@ -140,7 +174,11 @@ duck-typed and imports NOTHING from lite-query.
140
174
  once aborted after a failure, failover stops and the abort propagates. NOT a 0 B/op path (the
141
175
  kernel `pick()` is): a normal async wrapper adding O(1) counter ops + one small per-run array.
142
176
  BOUNDARY: Pool owns SPATIAL failover (across the pool); the caller / query cache owns TEMPORAL
143
- retry (backoff, staleness). Never double-owned (decisions/0007).
177
+ retry (backoff, staleness). Never double-owned (decisions/0007). OPT-IN latency feedback: when
178
+ `opts.clock` (a caller-owned nanosecond source) is supplied AND the balancer duck-types
179
+ `recordRtt` (PeakEwmaBalancer), Pool drives `pick(now)` and records the settled rtt on success;
180
+ otherwise the hook is inert -- Pool stays generic, in-flight stays net-zero, abort/failover
181
+ unchanged.
144
182
  - `liteQueryFetcher(pool, perEndpoint, opts?)` -> a `({ key, signal }) => Promise` fetcher for a
145
183
  query cache (lite-query's `fetcher`, or any fetcher-shaped consumer). `perEndpoint({ endpoint,
146
184
  key, signal })` -> the per-endpoint work. `opts.tries` (default 1) is the spatial failover count.
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.6.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; the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
4
+ "version": "0.7.1",
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), plus consistent hashing; the /pool subpath adds dispatch/settle counters + failover and a duck-typed query-cache fetcher.",
6
6
  "type": "module",
7
7
  "main": "./Pick.js",
8
8
  "module": "./Pick.js",