pond-ts 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/column.js CHANGED
@@ -76,33 +76,60 @@ Float64Column.prototype.minMax = function () {
76
76
  const n = this.length;
77
77
  if (n === 0)
78
78
  return undefined;
79
- let i = 0;
80
- let lo;
81
- let hi;
82
- if (!v) {
83
- lo = values[0];
79
+ if (this.allFinite) {
80
+ // Provably finite (the chart axis hot path) → no per-element finite
81
+ // guard; the seeded comparison is unambiguous without NaN.
82
+ let i = 0;
83
+ let lo;
84
+ let hi;
85
+ if (!v) {
86
+ lo = values[0];
87
+ hi = lo;
88
+ for (i = 1; i < n; i += 1) {
89
+ const x = values[i];
90
+ lo = lo <= x ? lo : x;
91
+ hi = hi >= x ? hi : x;
92
+ }
93
+ return [lo, hi];
94
+ }
95
+ while (i < n && !v.isDefined(i))
96
+ i += 1;
97
+ if (i >= n)
98
+ return undefined;
99
+ lo = values[i];
84
100
  hi = lo;
85
- for (i = 1; i < n; i += 1) {
101
+ for (i += 1; i < n; i += 1) {
102
+ if (!v.isDefined(i))
103
+ continue;
86
104
  const x = values[i];
87
105
  lo = lo <= x ? lo : x;
88
106
  hi = hi >= x ? hi : x;
89
107
  }
90
108
  return [lo, hi];
91
109
  }
92
- while (i < n && !v.isDefined(i))
93
- i += 1;
94
- if (i >= n)
95
- return undefined;
96
- lo = values[i];
97
- hi = lo;
98
- for (i += 1; i < n; i += 1) {
99
- if (!v.isDefined(i))
110
+ // Guarded: skip non-finite (and missing) cells, matching the reducer
111
+ // `min`/`max` (reducer non-finite policy — docs/notes/reducer-nan-policy.md).
112
+ // All-non-finite (or empty) → undefined.
113
+ let lo;
114
+ let hi;
115
+ for (let i = 0; i < n; i += 1) {
116
+ if (v && !v.isDefined(i))
100
117
  continue;
101
118
  const x = values[i];
102
- lo = lo <= x ? lo : x;
103
- hi = hi >= x ? hi : x;
119
+ if (!Number.isFinite(x))
120
+ continue;
121
+ if (lo === undefined) {
122
+ lo = x;
123
+ hi = x;
124
+ }
125
+ else {
126
+ if (x < lo)
127
+ lo = x;
128
+ if (x > hi)
129
+ hi = x;
130
+ }
104
131
  }
105
- return [lo, hi];
132
+ return lo === undefined ? undefined : [lo, hi];
106
133
  };
107
134
  Float64Column.prototype.hasMissing = function () {
108
135
  if (!this.validity)
@@ -228,6 +255,44 @@ Float64Column.prototype.bin = function (bins, reducer) {
228
255
  // underlying buffer.
229
256
  const values = this._values;
230
257
  const validity = this.validity;
258
+ if (!this.allFinite) {
259
+ // Guarded path: skip non-finite values (reducer non-finite policy —
260
+ // docs/notes/reducer-nan-policy.md), matching `minMax()` / the reducer
261
+ // min/max. A bin with no finite value (all-missing or all-non-finite)
262
+ // keeps the NaN empty-bin sentinel.
263
+ for (let b = 0; b < bins; b += 1) {
264
+ const start = Math.floor((b * n) / bins);
265
+ const end = Math.floor(((b + 1) * n) / bins);
266
+ let loVal;
267
+ let hiVal;
268
+ for (let i = start; i < end; i += 1) {
269
+ if (validity !== undefined && !validity.isDefined(i))
270
+ continue;
271
+ const x = values[i];
272
+ if (!Number.isFinite(x))
273
+ continue;
274
+ if (loVal === undefined) {
275
+ loVal = x;
276
+ hiVal = x;
277
+ }
278
+ else {
279
+ if (x < loVal)
280
+ loVal = x;
281
+ if (x > hiVal)
282
+ hiVal = x;
283
+ }
284
+ }
285
+ if (loVal === undefined) {
286
+ lo[b] = NaN;
287
+ hi[b] = NaN;
288
+ }
289
+ else {
290
+ lo[b] = loVal;
291
+ hi[b] = hiVal;
292
+ }
293
+ }
294
+ return { lo, hi };
295
+ }
231
296
  if (validity === undefined) {
232
297
  for (let b = 0; b < bins; b += 1) {
233
298
  const start = Math.floor((b * n) / bins);
@@ -568,10 +633,15 @@ ChunkedFloat64Column.prototype.percentile = function (q) {
568
633
  return materializeChunkedFloat64(this).percentile(q);
569
634
  };
570
635
  ChunkedFloat64Column.prototype.count = function () {
571
- // Validity-defined-count is available without materializing.
572
- if (!this.validity)
573
- return this.length;
574
- return this.validity.definedCount;
636
+ // The O(1) defined-count is correct only when the column is provably
637
+ // finite. Non-finite cells are skipped (reducer non-finite policy —
638
+ // docs/notes/reducer-nan-policy.md), so when finiteness isn't proven,
639
+ // delegate to the materialized packed `count()` (which routes through the
640
+ // count reducer and skips non-finite) — matching packed `Float64Column`.
641
+ if (this.allFinite) {
642
+ return this.validity ? this.validity.definedCount : this.length;
643
+ }
644
+ return materializeChunkedFloat64(this).count();
575
645
  };
576
646
  ChunkedFloat64Column.prototype.minMax = function () {
577
647
  return materializeChunkedFloat64(this).minMax();
@@ -72,6 +72,16 @@ export declare class ChunkedFloat64Column {
72
72
  readonly chunks: ReadonlyArray<Float64Column>;
73
73
  readonly chunkOffsets: Int32Array;
74
74
  readonly validity?: ValidityBitmap;
75
+ /**
76
+ * `true` iff **every** chunk is itself `allFinite` — the merged
77
+ * column is all-finite IFF each input is (an AND). Defaults to
78
+ * `false` whenever any chunk's flag is `false` (or absent), which is
79
+ * the safe direction: a chunk that didn't prove finiteness keeps the
80
+ * whole column on the guarded reducer path. Mirrors
81
+ * `Float64Column.allFinite`'s safety contract — see it for why a
82
+ * wrong `true` is unsafe.
83
+ */
84
+ readonly allFinite: boolean;
75
85
  constructor(chunks: ReadonlyArray<Float64Column>);
76
86
  /**
77
87
  * Reads cell `i` by binary-searching `chunkOffsets` to find the
@@ -192,6 +192,16 @@ export class ChunkedFloat64Column {
192
192
  chunks;
193
193
  chunkOffsets;
194
194
  validity;
195
+ /**
196
+ * `true` iff **every** chunk is itself `allFinite` — the merged
197
+ * column is all-finite IFF each input is (an AND). Defaults to
198
+ * `false` whenever any chunk's flag is `false` (or absent), which is
199
+ * the safe direction: a chunk that didn't prove finiteness keeps the
200
+ * whole column on the guarded reducer path. Mirrors
201
+ * `Float64Column.allFinite`'s safety contract — see it for why a
202
+ * wrong `true` is unsafe.
203
+ */
204
+ allFinite;
195
205
  constructor(chunks) {
196
206
  assertChunkKinds(chunks, 'number', 'ChunkedFloat64Column');
197
207
  const { length, chunkOffsets, validity } = buildOffsetsAndAggregateValidity(chunks, 'ChunkedFloat64Column');
@@ -202,6 +212,16 @@ export class ChunkedFloat64Column {
202
212
  this.chunkOffsets = chunkOffsets;
203
213
  if (validity !== undefined)
204
214
  this.validity = validity;
215
+ // AND across chunks: all-finite IFF every chunk is. An empty chunk
216
+ // list is vacuously all-finite (no non-finite cell exists).
217
+ let allFinite = true;
218
+ for (let c = 0; c < chunks.length; c += 1) {
219
+ if (!chunks[c].allFinite) {
220
+ allFinite = false;
221
+ break;
222
+ }
223
+ }
224
+ this.allFinite = allFinite;
205
225
  }
206
226
  /**
207
227
  * Reads cell `i` by binary-searching `chunkOffsets` to find the
@@ -287,10 +307,13 @@ export class ChunkedFloat64Column {
287
307
  out[i] = this.chunks[c]._values[local];
288
308
  validBits[i >> 3] |= 1 << (i & 7);
289
309
  }
310
+ // A gather only reads existing defined cells (out-of-range / invalid
311
+ // slots are skipped → marked invalid below), so the result is finite
312
+ // whenever this chunked column is. Propagate the AND-of-chunks flag.
290
313
  if (!hasInvalid) {
291
- return new Float64Column(out, indices.length);
314
+ return new Float64Column(out, indices.length, undefined, this.allFinite);
292
315
  }
293
- return new Float64Column(out, indices.length, validityFromBits(validBits, indices.length));
316
+ return new Float64Column(out, indices.length, validityFromBits(validBits, indices.length), this.allFinite);
294
317
  }
295
318
  }
296
319
  /* -------------------------------------------------------------------------- */
@@ -631,7 +654,10 @@ export function materializeChunkedFloat64(chunked) {
631
654
  out.set(chunk._values.subarray(0, chunk.length), cursor);
632
655
  cursor += chunk.length;
633
656
  }
634
- return new Float64Column(out, chunked.length, chunked.validity);
657
+ // Compacting doesn't touch values, so finiteness carries over from
658
+ // the chunked column (which is the AND of its chunks). This is what
659
+ // lets a `concatSorted(...).reduce(...)` take the reducer fast path.
660
+ return new Float64Column(out, chunked.length, chunked.validity, chunked.allFinite);
635
661
  }
636
662
  /**
637
663
  * Compacts a `ChunkedBooleanColumn` into a plain `BooleanColumn`.
@@ -128,7 +128,25 @@ export declare class Float64Column implements ColumnBase<number, 'number'> {
128
128
  readonly storage: "packed";
129
129
  readonly length: number;
130
130
  readonly validity?: ValidityBitmap;
131
- constructor(values: Float64Array, length: number, validity?: ValidityBitmap);
131
+ /**
132
+ * **Safety contract.** `true` MUST mean "every *defined* cell is
133
+ * finite — no `NaN`, no `±Infinity`" (missing cells, tracked by
134
+ * `validity`, are irrelevant). It is a promise the producer makes
135
+ * so reducers can skip the per-element `Number.isFinite` guard that
136
+ * the reducer non-finite policy (docs/notes/reducer-nan-policy.md)
137
+ * otherwise requires on every numeric `reduceColumn`.
138
+ *
139
+ * A wrongly-`true` flag makes `reduceColumn` take the unguarded fast
140
+ * path and silently include a non-finite cell → wrong result. So the
141
+ * **default is `false`** (conservative: guarded path, correct-but-
142
+ * slower) and a producer sets `true` ONLY where finiteness is proven
143
+ * — either data-derived by inspecting every cell, validated upstream
144
+ * (strict intake), or propagated from a source column that was
145
+ * itself `allFinite`. When unsure, leave it `false`: a missed `true`
146
+ * is slower, never wrong.
147
+ */
148
+ readonly allFinite: boolean;
149
+ constructor(values: Float64Array, length: number, validity?: ValidityBitmap, allFinite?: boolean);
132
150
  read(i: number): number | undefined;
133
151
  scan(fn: (value: number, i: number) => void, options?: ScanOptions): void;
134
152
  sliceByRange(start: number, end: number): Float64Column;
@@ -41,7 +41,25 @@ export class Float64Column {
41
41
  */
42
42
  _values;
43
43
  validity;
44
- constructor(values, length, validity) {
44
+ /**
45
+ * **Safety contract.** `true` MUST mean "every *defined* cell is
46
+ * finite — no `NaN`, no `±Infinity`" (missing cells, tracked by
47
+ * `validity`, are irrelevant). It is a promise the producer makes
48
+ * so reducers can skip the per-element `Number.isFinite` guard that
49
+ * the reducer non-finite policy (docs/notes/reducer-nan-policy.md)
50
+ * otherwise requires on every numeric `reduceColumn`.
51
+ *
52
+ * A wrongly-`true` flag makes `reduceColumn` take the unguarded fast
53
+ * path and silently include a non-finite cell → wrong result. So the
54
+ * **default is `false`** (conservative: guarded path, correct-but-
55
+ * slower) and a producer sets `true` ONLY where finiteness is proven
56
+ * — either data-derived by inspecting every cell, validated upstream
57
+ * (strict intake), or propagated from a source column that was
58
+ * itself `allFinite`. When unsure, leave it `false`: a missed `true`
59
+ * is slower, never wrong.
60
+ */
61
+ allFinite;
62
+ constructor(values, length, validity, allFinite = false) {
45
63
  validateColumnLength(length, 'Float64Column');
46
64
  if (length > values.length) {
47
65
  throw new RangeError(`Float64Column buffer underflow: length ${length} exceeds values.length ${values.length}`);
@@ -53,6 +71,7 @@ export class Float64Column {
53
71
  this.length = length;
54
72
  if (validity !== undefined)
55
73
  this.validity = validity;
74
+ this.allFinite = allFinite;
56
75
  }
57
76
  read(i) {
58
77
  if (i < 0 || i >= this.length)
@@ -88,7 +107,8 @@ export class Float64Column {
88
107
  }
89
108
  const valuesSlice = this._values.subarray(lo, hi);
90
109
  const validitySlice = validitySliceByRange(this.validity, lo, hi, this.length);
91
- return new Float64Column(valuesSlice, hi - lo, validitySlice);
110
+ // A contiguous slice of an all-finite column is all-finite.
111
+ return new Float64Column(valuesSlice, hi - lo, validitySlice, this.allFinite);
92
112
  }
93
113
  sliceByIndices(indices) {
94
114
  const out = new Float64Array(indices.length);
@@ -99,7 +119,10 @@ export class Float64Column {
99
119
  out[i] = idx >= 0 && idx < this.length ? this._values[idx] : 0;
100
120
  }
101
121
  const validity = validityGatherByIndices(this.validity, indices, this.length);
102
- return new Float64Column(out, indices.length, validity);
122
+ // A gathered subset of finite cells is finite. Out-of-range slots
123
+ // read 0 (finite) and are marked invalid by the validity gather, so
124
+ // they can't violate the contract either way.
125
+ return new Float64Column(out, indices.length, validity, this.allFinite);
103
126
  }
104
127
  }
105
128
  /* -------------------------------------------------------------------------- */
@@ -201,15 +224,27 @@ export function float64ColumnFromArray(source) {
201
224
  const length = source.length;
202
225
  validateColumnLength(length, 'Float64Column');
203
226
  const values = new Float64Array(length);
227
+ // Data-derive `allFinite` in the copy loop: only *defined* cells
228
+ // count (a missing slot doesn't make the column non-finite). Finite
229
+ // data → `true` (reducers take the fast path); an overflow / NaN cell
230
+ // → `false` (guarded path, correct). See the field's safety contract.
231
+ let allFinite = true;
204
232
  for (let i = 0; i < length; i += 1) {
205
233
  const v = source[i];
206
- values[i] = typeof v === 'number' ? v : 0;
234
+ if (typeof v === 'number') {
235
+ values[i] = v;
236
+ if (!Number.isFinite(v))
237
+ allFinite = false;
238
+ }
239
+ else {
240
+ values[i] = 0;
241
+ }
207
242
  }
208
243
  const validity = validityFromPredicate(length, (i) => {
209
244
  const v = source[i];
210
245
  return typeof v === 'number';
211
246
  });
212
- return new Float64Column(values, length, validity);
247
+ return new Float64Column(values, length, validity, allFinite);
213
248
  }
214
249
  /**
215
250
  * Builds a `BooleanColumn` from an array of `boolean | null | undefined`
@@ -8,24 +8,51 @@ export const avg = {
8
8
  reduceColumn(col) {
9
9
  const values = col._values;
10
10
  const validity = col.validity;
11
+ let s = 0;
12
+ let n = 0;
13
+ // Fast path: every defined cell is finite (`Float64Column.allFinite`),
14
+ // so every defined cell is a valid contributor — plain accumulate, and
15
+ // the divisor is `definedCount` (or `col.length`). Drops the
16
+ // per-element finite guard the reducer non-finite policy
17
+ // (docs/notes/reducer-nan-policy.md) otherwise requires.
18
+ if (col.allFinite) {
19
+ if (validity === undefined) {
20
+ for (let i = 0; i < col.length; i += 1)
21
+ s += values[i];
22
+ return col.length === 0 ? undefined : s / col.length;
23
+ }
24
+ const bits = validity.bits;
25
+ for (let i = 0; i < col.length; i += 1) {
26
+ if ((bits[i >> 3] & (1 << (i & 7))) !== 0)
27
+ s += values[i];
28
+ }
29
+ const count = validity.definedCount;
30
+ return count === 0 ? undefined : s / count;
31
+ }
32
+ // Guarded path: divide by the count of *finite* contributors, not
33
+ // `definedCount` — a non-finite cell is skipped per policy, so it must
34
+ // not inflate the divisor.
11
35
  if (validity === undefined) {
12
- if (col.length === 0)
13
- return undefined;
14
- let s = 0;
15
- for (let i = 0; i < col.length; i += 1)
16
- s += values[i];
17
- return s / col.length;
36
+ for (let i = 0; i < col.length; i += 1) {
37
+ const v = values[i];
38
+ if (Number.isFinite(v)) {
39
+ s += v;
40
+ n += 1;
41
+ }
42
+ }
43
+ return n === 0 ? undefined : s / n;
18
44
  }
19
- const definedCount = validity.definedCount;
20
- if (definedCount === 0)
21
- return undefined;
22
45
  const bits = validity.bits;
23
- let s = 0;
24
46
  for (let i = 0; i < col.length; i += 1) {
25
- if ((bits[i >> 3] & (1 << (i & 7))) !== 0)
26
- s += values[i];
47
+ if ((bits[i >> 3] & (1 << (i & 7))) !== 0) {
48
+ const v = values[i];
49
+ if (Number.isFinite(v)) {
50
+ s += v;
51
+ n += 1;
52
+ }
53
+ }
27
54
  }
28
- return s / definedCount;
55
+ return n === 0 ? undefined : s / n;
29
56
  },
30
57
  bucketState() {
31
58
  let s = 0;
@@ -24,10 +24,36 @@ export const count = {
24
24
  return defined.length;
25
25
  },
26
26
  reduceColumn(col) {
27
- // O(1) when validity is precomputed (it always is on Float64Column —
28
- // `validity.definedCount` is cached at construction). Falls back to
29
- // `col.length` when no validity bitmap exists (every cell defined).
30
- return col.validity === undefined ? col.length : col.validity.definedCount;
27
+ const values = col._values;
28
+ const validity = col.validity;
29
+ // Fast path: every defined cell is finite (`Float64Column.allFinite`),
30
+ // so "defined" and "defined AND finite" coincide — the O(1) shortcut
31
+ // is exact again (`definedCount`, or `col.length` when no bitmap).
32
+ // This is the O(N)→O(1) recovery the non-finite policy cost count
33
+ // (docs/notes/reducer-nan-policy.md).
34
+ if (col.allFinite) {
35
+ return validity === undefined ? col.length : validity.definedCount;
36
+ }
37
+ // Guarded path: O(N) scan, NOT the `definedCount` shortcut — the
38
+ // non-finite policy excludes non-finite cells, and `definedCount`
39
+ // counts them as present, so a defined-but-NaN cell would over-count.
40
+ // Walk and count valid AND finite cells.
41
+ let n = 0;
42
+ if (validity === undefined) {
43
+ for (let i = 0; i < col.length; i += 1) {
44
+ if (Number.isFinite(values[i]))
45
+ n += 1;
46
+ }
47
+ return n;
48
+ }
49
+ const bits = validity.bits;
50
+ for (let i = 0; i < col.length; i += 1) {
51
+ if ((bits[i >> 3] & (1 << (i & 7))) !== 0 &&
52
+ Number.isFinite(values[i])) {
53
+ n += 1;
54
+ }
55
+ }
56
+ return n;
31
57
  },
32
58
  bucketState() {
33
59
  let n = 0;
@@ -1,6 +1,7 @@
1
1
  import { rollingOrderedEntries } from './rolling.js';
2
2
  export const first = {
3
3
  outputKind: 'source',
4
+ definedBoundary: 'first',
4
5
  reduce(defined) {
5
6
  return defined[0];
6
7
  },
@@ -41,6 +41,38 @@ export function resolveReducer(operation) {
41
41
  return topReducer(n);
42
42
  throw new TypeError(`unsupported aggregate reducer: ${operation}`);
43
43
  }
44
+ /**
45
+ * Non-finite numerics (`NaN` / `±Infinity`) are treated as **missing** by
46
+ * every built-in reducer — the reducer non-finite policy
47
+ * (docs/notes/reducer-nan-policy.md). Row intake keeps user data finite; these
48
+ * values only arise inside computed columns (e.g. `cumulative` overflow) or
49
+ * trusted construction, and skipping them keeps every reducer consistent
50
+ * across all four execution paths. Mapping to `undefined` here lets the
51
+ * existing skip-missing logic in each incremental state do the work — so the
52
+ * policy holds for both the batch and live incremental paths without touching
53
+ * any reducer body. Custom-function reducers are intentionally NOT wrapped:
54
+ * they receive values as-is (the escape hatch decides its own semantics).
55
+ */
56
+ function finiteOrMissing(value) {
57
+ return typeof value === 'number' && !Number.isFinite(value)
58
+ ? undefined
59
+ : value;
60
+ }
61
+ /** Wrap a bucket state so non-finite numerics are skipped. {@link finiteOrMissing} */
62
+ function skipNonFiniteBucket(state) {
63
+ return {
64
+ add: (value) => state.add(finiteOrMissing(value)),
65
+ snapshot: () => state.snapshot(),
66
+ };
67
+ }
68
+ /** Wrap a rolling state so non-finite numerics are skipped. {@link finiteOrMissing} */
69
+ function skipNonFiniteRolling(state) {
70
+ return {
71
+ add: (index, value) => state.add(index, finiteOrMissing(value)),
72
+ remove: (index, value) => state.remove(index, finiteOrMissing(value)),
73
+ snapshot: () => state.snapshot(),
74
+ };
75
+ }
44
76
  /**
45
77
  * Build an `AggregateBucketState` for a reducer that may be either a
46
78
  * built-in name (string) or a custom function. Built-ins use their
@@ -52,7 +84,7 @@ export function resolveReducer(operation) {
52
84
  */
53
85
  export function bucketStateFor(reducer) {
54
86
  if (typeof reducer === 'string') {
55
- return resolveReducer(reducer).bucketState();
87
+ return skipNonFiniteBucket(resolveReducer(reducer).bucketState());
56
88
  }
57
89
  // Custom-function adapter: buffer values, call fn at snapshot time.
58
90
  const items = [];
@@ -90,7 +122,7 @@ export function bucketStateFor(reducer) {
90
122
  */
91
123
  export function rollingStateFor(reducer) {
92
124
  if (typeof reducer === 'string') {
93
- return resolveReducer(reducer).rollingState();
125
+ return skipNonFiniteRolling(resolveReducer(reducer).rollingState());
94
126
  }
95
127
  // Custom-function adapter: Map keyed by event index for O(1) remove.
96
128
  const items = new Map();
@@ -1,6 +1,7 @@
1
1
  import { rollingOrderedEntries } from './rolling.js';
2
2
  export const last = {
3
3
  outputKind: 'source',
4
+ definedBoundary: 'last',
4
5
  reduce(defined) {
5
6
  return defined[defined.length - 1];
6
7
  },
@@ -7,20 +7,46 @@ export const max = {
7
7
  : numeric.reduce((a, b) => (a >= b ? a : b));
8
8
  },
9
9
  reduceColumn(col) {
10
- // **NaN parity with row API.** Mirror the row-API expression
11
- // numeric.reduce((a, b) => a >= b ? a : b)
12
- // exactly. See `min.ts` for the full rationale. Closed Codex
13
- // review finding on PR #153.
14
10
  const values = col._values;
15
11
  const validity = col.validity;
16
12
  let hi;
17
- if (validity === undefined) {
18
- if (col.length === 0)
13
+ // Fast path: every defined cell is finite (`Float64Column.allFinite`),
14
+ // so we seed `hi` from the first defined cell and run a plain `v > hi`
15
+ // compare with NO per-element `Number.isFinite` guard and NO in-loop
16
+ // `hi === undefined` check (the seed hoists it out). No NaN to mishandle,
17
+ // also sidesteps the position-dependent `a>=b?a:b` extremum bug the policy
18
+ // fixed (docs/notes/reducer-nan-policy.md). The pre-policy column loop,
19
+ // recovered.
20
+ if (col.allFinite) {
21
+ const len = col.length;
22
+ if (len === 0)
19
23
  return undefined;
20
- hi = values[0];
21
- for (let i = 1; i < col.length; i += 1) {
24
+ if (validity === undefined) {
25
+ hi = values[0];
26
+ for (let i = 1; i < len; i += 1) {
27
+ const v = values[i];
28
+ if (v > hi)
29
+ hi = v;
30
+ }
31
+ return hi;
32
+ }
33
+ const bits = validity.bits;
34
+ for (let i = 0; i < len; i += 1) {
35
+ if ((bits[i >> 3] & (1 << (i & 7))) === 0)
36
+ continue;
37
+ const v = values[i];
38
+ if (hi === undefined || v > hi)
39
+ hi = v;
40
+ }
41
+ return hi;
42
+ }
43
+ // Guarded path: skip non-finite cells (reducer non-finite policy) —
44
+ // matches `bucketState`'s `v > hi`.
45
+ if (validity === undefined) {
46
+ for (let i = 0; i < col.length; i += 1) {
22
47
  const v = values[i];
23
- hi = hi >= v ? hi : v;
48
+ if (Number.isFinite(v) && (hi === undefined || v > hi))
49
+ hi = v;
24
50
  }
25
51
  return hi;
26
52
  }
@@ -29,7 +55,8 @@ export const max = {
29
55
  if ((bits[i >> 3] & (1 << (i & 7))) === 0)
30
56
  continue;
31
57
  const v = values[i];
32
- hi = hi === undefined ? v : hi >= v ? hi : v;
58
+ if (Number.isFinite(v) && (hi === undefined || v > hi))
59
+ hi = v;
33
60
  }
34
61
  return hi;
35
62
  },
@@ -7,33 +7,46 @@ export const min = {
7
7
  : numeric.reduce((a, b) => (a <= b ? a : b));
8
8
  },
9
9
  reduceColumn(col) {
10
- // **NaN parity with row API.** The row-API path uses
11
- // numeric.reduce((a, b) => a <= b ? a : b)
12
- // which has surprising NaN behavior: on `[1, NaN, 2]` it returns
13
- // `2` because the first `1 <= NaN` is false (returning NaN),
14
- // then `NaN <= 2` is also false (returning 2). The "natural"
15
- // column-side loop `if (v < lo) lo = v` would instead return
16
- // `1` (NaN comparisons always false → NaN is skipped). The two
17
- // paths diverge on NaN-bearing input.
18
- //
19
- // We mirror the row-API comparison expression exactly to
20
- // preserve the parity claim, even though both paths exhibit
21
- // surprising results on NaN (which can only reach a `kind:
22
- // 'number'` column via trusted construction — `assertCellKind`
23
- // rejects it at public intake). Closed Codex review finding on
24
- // PR #153. A principled "filter NaN consistently across both
25
- // paths" fix is a separate concern tracked in the followup
26
- // issue.
27
10
  const values = col._values;
28
11
  const validity = col.validity;
29
12
  let lo;
30
- if (validity === undefined) {
31
- if (col.length === 0)
13
+ // Fast path: every defined cell is finite (`Float64Column.allFinite`),
14
+ // so we seed `lo` from the first defined cell and run a plain `v < lo`
15
+ // compare with NO per-element `Number.isFinite` guard and NO in-loop
16
+ // `lo === undefined` check (the seed hoists it out). There is no NaN to
17
+ // mishandle, so this also sidesteps the position-dependent `a<=b?a:b`
18
+ // extremum bug the policy fixed (docs/notes/reducer-nan-policy.md). This
19
+ // is the pre-policy column loop, recovered.
20
+ if (col.allFinite) {
21
+ const len = col.length;
22
+ if (len === 0)
32
23
  return undefined;
33
- lo = values[0];
34
- for (let i = 1; i < col.length; i += 1) {
24
+ if (validity === undefined) {
25
+ lo = values[0];
26
+ for (let i = 1; i < len; i += 1) {
27
+ const v = values[i];
28
+ if (v < lo)
29
+ lo = v;
30
+ }
31
+ return lo;
32
+ }
33
+ const bits = validity.bits;
34
+ for (let i = 0; i < len; i += 1) {
35
+ if ((bits[i >> 3] & (1 << (i & 7))) === 0)
36
+ continue;
37
+ const v = values[i];
38
+ if (lo === undefined || v < lo)
39
+ lo = v;
40
+ }
41
+ return lo;
42
+ }
43
+ // Guarded path: skip non-finite cells (reducer non-finite policy) —
44
+ // matches `bucketState`'s `v < lo`.
45
+ if (validity === undefined) {
46
+ for (let i = 0; i < col.length; i += 1) {
35
47
  const v = values[i];
36
- lo = lo <= v ? lo : v;
48
+ if (Number.isFinite(v) && (lo === undefined || v < lo))
49
+ lo = v;
37
50
  }
38
51
  return lo;
39
52
  }
@@ -42,7 +55,8 @@ export const min = {
42
55
  if ((bits[i >> 3] & (1 << (i & 7))) === 0)
43
56
  continue;
44
57
  const v = values[i];
45
- lo = lo === undefined ? v : lo <= v ? lo : v;
58
+ if (Number.isFinite(v) && (lo === undefined || v < lo))
59
+ lo = v;
46
60
  }
47
61
  return lo;
48
62
  },