pond-ts 0.71.0 → 0.72.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
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
9
9
  include new features and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.71.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.72.0...HEAD
12
+ [0.72.0]: https://github.com/pond-ts/pond/compare/v0.71.0...v0.72.0
12
13
  [0.71.0]: https://github.com/pond-ts/pond/compare/v0.70.0...v0.71.0
13
14
  [0.70.0]: https://github.com/pond-ts/pond/compare/v0.69.0...v0.70.0
14
15
  [0.69.0]: https://github.com/pond-ts/pond/compare/v0.68.0...v0.69.0
@@ -74,6 +75,37 @@ include new features and type-level changes; patch bumps are strictly additive.
74
75
 
75
76
  ## [Unreleased]
76
77
 
78
+ ## [0.72.0] — 2026-10-08
79
+
80
+ ### Changed
81
+
82
+ - `pond-ts`: **`join` and `joinMany` work on columns now, not events** — 20
83
+ to 190× faster in the cases below, with the same output. `join` used to
84
+ build an event for every row on both sides, merge them row by row and
85
+ rebuild the columns. Now it walks the two key columns once, then either
86
+ adopts each column as is or gathers it in one pass. A side's value columns
87
+ are adopted as is whenever all its rows land in the output once and in order:
88
+ always the left side of a `left` join, the right side of a `right` join, and
89
+ both sides when the keys match one for one (`joinMany` over a shared grid).
90
+ One year of 1-minute bars, ~97.5k rows a side, `type: 'left'` unless noted
91
+ (`packages/core/scripts/perf-join.mjs`):
92
+
93
+ | Value columns per side | Before | After |
94
+ | ------------------------ | -------- | -------- |
95
+ | 59 and 59 | 1,868 ms | 24 ms |
96
+ | 5 and 5 | 168 ms | 4.2 ms |
97
+ | 59 and 2 | 766 ms | 4.1 ms |
98
+ | `joinMany`, 4 × 5, outer | 457 ms | 15–20 ms |
99
+
100
+ A left join now costs about as much as the **other** side is wide. To bring
101
+ across only the columns you read, narrow that side first; `select` and
102
+ `rename` don't copy:
103
+ `bars.join(spy.select('close').rename({ close: 'spy' }), { type: 'left' })`.
104
+
105
+ One visible difference: on a row with no match, `event.data()` now lists the
106
+ other side's fields as `undefined`, as every other operator's events do. It
107
+ used to leave them out. `get()` returns `undefined` either way.
108
+
77
109
  ## [0.71.0] — 2026-09-24
78
110
 
79
111
  ### Added
@@ -0,0 +1,37 @@
1
+ import { type ColumnSchema, ColumnarStore } from '../../columnar/index.js';
2
+ import type { JoinType } from '../../schema/index.js';
3
+ /**
4
+ * **Column-native exact-key `join`** ([PND-JOINCOL]). Merge-walks the two
5
+ * key columns once into a pair of row-match indices — output row `r` takes
6
+ * left row `leftIdx[r]` and right row `rightIdx[r]`, with `-1` meaning "no
7
+ * row on that side" — then builds every output column from those indices:
8
+ *
9
+ * - **Pass-through.** When one side's index is the identity (each of its
10
+ * rows appears once, in order, with nothing interleaved), that side's
11
+ * value columns are adopted by reference — and, on the left, the key
12
+ * column too. This is always true of the primary in a `left` join and of
13
+ * the other side in a `right` join, and it is true of **both** sides when
14
+ * the two keys match one for one — the `joinMany` over a shared grid case.
15
+ * - **Gather.** Otherwise each column is one `sliceByIndices` over the match
16
+ * index; a `-1` slot comes out missing through the column's validity, the
17
+ * substrate's existing out-of-range gather contract.
18
+ *
19
+ * No `Event` is materialised on either side or in the output.
20
+ *
21
+ * **Semantics are the event walk's, exactly.** The key comparison is
22
+ * `compareEventKeys` / `Interval.compare` restated over the buffers —
23
+ * `begin`, then `end`, then (interval keys only) `compareIntervalValues` on
24
+ * the labels — and the walk pairs equal keys **one to one** in order: a key
25
+ * that repeats `a` times on the left and `b` times on the right yields
26
+ * `min(a, b)` matched rows plus `|a − b|` one-sided rows, not `a·b`. A matched
27
+ * row carries the **left** key.
28
+ *
29
+ * Complexity: O(N + M) for the walk, plus O(R) per gathered column (R output
30
+ * rows). Pass-through columns cost nothing.
31
+ *
32
+ * The caller has already checked that the key kinds agree and that no value
33
+ * column name appears on both sides; `outSchema` is the left key column
34
+ * followed by the left then the right value columns.
35
+ */
36
+ export declare function joinOp(left: ColumnarStore<ColumnSchema>, right: ColumnarStore<ColumnSchema>, joinType: JoinType, outSchema: ColumnSchema): ColumnarStore<ColumnSchema>;
37
+ //# sourceMappingURL=join.d.ts.map
@@ -0,0 +1,252 @@
1
+ import { ColumnarStore, Float64Column, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, ValueKeyColumn, stringColumnFromArray, } from '../../columnar/index.js';
2
+ import { compareIntervalValues } from '../../core/temporal.js';
3
+ /**
4
+ * **Column-native exact-key `join`** ([PND-JOINCOL]). Merge-walks the two
5
+ * key columns once into a pair of row-match indices — output row `r` takes
6
+ * left row `leftIdx[r]` and right row `rightIdx[r]`, with `-1` meaning "no
7
+ * row on that side" — then builds every output column from those indices:
8
+ *
9
+ * - **Pass-through.** When one side's index is the identity (each of its
10
+ * rows appears once, in order, with nothing interleaved), that side's
11
+ * value columns are adopted by reference — and, on the left, the key
12
+ * column too. This is always true of the primary in a `left` join and of
13
+ * the other side in a `right` join, and it is true of **both** sides when
14
+ * the two keys match one for one — the `joinMany` over a shared grid case.
15
+ * - **Gather.** Otherwise each column is one `sliceByIndices` over the match
16
+ * index; a `-1` slot comes out missing through the column's validity, the
17
+ * substrate's existing out-of-range gather contract.
18
+ *
19
+ * No `Event` is materialised on either side or in the output.
20
+ *
21
+ * **Semantics are the event walk's, exactly.** The key comparison is
22
+ * `compareEventKeys` / `Interval.compare` restated over the buffers —
23
+ * `begin`, then `end`, then (interval keys only) `compareIntervalValues` on
24
+ * the labels — and the walk pairs equal keys **one to one** in order: a key
25
+ * that repeats `a` times on the left and `b` times on the right yields
26
+ * `min(a, b)` matched rows plus `|a − b|` one-sided rows, not `a·b`. A matched
27
+ * row carries the **left** key.
28
+ *
29
+ * Complexity: O(N + M) for the walk, plus O(R) per gathered column (R output
30
+ * rows). Pass-through columns cost nothing.
31
+ *
32
+ * The caller has already checked that the key kinds agree and that no value
33
+ * column name appears on both sides; `outSchema` is the left key column
34
+ * followed by the left then the right value columns.
35
+ */
36
+ export function joinOp(left, right, joinType, outSchema) {
37
+ const lk = left.keys;
38
+ const rk = right.keys;
39
+ const n = lk.length;
40
+ const m = rk.length;
41
+ const keepLeft = joinType === 'left' || joinType === 'outer';
42
+ const keepRight = joinType === 'right' || joinType === 'outer';
43
+ // Exact for left / right (every row of the kept side is emitted once), an
44
+ // upper bound for inner / outer.
45
+ const capacity = joinType === 'left'
46
+ ? n
47
+ : joinType === 'right'
48
+ ? m
49
+ : joinType === 'inner'
50
+ ? Math.min(n, m)
51
+ : n + m;
52
+ const leftIdx = new Int32Array(capacity);
53
+ const rightIdx = new Int32Array(capacity);
54
+ const compare = keyComparator(lk, rk);
55
+ let i = 0;
56
+ let j = 0;
57
+ let len = 0;
58
+ let leftOnly = 0;
59
+ let rightOnly = 0;
60
+ while (i < n && j < m) {
61
+ const c = compare(i, j);
62
+ if (c === 0) {
63
+ leftIdx[len] = i;
64
+ rightIdx[len] = j;
65
+ len += 1;
66
+ i += 1;
67
+ j += 1;
68
+ }
69
+ else if (c < 0) {
70
+ if (keepLeft) {
71
+ leftIdx[len] = i;
72
+ rightIdx[len] = -1;
73
+ len += 1;
74
+ leftOnly += 1;
75
+ }
76
+ i += 1;
77
+ }
78
+ else {
79
+ if (keepRight) {
80
+ leftIdx[len] = -1;
81
+ rightIdx[len] = j;
82
+ len += 1;
83
+ rightOnly += 1;
84
+ }
85
+ j += 1;
86
+ }
87
+ }
88
+ if (keepLeft) {
89
+ for (; i < n; i += 1) {
90
+ leftIdx[len] = i;
91
+ rightIdx[len] = -1;
92
+ len += 1;
93
+ leftOnly += 1;
94
+ }
95
+ }
96
+ if (keepRight) {
97
+ for (; j < m; j += 1) {
98
+ leftIdx[len] = -1;
99
+ rightIdx[len] = j;
100
+ len += 1;
101
+ rightOnly += 1;
102
+ }
103
+ }
104
+ // A side's index is the identity iff all its rows were emitted and the
105
+ // other side contributed no one-sided rows between them (rows of one side
106
+ // are always emitted in ascending order).
107
+ const leftIdentity = len === n && rightOnly === 0;
108
+ const rightIdentity = len === m && leftOnly === 0;
109
+ const li = leftIdx.subarray(0, len);
110
+ const ri = rightIdx.subarray(0, len);
111
+ // A matched row carries the left key, so only the left key column is ever
112
+ // adopted. The right one is not a safe stand-in even when its index is the
113
+ // identity: equal keys need not be identical — interval labels compare with
114
+ // `localeCompare` (a precomposed and a decomposed é are equal), and
115
+ // timestamps with `-`, under which `0` and `-0` are equal.
116
+ const keys = leftIdentity ? lk : gatherKeys(lk, rk, li, ri);
117
+ const columns = new Map();
118
+ for (let c = 1; c < left.schema.length; c += 1) {
119
+ const name = left.schema[c].name;
120
+ const col = left.columns.get(name);
121
+ columns.set(name, leftIdentity ? col : col.sliceByIndices(li));
122
+ }
123
+ for (let c = 1; c < right.schema.length; c += 1) {
124
+ const name = right.schema[c].name;
125
+ const col = right.columns.get(name);
126
+ columns.set(name, rightIdentity ? col : col.sliceByIndices(ri));
127
+ }
128
+ return ColumnarStore.fromTrustedStore(outSchema, keys, columns);
129
+ }
130
+ /**
131
+ * The event walk's key order (`compareEventKeys`, plus `Interval.compare`'s
132
+ * label tiebreak) over the raw key buffers. Both columns are the same kind.
133
+ */
134
+ function keyComparator(lk, rk) {
135
+ const lb = lk.begin;
136
+ const rb = rk.begin;
137
+ if (lk.kind === 'time' || lk.kind === 'value') {
138
+ return (i, j) => lb[i] - rb[j];
139
+ }
140
+ const le = lk.end;
141
+ const re = rk.end;
142
+ if (lk.kind === 'timeRange') {
143
+ return (i, j) => {
144
+ const d = lb[i] - rb[j];
145
+ return d !== 0 ? d : le[i] - re[j];
146
+ };
147
+ }
148
+ const ll = lk.labels;
149
+ const rl = rk.labels;
150
+ return (i, j) => {
151
+ const d = lb[i] - rb[j];
152
+ if (d !== 0)
153
+ return d;
154
+ const e = le[i] - re[j];
155
+ if (e !== 0)
156
+ return e;
157
+ return compareIntervalValues(ll.read(i), rl.read(j));
158
+ };
159
+ }
160
+ /**
161
+ * Builds the output key column row by row from whichever side has the row,
162
+ * preferring the left. Every `(begin, end, label)` triple is copied whole
163
+ * from one already-validated source row, so the per-row invariants (finite,
164
+ * `begin <= end`, defined label) hold by construction and the trusted
165
+ * factories skip re-checking them.
166
+ */
167
+ function gatherKeys(lk, rk, li, ri) {
168
+ const len = li.length;
169
+ const lb = lk.begin;
170
+ const rb = rk.begin;
171
+ const begin = new Float64Array(len);
172
+ if (lk.kind === 'time' || lk.kind === 'value') {
173
+ for (let r = 0; r < len; r += 1) {
174
+ const a = li[r];
175
+ begin[r] = a >= 0 ? lb[a] : rb[ri[r]];
176
+ }
177
+ return lk.kind === 'time'
178
+ ? TimeKeyColumn.fromValidatedSubarray(begin, len)
179
+ : new ValueKeyColumn(begin, len);
180
+ }
181
+ const le = lk.end;
182
+ const re = rk.end;
183
+ const end = new Float64Array(len);
184
+ for (let r = 0; r < len; r += 1) {
185
+ const a = li[r];
186
+ if (a >= 0) {
187
+ begin[r] = lb[a];
188
+ end[r] = le[a];
189
+ }
190
+ else {
191
+ const b = ri[r];
192
+ begin[r] = rb[b];
193
+ end[r] = re[b];
194
+ }
195
+ }
196
+ if (lk.kind === 'timeRange') {
197
+ return TimeRangeKeyColumn.fromValidatedSubarray(begin, end, len);
198
+ }
199
+ const { labels, labelKind } = gatherLabels(lk, rk, li, ri);
200
+ return IntervalKeyColumn.fromValidatedSubarray(begin, end, labels, labelKind, len);
201
+ }
202
+ function gatherLabels(lk, rk, li, ri) {
203
+ const len = li.length;
204
+ let fromLeft = 0;
205
+ for (let r = 0; r < len; r += 1)
206
+ if (li[r] >= 0)
207
+ fromLeft += 1;
208
+ if (fromLeft === len) {
209
+ return {
210
+ labels: lk.labels.sliceByIndices(li),
211
+ labelKind: lk.labelKind,
212
+ };
213
+ }
214
+ if (fromLeft === 0) {
215
+ return {
216
+ labels: rk.labels.sliceByIndices(ri),
217
+ labelKind: rk.labelKind,
218
+ };
219
+ }
220
+ // Mixed-type labels never match (`compareIntervalValues` orders numbers
221
+ // before strings), so this is an outer join keeping one-sided rows from
222
+ // both. The event path rejected the same output when re-columnarising it.
223
+ if (lk.labelKind !== rk.labelKind) {
224
+ throw new RangeError(`join: cannot combine interval keys with ${lk.labelKind} labels and interval keys with ${rk.labelKind} labels — an interval-keyed series must use one label type throughout`);
225
+ }
226
+ if (lk.labelKind === 'number') {
227
+ const lv = lk.labels._values;
228
+ const rv = rk.labels._values;
229
+ const out = new Float64Array(len);
230
+ for (let r = 0; r < len; r += 1) {
231
+ const a = li[r];
232
+ out[r] = a >= 0 ? lv[a] : rv[ri[r]];
233
+ }
234
+ // Numeric interval labels are validated finite at construction.
235
+ return {
236
+ labels: new Float64Column(out, len, undefined, true),
237
+ labelKind: lk.labelKind,
238
+ };
239
+ }
240
+ const ll = lk.labels;
241
+ const rl = rk.labels;
242
+ const out = new Array(len);
243
+ for (let r = 0; r < len; r += 1) {
244
+ const a = li[r];
245
+ out[r] = (a >= 0 ? ll.read(a) : rl.read(ri[r]));
246
+ }
247
+ return {
248
+ labels: stringColumnFromArray(out, { forceDict: true }),
249
+ labelKind: lk.labelKind,
250
+ };
251
+ }
252
+ //# sourceMappingURL=join.js.map
@@ -619,6 +619,19 @@ export declare class TimeSeries<S extends SeriesSchema> {
619
619
  * Value columns from both series are included in the result and are optional because joined rows
620
620
  * may have missing values on either side. If both series use the same payload column name,
621
621
  * you can either rename one side before joining or use `{ onConflict: "prefix", prefixes: [...] }`.
622
+ *
623
+ * Keys pair **one to one** in order: a key that appears `a` times on the left and `b` times on
624
+ * the right gives `min(a, b)` matched rows plus `|a − b|` one-sided rows, not `a × b`. A matched
625
+ * row carries the left key.
626
+ *
627
+ * **Cost.** Column-native: one walk over the two key columns, then each output column is either
628
+ * the source column itself (no copy) or one gather. A side's columns pass through untouched
629
+ * whenever every one of its rows lands in the output once, in order, with nothing interleaved —
630
+ * always the left side of a `"left"` join and the right side of a `"right"` join, and both sides
631
+ * when the keys match one for one (`joinMany` over a shared grid). So the cost of a left join
632
+ * scales with the **other** side's width; to bring across only the columns you read, narrow it
633
+ * first — `select` and `rename` are themselves zero-copy:
634
+ * `bars.join(spy.select("close").rename({ close: "spy" }), { type: "left" })`.
622
635
  */
623
636
  join<Other extends SeriesSchema>(other: TimeSeries<Other>, options?: ErrorJoinOptions): TimeSeries<JoinSchema<S, Other>>;
624
637
  join<Other extends SeriesSchema, const Prefixes extends readonly [string, string]>(other: TimeSeries<Other>, options: PrefixJoinOptions<Prefixes>): TimeSeries<PrefixedJoinSchema<S, Other, Prefixes>>;
@@ -12,6 +12,7 @@ import { diffRateOp } from './operators/diff-rate.js';
12
12
  import { fillOp } from './operators/fill.js';
13
13
  import { mapOp } from './operators/map.js';
14
14
  import { shiftOp } from './operators/shift.js';
15
+ import { joinOp } from './operators/join.js';
15
16
  import { collapseOp } from './operators/collapse.js';
16
17
  import { assertColumnValuesMatchKind, columnFromValuesByKind, } from './operators/column-builders.js';
17
18
  import { computeByColumn } from './by-column.js';
@@ -1499,46 +1500,10 @@ export class TimeSeries {
1499
1500
  .slice(1)
1500
1501
  .map((column) => ({ ...column, required: false })),
1501
1502
  ]);
1502
- const joinedEvents = [];
1503
- let leftIndex = 0;
1504
- let rightIndex = 0;
1505
- while (leftIndex < left.events.length || rightIndex < right.events.length) {
1506
- const leftEvent = left.events[leftIndex];
1507
- const rightEvent = right.events[rightIndex];
1508
- if (leftEvent && !rightEvent) {
1509
- if (joinType === 'left' || joinType === 'outer') {
1510
- joinedEvents.push(leftEvent.merge({}));
1511
- }
1512
- leftIndex += 1;
1513
- continue;
1514
- }
1515
- if (rightEvent && !leftEvent) {
1516
- if (joinType === 'right' || joinType === 'outer') {
1517
- joinedEvents.push(rightEvent.merge({}));
1518
- }
1519
- rightIndex += 1;
1520
- continue;
1521
- }
1522
- const comparison = leftEvent.key().compare(rightEvent.key());
1523
- if (comparison === 0) {
1524
- joinedEvents.push(leftEvent.merge(rightEvent.data()));
1525
- leftIndex += 1;
1526
- rightIndex += 1;
1527
- }
1528
- else if (comparison < 0) {
1529
- if (joinType === 'left' || joinType === 'outer') {
1530
- joinedEvents.push(leftEvent.merge({}));
1531
- }
1532
- leftIndex += 1;
1533
- }
1534
- else {
1535
- if (joinType === 'right' || joinType === 'outer') {
1536
- joinedEvents.push(rightEvent.merge({}));
1537
- }
1538
- rightIndex += 1;
1539
- }
1540
- }
1541
- return TimeSeries.#fromTrustedEvents(left.name, resultSchema, joinedEvents);
1503
+ // Column-native: merge-walk the key buffers, then pass each column
1504
+ // through or gather it. See `joinOp`.
1505
+ const store = joinOp(left.#store.store, right.#store.store, joinType, resultSchema);
1506
+ return TimeSeries.#fromTrustedStore(left.name, resultSchema, store);
1542
1507
  }
1543
1508
  /**
1544
1509
  * Example: `series.align(Sequence.every("1m"))`.
@@ -357,12 +357,12 @@ function validateCachedEvent(rowIndex, cachedEvent, store, schemaValueNames) {
357
357
  // kind-aware equality.
358
358
  // (b) when the field is **absent** from `cachedData`, the
359
359
  // column at that row must read as `undefined` — i.e. the
360
- // event genuinely doesn't carry that column. This is the
361
- // outer-join shape: `series.join(other, { type: 'outer' })`
362
- // produces events whose data omits the other side's columns
363
- // for rows that had no match. Pre-2a TimeSeries treated this
364
- // as a row-API concern (the strict missing-field check
365
- // pre-dated outer-join via the columnar substrate); the
360
+ // event genuinely doesn't carry that column. This shape was
361
+ // admitted for the event-walking outer `join`, whose unmatched
362
+ // rows omitted the other side's columns. `join` is column-native
363
+ // now ([PND-JOINCOL]) and no longer produces it, but a caller-
364
+ // supplied cache can, so the relaxation stands. Pre-2a
365
+ // TimeSeries treated this as a row-API concern; the
366
366
  // relaxation here keeps the original misalignment-detection
367
367
  // property — a cached event whose data is missing a field
368
368
  // for which the column DOES read a defined value still
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
4
4
  "description": "Typed time series for TypeScript: schema-driven TimeSeries + streaming LiveSeries with aggregate, rolling, align, fill, partitionBy and typed columns",
5
5
  "keywords": [
6
6
  "time-series",