pond-ts 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7
7
  file covers both packages. Pre-1.0: minor bumps may include new features and
8
8
  type-level changes; patch bumps are strictly additive.
9
9
 
10
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...HEAD
10
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.28.0...HEAD
11
+ [0.28.0]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...v0.28.0
11
12
  [0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
12
13
  [0.26.0]: https://github.com/pjm17971/pond-ts/compare/v0.25.0...v0.26.0
13
14
  [0.25.0]: https://github.com/pjm17971/pond-ts/compare/v0.24.0...v0.25.0
@@ -21,7 +22,29 @@ type-level changes; patch bumps are strictly additive.
21
22
 
22
23
  ## [Unreleased]
23
24
 
24
- ## [0.27.0] — 2026-06-16
25
+ ## [0.28.0] — 2026-06-17
26
+
27
+ ### Added
28
+
29
+ - **`TimeSeries.rollingByColumn(col, { radius }, mapping)` — windowed value-axis
30
+ aggregation.** The sliding-window sibling of `byColumn`: slides a centered
31
+ `±radius` window along a **non-decreasing** numeric column and reduces it at
32
+ every row, returning one record per row (positionally aligned with the
33
+ series). Where `byColumn` collapses rows into disjoint value-bins (the
34
+ value-axis analogue of `aggregate`), `rollingByColumn` is the value-axis
35
+ analogue of `rolling`. Built for windowed-percentile bands over a derived axis
36
+ (e.g. a spread band over cumulative distance). A missing/non-finite axis row is
37
+ excluded from every window and emits each reducer's empty value. O(n) two-pointer
38
+ sweep. See `docs/notes/rolling-by-column.md`.
39
+ - **`TimeSeries.withColumn(name, values)` — attach a computed numeric column.**
40
+ Appends a `Float64Array` / `(number | undefined)[]` as a new `number` column
41
+ (the schema type widens to include it), so a derived array — cumulative
42
+ distance, speed, gradient — can re-enter the pond pipeline as a real column
43
+ that `aggregate` / `byColumn` / `rollingByColumn` / `column(name)` can
44
+ reference. Existing key + value columns are shared by reference (zero-copy);
45
+ only the new column is added. `values` must match `series.length`; defined
46
+ cells are validated against the numeric intake contract (**non-finite is
47
+ rejected** — pass `undefined` for a missing cell, not `NaN`).
25
48
 
26
49
  ### Added
27
50
 
@@ -52,7 +75,7 @@ type-level changes; patch bumps are strictly additive.
52
75
  - **Behavior note — `array` columns:** an identity-comparing reducer (`keep`,
53
76
  or a custom reducer using `===` on the cell) on an `array`-kind source
54
77
  column now compares the value stored in the column, not the original object
55
- reference passed at construction. Two rows given the *same* array object
78
+ reference passed at construction. Two rows given the _same_ array object
56
79
  therefore read as distinct. Scalar columns (number / string / boolean) are
57
80
  unaffected. A non-finite or wrong-kind reducer result is still rejected with
58
81
  a `ValidationError`, exactly as the constructor's intake did.
@@ -2,9 +2,12 @@ import { type Column } from '../../columnar/index.js';
2
2
  /**
3
3
  * Build a typed {@link Column} from a per-row value array, dispatching on the
4
4
  * column's `kind`. An `undefined` cell becomes missing (its validity bit is
5
- * left unset, so `read(i)` returns `undefined`). Numeric arrays reject
6
- * non-finite values at construction (`float64ColumnFromArray`) packed
7
- * numeric columns stay NaN-free.
5
+ * left unset, so `read(i)` returns `undefined`). These builders do **not**
6
+ * enforce the intake contract: `float64ColumnFromArray` *stores* a non-finite
7
+ * number (flagging the column non-finite for the guarded reducer path) rather
8
+ * than rejecting it, and the `*FromArray` builders coerce a kind mismatch to a
9
+ * missing cell. A caller that must uphold intake (finite-or-missing, no NaN in
10
+ * a packed numeric column) calls {@link assertColumnValuesMatchKind} first.
8
11
  *
9
12
  * This is the shared form of the kind→builder dispatch that `fill`
10
13
  * (`buildFilledColumn`), `map` (its inline builder), and `collapse`
@@ -3,9 +3,12 @@ import { ValidationError } from '../../core/errors.js';
3
3
  /**
4
4
  * Build a typed {@link Column} from a per-row value array, dispatching on the
5
5
  * column's `kind`. An `undefined` cell becomes missing (its validity bit is
6
- * left unset, so `read(i)` returns `undefined`). Numeric arrays reject
7
- * non-finite values at construction (`float64ColumnFromArray`) packed
8
- * numeric columns stay NaN-free.
6
+ * left unset, so `read(i)` returns `undefined`). These builders do **not**
7
+ * enforce the intake contract: `float64ColumnFromArray` *stores* a non-finite
8
+ * number (flagging the column non-finite for the guarded reducer path) rather
9
+ * than rejecting it, and the `*FromArray` builders coerce a kind mismatch to a
10
+ * missing cell. A caller that must uphold intake (finite-or-missing, no NaN in
11
+ * a packed numeric column) calls {@link assertColumnValuesMatchKind} first.
9
12
  *
10
13
  * This is the shared form of the kind→builder dispatch that `fill`
11
14
  * (`buildFilledColumn`), `map` (its inline builder), and `collapse`
@@ -0,0 +1,36 @@
1
+ import type { ColumnSchema, ColumnarStore } from '../columnar/index.js';
2
+ import type { ColumnValue } from '../schema/index.js';
3
+ import type { AggregateColumnSpec } from './aggregate-columns.js';
4
+ /**
5
+ * Window spec for {@link TimeSeries.rollingByColumn}: a **centered** window of
6
+ * half-width `radius`, in the axis column's own units. The window for a row is
7
+ * every row whose axis value lies within `±radius` of it. See
8
+ * `docs/notes/rolling-by-column.md`.
9
+ */
10
+ export type WindowSpec = {
11
+ radius: number;
12
+ };
13
+ /** One windowed record: the mapped aggregates over the window centered at a row. */
14
+ export type WindowRecord = Record<string, ColumnValue | undefined>;
15
+ /**
16
+ * Windowed value-axis aggregation runtime — the sliding-window sibling of
17
+ * {@link computeByColumn}. For each row, reduces (via `columnSpecs`) the rows
18
+ * whose `axisColName` value lies within `±spec.radius` of that row's value,
19
+ * returning one {@link WindowRecord} per row, **positionally aligned with the
20
+ * store** (`out[i]` is the window centered at row `i`).
21
+ *
22
+ * The axis column must be **non-decreasing** — that ordering is what makes a
23
+ * sliding window meaningful (vs `byColumn`'s order-free group-by) and is what
24
+ * lets the window advance as a single O(n) two-pointer rather than a per-row
25
+ * range scan. A row whose axis value is missing / non-finite can't be placed in
26
+ * the ordering: it is excluded from every window, and its own output slot gets
27
+ * each reducer's empty snapshot (so the result stays positionally aligned). The
28
+ * reducer non-finite policy still applies to the *source* columns.
29
+ *
30
+ * Reads straight off the columnar store (`Column.read(i)`, no event
31
+ * materialization). Uses `rollingStateFor` (add/remove/snapshot) rather than the
32
+ * append-only `bucketStateFor`, because the window is a moving multiset; the two
33
+ * pointers `add` rows entering the right edge and `remove` rows leaving the left.
34
+ */
35
+ export declare function computeRollingByColumn(store: ColumnarStore<ColumnSchema>, axisColName: string, spec: WindowSpec, columnSpecs: ReadonlyArray<AggregateColumnSpec>): WindowRecord[];
36
+ //# sourceMappingURL=rolling-by-column.d.ts.map
@@ -0,0 +1,102 @@
1
+ import { rollingStateFor } from '../reducers/index.js';
2
+ /**
3
+ * Windowed value-axis aggregation runtime — the sliding-window sibling of
4
+ * {@link computeByColumn}. For each row, reduces (via `columnSpecs`) the rows
5
+ * whose `axisColName` value lies within `±spec.radius` of that row's value,
6
+ * returning one {@link WindowRecord} per row, **positionally aligned with the
7
+ * store** (`out[i]` is the window centered at row `i`).
8
+ *
9
+ * The axis column must be **non-decreasing** — that ordering is what makes a
10
+ * sliding window meaningful (vs `byColumn`'s order-free group-by) and is what
11
+ * lets the window advance as a single O(n) two-pointer rather than a per-row
12
+ * range scan. A row whose axis value is missing / non-finite can't be placed in
13
+ * the ordering: it is excluded from every window, and its own output slot gets
14
+ * each reducer's empty snapshot (so the result stays positionally aligned). The
15
+ * reducer non-finite policy still applies to the *source* columns.
16
+ *
17
+ * Reads straight off the columnar store (`Column.read(i)`, no event
18
+ * materialization). Uses `rollingStateFor` (add/remove/snapshot) rather than the
19
+ * append-only `bucketStateFor`, because the window is a moving multiset; the two
20
+ * pointers `add` rows entering the right edge and `remove` rows leaving the left.
21
+ */
22
+ export function computeRollingByColumn(store, axisColName, spec, columnSpecs) {
23
+ const axisCol = store.columns.get(axisColName);
24
+ if (axisCol === undefined) {
25
+ throw new RangeError(`rollingByColumn: unknown column '${axisColName}'`);
26
+ }
27
+ if (axisCol.kind !== 'number') {
28
+ throw new TypeError(`rollingByColumn: column '${axisColName}' must be a number column (got '${axisCol.kind}')`);
29
+ }
30
+ const { radius } = spec;
31
+ if (!Number.isFinite(radius) || radius <= 0) {
32
+ throw new RangeError('rollingByColumn: radius must be a positive finite number');
33
+ }
34
+ const sourceCols = columnSpecs.map((s) => store.columns.get(s.source));
35
+ const n = store.length;
36
+ // Compact the finite-axis rows in row order: `ax[k]` is the axis value, `idx[k]`
37
+ // the real row index. Validate non-decreasing — a sliding window over an
38
+ // unsorted axis is meaningless, and a descending step would break the
39
+ // monotonic two-pointer below (it only ever moves `lo`/`hi` to the right).
40
+ const ax = new Float64Array(n);
41
+ const idx = new Int32Array(n);
42
+ let m = 0;
43
+ for (let i = 0; i < n; i += 1) {
44
+ const av = axisCol.read(i);
45
+ if (typeof av !== 'number' || !Number.isFinite(av))
46
+ continue;
47
+ if (m > 0 && av < ax[m - 1]) {
48
+ throw new RangeError(`rollingByColumn: axis column '${axisColName}' must be non-decreasing; row ${i} (${av}) < previous (${ax[m - 1]})`);
49
+ }
50
+ ax[m] = av;
51
+ idx[m] = i;
52
+ m += 1;
53
+ }
54
+ // One shared rolling state per output column, maintained incrementally across
55
+ // the whole sweep — this is what makes it O(n) rather than O(n · window).
56
+ const states = columnSpecs.map((s) => rollingStateFor(s.reducer));
57
+ const specCount = columnSpecs.length;
58
+ const out = new Array(n);
59
+ let lo = 0;
60
+ let hi = 0; // the window currently holds compact positions [lo, hi)
61
+ for (let i = 0; i < n; i += 1) {
62
+ const av = axisCol.read(i);
63
+ if (typeof av !== 'number' || !Number.isFinite(av)) {
64
+ // No axis position → empty window. A FRESH empty snapshot per row (per
65
+ // spec), not a shared/cached value: an array-kind reducer would otherwise
66
+ // alias one `[]` across rows. Matches byColumn's empty-bin handling.
67
+ const rec = {};
68
+ for (let c = 0; c < specCount; c += 1) {
69
+ rec[columnSpecs[c].output] = rollingStateFor(columnSpecs[c].reducer).snapshot();
70
+ }
71
+ out[i] = rec;
72
+ continue;
73
+ }
74
+ const wlo = av - radius;
75
+ const whi = av + radius;
76
+ // Expand the right edge: add finite-axis rows with `ax ≤ center + radius`.
77
+ while (hi < m && ax[hi] <= whi) {
78
+ const r = idx[hi];
79
+ for (let c = 0; c < specCount; c += 1) {
80
+ states[c].add(r, sourceCols[c].read(r));
81
+ }
82
+ hi += 1;
83
+ }
84
+ // Contract the left edge: drop rows with `ax < center − radius`. The center
85
+ // row itself satisfies `wlo ≤ av ≤ whi`, so `lo` never passes it (`lo < hi`
86
+ // always holds here) and the window is non-empty.
87
+ while (lo < hi && ax[lo] < wlo) {
88
+ const r = idx[lo];
89
+ for (let c = 0; c < specCount; c += 1) {
90
+ states[c].remove(r, sourceCols[c].read(r));
91
+ }
92
+ lo += 1;
93
+ }
94
+ const rec = {};
95
+ for (let c = 0; c < specCount; c += 1) {
96
+ rec[columnSpecs[c].output] = states[c].snapshot();
97
+ }
98
+ out[i] = rec;
99
+ }
100
+ return out;
101
+ }
102
+ //# sourceMappingURL=rolling-by-column.js.map
@@ -1,6 +1,7 @@
1
- import type { AlignSchema, MaterializeSchema, ArrayAggregateAppendSchema, ArrayAggregateReplaceSchema, ArrayColumnNameForSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, BaselineSchema, AggregateReducer, AggregateSchema, CollapseSchema, EventDataForSchema, EventForSchema, FirstColKind, IntervalKeyedSchema, JsonRowFormat, JoinManySchema, JoinSchema, JoinType, NumericColumnNameForSchema, NormalizedObjectRow, NormalizedRowForSchema, PivotByGroupSchema, PointRowForSchema, PrefixedJoinManySchema, PrefixedJoinSchema, ReduceResult, RenameMap, ValidatedAggregateMap } from '../schema/index.js';
1
+ import type { AlignSchema, MaterializeSchema, ArrayAggregateAppendSchema, ArrayAggregateReplaceSchema, ArrayColumnNameForSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, BaselineSchema, AggregateReducer, AggregateSchema, AppendColumn, 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 } from '../schema/index.js';
3
3
  import { type BinSpec } from './by-column.js';
4
+ import { type WindowSpec } from './rolling-by-column.js';
4
5
  import { BoundedSequence } from '../sequence/bounded-sequence.js';
5
6
  import { type TimeZoneOptions } from '../core/calendar.js';
6
7
  import { TimeRange } from '../core/time-range.js';
@@ -551,6 +552,32 @@ export declare class TimeSeries<S extends SeriesSchema> {
551
552
  start: number;
552
553
  end: number;
553
554
  } & ReduceResult<S, Mapping>>;
555
+ /**
556
+ * Example:
557
+ * `series.rollingByColumn('cumDist', { radius: 120 }, { lo: { from: 'speed', using: 'p5' }, hi: { from: 'speed', using: 'p95' } })`.
558
+ *
559
+ * **Windowed value-axis aggregation — the sliding-window sibling of
560
+ * `byColumn`.** Where `byColumn` collapses rows into disjoint value-bins (the
561
+ * value-axis analogue of `aggregate`), `rollingByColumn` slides a **centered
562
+ * window** along a value axis and reduces the window at every row (the
563
+ * value-axis analogue of `rolling`). For each row it reduces the rows whose
564
+ * `col` value lies within `±radius` of that row's value, and returns **one
565
+ * record per row, positionally aligned with the series** (`out[i]` is the
566
+ * window centered at row `i`) — *not* a `TimeSeries`, and with no `start`/`end`
567
+ * range (the caller already has the axis column to zip against).
568
+ *
569
+ * `col` must be a **non-decreasing** numeric column — the ordering is what
570
+ * makes a sliding window meaningful (vs `byColumn`'s order-free group-by) and
571
+ * is enforced (a descending step throws). A row whose `col` value is missing /
572
+ * non-finite is excluded from every window and its own slot gets each
573
+ * reducer's empty snapshot, so the result stays positionally aligned. The
574
+ * reducer non-finite policy still applies to the *source* columns.
575
+ *
576
+ * The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
577
+ * radius`); a single O(n) two-pointer sweep maintains the window. See
578
+ * `docs/notes/rolling-by-column.md`.
579
+ */
580
+ rollingByColumn<const Mapping extends ValidatedAggregateMap<S, Mapping>>(col: NumericColumnNameForSchema<S>, spec: WindowSpec, mapping: Mapping): Array<ReduceResult<S, Mapping>>;
554
581
  /**
555
582
  * Example: `series.groupBy("host")`.
556
583
  * Partitions the series into groups keyed by the distinct values of a payload column.
@@ -1112,6 +1139,31 @@ export declare class TimeSeries<S extends SeriesSchema> {
1112
1139
  select<const Keys extends readonly (keyof EventDataForSchema<S>)[]>(...keys: Keys): TimeSeries<SelectSchema<S, Keys[number] & string>>;
1113
1140
  /** Example: `series.rename({ cpu: "usage" })`. Returns a new series with payload field names renamed according to the supplied mapping. */
1114
1141
  rename<const Mapping extends RenameMap<EventDataForSchema<S>>>(mapping: Mapping): TimeSeries<RenameSchema<S, Mapping>>;
1142
+ /**
1143
+ * Example:
1144
+ * `series.withColumn('cumDist', cumulativeDistances)` (a `Float64Array`).
1145
+ *
1146
+ * **Attach a computed numeric column.** Returns a new series with `values`
1147
+ * appended as a new `number` column named `name`, so downstream pond ops
1148
+ * (`aggregate`, `byColumn`, `rollingByColumn`, `column(name)`) can reference
1149
+ * it. The existing key + value columns are shared by reference (zero-copy);
1150
+ * only the new column is added. This is the seam that lets a derived array
1151
+ * (cumulative distance, speed, gradient) re-enter the pond pipeline as a real
1152
+ * column instead of staying a side-channel.
1153
+ *
1154
+ * `values` is a `Float64Array` (dense) or a `(number | undefined)[]` (where
1155
+ * `undefined` marks a missing cell), and must have exactly `series.length`
1156
+ * entries. Defined values are validated against the numeric intake contract —
1157
+ * **non-finite (`NaN` / `±Infinity`) is rejected** (a `ValidationError`,
1158
+ * matching construction), so packed numeric columns stay NaN-free; pass
1159
+ * `undefined` for a missing cell, never `NaN`. `name` must not collide with an
1160
+ * existing column.
1161
+ *
1162
+ * This is the *validated* attach. A trusted bulk-construction path
1163
+ * (`fromTrustedColumns`, skipping the finite scan) is a deferred sibling for
1164
+ * when a perf-critical consumer earns it.
1165
+ */
1166
+ withColumn<const Name extends string>(name: Name, values: ReadonlyArray<number | undefined> | Float64Array): TimeSeries<AppendColumn<S, Name, 'number'>>;
1115
1167
  /** Example: `series.collapse(["in", "out"], "avg", fn)`. Collapses selected payload fields into a single derived field across each event in the series. */
1116
1168
  collapse<const Keys extends readonly (keyof EventDataForSchema<S>)[], Name extends string, R extends ScalarValue>(keys: Keys, output: Name, reducer: (values: Pick<EventDataForSchema<S>, Keys[number]>) => R): TimeSeries<CollapseSchema<S, Keys[number] & string, Name, R>>;
1117
1169
  collapse<const Keys extends readonly (keyof EventDataForSchema<S>)[], Name extends string, R extends ScalarValue>(keys: Keys, output: Name, reducer: (values: Pick<EventDataForSchema<S>, Keys[number]>) => R, options: {
@@ -7,6 +7,7 @@ import { shiftOp } from './operators/shift.js';
7
7
  import { collapseOp } from './operators/collapse.js';
8
8
  import { assertColumnValuesMatchKind, columnFromValuesByKind, } from './operators/column-builders.js';
9
9
  import { computeByColumn } from './by-column.js';
10
+ import { computeRollingByColumn, } from './rolling-by-column.js';
10
11
  import { BoundedSequence } from '../sequence/bounded-sequence.js';
11
12
  import { Interval } from '../core/interval.js';
12
13
  import { Time } from '../core/time.js';
@@ -14,7 +15,7 @@ import { TimeRange } from '../core/time-range.js';
14
15
  import { compareEventKeys } from '../core/temporal.js';
15
16
  import { PartitionedTimeSeries } from './partitioned-time-series.js';
16
17
  import { Sequence } from '../sequence/sequence.js';
17
- import { ColumnarStore, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
18
+ import { ColumnarStore, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnAppended, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
18
19
  import { SeriesStore } from '../live/series-store.js';
19
20
  import { parseDuration } from '../core/duration.js';
20
21
  import { resolveReducer, bucketStateFor, rollingStateFor, } from '../reducers/index.js';
@@ -1403,6 +1404,35 @@ export class TimeSeries {
1403
1404
  const columnSpecs = normalizeAggregateColumns(this.schema, mapping);
1404
1405
  return computeByColumn(this.#store.store, col, spec, columnSpecs);
1405
1406
  }
1407
+ /**
1408
+ * Example:
1409
+ * `series.rollingByColumn('cumDist', { radius: 120 }, { lo: { from: 'speed', using: 'p5' }, hi: { from: 'speed', using: 'p95' } })`.
1410
+ *
1411
+ * **Windowed value-axis aggregation — the sliding-window sibling of
1412
+ * `byColumn`.** Where `byColumn` collapses rows into disjoint value-bins (the
1413
+ * value-axis analogue of `aggregate`), `rollingByColumn` slides a **centered
1414
+ * window** along a value axis and reduces the window at every row (the
1415
+ * value-axis analogue of `rolling`). For each row it reduces the rows whose
1416
+ * `col` value lies within `±radius` of that row's value, and returns **one
1417
+ * record per row, positionally aligned with the series** (`out[i]` is the
1418
+ * window centered at row `i`) — *not* a `TimeSeries`, and with no `start`/`end`
1419
+ * range (the caller already has the axis column to zip against).
1420
+ *
1421
+ * `col` must be a **non-decreasing** numeric column — the ordering is what
1422
+ * makes a sliding window meaningful (vs `byColumn`'s order-free group-by) and
1423
+ * is enforced (a descending step throws). A row whose `col` value is missing /
1424
+ * non-finite is excluded from every window and its own slot gets each
1425
+ * reducer's empty snapshot, so the result stays positionally aligned. The
1426
+ * reducer non-finite policy still applies to the *source* columns.
1427
+ *
1428
+ * The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
1429
+ * radius`); a single O(n) two-pointer sweep maintains the window. See
1430
+ * `docs/notes/rolling-by-column.md`.
1431
+ */
1432
+ rollingByColumn(col, spec, mapping) {
1433
+ const columnSpecs = normalizeAggregateColumns(this.schema, mapping);
1434
+ return computeRollingByColumn(this.#store.store, col, spec, columnSpecs);
1435
+ }
1406
1436
  groupBy(column, transform) {
1407
1437
  const buckets = new Map();
1408
1438
  for (const event of this.events) {
@@ -2720,6 +2750,47 @@ export class TimeSeries {
2720
2750
  const reshaped = withColumnsRenamed(this.#store.store, mapping);
2721
2751
  return TimeSeries.#fromTrustedStore(this.name, resultSchema, reshaped);
2722
2752
  }
2753
+ /**
2754
+ * Example:
2755
+ * `series.withColumn('cumDist', cumulativeDistances)` (a `Float64Array`).
2756
+ *
2757
+ * **Attach a computed numeric column.** Returns a new series with `values`
2758
+ * appended as a new `number` column named `name`, so downstream pond ops
2759
+ * (`aggregate`, `byColumn`, `rollingByColumn`, `column(name)`) can reference
2760
+ * it. The existing key + value columns are shared by reference (zero-copy);
2761
+ * only the new column is added. This is the seam that lets a derived array
2762
+ * (cumulative distance, speed, gradient) re-enter the pond pipeline as a real
2763
+ * column instead of staying a side-channel.
2764
+ *
2765
+ * `values` is a `Float64Array` (dense) or a `(number | undefined)[]` (where
2766
+ * `undefined` marks a missing cell), and must have exactly `series.length`
2767
+ * entries. Defined values are validated against the numeric intake contract —
2768
+ * **non-finite (`NaN` / `±Infinity`) is rejected** (a `ValidationError`,
2769
+ * matching construction), so packed numeric columns stay NaN-free; pass
2770
+ * `undefined` for a missing cell, never `NaN`. `name` must not collide with an
2771
+ * existing column.
2772
+ *
2773
+ * This is the *validated* attach. A trusted bulk-construction path
2774
+ * (`fromTrustedColumns`, skipping the finite scan) is a deferred sibling for
2775
+ * when a perf-critical consumer earns it.
2776
+ */
2777
+ withColumn(name, values) {
2778
+ if (values.length !== this.length) {
2779
+ throw new RangeError(`withColumn: values length ${values.length} does not match series length ${this.length}`);
2780
+ }
2781
+ // Re-assert the numeric intake contract (finite-or-missing) — trusted
2782
+ // construction below bypasses the constructor's strict intake, so a
2783
+ // non-finite cell would otherwise pack into the column and break the
2784
+ // reducer non-finite policy's NaN-free invariant.
2785
+ assertColumnValuesMatchKind('number', values, `withColumn '${String(name)}'`);
2786
+ const column = columnFromValuesByKind('number', values);
2787
+ const reshaped = withColumnAppended(this.#store.store, name, column);
2788
+ const resultSchema = Object.freeze([
2789
+ ...this.schema,
2790
+ { name, kind: 'number' },
2791
+ ]);
2792
+ return TimeSeries.#fromTrustedStore(this.name, resultSchema, reshaped);
2793
+ }
2723
2794
  collapse(keys, output, reducer, options) {
2724
2795
  // Column-native (Step 4): the reducer runs over the keyed columns read
2725
2796
  // straight off the store in the extracted `collapseOp` — no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {