npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-adaptive

v1.12.0

Published

Zero-dependency, zero-GC family of APPROXIMATE, sublinear-space streaming summaries over the TIME / RECENCY axis that witnesses its RECENCY against the paper's theoretical bound: ExponentialHistogram (sliding-window count / sum) + ADWIN (concept-drift det

Readme

@zakkster/lite-adaptive

Zero-GC streaming summaries over the TIME / RECENCY axis that witness their recency against the paper's bound -- ExponentialHistogram for sliding-window count / sum, in fixed memory, over a caller-supplied clock.

npm version sponsor Zero-GC npm bundle size npm downloads npm total downloads Tree-Shakeable TypeScript Dependencies license Stable

The recency family the ecosystem was missing

Exact analytics over an unbounded, EVOLVING stream cost unbounded memory: to answer "how many events in the last W" exactly you must buffer the whole window (O(W) items). Worse, most summaries are CUMULATIVE -- they fold the whole stream and never forget, so they cannot tell you what is happening right now. lite-adaptive is a zero-dependency, zero-GC family of streaming summaries over the time / recency axis: it answers a recency question in fixed memory, and it can forget. Its signature is a shipped recency witness -- every member proves its MEASURED windowed error against the paper's THEORETICAL bound, next to the memory it saves.

It is the fourth corner of the suite: @zakkster/lite-o1 is exact O(1), @zakkster/lite-filter is approximate membership, @zakkster/lite-sketch is cumulative approximate aggregates, and lite-adaptive is the windowed / decayed / drift complement. The line is recency: cumulative -> lite-sketch; last-W / decayed / drift -> lite-adaptive.

Stable at 1.0.0. The four-member core -- ExponentialHistogram, ADWIN, ForwardDecay, HeavyKeeper -- is frozen: signatures, options, and valid-input behavior will not change under 1.x (additive members may still land later; the core does not break). Follows semver from here.

npm i @zakkster/lite-adaptive
import { ExponentialHistogram } from '@zakkster/lite-adaptive';

// count events in the LAST 60_000 ms, within 1% relative error, in a fixed ~53 KB pool
// (default maxCount 2^32; pass { maxCount } to size the pool for your window population)
const eh = new ExponentialHistogram(60_000, 0.01);
eh.add(Date.now());                        // caller owns the clock (monotone `now`)
eh.add(Date.now());                        // ... add(now) per event, 0 bytes/op
eh.count();                                // windowed count over the last 60 s (+-1%)

// no clock? omit `now` for the "last N items" window (count mode auto-ticks)
const last1000 = new ExponentialHistogram(1000, 0.01);
for (let i = 0; i < 5000; i++) last1000.add();
last1000.count();                          // ~1000  (an exact ring would hold 1000 timestamps)

Contents

Why this exists

A recency summary is a promise: "I use X bytes and my answer about the last W is within E of the truth -- and I forget the rest." That promise is only as good as (1) a time source you can control and replay, and (2) whether anyone ever checks the windowed error against the theory. lite-adaptive takes a caller-supplied monotone clock (never reads the wall clock -- so every result is deterministic and testable) and ships the witness that measures the windowed error and gates it against the bound on every release. Recency is a co-headline, stated honestly as a TRIPLE: the space it costs, the error it guarantees at that space, and the recency model (a hard window vs decay vs an adaptive window).

What you get

  • ExponentialHistogram -- sliding-window count / sum ("how many / how much in the last W?") in a fixed pool of (timestamp, size) buckets, with a hard windowed COUNT relative-error bound <= epsilon (the SUM error is bounded absolutely by half the oldest straddling bucket's value-mass -- relative <= epsilon only for count or near-constant values). A maxCount option (default 2^32) sizes the pool to the window population. The reference member; DGIM (the 0/1 count stream) is its value = 1 special case.
  • ADWIN -- concept-drift detection with NO fixed window size (Bifet-Gavalda, SDM 2007): add(x) -> boolean tells you the moment the stream's mean changed, and the adaptive window GROWS while stable and SHRINKS to the new concept on a detected change. mean / variance / width report the current stable window. The marquee member.
  • ForwardDecay -- time-decayed count / sum / mean / rate (Cormode-Shkapenyuk-Srivastava-Xu, ICDE 2009) in O(1) space (two scalar accumulators, no pool): every element's weight halves every halfLife, so recent data dominates and old data fades smoothly instead of dropping at an edge. add(now?, value?) accepts any finite real (signed); count / sum / mean / rate are O(1) queries, EXACT modulo floating point.
  • HeavyKeeper -- decayed / windowed heavy hitters (Gong et al., USENIX ATC 2018): the top-k keys dominating the stream right now. A d x w fingerprint table with probabilistic exponential decay on collision (cold keys erode) + an intrusive top-k min-forest. add(key, weight = 1) ranks by count or any additive weight; forEach / topKInto read the leaders 0-alloc. Far lower error than Space-Saving on skewed, evolving streams. The final member of the frozen 1.0.0 core.
  • SlidingHyperLogLog -- windowed distinct-count (Chabchoub-Hebrail, "Sliding HyperLogLog", 2010): "how many distinct keys in the last W?" in fixed preallocated space at HLL accuracy (the recency sibling of @zakkster/lite-sketch's cumulative HyperLogLog). An m = 2^p register bank where each register keeps a small fixed LFPM ring of (timestamp, rho) maxima; add(now, key) / addFrom are 0-alloc incl. the windowed eviction, count(w?) runs Ertl's estimator over the live window with a 1.04 / sqrt(m) standard error. The first additive post-1.0 member (1.1.0).
  • DriftDetector -- scalar change detection (Page, Biometrika 1954): "did the mean of this signal just shift?" in O(1) state -- six core scalars plus the latch runtime (_lDir / _lvl) and one fixed Float64Array(3), no pool, no window (the lightest member). One class selects Page-Hinkley or two-sided CUSUM via a mode const; add(x) -> boolean returns true exactly on the detecting item, then resets to catch the next shift. The cheap per-channel companion to ADWIN -- run one per stream when you have many. The second additive post-1.0 member (1.2.0).
  • SlidingDDSketch -- windowed relative-error quantiles (Masson-Rim-Lee, "DDSketch", VLDB 2019): "what's p50 / p99 over the last W?" in fixed preallocated space at DDSketch accuracy (the recency sibling of @zakkster/lite-sketch's cumulative DDSketch). A ring of panes preallocated DDSketch panes (default 32), each covering W / panes; add(now, value) / addFrom are 0-alloc incl. the pane rotate + clear, and quantile(q, w?) / quantileInto merge the live panes into an instance-owned scratch with a relative error <= alpha. The ring holds panes + 1 panes and covers [W, W + W/panes]: the full window W, over-covered by at most one pane and never under-covered. The third additive post-1.0 member (1.3.0).
  • SlidingCountMin -- windowed per-label frequency (Cormode-Muthukrishnan Count-Min over a B+1 pane ring): "how many times did key k occur in the last W?" in fixed memory (the recency sibling of @zakkster/lite-sketch's cumulative CountMinSketch). A ring of B+1 panes (default panes B = 32), each a d x w Uint32 grid; add(now, key, count?) / addFrom are amortized 0-alloc incl. the pane rotate, and estimate(key, w?) sums the key's cells across the live panes then mins over rows -- a one-sided upper bound, never an under-count. Ships advance(now) (R11 idle-slide). The fourth additive post-1.0 member (1.5.0).
  • DecayedReservoir -- a recency-biased fixed-k sample of actual stream values (Efraimidis-Spirakis A-Res weighted reservoir over ForwardDecay weights): the "give me k real recent items" member -- the sampling complement to ForwardDecay (which gives decayed aggregates exactly, this gives a decayed sample you compute anything over). Each add(now?, value?) / addFrom draws one seeded xorshift32 uniform, forms an A-Res key in log space, and sifts it into a size-k min-forest; amortized 0-alloc incl. the order-preserving landmark rebase. Read the raw sample with sampleInto(buf) / forEach(fn). A sample, not a hard window, so (like ForwardDecay) it has no advance(). The fifth additive post-1.0 member (1.6.0).
  • SlidingAggregate -- EXACT windowed count / sum / mean / min / max over the covered span [w, w + W/B] (which always CONTAINS the last w) (ADR 0012): the aggregate the family lacked. A ring of B + 1 preallocated panes (default B = 32), each a stride-5 [count, sum, kcomp, min, max] slot; add(now, value) folds the value in with a branch-free per-pane Kahan compensated sum, and a reader Neumaier-merges the live panes. Over the covered span [w, w + W/B] (which always CONTAINS the last w) count / min / max are BIT-EXACT and |sum - exact| <= (4u + O(N u^2)) * sum|v| (u = 2^-53) -- an EXACT mean, where ExponentialHistogram.sum() is only population-bounded and fails on skew (F17). value is REQUIRED (a forgotten latency fails closed, no default of 1); into(out) reads all five at once, 0-alloc. 1584 B at the default, independent of W. Ships advance / advanceFrom (R11 idle-slide). The sixth additive post-1.0 member (1.9.0).
  • A caller-owned time source -- add(now) with a monotone now (a logical tick or ms), or add() in count mode for the "last N items" window. The member never reads the clock, so witnesses are exactly reproducible.
  • The recency witness -- measured windowed error vs the theoretical bound, printed side by side, with the exact-ring foil whose memory climbs O(W) while the histogram stays a fixed sliver.
  • Zero runtime dependencies, ESM, tree-shakeable named exports, TypeScript types, and a 0 bytes/op hot path -- including the amortized bucket merge / expire reshaping -- proven by a leak + GC-profiler torture gate.

ExponentialHistogram

Count (or sum) the elements in the last W of an unbounded, evolving stream using a fixed pool of buckets. Each add opens a level-0 bucket at now; a level-L bucket holds exactly 2^L elements (its population). When more than k = ceil(1/(2*epsilon)) + 1 buckets share a level, the two OLDEST merge into one bucket of the next level (a bounded, amortized-O(1) cascade); buckets whose timestamp fell out of [now - W, now] expire. The estimate is the sum of live buckets minus half the oldest bucket -- but only when that bucket genuinely straddles the window edge, so a not-yet-full window (and every population-1 bucket) is estimated EXACTLY.

The only source of error is the oldest bucket, which may straddle the window boundary: some of its elements have expired, some have not, and the structure cannot see inside a bucket. It estimates half of it is still in-window. That bucket's population is 2^Lmax, so the error is at most 2^Lmax / 2. Because each level below the top holds >= k - 1 buckets, the window's true count is >= (k - 1) * (2^Lmax - 1), which makes the relative error ~ 1/(2k) < epsilon -- a HARD bound, held on every query, not a statistical average.

The same invariant fixes the memory: the pool is sized from maxCount (the window population it is guaranteed to hold, default 2^32), not from W. The number of levels is levels = max(2, ceil(log2(maxCount/(k+1))) + 2), each level holds at most k + 1 buckets, so the pool is a fixed CAP = (k+1) * levels + 2 buckets -- preallocated at construction, never grown. At epsilon = 0.01 with the default maxCount = 2^32 that is 1510 buckets (~53 KB), independent of W, versus an exact ring of 65536 timestamps (512 KB at W = 65536).

A time-based window holds as many items as the arrival rate allows, so the population bound must be DECLARED -- a fixed pool cannot grow. maxCount is a guaranteed FLOOR: the exact ceiling is k * (2^levels - 1) elements (>= maxCount, ~3-6x it; epsilon = 0.01, maxCount = 5000 holds 26,061), and the add past it (its merge cascade would pass the top level) throws a tagged [lite-adaptive] RangeError (a byte-identical no-op, pre-checked with a read-only expiry scan). Size maxCount to your peak window population:

| epsilon (k) | maxCount 5e3 (5 s @ 1 kHz) | maxCount 5e5 (5 s @ 100 kHz) | maxCount 2^32 (default) | |-------------|---------------------------|------------------------------|-------------------------| | 0.01 (k 51) | 16.5 KB | 29.3 KB | 53.1 KB | | 0.05 (k 11) | 4.7 KB | 7.7 KB | 13.1 KB | | 0.1 (k 6) | 3.0 KB | 4.7 KB | 7.9 KB |

The pool is allocated before the mode locks, so a COUNT-mode instance ALSO gets the default 2^32 sizing (e.g. EH(1000, 0.01) is ~53 KB, not ~13 KB). Pass maxCount: W to keep the pre-1.7.0 W-sized pool.

The measured windowed COUNT relative error tracks 1/(2k) and stays under epsilon across the whole W in {64, 1000, 65536} x epsilon in {0.5, 0.1, 0.01} sweep, plus a dense 10 kHz / 70 s stream that never goes NaN -- see Testing. The SUM error obeys |sum_est - sum_true| <= size(oldest straddling bucket) / 2, which is relative <= epsilon only for count or near-constant values (a heavy-tailed or spiky value stream can exceed it). For an idle stream, advance(now) (and the zero-box advanceFrom(buf, i)) expires the window edge with no value added, so count() an hour later reflects the empty window instead of the last burst (R11 idle-slide, 0 B/op).

ADWIN

Detect when an unbounded stream's mean has CHANGED, with no window size to guess. ADWIN(delta) maintains a variance-carrying bucket list of recent values; add(x) appends x, compresses the buckets (at most 5 per level), and scans every bucket-boundary split of the window into an older sub-window W0 and a newer W1. When their means differ by more than a statistically justified threshold it flags a change (add returns true) and DROPS the older sub-window -- so the window automatically GROWS while the stream is stable and SHRINKS to the new concept on a change. mean / variance / width always describe the current, stable window.

import { ADWIN } from '@zakkster/lite-adaptive';

const adwin = new ADWIN(0.002);            // delta = false-alarm confidence
for (const x of latencies) {
    if (adwin.add(x)) {                     // true the moment the mean shifts
        console.log('drift! new mean', adwin.mean, 'over', adwin.width, 'items');
    }
}

A split of the window into W0 (older, n0 items) and W1 (newer, n1 items) is a change when |mean(W0) - mean(W1)| > epsCut, where

m       = 1 / (1/n0 + 1/n1)                 (harmonic mean of the two counts)
deltaP  = delta / ln(width)                 (Bonferroni over the tested splits)
epsCut  = sqrt( (2/m) * sigmaHat^2 * ln(2/deltaP) )   +   (2/3) * (R/m) * ln(2/deltaP)

sigmaHat^2 is the total-window variance and R is the current window's range (max - min over the live buckets, excluding the oldest -- since 1.7.0 F18; pre-1.7.0 this was a running global range that never shrank and could go deaf after a level shift). This is the ADWIN2 variance-aware bound (Bifet-Gavalda, SDM 2007): the variance term dominates on low-variance streams, so it detects small, real shifts faster than a range-only Hoeffding bound while keeping the stationary false-alarm rate <= delta (a Bernstein guarantee). Only bucket boundaries are tested (O(log width) splits), and on a detected cut the oldest bucket is dropped and the scan repeats -- so add is amortized O(1) and 0 B/op including the cut-scan and the shrink.

ADWIN is ITEM-INDEXED: add(x) per item, no clock -- the adaptive window is measured in items and is data-driven (unlike ExponentialHistogram's caller-supplied now). The change-response witness injects a known changepoint and gates the false-alarm rate <= delta, a detection latency that scales with shift magnitude, ~0 missed detections on a large shift, and that the adapted window reflects only the new concept -- see Testing.

ForwardDecay

Weight recent data more, forget old data smoothly. ForwardDecay(halfLife) maintains a time-decayed count / sum / mean / rate where each element's weight halves every halfLife time units -- there is no window edge, old data just fades. Unlike a backward decay (which re-weights the whole history on every query), ForwardDecay measures each element's age FORWARD from a fixed landmark, so its weight is computed ONCE at insert and folded into two running scalars (C = decayed count, Sv = decayed weighted sum). That is what makes it O(1) space and numerically stable.

import { ForwardDecay } from '@zakkster/lite-adaptive';

// a decayed average latency where a 5-minute-old sample counts half as much
const fd = new ForwardDecay(5 * 60_000);   // halfLife = 5 minutes (ms)
fd.add(Date.now(), 42);                     // add(now, value); caller owns the monotone clock
fd.add(Date.now(), 88);                     // ... 0 bytes/op, incl. the periodic landmark rebase
fd.mean();                                  // decayed mean latency (recent samples dominate)
fd.rate();                                  // decayed events per ms (see the rate() note below)

// no clock? omit `now` for a decayed "recent items" summary (count mode auto-ticks)
const recent = new ForwardDecay(1000);
for (let i = 0; i < 5000; i++) recent.add(undefined, Math.random());
recent.mean();                              // mean weighted toward the last ~1000 items

For exponential decay g(x) = exp(lambda * x), lambda = ln2 / halfLife, the accumulators are C = sum g(t_i - L) and Sv = sum value_i * g(t_i - L) from a landmark L. A query at now folds the common age factor back in:

decayedCount(now) = C  * exp(-lambda * (now - L))
decayedSum(now)   = Sv * exp(-lambda * (now - L))
mean(now)         = Sv / C                          // the age factor CANCELS -> now-invariant, EXACT
rate(now)         = decayedCount(now) * lambda

Because g grows without bound, add REBASES the landmark to t whenever lambda*(t - L) would exceed FD_EXP_CAP = 40: it rescales C *= exp(-lambda*(t - L)), Sv *= ..., L = t. This is EXACT modulo floating point -- it factors one common constant out of every accumulated term -- and 0 B/op. The cap is deliberately small so a single weight stays <= exp(40) ~ 2.35e17, leaving the accumulator ~7.6e290x of head-room under Double.MAX -- so overflow needs a physically unreachable term count. The one case no cap can cover -- a single value within a factor of exp(40) of Double.MAX -- is caught fail-closed: count / sum / mean / rate throw [lite-adaptive] on a non-finite accumulator rather than returning Infinity.

rate() is a DEFINITION, not a theorem. rate(now) = decayedCount(now) * lambda is defined as the decayed events per unit time under the exponential kernel: a steady arrival of r events/unit converges to decayedCount -> r / lambda, so rate() -> r. It is exact only in that steady-state limit, not a guaranteed instantaneous rate.

Signed values are allowed (unlike ExponentialHistogram's positive-only sum): because C and Sv are separate accumulators, a negative value lowers the decayed sum / mean while still contributing ONE decayed event to the count. The exact-aggregate witness recomputes the decayed aggregate directly from every stored (t_i, value_i) and gates |fd - oracle| / |oracle| <= 1e-9 on every query across 3 halfLife x 3 stream-shapes; two broken-rebase controls are rejected by the same gate -- see Testing.

HeavyKeeper

Find the few keys dominating the stream right now, with far lower error than Space-Saving on skewed and evolving traffic. HeavyKeeper(d, w, k) keeps a d x w table of (fingerprint, count) cells and a top-k min-forest. add(key, weight = 1) hashes the key to one cell per row: a matching fingerprint adds the weight; a colliding one is probabilistically decayed and, at count 0, evicted and replaced. So a key that stops arriving decays away and the live top-k tracks the current concept -- no window to size, no rotation to schedule.

import { HeavyKeeper } from '@zakkster/lite-adaptive';

const hk = new HeavyKeeper(4, 1024, 16, { seed: 1 });   // d rows, w cells/row, k leaders
for (const [tag, us] of spans) hk.add(tag, us);          // rank tags by TOTAL microseconds (weighted)

const topN = new Float64Array(2 * 16);                   // 2*k floats: [key, estimate] pairs
const n = hk.topKInto(topN);                             // 0-alloc: packs n pairs, returns entry count
hk.forEach((key, est) => { /* render key with est */ }); // 0-alloc iteration (topK() allocates)

On add(key, weight), for each of the d rows at the key's cell: an empty cell is claimed (fp, weight); a fingerprint match does count += weight; a fingerprint miss decays the resident count once with probability b^(-count) (b ~ 1.08, a seeded xorshift32 draw), then count -= weight (clamped at 0), replacing the fingerprint when it reaches 0. estimate(key) is the largest matching cell. Because a heavy key's count is large, b^(-count) is tiny, so it is almost never decayed -- while a cold key's small count erodes quickly. That asymmetry is why HeavyKeeper's error on the true heavy hitters is far below Space-Saving's on a Zipfian + drifting stream (the witness measures 0.001% vs 36.75% mean relative error over the true top-k).

The weighted-miss rule decays once (not once per weight unit): a per-unit decay would draw weight random numbers per miss -- with weight in microseconds that is thousands of draws, breaking the O(1), 0-alloc hot path. Decaying once then subtracting the weight keeps add amortized O(1) and 0 B/op including the decay draw and the forest sift (see ADR 0005).

The PRNG is a seeded Uint32 xorshift (seed option + getter, never Math.random), so the decay -- and therefore the top-k and the witness -- is fully reproducible. Keys are safe integers; on the mutating path (add / addFrom) a bad key / weight / buffer throws [lite-adaptive] typeof-first (a byte-identical no-op), while the estimate(key) query never throws -- a bad key reads NaN (F12). There is no merge: HeavyKeeper decays natively, so it needs no A/B rotation. The witness gates 100% recall of the true top-k above N/k vs an exact Map oracle (weighted and unit streams) and a bounded ONE-SIDED error -- a reported count is never above the truth and at most ~N/w below it (HeavyKeeper never overestimates); a frozen-forest and a decay-disabled variant are rejected by the same gate -- see Testing.

SlidingHyperLogLog

Count the distinct keys in the last W -- "how many distinct phases / sources / signatures in the recent window?" -- in fixed preallocated space at HyperLogLog accuracy. SlidingHyperLogLog is the recency sibling of @zakkster/lite-sketch's cumulative HyperLogLog: an m = 2^p register bank where each register, instead of a single rho byte, keeps a small fixed LFPM ring (List of Future Possible Maxima) of (timestamp, rho) entries -- a per-register monotonic deque with strictly decreasing rho. A register's windowed max rho equals the HLL register of the in-window distinct key set, so accuracy is the standard 1.04 / sqrt(m) standard error, no extra bias.

import { SlidingHyperLogLog } from '@zakkster/lite-adaptive';

const shll = new SlidingHyperLogLog(60000, { p: 10 });   // distinct keys in the last 60s (m = 1024)
shll.add(now, userId);                                    // monotone now (ms), safe-integer key
// ... a stream of (now, key) ...
shll.count();                 // windowed distinct estimate over the last W
shll.count(5000);             // distinct over the last 5s (a sub-window w in (0, W])
const out = new Float64Array(1);
shll.countInto(out);          // 0-alloc render: writes count() into out[0], returns 1
shll.standardError;           // 1.04 / sqrt(m), guaranteed while !degraded
shll.degraded;                // false while every register ring held every windowed maximum

On add(now, key), an inline two-lane murmur hash (design-parity with lite-sketch's HyperLogLog, never a dep) maps the key to a register and a rho; the entry is appended to that register's ring after dropping every dominated tail entry (a newer arrival with >= rho outlives an older one, so the older can never be a future window-max). add drops expired head entries (stamp <= now - W) before appending, so overflows counts only IN-WINDOW drops; count(w?) is a PURE read -- it skips expired entries, takes each register's live max rho, and runs Ertl's improved estimator (sigma / tau) -- all 0-alloc on the hot path, incl. the windowed eviction.

The per-register ring is a fixed ringCap (default 8). If a register receives more than ringCap still-in-window maxima at once, the ring drops its oldest entry and bumps overflows, after which that register's windowed max can be understated -- so the 1.04 / sqrt(m) bound is no longer guaranteed. degraded (true once overflows > 0) reports this honestly rather than silently returning a wrong count; raise ringCap (or lower p) to make overflow impossible for your rho-churn. The windowed-distinct witness gates the relative error vs an exact windowed Set oracle at 3 * 1.04/sqrt(m) on 100% of >= 2000 queries across a W-sweep + a distinct-set shift + a post-burst edge, AND asserts degraded === false; a no-expiry variant (stale keys counted forever) and a no-dominated-drop variant (a plain FIFO ring) are rejected by the same gate -- see Testing. advance(now) moves the lazy-expiry clock forward with no key added (O(1), 0 B/op, the ring untouched so overflows / degraded are unaffected), so an idle window's count() slides to empty (R11 idle-slide).

Driven by a caller-supplied monotone now (or count mode when now is omitted); the mode locks at the first add. Keys are safe integers; a bad W / p / ringCap / seed / key / now throws [lite-adaptive] typeof-first, before any allocation. addFrom(buf, i) is the zero-box entry (now = buf[i], key = buf[i+1] read unboxed from a Float64Array) for fractional / epoch-ms timestamps and large safe-integer keys.

DriftDetector

Answer one question -- "did the mean of this signal just shift?" -- in O(1) state: six core scalars (_n, _mean, _gP, _gN, _mMin, _mMax) plus the latch runtime (_lDir / _lvl, and the precomputed _half) and one fixed Float64Array(3), no pool, no window (the lightest member). DriftDetector is the scalar, item-based companion to ADWIN: where ADWIN keeps a variance-carrying bucket window and auto-adapts its size, DriftDetector keeps only a running mean and one or two bounded test accumulators and returns true the moment the mean shifts. Run one per channel when you have many streams and only need a change flag, not the adapted window. A single class selects one of two classical tests via a mode const -- and the two differ in their reference, which is what makes the flag load-bearing:

import { DriftDetector, DRIFT_PH, DRIFT_CUSUM } from '@zakkster/lite-adaptive';

// Page-Hinkley: no known baseline -- it learns the reference online (the running mean).
const ph = new DriftDetector(DRIFT_PH, { delta: 0.005, threshold: 50 });
for (const x of latencies) if (ph.add(x)) markRegimeChange();   // true EXACTLY on the shift

// CUSUM: you KNOW the in-control mean -- alarm on departure from a fixed target mu0.
const cs = new DriftDetector(DRIFT_CUSUM, { target: 200, delta: 0.5, threshold: 8 });
cs.add(x);          // two accumulators floored at 0; either crossing `threshold` fires
cs.statistic;       // how close to firing (>= 0); crosses `threshold` when add() returns true
cs.mean;            // the running mean of the signal (observability; CUSUM tests vs `target`)

const row = new Float64Array(5);
cs.into(row);       // 0-alloc render: [statistic, mean, count, lastDriftIndex, lastDirection] -> row[0..4]
  • DRIFT_PH (Page-Hinkley; Page 1954, Mouss et al. 2004) -- references the online running mean: it accumulates the deviation of each x from the mean it has learned so far and watches the gap between the cumulative sum and its running extreme; a persistent one-directional drift makes the gap exceed the threshold lambda. Two-sided (an upward accumulator against its running min, a downward one against its running max). Use it when you have no known baseline -- it tracks a slow ramp and stays quiet.
  • DRIFT_CUSUM (two-sided CUSUM; Page 1954) -- references a fixed target mu0 (the classic SPC in-control mean, a required option): two accumulators, each floored at 0, grow only while the signal departs mu0 past the slack delta; either exceeding the decision interval threshold fires. Use it when you know the target -- a sustained departure from mu0 keeps alarming, and it goes quiet only when the signal returns to mu0.

Why the reference matters: under a shared running-mean reference the two rules compute a mathematically identical statistic (CUSUM's floor-at-0 recursion is exactly Page-Hinkley's cumulative-sum-minus-running-min). PH's adaptive reference and CUSUM's fixed mu0 are what make the modes genuinely diverge -- on a slow mean ramp PH fires a handful of times while CUSUM (vs a fixed mu0) fires on nearly every item. See ADR 0007.

On a positive detection the accumulators + running mean are reset (the standard PH / CUSUM discipline; the fixed target is preserved) so the detector recalibrates and catches the next transient shift. That is the default (latch: false). With latch: true the reset moves from fire time to re-arm time: the detector fires once per regime, latches (re-clamping the firing gap back to threshold only while it is still above threshold, so a sustained regime stays bounded while a gradual return to baseline is left to decay), and re-arms only when the latched-direction gap falls back below threshold / 2 (hysteresis) or on clear() -- an opposite-direction crossing flips direction and is reported in both modes (CUSUM fires on the reversal's own item; PH resets its running-mean reference at the fire and keeps the mean built since across the re-arm -- so the reference tracks the post-shift level and the reversal delay does not grow with history -- reporting a sustained reversal a few items past the edge, measured ~3 on a +-10 step, not on its own item unless a single item jumps more than threshold). This is the one-marker-per-regime behavior a HUD drift marker needs (a sustained +10 step fires 5000 times unlatched vs exactly once latched); lastDriftIndex / lastDirection record the last fire. Both add(x) and the zero-box addFrom(buf, i) (reads x = buf[i] unboxed from a Float64Array for fractional signals) are 0 B/op in every mode, latch: true PH included -- no pool to reshape, just a Welford mean update and one mode branch (the latch decision is a cold call off the hot body, and _clampGap reads the mode / threshold from instance slots rather than as boxed arguments). On an infinite same-direction regime the latched PH accumulators are kept bounded (|gP|, |gN|, |mMin|, |mMax| <= threshold * 2^20 + 30) by a gated re-centre. Public output is identical to 1.x until the first re-centre trips (past a ~threshold * 2^20 accumulator magnitude); after it, statistic is more accurate (1.x lets the accumulators drift unbounded and suffers catastrophic cancellation at a large-magnitude reversal, which the bounded re-centred accumulators avoid), and a re-arm at an exact threshold / 2 tie may resolve differently -- fired, mean, count, lastDriftIndex and lastDirection stay bit-identical.

add(x) rejects a non-finite x and any finite |x| > DD_X_MAX (1e150) as a byte-identical no-op, so the running accumulators can never be driven to a non-finite value (each add moves them by ~2 * DD_X_MAX, and both are bounded between resets -- reaching Double.MAX would need ~1e158 un-fired adds). delta is capped at the same bound at the ctor door, because each add also moves the accumulators by ~delta -- an uncapped pathological delta would overflow them in a few adds and add() would silently stop firing (a fail-open boolean). As defense-in-depth, the mean / statistic getters throw [lite-adaptive] if any accumulator is ever non-finite, never a silent 0 / NaN. delta = 0 is a valid, meaningful setting (no dead-band), guarded as !== undefined -- null is not zero.

Item-indexed (no clock). delta is the magnitude the test ignores (widens the dead-band -> fewer detections, higher latency); threshold trades detection latency against false alarms. A bad mode / delta / threshold / option throws [lite-adaptive] at the ctor door, before any field init. DDM / EDDM are deliberately out of this class -- they consume a Bernoulli error-bit stream and emit a tri-state (stable / warning / drift) output, a different contract from add(x) -> boolean, and belong in a future member.

SlidingDDSketch

Answer "what's p50 / p99 over the last W?" in fixed preallocated space at DDSketch accuracy. SlidingDDSketch is the recency sibling of @zakkster/lite-sketch's cumulative DDSketch: it keeps a ring of panes + 1 preallocated DDSketch panes (panes is B, the user knob, default 32), each covering W / panes of the window. Every pane maps a value to the same DDSketch bin -- key = ceil(log_gamma(value)) with gamma = (1 + alpha) / (1 - alpha) -- so a merge across panes is just an element-wise bin sum, and the relative error of any reported quantile stays <= alpha (identical mapping, collapse, strict, declared range and getters to lite-sketch, so a consumer pre-checks a value's indexable range exactly as it does there).

import { SlidingDDSketch } from '@zakkster/lite-adaptive';

const q = new SlidingDDSketch(60000, { alpha: 0.01, panes: 32 });   // p50/p99 over the last 60s, 1% rel-error
for (const [t, ms] of latencySamples) q.add(t, ms);                  // HOT, 0 B/op incl. pane rotate + clear

q.quantile(0.99);            // COLD; the windowed p99 within 1% relative error (NaN on an empty window)
const qs = new Float64Array([0.5, 0.99]);   // qs must be a Float64Array (a plain array throws)
const out = new Float64Array(2);
q.quantileInto(qs, out);     // 0 B/call render path: out = [p50, p99]
q.count();                   // the windowed population (0 on empty)

add(now, value) writes the current pane; when now crosses a pane boundary it rotates to the next pane and clears it with a fill(0) (0-alloc). A now jump of many pane-widths (an idle gap, an epoch-ms clock) clears at most panes + 1 panes -- the rotation is bounded, never a loop over the skipped span. quantile / quantileInto / count are cold reads that merge the live panes (including the straddling oldest pane) into an instance-owned preallocated scratch (never allocated per query); an empty window reads NaN with count() === 0, never a misleading 0. A bad query VALUE never throws: quantile(q) with q outside [0, 1] or NaN returns NaN, and a bad sub-window w (<= 0, > W, NaN) returns NaN for BOTH quantile and count (NaN, not 0 -- null is not zero). quantile returns one boxed double (16 B/call); quantileInto is 0 B/call -- use it on a render path. A wrong CONTAINER type (a non-Float64Array qs/out, or out too short) is a programming error and still throws.

The window over-covers by up to one pane width W / panes: the covered span is [W, W + W/panes] -- always the FULL window W, over-covered by at most one pane and NEVER under-covered. The ring holds panes + 1 panes and the straddling oldest pane (the one that spans the now - W boundary) is KEPT, never dropped, so true(W) is always included; dropping it would under-count (the same one-sided reasoning as SlidingCountMin, and exactly the bug the pre-1.7.0 panes-only ring had -- it dropped the straddling pane up to one pane width early and under-reported the count). That is the disclosed price of a fixed-memory sliding window over an exact O(W) sort; the default 32 panes puts the over-coverage at ~3% of W, and it is the caller's knob (more panes -> tighter edge, linearly more memory). Per-pane bin counts are a Uint32Array that saturates at 2^32 - 1 (never wraps); the merge scratch sums the panes in Float64, so a merged window count stays exact well past 2^32. Each pane collapses its lowest bins independently (the DDSketch maxBins = 2048 bound), so the merged min-key can differ from a single sketch's -- the accuracy bound is therefore witnessed, not assumed. A value whose bin key would exceed SLD_KEY_MAX (1 << 30) is rejected fail-closed, so the bin index can never overflow.

Precision domain (fail-closed; 1.10.0). Exact pane containment needs the double clock to resolve the pane grid (ulp(now) << pw, pw = W / panes), so |now| (and the count-mode tick) must be <= pw * 2^42 (SLD_CLOCK_SPAN); at the bound ulp(now) <= pw * 2^-10, so the grid line (floor(now/pw) + 1) * pw still CONTAINS the true window. add / addFrom / advance / advanceFrom reject a larger clock with a RangeError [lite-adaptive] BEFORE any state write (one field compare against pw * 2^42, precomputed in the ctor), and pane ends are computed by multiplication from the grid index ((k+1) * pw), never an accumulating E += pw that drifts near the edge. A subnormal pw (W / panes < 2^-1022, SLD_MIN_NORMAL) is rejected at construction. The minimum legal W for a clock magnitude C is panes * C / 2^42. This is generous for real clocks (32 panes): epoch-ms allows |now| <= 1.37e14 at W = 1000 (to ~year 6300), 8.25e15 at W = 60000, 1.37e13 at W = 100; W = 1000 at 1024 panes allows 4.3e12 (to ~2106); W = 16 epoch-ms allows 2.2e12 -- legal until ~2039-09 (disclosed), and W < 12.7 epoch-ms is REJECTED today. epoch-us allows 1.37e17 at W = 1e6 (W = 1e5 legal, W = 1e4 REJECTED, min 12,733 us); performance.now() ms allows 2.2e12 at W = 16 (~70 yr uptime). In count mode the tick bound is 1.37e14 ticks at W = 1000, 4.4e12 ticks at W = 32 (~5 days at 1e7 adds/s -- clear() reopens). Before 1.10.0 a large epoch clock at a small W silently mis-answered (new SlidingDDSketch(1e-3) then add(1.75e12, ...) collapsed the window); it now fails closed. Rebase the clock (subtract an epoch) or use a larger W.

SlidingDDSketch locks EXPLICIT vs COUNT mode at the first add (a switch throws), and alpha / strict / panes / W are validated typeof-first before any allocation. The windowed-quantile witness gates the relative error <= alpha vs an exact windowed-sorted-array oracle on 100% of >= 2000 queries across a W x alpha sweep, a distribution shift, and a post-burst edge, and asserts the TRUE-window gate: count() >= true count in (now - W, now] on 100% of the queries, with quantiles within alpha of the covered span [W, W + W/panes]; a no-expiry variant (stale out-of-window values counted), a coarse panes = 2 variant (the edge bound blows past W / 32), and a panes-pane control (the pre-1.7.0 ring that drops the straddling pane and under-counts) are rejected by the same gate -- see Testing. advance(now) rotates + clears stale panes with no value added (0 B/op), so an idle window's quantile reads NaN (and count() reads 0) instead of freezing on the last burst (R11 idle-slide).

SlidingCountMin

Answer "how many times did key k occur over the last W?" in fixed memory at Count-Min accuracy. SlidingCountMin is the recency sibling of @zakkster/lite-sketch's cumulative CountMinSketch: a ring of B+1 panes (default panes B = 32), each a d x w Uint32 counter grid, every pane aligned to absolute time (pane = floor(now / (W/B))). A key hashes to one cell per row in the current pane; a query sums the key's cell across the live panes per row and takes the min over rows.

import { SlidingCountMin } from '@zakkster/lite-adaptive';

// per-label event rate over the last 60s, ~1% relative error. One SHARED instance across channels:
const cm = new SlidingCountMin(60000, { epsilon: 0.01, delta: 0.01 });
const key = channelIdx * 2 ** 32 + tag;          // a composite SAFE-INTEGER key (channelIdx < 2^21)
for (const [t, k] of events) cm.add(t, k);       // HOT, amortized 0 B/op incl. the pane rotate

cm.estimate(key);            // COLD; the windowed count for this label (a double; 0 for an unseen VALID key; NaN on a bad key / bad w -- never throws)
cm.total();                  // COLD; 0 B/call while N < 2^31 (once N >= 2^31 it boxes 16 B at a non-inlined site -- use totalInto); the EXACT windowed item total N -- the `epsilon * N` error denominator (NaN on a bad w -- even when unset; 0 when unset with a good w)
cm.advance(latestTime);      // at render: slide the window so an idle label decays to 0 (R11)
const nOut = new Float64Array(1);
cm.totalInto(nOut);          // 1.12.0: total() into nOut[0], 0 B/call even once N >= 2^31 (total() would box), returns 1

// batch reader -- 0 B/op, no boxed return even for a count >= 2^31 (the F6 render sibling of estimate):
const keys = Float64Array.of(key, channelIdx * 2 ** 32 + otherTag);
const out = new Float64Array(keys.length);
cm.estimateInto(keys, out);  // writes out[j] = estimate(keys[j]); returns keys.length (keys/out may be the SAME array but must not PARTIALLY overlap)

The live panes cover a span in [W, W + W/B] -- always the full window, plus at most one extra pane -- and the partially-expired oldest pane is kept, never dropped. That is what keeps the estimate a one-sided upper bound: true(W) <= estimate <= true(W + W/B) + epsilon * N. Dropping the oldest pane would under-count and silently break the Count-Min contract (an estimate is supposed to never be below the truth). The edge is the W/B of extra span the newest-plus-oldest panes admit -- disclosed, the price of a fixed-memory window.

Counters are a Uint32Array per pane that saturates at 2^32 - 1 (never wraps); a saturated getter counts saturated increments (the honesty flag, like SlidingHyperLogLog's degraded). Because a windowed sum across panes can exceed 2^32, estimate returns a double. The d x w cells saturate at 2^32 - 1, but a separate per-pane Float64 total (_paneTotal, one add-body += count line after the pane rotation) stays EXACT -- it sums the validated per-add count and the windowed sum across panes loses no unit until it passes 2^53 -- so total(w?) reports the exact windowed item count N (the epsilon * N denominator) even when individual cells clip. add rotates + fill(0)-clears stale panes as now crosses pane boundaries -- amortized 0 B/op, bounded to B+1 clears even on a huge now jump (an idle gap or epoch-ms clock never loops over the skipped span). Conservative update (conservative, default true) is applied per pane (min-increment over the current pane's d cells) -- a sum of per-pane overestimates is still an overestimate and stays O(d), so it never runs over window sums. The hash, seed, epsilon/delta sizing and saturation are design-parity with lite-sketch's CountMinSketch (inlined, not a dependency), so a consumer pre-checks and swaps between them identically. Memory is (B+1) * d * w * 4 B for the cell grid plus (B+1) * 8 B for the per-pane Float64 total backing total(), fixed at construction -- the default new SlidingCountMin(60000) (B = 32) is 338468 B.

SlidingCountMin locks EXPLICIT vs COUNT mode at the first add (a switch throws); W / epsilon / delta / w / d / panes / seed are validated typeof-first before any allocation. Keys are safe integers (a non-safe-integer key throws on add/addFrom but estimate never throws -- an UNSEEN but VALID key returns 0, while an out-of-domain key or a bad sub-window w returns NaN, not 0, so a render path can query freely; F12: 0 was a fail-open under-count on an upper-bound sketch, and an invalid key was never "seen 0 times"). The w check precedes the unset return in both estimate and total, so an UNSET instance with a bad w also fails closed to NaN (not a fail-open 0), keeping estimate/total/estimateInto in lockstep. The windowed-frequency witness gates the one-sided bound true(W) <= est <= true(W + W/B) + epsilon * N on 100% of >= 2000 queries across a W x epsilon sweep and a churny key stream; a drop-oldest-pane variant (under-counts, breaking the lower bound) and a min-then-sum variant (mis-estimates) are rejected by the same gate -- see Testing. advance(now) rotates out stale panes with no increment, so an idle key's estimate slides to 0 instead of freezing (R11 idle-slide). total(w?) returns the EXACT windowed item total N over the same covered span (the epsilon * N error denominator; NaN on a bad w, 0 when unset), and estimateInto(keys, out, w?) is the 0-alloc batch reader -- caller-owned Float64Arrays (out.length >= keys.length, else a tagged throw -- the container-type contract), writing out[j] = estimate(keys[j], w) with no boxed return, so a render loop over many keys never boxes even for a windowed count >= 2^31 (F6). keys and out may be the same array (each slot is read then written before the next), but must not partially overlap -- an aliased non-zero offset clobbers not-yet-read keys and gives wrong results. totalInto(out, w?) (1.12.0) is total's render sibling: once the windowed N passes 2^31, total() returns a non-Smi double that boxes a 16 B HeapNumber per call wherever the call is not inlined, so a render loop reads N through totalInto, which writes the same value (Object.is-identical) into out[0] and returns 1.

Precision domain (fail-closed; 1.10.0). Exactly like SlidingDDSketch (the two share the domain guard), exact pane containment needs the double clock to resolve the pane grid (ulp(now) << pw, pw = W / B), so |now| (and the count-mode tick) must be <= pw * 2^42 (SCM_CLOCK_SPAN); at the bound ulp(now) <= pw * 2^-10, so the grid line (floor(now/pw) + 1) * pw still CONTAINS the true window. add / addFrom / advance / advanceFrom reject a larger clock with a RangeError [lite-adaptive] BEFORE any state write (one field compare against pw * 2^42, precomputed in the ctor), and pane ends are computed by multiplication from the grid index ((k+1) * pw), never an accumulating E += pw that drifts near the edge. A subnormal pw (W / B < 2^-1022, SCM_MIN_NORMAL) is rejected at construction. The minimum legal W for a clock magnitude C is B * C / 2^42. Generous for real clocks (32 panes): epoch-ms allows |now| <= 1.37e14 at W = 1000 (to ~year 6300), 8.25e15 at W = 60000, 1.37e13 at W = 100; W = 1000 at 1024 panes allows 4.3e12 (to ~2106); W = 16 epoch-ms allows 2.2e12 -- legal until ~2039-09 (disclosed), and W < 12.7 epoch-ms is REJECTED today. epoch-us allows 1.37e17 at W = 1e6 (W = 1e5 legal, W = 1e4 REJECTED, min 12,733 us); performance.now() ms allows 2.2e12 at W = 16 (~70 yr uptime). In count mode the tick bound is 1.37e14 ticks at W = 1000, 4.4e12 ticks at W = 32 (~5 days at 1e7 adds/s -- clear() reopens). Before 1.10.0 a large epoch clock at a small W silently under-counted (new SlidingCountMin(1e-3) then add(1.75e12, 7) x3 read estimate 1, not 3); it now fails closed. Rebase the clock (subtract an epoch) or use a larger W.

Constants that shape the pane grid:

| Symbol | Value | Meaning | |--------|-------|---------| | panes | default 32 (B) | the ring holds B + 1 panes of width pw = W / B; the covered span is [W, W + W/B] | | SCM_CLOCK_SPAN | 2^42 | clock-precision domain: |now| (and the count-mode tick) must be <= pw * 2^42 (so ulp(now) <= pw * 2^-10); a larger clock throws [lite-adaptive] fail-closed before any write | | pane grid | NORMAL double | pw = W / B must be >= 2^-1022 (SCM_MIN_NORMAL; a subnormal pw under-covers W); the ctor throws before allocation |

DecayedReservoir

Every other member answers one pre-decided question. DecayedReservoir hands you k actual retained values biased toward the recent, and you compute anything over them -- any quantile, any custom function -- approximately. It is the sampling complement to ForwardDecay: FD gives decayed aggregates exactly (two scalars), this gives a decayed sample (k slots). An item's probability of being retained decays exponentially with its age (halves every halfLife), so at any moment the sample is a decay-weighted draw of the recent stream.

import { DecayedReservoir } from '@zakkster/lite-adaptive';

// a 64-value recency-biased sample; items age out over a ~30s half-life
const r = new DecayedReservoir(64, 30000, { seed: 1 });
for (const [t, latencyMs] of events) r.add(t, latencyMs);   // HOT, amortized 0 B/op

const buf = new Float64Array(64);
const n = r.sampleInto(buf);            // 0-alloc: copy the retained values out, returns the count
// ...now compute whatever you like over buf.subarray(0, n): a custom percentile, a trimmed mean, a histogram
r.forEach((v) => accumulate(v));        // or iterate the sample alloc-free

Under the hood it is an Efraimidis-Spirakis A-Res weighted reservoir over ForwardDecay weights. Each accepted item draws one seeded xorshift32 uniform u and forms an A-Res key in log space -- log(u) * exp(-lambda * (t - L)) -- which is numerically stable (it underflows toward 0 as an item ages, instead of exp(+...) overflowing to Infinity). The k highest keys are held in an intrusive size-k min-forest (design-parity with HeavyKeeper / lite-o1 FreqO1, inlined -- never a dependency): a new item that beats the forest root replaces it and sifts down. Recent items get keys nearer the top, so they win -- that is the decay bias.

As now advances from the landmark L, exp(-lambda * (t - L)) shrinks toward 0. When lambda * (t - L) exceeds DR_EXP_CAP the landmark rebases and a single scalar factor is folded into every stored key at once -- a monotone transform, so the retained-set membership and the forest order are unchanged. That means the rebase is O(1) scalar work, not an O(k) sweep, and it is 0 B/op. A very large idle gap then a resume caps the rebase factor argument (DR_F_CAP) so a single rebase stays finite; back-to-back capped rebases while the heap is not yet full can drive an ancient stored key to -Infinity, which is benign by design -- no NaN, the sampled values stay finite, and those ancient items simply sink and evict first, deterministically. Because decay is applied at insert and membership is fixed thereafter, an idle stream correctly holds its last decayed sample -- there is nothing to slide to empty, so (exactly like ForwardDecay) DecayedReservoir has no advance(). Memory is k * 16 + 64 bytes (two Float64Array(k) columns + scalars), fixed at construction.

DecayedReservoir locks EXPLICIT vs COUNT mode at the first add (a switch throws); k / halfLife / seed are validated typeof-first before any allocation. value is any finite real (signed OK; default 1); a mode switch, a non-finite / decreasing now, or a non-finite value is a byte-identical no-op that does not advance the PRNG or the landmark. The recency-sample witness gates the empirical inclusion rate by item age against the exp(-lambda * age) expectation over many seeds; a no-decay (uniform) reservoir (rejected by the slope fit) and a no-forest ("keep the first k") reservoir (rejected because its newest-age inclusion rate is ~0) are both rejected by the witness, and a long-idle-then-resume lane asserts the sample stays well-defined -- see Testing. add / addFrom / sampleInto / clear are torture-gated at 0 B/op (incl. a rebase-heavy lane).

SlidingAggregate

Get the EXACT windowed count / sum / mean / min / max over the covered span [w, w + W/B] (which always CONTAINS the true window) in fixed memory -- the aggregate the rest of the family could not give you. ExponentialHistogram.sum() is bounded by the oldest straddling bucket's population, not its value mass, so it fails hard on a skewed stream (F17); SlidingDDSketch answers quantiles and SlidingCountMin per-key counts. SlidingAggregate keeps a ring of B + 1 preallocated panes of width W / B (the SlidingCountMin ring, copied verbatim -- never a shared helper, so that member's bytes and behavior cannot move). Each pane is a stride-5 Float64Array slot [count, sum, kcomp, min, max]; add(now, value) does count += 1, a branch-free per-pane Kahan compensated sum, and min / max by comparison, and a reader Neumaier-merges the live, non-empty panes.

import { SlidingAggregate } from '@zakkster/lite-adaptive';

// exact mean / min / max latency over the last 60s, 32 panes
const lat = new SlidingAggregate(60_000, { panes: 32 });
for (const [t, ms] of latencySamples) lat.add(t, ms);   // HOT, 0 B/op incl. pane rotate + clear

lat.mean();        // COLD; the EXACT windowed mean (NaN on an empty window)
lat.min();         // bit-exact windowed min (NaN on empty)
lat.max();         // bit-exact windowed max (NaN on empty)

const out = new Float64Array(5);
lat.into(out);     // 0 B/call render path: out = [count, sum, mean, min, max]

THE GUARANTEE. Over the COVERED span [w, w + W/B] -- which always CONTAINS the true window (the straddling oldest pane is KEPT) -- count, min and max are BIT-EXACT, and the sum obeys |sum - exact| <= (4u + O(N u^2)) * sum|v|, u = 2^-53: Kahan per pane contributes 2u (Higham, ASNA eq. 4.8) and the Neumaier merge over the 2(B+1) terms adds 2u. mean = sum / count is one further rounding. A 2u bound is NOT provable for this merge -- even a one-pane window pays Kahan's 2u plus the final s + c rounding. That is an EXACT mean where ExponentialHistogram.sum() is not: on the F17 lite-hud lane (50 lognormal-ms channels) SlidingAggregate matches its covered span exactly (worst relative error 0) while ExponentialHistogram's sum is 12.7% off the true window and its covered-span sum is 26.2% off.

  • panes is the edge knob. A query covers every live pane (paneEnd > now - w), so the covered span is [w, w + W/B] -- always the FULL window, over-covered by at most one pane width W / B, NEVER under-covered (dropping the straddling oldest pane would under-cover [w - W/B, w)). Against a TRUE (now - W, now] oracle over 50 lognormal-ms channels the mean's worst relative error at the pane edge is 8.61e-3 at 32 panes and 2.93e-3 at 128 panes -- it shrinks with panes (more panes -> tighter edge, linearly more memory: bytes = (B + 1) * 48). The worst single case is ~0.35, dominated by lognormal outliers in near-empty windows. THE GUARANTEE is exactness over the covered span; panes tunes how close that span is to the true window.
  • Empty windows read NaN, not 0. A cleared pane holds the IDENTITY sentinels min = +Infinity, max = -Infinity (never fill(0) over the stride -- an all-positive window would then read min 0), with count / sum / kcomp = 0, and every merge skips count === 0. A covered count of 0 returns NaN from mean / min / max (null is not zero -- the min of nothing is undefined) and 0 from count and sum (the empty sum is exactly 0).
  • The value cap. value is REQUIRED -- there is no default of 1 (a forgotten latency must fail closed, not silently count as one). It is a finite real with |value| <= 1e150 (SA_X_MAX, the same cap as DriftDetector's DD_X_MAX), so |sum| <= n * 1e150 and every Kahan / Neumaier intermediate stays finite for any physically reachable n. Without the cap two adds of 1e308 would poison a pane to Infinity / NaN silently until it rotated out (fail-open). A non-number, NaN, +-Infinity or over-cap value throws TypeError [lite-adaptive] value-first, a byte-identical no-op. It ships advance(now) / advanceFrom(buf, i) (R11 idle-slide) so an idle window slides to empty.
  • The clock-precision domain (fail-closed). Exact containment needs the double clock to RESOLVE the pane grid -- ulp(now) << pw. Two rules enforce it. W / panes must be a NORMAL double: a SUBNORMAL pane width loses significand bits, so (B + 1) * pw can fall below W and the ring under-covers -- the ctor throws [lite-adaptive] before allocating. And |now| (and the count-mode tick) must be <= pw * 2^42 (SA_CLOCK_SPAN): at the bound ulp(now) <= pw * 2^-10, so the grid line (floor(now/pw)+1)*pw still CONTAINS the true window; past it the window collapses or under-covers (fail-open before 1.9.0). add / addFrom / advance / advanceFrom reject a larger clock with RangeError [lite-adaptive] BEFORE any state write (a byte-identical no-op) -- rebase the clock (e.g. performance.now(), or subtract an epoch) or use a larger W. The bound is generous: epoch-ms with W = 1000, panes = 32 allows |now| <= 1.37e14, epoch-us with W = 1e6 up to 1.37e17, performance.now() with W = 16 up to 2.2e12 ms (~25k days). Pane ends are computed by multiplication from the grid index ((k+1) * pw), not an accumulating E += pw that drifts near the domain edge, so containment is exact across the whole legal domain.

API reference

new ExponentialHistogram(W, epsilon, options?)

add(now?, value?) -> this   // HOT, 0 B/op incl. merge cascade + expire
addFrom(buf, i) -> this     // HOT, 0 B/op: zero-box packed [now, value] entry (now = buf[i], value = buf[i+1])
advance(now) -> this        // HOT, 0 B/op; move time to `now` with NO add -- expire the window edge (idle-slide, R11)
advanceFrom(buf, i) -> this // HOT, 0 B/op ZERO-BOX: now = buf[i] (explicit-time)
count() -> number           // COLD, O(levels): windowed population estimate
sum() -> number             // COLD, O(buckets): windowed value-sum estimate
query() -> number           // COLD: alias of count()
clear() -> this             // reset to empty; reuse the pool (unlocks the mode)

// getters
windowSize   epsilon   bucketCount   capacity   k   levels   maxCount   mode
  • W -- window size; a finite number > 0 (items in count mode, or the now-unit span in explicit mode).
  • epsilon -- relative-error knob in (0, 1); smaller means more buckets and tighter windowed error. The derived bucket-pool capacity (k+1)*levels + 2 is capped at 2^22 buckets (EH_CAP_MAX, ~150 MB at 36 B/bucket); a tiny epsilon (huge k) past it throws a tagged [lite-adaptive] RangeError BEFORE any allocation instead of aborting the process on a V8 fatal (this rejects an epsilon below ~3e-6 at the default maxCount).
  • options.maxCount -- the window population the pool is GUARANTEED to hold, in EITHER mode (the exact ceiling is k * (2^levels - 1) >= maxCount); a positive integer <= 2^53-1 (default 2^32), validated typeof-first before allocation (null is not a default). It sizes the fixed pool via levels = max(2, ceil(log2(maxCount/(k+1))) + 2). An add whose cascade would pass the top level throws [lite-adaptive]. Pass maxCount: W in count mode to keep the pre-1.7.0 W-sized pool.
  • add(now?, value?) -- the mode LOCKS at the first call: pass a finite, non-decreasing now for EXPLICIT mode, or omit it for COUNT mode (the member auto-ticks). value defaults to 1 (the DGIM count case) and must be a finite number > 0. Fail closed: a mode switch, a non-finite / decreasing now, or a non-positive value throws [lite-adaptive] (a byte-identical no-op).
  • addFrom(buf, i) -- the ZERO-BOX entry for a caller whose now AND value are both FRACTIONAL doubles. Reads now = buf[i] and value = buf[i+1] UNBOXED from a caller-owned PACKED [now, value] Float64Array (a batch steps i by 2), avoiding the ~16 B HeapNumber that add(now, value) boxes per fractional argument at a non-inlined call boundary. The lite-hud idiom: write a per-channel Float64Array(2) scratch each tick and call addFrom(scratch, 0). EXPLICIT-time only (a count-locked instance throws; the first addFrom locks EXPLICIT mode). Same validation / throws / byte-identical-no-op-on-reject as add(now, value); a non-Float64Array buf or a non-integer / out-of-range i throws [lite-adaptive].
  • count() / query() -- COLD windowed COUNT estimate; never throw (0 on an empty window). Relative error <= epsilon, HARD, on every query.
  • sum() -- COLD windowed SUM estimate; never throws. Absolute error <= size(oldest straddling bucket) / 2; relative <= epsilon only for count or near-constant values (heavy-tailed / spiky values can exceed it -- levels are sized by POPULATION, not value mass). Use count() for a relative bound.

Constants that shape the pool:

| Symbol | Value | Meaning | |--------|-------|---------| | k | ceil(1/(2*epsilon)) + 1 | max buckets per level (the two oldest merge on the k+1-th) | | maxCount | default 2^32 | guaranteed window population; sizes the pool (exact ceiling k * (2^levels - 1)) | | levels | max(2, ceil(log2(maxCount/(k+1))) + 2) | number of size-class levels the pool can occupy | | capacity | (k+1) * levels + 2 | the fixed bucket-pool size (never grows) | | count error | ~ 1/(2k) <= epsilon | windowed COUNT relative error, HARD, on every query | | sum error | <= size(straddling bucket) / 2 | absolute; relative <= epsilon only for count / near-constant values |

new ADWIN(delta, options?)

add(x) -> boolean           // HOT, amortized O(1), 0 B/op incl. cut-scan + shrink; true iff a change was detected on this item
addFrom(buf, i) -> boolean  // HOT, 0 B/op: zero-box entry, reads x = buf[i] unboxed; same drift flag as add(x)
clear() -> thi