pond-ts 0.28.0 → 0.30.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.28.0...HEAD
10
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.30.0...HEAD
11
+ [0.30.0]: https://github.com/pjm17971/pond-ts/compare/v0.29.0...v0.30.0
12
+ [0.29.0]: https://github.com/pjm17971/pond-ts/compare/v0.28.0...v0.29.0
11
13
  [0.28.0]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...v0.28.0
12
14
  [0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
13
15
  [0.26.0]: https://github.com/pjm17971/pond-ts/compare/v0.25.0...v0.26.0
@@ -20,7 +22,65 @@ type-level changes; patch bumps are strictly additive.
20
22
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
21
23
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
22
24
 
23
- ## [Unreleased]
25
+ ## [0.30.0] — 2026-06-17
26
+
27
+ ### Added
28
+
29
+ - **`rollingByColumn(col, { radius, at }, mapping)` — evaluate at explicit
30
+ centers.** `at` takes a **non-decreasing** array of center values (e.g. a chart's
31
+ coarse display grid) and returns **one record per center**, instead of the
32
+ default one-per-row. A center with no rows within `±radius` yields each
33
+ reducer's empty value. Same O(n + centers) two-pointer. Closes the
34
+ evaluate-at-grid gap surfaced adopting `rollingByColumn` for a chart variance
35
+ band. (estela F-rolling-by-row.)
36
+ - **`smooth(col, 'movingAverage' | 'loess', { …, missing: 'skip' })` —
37
+ validity-respecting smoothing.** By default (`missing: 'bridge'`) a cell whose
38
+ own value is missing is still assigned a smoothed value from its present
39
+ neighbours — the line is drawn _across_ the hole. `missing: 'skip'` keeps a
40
+ missing cell **missing** in the output, so a sustained dropout (a coast, a
41
+ sensor gap) is preserved as a break rather than fabricated through. Present
42
+ cells smooth over only the present values in their window either way. `ema`
43
+ takes no `missing` option (it is causal and never fabricates across a gap). A
44
+ `maxGap` hard segment boundary is a deferred follow-on. (estela
45
+ F-smooth-interactive.)
46
+
47
+ ### Changed
48
+
49
+ - **`byColumn(…, { inclusive: '(]' })` floor edge is now inclusive.** Under
50
+ `'(]'`, interior bins stay upper-inclusive (`(eᵢ, eᵢ₊₁]`) but the **floor `e₀`
51
+ is inclusive** (bin 0 is `[e₀, e₁]`), so a value at exactly the minimum edge —
52
+ e.g. a `0` W coast/stop sample at a zone floor of 0 — lands in bin 0 instead of
53
+ being dropped (the `include_lowest` convention). Previously the floor was
54
+ exclusive. (estela F-inclusive-floor.)
55
+
56
+ ## [0.29.0] — 2026-06-17
57
+
58
+ ### Added
59
+
60
+ - **`byColumn({ edges, inclusive })`** — `inclusive: '(]'` makes edge bins
61
+ upper-inclusive (`(eᵢ, eᵢ₊₁]`), for Coggan power / HR zones where a sample on a
62
+ zone's top edge belongs to the lower zone (the first edge becomes an exclusive
63
+ floor). Defaults to `'[)'` (unchanged — lower-inclusive `[eᵢ, eᵢ₊₁)`). (estela
64
+ F-geo-2 zone inclusivity.)
65
+ - **`'mean'` reducer alias for `'avg'`** — `'mean'` is now an accepted built-in
66
+ reducer name across `aggregate` / `rolling` / `byColumn` / `rollingByColumn` /
67
+ `reduce` (and the live equivalents), at **both runtime and the type level**: it
68
+ resolves to the `avg` kernel and classifies as numeric output
69
+ (`number | undefined`), exactly like `'avg'`. Matches the column API's
70
+ `Float64Column.mean()`. (estela F-reducer-naming.)
71
+
72
+ ### Fixed
73
+
74
+ - **`RowForSchema` honors `required: false`** — a **value** column declared
75
+ `required: false` now accepts `undefined` in its tuple-row cell at the type
76
+ level (matching the runtime, which records it as missing), so optional cells no
77
+ longer need an `as never` cast. The **key (first) column stays required** even
78
+ if marked `required: false` (the constructor always requires it). `null` is
79
+ still not admitted for tuple rows (only the JSON object-row path takes `null`).
80
+ Correspondingly, **`.rows` / `toRows()` now type an optional cell as
81
+ `… | undefined`** (`NormalizedRowForSchema`), so reading a possibly-missing
82
+ cell is no longer unsoundly typed as present — a type tightening on output for
83
+ schemas that use `required: false`. (estela F-geo-row-optional; Codex-hardened.)
24
84
 
25
85
  ## [0.28.0] — 2026-06-17
26
86
 
@@ -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,49 @@ 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ₙ)`. `'(]'`: interior bins are
51
+ // `(eᵢ, eᵢ₊₁]`, upper-inclusive (Coggan power/HR zones a sample at exactly
52
+ // a zone's top edge is the LOWER zone) — a boundary value goes to the bin
53
+ // BELOW. The **floor `e₀` is inclusive** (bin 0 is `[e₀, e₁]`), so a value at
54
+ // exactly the minimum edge — e.g. a `0` W coast/stop sample — lands in bin 0
55
+ // rather than being dropped (the `include_lowest` convention; estela
56
+ // F-inclusive-floor). Range is `[e₀, eₙ]`.
57
+ binOf =
58
+ spec.inclusive === '(]'
59
+ ? (v) => {
60
+ if (v < edges[0] || v > edges[last])
61
+ return NaN; // out of [e₀, eₙ]
62
+ // rightmost edge STRICTLY less than v (so an interior edge goes to
63
+ // the bin below), clamped to [0, last); v === e₀ → bin 0 (floor
64
+ // inclusive).
65
+ let lo = 0;
66
+ let hi = last;
67
+ while (lo < hi) {
68
+ const mid = (lo + hi + 1) >>> 1;
69
+ if (edges[mid] < v)
70
+ lo = mid;
71
+ else
72
+ hi = mid - 1;
73
+ }
74
+ return lo;
75
+ }
76
+ : (v) => {
77
+ if (v < edges[0] || v >= edges[last])
78
+ return NaN; // out of [e₀, eₙ)
79
+ // rightmost edge <= v, clamped to a valid bin [0, last)
80
+ let lo = 0;
81
+ let hi = last;
82
+ while (lo < hi) {
83
+ const mid = (lo + hi + 1) >>> 1;
84
+ if (edges[mid] <= v)
85
+ lo = mid;
86
+ else
87
+ hi = mid - 1;
88
+ }
89
+ return lo;
90
+ };
63
91
  rangeOf = (i) => ({ start: edges[i], end: edges[i + 1] });
64
92
  }
65
93
  else {
@@ -3,12 +3,19 @@ import type { ColumnValue } from '../schema/index.js';
3
3
  import type { AggregateColumnSpec } from './aggregate-columns.js';
4
4
  /**
5
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
6
+ * half-width `radius`, in the axis column's own units.
7
+ *
8
+ * By default a window is evaluated at **every input row** (one record per row).
9
+ * Pass `at` — a **non-decreasing** array of explicit center values (e.g. a chart's
10
+ * coarse display grid) — to evaluate at those centers instead, returning one
11
+ * record per center. Each center's window is the rows whose axis value lies
12
+ * within `±radius` of it; a center need not coincide with any row, and a center
13
+ * with no rows in range yields each reducer's empty value. See
8
14
  * `docs/notes/rolling-by-column.md`.
9
15
  */
10
16
  export type WindowSpec = {
11
17
  radius: number;
18
+ at?: readonly number[];
12
19
  };
13
20
  /** One windowed record: the mapped aggregates over the window centered at a row. */
14
21
  export type WindowRecord = Record<string, ColumnValue | undefined>;
@@ -55,6 +55,50 @@ export function computeRollingByColumn(store, axisColName, spec, columnSpecs) {
55
55
  // the whole sweep — this is what makes it O(n) rather than O(n · window).
56
56
  const states = columnSpecs.map((s) => rollingStateFor(s.reducer));
57
57
  const specCount = columnSpecs.length;
58
+ // `at` mode (estela F-rolling-by-row): evaluate at explicit ascending centers
59
+ // (e.g. a chart's display grid) rather than at every input row. One record per
60
+ // center; the two-pointer sweeps the same shared states across the centers, so
61
+ // it stays O(n + at.length). A center with no rows in `±radius` empties the
62
+ // window and yields each reducer's empty value.
63
+ if (spec.at !== undefined) {
64
+ const at = spec.at;
65
+ for (let k = 0; k < at.length; k += 1) {
66
+ if (!Number.isFinite(at[k])) {
67
+ throw new RangeError(`rollingByColumn: at[${k}] is not finite`);
68
+ }
69
+ if (k > 0 && at[k] < at[k - 1]) {
70
+ throw new RangeError('rollingByColumn: at centers must be non-decreasing');
71
+ }
72
+ }
73
+ const atOut = new Array(at.length);
74
+ let alo = 0;
75
+ let ahi = 0; // window holds compact positions [alo, ahi)
76
+ for (let k = 0; k < at.length; k += 1) {
77
+ const c = at[k];
78
+ const wlo = c - radius;
79
+ const whi = c + radius;
80
+ while (ahi < m && ax[ahi] <= whi) {
81
+ const r = idx[ahi];
82
+ for (let cc = 0; cc < specCount; cc += 1) {
83
+ states[cc].add(r, sourceCols[cc].read(r));
84
+ }
85
+ ahi += 1;
86
+ }
87
+ while (alo < ahi && ax[alo] < wlo) {
88
+ const r = idx[alo];
89
+ for (let cc = 0; cc < specCount; cc += 1) {
90
+ states[cc].remove(r, sourceCols[cc].read(r));
91
+ }
92
+ alo += 1;
93
+ }
94
+ const rec = {};
95
+ for (let cc = 0; cc < specCount; cc += 1) {
96
+ rec[columnSpecs[cc].output] = states[cc].snapshot();
97
+ }
98
+ atOut[k] = rec;
99
+ }
100
+ return atOut;
101
+ }
58
102
  const out = new Array(n);
59
103
  let lo = 0;
60
104
  let hi = 0; // the window currently holds compact positions [lo, hi)
@@ -539,8 +539,13 @@ export declare class TimeSeries<S extends SeriesSchema> {
539
539
  * source (cumulative distance / work) yields contiguous ranges (per-km
540
540
  * splits, elevation-vs-distance profile); a non-monotonic source (power)
541
541
  * yields a histogram (distribution).
542
- * - `{ edges }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins, bin `i` =
543
- * `[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 interior bins (`(eᵢ, eᵢ₊₁]`) — Coggan power
545
+ * / HR zones, where a sample exactly on a zone's top edge belongs to the
546
+ * lower zone. The floor `e₀` stays **inclusive** (bin 0 is `[e₀, e₁]`), so a
547
+ * minimum-edge value (e.g. a `0` W coast sample) lands in bin 0 rather than
548
+ * being dropped. Always emits all `n` bins.
544
549
  *
545
550
  * A row whose bin value is missing / non-finite (or, for `edges`, outside
546
551
  * `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
@@ -574,7 +579,12 @@ export declare class TimeSeries<S extends SeriesSchema> {
574
579
  * reducer non-finite policy still applies to the *source* columns.
575
580
  *
576
581
  * 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
582
+ * radius`); a single O(n) two-pointer sweep maintains the window.
583
+ *
584
+ * Pass `{ radius, at }` — a **non-decreasing** array of explicit center values
585
+ * (e.g. a chart's coarse display grid) — to evaluate at those centers instead
586
+ * of at every row, returning **one record per center** (a center with no rows
587
+ * in range yields each reducer's empty value). See
578
588
  * `docs/notes/rolling-by-column.md`.
579
589
  */
580
590
  rollingByColumn<const Mapping extends ValidatedAggregateMap<S, Mapping>>(col: NumericColumnNameForSchema<S>, spec: WindowSpec, mapping: Mapping): Array<ReduceResult<S, Mapping>>;
@@ -995,6 +1005,17 @@ export declare class TimeSeries<S extends SeriesSchema> {
995
1005
  * When `output` is omitted, the smoothed values replace the target column. When `output` is
996
1006
  * supplied, the smoothed values are appended as a new optional numeric column.
997
1007
  *
1008
+ * **Missing cells** (`movingAverage` / `loess`): by default (`missing:
1009
+ * 'bridge'`) a cell whose own value is missing is still assigned a smoothed
1010
+ * value computed from its present neighbours — i.e. the line is drawn *across*
1011
+ * the hole. Pass `missing: 'skip'` to keep a missing cell **missing** in the
1012
+ * output, so a sustained dropout (a coast, a sensor gap) is preserved as a
1013
+ * break rather than fabricated through. Present cells already smooth over only
1014
+ * the present values in their window either way. `ema` is causal and never
1015
+ * fabricates across a gap, so it takes no `missing` option. (estela
1016
+ * F-smooth-interactive; a `maxGap` hard segment boundary is a deferred
1017
+ * follow-on.)
1018
+ *
998
1019
  * **Multi-entity series:** the smoothing window pulls values from
999
1020
  * every entity into each smoothed point — `host-A`'s smoothed value
1000
1021
  * is blended with `host-B`'s and `host-C`'s. On a series carrying
@@ -1010,9 +1031,11 @@ export declare class TimeSeries<S extends SeriesSchema> {
1010
1031
  window: DurationInput;
1011
1032
  alignment?: RollingAlignment;
1012
1033
  output?: Output;
1034
+ missing?: 'skip' | 'bridge';
1013
1035
  } | {
1014
1036
  span: number;
1015
1037
  output?: Output;
1038
+ missing?: 'skip' | 'bridge';
1016
1039
  }): TimeSeries<Output extends string ? SmoothAppendSchema<S, Output> : SmoothSchema<S, Target>>;
1017
1040
  /** Example: `series.slice(0, 10)`. Returns a positional half-open slice of the series. */
1018
1041
  slice(beginIndex?: number, endIndex?: number): TimeSeries<S>;
@@ -872,6 +872,10 @@ export class TimeSeries {
872
872
  }
873
873
  /** Example: `series.rows`. Returns the normalized row view of the series. */
874
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`.
875
879
  return toRows(this.schema, this.events);
876
880
  }
877
881
  /** Example: `series.toRows()`. Returns normalized row arrays using `Time`/`TimeRange`/`Interval` keys and `undefined` for missing payload values. */
@@ -1391,8 +1395,13 @@ export class TimeSeries {
1391
1395
  * source (cumulative distance / work) yields contiguous ranges (per-km
1392
1396
  * splits, elevation-vs-distance profile); a non-monotonic source (power)
1393
1397
  * yields a histogram (distribution).
1394
- * - `{ edges }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins, bin `i` =
1395
- * `[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 interior bins (`(eᵢ, eᵢ₊₁]`) — Coggan power
1401
+ * / HR zones, where a sample exactly on a zone's top edge belongs to the
1402
+ * lower zone. The floor `e₀` stays **inclusive** (bin 0 is `[e₀, e₁]`), so a
1403
+ * minimum-edge value (e.g. a `0` W coast sample) lands in bin 0 rather than
1404
+ * being dropped. Always emits all `n` bins.
1396
1405
  *
1397
1406
  * A row whose bin value is missing / non-finite (or, for `edges`, outside
1398
1407
  * `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
@@ -1426,7 +1435,12 @@ export class TimeSeries {
1426
1435
  * reducer non-finite policy still applies to the *source* columns.
1427
1436
  *
1428
1437
  * 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
1438
+ * radius`); a single O(n) two-pointer sweep maintains the window.
1439
+ *
1440
+ * Pass `{ radius, at }` — a **non-decreasing** array of explicit center values
1441
+ * (e.g. a chart's coarse display grid) — to evaluate at those centers instead
1442
+ * of at every row, returning **one record per center** (a center with no rows
1443
+ * in range yields each reducer's empty value). See
1430
1444
  * `docs/notes/rolling-by-column.md`.
1431
1445
  */
1432
1446
  rollingByColumn(col, spec, mapping) {
@@ -2204,6 +2218,17 @@ export class TimeSeries {
2204
2218
  * When `output` is omitted, the smoothed values replace the target column. When `output` is
2205
2219
  * supplied, the smoothed values are appended as a new optional numeric column.
2206
2220
  *
2221
+ * **Missing cells** (`movingAverage` / `loess`): by default (`missing:
2222
+ * 'bridge'`) a cell whose own value is missing is still assigned a smoothed
2223
+ * value computed from its present neighbours — i.e. the line is drawn *across*
2224
+ * the hole. Pass `missing: 'skip'` to keep a missing cell **missing** in the
2225
+ * output, so a sustained dropout (a coast, a sensor gap) is preserved as a
2226
+ * break rather than fabricated through. Present cells already smooth over only
2227
+ * the present values in their window either way. `ema` is causal and never
2228
+ * fabricates across a gap, so it takes no `missing` option. (estela
2229
+ * F-smooth-interactive; a `maxGap` hard segment boundary is a deferred
2230
+ * follow-on.)
2231
+ *
2207
2232
  * **Multi-entity series:** the smoothing window pulls values from
2208
2233
  * every entity into each smoothed point — `host-A`'s smoothed value
2209
2234
  * is blended with `host-B`'s and `host-C`'s. On a series carrying
@@ -2290,8 +2315,14 @@ export class TimeSeries {
2290
2315
  loessValues.push(value);
2291
2316
  }
2292
2317
  }
2318
+ // `missing: 'skip'` — a cell whose own value is missing stays missing
2319
+ // (don't fit a fabricated value across the hole from its present
2320
+ // neighbours). Default `'bridge'` is the prior behaviour (fit everywhere).
2321
+ const skipMissing = options.missing === 'skip';
2293
2322
  const resultRows = this.events.map((event, index) => {
2294
- const smoothed = loessAt(anchors[index], loessAnchors, loessValues, span);
2323
+ const smoothed = skipMissing && sourceValues[index] === undefined
2324
+ ? undefined
2325
+ : loessAt(anchors[index], loessAnchors, loessValues, span);
2295
2326
  const nextEvent = output === undefined
2296
2327
  ? event.set(column, smoothed)
2297
2328
  : event.merge({ [output]: smoothed });
@@ -2314,6 +2345,7 @@ export class TimeSeries {
2314
2345
  const window = options.window;
2315
2346
  const windowMs = parseDuration(window);
2316
2347
  const alignment = options.alignment ?? 'trailing';
2348
+ const skipMissing = options.missing === 'skip';
2317
2349
  const resultValues = new Array(this.events.length);
2318
2350
  let windowStart = 0;
2319
2351
  let windowEnd = 0;
@@ -2380,7 +2412,12 @@ export class TimeSeries {
2380
2412
  }
2381
2413
  const smoothed = snapshot();
2382
2414
  for (let index = groupStart; index < groupEnd; index++) {
2383
- resultValues[index] = smoothed;
2415
+ // `missing: 'skip'` — a cell whose own value is missing stays missing
2416
+ // (don't fabricate it from the window average). Default `'bridge'` fills.
2417
+ resultValues[index] =
2418
+ skipMissing && sourceValues[index] === undefined
2419
+ ? undefined
2420
+ : smoothed;
2384
2421
  }
2385
2422
  groupStart = groupEnd;
2386
2423
  }
@@ -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.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {