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 +49 -1
- package/README.md +18 -14
- package/dist/batch/aggregate-columns.d.ts +0 -24
- package/dist/batch/aggregate-columns.js +47 -32
- package/dist/batch/partitioned-time-series.d.ts +0 -2
- package/dist/batch/partitioned-time-series.js +25 -67
- package/dist/batch/time-series.js +122 -5
- package/dist/reducers/first.js +1 -0
- package/dist/reducers/last.js +1 -0
- package/dist/reducers/types.d.ts +16 -0
- package/package.json +1 -1
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.
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
|
162
|
-
|
|
|
163
|
-
| **
|
|
164
|
-
| **
|
|
165
|
-
| **
|
|
166
|
-
| **Statistics** |
|
|
167
|
-
| **
|
|
168
|
-
| **
|
|
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** | 77–87x | Single columnar pass vs Pipeline per strategy |
|
|
165
|
+
| **Aggregation** | 57–82x | O(N+B) bucketing vs O(N×B) Pipeline |
|
|
166
|
+
| **Statistics** | 18–80x | 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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2405
|
-
|
|
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
|
-
|
|
2409
|
-
|
|
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. */
|
package/dist/reducers/first.js
CHANGED
package/dist/reducers/last.js
CHANGED
package/dist/reducers/types.d.ts
CHANGED
|
@@ -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. */
|