pond-ts 0.33.0 → 0.34.1

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`, and
8
8
  them all. Pre-1.0: minor bumps may include new features and type-level changes;
9
9
  patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.33.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.34.1...HEAD
12
+ [0.34.1]: https://github.com/pjm17971/pond-ts/compare/v0.34.0...v0.34.1
13
+ [0.34.0]: https://github.com/pjm17971/pond-ts/compare/v0.33.0...v0.34.0
12
14
  [0.33.0]: https://github.com/pjm17971/pond-ts/compare/v0.32.0...v0.33.0
13
15
  [0.32.0]: https://github.com/pjm17971/pond-ts/compare/v0.31.2...v0.32.0
14
16
  [0.31.2]: https://github.com/pjm17971/pond-ts/compare/v0.31.1...v0.31.2
@@ -28,6 +30,63 @@ patch bumps are strictly additive.
28
30
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
29
31
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
30
32
 
33
+ ## [0.34.1] — 2026-07-01
34
+
35
+ A `pond-ts` core patch: fixes a performance regression introduced in 0.34.0.
36
+ `@pond-ts/react`, `@pond-ts/charts`, and `@pond-ts/fit` carry no code
37
+ changes — republished in lock-step; their `^0.34.0` peer ranges already
38
+ admit this patch.
39
+
40
+ ### Fixed
41
+
42
+ - **`TimeSeries.fromColumns` — `number[]` column conversion was ~7-18x
43
+ slower than intended.** The `null`/`NaN`-gap parity fix in 0.34.0
44
+ converted `number[]` columns via `Float64Array.from(raw, mapFn)`.
45
+ Supplying a map function forces V8's generic iterable-protocol path even
46
+ for a plain array, dramatically slower than a manual loop into a
47
+ preallocated buffer at 100k-element scale. Found while pointing the
48
+ `pond-columnar-ingest` wire-format spike at the real published package
49
+ instead of a local build. Fixed with a manual `for` loop in both the
50
+ key-column and value-column conversion paths — no behavior change, same
51
+ gap semantics, all 12 `fromColumns` tests unchanged and green. Added
52
+ `scripts/perf-from-columns.mjs` as the durable regression benchmark.
53
+ Measured: 100k × 7 cols, `number[]` columns, dense — 21.5ms → 2.9ms.
54
+ (#311)
55
+
56
+ ## [0.34.0] — 2026-07-01
57
+
58
+ A `pond-ts` core release: the columnar/typed-array ingress driven by the
59
+ Tidal wire-format spike. `@pond-ts/react`, `@pond-ts/charts`, and
60
+ `@pond-ts/fit` carry no code changes — republished in lock-step (their
61
+ `pond-ts` / `@pond-ts/react` peer ranges widen to `^0.34.0`).
62
+
63
+ ### Added
64
+
65
+ - **`TimeSeries.fromColumns`** — the columnar (struct-of-arrays) ingress,
66
+ the counterpart to `fromJSON`'s row-tuple shape. Accepts either a plain
67
+ `number[]` or a `Float64Array` per column — one polymorphic door, so a
68
+ wire format only changes the *decoder*, not the ingest. `Float64Array`
69
+ columns are adopted directly (zero-copy); `number[]` columns are copied.
70
+ A `null`/`undefined` cell or a non-finite value (`NaN`/`Infinity`) is a
71
+ gap, identically across both input shapes. Enforces the same
72
+ non-decreasing key-order invariant as `fromJSON`. v1 scope: a `time`-kind
73
+ key and `number` value columns. (#310)
74
+
75
+ Measured against a wire-format spike (`tidal-app/pond-columnar-ingest`):
76
+ `fromColumns` collapses the ingest step that every non-rows path
77
+ previously paid (transpose → `fromJSON`) — 100k-point protobuf ingest
78
+ 27.7ms → 2.8ms; JSON-columnar 27.0ms → 5.2ms. In a browser, decoding
79
+ off-main in a Web Worker and transferring the resulting `Float64Array`s
80
+ back keeps `fromColumns`'s adopt path on the main thread to ~9ms,
81
+ dropping a 500k-point ingest's worst animation-frame stall from ~50ms to
82
+ ~9ms.
83
+
84
+ - **Docs** — a "Columnar ingest" section on the
85
+ [Creating series](https://pjm17971.github.io/pond-ts/docs/start-here/creating#columnar-ingest)
86
+ page covering both input shapes, the adopt-vs-copy/aliasing distinction,
87
+ missing-value and ordering semantics, and why this matters for
88
+ interactive charts; a pointer from Getting Started.
89
+
31
90
  ## [0.33.0] — 2026-06-30
32
91
 
33
92
  A `@pond-ts/charts` release: a label opt-out for annotations plus interaction
@@ -87,6 +87,46 @@ export declare class TimeSeries<S extends SeriesSchema> {
87
87
  */
88
88
  sort?: boolean;
89
89
  }): TimeSeries<S>;
90
+ /**
91
+ * Example: `TimeSeries.fromColumns({ name, schema, columns })`.
92
+ *
93
+ * The **columnar** ingress — the struct-of-arrays counterpart to
94
+ * {@link TimeSeries.fromJSON} (which takes row tuples). Each entry in
95
+ * `columns` is one column's values, keyed by schema column name and aligned by
96
+ * index; every column must have the same length. Values may be a plain
97
+ * `number[]` (e.g. `JSON.parse` of a columnar wire payload) **or** a
98
+ * `Float64Array` (e.g. protobuf packed doubles / fixed-point after descale) —
99
+ * one polymorphic door, so the wire format only changes the *decoder*, not the
100
+ * ingest. A value cell is a gap (missing) iff it's `null`/`undefined` or
101
+ * non-finite (`NaN`/`Infinity`) — identical rule regardless of which array
102
+ * type supplied it. Note this is looser than `fromJSON`, which *rejects* a
103
+ * non-finite provided number outright rather than treating it as a gap.
104
+ *
105
+ * Skips the row-tuple materialization + per-row intake a columnar caller
106
+ * otherwise pays (transpose → `fromJSON`): each column packs directly, the key
107
+ * column is built from the first schema column, and the store is assembled via
108
+ * trusted construction (`ColumnarStore.fromTrustedStore` still validates kind +
109
+ * aligned length).
110
+ *
111
+ * **`Float64Array` inputs are adopted, not copied** (zero-copy — the point of
112
+ * the fast path): the resulting series' columns alias the caller's buffers.
113
+ * Mutating an adopted buffer after construction mutates the series. Pass a
114
+ * fresh buffer (e.g. straight off a decode) if this matters; `number[]`
115
+ * columns are always copied.
116
+ *
117
+ * **v1 scope:** a `time`-kind key + `number` value columns (the market-data /
118
+ * chart wire case). Other key kinds (`interval` / `timeRange`) and non-numeric
119
+ * value columns throw for now — extend as consumers need.
120
+ *
121
+ * @throws ValidationError on a missing column, a length mismatch, an
122
+ * unsupported kind, a non-finite timestamp key, or an out-of-order
123
+ * (decreasing) timestamp — keys must be non-decreasing, same as `fromJSON`.
124
+ */
125
+ static fromColumns<S extends SeriesSchema>(input: {
126
+ name: string;
127
+ schema: S;
128
+ columns: Record<string, ReadonlyArray<number | null | undefined> | Float64Array>;
129
+ }): TimeSeries<S>;
90
130
  /**
91
131
  * Example: `TimeSeries.fromEvents(events, { schema, name })`.
92
132
  * Builds a typed series from an array of `Event` instances. The events
@@ -18,7 +18,8 @@ import { TimeRange } from '../core/time-range.js';
18
18
  import { compareEventKeys } from '../core/temporal.js';
19
19
  import { PartitionedTimeSeries } from './partitioned-time-series.js';
20
20
  import { Sequence } from '../sequence/sequence.js';
21
- import { ColumnarStore, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnAppended, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
21
+ import { ColumnarStore, Float64Column, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, validityFromPredicate, withColumnAppended, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
22
+ import { ValidationError } from '../core/errors.js';
22
23
  import { SeriesStore } from '../live/series-store.js';
23
24
  import { parseDuration } from '../core/duration.js';
24
25
  import { resolveReducer, bucketStateFor, rollingStateFor, } from '../reducers/index.js';
@@ -485,6 +486,130 @@ export class TimeSeries {
485
486
  sort: input.sort ?? false,
486
487
  });
487
488
  }
489
+ /**
490
+ * Example: `TimeSeries.fromColumns({ name, schema, columns })`.
491
+ *
492
+ * The **columnar** ingress — the struct-of-arrays counterpart to
493
+ * {@link TimeSeries.fromJSON} (which takes row tuples). Each entry in
494
+ * `columns` is one column's values, keyed by schema column name and aligned by
495
+ * index; every column must have the same length. Values may be a plain
496
+ * `number[]` (e.g. `JSON.parse` of a columnar wire payload) **or** a
497
+ * `Float64Array` (e.g. protobuf packed doubles / fixed-point after descale) —
498
+ * one polymorphic door, so the wire format only changes the *decoder*, not the
499
+ * ingest. A value cell is a gap (missing) iff it's `null`/`undefined` or
500
+ * non-finite (`NaN`/`Infinity`) — identical rule regardless of which array
501
+ * type supplied it. Note this is looser than `fromJSON`, which *rejects* a
502
+ * non-finite provided number outright rather than treating it as a gap.
503
+ *
504
+ * Skips the row-tuple materialization + per-row intake a columnar caller
505
+ * otherwise pays (transpose → `fromJSON`): each column packs directly, the key
506
+ * column is built from the first schema column, and the store is assembled via
507
+ * trusted construction (`ColumnarStore.fromTrustedStore` still validates kind +
508
+ * aligned length).
509
+ *
510
+ * **`Float64Array` inputs are adopted, not copied** (zero-copy — the point of
511
+ * the fast path): the resulting series' columns alias the caller's buffers.
512
+ * Mutating an adopted buffer after construction mutates the series. Pass a
513
+ * fresh buffer (e.g. straight off a decode) if this matters; `number[]`
514
+ * columns are always copied.
515
+ *
516
+ * **v1 scope:** a `time`-kind key + `number` value columns (the market-data /
517
+ * chart wire case). Other key kinds (`interval` / `timeRange`) and non-numeric
518
+ * value columns throw for now — extend as consumers need.
519
+ *
520
+ * @throws ValidationError on a missing column, a length mismatch, an
521
+ * unsupported kind, a non-finite timestamp key, or an out-of-order
522
+ * (decreasing) timestamp — keys must be non-decreasing, same as `fromJSON`.
523
+ */
524
+ static fromColumns(input) {
525
+ const { name, schema, columns } = input;
526
+ // Key column (schema[0]). v1: point-in-time (`time`) keys only.
527
+ const keyDef = schema[0];
528
+ if (keyDef === undefined) {
529
+ throw new ValidationError('fromColumns: schema must have at least a key column');
530
+ }
531
+ if (keyDef.kind !== 'time') {
532
+ throw new ValidationError(`fromColumns: v1 supports a 'time' key; schema[0] '${keyDef.name}' is '${keyDef.kind}'`);
533
+ }
534
+ const keyRaw = columns[keyDef.name];
535
+ if (keyRaw === undefined) {
536
+ throw new ValidationError(`fromColumns: missing key column '${keyDef.name}'`);
537
+ }
538
+ // epoch-ms buffer; TimeKeyColumn asserts all finite. A manual loop, not
539
+ // `Float64Array.from(arr, mapFn)`: supplying a map function forces V8's
540
+ // generic iterable-protocol path even for a plain array, ~15-20x slower
541
+ // than a preallocated-buffer copy at 100k-element scale — measured, not
542
+ // theoretical (see the pond-columnar-ingest spike's ingest regression).
543
+ let begin;
544
+ if (keyRaw instanceof Float64Array) {
545
+ begin = keyRaw;
546
+ }
547
+ else {
548
+ begin = new Float64Array(keyRaw.length);
549
+ for (let j = 0; j < keyRaw.length; j += 1) {
550
+ const v = keyRaw[j];
551
+ begin[j] = v == null ? NaN : Number(v);
552
+ }
553
+ }
554
+ const count = begin.length;
555
+ // Throws on any non-finite timestamp.
556
+ const keys = new TimeKeyColumn(begin, count);
557
+ // Enforce the non-decreasing-key invariant that `fromJSON`'s
558
+ // `validateAndNormalize` guarantees. Trusted construction skips row
559
+ // materialization + kind re-validation, but NOT this correctness contract:
560
+ // bisect-based operators (crop, `atTime`, range queries) rely on it, so an
561
+ // unsorted columnar input must fail loudly here rather than build a silently
562
+ // broken series. One O(N) scan over already-finite values — negligible next
563
+ // to decode.
564
+ for (let j = 1; j < count; j += 1) {
565
+ if (begin[j] < begin[j - 1]) {
566
+ throw new ValidationError(`fromColumns: key column '${keyDef.name}' is out of order at index ${j} ` +
567
+ `(${begin[j]} < ${begin[j - 1]}) — timestamps must be non-decreasing; pre-sort the columns`);
568
+ }
569
+ }
570
+ // Value columns — packed directly (missing-aware) from the arrays.
571
+ const columnMap = new Map();
572
+ for (let i = 1; i < schema.length; i += 1) {
573
+ const def = schema[i];
574
+ if (def.kind !== 'number') {
575
+ throw new ValidationError(`fromColumns: v1 supports 'number' value columns; column '${def.name}' is '${def.kind}'`);
576
+ }
577
+ const raw = columns[def.name];
578
+ if (raw === undefined) {
579
+ throw new ValidationError(`fromColumns: missing column '${def.name}'`);
580
+ }
581
+ if (raw.length !== count) {
582
+ throw new ValidationError(`fromColumns: column '${def.name}' length ${raw.length} does not match key length ${count}`);
583
+ }
584
+ // Normalize to a Float64Array either way — adopt if already typed (the
585
+ // fast path a protobuf / fixed-point decoder hits, zero-copy), else
586
+ // convert (`null`/`undefined` -> `NaN`) — then apply ONE validity rule
587
+ // to both: a cell is a gap iff it's non-finite. This must be identical
588
+ // regardless of input type: an earlier version used `float64ColumnFromArray`
589
+ // for the `number[]` branch, which treats a `NaN` *value* (as opposed to
590
+ // `null`) as defined-but-non-finite rather than missing, diverging from
591
+ // the `Float64Array` branch's `Number.isFinite` gap signal — the same
592
+ // wire value would silently mean different things depending on which
593
+ // array type decoded it.
594
+ // Manual loop, not `Float64Array.from(arr, mapFn)` — see the key-column
595
+ // comment above; the cost applies identically here.
596
+ let values;
597
+ if (raw instanceof Float64Array) {
598
+ values = raw;
599
+ }
600
+ else {
601
+ values = new Float64Array(count);
602
+ for (let j = 0; j < count; j += 1) {
603
+ const v = raw[j];
604
+ values[j] = v == null ? NaN : v;
605
+ }
606
+ }
607
+ const validity = validityFromPredicate(count, (j) => Number.isFinite(values[j]));
608
+ columnMap.set(def.name, new Float64Column(values, count, validity, validity === undefined));
609
+ }
610
+ const store = ColumnarStore.fromTrustedStore(schema, keys, columnMap);
611
+ return TimeSeries.#fromTrustedStore(name, schema, store);
612
+ }
488
613
  /**
489
614
  * Example: `TimeSeries.fromEvents(events, { schema, name })`.
490
615
  * Builds a typed series from an array of `Event` instances. The events
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {