pond-ts 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,36 +5,20 @@ export declare function parsePercentile(op: string): number | undefined;
5
5
  /**
6
6
  * Shared `reduceColumn` body for percentile-shaped reducers
7
7
  * (`median`, `p50`, `p95`, etc.). Walks the validity bitmap to
8
- * gather defined cells into a dense `Float64Array`, detecting NaN
9
- * cells in the same pass; sorts and reads the percentile from
10
- * the sorted view.
8
+ * gather defined **and finite** cells into a dense `Float64Array`,
9
+ * sorts with the typed-array intrinsic, and reads the percentile
10
+ * from the sorted view.
11
11
  *
12
- * **NaN parity with row API.** Two sort behaviors diverge:
12
+ * Non-finite cells (`NaN` / `±Infinity`) are excluded by the
13
+ * reducer non-finite policy (docs/notes/reducer-nan-policy.md) —
14
+ * uniformly, across every path. With non-finite filtered out
15
+ * before the sort, `Float64Array.prototype.sort()` (the numeric,
16
+ * NaN-free intrinsic, ~2× faster than `Array.sort` with a
17
+ * comparator) produces the same total order as the row path's
18
+ * `Array.sort((a, b) => a - b)` over the same finite values — so
19
+ * there is no longer any NaN-ordering seam to special-case.
13
20
  *
14
- * - `Array.prototype.sort((a, b) => a - b)` (row-API path) returns
15
- * NaN from the comparator on NaN inputs; V8 treats this as
16
- * "equal" and leaves NaN cells in undefined order — the
17
- * resulting percentile is whatever cell happens to land at the
18
- * computed rank, possibly NaN itself.
19
- * - `Float64Array.prototype.sort()` (typed-array intrinsic) puts
20
- * NaN deterministically at the end of the sorted view — the
21
- * percentile rank then reads a non-NaN cell unless the rank
22
- * lands in the NaN suffix.
23
- *
24
- * The first-pass NaN detection lets us use `Float64Array.sort`'s
25
- * 2× speedup for the common no-NaN case (full parity with row API
26
- * because both produce identical sorted orders when no NaN
27
- * present), and fall back to `Array.sort` with comparator only
28
- * when NaN is present (preserving bug-for-bug row-API parity on
29
- * the rare contract-violating input).
30
- *
31
- * NaN can only reach a `kind: 'number'` column via trusted
32
- * construction (`fromEvents`); the public `assertCellKind`
33
- * rejects it at intake. Closed Codex review finding on PR #153 —
34
- * earlier L2 fix that filtered NaN was correct in spirit but
35
- * introduced a *different* divergence from the row API. A
36
- * principled "filter NaN consistently across both paths" fix is
37
- * tracked in the followup issue.
21
+ * Empty (no defined+finite values) `undefined`.
38
22
  */
39
23
  export declare function reducePercentileColumn(col: Float64Column, q: number): number | undefined;
40
24
  export declare function percentileReducer(q: number): ReducerDef;
@@ -18,51 +18,64 @@ export function parsePercentile(op) {
18
18
  /**
19
19
  * Shared `reduceColumn` body for percentile-shaped reducers
20
20
  * (`median`, `p50`, `p95`, etc.). Walks the validity bitmap to
21
- * gather defined cells into a dense `Float64Array`, detecting NaN
22
- * cells in the same pass; sorts and reads the percentile from
23
- * the sorted view.
21
+ * gather defined **and finite** cells into a dense `Float64Array`,
22
+ * sorts with the typed-array intrinsic, and reads the percentile
23
+ * from the sorted view.
24
24
  *
25
- * **NaN parity with row API.** Two sort behaviors diverge:
25
+ * Non-finite cells (`NaN` / `±Infinity`) are excluded by the
26
+ * reducer non-finite policy (docs/notes/reducer-nan-policy.md) —
27
+ * uniformly, across every path. With non-finite filtered out
28
+ * before the sort, `Float64Array.prototype.sort()` (the numeric,
29
+ * NaN-free intrinsic, ~2× faster than `Array.sort` with a
30
+ * comparator) produces the same total order as the row path's
31
+ * `Array.sort((a, b) => a - b)` over the same finite values — so
32
+ * there is no longer any NaN-ordering seam to special-case.
26
33
  *
27
- * - `Array.prototype.sort((a, b) => a - b)` (row-API path) returns
28
- * NaN from the comparator on NaN inputs; V8 treats this as
29
- * "equal" and leaves NaN cells in undefined order — the
30
- * resulting percentile is whatever cell happens to land at the
31
- * computed rank, possibly NaN itself.
32
- * - `Float64Array.prototype.sort()` (typed-array intrinsic) puts
33
- * NaN deterministically at the end of the sorted view — the
34
- * percentile rank then reads a non-NaN cell unless the rank
35
- * lands in the NaN suffix.
36
- *
37
- * The first-pass NaN detection lets us use `Float64Array.sort`'s
38
- * 2× speedup for the common no-NaN case (full parity with row API
39
- * because both produce identical sorted orders when no NaN
40
- * present), and fall back to `Array.sort` with comparator only
41
- * when NaN is present (preserving bug-for-bug row-API parity on
42
- * the rare contract-violating input).
43
- *
44
- * NaN can only reach a `kind: 'number'` column via trusted
45
- * construction (`fromEvents`); the public `assertCellKind`
46
- * rejects it at intake. Closed Codex review finding on PR #153 —
47
- * earlier L2 fix that filtered NaN was correct in spirit but
48
- * introduced a *different* divergence from the row API. A
49
- * principled "filter NaN consistently across both paths" fix is
50
- * tracked in the followup issue.
34
+ * Empty (no defined+finite values) `undefined`.
51
35
  */
52
36
  export function reducePercentileColumn(col, q) {
53
37
  const validity = col.validity;
54
38
  const values = col._values;
55
39
  let dense;
56
40
  let denseLength = 0;
57
- let hasNaN = false;
58
- if (validity === undefined) {
41
+ // Fast path: every defined cell is finite (`Float64Column.allFinite`),
42
+ // so we gather defined cells with no per-element `Number.isFinite`
43
+ // filter (reducer non-finite policy, docs/notes/reducer-nan-policy.md).
44
+ // The subsequent `Float64Array.sort` is the same NaN-free intrinsic
45
+ // either way → identical order, identical percentile.
46
+ if (col.allFinite) {
47
+ if (validity === undefined) {
48
+ if (col.length === 0)
49
+ return undefined;
50
+ dense = new Float64Array(col.length);
51
+ for (let i = 0; i < col.length; i += 1) {
52
+ dense[denseLength] = values[i];
53
+ denseLength += 1;
54
+ }
55
+ }
56
+ else {
57
+ const definedCount = validity.definedCount;
58
+ if (definedCount === 0)
59
+ return undefined;
60
+ dense = new Float64Array(definedCount);
61
+ const bits = validity.bits;
62
+ for (let i = 0; i < col.length; i += 1) {
63
+ if ((bits[i >> 3] & (1 << (i & 7))) === 0)
64
+ continue;
65
+ dense[denseLength] = values[i];
66
+ denseLength += 1;
67
+ }
68
+ }
69
+ }
70
+ else if (validity === undefined) {
71
+ // Guarded path: filter non-finite before the sort.
59
72
  if (col.length === 0)
60
73
  return undefined;
61
74
  dense = new Float64Array(col.length);
62
75
  for (let i = 0; i < col.length; i += 1) {
63
76
  const v = values[i];
64
- if (Number.isNaN(v))
65
- hasNaN = true;
77
+ if (!Number.isFinite(v))
78
+ continue;
66
79
  dense[denseLength] = v;
67
80
  denseLength += 1;
68
81
  }
@@ -77,23 +90,16 @@ export function reducePercentileColumn(col, q) {
77
90
  if ((bits[i >> 3] & (1 << (i & 7))) === 0)
78
91
  continue;
79
92
  const v = values[i];
80
- if (Number.isNaN(v))
81
- hasNaN = true;
93
+ if (!Number.isFinite(v))
94
+ continue;
82
95
  dense[denseLength] = v;
83
96
  denseLength += 1;
84
97
  }
85
98
  }
86
99
  if (denseLength === 0)
87
100
  return undefined;
88
- if (hasNaN) {
89
- // Match row-API exactly via `Array.sort` with comparator
90
- // diverges from `Float64Array.sort` on NaN ordering.
91
- const arr = Array.from(dense.subarray(0, denseLength));
92
- arr.sort((a, b) => a - b);
93
- return percentileOfSorted(arr, q);
94
- }
95
- // No NaN: `Float64Array.sort` is parity-correct (same total
96
- // order as `Array.sort` with comparator) and ~2× faster.
101
+ // Non-finite excluded upstream by policy → `Float64Array.sort` (numeric,
102
+ // NaN-free) gives the same order as the row path's comparator sort.
97
103
  const view = dense.subarray(0, denseLength);
98
104
  view.sort();
99
105
  const rank = (q / 100) * (denseLength - 1);
@@ -20,9 +20,12 @@
20
20
  * agree to floating-point noise — bit-for-bit when they see the values in the
21
21
  * same order, which the bucketed paths do.
22
22
  *
23
- * `rollingState` is the exception: its sliding window needs `remove`, which
24
- * Welford can't do stably (windowed removal drifts), so it keeps the one-pass
25
- * `sq/n − mean²` (with its clamp). A stable rolling stdev is a deferred item.
23
+ * `rollingState` adds `remove` for its sliding window via Welford's
24
+ * **order-independent delete** (the reverse recurrence). It works in the same
25
+ * deviation space — no `sq/n − mean²` cancellation, no shift drift and
26
+ * removes *by value*, so it stays correct under the live layer's reorder-mode
27
+ * eviction (which removes the sorted-prefix, not the oldest-arrived event).
28
+ * See its inline note for the mechanics.
26
29
  */
27
30
  // Welford accumulator: `add` each value, then read `result()` for the
28
31
  // population stdev (`undefined` if nothing was added). Shared by `reduce` and
@@ -54,20 +57,55 @@ export const stdev = {
54
57
  },
55
58
  reduceColumn(col) {
56
59
  // Inlined Welford (mirrors `welfordStdev`) over the packed array — a single
57
- // pass, skipping gaps via the validity bitmask. One division per element vs
58
- // the old two-pass's two divisionless scans, but identical to the other
59
- // paths' recurrence so the fast path and row path cannot diverge.
60
+ // pass, skipping gaps via the validity bitmask and skipping non-finite
61
+ // cells (reducer non-finite policy, docs/notes/reducer-nan-policy.md). One
62
+ // division per finite element vs the old two-pass's two divisionless scans,
63
+ // but identical to the other paths' recurrence so the fast path and row
64
+ // path cannot diverge. `n` counts only finite contributors, so `n === 0`
65
+ // (no finite cells) → `undefined`.
60
66
  const values = col._values;
61
67
  const validity = col.validity;
62
68
  let n = 0;
63
69
  let mean = 0;
64
70
  let m2 = 0;
71
+ // Fast path: every defined cell is finite (`Float64Column.allFinite`),
72
+ // so the Welford recurrence runs over every defined cell with no
73
+ // per-element `Number.isFinite` guard (reducer non-finite policy,
74
+ // docs/notes/reducer-nan-policy.md). Same recurrence as the guarded
75
+ // path → identical result.
76
+ if (col.allFinite) {
77
+ const len = col.length;
78
+ if (validity === undefined) {
79
+ for (let i = 0; i < len; i += 1) {
80
+ const v = values[i];
81
+ n += 1;
82
+ const delta = v - mean;
83
+ mean += delta / n;
84
+ m2 += delta * (v - mean);
85
+ }
86
+ }
87
+ else {
88
+ const bits = validity.bits;
89
+ for (let i = 0; i < len; i += 1) {
90
+ if ((bits[i >> 3] & (1 << (i & 7))) === 0)
91
+ continue;
92
+ const v = values[i];
93
+ n += 1;
94
+ const delta = v - mean;
95
+ mean += delta / n;
96
+ m2 += delta * (v - mean);
97
+ }
98
+ }
99
+ return n === 0 ? undefined : Math.sqrt(Math.max(0, m2 / n));
100
+ }
101
+ // Guarded path: skip non-finite cells; `n` counts only finite
102
+ // contributors so `n === 0` (no finite cells) → `undefined`.
65
103
  if (validity === undefined) {
66
104
  const len = col.length;
67
- if (len === 0)
68
- return undefined;
69
105
  for (let i = 0; i < len; i += 1) {
70
106
  const v = values[i];
107
+ if (!Number.isFinite(v))
108
+ continue;
71
109
  n += 1;
72
110
  const delta = v - mean;
73
111
  mean += delta / n;
@@ -75,21 +113,21 @@ export const stdev = {
75
113
  }
76
114
  }
77
115
  else {
78
- if (validity.definedCount === 0)
79
- return undefined;
80
116
  const bits = validity.bits;
81
117
  const len = col.length;
82
118
  for (let i = 0; i < len; i += 1) {
83
119
  if ((bits[i >> 3] & (1 << (i & 7))) === 0)
84
120
  continue;
85
121
  const v = values[i];
122
+ if (!Number.isFinite(v))
123
+ continue;
86
124
  n += 1;
87
125
  const delta = v - mean;
88
126
  mean += delta / n;
89
127
  m2 += delta * (v - mean);
90
128
  }
91
129
  }
92
- return Math.sqrt(Math.max(0, m2 / n));
130
+ return n === 0 ? undefined : Math.sqrt(Math.max(0, m2 / n));
93
131
  },
94
132
  bucketState() {
95
133
  const w = welfordStdev();
@@ -104,32 +142,80 @@ export const stdev = {
104
142
  };
105
143
  },
106
144
  rollingState() {
107
- // One-pass `sq/n mean²` with the `Math.max(0, …)` clamp. Unlike the other
108
- // paths this has a `remove` for the sliding window, which Welford can't do
109
- // stably a stable rolling stdev is deferred (see the module note above).
110
- let s = 0;
111
- let sq = 0;
145
+ // Sliding-window population stdev: Welford's online variance with an
146
+ // **order-independent delete**. `add(v)` is the standard recurrence;
147
+ // `remove(v)` reverses it. Working in deviation space, it fixes the old
148
+ // one-pass `sq/n − mean²` failure modes — catastrophic cancellation on
149
+ // near-equal large values (`[1e10, 1e10+1, …]` → 0 or a negative variance →
150
+ // NaN; the audit-§1.1 case, previously still live on this path) and shift
151
+ // drift on trending data (cumulative distance, elevation).
152
+ //
153
+ // Limitation — **outlier eviction** (shared with the old one-pass, so not a
154
+ // regression): like any *subtractive* sliding variance, evicting a value far
155
+ // outside the residual spread cancels — `m2 −= huge` loses the small
156
+ // remainder, and the error persists in the running accumulator (the clamp
157
+ // floors it to 0 at the extreme). Negligible until the evicted point is
158
+ // ~1e7–1e8× the residual stdev (e.g. a ~1e6 spike over a ~0.01-σ baseline) —
159
+ // far beyond realistic monitoring / activity data, and exactly why the
160
+ // add-only paths use plain Welford. A FIFO-only two-stack merge avoids it
161
+ // entirely but can't serve the by-value eviction the live layer needs.
162
+ //
163
+ // Removal is **by value, not by position**. That is load-bearing for the
164
+ // live layer: `LiveReduce` shares this state, and a `reorder`-mode source
165
+ // with retention evicts the sorted-prefix — which may be a later arrival,
166
+ // not the oldest — so a positional (FIFO) remove would corrupt the window.
167
+ // A value-based delete is correct regardless of eviction order (the
168
+ // documented contract; see live-reduce.ts and live-buffer-as-window.test).
169
+ // The batch rolling driver removes strictly oldest-first — a special case.
170
+ //
171
+ // Non-finite / missing cells arrive as `undefined` (the factory wrapper
172
+ // applies the non-finite policy); `add`/`remove` both skip them symmetrically
173
+ // so they never enter `n`.
112
174
  let n = 0;
175
+ let mean = 0;
176
+ let m2 = 0;
113
177
  return {
114
178
  add(_i, v) {
115
- if (typeof v === 'number') {
116
- s += v;
117
- sq += v * v;
118
- n++;
119
- }
179
+ if (typeof v !== 'number')
180
+ return;
181
+ n += 1;
182
+ const delta = v - mean;
183
+ mean += delta / n;
184
+ m2 += delta * (v - mean);
120
185
  },
121
186
  remove(_i, v) {
122
- if (typeof v === 'number') {
123
- s -= v;
124
- sq -= v * v;
125
- n--;
187
+ if (typeof v !== 'number')
188
+ return;
189
+ if (n <= 1) {
190
+ // Removing the final contributor — reset exactly (no 0/0, no drift).
191
+ n = 0;
192
+ mean = 0;
193
+ m2 = 0;
194
+ return;
195
+ }
196
+ const meanWith = mean;
197
+ n -= 1;
198
+ if (n === 1) {
199
+ // A single remaining element has population variance *exactly* 0.
200
+ // Setting it directly (rather than via the subtraction below) keeps
201
+ // the n→1 result exact at any magnitude — the reverse step alone
202
+ // leaves rounding residue at large offsets (e.g. ~0.016 on 1e10).
203
+ mean = meanWith * 2 - v; // the one survivor: 2·mean₂ − removed
204
+ m2 = 0;
205
+ return;
126
206
  }
207
+ // Deviation-space mean update (mean − (v − mean)/n): avoids the large
208
+ // `n·mean − v` product, staying precise at large magnitudes.
209
+ mean = meanWith - (v - meanWith) / n;
210
+ // Reverse Welford: M2 −= (v − meanNew)·(v − meanOld).
211
+ m2 -= (v - mean) * (v - meanWith);
212
+ // Normally absorbs FP round-off; on a gross outlier eviction (see the
213
+ // limitation note above) the subtraction can cancel below 0 — clamp.
214
+ if (m2 < 0)
215
+ m2 = 0;
127
216
  },
128
217
  snapshot() {
129
- if (n === 0)
130
- return undefined;
131
- const mean = s / n;
132
- return Math.sqrt(Math.max(0, sq / n - mean * mean));
218
+ return n === 0 ? undefined : Math.sqrt(Math.max(0, m2 / n));
133
219
  },
134
220
  };
135
221
  },
@@ -7,18 +7,42 @@ export const sum = {
7
7
  const values = col._values;
8
8
  const validity = col.validity;
9
9
  let s = 0;
10
+ // Fast path: the column proved every defined cell is finite
11
+ // (`Float64Column.allFinite`), so we can drop the per-element
12
+ // `Number.isFinite` guard the reducer non-finite policy
13
+ // (docs/notes/reducer-nan-policy.md) otherwise requires — plain
14
+ // accumulate, identical result.
15
+ if (col.allFinite) {
16
+ if (validity === undefined) {
17
+ for (let i = 0; i < col.length; i += 1)
18
+ s += values[i];
19
+ return s;
20
+ }
21
+ const bits = validity.bits;
22
+ for (let i = 0; i < col.length; i += 1) {
23
+ if ((bits[i >> 3] & (1 << (i & 7))) !== 0)
24
+ s += values[i];
25
+ }
26
+ return s;
27
+ }
28
+ // Guarded path: finiteness not proven, skip non-finite per policy.
10
29
  if (validity === undefined) {
11
- // Hot path: every cell defined; no per-row branch.
12
- for (let i = 0; i < col.length; i += 1)
13
- s += values[i];
30
+ for (let i = 0; i < col.length; i += 1) {
31
+ const v = values[i];
32
+ if (Number.isFinite(v))
33
+ s += v;
34
+ }
14
35
  return s;
15
36
  }
16
37
  // Inline bitmap check rather than method dispatch — same pattern
17
38
  // the chart-friction-spike notes flagged for hot draw loops.
18
39
  const bits = validity.bits;
19
40
  for (let i = 0; i < col.length; i += 1) {
20
- if ((bits[i >> 3] & (1 << (i & 7))) !== 0)
21
- s += values[i];
41
+ if ((bits[i >> 3] & (1 << (i & 7))) !== 0) {
42
+ const v = values[i];
43
+ if (Number.isFinite(v))
44
+ s += v;
45
+ }
22
46
  }
23
47
  return s;
24
48
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {