pond-ts 0.51.0 → 0.53.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,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.51.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.53.0...HEAD
12
+ [0.53.0]: https://github.com/pond-ts/pond/compare/v0.52.0...v0.53.0
13
+ [0.52.0]: https://github.com/pond-ts/pond/compare/v0.51.0...v0.52.0
12
14
  [0.51.0]: https://github.com/pond-ts/pond/compare/v0.50.0...v0.51.0
13
15
  [0.50.0]: https://github.com/pond-ts/pond/compare/v0.49.0...v0.50.0
14
16
  [0.49.0]: https://github.com/pond-ts/pond/compare/v0.48.1...v0.49.0
@@ -51,6 +53,164 @@ and type-level changes; patch bumps are strictly additive.
51
53
 
52
54
  ## [Unreleased]
53
55
 
56
+ ## [0.53.0] — 2026-07-25
57
+
58
+ ### Changed
59
+
60
+ - **fit (breaking):** **Power bins and zones now use pond's canonical bin
61
+ edges**, so they feed `@pond-ts/charts` with no mapping step:
62
+
63
+ ```tsx
64
+ <BarChart bins={power.distribution} column="seconds" />
65
+ <BarChart bins={power.zones} column="seconds" orientation="horizontal" ordinal />
66
+ ```
67
+
68
+ Each type previously spoke its own dialect for the same concept —
69
+ `PowerBin.wattsFrom` (with **no upper edge at all**), `ZoneTime.lo`/`hi`, and
70
+ `PowerZone.minWatts`/`maxWatts` — while core's `byColumn` and charts' `BinRecord`
71
+ both use `{ start, end, …aggregates }`. Every caller had to hand-map before
72
+ drawing, even though the internals already computed the canonical shape and
73
+ discarded it.
74
+
75
+ **Migration** (pre-1.0, so the old names are gone rather than deprecated):
76
+
77
+ | Was | Now |
78
+ | --------------------- | -------------------------------------- |
79
+ | `PowerBin.wattsFrom` | `PowerBin.start` (+ new `end`) |
80
+ | `ZoneTime.lo` / `.hi` | `ZoneTime.start` / `.end`, `openEnded` |
81
+ | `PowerZone.minWatts` | `PowerZone.start` |
82
+ | `PowerZone.maxWatts` | `PowerZone.end`, `openEnded` |
83
+
84
+ Only **`PowerZone.maxWatts`** — a zone's upper edge — is affected. The
85
+ identically-named `PowerSummary.maxWatts` and the per-lap / per-section peak
86
+ power are a different concept and are unchanged.
87
+
88
+ `end` is now **always finite and always `> start`** — the guarantee core
89
+ enforces (`byColumn` throws on a zero-width bin) and charts need (an infinite
90
+ edge blows up an axis domain). The open-ended top band, which previously
91
+ carried only `Infinity`, gets a **drawable stand-in** edge: wide enough to
92
+ cover the highest value observed, and at least as wide as the band below it.
93
+ Treat it as a drawing bound rather than data, and test for the band with the
94
+ new **`openEnded`** flag rather than comparing an edge against `Infinity`
95
+ (`openEnded` is now also strictly positional — only the final band can carry
96
+ it). Rounding zone edges to whole watts no longer collapses bands at very low
97
+ FTPs.
98
+
99
+ ### Added
100
+
101
+ - **charts:** **Duration (elapsed) x axis** — `<ChartContainer origin>` labels
102
+ the shared x axis as offsets from a zero point instead of absolute values, so
103
+ a workout / lab run / load test reads `00:00 00:05 00:10` rather than
104
+ `10:35 10:40 10:45`:
105
+
106
+ ```tsx
107
+ <ChartContainer width={620} origin="data">
108
+
109
+ <XAxis label="Elapsed" />
110
+ </ChartContainer>
111
+ ```
112
+
113
+ `'data'` zeroes at the start of the data (and stays there as you pan); a
114
+ **number** sets an explicit zero point — a gun, a trigger, a lap — with ticks
115
+ before it reading negative (`-00:05`). Ticks are placed at round durations
116
+ **measured from the origin** (a ride starting at 10:33:17 ticks 10:33:17,
117
+ 10:38:17, …), off a clock ladder (…15s, 30s, 1m, 2m, 5m, …, 12h, then whole
118
+ days) rather than the 1-2-5 ladder — the part a formatter alone can't do.
119
+ Labels pick their shape from the step and the axis's magnitude
120
+ (`00:00.500` · `00:15` · `01:01:30` · `1d 12:00` · `5d`), gridlines follow the
121
+ same ticks, and the cursor / marker pills read one grain finer (`00:05:12`).
122
+
123
+ It's a **labelling** mode, not a data transform: `range`, `<Marker at>`,
124
+ `onRegionSelect`, `trackerPosition` all stay in absolute axis units. The same
125
+ prop works on a **value** x axis (distance travelled, not distance recorded).
126
+ An explicit format still wins — on a time axis a d3 _time_ specifier can only
127
+ describe an instant, so it labels the wall clock, which is the lever for
128
+ stacking a wall-clock strip under a duration strip on one shared tick set; on
129
+ a value axis a number specifier formats the offset. Ignored on a category
130
+ axis; on a trading calendar the durations are wall-clock, so ticks spanning a
131
+ collapsed session gap sit unevenly.
132
+
133
+ - **fit:** `computePower` takes an options object — **`{ binWatts }`** sets the
134
+ width of the `distribution` buckets (default `1`, unchanged). 1 W bins draw as
135
+ hairlines, so pass the width you intend to render rather than re-bucketing the
136
+ output yourself. It throws `RangeError` on a non-positive or non-finite
137
+ `binWatts`. New exported type `ComputePowerOptions`, also accepted by the
138
+ activity façade: `Activity.power(ftp, options)` and
139
+ `ProfiledActivity.power(options)`.
140
+
141
+ ## [0.52.0] — 2026-07-23
142
+
143
+ ### Changed
144
+
145
+ - **core / financial:** **Market-scale studies are now typed-array fast**
146
+ (the "SMA/EMA at 1M bars costs hundreds of ms" report). Three cuts along
147
+ the same path, all behaviour-preserving (identical values, warm-ups,
148
+ missing-cell semantics, and rejection errors; every fast path falls back
149
+ to the original sweep when it doesn't apply):
150
+ - **`smooth('ema')` columnar fast path** — on a packed numeric source
151
+ column the EMA recurrence runs straight off the typed buffer into a
152
+ typed result column via trusted construction (key + untouched columns
153
+ pass through zero-copy), replacing the per-row Event/tuple rebuild +
154
+ full-series intake re-pack. 1M rows: **530 ms → 4.4 ms (~120×)**.
155
+ - **`rolling({ count })` numeric fast path** — an all-built-in numeric
156
+ mapping over packed sources feeds the shared incremental reducer states
157
+ directly from the typed buffers and writes snapshots into typed columns
158
+ (no per-row snapshot arrays, no boxed accumulators, no post-pass
159
+ assert/re-pack). 1M rows, `avg`: **135 ms → 32 ms (~4×)**.
160
+ - **financial kernel reads columns, not events** — `rollingColumns` /
161
+ `columnValues` now read study inputs/outputs off the public column API
162
+ instead of materializing `series.events` (an Event + data object per
163
+ row, ~400 ms of pure overhead at 1M rows).
164
+ - End-to-end at 1M bars: `ema()` **603 ms → 2.5 ms (~240×)**, `sma()`
165
+ **569 ms → 56 ms (~10×)**, `bollinger()` **748 ms → 162 ms (~4.6×)**.
166
+ Durable benchmarks: `packages/core/scripts/perf-smooth-ema.mjs`,
167
+ `packages/financial/scripts/perf-studies.mjs`.
168
+
169
+ ### Added
170
+
171
+ - **core:** **`TimeSeries.fromArrow(table, options?)` — ingest a decoded Apache
172
+ Arrow `Table`.** pond stays zero-dependency: bring your own Arrow
173
+ (`tableFromIPC(...)`) and hand the `Table` in; the input is duck-typed against
174
+ a small structural surface (`ArrowTableLike` / `ArrowVectorLike` / …, all
175
+ exported). Ingest is the zero-copy path — every `Float64` column's backing
176
+ `Float64Array` is adopted as-is (`Float32`/int columns convert; int64 value
177
+ columns recombine BigInt-free), and the schema is derived from the Arrow
178
+ fields. The time key is converted **BigInt-free**: Arrow's idiomatic int64
179
+ timestamps are recombined from their two int32 halves rather than
180
+ `Number(bigint)` per row — measured **~11× faster** on the time column (0.6ms
181
+ vs 6.8ms at 500k rows; `scripts/perf-from-arrow.mjs`). Options: `time` (key
182
+ column, default the `'time'` field), `timeUnit` (default read from the Arrow
183
+ Arrow type family — a `Timestamp`'s raw-unit int64 is scaled by its
184
+ `TimeUnit`; `Date32`/`Date64` arrive already normalized to epoch-ms and pass
185
+ through; overridable), `columns` (subset, in order), `name`, `sort`. Numeric
186
+ **and string** columns are supported — string columns (Arrow
187
+ `Utf8`) become dict-encoded `StringColumn`s; any other Arrow type
188
+ (list/struct) throws, naming it. A null time key throws; numeric nulls map to
189
+ `NaN` and string nulls to missing.
190
+ - **core:** **`TimeSeries.fromColumns` / `ValueSeries.fromColumns` now accept
191
+ `string` value columns** (previously numeric-only), packed to dict-encoded
192
+ `StringColumn`s (`null`/`undefined` → missing) — the shared columnar-ingress
193
+ engine now dispatches on the schema kind. Other value kinds (`boolean`,
194
+ arrays) still throw.
195
+ - **charts:** **`<ScatterChart decimate>` — dense scatter plots now decimate**
196
+ (PND-MARKDEC scatter half — the last un-decimated mark type). **Default
197
+ `true`.** When the marks are **uniform** (fixed size + colour, no data-driven
198
+ `radius`/`color`), **opaque**, and denser than the pixel grid, overlapping
199
+ marks collapse to one representative per **mark-radius cell** via a 2D
200
+ pixel-**occupancy** sweep. Scatter has no fill, so a line/bar's per-column
201
+ `[min, max]` envelope would erase interior points — the occupancy grid keeps
202
+ one mark per occupied cell instead, which is **visually lossless** for uniform
203
+ opaque marks at that density (same-cell marks overlap). Interaction (hover /
204
+ click / tracker) still reads **every source point**; the per-point selection
205
+ ring + labels are suppressed only on the decimated (dense) path. A
206
+ **translucent** fill (density-encoded — overlap _should_ build up) or a
207
+ data-driven size/colour keeps the full draw. `decimate={false}` draws every
208
+ mark; `{ threshold }` tunes the trigger. The occupancy sweep uses the affine
209
+ fast path for the per-point pixel mapping. Measured (SciChart-suite
210
+ point-update, real browser): **100k 18 → 73 fps (4×)**, and the ladder now
211
+ runs to **10M** points (previously dead by 1M). `drawScatter` now returns
212
+ `LayerDrawStats` (visible via `onDrawStats`).
213
+
54
214
  ## [0.51.0] — 2026-07-22
55
215
 
56
216
  ### Changed
@@ -1,5 +1,6 @@
1
1
  import type { Column } from '../columnar/index.js';
2
- import type { AggregateMap, AggregateOutputMap, AggregateReducer, ScalarKind, SeriesSchema } from '../schema/index.js';
2
+ import { Float64Column } from '../columnar/index.js';
3
+ import type { AggregateMap, AggregateOutputMap, AggregateReducer, RollingAlignment, ScalarKind, SeriesSchema } from '../schema/index.js';
3
4
  /**
4
5
  * Normalised column spec used by both batch and live aggregation paths.
5
6
  *
@@ -38,4 +39,32 @@ export declare function tryAggregateColumnarTimeKeyed<B extends {
38
39
  begin(): number;
39
40
  end(): number;
40
41
  }>(begins: Float64Array, getColumn: (name: string) => Column | undefined, buckets: ReadonlyArray<B>, columns: ReadonlyArray<AggregateColumnSpec>): Array<ReadonlyArray<unknown>> | null;
42
+ /**
43
+ * **Columnar fast path for count-window `rolling()`** — the N-bar window
44
+ * financial studies compose on (SMA / Bollinger / rolling stats). When every
45
+ * mapped column is a built-in reducer producing a `'number'` output from a
46
+ * **packed `Float64Column`** source, the window sweep feeds the shared
47
+ * incremental rolling states (`rollingStateFor` — the same add / remove /
48
+ * snapshot arithmetic and non-finite skip policy as the generic path) values
49
+ * read straight off the typed buffers, and writes each snapshot into a typed
50
+ * result column. That removes the generic sweep's per-row costs: the
51
+ * `snapshotWindow` result-array allocation, the boxed accumulator rows, the
52
+ * polymorphic `col.read(i)` per add/remove, and the post-pass
53
+ * `assertColumnValuesMatchKind` + re-pack over boxed values (each written
54
+ * value is finite-asserted inline instead, same rejection class + message).
55
+ *
56
+ * Returns `null` — caller takes the generic sweep — when any column doesn't
57
+ * qualify: a custom-function reducer, a non-`'number'` output kind
58
+ * (`unique` / `samples` / `keep` over a non-number source / an explicit
59
+ * `kind` override), or a non-numeric / chunked / missing source column.
60
+ * All-or-nothing per call, matching {@link tryAggregateColumnarTimeKeyed}.
61
+ *
62
+ * Window shape replicates the generic count sweep exactly: rows are the unit
63
+ * (`count` is a bar count — no equal-key grouping), `lo` / `hi` are monotonic
64
+ * in the row index for every alignment (each row enters and leaves the window
65
+ * once, amortized O(1)), a centered even `count` biases one row toward the
66
+ * leading side, and a row whose window holds fewer than `minSamples` rows
67
+ * emits missing cells without consulting the reducer states.
68
+ */
69
+ export declare function tryRollingCountColumnarNumeric(getColumn: (name: string) => Column | undefined, rowCount: number, columns: ReadonlyArray<AggregateColumnSpec>, count: number, alignment: RollingAlignment, minSamples: number): Float64Column[] | null;
41
70
  //# sourceMappingURL=aggregate-columns.d.ts.map
@@ -1,4 +1,6 @@
1
- import { resolveReducer } from '../reducers/index.js';
1
+ import { resolveReducer, rollingStateFor } from '../reducers/index.js';
2
+ import { Float64Column, bitmapByteCount, validityFromBits, } from '../columnar/index.js';
3
+ import { ValidationError } from '../core/errors.js';
2
4
  /**
3
5
  * @internal — discriminator between an `AggregateOutputSpec` (`{ from,
4
6
  * using, kind? }`) and a bare reducer string/function passed in an
@@ -159,4 +161,137 @@ export function tryAggregateColumnarTimeKeyed(begins, getColumn, buckets, column
159
161
  }
160
162
  return rows;
161
163
  }
164
+ /**
165
+ * **Columnar fast path for count-window `rolling()`** — the N-bar window
166
+ * financial studies compose on (SMA / Bollinger / rolling stats). When every
167
+ * mapped column is a built-in reducer producing a `'number'` output from a
168
+ * **packed `Float64Column`** source, the window sweep feeds the shared
169
+ * incremental rolling states (`rollingStateFor` — the same add / remove /
170
+ * snapshot arithmetic and non-finite skip policy as the generic path) values
171
+ * read straight off the typed buffers, and writes each snapshot into a typed
172
+ * result column. That removes the generic sweep's per-row costs: the
173
+ * `snapshotWindow` result-array allocation, the boxed accumulator rows, the
174
+ * polymorphic `col.read(i)` per add/remove, and the post-pass
175
+ * `assertColumnValuesMatchKind` + re-pack over boxed values (each written
176
+ * value is finite-asserted inline instead, same rejection class + message).
177
+ *
178
+ * Returns `null` — caller takes the generic sweep — when any column doesn't
179
+ * qualify: a custom-function reducer, a non-`'number'` output kind
180
+ * (`unique` / `samples` / `keep` over a non-number source / an explicit
181
+ * `kind` override), or a non-numeric / chunked / missing source column.
182
+ * All-or-nothing per call, matching {@link tryAggregateColumnarTimeKeyed}.
183
+ *
184
+ * Window shape replicates the generic count sweep exactly: rows are the unit
185
+ * (`count` is a bar count — no equal-key grouping), `lo` / `hi` are monotonic
186
+ * in the row index for every alignment (each row enters and leaves the window
187
+ * once, amortized O(1)), a centered even `count` biases one row toward the
188
+ * leading side, and a row whose window holds fewer than `minSamples` rows
189
+ * emits missing cells without consulting the reducer states.
190
+ */
191
+ export function tryRollingCountColumnarNumeric(getColumn, rowCount, columns, count, alignment, minSamples) {
192
+ const specCount = columns.length;
193
+ const sources = [];
194
+ for (const spec of columns) {
195
+ if (typeof spec.reducer !== 'string')
196
+ return null; // custom function
197
+ if (spec.kind !== 'number')
198
+ return null; // non-numeric output kind
199
+ const source = getColumn(spec.source);
200
+ if (source === undefined)
201
+ return null; // missing source
202
+ if (source.kind !== 'number' || source.storage !== 'packed') {
203
+ return null; // non-numeric / chunked numeric source
204
+ }
205
+ sources.push(source);
206
+ }
207
+ // An all-finite fully-defined source feeds its reducer state the raw
208
+ // buffer values, so the `rollingStateFor` non-finite skip wrapper is
209
+ // provably an identity there — resolve the bare built-in state instead
210
+ // and drop one call layer from every add / remove / snapshot. Any other
211
+ // source keeps the wrapped state (same values, same skip policy).
212
+ const states = columns.map((spec, c) => {
213
+ const source = sources[c];
214
+ return source.allFinite && source.validity === undefined
215
+ ? resolveReducer(spec.reducer).rollingState()
216
+ : rollingStateFor(spec.reducer);
217
+ });
218
+ const srcValues = sources.map((col) => col._values);
219
+ const srcValidity = sources.map((col) => col.validity);
220
+ const outValues = columns.map(() => new Float64Array(rowCount));
221
+ const outBits = columns.map(() => new Uint8Array(bitmapByteCount(rowCount)));
222
+ const outDefined = new Array(specCount).fill(0);
223
+ // Feed the states exactly what the generic sweep's `col.read(index)`
224
+ // yields: `undefined` for a missing cell, the raw (possibly non-finite)
225
+ // number otherwise — the non-finite skip stays inside the shared state
226
+ // wrapper so the contributor set can't drift between the two paths.
227
+ const addAt = (index) => {
228
+ for (let c = 0; c < specCount; c += 1) {
229
+ const validity = srcValidity[c];
230
+ const value = validity === undefined || validity.isDefined(index)
231
+ ? srcValues[c][index]
232
+ : undefined;
233
+ states[c].add(index, value);
234
+ }
235
+ };
236
+ const removeAt = (index) => {
237
+ for (let c = 0; c < specCount; c += 1) {
238
+ const validity = srcValidity[c];
239
+ const value = validity === undefined || validity.isDefined(index)
240
+ ? srcValues[c][index]
241
+ : undefined;
242
+ states[c].remove(index, value);
243
+ }
244
+ };
245
+ const leftSpan = Math.floor((count - 1) / 2);
246
+ const rightSpan = count - 1 - leftSpan;
247
+ let windowStart = 0;
248
+ let windowEnd = 0;
249
+ for (let index = 0; index < rowCount; index += 1) {
250
+ let lo;
251
+ let hi;
252
+ if (alignment === 'trailing') {
253
+ lo = index - count + 1 < 0 ? 0 : index - count + 1;
254
+ hi = index;
255
+ }
256
+ else if (alignment === 'leading') {
257
+ lo = index;
258
+ hi = index + count - 1 < rowCount ? index + count - 1 : rowCount - 1;
259
+ }
260
+ else {
261
+ lo = index - leftSpan < 0 ? 0 : index - leftSpan;
262
+ hi = index + rightSpan < rowCount ? index + rightSpan : rowCount - 1;
263
+ }
264
+ while (windowEnd <= hi) {
265
+ addAt(windowEnd);
266
+ windowEnd += 1;
267
+ }
268
+ while (windowStart < lo) {
269
+ removeAt(windowStart);
270
+ windowStart += 1;
271
+ }
272
+ if (windowEnd - windowStart < minSamples)
273
+ continue; // missing row
274
+ for (let c = 0; c < specCount; c += 1) {
275
+ const v = states[c].snapshot();
276
+ if (v === undefined)
277
+ continue; // missing cell (e.g. all-missing window)
278
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
279
+ // Same rejection class + message as the generic path's post-pass
280
+ // `assertColumnValuesMatchKind` (e.g. a `sum` overflow to Infinity)
281
+ // — checked at write time since there is no post-pass here.
282
+ throw new ValidationError(`rolling column '${columns[c].output}': result ${String(v)} is not a valid 'number' value`);
283
+ }
284
+ outValues[c][index] = v;
285
+ outBits[c][index >> 3] |= 1 << (index & 7);
286
+ outDefined[c] = outDefined[c] + 1;
287
+ }
288
+ }
289
+ return columns.map((_, c) => {
290
+ const validity = outDefined[c] === rowCount
291
+ ? undefined
292
+ : validityFromBits(outBits[c], rowCount);
293
+ // Every written cell was finite-asserted above → `allFinite`.
294
+ return new Float64Column(outValues[c], rowCount, validity, true);
295
+ });
296
+ }
162
297
  //# sourceMappingURL=aggregate-columns.js.map
@@ -0,0 +1,101 @@
1
+ import type { SeriesSchema } from '../../schema/index.js';
2
+ import type { RawColumns } from './ingest-columns.js';
3
+ /**
4
+ * Structural view of an Apache Arrow `Vector`. pond does **not** depend on
5
+ * `apache-arrow` — the caller brings their own Arrow (`tableFromIPC(...)`) and
6
+ * hands the decoded `Table` to {@link TimeSeries.fromArrow}. We duck-type the
7
+ * small slice of the vector surface we read: the length, the null count, and
8
+ * `toArray()` (which for a contiguous numeric column returns the backing typed
9
+ * array — the zero-copy handoff pond adopts).
10
+ */
11
+ export interface ArrowVectorLike {
12
+ readonly length: number;
13
+ /** Number of null slots; `0` unlocks the fast (validity-free) path. */
14
+ readonly nullCount: number;
15
+ /**
16
+ * Backing values. For a **single-chunk** numeric column this is the typed
17
+ * array itself (`Float64Array`, `Int32Array`, `BigInt64Array`, …), adopted
18
+ * where possible; for a string column (Arrow `Utf8`) it is a plain `Array` of
19
+ * strings. Any other shape (list/struct) is rejected by
20
+ * {@link TimeSeries.fromArrow}. A **multi-chunk** vector (a table decoded from
21
+ * a multi-record-batch IPC stream) concatenates into a fresh array here — so
22
+ * such tables are correct but pay a copy, and the zero-copy/aliasing note
23
+ * does not apply to them.
24
+ */
25
+ toArray(): unknown;
26
+ /**
27
+ * Per-slot access, used only on the (rare) null-containing numeric slow path.
28
+ * Typed to match a real Arrow `Vector.get` across column kinds (a `Utf8`
29
+ * vector returns `string`), so a genuine `apache-arrow` `Table` satisfies
30
+ * this interface structurally.
31
+ */
32
+ get(index: number): number | bigint | string | null | undefined;
33
+ }
34
+ /** Structural view of an Arrow `Field` (`schema.fields[i]`). */
35
+ export interface ArrowFieldLike {
36
+ readonly name: string;
37
+ /**
38
+ * The field's `DataType`. We read `typeId` (the Arrow `Type` enum) to detect
39
+ * a `Timestamp` (= 10), whose `toArray()` is raw-unit and so needs `unit`
40
+ * (a `TimeUnit`) to scale; every other kind is read straight off `toArray()`.
41
+ * Both are optional so a bare structural stand-in — and a real `DataType`,
42
+ * which carries far more — satisfy the shape.
43
+ */
44
+ readonly type: {
45
+ readonly typeId?: number;
46
+ readonly unit?: number;
47
+ };
48
+ }
49
+ /** Structural view of an Arrow `Schema`. */
50
+ export interface ArrowSchemaLike {
51
+ readonly fields: ReadonlyArray<ArrowFieldLike>;
52
+ }
53
+ /** Structural view of an Arrow `Table` — the input to {@link TimeSeries.fromArrow}. */
54
+ export interface ArrowTableLike {
55
+ readonly numRows: number;
56
+ readonly schema: ArrowSchemaLike;
57
+ getChild(name: string): ArrowVectorLike | null | undefined;
58
+ }
59
+ /** Options for {@link TimeSeries.fromArrow}. */
60
+ export interface FromArrowOptions {
61
+ /** Series name. Default `'arrow'`. */
62
+ name?: string;
63
+ /**
64
+ * Which Arrow column is the time key. Default: the field named `'time'`.
65
+ * Throws if neither `time` is given nor a `'time'` field exists.
66
+ */
67
+ time?: string;
68
+ /**
69
+ * Unit of the time column's raw integer values. Default: read from the Arrow
70
+ * `Timestamp` field's `TimeUnit` when present, otherwise `'millisecond'`.
71
+ * An explicit value here always wins over the field's declared unit.
72
+ */
73
+ timeUnit?: ArrowTimeUnit;
74
+ /**
75
+ * Value columns to include, in order. Default: every non-time field. Each
76
+ * must be numeric (→ `Float64Column`) or a string column (→ `StringColumn`);
77
+ * any other Arrow type (list/struct/…) throws, naming it. Pass this to select
78
+ * a supported subset out of a mixed table.
79
+ */
80
+ columns?: readonly string[];
81
+ /**
82
+ * Sort rows by time key before construction (off by default — Arrow feeds
83
+ * are usually already time-ordered). Forwarded to `fromColumns`; disables
84
+ * zero-copy adoption when set (rows are permuted into fresh buffers).
85
+ */
86
+ sort?: boolean;
87
+ }
88
+ export type ArrowTimeUnit = 'second' | 'millisecond' | 'microsecond' | 'nanosecond';
89
+ /**
90
+ * Translate an Arrow `Table` into the `{ name, schema, columns }` triple that
91
+ * `TimeSeries.fromColumns` ingests. Kept separate from the static so the
92
+ * conversion is unit-testable without the class and so the Arrow-shaped types
93
+ * live in one place. The returned columns are all `Float64Array`, so
94
+ * `fromColumns` takes its zero-copy adoption path throughout.
95
+ */
96
+ export declare function arrowToColumns(table: ArrowTableLike, options?: FromArrowOptions): {
97
+ name: string;
98
+ schema: SeriesSchema;
99
+ columns: RawColumns;
100
+ };
101
+ //# sourceMappingURL=from-arrow.d.ts.map
@@ -0,0 +1,297 @@
1
+ import { ValidationError } from '../../core/errors.js';
2
+ // Arrow's `TimeUnit` enum, keyed by its wire ordinal (unit read off a
3
+ // `Timestamp` DataType). SECOND=0, MILLISECOND=1, MICROSECOND=2, NANOSECOND=3.
4
+ const TIME_UNIT_BY_ORDINAL = {
5
+ 0: 'second',
6
+ 1: 'millisecond',
7
+ 2: 'microsecond',
8
+ 3: 'nanosecond',
9
+ };
10
+ // Multiplier taking one raw unit to milliseconds (pond's `Time` key is epoch
11
+ // ms). Nanoseconds divide by 1e6, so sub-ms precision below the double's
12
+ // integer range is discarded — expected at ms resolution.
13
+ const MS_SCALE = {
14
+ second: 1000,
15
+ millisecond: 1,
16
+ microsecond: 1 / 1000,
17
+ nanosecond: 1 / 1_000_000,
18
+ };
19
+ // Arrow `Type` enum ordinal for `Timestamp` (a stable wire-level contract).
20
+ // Only `Timestamp` needs unit scaling: its `toArray()` hands back the raw int64
21
+ // values in the declared `TimeUnit`. Arrow's `Date32`/`Date64` `toArray()`, by
22
+ // contrast, is already normalized to epoch-**ms** JS numbers by the logical
23
+ // vector view — so a `Date` key is scale-1, NOT days/ms-from-a-DateUnit. (An
24
+ // earlier version read `.unit` blind and scaled Date32 as seconds; verified
25
+ // against real `apache-arrow` in `from-arrow.arrow.test.ts`, that was wrong.)
26
+ const ARROW_TYPE_TIMESTAMP = 10;
27
+ const TWO_POW_32 = 4294967296;
28
+ /**
29
+ * Resolve the raw-unit → milliseconds multiplier for the time key. An explicit
30
+ * `timeUnit` always wins. Otherwise: a `Timestamp` (`typeId` 10, or a bare
31
+ * structural input carrying a `unit` but no `typeId` — overwhelmingly a
32
+ * timestamp) is scaled by its `TimeUnit`, since its `toArray()` is raw-unit
33
+ * int64. Everything else — `Date` (arrow already normalizes its `toArray()` to
34
+ * epoch-ms), plain int/float epoch-ms columns — is taken as milliseconds
35
+ * (scale 1).
36
+ */
37
+ function resolveMsScale(field, override) {
38
+ if (override !== undefined)
39
+ return MS_SCALE[override];
40
+ const type = field.type ?? {};
41
+ const typeId = typeof type.typeId === 'number' ? type.typeId : undefined;
42
+ const unit = typeof type.unit === 'number' ? type.unit : undefined;
43
+ if (typeId === ARROW_TYPE_TIMESTAMP ||
44
+ (typeId === undefined && unit !== undefined)) {
45
+ return MS_SCALE[TIME_UNIT_BY_ORDINAL[unit ?? 1] ?? 'millisecond'];
46
+ }
47
+ return 1;
48
+ }
49
+ /**
50
+ * Numeric typed-array constructors Arrow's `toArray()` can hand back. A
51
+ * `BigInt64Array` / `BigUint64Array` is numeric but needs the BigInt-free
52
+ * recombination; the rest are plain-number typed arrays.
53
+ */
54
+ function isBigIntArray(value) {
55
+ return value instanceof BigInt64Array || value instanceof BigUint64Array;
56
+ }
57
+ function isNumberTypedArray(value) {
58
+ return (value instanceof Float64Array ||
59
+ value instanceof Float32Array ||
60
+ value instanceof Int32Array ||
61
+ value instanceof Int16Array ||
62
+ value instanceof Int8Array ||
63
+ value instanceof Uint32Array ||
64
+ value instanceof Uint16Array ||
65
+ value instanceof Uint8Array ||
66
+ value instanceof Uint8ClampedArray);
67
+ }
68
+ // Detected once: is this host little-endian? Arrow IPC is always LE, but the
69
+ // buffer a BigInt64Array exposes is in *host* byte order, so a big-endian host
70
+ // (vanishingly rare for Node, but real) needs the two int32 halves swapped.
71
+ const HOST_LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1;
72
+ /**
73
+ * Convert an int64 column to `Float64Array` **without BigInt**. We alias the
74
+ * buffer as `Int32Array` and recombine each pair `hi * 2^32 + (lo >>> 0)` —
75
+ * exact for the ±2^53 range that covers every realistic epoch timestamp
76
+ * (ms ≈ 1.7e12, µs ≈ 1.7e15). This is the ~30ms `Number(bigint)` ×N cost the
77
+ * fromArrow note called out, reclaimed: the per-element work is two array reads
78
+ * and a multiply-add, no BigInt boxing.
79
+ *
80
+ * Signedness follows the source: `BigUint64Array`'s high word is read unsigned
81
+ * (so a `Uint64` ≥ 2^63 stays positive rather than flipping negative);
82
+ * `BigInt64Array`'s stays signed (so pre-epoch / negative values recombine
83
+ * correctly). Half order follows {@link HOST_LITTLE_ENDIAN}. Values beyond
84
+ * ±2^53 (a huge id/count, or a nanosecond stamp) round to the nearest double —
85
+ * inherent to `Float64Column` storage, same as `Number(bigint)` would give.
86
+ */
87
+ function int64ToFloat64(source, scale) {
88
+ const count = source.length;
89
+ const out = new Float64Array(count);
90
+ const halves = new Int32Array(source.buffer, source.byteOffset, count * 2);
91
+ const unsigned = source instanceof BigUint64Array;
92
+ const loOff = HOST_LITTLE_ENDIAN ? 0 : 1;
93
+ const hiOff = HOST_LITTLE_ENDIAN ? 1 : 0;
94
+ if (scale === 1) {
95
+ for (let j = 0; j < count; j += 1) {
96
+ const lo = halves[j * 2 + loOff] >>> 0;
97
+ const hiRaw = halves[j * 2 + hiOff];
98
+ const hi = unsigned ? hiRaw >>> 0 : hiRaw;
99
+ out[j] = hi * TWO_POW_32 + lo;
100
+ }
101
+ }
102
+ else {
103
+ for (let j = 0; j < count; j += 1) {
104
+ const lo = halves[j * 2 + loOff] >>> 0;
105
+ const hiRaw = halves[j * 2 + hiOff];
106
+ const hi = unsigned ? hiRaw >>> 0 : hiRaw;
107
+ out[j] = (hi * TWO_POW_32 + lo) * scale;
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+ /**
113
+ * Read a numeric column carrying nulls, mapping null → `NaN` (missing). This is
114
+ * the slow path: per-element `get()`, and for an int64 column also the
115
+ * `Number(bigint)` boxing the two-int32 recombination avoids — the BigInt-free
116
+ * fast path is null-free-only. Acceptable given how rare a nulled int64 column
117
+ * is; a dense numeric column never lands here.
118
+ */
119
+ function numericWithNulls(vector) {
120
+ const count = vector.length;
121
+ const out = new Float64Array(count);
122
+ for (let j = 0; j < count; j += 1) {
123
+ const v = vector.get(j);
124
+ out[j] = v == null ? NaN : Number(v);
125
+ }
126
+ return out;
127
+ }
128
+ /** Copy a string array's values, mapping null/undefined → missing. */
129
+ function readStrings(raw) {
130
+ const out = new Array(raw.length);
131
+ for (let j = 0; j < raw.length; j += 1) {
132
+ const v = raw[j];
133
+ out[j] = v == null ? null : v;
134
+ }
135
+ return out;
136
+ }
137
+ /** Build a `Float64Array` from a plain number array, null/undefined → `NaN`. */
138
+ function readNumbersFromArray(raw) {
139
+ const out = new Float64Array(raw.length);
140
+ for (let j = 0; j < raw.length; j += 1) {
141
+ const v = raw[j];
142
+ out[j] = v == null ? NaN : Number(v);
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * Classify a plain `Array` (what `toArray()` returns for non-typed-array
148
+ * columns) by its first defined element. Arrow hands back an `Array<string>`
149
+ * for `Utf8`/`LargeUtf8` and an `Array<number>` (epoch-ms) for `Date32`/`Date64`
150
+ * — the element type is the only reliable discriminator. All-null → `'number'`
151
+ * (an all-missing `Float64Column`); a non-string/number element → `'other'`
152
+ * (a list/struct vector), rejected by the caller.
153
+ */
154
+ function classifyArray(raw) {
155
+ for (let j = 0; j < raw.length; j += 1) {
156
+ const v = raw[j];
157
+ if (v == null)
158
+ continue;
159
+ if (typeof v === 'number')
160
+ return 'number';
161
+ if (typeof v === 'string')
162
+ return 'string';
163
+ return 'other';
164
+ }
165
+ return 'number';
166
+ }
167
+ /**
168
+ * Read one value column, inferring its pond kind from what `toArray()` hands
169
+ * back. Numeric fast paths: `Float64Array` adopted as-is (zero copy), other
170
+ * number typed arrays copy through the `Float64Array` constructor (no map fn —
171
+ * stays off V8's slow iterable path), int64 recombines BigInt-free; a numeric
172
+ * column carrying nulls takes the per-element `get()` path (null → `NaN`). A
173
+ * plain `Array` is classified by content: `Array<string>` (Arrow `Utf8`) →
174
+ * `StringColumn` downstream (dict-encoded when it pays), `Array<number>` (a
175
+ * normalized `Date` column, epoch-ms) → `Float64Column`. Anything else (a
176
+ * list/struct vector) throws, naming the column.
177
+ */
178
+ function readColumn(vector, name) {
179
+ const raw = vector.toArray();
180
+ if (isNumberTypedArray(raw)) {
181
+ if (vector.nullCount > 0)
182
+ return { kind: 'number', values: numericWithNulls(vector) };
183
+ return {
184
+ kind: 'number',
185
+ values: raw instanceof Float64Array ? raw : new Float64Array(raw),
186
+ };
187
+ }
188
+ if (isBigIntArray(raw)) {
189
+ if (vector.nullCount > 0)
190
+ return { kind: 'number', values: numericWithNulls(vector) };
191
+ return { kind: 'number', values: int64ToFloat64(raw, 1) };
192
+ }
193
+ if (Array.isArray(raw)) {
194
+ const kind = classifyArray(raw);
195
+ if (kind === 'string')
196
+ return { kind: 'string', values: readStrings(raw) };
197
+ if (kind === 'number')
198
+ return { kind: 'number', values: readNumbersFromArray(raw) };
199
+ }
200
+ throw new ValidationError(`fromArrow: value column '${name}' is neither numeric nor string — ` +
201
+ `fromArrow supports 'number' and 'string' columns; drop it or select a ` +
202
+ `supported subset via { columns: [...] }`);
203
+ }
204
+ /** Read the time column into an epoch-ms `Float64Array`, scaled by unit. */
205
+ function readTimeColumn(vector, name, scale) {
206
+ if (vector.nullCount > 0) {
207
+ throw new ValidationError(`fromArrow: time column '${name}' has ${vector.nullCount} null value(s) ` +
208
+ `— time keys must be present`);
209
+ }
210
+ const raw = vector.toArray();
211
+ if (isBigIntArray(raw))
212
+ return int64ToFloat64(raw, scale);
213
+ if (raw instanceof Float64Array) {
214
+ if (scale === 1)
215
+ return raw;
216
+ const out = new Float64Array(raw.length);
217
+ for (let j = 0; j < raw.length; j += 1)
218
+ out[j] = raw[j] * scale;
219
+ return out;
220
+ }
221
+ if (isNumberTypedArray(raw)) {
222
+ const out = new Float64Array(raw);
223
+ if (scale !== 1)
224
+ for (let j = 0; j < out.length; j += 1)
225
+ out[j] *= scale;
226
+ return out;
227
+ }
228
+ // Arrow `Date32`/`Date64` `toArray()` yields a plain `Array<number>` already
229
+ // normalized to epoch-ms (scale is 1 for a Date key; see resolveMsScale).
230
+ if (Array.isArray(raw) && classifyArray(raw) === 'number') {
231
+ const out = readNumbersFromArray(raw);
232
+ if (scale !== 1)
233
+ for (let j = 0; j < out.length; j += 1)
234
+ out[j] *= scale;
235
+ return out;
236
+ }
237
+ throw new ValidationError(`fromArrow: time column '${name}' is not a numeric or temporal Arrow column`);
238
+ }
239
+ /**
240
+ * Translate an Arrow `Table` into the `{ name, schema, columns }` triple that
241
+ * `TimeSeries.fromColumns` ingests. Kept separate from the static so the
242
+ * conversion is unit-testable without the class and so the Arrow-shaped types
243
+ * live in one place. The returned columns are all `Float64Array`, so
244
+ * `fromColumns` takes its zero-copy adoption path throughout.
245
+ */
246
+ export function arrowToColumns(table, options = {}) {
247
+ const fields = table.schema?.fields;
248
+ if (!fields || fields.length === 0) {
249
+ throw new ValidationError('fromArrow: table has no columns');
250
+ }
251
+ // Resolve the time column: explicit `time`, else a field named 'time'.
252
+ const timeName = options.time ??
253
+ (fields.some((f) => f.name === 'time') ? 'time' : undefined);
254
+ if (timeName === undefined) {
255
+ throw new ValidationError("fromArrow: no time column — pass { time: '<column>' } or include a " +
256
+ "field named 'time'");
257
+ }
258
+ const timeField = fields.find((f) => f.name === timeName);
259
+ if (timeField === undefined) {
260
+ throw new ValidationError(`fromArrow: time column '${timeName}' not found in the table schema`);
261
+ }
262
+ const timeVector = table.getChild(timeName);
263
+ if (timeVector == null) {
264
+ throw new ValidationError(`fromArrow: time column '${timeName}' has no vector`);
265
+ }
266
+ // Time unit → ms scale, gated on the Arrow type family (see resolveMsScale).
267
+ const scale = resolveMsScale(timeField, options.timeUnit);
268
+ // Value column selection: explicit `columns` (in order), else every non-time
269
+ // field. Every selected column must be numeric (checked in readValueColumn).
270
+ const valueNames = options.columns ??
271
+ fields.map((f) => f.name).filter((name) => name !== timeName);
272
+ const columns = {
273
+ [timeName]: readTimeColumn(timeVector, timeName, scale),
274
+ };
275
+ const schema = [
276
+ { name: timeName, kind: 'time' },
277
+ ];
278
+ for (const name of valueNames) {
279
+ if (name === timeName) {
280
+ throw new ValidationError(`fromArrow: column '${name}' is the time key and can't also be a ` +
281
+ `value column`);
282
+ }
283
+ const vector = table.getChild(name);
284
+ if (vector == null) {
285
+ throw new ValidationError(`fromArrow: column '${name}' not found in the table`);
286
+ }
287
+ const result = readColumn(vector, name);
288
+ columns[name] = result.values;
289
+ schema.push({ name, kind: result.kind });
290
+ }
291
+ return {
292
+ name: options.name ?? 'arrow',
293
+ schema: schema,
294
+ columns,
295
+ };
296
+ }
297
+ //# sourceMappingURL=from-arrow.js.map
@@ -1,17 +1,24 @@
1
1
  import { ColumnarStore, type ColumnSchema, type KeyColumn } from '../../columnar/index.js';
2
- /** The per-column raw input the `fromColumns` doors accept. */
3
- export type RawColumns = Record<string, ReadonlyArray<number | null | undefined> | Float64Array>;
2
+ /**
3
+ * The per-column raw input the `fromColumns` doors accept. A `'number'` column
4
+ * is a `number[]` (adopted-if-`Float64Array`); a `'string'` column is a
5
+ * `string[]` (`null`/`undefined` → missing). The kind is taken from the
6
+ * matching schema entry — the engine dispatches on it.
7
+ */
8
+ export type RawColumns = Record<string, ReadonlyArray<number | null | undefined> | Float64Array | ReadonlyArray<string | null | undefined>>;
4
9
  /**
5
10
  * The shared columnar-ingress engine behind `TimeSeries.fromColumns` and
6
11
  * `ValueSeries.fromColumns`. Both doors are the same machine — normalize the
7
12
  * key column (adopt a `Float64Array` zero-copy, convert a `number[]`),
8
13
  * optionally sort by key (stable permutation, disables adoption), enforce the
9
- * non-decreasing-key invariant, pack each `'number'` value column
10
- * (`null`/`undefined` → `NaN`, one gap rule for both input types) — and differ
11
- * only in the key column they mint (`TimeKeyColumn` vs `ValueKeyColumn`) and
12
- * the words their errors use. `op` prefixes every message (so a throw names
13
- * the door the caller went through) and `keyNoun` names the key in the
14
- * out-of-order error (`timestamps` / `axis values`).
14
+ * non-decreasing-key invariant, pack each value column by its schema kind
15
+ * (`'number'` → `Float64Column`, `null`/`undefined`/non-finite → `NaN` gap;
16
+ * `'string'` `StringColumn` via the shared dict-encode heuristic,
17
+ * `null`/`undefined` missing) and differ only in the key column they mint
18
+ * (`TimeKeyColumn` vs `ValueKeyColumn`) and the words their errors use. `op`
19
+ * prefixes every message (so a throw names the door the caller went through)
20
+ * and `keyNoun` names the key in the out-of-order error
21
+ * (`timestamps` / `axis values`).
15
22
  *
16
23
  * `makeKey` runs **before** the ordering scan, matching the original inline
17
24
  * order of checks: a non-finite key fails in the key-column constructor first,
@@ -1,16 +1,18 @@
1
- import { ColumnarStore, Float64Column, validityFromPredicate, } from '../../columnar/index.js';
1
+ import { ColumnarStore, Float64Column, stringColumnFromArray, validityFromPredicate, } from '../../columnar/index.js';
2
2
  import { ValidationError } from '../../core/errors.js';
3
3
  /**
4
4
  * The shared columnar-ingress engine behind `TimeSeries.fromColumns` and
5
5
  * `ValueSeries.fromColumns`. Both doors are the same machine — normalize the
6
6
  * key column (adopt a `Float64Array` zero-copy, convert a `number[]`),
7
7
  * optionally sort by key (stable permutation, disables adoption), enforce the
8
- * non-decreasing-key invariant, pack each `'number'` value column
9
- * (`null`/`undefined` → `NaN`, one gap rule for both input types) — and differ
10
- * only in the key column they mint (`TimeKeyColumn` vs `ValueKeyColumn`) and
11
- * the words their errors use. `op` prefixes every message (so a throw names
12
- * the door the caller went through) and `keyNoun` names the key in the
13
- * out-of-order error (`timestamps` / `axis values`).
8
+ * non-decreasing-key invariant, pack each value column by its schema kind
9
+ * (`'number'` → `Float64Column`, `null`/`undefined`/non-finite → `NaN` gap;
10
+ * `'string'` `StringColumn` via the shared dict-encode heuristic,
11
+ * `null`/`undefined` missing) and differ only in the key column they mint
12
+ * (`TimeKeyColumn` vs `ValueKeyColumn`) and the words their errors use. `op`
13
+ * prefixes every message (so a throw names the door the caller went through)
14
+ * and `keyNoun` names the key in the out-of-order error
15
+ * (`timestamps` / `axis values`).
14
16
  *
15
17
  * `makeKey` runs **before** the ordering scan, matching the original inline
16
18
  * order of checks: a non-finite key fails in the key-column constructor first,
@@ -85,12 +87,14 @@ export function ingestColumnsToStore(input) {
85
87
  `pass { sort: true } or pre-sort the columns`);
86
88
  }
87
89
  }
88
- // Value columns — packed directly (missing-aware) from the arrays.
90
+ // Value columns — packed directly (missing-aware) from the arrays,
91
+ // dispatched by the schema kind.
89
92
  const columnMap = new Map();
90
93
  for (let i = 1; i < schema.length; i += 1) {
91
94
  const def = schema[i];
92
- if (def.kind !== 'number') {
93
- throw new ValidationError(`${op}: v1 supports 'number' value columns; column '${def.name}' is '${def.kind}'`);
95
+ if (def.kind !== 'number' && def.kind !== 'string') {
96
+ throw new ValidationError(`${op}: supports 'number' and 'string' value columns; column ` +
97
+ `'${def.name}' is '${def.kind}'`);
94
98
  }
95
99
  const raw = columns[def.name];
96
100
  if (raw === undefined) {
@@ -99,6 +103,36 @@ export function ingestColumnsToStore(input) {
99
103
  if (raw.length !== count) {
100
104
  throw new ValidationError(`${op}: column '${def.name}' length ${raw.length} does not match key length ${count}`);
101
105
  }
106
+ if (def.kind === 'string') {
107
+ // String column → StringColumn (dict-encoded when it pays; see
108
+ // `stringColumnFromArray`). `null`/`undefined` are missing. When sorting,
109
+ // reorder into a fresh array through the key permutation first — strings
110
+ // are heap objects, so there's no zero-copy story to preserve anyway.
111
+ //
112
+ // The `columns` input type isn't correlated per-column with the schema
113
+ // kind (one `RawColumns` union covers both), so a numeric typed array can
114
+ // reach a `'string'` column at the type level. Reject that clear mismatch
115
+ // loudly rather than stringifying numbers — the caller crossed the schema
116
+ // wires. (A plain `number[]` handed to a string column is still trusted;
117
+ // per-cell kind-checking isn't worth the hot-path cost.)
118
+ if (ArrayBuffer.isView(raw)) {
119
+ throw new ValidationError(`${op}: string column '${def.name}' must be a string[] — got a ` +
120
+ `typed array (a numeric buffer can't back a string column)`);
121
+ }
122
+ const rawStrings = raw;
123
+ let source;
124
+ if (order !== null) {
125
+ const reordered = new Array(count);
126
+ for (let j = 0; j < count; j += 1)
127
+ reordered[j] = rawStrings[order[j]];
128
+ source = reordered;
129
+ }
130
+ else {
131
+ source = rawStrings;
132
+ }
133
+ columnMap.set(def.name, stringColumnFromArray(source));
134
+ continue;
135
+ }
102
136
  // Normalize to a Float64Array either way — adopt if already typed (the
103
137
  // fast path a protobuf / fixed-point decoder hits, zero-copy), else
104
138
  // convert (`null`/`undefined` -> `NaN`) — then apply ONE validity rule
@@ -111,30 +145,31 @@ export function ingestColumnsToStore(input) {
111
145
  // array type decoded it.
112
146
  // Manual loop, not `Float64Array.from(arr, mapFn)` — see the key-column
113
147
  // comment above; the cost applies identically here.
148
+ const numeric = raw;
114
149
  let values;
115
150
  if (order !== null) {
116
151
  // Sorting: reorder into a fresh buffer through the key permutation (no
117
152
  // zero-copy adoption — the rows are being moved). Same missing rule
118
153
  // (`null`/`undefined` → NaN) applied while remapping.
119
154
  values = new Float64Array(count);
120
- if (raw instanceof Float64Array) {
155
+ if (numeric instanceof Float64Array) {
121
156
  for (let j = 0; j < count; j += 1)
122
- values[j] = raw[order[j]];
157
+ values[j] = numeric[order[j]];
123
158
  }
124
159
  else {
125
160
  for (let j = 0; j < count; j += 1) {
126
- const v = raw[order[j]];
161
+ const v = numeric[order[j]];
127
162
  values[j] = v == null ? NaN : v;
128
163
  }
129
164
  }
130
165
  }
131
- else if (raw instanceof Float64Array) {
132
- values = raw;
166
+ else if (numeric instanceof Float64Array) {
167
+ values = numeric;
133
168
  }
134
169
  else {
135
170
  values = new Float64Array(count);
136
171
  for (let j = 0; j < count; j += 1) {
137
- const v = raw[j];
172
+ const v = numeric[j];
138
173
  values[j] = v == null ? NaN : v;
139
174
  }
140
175
  }
@@ -1,6 +1,7 @@
1
1
  import type { AlignSchema, MaterializeSchema, ArrayAggregateAppendSchema, ArrayAggregateReplaceSchema, ArrayColumnNameForSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, BaselineSchema, AggregateReducer, AggregateSchema, AppendColumn, OptionalNumberColumn, CollapseSchema, EventDataForSchema, EventForSchema, FirstColKind, IntervalKeyedSchema, JsonRowFormat, JoinManySchema, JoinSchema, JoinType, NumericColumnNameForSchema, NormalizedObjectRow, NormalizedRowForSchema, PivotByGroupSchema, PointRowForSchema, PrefixedJoinManySchema, PrefixedJoinSchema, ReduceResult, RenameMap, ValidatedAggregateMap } from '../schema/index.js';
2
2
  import type { RenameSchema, RollingAlignment, RollingSchema, ColumnValue, DedupeKeep, DiffSchema, FillMapping, FillStrategy, ScalarKind, ScalarValue, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectSchema, SeriesSchema, TimeKeyedSchema, TimeSeriesJsonInput, TimeSeriesInput, TimeRangeKeyedSchema, ValueColumnKindForName, ValueColumnNameForSchema, ValueColumnsForSchema, ValueKeyedSchema } from '../schema/index.js';
3
3
  import { type ScanStep } from './operators/scan.js';
4
+ import { type ArrowTableLike, type FromArrowOptions } from './operators/from-arrow.js';
4
5
  import { ValueSeries } from './value-series.js';
5
6
  import { type BinSpec } from './by-column.js';
6
7
  import { type WindowSpec } from './rolling-by-column.js';
@@ -134,9 +135,10 @@ export declare class TimeSeries<S extends SeriesSchema> {
134
135
  * — clamp the key column yourself before ingest; the library won't mutate data
135
136
  * for you.)
136
137
  *
137
- * **v1 scope:** a `time`-kind key + `number` value columns (the market-data /
138
- * chart wire case). Other key kinds (`interval` / `timeRange`) and non-numeric
139
- * value columns throw for now extend as consumers need.
138
+ * **Value columns:** `number` (→ `Float64Column`; `Float64Array` adopted) and
139
+ * `string` (→ `StringColumn`, dict-encoded when it pays; `null`/`undefined`
140
+ * missing). Other key kinds (`interval` / `timeRange`) and other value kinds
141
+ * (`boolean` / array) throw for now — extend as consumers need.
140
142
  *
141
143
  * @throws ValidationError on a missing column, a length mismatch, an
142
144
  * unsupported kind, or an out-of-order (decreasing) timestamp when `sort`
@@ -147,7 +149,7 @@ export declare class TimeSeries<S extends SeriesSchema> {
147
149
  static fromColumns<S extends SeriesSchema>(input: {
148
150
  name: string;
149
151
  schema: S;
150
- columns: Record<string, ReadonlyArray<number | null | undefined> | Float64Array>;
152
+ columns: Record<string, ReadonlyArray<number | null | undefined> | Float64Array | ReadonlyArray<string | null | undefined>>;
151
153
  /**
152
154
  * Sort the rows by key before construction (off by default), for a columnar
153
155
  * payload whose rows aren't guaranteed ordered — the counterpart of
@@ -156,6 +158,43 @@ export declare class TimeSeries<S extends SeriesSchema> {
156
158
  */
157
159
  sort?: boolean;
158
160
  }): TimeSeries<S>;
161
+ /**
162
+ * Example: `TimeSeries.fromArrow(tableFromIPC(bytes), { time: 'ts' })`.
163
+ *
164
+ * Build a time series from a decoded Apache Arrow `Table`. pond does **not**
165
+ * depend on `apache-arrow` — bring your own (`tableFromIPC(...)` /
166
+ * `tableFromArrays(...)`) and hand the `Table` here; the input is duck-typed
167
+ * against the small {@link ArrowTableLike} slice we read. Ingest is the
168
+ * zero-copy path: every **single-chunk** `Float64` column's backing
169
+ * `Float64Array` is adopted as-is, and the time key is converted
170
+ * **BigInt-free** — Arrow's idiomatic int64 timestamps are recombined from
171
+ * their two int32 halves rather than `Number(bigint)` per row (which costs
172
+ * ~30ms at 500k rows). A table decoded from a multi-record-batch IPC stream
173
+ * has multi-chunk columns, whose `toArray()` concatenates into a fresh buffer
174
+ * — still correct, but a copy rather than an adopt.
175
+ *
176
+ * Column handling:
177
+ * - **Time key** — named by `time` (default: a field named `'time'`).
178
+ * Timestamp/int/float/Date sources accepted and normalized to epoch ms: a
179
+ * `Timestamp`'s raw-unit int64 is scaled by its `TimeUnit`; a `Date32`/
180
+ * `Date64` already arrives as epoch-ms via Arrow's `toArray()` and passes
181
+ * through; a plain int/float is taken as ms. `timeUnit` overrides.
182
+ * - **Value columns** — every non-time field by default, or the subset named
183
+ * by `columns` (in order). Numeric columns (`Float32`/int convert to
184
+ * `Float64Array`; int64 recombines BigInt-free; nulls → `NaN`) and string
185
+ * columns (Arrow `Utf8` → `StringColumn`, dict-encoded when it pays; nulls →
186
+ * missing) are supported. Any other Arrow type (list/struct/…) throws,
187
+ * naming it. A null in the time key throws.
188
+ *
189
+ * The rows must be time-ordered (as `fromColumns` requires); pass
190
+ * `{ sort: true }` for an unordered table (disables zero-copy adoption).
191
+ *
192
+ * **Trust contract on the type parameter:** the runtime schema is derived
193
+ * from the Arrow fields. Supplying `S` (`fromArrow<MySchema>(...)`) is a
194
+ * downstream-typing convenience taken on trust — pond does not verify the
195
+ * Arrow fields match `S`, exactly as `fromEvents` trusts its schema.
196
+ */
197
+ static fromArrow<S extends SeriesSchema = SeriesSchema>(table: ArrowTableLike, options?: FromArrowOptions): TimeSeries<S>;
159
198
  /**
160
199
  * Example: `TimeSeries.fromEvents(events, { schema, name })`.
161
200
  * Builds a typed series from an array of `Event` instances. The events
@@ -1,8 +1,9 @@
1
- import { isAggregateOutputSpec, normalizeAggregateColumns, tryAggregateColumnarTimeKeyed, } from './aggregate-columns.js';
1
+ import { isAggregateOutputSpec, normalizeAggregateColumns, tryAggregateColumnarTimeKeyed, tryRollingCountColumnarNumeric, } from './aggregate-columns.js';
2
2
  import { cumulativeOp, } from './operators/cumulative.js';
3
3
  import { scanOp } from './operators/scan.js';
4
4
  import { byValueOp } from './operators/by-value.js';
5
5
  import { ingestColumnsToStore } from './operators/ingest-columns.js';
6
+ import { arrowToColumns, } from './operators/from-arrow.js';
6
7
  import { ValueSeries } from './value-series.js';
7
8
  import { diffRateOp } from './operators/diff-rate.js';
8
9
  import { fillOp } from './operators/fill.js';
@@ -19,7 +20,7 @@ import { TimeRange } from '../core/time-range.js';
19
20
  import { compareEventKeys } from '../core/temporal.js';
20
21
  import { PartitionedTimeSeries } from './partitioned-time-series.js';
21
22
  import { Sequence } from '../sequence/sequence.js';
22
- import { ColumnarStore, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnAppended, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
23
+ import { ColumnarStore, Float64Column, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, bitmapByteCount, float64ColumnFromArray, stringColumnFromArray, validityFromBits, withColumnAppended, withColumnReplaced, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
23
24
  import { ValidationError } from '../core/errors.js';
24
25
  import { SeriesStore } from '../live/series-store.js';
25
26
  import { parseDuration } from '../core/duration.js';
@@ -539,9 +540,10 @@ export class TimeSeries {
539
540
  * — clamp the key column yourself before ingest; the library won't mutate data
540
541
  * for you.)
541
542
  *
542
- * **v1 scope:** a `time`-kind key + `number` value columns (the market-data /
543
- * chart wire case). Other key kinds (`interval` / `timeRange`) and non-numeric
544
- * value columns throw for now extend as consumers need.
543
+ * **Value columns:** `number` (→ `Float64Column`; `Float64Array` adopted) and
544
+ * `string` (→ `StringColumn`, dict-encoded when it pays; `null`/`undefined`
545
+ * missing). Other key kinds (`interval` / `timeRange`) and other value kinds
546
+ * (`boolean` / array) throw for now — extend as consumers need.
545
547
  *
546
548
  * @throws ValidationError on a missing column, a length mismatch, an
547
549
  * unsupported kind, or an out-of-order (decreasing) timestamp when `sort`
@@ -572,6 +574,51 @@ export class TimeSeries {
572
574
  });
573
575
  return TimeSeries.#fromTrustedStore(name, schema, store);
574
576
  }
577
+ /**
578
+ * Example: `TimeSeries.fromArrow(tableFromIPC(bytes), { time: 'ts' })`.
579
+ *
580
+ * Build a time series from a decoded Apache Arrow `Table`. pond does **not**
581
+ * depend on `apache-arrow` — bring your own (`tableFromIPC(...)` /
582
+ * `tableFromArrays(...)`) and hand the `Table` here; the input is duck-typed
583
+ * against the small {@link ArrowTableLike} slice we read. Ingest is the
584
+ * zero-copy path: every **single-chunk** `Float64` column's backing
585
+ * `Float64Array` is adopted as-is, and the time key is converted
586
+ * **BigInt-free** — Arrow's idiomatic int64 timestamps are recombined from
587
+ * their two int32 halves rather than `Number(bigint)` per row (which costs
588
+ * ~30ms at 500k rows). A table decoded from a multi-record-batch IPC stream
589
+ * has multi-chunk columns, whose `toArray()` concatenates into a fresh buffer
590
+ * — still correct, but a copy rather than an adopt.
591
+ *
592
+ * Column handling:
593
+ * - **Time key** — named by `time` (default: a field named `'time'`).
594
+ * Timestamp/int/float/Date sources accepted and normalized to epoch ms: a
595
+ * `Timestamp`'s raw-unit int64 is scaled by its `TimeUnit`; a `Date32`/
596
+ * `Date64` already arrives as epoch-ms via Arrow's `toArray()` and passes
597
+ * through; a plain int/float is taken as ms. `timeUnit` overrides.
598
+ * - **Value columns** — every non-time field by default, or the subset named
599
+ * by `columns` (in order). Numeric columns (`Float32`/int convert to
600
+ * `Float64Array`; int64 recombines BigInt-free; nulls → `NaN`) and string
601
+ * columns (Arrow `Utf8` → `StringColumn`, dict-encoded when it pays; nulls →
602
+ * missing) are supported. Any other Arrow type (list/struct/…) throws,
603
+ * naming it. A null in the time key throws.
604
+ *
605
+ * The rows must be time-ordered (as `fromColumns` requires); pass
606
+ * `{ sort: true }` for an unordered table (disables zero-copy adoption).
607
+ *
608
+ * **Trust contract on the type parameter:** the runtime schema is derived
609
+ * from the Arrow fields. Supplying `S` (`fromArrow<MySchema>(...)`) is a
610
+ * downstream-typing convenience taken on trust — pond does not verify the
611
+ * Arrow fields match `S`, exactly as `fromEvents` trusts its schema.
612
+ */
613
+ static fromArrow(table, options = {}) {
614
+ const { name, schema, columns } = arrowToColumns(table, options);
615
+ return TimeSeries.fromColumns({
616
+ name,
617
+ schema: schema,
618
+ columns,
619
+ sort: options.sort ?? false,
620
+ });
621
+ }
575
622
  /**
576
623
  * Example: `TimeSeries.fromEvents(events, { schema, name })`.
577
624
  * Builds a typed series from an array of `Event` instances. The events
@@ -2205,6 +2252,23 @@ export class TimeSeries {
2205
2252
  // materialization, no per-row `Event`, no row re-validation/re-pack.
2206
2253
  const store = this.#store.store;
2207
2254
  const rowCount = store.length;
2255
+ // Count-window numeric fast path: an all-built-in numeric mapping over
2256
+ // packed sources builds its typed result columns in one sweep (see
2257
+ // `tryRollingCountColumnarNumeric`) — none of the generic sweep's
2258
+ // per-row snapshot arrays, boxed accumulators, or post-pass re-pack.
2259
+ // `null` (a custom reducer, non-number output, chunked source) falls
2260
+ // through to the generic sweep below.
2261
+ if (countWindow !== null) {
2262
+ const fastColumns = tryRollingCountColumnarNumeric((name) => store.columns.get(name), rowCount, columnSpecs, countWindow, alignment, minSamples);
2263
+ if (fastColumns !== null) {
2264
+ const fastOutColumns = new Map();
2265
+ for (let c = 0; c < columnSpecs.length; c += 1) {
2266
+ fastOutColumns.set(columnSpecs[c].output, fastColumns[c]);
2267
+ }
2268
+ const fastStore = ColumnarStore.fromTrustedStore(resultSchema, store.keys, fastOutColumns);
2269
+ return TimeSeries.#fromTrustedStore(this.name, resultSchema, fastStore);
2270
+ }
2271
+ }
2208
2272
  const sourceCols = columnSpecs.map((spec) => store.columns.get(spec.source));
2209
2273
  const reducerStates = columnSpecs.map((spec) => isBuiltInAggregateReducer(spec.reducer)
2210
2274
  ? createRollingReducerState(spec.reducer)
@@ -2434,11 +2498,6 @@ export class TimeSeries {
2434
2498
  const resultSchema = output === undefined
2435
2499
  ? makeSmoothSchema(this.schema, column)
2436
2500
  : makeSmoothSchema(this.schema, column, output);
2437
- const anchors = this.events.map((event) => eventAnchorTime(event.key()));
2438
- const sourceValues = this.events.map((event) => {
2439
- const raw = event.get(column);
2440
- return typeof raw === 'number' ? raw : undefined;
2441
- });
2442
2501
  if (method === 'ema') {
2443
2502
  // Rate parameter: `alpha` directly, or the financial `span` convention
2444
2503
  // (α = 2/(span+1) — a `span`-period EMA), exactly one of the two.
@@ -2488,6 +2547,63 @@ export class TimeSeries {
2488
2547
  !Number.isFinite(minSamples)) {
2489
2548
  throw new TypeError('ema smoothing requires minSamples to be a non-negative integer');
2490
2549
  }
2550
+ // Columnar fast path: on a packed numeric source column the EMA
2551
+ // recurrence runs straight off the typed buffer into a typed result
2552
+ // column — no event materialization, no per-row Event / frozen-tuple
2553
+ // allocation, and no full-series intake re-pack (the key and every
2554
+ // untouched column pass through by reference). Emitted values are the
2555
+ // exact sequence the event path emits (same reads, same arithmetic),
2556
+ // and each one is asserted finite so the intake rejection the event
2557
+ // path gets from its row re-pack is preserved. Non-packed sources
2558
+ // (chunked storage) fall back to the event path below.
2559
+ const emaSourceCol = this.#store.store.columns.get(column);
2560
+ if (emaSourceCol !== undefined &&
2561
+ emaSourceCol.kind === 'number' &&
2562
+ emaSourceCol.storage === 'packed') {
2563
+ const store = this.#store.store;
2564
+ const packed = emaSourceCol;
2565
+ const rowCount = store.length;
2566
+ const src = packed._values;
2567
+ const srcValidity = packed.validity;
2568
+ const out = new Float64Array(rowCount);
2569
+ const outBits = new Uint8Array(bitmapByteCount(rowCount));
2570
+ let outDefined = 0;
2571
+ let hasPrevious = false;
2572
+ let previous = 0;
2573
+ let seen = 0;
2574
+ for (let i = 0; i < rowCount; i += 1) {
2575
+ if (srcValidity !== undefined && !srcValidity.isDefined(i)) {
2576
+ continue; // missing source cell — emitted value is missing too
2577
+ }
2578
+ const raw = src[i];
2579
+ const smoothed = hasPrevious
2580
+ ? alpha * raw + (1 - alpha) * previous
2581
+ : raw;
2582
+ previous = smoothed;
2583
+ hasPrevious = true;
2584
+ seen += 1;
2585
+ // Mask the emitted value during the length-preserving warm-up;
2586
+ // `previous` / `seen` advanced on the real value above.
2587
+ if (seen < minSamples)
2588
+ continue;
2589
+ if (!Number.isFinite(smoothed)) {
2590
+ throw new ValidationError(`smooth column '${output ?? column}': result ${smoothed} is not a valid 'number' value`);
2591
+ }
2592
+ out[i] = smoothed;
2593
+ outBits[i >> 3] |= 1 << (i & 7);
2594
+ outDefined += 1;
2595
+ }
2596
+ const outValidity = outDefined === rowCount
2597
+ ? undefined
2598
+ : validityFromBits(outBits, rowCount);
2599
+ // Every defined cell was asserted finite above → `allFinite`.
2600
+ const outColumn = new Float64Column(out, rowCount, outValidity, true);
2601
+ const reshaped = output === undefined || output === column
2602
+ ? withColumnReplaced(store, column, outColumn)
2603
+ : withColumnAppended(store, output, outColumn);
2604
+ const kept = warmup > 0 ? withRowRange(reshaped, warmup, rowCount) : reshaped;
2605
+ return TimeSeries.#fromTrustedStore(this.name, resultSchema, kept);
2606
+ }
2491
2607
  let previous;
2492
2608
  let seen = 0;
2493
2609
  const resultRows = this.events.map((event) => {
@@ -2521,6 +2637,14 @@ export class TimeSeries {
2521
2637
  rows: keptRows,
2522
2638
  });
2523
2639
  }
2640
+ // Anchor times + source values for the window-based methods
2641
+ // (movingAverage / loess). Materialized here — below the ema branch —
2642
+ // because ema never reads either (its fast path stays event-free).
2643
+ const anchors = this.events.map((event) => eventAnchorTime(event.key()));
2644
+ const sourceValues = this.events.map((event) => {
2645
+ const raw = event.get(column);
2646
+ return typeof raw === 'number' ? raw : undefined;
2647
+ });
2524
2648
  if (method === 'loess') {
2525
2649
  if (!('span' in options)) {
2526
2650
  throw new TypeError('loess smoothing requires a span option');
@@ -57,10 +57,10 @@ export declare class ValueSeries<VS extends ValueSeriesSchema> {
57
57
  * stable sort every unordered snapshot wants (e.g. a keyed live feed that
58
58
  * delivers rows in update order, not axis order).
59
59
  *
60
- * **v1 scope:** `number` value columns, matching `TimeSeries.fromColumns`.
60
+ * **Value columns:** `number` and `string`, matching `TimeSeries.fromColumns`.
61
61
  *
62
62
  * @throws ValidationError on a non-`'value'` axis kind, a missing column, a
63
- * length mismatch, a non-`'number'` value column, or an out-of-order axis
63
+ * length mismatch, an unsupported value-column kind, or an out-of-order axis
64
64
  * when `sort` is not set. Throws RangeError on a non-finite
65
65
  * (`null`/`NaN`/`±Infinity`) axis cell — sorting can't make it valid — or
66
66
  * a duplicate column name (the axis name repeated among the value columns).
@@ -68,7 +68,7 @@ export declare class ValueSeries<VS extends ValueSeriesSchema> {
68
68
  static fromColumns<VS extends ValueSeriesSchema>(input: {
69
69
  name: string;
70
70
  schema: VS;
71
- columns: Record<string, ReadonlyArray<number | null | undefined> | Float64Array>;
71
+ columns: Record<string, ReadonlyArray<number | null | undefined> | Float64Array | ReadonlyArray<string | null | undefined>>;
72
72
  /**
73
73
  * Sort the rows by axis value before construction (off by default), for a
74
74
  * payload whose rows aren't guaranteed ordered. Stable; disables the
@@ -67,10 +67,10 @@ export class ValueSeries {
67
67
  * stable sort every unordered snapshot wants (e.g. a keyed live feed that
68
68
  * delivers rows in update order, not axis order).
69
69
  *
70
- * **v1 scope:** `number` value columns, matching `TimeSeries.fromColumns`.
70
+ * **Value columns:** `number` and `string`, matching `TimeSeries.fromColumns`.
71
71
  *
72
72
  * @throws ValidationError on a non-`'value'` axis kind, a missing column, a
73
- * length mismatch, a non-`'number'` value column, or an out-of-order axis
73
+ * length mismatch, an unsupported value-column kind, or an out-of-order axis
74
74
  * when `sort` is not set. Throws RangeError on a non-finite
75
75
  * (`null`/`NaN`/`±Infinity`) axis cell — sorting can't make it valid — or
76
76
  * a duplicate column name (the axis name repeated among the value columns).
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export { Time } from './core/time.js';
19
19
  export { TimeRange, toTimeRange } from './core/time-range.js';
20
20
  export { Sequence } from './sequence/sequence.js';
21
21
  export { TimeSeries, type KeyLike } from './batch/time-series.js';
22
+ export type { ArrowTableLike, ArrowVectorLike, ArrowFieldLike, ArrowSchemaLike, ArrowTimeUnit, FromArrowOptions, } from './batch/operators/from-arrow.js';
22
23
  export { ValueSeries } from './batch/value-series.js';
23
24
  export { top } from './reducers/index.js';
24
25
  export { ValidationError } from './core/errors.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,6 +43,7 @@
43
43
  "devDependencies": {
44
44
  "@babel/runtime": "^7.29.2",
45
45
  "@types/node": "^20.17.0",
46
+ "apache-arrow": "^21.2.0",
46
47
  "pondjs": "^0.9.0",
47
48
  "prettier": "^3.8.3",
48
49
  "typescript": "^5.6.3",