pond-ts 0.27.0 → 0.29.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,9 @@ 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.29.0...HEAD
11
+ [0.29.0]: https://github.com/pjm17971/pond-ts/compare/v0.28.0...v0.29.0
12
+ [0.28.0]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...v0.28.0
11
13
  [0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
12
14
  [0.26.0]: https://github.com/pjm17971/pond-ts/compare/v0.25.0...v0.26.0
13
15
  [0.25.0]: https://github.com/pjm17971/pond-ts/compare/v0.24.0...v0.25.0
@@ -21,7 +23,58 @@ type-level changes; patch bumps are strictly additive.
21
23
 
22
24
  ## [Unreleased]
23
25
 
24
- ## [0.27.0] — 2026-06-16
26
+ ## [0.29.0] — 2026-06-17
27
+
28
+ ### Added
29
+
30
+ - **`byColumn({ edges, inclusive })`** — `inclusive: '(]'` makes edge bins
31
+ upper-inclusive (`(eᵢ, eᵢ₊₁]`), for Coggan power / HR zones where a sample on a
32
+ zone's top edge belongs to the lower zone (the first edge becomes an exclusive
33
+ floor). Defaults to `'[)'` (unchanged — lower-inclusive `[eᵢ, eᵢ₊₁)`). (estela
34
+ F-geo-2 zone inclusivity.)
35
+ - **`'mean'` reducer alias for `'avg'`** — `'mean'` is now an accepted built-in
36
+ reducer name across `aggregate` / `rolling` / `byColumn` / `rollingByColumn` /
37
+ `reduce` (and the live equivalents), at **both runtime and the type level**: it
38
+ resolves to the `avg` kernel and classifies as numeric output
39
+ (`number | undefined`), exactly like `'avg'`. Matches the column API's
40
+ `Float64Column.mean()`. (estela F-reducer-naming.)
41
+
42
+ ### Fixed
43
+
44
+ - **`RowForSchema` honors `required: false`** — a **value** column declared
45
+ `required: false` now accepts `undefined` in its tuple-row cell at the type
46
+ level (matching the runtime, which records it as missing), so optional cells no
47
+ longer need an `as never` cast. The **key (first) column stays required** even
48
+ if marked `required: false` (the constructor always requires it). `null` is
49
+ still not admitted for tuple rows (only the JSON object-row path takes `null`).
50
+ Correspondingly, **`.rows` / `toRows()` now type an optional cell as
51
+ `… | undefined`** (`NormalizedRowForSchema`), so reading a possibly-missing
52
+ cell is no longer unsoundly typed as present — a type tightening on output for
53
+ schemas that use `required: false`. (estela F-geo-row-optional; Codex-hardened.)
54
+
55
+ ## [0.28.0] — 2026-06-17
56
+
57
+ ### Added
58
+
59
+ - **`TimeSeries.rollingByColumn(col, { radius }, mapping)` — windowed value-axis
60
+ aggregation.** The sliding-window sibling of `byColumn`: slides a centered
61
+ `±radius` window along a **non-decreasing** numeric column and reduces it at
62
+ every row, returning one record per row (positionally aligned with the
63
+ series). Where `byColumn` collapses rows into disjoint value-bins (the
64
+ value-axis analogue of `aggregate`), `rollingByColumn` is the value-axis
65
+ analogue of `rolling`. Built for windowed-percentile bands over a derived axis
66
+ (e.g. a spread band over cumulative distance). A missing/non-finite axis row is
67
+ excluded from every window and emits each reducer's empty value. O(n) two-pointer
68
+ sweep. See `docs/notes/rolling-by-column.md`.
69
+ - **`TimeSeries.withColumn(name, values)` — attach a computed numeric column.**
70
+ Appends a `Float64Array` / `(number | undefined)[]` as a new `number` column
71
+ (the schema type widens to include it), so a derived array — cumulative
72
+ distance, speed, gradient — can re-enter the pond pipeline as a real column
73
+ that `aggregate` / `byColumn` / `rollingByColumn` / `column(name)` can
74
+ reference. Existing key + value columns are shared by reference (zero-copy);
75
+ only the new column is added. `values` must match `series.length`; defined
76
+ cells are validated against the numeric intake contract (**non-finite is
77
+ rejected** — pass `undefined` for a missing cell, not `NaN`).
25
78
 
26
79
  ### Added
27
80
 
@@ -52,7 +105,7 @@ type-level changes; patch bumps are strictly additive.
52
105
  - **Behavior note — `array` columns:** an identity-comparing reducer (`keep`,
53
106
  or a custom reducer using `===` on the cell) on an `array`-kind source
54
107
  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
108
+ reference passed at construction. Two rows given the _same_ array object
56
109
  therefore read as distinct. Scalar columns (number / string / boolean) are
57
110
  unaffected. A non-finite or wrong-kind reducer result is still rejected with
58
111
  a `ValidationError`, exactly as the constructor's intake did.
@@ -11,6 +11,7 @@ export type BinSpec = {
11
11
  origin?: number;
12
12
  } | {
13
13
  edges: readonly number[];
14
+ inclusive?: '[)' | '(]';
14
15
  };
15
16
  /** One value-bin's record: its `[start, end)` range plus the mapped aggregates. */
16
17
  export type BinRecord = {
@@ -45,21 +45,45 @@ export function computeByColumn(store, binColName, spec, columnSpecs) {
45
45
  }
46
46
  }
47
47
  const last = edges.length - 1;
48
- binOf = (v) => {
49
- if (v < edges[0] || v >= edges[last])
50
- return NaN; // out of range drop
51
- // rightmost edge <= v, clamped to a valid bin [0, last)
52
- let lo = 0;
53
- let hi = last; // bins are [0, last)
54
- while (lo < hi) {
55
- const mid = (lo + hi + 1) >>> 1;
56
- if (edges[mid] <= v)
57
- lo = mid;
58
- else
59
- hi = mid - 1;
60
- }
61
- return lo;
62
- };
48
+ // `inclusive` picks the bin a value exactly on an interior edge falls into.
49
+ // `'[)'` (default): bins are `[eᵢ, eᵢ₊₁)`, lower-inclusive a boundary value
50
+ // goes to the bin ABOVE; range is `[e₀, eₙ)`. `'(]'`: bins are `(eᵢ, eᵢ₊₁]`,
51
+ // upper-inclusive (Coggan power/HR zones a sample at exactly a zone's top
52
+ // edge is the LOWER zone) — a boundary value goes to the bin BELOW; range is
53
+ // `(e₀, eₙ]`, so the first edge is an exclusive floor (set it below your
54
+ // minimum to keep the minimum in bin 0).
55
+ binOf =
56
+ spec.inclusive === '(]'
57
+ ? (v) => {
58
+ if (v <= edges[0] || v > edges[last])
59
+ return NaN; // out of (e₀, eₙ]
60
+ // rightmost edge STRICTLY less than v, clamped to a valid bin [0, last)
61
+ let lo = 0;
62
+ let hi = last;
63
+ while (lo < hi) {
64
+ const mid = (lo + hi + 1) >>> 1;
65
+ if (edges[mid] < v)
66
+ lo = mid;
67
+ else
68
+ hi = mid - 1;
69
+ }
70
+ return lo;
71
+ }
72
+ : (v) => {
73
+ if (v < edges[0] || v >= edges[last])
74
+ return NaN; // out of [e₀, eₙ)
75
+ // rightmost edge <= v, clamped to a valid bin [0, last)
76
+ let lo = 0;
77
+ let hi = last;
78
+ while (lo < hi) {
79
+ const mid = (lo + hi + 1) >>> 1;
80
+ if (edges[mid] <= v)
81
+ lo = mid;
82
+ else
83
+ hi = mid - 1;
84
+ }
85
+ return lo;
86
+ };
63
87
  rangeOf = (i) => ({ start: edges[i], end: edges[i + 1] });
64
88
  }
65
89
  else {
@@ -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';
@@ -538,8 +539,11 @@ export declare class TimeSeries<S extends SeriesSchema> {
538
539
  * source (cumulative distance / work) yields contiguous ranges (per-km
539
540
  * splits, elevation-vs-distance profile); a non-monotonic source (power)
540
541
  * yields a histogram (distribution).
541
- * - `{ edges }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins, bin `i` =
542
- * `[eᵢ, eᵢ₊₁)` (e.g. FTP / Coggan power zones). Always emits all `n` bins.
542
+ * - `{ edges, inclusive? }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins.
543
+ * `inclusive` defaults to `'[)'` (bin `i` = `[eᵢ, eᵢ₊₁)`, lower-inclusive);
544
+ * pass `'(]'` for upper-inclusive bins (`(eᵢ, eᵢ₊₁]`) — Coggan power / HR
545
+ * zones, where a sample exactly on a zone's top edge belongs to the lower
546
+ * zone (the first edge becomes an exclusive floor). Always emits all `n` bins.
543
547
  *
544
548
  * A row whose bin value is missing / non-finite (or, for `edges`, outside
545
549
  * `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
@@ -551,6 +555,32 @@ export declare class TimeSeries<S extends SeriesSchema> {
551
555
  start: number;
552
556
  end: number;
553
557
  } & ReduceResult<S, Mapping>>;
558
+ /**
559
+ * Example:
560
+ * `series.rollingByColumn('cumDist', { radius: 120 }, { lo: { from: 'speed', using: 'p5' }, hi: { from: 'speed', using: 'p95' } })`.
561
+ *
562
+ * **Windowed value-axis aggregation — the sliding-window sibling of
563
+ * `byColumn`.** Where `byColumn` collapses rows into disjoint value-bins (the
564
+ * value-axis analogue of `aggregate`), `rollingByColumn` slides a **centered
565
+ * window** along a value axis and reduces the window at every row (the
566
+ * value-axis analogue of `rolling`). For each row it reduces the rows whose
567
+ * `col` value lies within `±radius` of that row's value, and returns **one
568
+ * record per row, positionally aligned with the series** (`out[i]` is the
569
+ * window centered at row `i`) — *not* a `TimeSeries`, and with no `start`/`end`
570
+ * range (the caller already has the axis column to zip against).
571
+ *
572
+ * `col` must be a **non-decreasing** numeric column — the ordering is what
573
+ * makes a sliding window meaningful (vs `byColumn`'s order-free group-by) and
574
+ * is enforced (a descending step throws). A row whose `col` value is missing /
575
+ * non-finite is excluded from every window and its own slot gets each
576
+ * reducer's empty snapshot, so the result stays positionally aligned. The
577
+ * reducer non-finite policy still applies to the *source* columns.
578
+ *
579
+ * The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
580
+ * radius`); a single O(n) two-pointer sweep maintains the window. See
581
+ * `docs/notes/rolling-by-column.md`.
582
+ */
583
+ rollingByColumn<const Mapping extends ValidatedAggregateMap<S, Mapping>>(col: NumericColumnNameForSchema<S>, spec: WindowSpec, mapping: Mapping): Array<ReduceResult<S, Mapping>>;
554
584
  /**
555
585
  * Example: `series.groupBy("host")`.
556
586
  * Partitions the series into groups keyed by the distinct values of a payload column.
@@ -1112,6 +1142,31 @@ export declare class TimeSeries<S extends SeriesSchema> {
1112
1142
  select<const Keys extends readonly (keyof EventDataForSchema<S>)[]>(...keys: Keys): TimeSeries<SelectSchema<S, Keys[number] & string>>;
1113
1143
  /** Example: `series.rename({ cpu: "usage" })`. Returns a new series with payload field names renamed according to the supplied mapping. */
1114
1144
  rename<const Mapping extends RenameMap<EventDataForSchema<S>>>(mapping: Mapping): TimeSeries<RenameSchema<S, Mapping>>;
1145
+ /**
1146
+ * Example:
1147
+ * `series.withColumn('cumDist', cumulativeDistances)` (a `Float64Array`).
1148
+ *
1149
+ * **Attach a computed numeric column.** Returns a new series with `values`
1150
+ * appended as a new `number` column named `name`, so downstream pond ops
1151
+ * (`aggregate`, `byColumn`, `rollingByColumn`, `column(name)`) can reference
1152
+ * it. The existing key + value columns are shared by reference (zero-copy);
1153
+ * only the new column is added. This is the seam that lets a derived array
1154
+ * (cumulative distance, speed, gradient) re-enter the pond pipeline as a real
1155
+ * column instead of staying a side-channel.
1156
+ *
1157
+ * `values` is a `Float64Array` (dense) or a `(number | undefined)[]` (where
1158
+ * `undefined` marks a missing cell), and must have exactly `series.length`
1159
+ * entries. Defined values are validated against the numeric intake contract —
1160
+ * **non-finite (`NaN` / `±Infinity`) is rejected** (a `ValidationError`,
1161
+ * matching construction), so packed numeric columns stay NaN-free; pass
1162
+ * `undefined` for a missing cell, never `NaN`. `name` must not collide with an
1163
+ * existing column.
1164
+ *
1165
+ * This is the *validated* attach. A trusted bulk-construction path
1166
+ * (`fromTrustedColumns`, skipping the finite scan) is a deferred sibling for
1167
+ * when a perf-critical consumer earns it.
1168
+ */
1169
+ withColumn<const Name extends string>(name: Name, values: ReadonlyArray<number | undefined> | Float64Array): TimeSeries<AppendColumn<S, Name, 'number'>>;
1115
1170
  /** Example: `series.collapse(["in", "out"], "avg", fn)`. Collapses selected payload fields into a single derived field across each event in the series. */
1116
1171
  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
1172
  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';
@@ -871,6 +872,10 @@ export class TimeSeries {
871
872
  }
872
873
  /** Example: `series.rows`. Returns the normalized row view of the series. */
873
874
  get rows() {
875
+ // `toRows` returns runtime-normalized rows (Time/Interval keys, `undefined`
876
+ // for missing cells); the double cast is the existing trust point — the
877
+ // `RowForSchema`→`NormalizedRowForSchema` conditional types no longer overlap
878
+ // structurally for a direct cast now that both honor `required: false`.
874
879
  return toRows(this.schema, this.events);
875
880
  }
876
881
  /** Example: `series.toRows()`. Returns normalized row arrays using `Time`/`TimeRange`/`Interval` keys and `undefined` for missing payload values. */
@@ -1390,8 +1395,11 @@ export class TimeSeries {
1390
1395
  * source (cumulative distance / work) yields contiguous ranges (per-km
1391
1396
  * splits, elevation-vs-distance profile); a non-monotonic source (power)
1392
1397
  * yields a histogram (distribution).
1393
- * - `{ edges }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins, bin `i` =
1394
- * `[eᵢ, eᵢ₊₁)` (e.g. FTP / Coggan power zones). Always emits all `n` bins.
1398
+ * - `{ edges, inclusive? }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins.
1399
+ * `inclusive` defaults to `'[)'` (bin `i` = `[eᵢ, eᵢ₊₁)`, lower-inclusive);
1400
+ * pass `'(]'` for upper-inclusive bins (`(eᵢ, eᵢ₊₁]`) — Coggan power / HR
1401
+ * zones, where a sample exactly on a zone's top edge belongs to the lower
1402
+ * zone (the first edge becomes an exclusive floor). Always emits all `n` bins.
1395
1403
  *
1396
1404
  * A row whose bin value is missing / non-finite (or, for `edges`, outside
1397
1405
  * `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
@@ -1403,6 +1411,35 @@ export class TimeSeries {
1403
1411
  const columnSpecs = normalizeAggregateColumns(this.schema, mapping);
1404
1412
  return computeByColumn(this.#store.store, col, spec, columnSpecs);
1405
1413
  }
1414
+ /**
1415
+ * Example:
1416
+ * `series.rollingByColumn('cumDist', { radius: 120 }, { lo: { from: 'speed', using: 'p5' }, hi: { from: 'speed', using: 'p95' } })`.
1417
+ *
1418
+ * **Windowed value-axis aggregation — the sliding-window sibling of
1419
+ * `byColumn`.** Where `byColumn` collapses rows into disjoint value-bins (the
1420
+ * value-axis analogue of `aggregate`), `rollingByColumn` slides a **centered
1421
+ * window** along a value axis and reduces the window at every row (the
1422
+ * value-axis analogue of `rolling`). For each row it reduces the rows whose
1423
+ * `col` value lies within `±radius` of that row's value, and returns **one
1424
+ * record per row, positionally aligned with the series** (`out[i]` is the
1425
+ * window centered at row `i`) — *not* a `TimeSeries`, and with no `start`/`end`
1426
+ * range (the caller already has the axis column to zip against).
1427
+ *
1428
+ * `col` must be a **non-decreasing** numeric column — the ordering is what
1429
+ * makes a sliding window meaningful (vs `byColumn`'s order-free group-by) and
1430
+ * is enforced (a descending step throws). A row whose `col` value is missing /
1431
+ * non-finite is excluded from every window and its own slot gets each
1432
+ * reducer's empty snapshot, so the result stays positionally aligned. The
1433
+ * reducer non-finite policy still applies to the *source* columns.
1434
+ *
1435
+ * The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
1436
+ * radius`); a single O(n) two-pointer sweep maintains the window. See
1437
+ * `docs/notes/rolling-by-column.md`.
1438
+ */
1439
+ rollingByColumn(col, spec, mapping) {
1440
+ const columnSpecs = normalizeAggregateColumns(this.schema, mapping);
1441
+ return computeRollingByColumn(this.#store.store, col, spec, columnSpecs);
1442
+ }
1406
1443
  groupBy(column, transform) {
1407
1444
  const buckets = new Map();
1408
1445
  for (const event of this.events) {
@@ -2720,6 +2757,47 @@ export class TimeSeries {
2720
2757
  const reshaped = withColumnsRenamed(this.#store.store, mapping);
2721
2758
  return TimeSeries.#fromTrustedStore(this.name, resultSchema, reshaped);
2722
2759
  }
2760
+ /**
2761
+ * Example:
2762
+ * `series.withColumn('cumDist', cumulativeDistances)` (a `Float64Array`).
2763
+ *
2764
+ * **Attach a computed numeric column.** Returns a new series with `values`
2765
+ * appended as a new `number` column named `name`, so downstream pond ops
2766
+ * (`aggregate`, `byColumn`, `rollingByColumn`, `column(name)`) can reference
2767
+ * it. The existing key + value columns are shared by reference (zero-copy);
2768
+ * only the new column is added. This is the seam that lets a derived array
2769
+ * (cumulative distance, speed, gradient) re-enter the pond pipeline as a real
2770
+ * column instead of staying a side-channel.
2771
+ *
2772
+ * `values` is a `Float64Array` (dense) or a `(number | undefined)[]` (where
2773
+ * `undefined` marks a missing cell), and must have exactly `series.length`
2774
+ * entries. Defined values are validated against the numeric intake contract —
2775
+ * **non-finite (`NaN` / `±Infinity`) is rejected** (a `ValidationError`,
2776
+ * matching construction), so packed numeric columns stay NaN-free; pass
2777
+ * `undefined` for a missing cell, never `NaN`. `name` must not collide with an
2778
+ * existing column.
2779
+ *
2780
+ * This is the *validated* attach. A trusted bulk-construction path
2781
+ * (`fromTrustedColumns`, skipping the finite scan) is a deferred sibling for
2782
+ * when a perf-critical consumer earns it.
2783
+ */
2784
+ withColumn(name, values) {
2785
+ if (values.length !== this.length) {
2786
+ throw new RangeError(`withColumn: values length ${values.length} does not match series length ${this.length}`);
2787
+ }
2788
+ // Re-assert the numeric intake contract (finite-or-missing) — trusted
2789
+ // construction below bypasses the constructor's strict intake, so a
2790
+ // non-finite cell would otherwise pack into the column and break the
2791
+ // reducer non-finite policy's NaN-free invariant.
2792
+ assertColumnValuesMatchKind('number', values, `withColumn '${String(name)}'`);
2793
+ const column = columnFromValuesByKind('number', values);
2794
+ const reshaped = withColumnAppended(this.#store.store, name, column);
2795
+ const resultSchema = Object.freeze([
2796
+ ...this.schema,
2797
+ { name, kind: 'number' },
2798
+ ]);
2799
+ return TimeSeries.#fromTrustedStore(this.name, resultSchema, reshaped);
2800
+ }
2723
2801
  collapse(keys, output, reducer, options) {
2724
2802
  // Column-native (Step 4): the reducer runs over the keyed columns read
2725
2803
  // straight off the store in the extracted `collapseOp` — no
@@ -33,6 +33,11 @@ export function resolveReducer(operation) {
33
33
  const r = registry[operation];
34
34
  if (r)
35
35
  return r;
36
+ // `'mean'` is an accepted alias for `'avg'` in aggregate/rolling mappings,
37
+ // matching the column API (`Float64Column.mean()` → the `'avg'` kernel). Keeps
38
+ // the registry free of a duplicate entry. (estela F-reducer-naming.)
39
+ if (operation === 'mean')
40
+ return avg;
36
41
  const q = parsePercentile(operation);
37
42
  if (q !== undefined)
38
43
  return percentileReducer(q);
@@ -1,5 +1,5 @@
1
1
  import type { AppendColumn, ArrayColumnNameForSchema, ColumnDef, ColumnValue, OptionalizeColumns, ReplaceColumnKind, ScalarKind, SeriesSchema, ValueColumn, ValueColumnsForSchema } from './series.js';
2
- export type AggregateFunction = 'sum' | 'avg' | 'min' | 'max' | 'count' | 'first' | 'last' | 'median' | 'stdev' | 'difference' | 'keep' | 'unique' | 'samples' | `p${number}` | `top${number}`;
2
+ export type AggregateFunction = 'sum' | 'avg' | 'mean' | 'min' | 'max' | 'count' | 'first' | 'last' | 'median' | 'stdev' | 'difference' | 'keep' | 'unique' | 'samples' | `p${number}` | `top${number}`;
3
3
  /**
4
4
  * Custom aggregate reducers receive every value in a bucket (including
5
5
  * `undefined`) and return a single result. The return type is widened to
@@ -152,7 +152,7 @@ type UnifiedOutputKind<Columns extends readonly ValueColumn[], K extends string,
152
152
  * reducer function (or otherwise unrecognized) — the spec branch passes
153
153
  * `ScalarKind`, the shorthand branch passes the source column's kind.
154
154
  */
155
- type ReducerOutputKind<Columns extends readonly ValueColumn[], From extends string, Using, Fallback extends ScalarKind> = Using extends 'sum' | 'avg' | 'count' | 'min' | 'max' | 'median' | 'stdev' | 'difference' | `p${number}` ? 'number' : Using extends 'unique' | 'samples' | `top${number}` ? 'array' : Using extends 'first' | 'last' | 'keep' ? From extends Columns[number]['name'] ? ColumnKindByName<Columns, From> : ScalarKind : Fallback;
155
+ type ReducerOutputKind<Columns extends readonly ValueColumn[], From extends string, Using, Fallback extends ScalarKind> = Using extends 'sum' | 'avg' | 'mean' | 'count' | 'min' | 'max' | 'median' | 'stdev' | 'difference' | `p${number}` ? 'number' : Using extends 'unique' | 'samples' | `top${number}` ? 'array' : Using extends 'first' | 'last' | 'keep' ? From extends Columns[number]['name'] ? ColumnKindByName<Columns, From> : ScalarKind : Fallback;
156
156
  /**
157
157
  * Union of typed `ColumnDef`s — one per **output key** in the mapping
158
158
  * (not per source column). Used as the `...Rest` of the schema tuple;
@@ -209,7 +209,7 @@ export type AggregateOutputMapResultSchema<S extends SeriesSchema, Mapping> = Ag
209
209
  * Aggregate functions that always produce a numeric result regardless of
210
210
  * source column kind. Matches the reducer registry's `outputKind: 'number'`.
211
211
  */
212
- type NumericAggregateFunction = 'sum' | 'avg' | 'count' | 'min' | 'max' | 'median' | 'stdev' | 'difference' | `p${number}`;
212
+ type NumericAggregateFunction = 'sum' | 'avg' | 'mean' | 'count' | 'min' | 'max' | 'median' | 'stdev' | 'difference' | `p${number}`;
213
213
  /**
214
214
  * Output column kind for `arrayAggregate(col, reducer, { kind? })`.
215
215
  * Numeric reducers → `'number'`, `'unique'` → `'array'`, `'first'`/`'last'`/
@@ -20,7 +20,9 @@ export type TimeSeriesInput<S extends SeriesSchema> = {
20
20
  sort?: boolean;
21
21
  };
22
22
  export type NormalizedRowForSchema<S extends readonly ColumnDef<string, string>[]> = {
23
- [I in keyof S]: S[I] extends ColumnDef<any, infer K> ? NormalizedValueForKind<K> : never;
23
+ [I in keyof S]: S[I] extends ColumnDef<any, infer K> ? I extends '0' ? NormalizedValueForKind<K> : S[I] extends {
24
+ required: false;
25
+ } ? NormalizedValueForKind<K> | undefined : NormalizedValueForKind<K> : never;
24
26
  };
25
27
  type DataValueForColumn<C extends ColumnDef<string, string>> = C extends ColumnDef<any, infer K> ? C['required'] extends false ? NormalizedValueForKind<K> | undefined : NormalizedValueForKind<K> : never;
26
28
  type NormalizedDataValueForColumn<C extends ColumnDef<string, string>> = C extends ColumnDef<any, infer K> ? K extends FirstColKind ? EventKeyForKind<K> : C['required'] extends false ? NormalizedValueForKind<K> | undefined : NormalizedValueForKind<K> : never;
@@ -73,7 +73,7 @@ export type ReduceResult<S extends SeriesSchema, Mapping> = {
73
73
  * and shorthand branches. `From` is the spec's `from` or the shorthand
74
74
  * key; `Using` is the reducer.
75
75
  */
76
- type ReduceValueForReducer<S extends SeriesSchema, From extends string, Using> = Using extends 'sum' | 'avg' | 'count' | 'min' | 'max' | 'median' | 'stdev' | 'difference' | `p${number}` ? number | undefined : Using extends 'unique' | 'samples' | `top${number}` ? From extends ValueColumnsForSchema<S>[number]['name'] ? ColumnByName<S, From>['kind'] extends 'number' ? ReadonlyArray<number> | undefined : ColumnByName<S, From>['kind'] extends 'string' ? ReadonlyArray<string> | undefined : ColumnByName<S, From>['kind'] extends 'boolean' ? ReadonlyArray<boolean> | undefined : ReadonlyArray<string | number | boolean> | undefined : ReadonlyArray<string | number | boolean> | undefined : Using extends 'first' | 'last' | 'keep' ? From extends ValueColumnsForSchema<S>[number]['name'] ? ColumnByName<S, From>['kind'] extends 'number' ? number | undefined : ColumnByName<S, From>['kind'] extends 'string' ? string | undefined : ColumnByName<S, From>['kind'] extends 'boolean' ? boolean | undefined : ColumnValue | undefined : ColumnValue | undefined : ColumnValue | undefined;
76
+ type ReduceValueForReducer<S extends SeriesSchema, From extends string, Using> = Using extends 'sum' | 'avg' | 'mean' | 'count' | 'min' | 'max' | 'median' | 'stdev' | 'difference' | `p${number}` ? number | undefined : Using extends 'unique' | 'samples' | `top${number}` ? From extends ValueColumnsForSchema<S>[number]['name'] ? ColumnByName<S, From>['kind'] extends 'number' ? ReadonlyArray<number> | undefined : ColumnByName<S, From>['kind'] extends 'string' ? ReadonlyArray<string> | undefined : ColumnByName<S, From>['kind'] extends 'boolean' ? ReadonlyArray<boolean> | undefined : ReadonlyArray<string | number | boolean> | undefined : ReadonlyArray<string | number | boolean> | undefined : Using extends 'first' | 'last' | 'keep' ? From extends ValueColumnsForSchema<S>[number]['name'] ? ColumnByName<S, From>['kind'] extends 'number' ? number | undefined : ColumnByName<S, From>['kind'] extends 'string' ? string | undefined : ColumnByName<S, From>['kind'] extends 'boolean' ? boolean | undefined : ColumnValue | undefined : ColumnValue | undefined : ColumnValue | undefined;
77
77
  /**
78
78
  * Value type for an explicit `kind` on a reduce spec. Mirrors
79
79
  * `NormalizedValueForKind` but stays inline here for the same TS2394
@@ -90,7 +90,7 @@ type FusedColumnKind<S extends SeriesSchema, Key extends string, Value> = Value
90
90
  * Reducer-string-to-output-kind dispatch, shared by both AggregateMap
91
91
  * and AggregateOutputMap entry shapes.
92
92
  */
93
- type FusedReducerKind<S extends SeriesSchema, From extends string, Using> = Using extends 'sum' | 'avg' | 'count' | 'min' | 'max' | 'median' | 'stdev' | 'difference' | `p${number}` ? 'number' : Using extends 'unique' | 'samples' | `top${number}` ? 'array' : Using extends 'first' | 'last' | 'keep' ? FromColumnKind<S, From> : ScalarKind;
93
+ type FusedReducerKind<S extends SeriesSchema, From extends string, Using> = Using extends 'sum' | 'avg' | 'mean' | 'count' | 'min' | 'max' | 'median' | 'stdev' | 'difference' | `p${number}` ? 'number' : Using extends 'unique' | 'samples' | `top${number}` ? 'array' : Using extends 'first' | 'last' | 'keep' ? FromColumnKind<S, From> : ScalarKind;
94
94
  /**
95
95
  * Look up the kind of a value column by name. Used by source-
96
96
  * preserving reducers (`first` / `last` / `keep`) where the output
@@ -22,6 +22,14 @@ export type ColumnDef<Name extends string, Kind extends string> = {
22
22
  kind: Kind;
23
23
  required?: boolean;
24
24
  };
25
+ /**
26
+ * The key (first) column of a schema. Its **name must equal its kind** —
27
+ * `time` / `timeRange` / `interval`. So `{ name: 'time', kind: 'time' }` is the
28
+ * only valid time key; `{ name: 'at', kind: 'time' }` does **not** typecheck
29
+ * (the error surfaces as a name/literal-type mismatch, e.g. `'"at"' is not
30
+ * assignable to '"time"'` — read it as "the key column must be named for its
31
+ * kind", not as a value error). Value columns, by contrast, take any name.
32
+ */
25
33
  export type FirstColumn = ColumnDef<'time', 'time'> | ColumnDef<'interval', 'interval'> | ColumnDef<'timeRange', 'timeRange'>;
26
34
  export type ValueColumn<Name extends string = string> = ColumnDef<Name, ScalarKind>;
27
35
  export type SeriesSchema = readonly [FirstColumn, ...ValueColumn[]];
@@ -32,8 +40,18 @@ export type ValueColumnsForSchema<S extends SeriesSchema> = S extends readonly [
32
40
  export type ValueForKind<K extends string> = K extends 'time' ? TimestampInput | Time : K extends 'interval' ? IntervalInput | Interval : K extends 'timeRange' ? TimeRangeInput | TimeRange : K extends 'number' ? number : K extends 'string' ? string : K extends 'boolean' ? boolean : K extends 'array' ? ArrayValue : never;
33
41
  export type NormalizedValueForKind<K extends string> = K extends 'time' ? Time : K extends 'timeRange' ? TimeRange : K extends 'interval' ? Interval : K extends 'number' ? number : K extends 'string' ? string : K extends 'boolean' ? boolean : K extends 'array' ? ArrayValue : never;
34
42
  export type KindForValue<V extends ScalarValue> = V extends number ? 'number' : V extends string ? 'string' : 'boolean';
43
+ /**
44
+ * Tuple-row input type for a schema. A column declared `required: false`
45
+ * accepts `undefined` in its cell (a missing value — the constructor records
46
+ * it in the validity bitmap), matching the runtime's intake. `null` is **not**
47
+ * admitted: the tuple-row constructor rejects it (only the JSON object-row path
48
+ * accepts `null`), so the type stays honest to what intake actually takes —
49
+ * pass `undefined` for a missing tuple cell. (estela F-geo-row-optional.)
50
+ */
35
51
  export type RowForSchema<S extends readonly ColumnDef<string, string>[]> = {
36
- [I in keyof S]: S[I] extends ColumnDef<any, infer K> ? ValueForKind<K> : never;
52
+ [I in keyof S]: S[I] extends ColumnDef<any, infer K> ? I extends '0' ? ValueForKind<K> : S[I] extends {
53
+ required: false;
54
+ } ? ValueForKind<K> | undefined : ValueForKind<K> : never;
37
55
  };
38
56
  export type NumericColumnNameForSchema<S extends SeriesSchema> = Extract<ValueColumnsForSchema<S>[number], ColumnDef<string, 'number'>>['name'];
39
57
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {