pond-ts 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7
7
  file covers both packages. Pre-1.0: minor bumps may include new features and
8
8
  type-level changes; patch bumps are strictly additive.
9
9
 
10
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.23.0...HEAD
10
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.24.0...HEAD
11
+ [0.24.0]: https://github.com/pjm17971/pond-ts/compare/v0.23.0...v0.24.0
11
12
  [0.23.0]: https://github.com/pjm17971/pond-ts/compare/v0.22.0...v0.23.0
12
13
  [0.22.0]: https://github.com/pjm17971/pond-ts/compare/v0.21.0...v0.22.0
13
14
  [0.21.0]: https://github.com/pjm17971/pond-ts/compare/v0.20.0...v0.21.0
@@ -17,6 +18,53 @@ type-level changes; patch bumps are strictly additive.
17
18
 
18
19
  ## [Unreleased]
19
20
 
21
+ ## [0.24.0] — 2026-06-14
22
+
23
+ ### Changed
24
+
25
+ - **`TimeSeries.timeRange()` is now a columnar key-axis read instead of a
26
+ reduce over materialized events.** Behavior is unchanged, but the old
27
+ implementation materialized every `Event` on its first call — and because
28
+ `aggregate()` defaults its `range` to `series.timeRange()`, a one-shot
29
+ `aggregate()` paid full event materialization before the columnar fast
30
+ path could run, erasing the win. The new path reads the key column's
31
+ begin/end axis directly: O(1) for time-keyed series, a typed-array scan
32
+ for range/interval-keyed series, with no event materialization. Measured
33
+ on 1M rows: `timeRange()` itself ~407 ms → ~0.002 ms (time-keyed); cold
34
+ `aggregate()` with a defaulted range ~387 ms → ~6 ms (~63×). Every
35
+ `timeRange()` / `overlaps` / `contains` / `intersection` caller benefits.
36
+ (Audit v2 §3.3.)
37
+ - **`aggregate()` now takes the columnar fast path when a mapping mixes
38
+ numeric reducers with `first` / `last`.** Previously a single `first` or
39
+ `last` column (they have no numeric `reduceColumn`) bailed the entire call
40
+ to the row path. They now qualify via a boundary scan — the first/last
41
+ _defined_ cell, on any column kind. Behavior is unchanged. The big
42
+ beneficiary is **partitioned `aggregate`**, which auto-injects a `'first'`
43
+ reducer for the partition column and so was excluded from the fast path on
44
+ every call (audit v2 §3.2/§3.3). Measured on 1M rows, flat
45
+ `{ cpu: 'avg', host: 'first' }`: ~37.7 ms → ~4.8 ms (~7.8×); the
46
+ pure-numeric path is unchanged. (The remaining `partitionBy` materialization
47
+ cost is addressed separately by the columnar `partitionBy` split.)
48
+ - **`partitionBy(...)` now splits the columnar store directly instead of
49
+ materializing events.** `collect()` / the per-partition sugar methods
50
+ (`fill` / `diff` / `rolling` / …) and `toMap()` previously walked
51
+ `this.events` to bucket rows, then rebuilt each partition via `fromEvents`
52
+ (re-validating + re-packing) — silently re-paying the event-materialization
53
+ tax the columnar wave removed, and making `partitionBy(host).fill().collect()`
54
+ the #1 batch hotspot. They now group row indices off the store and gather
55
+ each partition via a zero-materialization columnar selection. Behavior is
56
+ unchanged (partition order, the `' undefined'` missing-key bucket, composite
57
+ keys, and declared `groups` all preserved). Measured on 100k rows / 64
58
+ partitions: `toMap()` ~389 → ~25 ns/row (~15×, no event materialization at
59
+ all); `diff().collect()` ~2×; `fill(hold).collect()` ~1.7× (the residual is
60
+ `TimeSeries.concat` still materializing to re-sort — a separate follow-up).
61
+ Declared-`groups` membership is validated by the same columnar scan, so that
62
+ path is materialization-free too (~331 → ~33 ns/row). **Behavior note:**
63
+ per-partition sub-series from `toMap()` / `apply()` now lazily materialize
64
+ their own `Event` objects rather than reusing the source's instances — cell
65
+ values are identical; only object identity differs (`collect()`, which
66
+ returns the source unchanged, is unaffected). (Audit v2 §3.2.)
67
+
20
68
  ## [0.23.0] — 2026-06-13
21
69
 
22
70
  ### Added
package/README.md CHANGED
@@ -152,20 +152,24 @@ const points = series.sample({ reservoir: { size: 500 } }).toRows();
152
152
 
153
153
  ## Performance
154
154
 
155
- pond-ts is **7.6x faster** than pondjs on average across all comparable
156
- operations, with no regressions. The advantage grows with data size.
157
-
158
- | Category | Speedup (N=16k) | Notes |
159
- | ----------------- | --------------- | --------------------------------------------- |
160
- | **Aggregation** | 25–32x | O(N+B) bucketing vs O(N×B) Pipeline |
161
- | **Alignment** | 32x | Forward cursor vs repeated binary search |
162
- | **Rate/diff** | 18x | Direct array walk vs Pipeline materialization |
163
- | **Fill** | 10–11x | Single-pass vs Pipeline per strategy |
164
- | **Transforms** | 316x | Pre-validated constructor skips re-validation |
165
- | **Construction** | 7x | Plain objects vs ImmutableJS wrapping |
166
- | **Statistics** | 79x | Direct computation vs ImmutableJS iteration |
167
- | **Serialization** | 4x | Simpler internal representation |
168
- | **Event access** | 23x | Array indexing vs ImmutableJS `get()` |
155
+ pond-ts is faster on **every** comparable operation, with no regressions
156
+ a **~17x** geometric-mean speedup across the measurable ops, plus a handful
157
+ of transforms (`select` / `rename`) that are **effectively instant** (O(1)
158
+ column rebinds, below the timer's resolution). The advantage grows with data
159
+ size.
160
+
161
+ | Category | Speedup (N=16k) | Notes |
162
+ | ----------------- | ---------------------------------------------------- | --------------------------------------------- |
163
+ | **Rate** | ~120x | Single columnar walk vs Pipeline |
164
+ | **Fill** | 7787x | Single columnar pass vs Pipeline per strategy |
165
+ | **Aggregation** | 57–82x | O(N+B) bucketing vs O(N×B) Pipeline |
166
+ | **Statistics** | 1880x | Typed-array reduce vs ImmutableJS iteration |
167
+ | **Alignment** | 42x | Forward cursor vs repeated binary search |
168
+ | **Construction** | 13x | Columnar intake vs ImmutableJS wrapping |
169
+ | **Chained** | 8x | Derived constructors vs per-step Pipeline |
170
+ | **Transforms** | `select`/`rename` instant; `collapse` 30x; `map` ~4x | Column reshapes vs Pipeline |
171
+ | **Event access** | 6x | Array indexing vs ImmutableJS `get()` |
172
+ | **Serialization** | 4x | Lightweight columnar representation |
169
173
 
170
174
  See the [full benchmark results](website/docs/reference/benchmarks.mdx)
171
175
  for detailed numbers. Run locally:
@@ -34,30 +34,6 @@ export type AggregateColumnSpec = {
34
34
  * same `mapping` shape produces the same schema.
35
35
  */
36
36
  export declare function normalizeAggregateColumns<S extends SeriesSchema>(schema: S, mapping: AggregateMap<S> | AggregateOutputMap<S>): AggregateColumnSpec[];
37
- /**
38
- * **Phase 4.7 step 3B — columnar fast path for time-keyed `aggregate()`.**
39
- * On sorted time-keyed data each bucket is a contiguous index range, so
40
- * when every mapped column is a built-in numeric reducer with a
41
- * `reduceColumn` fast path over a **packed `Float64Column`** source, each
42
- * bucket reduces straight off the typed-array slice — skipping the
43
- * `series.events` materialization and the per-cell `state.add` walk the
44
- * row path pays. Reuses the shipped step-3A `reduceColumn` kernels
45
- * (sum/min/max/avg 59–73×, stdev 35×, median/p95 3.4×) per bucket.
46
- *
47
- * Returns `null` — caller takes the unchanged row path — when any column
48
- * doesn't qualify: a custom-function reducer, a reducer with no
49
- * `reduceColumn` (`first` / `last` / `unique` / `top` / `samples`), or a
50
- * non-numeric / chunked / missing source column. All-or-nothing per call
51
- * keeps the bucket walk single-pass; mixed mappings fall back wholesale.
52
- *
53
- * `begins` is the key column's begin axis (sorted, identical row order to
54
- * the value columns — both read straight off the store). Bucketing
55
- * replicates the row path exactly: `cursor` carries across buckets, and a
56
- * bucket owns `[start, scan)` where `begins[i] ∈ [bucket.begin,
57
- * bucket.end)`. Empty buckets reduce an empty slice — the reducer's
58
- * empty-input result (the step-3A parity contract guarantees this matches
59
- * a zero-`add` bucket snapshot).
60
- */
61
37
  export declare function tryAggregateColumnarTimeKeyed<B extends {
62
38
  begin(): number;
63
39
  end(): number;
@@ -73,45 +73,34 @@ export function normalizeAggregateColumns(schema, mapping) {
73
73
  }
74
74
  return normalized;
75
75
  }
76
- /**
77
- * **Phase 4.7 step 3B — columnar fast path for time-keyed `aggregate()`.**
78
- * On sorted time-keyed data each bucket is a contiguous index range, so
79
- * when every mapped column is a built-in numeric reducer with a
80
- * `reduceColumn` fast path over a **packed `Float64Column`** source, each
81
- * bucket reduces straight off the typed-array slice — skipping the
82
- * `series.events` materialization and the per-cell `state.add` walk the
83
- * row path pays. Reuses the shipped step-3A `reduceColumn` kernels
84
- * (sum/min/max/avg 59–73×, stdev 35×, median/p95 3.4×) per bucket.
85
- *
86
- * Returns `null` — caller takes the unchanged row path — when any column
87
- * doesn't qualify: a custom-function reducer, a reducer with no
88
- * `reduceColumn` (`first` / `last` / `unique` / `top` / `samples`), or a
89
- * non-numeric / chunked / missing source column. All-or-nothing per call
90
- * keeps the bucket walk single-pass; mixed mappings fall back wholesale.
91
- *
92
- * `begins` is the key column's begin axis (sorted, identical row order to
93
- * the value columns — both read straight off the store). Bucketing
94
- * replicates the row path exactly: `cursor` carries across buckets, and a
95
- * bucket owns `[start, scan)` where `begins[i] ∈ [bucket.begin,
96
- * bucket.end)`. Empty buckets reduce an empty slice — the reducer's
97
- * empty-input result (the step-3A parity contract guarantees this matches
98
- * a zero-`add` bucket snapshot).
99
- */
100
76
  export function tryAggregateColumnarTimeKeyed(begins, getColumn, buckets, columns) {
101
77
  const plans = [];
102
78
  for (const spec of columns) {
103
79
  if (typeof spec.reducer !== 'string')
104
80
  return null; // custom function
105
81
  const def = resolveReducer(spec.reducer);
106
- if (def.reduceColumn === undefined)
107
- return null; // first/last/unique/...
108
82
  const source = getColumn(spec.source);
109
- if (source === undefined ||
110
- source.kind !== 'number' ||
111
- source.storage !== 'packed') {
112
- return null; // non-numeric / chunked / missing source
83
+ if (source === undefined)
84
+ return null; // missing source
85
+ if (def.definedBoundary !== undefined) {
86
+ // `first` / `last`: pick the first/last *defined* cell in the bucket
87
+ // via a boundary scan over any column kind / storage (`col.read(i)`).
88
+ // This is what lets a partitioned `aggregate` — whose auto-injected
89
+ // partition-column reducer is `'first'` — take the fast path instead
90
+ // of bailing the whole call for lack of a numeric `reduceColumn`.
91
+ plans.push({
92
+ kind: 'boundary',
93
+ column: source,
94
+ which: def.definedBoundary,
95
+ });
96
+ continue;
113
97
  }
114
- plans.push({ column: source, reduce: def.reduceColumn });
98
+ if (def.reduceColumn === undefined)
99
+ return null; // unique / top / samples / keep
100
+ if (source.kind !== 'number' || source.storage !== 'packed') {
101
+ return null; // non-numeric / chunked numeric source
102
+ }
103
+ plans.push({ kind: 'reduce', column: source, reduce: def.reduceColumn });
115
104
  }
116
105
  const n = begins.length;
117
106
  let cursor = 0;
@@ -130,7 +119,33 @@ export function tryAggregateColumnarTimeKeyed(begins, getColumn, buckets, column
130
119
  const reduced = new Array(plans.length);
131
120
  for (let p = 0; p < plans.length; p += 1) {
132
121
  const plan = plans[p];
133
- reduced[p] = plan.reduce(plan.column.sliceByRange(start, scan));
122
+ if (plan.kind === 'reduce') {
123
+ reduced[p] = plan.reduce(plan.column.sliceByRange(start, scan));
124
+ }
125
+ else if (plan.which === 'first') {
126
+ // First defined cell in [start, scan); scans past missing cells.
127
+ let value;
128
+ for (let i = start; i < scan; i += 1) {
129
+ const cell = plan.column.read(i);
130
+ if (cell !== undefined) {
131
+ value = cell;
132
+ break;
133
+ }
134
+ }
135
+ reduced[p] = value;
136
+ }
137
+ else {
138
+ // Last defined cell in [start, scan); scans backward past missing.
139
+ let value;
140
+ for (let i = scan - 1; i >= start; i -= 1) {
141
+ const cell = plan.column.read(i);
142
+ if (cell !== undefined) {
143
+ value = cell;
144
+ break;
145
+ }
146
+ }
147
+ reduced[p] = value;
148
+ }
134
149
  }
135
150
  rows[b] = Object.freeze([bucket, ...reduced]);
136
151
  }
@@ -196,8 +196,6 @@ export declare class PartitionedTimeSeries<S extends SeriesSchema, K extends str
196
196
  toMap(): Map<K, TimeSeries<S>>;
197
197
  toMap<R extends SeriesSchema>(transform: (group: TimeSeries<S>) => TimeSeries<R>): Map<K, TimeSeries<R>>;
198
198
  toMap<R>(transform: (group: TimeSeries<S>) => R): Map<K, R>;
199
- private static partitionKeyOf;
200
- private static bucketByPartition;
201
199
  private static applyToSource;
202
200
  private rewrap;
203
201
  /**
@@ -88,17 +88,17 @@ export class PartitionedTimeSeries {
88
88
  this.validateGroupMembership();
89
89
  }
90
90
  }
91
- // Validate that every event's partition value appears in the
92
- // declared groups. Mirrors the partition encoder so the comparison
93
- // accepts the same string forms toMap will produce as keys.
91
+ // Validate that every partition value appears in the declared groups.
92
+ // Scans the partition column columnar-natively via `_distinctPartitionKeys`
93
+ // (no event materialization — same encoding `toMap` produces), so the
94
+ // declared-`groups` path is materialization-free like the rest of the
95
+ // split.
94
96
  validateGroupMembership() {
95
97
  if (!this.groups)
96
98
  return;
97
99
  const col = this.by[0];
98
100
  const declared = new Set(this.groups);
99
- const keyOf = PartitionedTimeSeries.partitionKeyOf(this.by);
100
- for (const event of this.source.events) {
101
- const key = keyOf(event);
101
+ for (const key of this.source._distinctPartitionKeys(this.by)) {
102
102
  if (!declared.has(key)) {
103
103
  // Decode the encoder's leading-space sentinel so the message
104
104
  // shows the user-facing concept, not the internal encoding.
@@ -205,96 +205,54 @@ export class PartitionedTimeSeries {
205
205
  }
206
206
  toMap(transform) {
207
207
  const result = new Map();
208
- const buckets = this.source.events.length === 0
208
+ // Columnar partition split each sub-series is gathered straight off
209
+ // the store, no event materialization (the old path rebuilt every
210
+ // partition via `fromEvents`). Map order = first-encountered partition.
211
+ const partitions = this.source.length === 0
209
212
  ? new Map()
210
- : PartitionedTimeSeries.bucketByPartition(this.source, this.by);
213
+ : this.source._partitionByColumns(this.by);
211
214
  if (this.groups) {
212
215
  // Declared-order iteration. Empty groups produce empty
213
216
  // TimeSeries entries (consistent with pivotByGroup's typed
214
217
  // groups behavior, which emits a column for every declared
215
218
  // value even when no events match).
216
219
  for (const g of this.groups) {
217
- const events = buckets.get(g) ?? [];
218
- const sub = TimeSeries.fromEvents(events, {
219
- schema: this.source.schema,
220
- name: this.source.name,
221
- });
220
+ const sub = partitions.get(g) ??
221
+ TimeSeries.fromEvents([], {
222
+ schema: this.source.schema,
223
+ name: this.source.name,
224
+ });
222
225
  result.set(g, transform ? transform(sub) : sub);
223
226
  }
224
227
  return result;
225
228
  }
226
229
  // Insertion-order iteration (matches the order each partition was
227
230
  // first encountered in the source events).
228
- for (const [key, events] of buckets) {
229
- const sub = TimeSeries.fromEvents(events, {
230
- schema: this.source.schema,
231
- name: this.source.name,
232
- });
231
+ for (const [key, sub] of partitions) {
233
232
  result.set(key, transform ? transform(sub) : sub);
234
233
  }
235
234
  return result;
236
235
  }
237
- // Build the encoder that produces a string key for an event given
238
- // the partition columns. Single-column case avoids the JSON encoding
239
- // overhead. Multi-column uses JSON.stringify to guarantee no key
240
- // collisions on values containing separators (e.g. region names with
241
- // spaces) — a naive `parts.join('|')` would collide. `undefined` in a
242
- // single-column key becomes the literal `' undefined'` (with the
243
- // leading space ensuring it can never collide with a string column
244
- // whose value is the literal `'undefined'`).
245
- static partitionKeyOf(by) {
246
- if (by.length === 1) {
247
- const col = by[0];
248
- return (event) => {
249
- const v = event.data()[col];
250
- return v === undefined ? ' undefined' : `${String(v)}`;
251
- };
252
- }
253
- return (event) => {
254
- const data = event.data();
255
- const parts = new Array(by.length);
256
- for (let i = 0; i < by.length; i += 1) {
257
- parts[i] = data[by[i]] ?? null;
258
- }
259
- return JSON.stringify(parts);
260
- };
261
- }
262
- // Group source events into buckets keyed by partition value. Returned
263
- // Map iteration order = insertion order, which matches the order
264
- // partitions were first seen in the source events array.
265
- static bucketByPartition(source, by) {
266
- const keyOf = PartitionedTimeSeries.partitionKeyOf(by);
267
- const buckets = new Map();
268
- for (const event of source.events) {
269
- const key = keyOf(event);
270
- let bucket = buckets.get(key);
271
- if (!bucket) {
272
- bucket = [];
273
- buckets.set(key, bucket);
274
- }
275
- bucket.push(event);
276
- }
277
- return buckets;
278
- }
279
236
  // Internal helper used by both `apply` (terminal) and the sugar
280
237
  // methods (which re-wrap the result back into a partitioned view).
281
238
  static applyToSource(source, by, fn) {
282
239
  // Empty source: apply fn to an empty group so the output schema
283
240
  // and name come from fn, not from inferring R structurally.
284
- if (source.events.length === 0) {
241
+ if (source.length === 0) {
285
242
  const empty = TimeSeries.fromEvents([], {
286
243
  schema: source.schema,
287
244
  name: source.name,
288
245
  });
289
246
  return fn(empty);
290
247
  }
291
- const buckets = PartitionedTimeSeries.bucketByPartition(source, by);
248
+ // Columnar partition split — no event materialization. The old path
249
+ // walked `source.events` to bucket, then rebuilt each partition via
250
+ // `fromEvents` (re-validating + re-packing), silently re-paying the
251
+ // 495 ns/row tax the columnar wave removed. `_partitionByColumns`
252
+ // gathers each partition straight off the store (audit v2 §3.2).
253
+ const partitions = source._partitionByColumns(by);
292
254
  const transformed = [];
293
- for (const events of buckets.values()) {
294
- const sub = TimeSeries.fromEvents(events, {
295
- schema: source.schema,
296
- name: source.name,
297
- });
255
+ for (const sub of partitions.values()) {
298
256
  transformed.push(fn(sub));
299
257
  }
300
258
  return TimeSeries.concat(transformed);
@@ -12,7 +12,7 @@ import { TimeRange } from '../core/time-range.js';
12
12
  import { compareEventKeys } from '../core/temporal.js';
13
13
  import { PartitionedTimeSeries } from './partitioned-time-series.js';
14
14
  import { Sequence } from '../sequence/sequence.js';
15
- import { IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, } from '../columnar/index.js';
15
+ import { IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
16
16
  import { SeriesStore } from '../live/series-store.js';
17
17
  import { parseDuration } from '../core/duration.js';
18
18
  import { resolveReducer, } from '../reducers/index.js';
@@ -710,6 +710,98 @@ export class TimeSeries {
710
710
  };
711
711
  return new TimeSeries(trustedInput);
712
712
  }
713
+ /**
714
+ * @internal Per-row partition-key encoder, mirroring the (removed)
715
+ * event-based `partitionKeyOf` exactly: single column → `String(value)`,
716
+ * or `' undefined'` (leading space, so it can't collide with the literal
717
+ * string `'undefined'`) for a missing cell; composite → `JSON.stringify`
718
+ * of the cells with a `?? null` fallback. Reads cells by index off the
719
+ * store — no event materialization; array cells stringify as the event
720
+ * path did. Shared by `_partitionByColumns` and `_distinctPartitionKeys`
721
+ * so the encoding can't drift between the split and the declared-`groups`
722
+ * membership check.
723
+ */
724
+ #partitionKeyEncoder(by) {
725
+ const store = this.#store.store;
726
+ if (by.length === 1) {
727
+ // Single-column fast path — no per-row array allocation.
728
+ const col = store.columns.get(by[0]);
729
+ return (i) => {
730
+ const value = col.read(i);
731
+ return value === undefined ? ' undefined' : `${String(value)}`;
732
+ };
733
+ }
734
+ const cols = by.map((name) => store.columns.get(name));
735
+ const parts = new Array(cols.length);
736
+ return (i) => {
737
+ for (let c = 0; c < cols.length; c += 1) {
738
+ parts[c] = cols[c].read(i) ?? null;
739
+ }
740
+ return JSON.stringify(parts);
741
+ };
742
+ }
743
+ /**
744
+ * @internal Columnar partition split — groups row indices by the partition
745
+ * key (see {@link TimeSeries.#partitionKeyEncoder}), gathers each group via
746
+ * `withRowSelection`, and wraps it as a trusted-store `TimeSeries`, with
747
+ * **no event materialization**. The columnar dual of the old
748
+ * `fromEvents`-per-bucket split that `PartitionedTimeSeries` paid through
749
+ * `this.events`. First-encountered partition order is preserved (Map
750
+ * insertion order), matching the previous `bucketByPartition`.
751
+ *
752
+ * **Identity note:** each sub-series lazily materializes its own `Event`
753
+ * objects from the gathered store — it does NOT reuse the source's `Event`
754
+ * instances (the old `fromEvents` path did). Cell values are identical;
755
+ * only object identity differs, and only for the no-transform paths
756
+ * (`toMap()` / `apply(g => g)`). `collect()` returns the source unchanged,
757
+ * so it is unaffected. This is the deliberate cost of skipping
758
+ * materialization.
759
+ *
760
+ * Used by `PartitionedTimeSeries` (`collect` / sugar via `applyToSource`,
761
+ * and `toMap`).
762
+ */
763
+ _partitionByColumns(by) {
764
+ const columnarStore = this.#store.store;
765
+ const length = columnarStore.length;
766
+ const encode = this.#partitionKeyEncoder(by);
767
+ const groups = new Map();
768
+ for (let i = 0; i < length; i += 1) {
769
+ const key = encode(i);
770
+ let indices = groups.get(key);
771
+ if (indices === undefined) {
772
+ indices = [];
773
+ groups.set(key, indices);
774
+ }
775
+ indices.push(i);
776
+ }
777
+ const result = new Map();
778
+ for (const [key, rowIndices] of groups) {
779
+ const sub = withRowSelection(columnarStore, new Int32Array(rowIndices));
780
+ result.set(key, TimeSeries.#fromTrustedStore(this.name, this.schema, sub));
781
+ }
782
+ return result;
783
+ }
784
+ /**
785
+ * @internal Distinct partition keys in first-encountered order, encoded by
786
+ * {@link TimeSeries.#partitionKeyEncoder} (identical to
787
+ * `_partitionByColumns`). Scans the partition columns off the store — no
788
+ * event materialization. Used by `PartitionedTimeSeries`'s declared-`groups`
789
+ * membership check so that path is materialization-free too.
790
+ */
791
+ _distinctPartitionKeys(by) {
792
+ const length = this.#store.store.length;
793
+ const encode = this.#partitionKeyEncoder(by);
794
+ const seen = new Set();
795
+ const keys = [];
796
+ for (let i = 0; i < length; i += 1) {
797
+ const key = encode(i);
798
+ if (!seen.has(key)) {
799
+ seen.add(key);
800
+ keys.push(key);
801
+ }
802
+ }
803
+ return keys;
804
+ }
713
805
  /**
714
806
  * Example: `series.events`. Returns the full event array.
715
807
  *
@@ -2401,12 +2493,37 @@ export class TimeSeries {
2401
2493
  }
2402
2494
  /** Example: `series.timeRange()`. Returns the overall temporal extent of the series, if the series is not empty. */
2403
2495
  timeRange() {
2404
- const first = this.first();
2405
- if (!first) {
2496
+ // Columnar key-axis read. The old implementation reduced over
2497
+ // `this.events`, materializing every Event (the ~495 ns/row + heap
2498
+ // tax) on every call — including the cold `aggregate()` default
2499
+ // range, which erased the 3B fast-path win for one-shot pipelines.
2500
+ const key = this.keyColumn();
2501
+ const n = key.length;
2502
+ if (n === 0) {
2406
2503
  return undefined;
2407
2504
  }
2408
- const start = first.begin();
2409
- const end = this.events.reduce((maxEnd, event) => Math.max(maxEnd, event.end()), first.end());
2505
+ // `begin` is sorted non-decreasing (the intake invariant) for every
2506
+ // key kind, so the minimum start is always `begin[0]`.
2507
+ const start = key.begin[0];
2508
+ let end;
2509
+ if (key.kind === 'time') {
2510
+ // Point-in-time keys: `end === begin`, and begin is sorted, so the
2511
+ // maximum end is the final begin. O(1).
2512
+ end = key.begin[n - 1];
2513
+ }
2514
+ else {
2515
+ // Range / interval keys: `begin[i] <= end[i]` per row, but `end` is
2516
+ // NOT monotonic (a long early event can outlast the final row), so
2517
+ // the max end needs a scan — a typed-array scan, not Event
2518
+ // materialization. Seeds with `end[0]` and maxes the rest, matching
2519
+ // the old `reduce` seeded at `first.end()`.
2520
+ const ends = key.end;
2521
+ end = ends[0];
2522
+ for (let i = 1; i < n; i += 1) {
2523
+ if (ends[i] > end)
2524
+ end = ends[i];
2525
+ }
2526
+ }
2410
2527
  return new TimeRange({ start, end });
2411
2528
  }
2412
2529
  /** Example: `series.overlaps(range)`. Returns `true` when the overall series extent overlaps the supplied temporal value. */
@@ -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
  },
@@ -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
  },
@@ -85,6 +85,22 @@ export type ReducerDef = {
85
85
  * `reduceColumn` may assume the column is a packed `Float64Column`.
86
86
  */
87
87
  reduceColumn?(col: Float64Column): ColumnValue | undefined;
88
+ /**
89
+ * **Columnar fast-path for boundary selectors.** Present only on
90
+ * `first` / `last`. Marks the reducer as "pick the {first|last}
91
+ * *defined* value in the bucket" — which a columnar caller can compute
92
+ * as a boundary scan over the source column (via `col.read(i)`, any
93
+ * kind / any storage) instead of bailing the whole call to the row
94
+ * path just because these reducers lack a numeric `reduceColumn`.
95
+ *
96
+ * Semantics match `reduce` / `bucketState` exactly: scan past *missing*
97
+ * cells to the first/last cell whose `read(i)` is not `undefined`; an
98
+ * all-missing or empty bucket yields `undefined`. This is what lets the
99
+ * partitioned-`aggregate` fast path run — the auto-injected
100
+ * partition-column reducer is `'first'` (see `PartitionedTimeSeries`),
101
+ * which previously tripped the all-or-nothing gate for every call.
102
+ */
103
+ definedBoundary?: 'first' | 'last';
88
104
  /** Return a fresh incremental state for one aggregation bucket. */
89
105
  bucketState(): AggregateBucketState;
90
106
  /** Return a fresh incremental state for one rolling window column. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {