@pond-ts/charts 0.31.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +3254 -0
  2. package/LICENSE +21 -0
  3. package/README.md +229 -0
  4. package/dist/AreaChart.d.ts +85 -0
  5. package/dist/AreaChart.js +119 -0
  6. package/dist/BandChart.d.ts +55 -0
  7. package/dist/BandChart.js +93 -0
  8. package/dist/BarChart.d.ts +72 -0
  9. package/dist/BarChart.js +137 -0
  10. package/dist/BoxPlot.d.ts +77 -0
  11. package/dist/BoxPlot.js +137 -0
  12. package/dist/Canvas.d.ts +37 -0
  13. package/dist/Canvas.js +39 -0
  14. package/dist/ChartContainer.d.ts +106 -0
  15. package/dist/ChartContainer.js +306 -0
  16. package/dist/ChartRow.d.ts +29 -0
  17. package/dist/ChartRow.js +215 -0
  18. package/dist/Layers.d.ts +22 -0
  19. package/dist/Layers.js +399 -0
  20. package/dist/LineChart.d.ts +60 -0
  21. package/dist/LineChart.js +105 -0
  22. package/dist/ScatterChart.d.ts +84 -0
  23. package/dist/ScatterChart.js +139 -0
  24. package/dist/TimeAxis.d.ts +9 -0
  25. package/dist/TimeAxis.js +12 -0
  26. package/dist/XAxis.d.ts +39 -0
  27. package/dist/XAxis.js +84 -0
  28. package/dist/YAxis.d.ts +42 -0
  29. package/dist/YAxis.js +86 -0
  30. package/dist/annotations.d.ts +110 -0
  31. package/dist/annotations.js +459 -0
  32. package/dist/area.d.ts +54 -0
  33. package/dist/area.js +186 -0
  34. package/dist/band.d.ts +31 -0
  35. package/dist/band.js +57 -0
  36. package/dist/bars.d.ts +96 -0
  37. package/dist/bars.js +171 -0
  38. package/dist/box.d.ts +59 -0
  39. package/dist/box.js +140 -0
  40. package/dist/chip.d.ts +23 -0
  41. package/dist/chip.js +43 -0
  42. package/dist/cjs-fallback.cjs +16 -0
  43. package/dist/context.d.ts +362 -0
  44. package/dist/context.js +5 -0
  45. package/dist/curve.d.ts +22 -0
  46. package/dist/curve.js +13 -0
  47. package/dist/data.d.ts +154 -0
  48. package/dist/data.js +197 -0
  49. package/dist/domain.d.ts +19 -0
  50. package/dist/domain.js +61 -0
  51. package/dist/encoding.d.ts +89 -0
  52. package/dist/encoding.js +144 -0
  53. package/dist/format.d.ts +53 -0
  54. package/dist/format.js +47 -0
  55. package/dist/gaps.d.ts +146 -0
  56. package/dist/gaps.js +209 -0
  57. package/dist/grid.d.ts +11 -0
  58. package/dist/grid.js +29 -0
  59. package/dist/index.d.ts +53 -0
  60. package/dist/index.js +34 -0
  61. package/dist/line.d.ts +46 -0
  62. package/dist/line.js +88 -0
  63. package/dist/range.d.ts +15 -0
  64. package/dist/range.js +27 -0
  65. package/dist/scatter.d.ts +70 -0
  66. package/dist/scatter.js +213 -0
  67. package/dist/select.d.ts +13 -0
  68. package/dist/select.js +23 -0
  69. package/dist/slots.d.ts +48 -0
  70. package/dist/slots.js +64 -0
  71. package/dist/theme.d.ts +224 -0
  72. package/dist/theme.js +232 -0
  73. package/dist/tracker.d.ts +30 -0
  74. package/dist/tracker.js +47 -0
  75. package/dist/use-slot-key.d.ts +21 -0
  76. package/dist/use-slot-key.js +25 -0
  77. package/dist/viewport.d.ts +20 -0
  78. package/dist/viewport.js +30 -0
  79. package/package.json +67 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,3254 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
7
+ `@pond-ts/fit` — release together under a single `v*` tag, so this file covers
8
+ them all. Pre-1.0: minor bumps may include new features and type-level changes;
9
+ patch bumps are strictly additive.
10
+
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.31.0...HEAD
12
+ [0.31.0]: https://github.com/pjm17971/pond-ts/compare/v0.30.0...v0.31.0
13
+ [0.30.0]: https://github.com/pjm17971/pond-ts/compare/v0.29.0...v0.30.0
14
+ [0.29.0]: https://github.com/pjm17971/pond-ts/compare/v0.28.0...v0.29.0
15
+ [0.28.0]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...v0.28.0
16
+ [0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
17
+ [0.26.0]: https://github.com/pjm17971/pond-ts/compare/v0.25.0...v0.26.0
18
+ [0.25.0]: https://github.com/pjm17971/pond-ts/compare/v0.24.0...v0.25.0
19
+ [0.24.0]: https://github.com/pjm17971/pond-ts/compare/v0.23.0...v0.24.0
20
+ [0.23.0]: https://github.com/pjm17971/pond-ts/compare/v0.22.0...v0.23.0
21
+ [0.22.0]: https://github.com/pjm17971/pond-ts/compare/v0.21.0...v0.22.0
22
+ [0.21.0]: https://github.com/pjm17971/pond-ts/compare/v0.20.0...v0.21.0
23
+ [0.20.0]: https://github.com/pjm17971/pond-ts/compare/v0.19.0...v0.20.0
24
+ [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
25
+ [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
26
+
27
+ ## [0.31.0] — 2026-06-28
28
+
29
+ First published release of **`@pond-ts/charts`** and **`@pond-ts/fit`** (both were
30
+ previously `private`). All four packages — `pond-ts`, `@pond-ts/react`,
31
+ `@pond-ts/charts`, `@pond-ts/fit` — now release together, lock-step, under one
32
+ `v*` tag.
33
+
34
+ ### Added — `pond-ts` (core)
35
+
36
+ - **`ValueSeries` + `TimeSeries.byValue(axis)` — the value axis as a closed
37
+ type.** `byValue` re-keys a series onto a monotonic non-time **value axis**
38
+ (distance, cumulative work, …), returning a `ValueSeries` — the value-keyed
39
+ counterpart of `TimeSeries`. It carries the ordering-based operators
40
+ (`axisValues`, `axisAt`, `column`, `nearestIndex`, `sliceByValue`); the
41
+ calendar/clock operators are deliberately absent — a value axis has no
42
+ wall-clock semantics, and the disjoint `ValueSeriesSchema` makes them
43
+ type-impossible. The axis must be **defined, finite, and non-decreasing at
44
+ every row** (it becomes the index); it is dropped from the value columns (it
45
+ is now the key) and the rest reshare zero-copy. Substrate: a new `'value'`
46
+ `KeyKind` + `ValueKeyColumn`. Projection is O(N + C); `nearestIndex` is
47
+ O(log N); `sliceByValue` is O(log N + C) zero-copy. (value-axis RFC Phase 1.)
48
+ - **`scan(source, step, init, options?)` — typed-accumulator running fold.** The
49
+ general form of `cumulative` (the classic `mapAccumL`): the accumulator `A`
50
+ (any value, seeded from `init`) is **decoupled** from the numeric `output` and
51
+ the output column. `step(acc, value, i)` returns `[nextAcc, output]`. With no
52
+ `options.output` the source column is **replaced** in place (as `cumulative`
53
+ does); with `options.output` a **new** column is appended and the source is
54
+ left intact. Missing-cell carry, stored-`NaN`, and multi-entity semantics are
55
+ inherited from `cumulative` (scope per entity with
56
+ `partitionBy(col).scan(...).collect()`). Column-native, O(N + C), no event
57
+ materialization. Enables `split = scan + byColumn` — materialize cross-bin
58
+ state (e.g. hysteresis elevation gain) into a column, then segment it with
59
+ `byColumn`'s pure, order-free reducers. (estela F-geo-2-splits; value-axis
60
+ RFC wave lead.)
61
+
62
+ ### Added — `@pond-ts/charts` (initial release)
63
+
64
+ - **First public release.** A React charting layer over pond-ts — a canvas data
65
+ plane with SVG interactive overlays. `ChartContainer` / `ChartRow` / `Layers`
66
+ composition; `LineChart`, `AreaChart`, `BarChart`, `Scatter`, `BoxPlot`;
67
+ `TimeAxis` / `YAxis` / `XAxis` (time **and** value x-axes); the cursor system
68
+ (staffed flag, per-row cursor modes); shared gap-rendering modes; and the
69
+ estela theme. Peer-depends on `pond-ts`, `@pond-ts/react`, and React 18/19.
70
+
71
+ ### Added — `@pond-ts/fit` (initial release)
72
+
73
+ - **First public release.** A fitness / activity domain library over pond-ts — the
74
+ `Activity` / `Section` façade, unit-safe quantities (`Distance` / `Speed` /
75
+ `Power` / … with `.format()`), geo / power / zones analytics, `Profile` +
76
+ `usingProfile()` → `ProfiledActivity` / `ProfiledSection`, and the `Track`
77
+ value object. Façade-first: one curated flat barrel, with the functional
78
+ operator surface kept internal. Peer-depends on `pond-ts`.
79
+
80
+ ### Changed
81
+
82
+ - **All `@pond-ts/*` peer / dependency ranges widened to `^0.31.0`** for the
83
+ lock-step release.
84
+
85
+ ## [0.30.0] — 2026-06-17
86
+
87
+ ### Added
88
+
89
+ - **`rollingByColumn(col, { radius, at }, mapping)` — evaluate at explicit
90
+ centers.** `at` takes a **non-decreasing** array of center values (e.g. a chart's
91
+ coarse display grid) and returns **one record per center**, instead of the
92
+ default one-per-row. A center with no rows within `±radius` yields each
93
+ reducer's empty value. Same O(n + centers) two-pointer. Closes the
94
+ evaluate-at-grid gap surfaced adopting `rollingByColumn` for a chart variance
95
+ band. (estela F-rolling-by-row.)
96
+ - **`smooth(col, 'movingAverage' | 'loess', { …, missing: 'skip' })` —
97
+ validity-respecting smoothing.** By default (`missing: 'bridge'`) a cell whose
98
+ own value is missing is still assigned a smoothed value from its present
99
+ neighbours — the line is drawn _across_ the hole. `missing: 'skip'` keeps a
100
+ missing cell **missing** in the output, so a sustained dropout (a coast, a
101
+ sensor gap) is preserved as a break rather than fabricated through. Present
102
+ cells smooth over only the present values in their window either way. `ema`
103
+ takes no `missing` option (it is causal and never fabricates across a gap). A
104
+ `maxGap` hard segment boundary is a deferred follow-on. (estela
105
+ F-smooth-interactive.)
106
+
107
+ ### Changed
108
+
109
+ - **`byColumn(…, { inclusive: '(]' })` floor edge is now inclusive.** Under
110
+ `'(]'`, interior bins stay upper-inclusive (`(eᵢ, eᵢ₊₁]`) but the **floor `e₀`
111
+ is inclusive** (bin 0 is `[e₀, e₁]`), so a value at exactly the minimum edge —
112
+ e.g. a `0` W coast/stop sample at a zone floor of 0 — lands in bin 0 instead of
113
+ being dropped (the `include_lowest` convention). Previously the floor was
114
+ exclusive. (estela F-inclusive-floor.)
115
+
116
+ ## [0.29.0] — 2026-06-17
117
+
118
+ ### Added
119
+
120
+ - **`byColumn({ edges, inclusive })`** — `inclusive: '(]'` makes edge bins
121
+ upper-inclusive (`(eᵢ, eᵢ₊₁]`), for Coggan power / HR zones where a sample on a
122
+ zone's top edge belongs to the lower zone (the first edge becomes an exclusive
123
+ floor). Defaults to `'[)'` (unchanged — lower-inclusive `[eᵢ, eᵢ₊₁)`). (estela
124
+ F-geo-2 zone inclusivity.)
125
+ - **`'mean'` reducer alias for `'avg'`** — `'mean'` is now an accepted built-in
126
+ reducer name across `aggregate` / `rolling` / `byColumn` / `rollingByColumn` /
127
+ `reduce` (and the live equivalents), at **both runtime and the type level**: it
128
+ resolves to the `avg` kernel and classifies as numeric output
129
+ (`number | undefined`), exactly like `'avg'`. Matches the column API's
130
+ `Float64Column.mean()`. (estela F-reducer-naming.)
131
+
132
+ ### Fixed
133
+
134
+ - **`RowForSchema` honors `required: false`** — a **value** column declared
135
+ `required: false` now accepts `undefined` in its tuple-row cell at the type
136
+ level (matching the runtime, which records it as missing), so optional cells no
137
+ longer need an `as never` cast. The **key (first) column stays required** even
138
+ if marked `required: false` (the constructor always requires it). `null` is
139
+ still not admitted for tuple rows (only the JSON object-row path takes `null`).
140
+ Correspondingly, **`.rows` / `toRows()` now type an optional cell as
141
+ `… | undefined`** (`NormalizedRowForSchema`), so reading a possibly-missing
142
+ cell is no longer unsoundly typed as present — a type tightening on output for
143
+ schemas that use `required: false`. (estela F-geo-row-optional; Codex-hardened.)
144
+
145
+ ## [0.28.0] — 2026-06-17
146
+
147
+ ### Added
148
+
149
+ - **`TimeSeries.rollingByColumn(col, { radius }, mapping)` — windowed value-axis
150
+ aggregation.** The sliding-window sibling of `byColumn`: slides a centered
151
+ `±radius` window along a **non-decreasing** numeric column and reduces it at
152
+ every row, returning one record per row (positionally aligned with the
153
+ series). Where `byColumn` collapses rows into disjoint value-bins (the
154
+ value-axis analogue of `aggregate`), `rollingByColumn` is the value-axis
155
+ analogue of `rolling`. Built for windowed-percentile bands over a derived axis
156
+ (e.g. a spread band over cumulative distance). A missing/non-finite axis row is
157
+ excluded from every window and emits each reducer's empty value. O(n) two-pointer
158
+ sweep. See `docs/notes/rolling-by-column.md`.
159
+ - **`TimeSeries.withColumn(name, values)` — attach a computed numeric column.**
160
+ Appends a `Float64Array` / `(number | undefined)[]` as a new `number` column
161
+ (the schema type widens to include it), so a derived array — cumulative
162
+ distance, speed, gradient — can re-enter the pond pipeline as a real column
163
+ that `aggregate` / `byColumn` / `rollingByColumn` / `column(name)` can
164
+ reference. Existing key + value columns are shared by reference (zero-copy);
165
+ only the new column is added. `values` must match `series.length`; defined
166
+ cells are validated against the numeric intake contract (**non-finite is
167
+ rejected** — pass `undefined` for a missing cell, not `NaN`).
168
+
169
+ ### Added
170
+
171
+ - **`TimeSeries.byColumn(col, { width, origin? } | { edges }, mapping)` —
172
+ value-axis aggregation.** Where `aggregate` buckets the temporal key,
173
+ `byColumn` buckets rows by the **value** of a numeric column and reduces each
174
+ bin, returning an ordered array of `{ start, end, ...aggregates }` records
175
+ (one per bin) — not a `TimeSeries`, since value-bins (distance / power ranges)
176
+ aren't time-indexed. `{ width }` gives even bins emitted contiguously from the
177
+ lowest to highest occupied bin (monotonic source → splits / profile;
178
+ non-monotonic → histogram); `{ edges }` gives explicit ascending bins (e.g.
179
+ power zones). Reuses the reducer mapping + non-finite policy. Rows whose bin
180
+ value is missing / non-finite (or, for `edges`, out of range) are dropped;
181
+ empty bins emit the reducer's empty value; a non-finite / wrong-kind reducer
182
+ result throws `ValidationError`. See `docs/notes/bycolumn-value-axis.md`.
183
+
184
+ ### Changed
185
+
186
+ - **`rolling(...)` now builds its output columns directly instead of
187
+ materializing events.** The rolling family was the last batch operator still
188
+ assembling a row per event and re-validating/re-packing it through the
189
+ constructor; it now reads the key axis and source values straight off the
190
+ columnar store and writes the result columns via trusted construction. The
191
+ result is unchanged for the common (scalar) cases. Measured: `rolling` with
192
+ `avg`/`sum` ~2.2–2.7× faster; rolling `stdev` on 100k events ~3.3–7.3×
193
+ (a 1-event window 45.7 ms → 6.3 ms); partitioned rolling ~1.8×.
194
+ `baseline` / `outliers` (which delegate to `rolling`) inherit the speedup.
195
+ - **Behavior note — `array` columns:** an identity-comparing reducer (`keep`,
196
+ or a custom reducer using `===` on the cell) on an `array`-kind source
197
+ column now compares the value stored in the column, not the original object
198
+ reference passed at construction. Two rows given the _same_ array object
199
+ therefore read as distinct. Scalar columns (number / string / boolean) are
200
+ unaffected. A non-finite or wrong-kind reducer result is still rejected with
201
+ a `ValidationError`, exactly as the constructor's intake did.
202
+
203
+ ## [0.25.0] — 2026-06-15
204
+
205
+ ### Changed
206
+
207
+ - **Reducers now treat non-finite numerics (`NaN` / `±Infinity`) as missing —
208
+ they are skipped — uniformly across every built-in reducer and all four
209
+ execution paths (`reduce`, the columnar fast path, `aggregate`/bucket, and
210
+ `rolling`/live).** Previously the paths disagreed on non-finite input: e.g.
211
+ `min`/`max` returned a position-dependent wrong extreme on the batch/columnar
212
+ paths but the true extreme on aggregate/rolling; `sum`/`avg` propagated
213
+ `NaN`. Non-finite can't enter via the row API (intake rejects it) — it only
214
+ arises inside computed columns (`cumulative` overflow, `diff`/`rate`
215
+ overflow, `collapse`, trusted construction) — so this only changes results
216
+ for those degenerate values, and makes every path agree. The three-layer
217
+ contract: **intake** stays strict (rejects non-finite), **computed writers**
218
+ stay permissive (pack honest non-finite), **reducers** are robust (skip it).
219
+ A standing parity-matrix test now pins all paths together. See
220
+ `docs/notes/reducer-nan-policy.md`. This also resolves the `aggregate('stdev')`
221
+ divergence class and the `min`/`max` NaN-laundering bug.
222
+ - Internal: `Float64Column` gained an `allFinite` fast-path flag (data-derived
223
+ at construction, conservative-by-default) so reducers skip the per-element
224
+ finite check on provably-finite columns — keeping the policy's cost off the
225
+ hot path (min/max/count stay at their pre-policy speed).
226
+
227
+ ### Fixed
228
+
229
+ - **`rolling(window, { x: 'stdev' })` is now numerically stable.** It was the
230
+ last batch stdev path still on the one-pass `Σx²/n − mean²`, which cancels
231
+ catastrophically on near-equal large values (`[1e10, 1e10+1, …]` → `0`
232
+ instead of ≈1.118, or a negative variance → `NaN`) and drifts on trending
233
+ data (cumulative distance, elevation). It now uses Welford's online variance
234
+ with an order-independent **delete** — deviation-space, so no cancellation,
235
+ and removal **by value**, which keeps it correct under the live layer's
236
+ `reorder`-mode eviction (a positional/FIFO remove would have broken it; the
237
+ documented "stdev is reorder-safe" contract is preserved). Rolling-stdev
238
+ values shift in the last ULPs (now correct); the path stays O(1) and within
239
+ run-noise of the old one-pass, and a single-element window now reports exactly
240
+ `0` at any magnitude. Like any subtractive sliding variance, evicting an
241
+ outlier far outside the residual spread loses precision — negligible until the
242
+ evicted point is ~1e7–1e8× the residual stdev, far beyond realistic data.
243
+ - A standing differential-fuzz parity suite now pins every built-in reducer's
244
+ execution paths (columnar fast path vs `bucket` vs `rolling`, and the FIFO
245
+ sliding window vs a from-scratch recompute) against silent drift across
246
+ randomized magnitudes and window sizes — the class of bug behind the stdev
247
+ and `min`/`max` divergences.
248
+
249
+ ## [0.24.0] — 2026-06-14
250
+
251
+ ### Changed
252
+
253
+ - **`TimeSeries.timeRange()` is now a columnar key-axis read instead of a
254
+ reduce over materialized events.** Behavior is unchanged, but the old
255
+ implementation materialized every `Event` on its first call — and because
256
+ `aggregate()` defaults its `range` to `series.timeRange()`, a one-shot
257
+ `aggregate()` paid full event materialization before the columnar fast
258
+ path could run, erasing the win. The new path reads the key column's
259
+ begin/end axis directly: O(1) for time-keyed series, a typed-array scan
260
+ for range/interval-keyed series, with no event materialization. Measured
261
+ on 1M rows: `timeRange()` itself ~407 ms → ~0.002 ms (time-keyed); cold
262
+ `aggregate()` with a defaulted range ~387 ms → ~6 ms (~63×). Every
263
+ `timeRange()` / `overlaps` / `contains` / `intersection` caller benefits.
264
+ (Audit v2 §3.3.)
265
+ - **`aggregate()` now takes the columnar fast path when a mapping mixes
266
+ numeric reducers with `first` / `last`.** Previously a single `first` or
267
+ `last` column (they have no numeric `reduceColumn`) bailed the entire call
268
+ to the row path. They now qualify via a boundary scan — the first/last
269
+ _defined_ cell, on any column kind. Behavior is unchanged. The big
270
+ beneficiary is **partitioned `aggregate`**, which auto-injects a `'first'`
271
+ reducer for the partition column and so was excluded from the fast path on
272
+ every call (audit v2 §3.2/§3.3). Measured on 1M rows, flat
273
+ `{ cpu: 'avg', host: 'first' }`: ~37.7 ms → ~4.8 ms (~7.8×); the
274
+ pure-numeric path is unchanged. (The remaining `partitionBy` materialization
275
+ cost is addressed separately by the columnar `partitionBy` split.)
276
+ - **`partitionBy(...)` now splits the columnar store directly instead of
277
+ materializing events.** `collect()` / the per-partition sugar methods
278
+ (`fill` / `diff` / `rolling` / …) and `toMap()` previously walked
279
+ `this.events` to bucket rows, then rebuilt each partition via `fromEvents`
280
+ (re-validating + re-packing) — silently re-paying the event-materialization
281
+ tax the columnar wave removed, and making `partitionBy(host).fill().collect()`
282
+ the #1 batch hotspot. They now group row indices off the store and gather
283
+ each partition via a zero-materialization columnar selection. Behavior is
284
+ unchanged (partition order, the `' undefined'` missing-key bucket, composite
285
+ keys, and declared `groups` all preserved). Measured on 100k rows / 64
286
+ partitions: `toMap()` ~389 → ~25 ns/row (~15×, no event materialization at
287
+ all); `diff().collect()` ~2×; `fill(hold).collect()` ~1.7× (the residual is
288
+ `TimeSeries.concat` still materializing to re-sort — a separate follow-up).
289
+ Declared-`groups` membership is validated by the same columnar scan, so that
290
+ path is materialization-free too (~331 → ~33 ns/row). **Behavior note:**
291
+ per-partition sub-series from `toMap()` / `apply()` now lazily materialize
292
+ their own `Event` objects rather than reusing the source's instances — cell
293
+ values are identical; only object identity differs (`collect()`, which
294
+ returns the source unchanged, is unaffected). (Audit v2 §3.2.)
295
+
296
+ ## [0.23.0] — 2026-06-13
297
+
298
+ ### Added
299
+
300
+ - **`new TimeSeries({ …, sort: true })` (and `TimeSeries.fromJSON`) sort rows by
301
+ key on construction.** Pond requires rows in non-decreasing key order and
302
+ throws otherwise; `sort: true` accepts unsorted input (messy CSVs, merged
303
+ sources) and sorts it for you instead of forcing a manual pre-sort. The sort
304
+ is **stable** — rows with equal keys keep their input order — matching what
305
+ `TimeSeries.fromEvents` already does. The out-of-order error now names the
306
+ option. (Audit v2 §5 F3.)
307
+
308
+ ### Changed
309
+
310
+ - **CommonJS consumers now get a clear error instead of
311
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`.** Both `pond-ts` and `@pond-ts/react`
312
+ add a `require` condition to their `exports["."]` map pointing at a tiny
313
+ shipped CJS stub that throws an ESM-only message naming `import` as the
314
+ fix. The packages remain ES-module-only; this only improves the error a
315
+ `require('pond-ts')` caller sees. (Audit v2 §5 F6/F7/F9/F10/F11)
316
+ - **Published tarballs no longer ship `*.js.map` / `*.d.ts.map` source
317
+ maps.** The maps referenced a `../src` tree that was never included in
318
+ the tarball (`files: ["dist", …]`), so they were dead weight (~⅓ of the
319
+ unpacked size). A `prepack` step now strips them from the published
320
+ artifact for both packages; local `npm run build` still emits them, so
321
+ in-repo debugging is unaffected. (Audit v2 §5 F6/F7/F9/F10/F11)
322
+
323
+ ### Fixed
324
+
325
+ - **Shipped `.d.ts` now type-check under `skipLibCheck: false`.** The internal
326
+ `EMITS_EVICT` marker symbol was `@internal` (stripped from the emitted
327
+ `series.d.ts`) but still referenced by un-stripped public declarations — a
328
+ by-name re-export in `schema/index.d.ts` and the `[EMITS_EVICT]` brand members
329
+ on `LiveSeries` / `LiveView` — leaving dangling references that broke strict
330
+ consumer builds with **TS2305**. Those references are now `@internal` too, so
331
+ the symbol is fully stripped from the published types; runtime behavior is
332
+ unchanged. (Audit v2 §5 F2.)
333
+ - **`TimeSeries.at(-1)` counts from the end**, matching `LiveSeries.at` and
334
+ `Array.prototype.at` (it previously returned `undefined` for any negative
335
+ index). Deep underflow (e.g. `at(-100)` on a 3-event series) still returns
336
+ `undefined`, and the non-integer / `NaN` guard is unchanged. (Audit v2 §5 F8.)
337
+ - **Docs: corrected `Time.asString()` (does not exist), the missing
338
+ `aggregate`/`materialize` → `pivotByGroup` rekey pointer, and an
339
+ inaccurate `rolling().value()` return-type example.** The getting-started
340
+ example now calls `event.key().toDate().toISOString()`; the aggregation
341
+ and reshape pages note that interval-keyed output must be rekeyed with
342
+ `.asTime({ at: 'begin' })` before a time-keyed transform like
343
+ `pivotByGroup` (whose runtime error now says so too); the rolling page
344
+ documents `value()` as `Record<string, ColumnValue | undefined>`.
345
+ (Audit v2 §5 F6/F7/F9/F10/F11)
346
+ - **Mixed shorthand + `{ from, using }` mappings now keep every output
347
+ column in the result type (Audit v2 §5 F1).** Calling
348
+ `aggregate` / `rolling` / `reduce` with a mapping that mixes the
349
+ shorthand form (`cpu: 'avg'`) and the spec form
350
+ (`cpu_p95: { from: 'cpu', using: 'p95' }`) in one call — the
351
+ docs-blessed pattern — previously resolved to the shorthand overload
352
+ and **silently dropped every spec-keyed output column from the result
353
+ type** (`event.get('cpu_p95')` failed to compile with `TS2345`), even
354
+ though the runtime emitted the column. The two overloads
355
+ (`AggregateMap` shorthand + `AggregateOutputMap` spec) are now
356
+ collapsed into one unified mapping shape whose result schema dispatches
357
+ per output key, so all columns survive and each narrows to its
358
+ reducer's output kind. Runtime behavior is unchanged — this is a
359
+ types-only fix plus the tests that should have caught it.
360
+ - **The unified mapping keeps the shorthand compile-time guards.** A
361
+ shorthand reducer is still kind-checked against its source column
362
+ (`host: 'avg'` on a `string` column stays a compile error), and a bare
363
+ reducer on a key that is not a source column (`ghost: 'avg'` — a typo
364
+ the runtime rejects with "unknown source column") is now a compile
365
+ error too. Spec keys (`{ from, using }`) remain free output names.
366
+ Inline mapping literals get full validation; values pre-widened to
367
+ `AggregateMap<S>` and broad-schema (`TimeSeries<SeriesSchema>`)
368
+ callers keep the permissive shape. `AggregateOutputMap` is retained
369
+ as a back-compat alias of `AggregateMap`.
370
+
371
+ ## [0.22.0] — 2026-06-12
372
+
373
+ ### Changed
374
+
375
+ - **`asTime` / `asTimeRange` / `asInterval` are now column-native.** They
376
+ reinterpret the key's kind (a "rekey") straight off the existing key's
377
+ `begin` / `end` buffers instead of materializing events — value columns pass
378
+ through by reference. `asTimeRange` and `asTime` with `begin` / `end` reuse
379
+ the key buffer zero-copy (≈ **9×** faster on a build → rekey → read pipeline);
380
+ `asTime({ at: 'center' })` adds one midpoint pass; `asInterval` builds the
381
+ label column (string → `StringColumn`, number → `Float64Column`, inferred
382
+ from the first label and required consistent across rows). `asTime` with
383
+ `center` / `end` throws if anchoring a source with overlapping extents would
384
+ produce a non-monotonic time axis (preserving the prior validation — `begin`
385
+ is always sorted and is exempt).
386
+ - **Breaking: `asInterval`'s label function now receives the interval's
387
+ `TimeRange` (its `[begin, end]` extent) and index — not the whole `Event`.**
388
+ The canonical form is unchanged: `series.asInterval(range => range.begin())`
389
+ works exactly as before (both `Event` and `TimeRange` expose `begin()` /
390
+ `end()`). Only a label fn that read a _value column_ off the event (e.g.
391
+ `event => event.get('label')`) needs rewriting — compute the label before
392
+ `asInterval`, or derive it from the extent. The constant form
393
+ (`asInterval('bucket')` / `asInterval(42)`) is unaffected. (Pre-1.0 minor;
394
+ this is the change that lets the function form stay on the columnar path.)
395
+
396
+ ### Fixed
397
+
398
+ - **`mapColumns` rejects a non-finite numeric result at write.** A mapper on a
399
+ `number` column that returns `NaN` or `±Infinity` now throws a `RangeError`,
400
+ consistent with construction intake (which already rejects non-finite
401
+ numbers). Previously the value was packed into the column, where the reduce
402
+ fast path and the row path could disagree on the same bucket (e.g.
403
+ `aggregate('min')` returning a different result depending on which path ran).
404
+ A stored `NaN` is still a defined value the mapper sees — map it to a finite
405
+ number, or to `undefined` (missing), to clean it. (Closes a hole introduced
406
+ alongside `mapColumns` in 0.21.0.)
407
+ - **`aggregate('stdev')` is now numerically stable and path-independent.** The
408
+ bucketed row path (`bucketState`) used a one-pass `sq/n − mean²` accumulator
409
+ that cancels catastrophically on near-equal large-magnitude values —
410
+ returning `0` (e.g. `[1e10, 1e10+1, 1e10+2, 1e10+3]` → `0` instead of
411
+ `≈1.118`), or a negative variance whose `sqrt` is `NaN` that the validating
412
+ constructor then rejected with a throw. Because the columnar fast path is
413
+ all-or-nothing, an unrelated mapping (e.g. a `count` over a string column)
414
+ could silently flip the _same_ series' stdev. All three batch paths (`reduce`,
415
+ `reduceColumn`, `bucketState`) now share **one Welford recurrence** — O(1) per
416
+ element, no buffer (so the live aggregation path that shares `bucketState`
417
+ stays O(1)), `m2 ≥ 0` by construction — so they agree regardless of magnitude.
418
+ (Even the prior two-pass `Σv/n`-then-deviations drifted ~8.7% from the true
419
+ value at `2^52`, where the summed mean rounds — so unifying on Welford, not
420
+ two-pass, was necessary.) **Correction:** 0.21.0's columnar `aggregate()` fast
421
+ path (#186) was described as "signature + semantics unchanged", but it did
422
+ change released `stdev` output for fast-path-qualifying aggregates; this fix
423
+ makes every path agree. (`rolling`/`smooth` stdev keep the one-pass form for
424
+ now — a separate, deferred item.)
425
+
426
+ ## [0.21.0] — 2026-06-11
427
+
428
+ ### Added
429
+
430
+ - **`TimeSeries.mapColumns({ col: (value) => newValue })`** — a per-cell column
431
+ value transform. The column-scoped counterpart of the event-based `map()`:
432
+ where `map(schema, event => newEvent)` rebuilds whole rows through an
433
+ arbitrary closure (and can change the schema/key), `mapColumns` transforms
434
+ individual columns' values in place, reading the columns directly (no
435
+ per-row `Event`) so it stays on the fast columnar path. Same kind in/out
436
+ (number→number, string→string, …), so the schema is unchanged; missing cells
437
+ carry (the mapper isn't called on `undefined`). ~5–6× faster than the
438
+ `map()` workaround on a build → transform → read pipeline.
439
+
440
+ ### Changed
441
+
442
+ - **`select` / `rename` / `slice` / `cumulative` / `diff` / `rate` /
443
+ `pctChange` / `fill` / `shift` / `collapse` are now column-native.** They
444
+ reshape the columnar store directly instead of materializing events, so the
445
+ columnar construction win is preserved through these transforms — build →
446
+ transform → read pipelines run several× faster (~7–10× for `select` /
447
+ `rename` / `slice`; ~5–7× for the `cumulative` / `diff` / `rate` / `fill` /
448
+ `shift` / `collapse` folds). No API change for type-correct callers (one
449
+ narrow `fill` behavior change is noted under Fixed). `cumulative` / `diff` /
450
+ `rate` / `pctChange` / `fill` / `shift` / `collapse` are also the first
451
+ operators extracted into `batch/operators/` (internal refactor); `fill`
452
+ rebuilds only the columns it actually changes; `slice` normalizes
453
+ `Array.prototype.slice` semantics onto a zero-copy `withRowRange` reshape;
454
+ `collapse` reads only the keyed columns and passes the kept columns through
455
+ by reference.
456
+
457
+ ### Fixed
458
+
459
+ - **`rename` now rejects target-name collisions** (e.g. renaming `a` → `b`
460
+ when `b` already exists) with a clear error, instead of silently producing a
461
+ duplicate-named schema. Also fixes a prototype-chain bug where a column named
462
+ `toString` (or another `Object.prototype` member) could be corrupted during
463
+ a rename.
464
+ - **`fill` now throws on a kind-mismatched literal** (e.g.
465
+ `fill({ value: 'banana' })` where `value` is numeric — type-allowed because
466
+ mapping values are the broad `FillStrategy | ScalarValue`) with a clear
467
+ `RangeError` naming the column, instead of silently producing an
468
+ internally-inconsistent series (the old events path returned the literal
469
+ from `.get()` while the numeric column read `NaN`). The throw is
470
+ gap-dependent — it only fires when the literal would actually be placed.
471
+
472
+ ## [0.20.0] — 2026-06-04
473
+
474
+ Two internal performance improvements driven by the dashboard experiment at
475
+ 256-host stress. **No public API changes** — both are behavior-preserving.
476
+
477
+ ### Changed
478
+
479
+ - **Column-native partition routing.** `partitionBy(...)` over a strict
480
+ time-keyed source now routes its source chunks into per-partition
481
+ **chunked** sub-series via a coalescing staging tier, replacing the
482
+ per-partition `Event[]` retention. A large drop in retained memory and
483
+ object count at high partition counts (gRPC bench at 256 partitions: 60×
484
+ fewer columnar stores, −99.4% `Event` retention, +24% sustained throughput)
485
+ ([#175](https://github.com/pjm17971/pond-ts/pull/175)). Behavior-preserving;
486
+ internal only — no public surface added.
487
+ - **`LiveView.toTimeSeries()` snapshot caching.** The built `TimeSeries` is
488
+ memoized against an internal mutation counter, so back-to-back
489
+ identical-state calls (multiple subscribers, framework commit batching,
490
+ StrictMode double-invoke) return the cached instance by reference instead of
491
+ rebuilding the whole snapshot — ~44 ms → ~0 at a 262k-event window. A
492
+ fresh-state call still builds; safe because `TimeSeries` is immutable
493
+ ([#180](https://github.com/pjm17971/pond-ts/pull/180)).
494
+
495
+ ## [0.19.0] — 2026-06-02
496
+
497
+ Adds an **experimental column-read surface to the live side** — read typed
498
+ columns straight off a `LiveView` without materializing a `TimeSeries`
499
+ snapshot — driven by the dashboard experiment's per-tick memo cost. Plus a
500
+ `useTimeSeries` schema-inference fix. The live column surface is
501
+ **experimental and expected to keep moving in 0.19.x**.
502
+
503
+ ### Added
504
+
505
+ - **`LiveView` column-read surface (experimental).** Read columns directly
506
+ off a windowed live view, the column-API counterpart to the batch
507
+ `TimeSeries` surface ([#179](https://github.com/pjm17971/pond-ts/pull/179)):
508
+ - `liveView.column(name)` — a numeric value column gathered from the view's
509
+ current events (string / array columns are a compile error; read those as
510
+ scalars or snapshot via `toTimeSeries()`).
511
+ - `liveView.keyColumn()` — the time axis (`TimeKeyColumn`; time-keyed views
512
+ only, enforced at compile time).
513
+ - `liveView.partitionBy(col).toMap(fn)` — a walk-now per-partition read
514
+ returning `Map<string, R>`, mirroring `TimeSeries.partitionBy().toMap()`
515
+ but without per-partition `TimeSeries` construction. Distinct from
516
+ `LiveSeries.partitionBy` (which is subscription-oriented). Throws on a
517
+ missing / key partition column rather than silently merging.
518
+ - `LiveColumnGroup` — the per-partition view passed to the `toMap` callback.
519
+ - **`@pond-ts/react`: `useLiveVersion(source, { throttle })` (experimental)**
520
+ — a `useSyncExternalStore`-based change signal that bumps on append **and**
521
+ eviction, so a component can read columns off a live view each render
522
+ without manufacturing a `TimeSeries` snapshot. Closes the
523
+ render-before-subscribe gap; throttling bounds only the React notification
524
+ ([#179](https://github.com/pjm17971/pond-ts/pull/179)).
525
+
526
+ ### Changed
527
+
528
+ - **`useTimeSeries` collapsed to a single generic** `<S extends SeriesSchema>`
529
+ so the schema infers from `input.schema`. The prior two-generic signature
530
+ lost `S` through the input-wrapper generic and resolved
531
+ `result.column('cpu')` to `never`; the accepted input type is unchanged, so
532
+ this is an inference fix — but a caller passing two explicit type arguments
533
+ must drop the second ([#176](https://github.com/pjm17971/pond-ts/pull/176)).
534
+
535
+ ## [0.18.0] — 2026-05-30
536
+
537
+ This release graduates the **Phase 4.7 columnar substrate** from
538
+ framework-internal (shipped piecemeal to `main` since v0.17.1) to a
539
+ user-visible **public column API**, plus a column-native live buffer that
540
+ fixes a high-partition-count OOM. Everything is additive except one
541
+ documented breaking change (interval label kinds) and one documented
542
+ behavior change (chunked-backed `pushMany` commit semantics). Pre-1.0: the
543
+ column API is expected to keep moving toward its eventual shape.
544
+
545
+ ### Added
546
+
547
+ - **Public column API (Phase 4.7 step 8).** A column-centric extraction
548
+ surface on `TimeSeries`, for high-throughput and charting consumers that
549
+ want typed-array access instead of per-`Event` iteration. Additive — every
550
+ existing row / `Event` API is unchanged.
551
+ - `series.column(name)` returns a schema-narrowed typed column view, with
552
+ public re-exports of the `Float64Column` / `BooleanColumn` /
553
+ `StringColumn` / `KeyColumn` (time / timeRange / interval) variants
554
+ ([#154](https://github.com/pjm17971/pond-ts/pull/154),
555
+ [#155](https://github.com/pjm17971/pond-ts/pull/155)).
556
+ - `Float64Column`: scalar reductions (`min` / `max` / `sum` / `mean` /
557
+ `count` / …) and `scan`
558
+ ([#155](https://github.com/pjm17971/pond-ts/pull/155)); `bin(...)` for
559
+ histogram / downsample bucketing
560
+ ([#156](https://github.com/pjm17971/pond-ts/pull/156)); and
561
+ `toFloat64Array()` for a storage-agnostic gather into a dense array
562
+ ([#165](https://github.com/pjm17971/pond-ts/pull/165)).
563
+ - `KeyColumn.at(i)` and `.slice(start, end)`
564
+ ([#159](https://github.com/pjm17971/pond-ts/pull/159)).
565
+ - **Columnar substrate (Phase 4.7 step 1, framework layer).** All
566
+ eight sub-steps (1a–1h) shipped to main as PRs #132 / #133 /
567
+ #134 / #135 / #136 / #147 / #148 / #149. See `PLAN.md` and
568
+ [`packages/core/src/columnar/README.md`](packages/core/src/columnar/README.md)
569
+ for the full inventory. Framework-internal — surfaced behind the existing
570
+ `TimeSeries` API at step 2 (below) and the public column API at step 8
571
+ (above).
572
+
573
+ ### Changed
574
+
575
+ - **Chunked columnar live backing for strict time-keyed `LiveSeries`**
576
+ ([#170](https://github.com/pjm17971/pond-ts/pull/170)). A top-level
577
+ `LiveSeries` with `ordering: 'strict'` and a time key now backs its
578
+ retained window with batch-granular columnar chunks instead of an
579
+ `Event[]` window — each `pushMany` validates straight into typed columns,
580
+ retaining **zero `Event` objects** (~4.7× less retained heap in-pond; the
581
+ high-partition-count OOM fix). Two consequences:
582
+ - **`pushMany` commit semantics** on the chunked path: the batch is
583
+ appended atomically _before_ any `'event'` fires, so a listener observes
584
+ the full post-batch `length` (not a row-by-row `1, 2, 3`), and a listener
585
+ that throws mid-batch leaves the whole batch committed. The per-row
586
+ `Event[]` backing (`reorder` / `drop` / interval-keyed /
587
+ internally-created series) keeps per-row commit. Listener _values_ and
588
+ `event → batch → evict` ordering are unchanged.
589
+ - **`LiveReduce` eviction** resolves by event identity (primary) with a
590
+ FIFO-frontier fallback for the chunked backing's materialized evictions —
591
+ correct for both `reorder` and the chunked backing. `min` / `max` /
592
+ `first` / `last` / `samples` over a `reorder` source **with retention**
593
+ remain a documented limitation (see `LiveReduce` JSDoc and PLAN
594
+ "Deferred") — pre-existing, not introduced here.
595
+ - **Internal, behavior-preserving performance work.** Column-native intake
596
+ bypasses per-row `Event` allocation at `TimeSeries` construction
597
+ ([#151](https://github.com/pjm17971/pond-ts/pull/151)); numeric reducers
598
+ (`min` / `max` / `sum` / `avg` / …) compute over typed-array columns where
599
+ available, with NaN parity preserved
600
+ ([#153](https://github.com/pjm17971/pond-ts/pull/153)); the live storage
601
+ strategy was extracted behind an internal interface
602
+ ([#168](https://github.com/pjm17971/pond-ts/pull/168)).
603
+
604
+ ### Changed (BREAKING)
605
+
606
+ - **Interval-keyed series must use one label type throughout**
607
+ ([#150](https://github.com/pjm17971/pond-ts/pull/150)). Pre-2a,
608
+ TimeSeries silently tolerated mixed-kind interval labels —
609
+ rows with `value: 'row-1'` (string) and `value: 2` (number) could
610
+ coexist in a single series because events were stored as a raw
611
+ array with no per-column type alignment. The columnar substrate
612
+ introduced at Phase 4.7 enforces one label kind per column via
613
+ `IntervalKeyColumn`, so mixed-kind labels now throw at series
614
+ construction with a row-pointed error message.
615
+ - **Affected:** Any series built via `new TimeSeries(...)`,
616
+ `TimeSeries.fromJSON(...)`, `TimeSeries.fromEvents(...)`, or
617
+ any transform that produces interval-keyed events, where the
618
+ `value` field of `IntervalInput` rows or `Interval` keys
619
+ mixes `string` and `number` types.
620
+ - **Migration:** Choose one label kind for the whole series.
621
+ Numeric labels can be stringified at intake (`String(label)`)
622
+ if the downstream consumer accepts string equality; string
623
+ labels parseable as integers can be converted to numbers at
624
+ intake. The error message names the first offending row so
625
+ the offending data is easy to find.
626
+ - **Rationale:** Aligns the row-API contract with the columnar
627
+ substrate's per-column kind discipline (matching Polars /
628
+ Arrow / Parquet). The previous behavior produced type-broken
629
+ events that worked only because TimeSeries didn't enforce
630
+ per-column alignment; downstream columnar operators (the
631
+ upcoming reducer adaptation in steps 3+) require it.
632
+ - **Affected types:** `IntervalValue` remains `string | number`
633
+ per the `Interval` class contract. The runtime restriction
634
+ is at the **series** level (all intervals within one series
635
+ must share a kind), not the per-interval level. Type-level
636
+ narrowing of `IntervalKeyedSchema<S>` over label kind is a
637
+ follow-up deferred to a later sub-step.
638
+
639
+ ## [0.17.1] — 2026-05-11
640
+
641
+ Bug fix: `live.partitionBy()` now default-inherits `ordering`,
642
+ `graceWindow`, and `retention` from the source `LiveSeries`. Surfaced
643
+ by the gRPC experiment's
644
+ [M4 late-data friction note](https://github.com/pjm17971/pond-grpc-experiment/blob/main/friction-notes/M4.md),
645
+ which measured `99.5%` of late events crashing the partition router
646
+ under `source = LiveSeries({ ordering: 'reorder', graceWindow })`
647
+ followed by bare `partitionBy('host')`.
648
+
649
+ ### Fixed
650
+
651
+ - **`LiveSeries.partitionBy(by)` default-inherits source config**
652
+ ([#TBD](https://github.com/pjm17971/pond-ts/pull/TBD)). Pre-fix,
653
+ per-partition sub-series were constructed with default
654
+ `ordering: 'strict'` regardless of source mode. Under a `'reorder'`
655
+ source, late events that the source accepted via its reorder path
656
+ were routed into the partition's `#insert` and threw with a
657
+ strict-mode error; the throw propagated back through the source's
658
+ listener fan-out into `live.push()`.
659
+
660
+ Post-fix, `partitionBy(by)` defaults each per-partition sub-series'
661
+ `ordering`, `graceWindow`, and `retention` to the source's values.
662
+ Explicit options on `partitionBy(by, { ordering, ... })` override
663
+ per-field. `graceWindow` inheritance is gated on effective ordering
664
+ being `'reorder'` (LiveSeries rejects strict + graceWindow combos).
665
+
666
+ ```ts
667
+ // Pre-0.17.1: crashed the partition router
668
+ const live = new LiveSeries({
669
+ name: 'metrics',
670
+ schema,
671
+ ordering: 'reorder',
672
+ graceWindow: '30s',
673
+ });
674
+ live.partitionBy('host'); // ← partition was strict regardless
675
+
676
+ // Post-0.17.1: partitions inherit reorder + 30s grace; late events
677
+ // accept correctly via the reorder path.
678
+ ```
679
+
680
+ Existing callers with explicit `partitionBy(by, { ordering, ... })`:
681
+ unchanged. Existing callers on `'strict'` sources: unchanged.
682
+ Existing callers on `'reorder'` sources with bare `partitionBy`:
683
+ the previously-thrown late events now accept correctly — bug fix,
684
+ not a behavior change anyone could rely on.
685
+
686
+ - **`collect()` and `apply()` on `LivePartitionedSeries` default-
687
+ inherit `ordering` and `graceWindow`** from the partitioned series
688
+ (which inherits from source). Pre-fix, the unified buffer defaulted
689
+ to `'strict'`, so partition fan-in on a `'reorder'` source could
690
+ deliver events out-of-order to a strict unified buffer and throw.
691
+ Retention stays caller-explicit on these per the existing append-
692
+ only fan-in semantics.
693
+
694
+ ### Notes
695
+
696
+ - **Six regression tests pin the new defaults** in
697
+ `LivePartitionedSeries.test.ts`: inherited ordering, inherited
698
+ graceWindow within reorder, inherited retention on partitions,
699
+ explicit override of inheritance, strict-source no-change, and the
700
+ edge case where overriding ordering to strict suppresses graceWindow
701
+ inheritance. `collect()` inheritance pinned separately.
702
+ - The gRPC experiment's M4 friction note also surfaced milestone B
703
+ (capability-based late repair) as **driver-light by empirical test**
704
+ after Codex's adversarial pass caught simulator RNG leakage across
705
+ A/B legs. Drift signal collapsed to within noise on every host once
706
+ all randomness sources were seeded — milestone B's library design
707
+ stays sound, but the gRPC experiment's measurement style (last-tick
708
+ `.value()` reads) doesn't surface its payoff. Milestone B sequencing
709
+ updated in PLAN.md to reflect this finding.
710
+
711
+ ## [0.17.0] — 2026-05-08
712
+
713
+ `sample({...})` operator wave: bounded-memory stream thinning, surfaced
714
+ by the gRPC experiment's M3.5 finish-line work
715
+ ([friction note](https://github.com/pjm17971/pond-grpc-experiment/blob/main/friction-notes/rfcs/bounded-memory-sampling.md)
716
+ with measured firehose numbers). Decouples downstream baseline window
717
+ length from event rate — at firehose rates × stride 10, `sd / sqrt(N)`
718
+ standard error stays well below per-event noise while a 5-minute
719
+ baseline that wouldn't fit in a Node heap un-sampled does at
720
+ stride 10. PR [#129](https://github.com/pjm17971/pond-ts/pull/129).
721
+
722
+ ### Added
723
+
724
+ - **`series.sample({ stride | reservoir })`** on `TimeSeries` and
725
+ `PartitionedTimeSeries` — single-pass thinning that keeps the
726
+ `TimeSeries<S>` schema. Stride is deterministic 1-in-N
727
+ (`{ stride: N }`); reservoir is random K-of-N via single-pass
728
+ [Vitter's Algorithm R](https://en.wikipedia.org/wiki/Reservoir_sampling#Simple:_Algorithm_R)
729
+ (`{ reservoir: { size: K } }`), sorted by key on output to preserve
730
+ the chronological invariant. The canonical visualization shape:
731
+
732
+ ```ts
733
+ series.sample({ reservoir: { size: 500 } }).toRows();
734
+ ```
735
+
736
+ 500 uncorrelated points drawn uniformly from the source — no
737
+ `aggregate(seq, ...)` grid collapse, no regular-spacing artifact,
738
+ fixed point count regardless of source size. Per-partition state on
739
+ `PartitionedTimeSeries.sample(...)` — each partition gets its own
740
+ K-event reservoir or stride counter.
741
+
742
+ - **`live.sample({ stride })`** on `LiveSeries`, `LiveView`,
743
+ `LivePartitionedSeries`, `LivePartitionedView` — closure-captured
744
+ counter inside a `LiveView<S>`, so the chainable surface (`filter`,
745
+ `rolling`, `reduce`, `select`, `map`, `diff`, `rate`, `cumulative`,
746
+ `fill`) is immediately available downstream of the sample. The
747
+ bounded-memory firehose pattern:
748
+
749
+ ```ts
750
+ live.partitionBy('host').sample({ stride: 10 }).rolling('5m', mapping);
751
+ ```
752
+
753
+ Each host's stream is thinned 1-in-10 before flowing into a per-host
754
+ 5m rolling window. `live.stats().ingested` and `live.on('batch', cb)`
755
+ are upstream of any `.sample(...)` op — they continue counting true
756
+ throughput; only consumers downstream see the thinned stream.
757
+
758
+ - **Sampling docs page** at
759
+ [`pond-ts/transforms/sampling`](https://pjm17971.github.io/pond-ts/docs/pond-ts/transforms/sampling/)
760
+ covering when-to-use-which decision table, both strategies, the
761
+ visualization shape, multi-entity considerations, and a forward-link
762
+ to the live counterpart. New `## Sampling: bounded-memory thinning`
763
+ section in
764
+ [Live transforms](https://pjm17971.github.io/pond-ts/docs/pond-ts/live/live-transforms#sampling).
765
+
766
+ ### Deferred
767
+
768
+ - **Live-side reservoir sampling** is queued for v0.18.0+. Algorithm R's
769
+ random-slot replacement produces non-prefix evictions, but the existing
770
+ live-eviction protocol (`'evict'` event + cutoff-based mirroring in
771
+ `LiveView`) assumes prefix evictions only. Bridging needs an exact-
772
+ removal eviction channel — arriving with the streaming RFC's
773
+ `LiveChange` model (Phase 4.5 milestone A). For visualization-shaped
774
+ reservoir today, materialize via `live.toTimeSeries().sample({ reservoir })`.
775
+
776
+ ### Notes
777
+
778
+ - **Multi-entity bias trap** is documented in JSDoc on the pre-partition
779
+ sites (`LiveSeries.sample`, `LiveView.sample`) with the
780
+ `partitionBy(...).sample(...)` recommendation, matching the existing
781
+ convention for `rolling` / `aggregate` / `fill` / `diff` / `rate` /
782
+ `cumulative` / `pctChange` / `reduce`. An earlier iteration of #129
783
+ shipped a type-level `unsafeGlobal: true` token; pulled during review
784
+ for consistency with how every other stateful live operator handles
785
+ the same multi-entity consideration. Token-of-the-week novelty was
786
+ the wrong shape; the doc warning is the same answer the other
787
+ operators already give.
788
+
789
+ - **Legacy `rolling.sample(seq)` doc references removed.** Pre-v0.12
790
+ pond exposed `LiveRollingAggregation.sample(sequence)` as a separate
791
+ method (deleted in v0.12.0, replaced by `Trigger.every`). Active doc
792
+ references in `pond-ts/live/triggering.mdx`,
793
+ `pond-ts/transforms/alignment.mdx`, `pond-ts/transforms/rolling.mdx`,
794
+ and `pond-ts/live/live-transforms.mdx` removed to eliminate the
795
+ naming-collision confusion now that `series.sample({ stride | reservoir })`
796
+ is a real but completely unrelated operator. Historical record
797
+ preserved in PLAN.md, the v0.11.8 CHANGELOG entry, and the triggers RFC.
798
+
799
+ ## [0.16.1] — 2026-05-06
800
+
801
+ Patch wave addressing one ergonomic gap surfaced by the gRPC
802
+ experiment ([pond-grpc-experiment#29](https://github.com/pjm17971/pond-grpc-experiment/pull/29))
803
+ plus the v0.16.0 docs deploy that broke since v0.15.2.
804
+
805
+ ### Added
806
+
807
+ - **`PartitionedTimeSeries.aggregate(...)` and `.rolling(...)` now
808
+ auto-inject the partition column into the user's mapping**
809
+ ([#128](https://github.com/pjm17971/pond-ts/pull/128)). The
810
+ natural shape just works:
811
+
812
+ ```ts
813
+ series
814
+ .partitionBy('host')
815
+ .aggregate(Sequence.every('600ms'), { cpu_avg: 'avg' });
816
+ ```
817
+
818
+ Pre-fix this threw `column "host" not in schema` at the rewrap
819
+ step because the user's mapping didn't carry the partition
820
+ column through; users had to add `host: 'first'` mechanically
821
+ to every partitioned-aggregate call. Pond now adds it
822
+ automatically — `'first'` is by-construction-correct since
823
+ every row in a single partition shares that column's value.
824
+ User-supplied mappings for the partition column win (auto-
825
+ inject is a no-op when the user has already opted in).
826
+ Composite partitions (`partitionBy(['host', 'region'])`)
827
+ auto-inject every partition column. Strictly additive — the
828
+ pre-fix workaround pattern still works unchanged.
829
+
830
+ ### Fixed
831
+
832
+ - **Docs deploy workflow unblocked**
833
+ ([#126](https://github.com/pjm17971/pond-ts/pull/126)). Has
834
+ been failing since v0.15.2 with `Cannot find name
835
+ 'queueMicrotask'` — TypeDoc runs the same tsconfig as the
836
+ npm-publish path but from a different cwd, where `@types/node`
837
+ doesn't resolve. Fixed via a one-line ambient declaration in
838
+ `LiveReduce.ts`. No runtime change; `queueMicrotask` is still
839
+ the host-provided global it always was.
840
+
841
+ ### Changed
842
+
843
+ - **Updated `LiveSeries` tool comparisons in the docs**
844
+ ([#127](https://github.com/pjm17971/pond-ts/pull/127)).
845
+ Tightened the Beam/Flink, PondJS, and pandas comparison tables
846
+ to be technically accurate. Doc prose only; no code change.
847
+
848
+ ### Notes
849
+
850
+ - **Captured `@pond-ts/charts` design constraints in PLAN.md**
851
+ ([#128](https://github.com/pjm17971/pond-ts/pull/128)). The
852
+ gRPC experiment's M3.5 friction note hit Recharts' SVG render
853
+ cliff at firehose loads (~75-80k SVG nodes per render, ~1 fps
854
+ at 10 hosts × 70k events/s). Four constraints from real
855
+ workload now baked into the plan so the eventual extraction
856
+ starts with the answer key — not new code, just durable
857
+ design capture.
858
+
859
+ ## [0.16.0] — 2026-05-06
860
+
861
+ Live-API ergonomic wave. Four PRs:
862
+ [#122](https://github.com/pjm17971/pond-ts/pull/122) (buffer-as-window
863
+ Tier 1), [#123](https://github.com/pjm17971/pond-ts/pull/123)
864
+ (`stats()` accessor), [#124](https://github.com/pjm17971/pond-ts/pull/124)
865
+ (`history` option + compile-time fused uniqueness),
866
+ [#125](https://github.com/pjm17971/pond-ts/pull/125) (Tier 2 query
867
+ primitives). Strictly additive surface — no public-API removals or
868
+ narrowings.
869
+
870
+ ### Added
871
+
872
+ - **`live.reduce(mapping, opts?)`** on `LiveSeries` and `LiveView`
873
+ — streaming reduce over the source's current buffer. Mirrors
874
+ `series.reduce(mapping)` from batch but reactive: per-event
875
+ `add`, per-eviction `remove`, microtask-deferred trigger
876
+ emission so retention has run before the snapshot. Closes the
877
+ buffer-as-window persona's biggest ergonomic gap.
878
+ - **`live.timeRange()`** on `LiveSeries` and `LiveView` — O(1)
879
+ temporal extent of the current buffer (`undefined` when empty).
880
+ - **`live.eventRate()`** on `LiveSeries` and `LiveView` — O(1)
881
+ events-per-second over the buffer's time span (zero when fewer
882
+ than two events). Convenience over the existing
883
+ `view.eventRate()` shape; no window argument required.
884
+ - **`live.count()`** on `LiveSeries` (alias for `length`) for
885
+ parity with `LiveView.count()` and chainable composition with
886
+ `eventRate()`.
887
+ - **`stats()` accessor on every live accumulator/series.** Per-class
888
+ shapes, all returning a plain record (cumulative integer counters
889
+ - current-state fields):
890
+
891
+ | Class | Shape |
892
+ | --------------------------- | --------------------------------------------------------------------- |
893
+ | LiveSeries | `{ ingested, evicted, rejected, length, earliestTs?, latestTs? }` |
894
+ | LiveRollingAggregation | `{ eventsObserved, evictions, emissions, windowSize }` |
895
+ | LiveFusedRolling | `{ eventsObserved, evictions, emissions, windowSize, windowsCount }` |
896
+ | LiveAggregation | `{ eventsObserved, bucketsClosed, openBuckets, openBucketStart? }` |
897
+ | LiveReduce | `{ eventsObserved, evictions, emissions, bufferSize }` |
898
+ | LivePartitionedSeries | `{ partitions, eventsRouted }` |
899
+ | LivePartitionedSyncRolling | `{ partitions, eventsObserved, emissions, windowSize }` |
900
+ | LivePartitionedFusedRolling | `{ partitions, eventsObserved, emissions, windowSize, windowsCount }` |
901
+
902
+ Per-event cost: ~1-3 integer increments in already-existing
903
+ handlers. `stats()` itself is O(1) — or O(partitions) for the
904
+ max-across-partitions `windowSize` on partitioned variants.
905
+ Polling-based by design — wall-clock timers inside pond would
906
+ break the data-is-the-clock invariant.
907
+
908
+ - **`history: false | RetentionPolicy` option on
909
+ `LiveRollingAggregation` and `LiveFusedRolling`** (and
910
+ partitioned variants — threaded through
911
+ `LivePartitionedSeries.rolling` end-to-end). Controls how much
912
+ of the rolling's emitted history the accumulator keeps in its
913
+ own output buffer (the one read by `length` / `at(i)`). Default
914
+ `true` preserves current behavior; `false` skips the push
915
+ entirely (`'event'` listeners and `value()` still work, but
916
+ `length` stays at 0); `RetentionPolicy` (`{ maxEvents?, maxAge? }`)
917
+ caps the buffer using the same shape as `LiveSeries.retention`.
918
+ Stricter validation: rejects 0, negative, or non-integer
919
+ `maxEvents`; `Infinity` is the documented "no cap" sentinel.
920
+
921
+ - **Compile-time uniqueness check on fused output columns**
922
+ (`FusedMappingValid<FM>`). Two windows declaring the same
923
+ output name now fail at the call site with a branded error
924
+ type naming the conflict. Wired into all four fused-rolling
925
+ overloads (LiveSeries, LiveView, root + view
926
+ LivePartitionedSeries). Runtime check still in place.
927
+
928
+ - **Tier 2 query primitives on `LiveSeries` and `LiveView`** —
929
+ pure parity additions mirroring `TimeSeries`:
930
+ - `find(pred)`, `some(pred)`, `every(pred)` — O(N) predicate query
931
+ - `includesKey(key)`, `bisect(key)`, `atOrBefore(key)`,
932
+ `atOrAfter(key)` — O(log N) binary search on the sorted buffer
933
+
934
+ Use cases: "is there already an event with key K?" / "what was
935
+ the most recent event before time T?" Both come up in dashboard
936
+ patterns where the live buffer IS the working set.
937
+
938
+ - **`KeyLike` type** exported from the package root (re-exported
939
+ from `TimeSeries`). Accepts `EventKey | TimestampInput |
940
+ TimeRangeInput | IntervalInput`; normalised by the new query
941
+ primitives.
942
+
943
+ - **`DurationLiteral` and `DurationUnit` types** extracted from
944
+ `utils/duration.ts` and exported. Same shape as before, just
945
+ named.
946
+
947
+ - **Concrete return types from partitioned rolling overloads.**
948
+ `LivePartitionedSeries.rolling` and `LivePartitionedView.rolling`
949
+ clock-trigger and fused-mapping overloads now return the concrete
950
+ `LivePartitionedSyncRolling` / `LivePartitionedFusedRolling`
951
+ classes (instead of bare `LiveSource<...>`), exposing `stats()`
952
+ to callers without a cast. Strictly additive — concrete classes
953
+ implement `LiveSource` plus `stats()`.
954
+
955
+ ### Changed
956
+
957
+ - **`LiveSeries.clear()`** now increments the `evicted` counter
958
+ on `stats()` to match the existing `'evict'` listener fan-out.
959
+ Previously cleared the buffer and fired listeners but didn't
960
+ update the counter.
961
+ - **`LiveSeries` insertion comparator** delegates to
962
+ `EventKey.compare` (was previously `begin/end` only). Affects
963
+ interval-keyed series with same-span / different-value
964
+ intervals: previously stored in arrival order — and broke
965
+ `bisect`/`includesKey` queries — now stored in value-ascending
966
+ order. Time-keyed and timeRange-keyed series unaffected.
967
+ - **`LiveView.map(fn)` runtime check** rejects re-keying maps
968
+ that produce non-monotonic outputs. Throws `ValidationError`
969
+ at append time rather than silently breaking the view's
970
+ sorted-buffer invariant (which Tier 2 query primitives rely
971
+ on). Sane transforms (data-only maps, monotonic time-shifts)
972
+ unaffected.
973
+ - **`LiveAggregationOptions.grace`** type tightened from
974
+ `DurationInput | \`${number}${unit}\``(redundant union) to
975
+ just`DurationInput`. No behavioral change.
976
+
977
+ ### Notes
978
+
979
+ - React package (`@pond-ts/react`) version-bumped lock-step; no
980
+ hook surface changes in this release. New core hooks
981
+ (`useLiveReduce`, `useStats`, optional-window `useEventRate`)
982
+ are queued for a follow-up — see PLAN.md for the design.
983
+ - Codex caught real bugs on every Layer-2-reviewed PR in this
984
+ wave (1 HIGH + 1 MEDIUM on PR #123, 1 HIGH + 1 MEDIUM on
985
+ PR #124, 2 MEDIUM on PR #125). The Layer 2 + Codex two-pass
986
+ protocol earned its keep again.
987
+
988
+ ## [0.15.2] — 2026-05-06
989
+
990
+ Performance fix for live rolling at firehose rates. The gRPC
991
+ experiment's step 6
992
+ ([pond-grpc-experiment#26](https://github.com/pjm17971/pond-grpc-experiment/pull/26))
993
+ attempted to use the non-partitioned `live.rolling({...}, opts)`
994
+ overload for global counters and saw throughput collapse from 88k/s
995
+ to 21k/s — a 4× regression even worse than the V7→V6 gap that
996
+ motivated v0.15.0. The cliff is the same `Array.shift()` pattern
997
+ already flagged as queued tactical work in PLAN; the gRPC encounter
998
+ made it urgent.
999
+
1000
+ ### Fixed
1001
+
1002
+ - **Eviction is now O(1) per ingest in all live rolling classes.**
1003
+ Replaced `entries.shift()` (worst-case O(N) on the deque length)
1004
+ with a head-index pointer + periodic batched compaction:
1005
+ - `LiveFusedRolling.#compactFront` — non-partitioned multi-window
1006
+ - `LivePartitionedFusedRolling.#compactPartitionFront` —
1007
+ per-partition fused
1008
+ - `LiveRollingAggregation.#removeFirst` — single-window
1009
+ non-partitioned
1010
+ - `LivePartitionedSyncRolling.#evictPartition` — per-partition
1011
+ single-window synced
1012
+
1013
+ The pattern: track a `frontIdx` field; "evicting" advances the
1014
+ pointer instead of shifting. When the dead prefix grows past
1015
+ half the array length, batch-splice it off and reset the
1016
+ pointer. Per-event cost stays O(1) amortized at every live-
1017
+ window size — each surviving entry is copied at most once
1018
+ between two compactions, and compactions fire at most every
1019
+ (live-size) events.
1020
+
1021
+ An earlier draft also compacted on a fixed 1024-entry threshold;
1022
+ Codex's adversarial review on PR #119 caught that this would
1023
+ reintroduce O(live_size / 1024) per-eviction cost on large
1024
+ windows (100k+ live entries) — the threshold would fire
1025
+ repeatedly and copy the entire live slice each time. The
1026
+ proportional guard alone has the right amortization invariant.
1027
+
1028
+ ### Performance
1029
+
1030
+ `packages/core/scripts/perf-fused-rolling.mjs` — new regression
1031
+ scenario that reproduces the cliff (50k-event deque with continuous
1032
+ eviction):
1033
+
1034
+ ```
1035
+ Worst-case shift pattern (50s window, 50k fill + 50k evict):
1036
+ median (ms) min (ms) max (ms)
1037
+ pre-fix 1123.12 1118.47 1149.95
1038
+ v0.15.2 53.00 52.34 53.56
1039
+ speedup 21.2×
1040
+
1041
+ Steady-state deque, no eviction (5m window, 200k events):
1042
+ median (ms) min (ms) max (ms)
1043
+ pre-fix 91.28 89.84 97.04
1044
+ v0.15.2 99.28 96.80 103.94
1045
+ delta +9% (within noise)
1046
+ ```
1047
+
1048
+ The fix targets the eviction-loop case specifically. Workloads with
1049
+ no eviction (or rare eviction relative to ingest) see no change —
1050
+ V8's internal hidden-offset optimization handles those well. The
1051
+ cliff appears once eviction fires per-ingest at large deque size,
1052
+ which is exactly the firehose-rolling shape.
1053
+
1054
+ ### Why the cliff was hidden
1055
+
1056
+ V8's `Array.shift()` is amortized O(1) for shift-heavy workloads up
1057
+ to ~10k-element arrays — it maintains a hidden offset and only
1058
+ periodically compacts. Beyond that size or with mixed access
1059
+ patterns, the optimization breaks down and shift falls back to true
1060
+ O(N) memcpy. The bench scales from 1k to 50k deque sizes and the
1061
+ cliff appears around 30k-40k. Pond's tests pin behavior at small
1062
+ window sizes; the cliff was invisible to the test suite, only
1063
+ showed up under the gRPC experiment's firehose load.
1064
+
1065
+ ### What this unlocks
1066
+
1067
+ The agent's manual-counter workaround in `aggregator/src/aggregate.ts`
1068
+ can now drop. The natural shape — a non-partitioned
1069
+ `live.rolling({...}, { trigger })` over the firehose — is now
1070
+ viable at the rates the experiment cares about. PLAN's
1071
+ "`samples` reducer would exhibit a similar shape at firehose"
1072
+ caveat also resolves: same fix in the same call sites covers
1073
+ samples too.
1074
+
1075
+ ### Note for downstream consumers
1076
+
1077
+ This is a **strict-additive perf fix.** All output behavior is
1078
+ preserved — same eviction order, same emission timing, same
1079
+ snapshot values. The deque's internal representation changed
1080
+ (`#entries[0]` may now be a logically-evicted entry until periodic
1081
+ compaction); any downstream code reading `#entries` directly would
1082
+ break, but those fields are private. Public APIs and types are
1083
+ unchanged.
1084
+
1085
+ [0.17.1]: https://github.com/pjm17971/pond-ts/compare/v0.17.0...v0.17.1
1086
+ [0.17.0]: https://github.com/pjm17971/pond-ts/compare/v0.16.1...v0.17.0
1087
+ [0.16.1]: https://github.com/pjm17971/pond-ts/compare/v0.16.0...v0.16.1
1088
+ [0.16.0]: https://github.com/pjm17971/pond-ts/compare/v0.15.2...v0.16.0
1089
+ [0.15.2]: https://github.com/pjm17971/pond-ts/compare/v0.15.1...v0.15.2
1090
+
1091
+ ## [0.15.1] — 2026-05-05
1092
+
1093
+ Type-narrowing follow-up to v0.15.0. The fused partitioned-rolling
1094
+ typing chain exposed a pre-existing pond limitation where
1095
+ `partitionBy('host')` widened the partition-column type instead of
1096
+ narrowing it to the literal `'host'`. The gRPC experiment's V8
1097
+ migration ([pond-grpc-experiment#22](https://github.com/pjm17971/pond-grpc-experiment/pull/22))
1098
+ worked around it as `partitionBy<'host'>('host')` — clobbering the
1099
+ value-type parameter `K` to fill the column-name slot. v0.15.1
1100
+ captures the column literal directly so the workaround can drop.
1101
+
1102
+ ### Fixed
1103
+
1104
+ - **`partitionBy` narrows the partition column literal.** The
1105
+ `by` argument's literal type now flows into a new `ByCol`
1106
+ generic on `LivePartitionedSeries<S, K, ByCol>` and
1107
+ `LivePartitionedView<SBase, R, K, ByCol>`. Threaded through every
1108
+ per-partition method (`fill`, `diff`, `rate`, `pctChange`,
1109
+ `cumulative`, `apply`, the rolling overloads). The fused
1110
+ partitioned-rolling overload's
1111
+ `FusedPartitionedRollingSchema<S, ByCol, FM>` now resolves
1112
+ correctly without the `<'host'>` workaround:
1113
+
1114
+ ```ts
1115
+ // Before v0.15.1: needed the explicit type arg to narrow
1116
+ // host through the fused-rolling schema chain.
1117
+ live.partitionBy<'host'>('host').rolling({ ... }, { trigger });
1118
+
1119
+ // v0.15.1+: the literal 'host' is captured automatically.
1120
+ live.partitionBy('host').rolling({ ... }, { trigger });
1121
+ // Output schema includes `host` narrowed to its column kind;
1122
+ // event.get('host') resolves correctly.
1123
+ ```
1124
+
1125
+ Existing V8 callers using the `partitionBy<'host'>('host')`
1126
+ workaround continue to narrow correctly. Type-parameter order
1127
+ on `partitionBy` is `<ByCol, K>` (column name first, value type
1128
+ second) so the explicit `<'host'>` binds the literal to `ByCol`
1129
+ — exactly what the workaround intended pre-v0.15.1. The
1130
+ workaround can now drop because automatic inference does the
1131
+ same job, but it doesn't have to.
1132
+
1133
+ ### Type system
1134
+
1135
+ - `LivePartitionedSeries<S, K, ByCol>` — third generic added with
1136
+ default `keyof EventDataForSchema<S> & string`. Backwards-
1137
+ compatible: existing references to `LivePartitionedSeries<S, K>`
1138
+ and `LivePartitionedSeries<S>` resolve to the upper-bound default.
1139
+ - `LivePartitionedView<SBase, R, K, ByCol>` — same shape; `ByCol`
1140
+ threaded through every chain hop so partition-column literals
1141
+ survive `partitionBy('host').fill(...).rolling({...}, opts)`.
1142
+
1143
+ ### Test surface
1144
+
1145
+ `test-d/fused-rolling.test-d.ts` extended to pin the narrowing at
1146
+ both the root and chained levels:
1147
+
1148
+ ```ts
1149
+ const fC = live.partitionBy('host').rolling({ ... }, { trigger });
1150
+ sampleEvent.get('host'); // narrows to string | undefined
1151
+
1152
+ const chained = live.partitionBy('host').fill({ cpu: 'hold' })
1153
+ .rolling({ '1m': { cpu_avg: ... } }, { trigger });
1154
+ chainedSample.get('host'); // narrows correctly through the chain
1155
+ ```
1156
+
1157
+ All 1115 + 55 runtime tests still pass; type-d clean.
1158
+
1159
+ [0.15.1]: https://github.com/pjm17971/pond-ts/compare/v0.15.0...v0.15.1
1160
+
1161
+ ## [0.15.0] — 2026-05-05
1162
+
1163
+ The "fused multi-window rolling" release. Shipping the primitive
1164
+ that closes the gRPC experiment's V6→V7 architectural cliff: a
1165
+ keyed-form overload on `live.rolling()` that maintains N windows
1166
+ in one ingest pass over a single shared deque, emits one merged
1167
+ event per trigger boundary, and (on the partitioned variant) eats
1168
+ the doubled `#routeEvent` / `#evictPartition` / `_pushTrustedEvents`
1169
+ hops V7 surfaced.
1170
+
1171
+ Two independent signals motivated this: the gRPC profile-diff
1172
+ (PR #19 in `pond-grpc-experiment`) and the buffer-as-window
1173
+ persona's metric-agent call site
1174
+ (`series.rolling(RETENTION, mapping, ...)` as workaround). Both
1175
+ point at one primitive; both shipped together. RFC #20 in
1176
+ `pond-grpc-experiment` is the design record.
1177
+
1178
+ ### Added
1179
+
1180
+ - **Keyed-form fused rolling on `LiveSeries.rolling`,
1181
+ `LiveView.rolling`, and `LivePartitionedSeries.rolling`.** Pass
1182
+ a record of `{ duration: mapping }` instead of `(window, mapping)`
1183
+ to declare multiple windows; the rolling maintains them all in
1184
+ one ingest pass:
1185
+
1186
+ ```ts
1187
+ const fused = byHost.rolling(
1188
+ {
1189
+ '1m': {
1190
+ cpu_avg: { from: 'cpu', using: 'avg' },
1191
+ cpu_sd: { from: 'cpu', using: 'stdev' },
1192
+ },
1193
+ '200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
1194
+ },
1195
+ { trigger: Trigger.every('200ms') },
1196
+ );
1197
+ // fused emits one merged event per boundary with all four
1198
+ // columns; one ingest pass per source event.
1199
+ ```
1200
+
1201
+ - **Output: one merged stream.** All declared windows' columns
1202
+ concatenated into one record per trigger fire — not N
1203
+ accumulators or N streams. User code collapses to one event
1204
+ handler (the V7 → V8 migration in the gRPC experiment drops
1205
+ ~30 lines of `pendingByTs` / `partsFor` / `tryEmit` join
1206
+ machinery).
1207
+ - **Constraints.** Time-based windows only (object keys are
1208
+ duration strings); single trigger across all windows by
1209
+ design (per-window cadence falls back to two `rolling()`
1210
+ calls, paying the V7 cost). On partitioned series, clock
1211
+ trigger is required.
1212
+ - **Per-window options.** Use the elaborated value form
1213
+ (`{ mapping, minSamples }`) when one window needs different
1214
+ options from the rest; bare-mapping value stays clean for
1215
+ the common case.
1216
+ - **Duplicate output column names** across windows are rejected
1217
+ at construction with a clear error. Partition column auto-
1218
+ injection is unified across all windows.
1219
+ - **Single-window equivalence pin.**
1220
+ `live.rolling('1m', mapping, opts)` and
1221
+ `live.rolling({ '1m': mapping }, opts)` produce identical
1222
+ output (locked down by tests).
1223
+
1224
+ - **`LiveFusedRolling<S, Out>`** — non-partitioned class, exposed
1225
+ on the public surface via `live.rolling({...}, opts)`.
1226
+ - **`LivePartitionedFusedRolling<S, K, Out>`** — synchronised-cross-
1227
+ partition class, exposed via `byHost.rolling({...}, { trigger })`.
1228
+ - **Type-level surface:** `FusedMapping<S>`, `FusedMappingValue<S>`,
1229
+ `FusedMappingElaborated<S>`, `FusedRollingSchema<S, FM>`,
1230
+ `FusedPartitionedRollingSchema<S, ByCol, FM>`, and
1231
+ `DurationString` — all exported from `pond-ts`. Output column
1232
+ kinds narrow correctly through `event.get('cpu_avg')` to
1233
+ `number | undefined`.
1234
+
1235
+ ### Performance
1236
+
1237
+ `packages/core/scripts/perf-fused-rolling.mjs` — bench against
1238
+ gRPC RFC #20 acceptance criteria. Headline numbers (median of 3
1239
+ runs, `node --expose-gc`):
1240
+
1241
+ ```
1242
+ Partitioned, 100k events × 100 hosts (the gRPC use case):
1243
+ wall (ms) heap (MB)
1244
+ single rolling baseline 95.20 74.33
1245
+ two separate rollings (V7 shape) 141.12 101.71
1246
+ fused two-window (V8 shape) 112.36 68.46
1247
+
1248
+ Fused vs V7 shape: -20.4% wall, -32.7% heap
1249
+ Fused vs baseline: +18.0% wall, -7.9% heap
1250
+
1251
+ Partitioned, 100k events × 1000 hosts (saturation):
1252
+ wall (ms) heap (MB)
1253
+ two separate rollings (V7 shape) 700.35 556.56
1254
+ fused two-window (V8 shape) 446.21 309.25
1255
+
1256
+ Fused vs V7 shape: -36.3% wall, -44.4% heap
1257
+ ```
1258
+
1259
+ **Scaling beyond two windows — the architectural argument
1260
+ verified.** Every per-event pond hop runs ONCE in fused vs N times
1261
+ in N separate rollings. The bench scales N from 2 to 5 windows
1262
+ over the same 100k-events × 100-hosts source:
1263
+
1264
+ ```
1265
+ Separate (ms) Fused (ms) Wall delta
1266
+ N = 2 152.91 102.91 -32.7%
1267
+ N = 3 186.63 79.89 -57.2%
1268
+ N = 4 245.42 107.51 -56.2%
1269
+ N = 5 279.79 118.90 -57.5%
1270
+
1271
+ Separate (MB) Fused (MB) Heap delta
1272
+ N = 2 108.13 72.20 -33.2%
1273
+ N = 3 93.30 43.08 -53.8%
1274
+ N = 4 113.69 47.19 -58.5%
1275
+ N = 5 137.17 47.12 -65.6%
1276
+ ```
1277
+
1278
+ Fused stays roughly constant (~100ms) across N=2..5; separate
1279
+ scales linearly. At N=5: **2.4× faster wall, 34% of the heap.**
1280
+
1281
+ The architectural cliff is closed and the win compounds with N.
1282
+ Fused rolling's per-event cost is O(1) in the number of windows
1283
+ for pipeline overhead — only O(N) for the unavoidable per-window
1284
+ reducer-state updates (which separate also pays). Heap is
1285
+ dominated by the saved per-rolling deque + per-partition state.
1286
+
1287
+ ### Notes on what this does NOT include
1288
+
1289
+ - **`live.reduce(mapping)` sugar.** Designed in PLAN as
1290
+ `live.rolling({ buffer: mapping }, { history: false })`; the
1291
+ `'buffer'` sentinel is reserved at the type level but throws at
1292
+ runtime for now. Lands with the buffer-as-window Tier 1 PR.
1293
+ - **`TimeSeries.rolling` snapshot-side parity.** The keyed-form
1294
+ overload is live-side only in v0.15.0; batch-side comes in a
1295
+ follow-up.
1296
+ - **Path A (share `LiveSeries` buffer).** Currently Path B (own
1297
+ deque) — fused rolling subscribes via `'event'` and maintains
1298
+ its own per-partition deque. Path A is a transparent perf
1299
+ follow-up; same API.
1300
+ - **Compile-time uniqueness check on output columns.** Runtime
1301
+ check is in place; the type-level `CheckUniqueOutputs` helper
1302
+ is parked as a follow-up. Same with tightening `DurationString`
1303
+ to reject `'1min'`-style typos at the type level (today's
1304
+ template-literal type is permissive; runtime `parseDuration`
1305
+ catches malformed durations).
1306
+
1307
+ ### Migration
1308
+
1309
+ Existing `live.rolling(window, mapping, opts)` calls are
1310
+ unchanged. The keyed form is opt-in and additive. Two-rolling
1311
+ patterns can migrate by collapsing to one fused call:
1312
+
1313
+ ```ts
1314
+ // Before:
1315
+ const baseline = byHost.rolling('1m', m1, { trigger });
1316
+ const slice = byHost.rolling('200ms', m2, { trigger });
1317
+ // Then a per-(ts, host) join over both event streams …
1318
+
1319
+ // After:
1320
+ const fused = byHost.rolling({ '1m': m1, '200ms': m2 }, { trigger });
1321
+ fused.on('event', (e) => {
1322
+ // All columns from both windows on one event.
1323
+ });
1324
+ ```
1325
+
1326
+ [0.15.0]: https://github.com/pjm17971/pond-ts/compare/v0.14.3...v0.15.0
1327
+
1328
+ ## [0.14.3] — 2026-05-04
1329
+
1330
+ A targeted allocation fix in the `'samples'` reducer's rolling-state
1331
+ implementation. Motivated by gRPC experiment V7 numbers — at the
1332
+ ceiling regime (1k partitions × 1k events/s, 1M target) the all-
1333
+ pond pipeline using `samples()` regressed throughput ~19% vs V6's
1334
+ hybrid pond-rolling + manual-deque pattern, with +17% heap at
1335
+ moderate loads. Per-event cost analysis pointed at a 1-element
1336
+ `ScalarValue[]` allocation per scalar `add()` — one wasted
1337
+ allocation per event compounding under sustained kHz × N-partition
1338
+ load.
1339
+
1340
+ ### Changed
1341
+
1342
+ - **`samples.rollingState()` skips array wrap for scalar source
1343
+ columns.** Scalar values (the common case at saturation) now
1344
+ store directly into the keyed map; only array-kind sources
1345
+ build a sub-array (because `remove(index)` needs to drop a
1346
+ single event's contributions together). Snapshot branches on
1347
+ `Array.isArray` to flatten the mixed map.
1348
+
1349
+ ```
1350
+ Focused micro-bench (5M scalar add+remove cycles):
1351
+ median (ms) min (ms) max (ms)
1352
+ baseline (v0.14.2) 239.85 236.62 244.58
1353
+ v0.14.3 209.09 207.42 215.26
1354
+ delta −12.8% −12.3% −12.0%
1355
+
1356
+ Integration bench (100k events × N hosts, full pipeline):
1357
+ Tight wall-clock parity within run-to-run noise across all
1358
+ scenarios (samples 1m/5s, scalar/array). Allocation pressure
1359
+ isn't the dominant cost at this scale; the optimization
1360
+ compounds only at saturation regimes where GC pressure stacks.
1361
+ ```
1362
+
1363
+ Behavior is preserved bit-for-bit — every existing
1364
+ `samples-reducer.test.ts` assertion passes without modification.
1365
+
1366
+ ### Added
1367
+
1368
+ - `packages/core/scripts/perf-samples-reducer.mjs` — benchmark
1369
+ covering the focused micro-bench + four integration scenarios
1370
+ (scalar moderate / scalar high-cardinality / scalar high-churn
1371
+ / array source) with a comparison anchor against `'avg'` on
1372
+ the same shape. Run with `node --expose-gc` for heap numbers.
1373
+
1374
+ ### Note on saturation regimes
1375
+
1376
+ V7's regression isn't fully closed by this fix. The remaining gap
1377
+ is architectural — V7 routes events through two full
1378
+ `LiveRollingAggregation` pipelines (Map ops + reducer state +
1379
+ trigger dispatch + subscriber fan-out per pipeline), where V6's
1380
+ hybrid had one pond rolling for stats plus a passive
1381
+ `array.push` listener for raw values. At the kHz × 1k-partition
1382
+ saturation regime, the manual-deque pattern is genuinely the
1383
+ right shape; pond's `samples` is for typical loads where per-
1384
+ event overhead is invisible. A shared-buffer primitive (parked
1385
+ as `tap()` in PLAN.md) would close the saturation gap; out of
1386
+ scope for v0.14.3.
1387
+
1388
+ [0.14.3]: https://github.com/pjm17971/pond-ts/compare/v0.14.2...v0.14.3
1389
+
1390
+ ## [0.14.2] — 2026-05-03
1391
+
1392
+ Hotfix over v0.14.1 — closes a type-narrowing gap on the new
1393
+ `'samples'` reducer that the v0.14.1 Layer 2 review caught
1394
+ post-merge. The runtime worked, but TypeScript didn't know about
1395
+ `'samples'`: passing it through `series.aggregate({ col: 'samples' })`
1396
+ or `live.rolling(window, { col: 'samples' })` produced
1397
+ `Type '"samples"' is not assignable to type 'AggregateReducer'`,
1398
+ and `series.reduce({ col: 'samples' }).col` fell through to
1399
+ `ColumnValue | undefined` instead of the narrowed array type.
1400
+
1401
+ ### Fixed
1402
+
1403
+ - **`'samples'` is now in the type system everywhere.** Added to
1404
+ `AggregateFunction` union, both branches of
1405
+ `AggregateFunctionsForKind` (numeric and array/string/boolean),
1406
+ `AggregateKindForColumn` (so output columns get
1407
+ `kind: 'array'`), `ArrayAggregateKind`, and the array branch of
1408
+ `ReduceResult` in `types-reduce.ts`.
1409
+
1410
+ ```ts
1411
+ // Pre-v0.14.2: TS error, but ran correctly.
1412
+ // Post-v0.14.2: typechecks and narrows the same way `unique` and
1413
+ // `top${N}` do — `ReadonlyArray<T>` for source kind T.
1414
+ series.reduce({ vals: 'samples' }).vals; // ReadonlyArray<number> | undefined
1415
+
1416
+ series.aggregate(Sequence.every('5s'), { vals: 'samples' });
1417
+ // Output column: { name: 'vals', kind: 'array' }
1418
+ ```
1419
+
1420
+ - **`reducer-reference.mdx`** updated: "14 built-in reducers" → 15.
1421
+
1422
+ ### Added
1423
+
1424
+ - `test-d/types.test-d.ts` block pinning `'samples'` narrowing
1425
+ parity with `'unique'` / `'top${N}'`. Closes the regression hole
1426
+ the v0.14.1 review surfaced.
1427
+
1428
+ ### Known follow-up
1429
+
1430
+ The v0.14.1 review also flagged that `npm run verify`'s
1431
+ `test:type` step uses `tsconfig.types.json` (covers `src` +
1432
+ `test-d/`), not `tsconfig.vitest.json` (covers `test/`) — that's
1433
+ why the missing `'samples'` narrowing didn't fail CI even though
1434
+ `packages/core/test/samples-reducer.test.ts` had ~30 type errors.
1435
+ Captured in DOCPLAN.md / PLAN.md as a future safety-net widening;
1436
+ not in scope for v0.14.2 because pre-existing test files have
1437
+ their own type drift that would need cleanup first.
1438
+
1439
+ [0.14.2]: https://github.com/pjm17971/pond-ts/compare/v0.14.1...v0.14.2
1440
+
1441
+ ## [0.14.1] — 2026-05-03
1442
+
1443
+ The "samples reducer + lifted custom-fn guard" release. Surfaced by
1444
+ the gRPC experiment's step-4 (anomaly density) walkback: the use
1445
+ case "compute counts of values exceeding `k·σ` from a baseline" needs
1446
+ the **raw values** from the rolling window, but pond's existing
1447
+ built-ins all collapse to scalars or deduplicate. Custom-function
1448
+ reducers — which would cover the use case cleanly — worked on batch
1449
+ but were rejected at runtime on live with a `TypeError` pointing at
1450
+ `AggregateOutputMap` aliases (which don't actually solve "all values"
1451
+ either). Two related changes ship together to close both gaps.
1452
+
1453
+ ### Added
1454
+
1455
+ - **`'samples'` built-in reducer.** Returns the bucket's defined
1456
+ values as an array, in arrival order, with duplicates preserved.
1457
+ Sits beside `'unique'` (which deduplicates) and `'top${N}'` (which
1458
+ bounds and frequency-orders) — same array-output kind, same
1459
+ type-system narrowing through `AggregateOutputMap`. Library-
1460
+ implemented; per-event cost is O(1) `add` / O(1) `remove`
1461
+ (Map-keyed by event index); `snapshot` is O(N) array copy.
1462
+ Memory O(window size).
1463
+
1464
+ ```ts
1465
+ // Anomaly density: count samples > k·σ from a separate baseline.
1466
+ const stats = live.rolling(
1467
+ '1m',
1468
+ {
1469
+ mean: { from: 'cpu', using: 'avg' },
1470
+ sd: { from: 'cpu', using: 'stdev' },
1471
+ },
1472
+ { trigger: Trigger.every('30s') },
1473
+ );
1474
+
1475
+ const recent = live.rolling('200ms', {
1476
+ vals: { from: 'cpu', using: 'samples' },
1477
+ });
1478
+
1479
+ // At each tick, count threshold crossings against the baseline:
1480
+ stats.on('event', (e) => {
1481
+ const samples = recent.value().vals as ReadonlyArray<number>;
1482
+ const counts = thresholds.map(
1483
+ (k) => samples.filter((v) => v - e.get('mean') > k * e.get('sd')).length,
1484
+ );
1485
+ // ... emit anomaly density
1486
+ });
1487
+ ```
1488
+
1489
+ Like `unique`, `samples` flattens one level on array-kind source
1490
+ columns. Returns `[]` for an empty bucket.
1491
+
1492
+ ### Changed
1493
+
1494
+ - **Custom-function reducers now work on live.** Removed the runtime
1495
+ `TypeError` guards on `LiveAggregation`, `LiveRollingAggregation`,
1496
+ and `LivePartitionedSyncRolling` that previously rejected
1497
+ function-typed reducers. New `bucketStateFor` and `rollingStateFor`
1498
+ helpers in `reducers/index.ts` route built-ins to their dedicated
1499
+ O(1) machinery and wrap custom functions in a generic adapter:
1500
+ - **Bucket adapter** (`LiveAggregation`): buffers values, calls
1501
+ the function once at `snapshot()` time. O(N) per snapshot.
1502
+ - **Rolling adapter** (`LiveRollingAggregation`,
1503
+ `LivePartitionedSyncRolling`): Map-keyed by event index for O(1)
1504
+ `add` / O(1) `remove`; `snapshot()` calls the function with
1505
+ `Array.from(map.values())` in arrival order. **O(N) per
1506
+ snapshot** — the function re-runs over the current window each
1507
+ time the accumulator emits.
1508
+
1509
+ Documented as the explicit trade-off: convenience of writing
1510
+ `(values) => ...` inline against the perf cliff at high event
1511
+ rates. For high-throughput streams prefer built-ins or `'samples'`
1512
+ (collapse the window once on the producer side, run custom logic
1513
+ on the consumer). For low-rate dashboards / debug pipelines /
1514
+ prototypes, the convenience usually wins.
1515
+
1516
+ Pre-v0.14.1, calling `live.rolling(...)` with a custom-function
1517
+ reducer threw `TypeError: live rolling reducer for output 'X' must
1518
+ be a built-in name; ...`. Post-v0.14.1, the same call constructs
1519
+ successfully and runs.
1520
+
1521
+ ### Tests
1522
+
1523
+ - 15 new tests in `test/samples-reducer.test.ts` covering: batch
1524
+ reduce / aggregate / rolling (including the array-source
1525
+ flattening); live aggregate (per-bucket arrays); live rolling
1526
+ (window eviction, snapshot correctness through multiple cycles);
1527
+ synced partitioned rolling with samples per partition; an
1528
+ end-to-end anomaly-density-against-baseline scenario.
1529
+ - 2 obsolete tests in `LiveAggregateOutputMap.test.ts` rewritten —
1530
+ previously asserted the rejection error, now assert that custom
1531
+ functions construct successfully and produce the right value.
1532
+ - Total core tests: 1087 (was 1072).
1533
+
1534
+ ### Docs
1535
+
1536
+ - `pond-ts/transforms/reducer-reference.mdx`: new `'samples'` entry
1537
+ in the Array-producing reducers section; "Choosing a reducer"
1538
+ matrix updated; empty-bucket and rolling-complexity tables
1539
+ updated; Custom reducers section gained the live perf-cliff
1540
+ callout.
1541
+ - `pond-ts/transforms/rolling.mdx`: replaced the "Custom-function
1542
+ reducers are batch-only" note with the new "O(N) per snapshot on
1543
+ live" perf-cliff note pointing at the reducer reference.
1544
+
1545
+ [0.14.1]: https://github.com/pjm17971/pond-ts/compare/v0.14.0...v0.14.1
1546
+
1547
+ ## [0.14.0] — 2026-05-01
1548
+
1549
+ Two perf wins driven by the gRPC experiment's V3 profiling pass
1550
+ (PR #14 on `pond-grpc-experiment`): `estimateEventBytes` at 6.2%
1551
+ self time and the partition router's `Event → row → Event`
1552
+ round-trip (combined ~7% in `#validateRow` + `Event` constructor
1553
+ re-allocations). Both root-caused, both fixed.
1554
+
1555
+ Benchmark deltas on `scripts/perf-live-partitioned.mjs`
1556
+ (100k events, median ms):
1557
+
1558
+ | Scenario | Before | After | Δ |
1559
+ | ------------------------------------ | -----: | ----: | -------: |
1560
+ | bare `LiveSeries.push` | 41.11 | 30.08 | **−27%** |
1561
+ | `partitionBy('host')` routing (10) | 83.14 | 39.10 | **−53%** |
1562
+ | `partitionBy + collect()` | 124.82 | 49.96 | **−60%** |
1563
+ | `partitionBy + apply(fill)` | 120.53 | 49.64 | **−59%** |
1564
+ | `partitionBy('host')` routing (1000) | 105.92 | 43.23 | **−59%** |
1565
+
1566
+ The bare-push delta is from the byte-estimate removal; the
1567
+ partition-routing deltas are from the trusted-pipeline path that
1568
+ skips `Event → row → Event` reconstruction at every routing hop.
1569
+
1570
+ ### Removed (breaking, pre-1.0)
1571
+
1572
+ - **`retention.maxBytes`** option on `LiveSeriesOptions`. Speculative
1573
+ feature from pre-v0.10 that no real user has reached for. Use
1574
+ `retention.maxEvents` for count-based caps; `maxBytes` was
1575
+ approximate (rough per-event byte estimate) and the imprecision
1576
+ meant it was rarely used as designed.
1577
+
1578
+ Migration: replace `{ retention: { maxBytes: N } }` with
1579
+ `{ retention: { maxEvents: M } }` where M is your desired
1580
+ upper bound on event count.
1581
+
1582
+ ### Changed
1583
+
1584
+ - **`estimateEventBytes` and the `#byteEstimate` accumulator
1585
+ removed** from `LiveSeries`. Closes the 6.2% per-push self-time
1586
+ line the gRPC experiment surfaced. Bare push is now ~27% faster
1587
+ for the typical case where `maxBytes` was never set.
1588
+
1589
+ - **Partition router uses a trusted-pipeline fast path.**
1590
+ `LivePartitionedSeries.#routeEvent`, `collect()`, and `apply()`
1591
+ previously round-tripped `Event → row → Event` at every routing
1592
+ hop — re-validating and re-allocating Events that the source
1593
+ pipeline had already constructed. New `_pushTrustedEvents` method
1594
+ on `LiveSeries` accepts pre-validated Event references (under a
1595
+ schema-identity contract; only used internally where the source
1596
+ and target schemas are guaranteed identical). Closes the ~7%
1597
+ combined self-time line in `#validateRow` (×2) and `Event`
1598
+ constructor (×2) that the gRPC profile flagged.
1599
+
1600
+ Trusted-pipeline applies to: the source-to-partition route, the
1601
+ per-partition replay-on-construct prefix, the unified-buffer
1602
+ `collect()` subscriber, and `apply()`'s factory-output forwarding.
1603
+ All four sites had identical schemas at both ends — the trust
1604
+ contract holds without runtime re-checking.
1605
+
1606
+ `_pushTrustedEvents` is `@internal` and not exported from the
1607
+ public type surface. Reach for `pushMany` from any external
1608
+ context; the trusted variant skips schema validation and is
1609
+ only safe for pond's own internal pipelines.
1610
+
1611
+ ### Tests
1612
+
1613
+ - 4 new tests in `test/LiveSeries.test.ts` for the trusted-pipeline
1614
+ path: insertion without re-validation, listener fan-out and
1615
+ retention behaviour, ordering enforcement (strict still rejects
1616
+ out-of-order on the trusted path — the trust contract is only
1617
+ about validation/allocation, not insertion ordering), empty-array
1618
+ no-op.
1619
+ - Removed the `retention: maxBytes` describe block in
1620
+ `test/LiveSeries.test.ts` and the `forwards retention.maxBytes`
1621
+ assertion in `test/LiveSeries.snapshot-append.test.ts`.
1622
+ - Total core tests: 1072 (was 1070; +4 new for the trusted path,
1623
+ −2 for the removed maxBytes assertions).
1624
+
1625
+ ### Docs
1626
+
1627
+ - `live-series.mdx`: retention table and example trimmed to
1628
+ `maxEvents` + `maxAge` only. Removed the byte-estimate prose.
1629
+
1630
+ [0.14.0]: https://github.com/pjm17971/pond-ts/compare/v0.13.2...v0.14.0
1631
+
1632
+ ## [0.13.2] — 2026-05-01
1633
+
1634
+ Strictly additive over v0.13.1. Adds `Trigger.count(n)` per the
1635
+ second wave of Codex feedback after webapp-telemetry adoption. Use
1636
+ case: "very hot metrics like row stale times or handler payload
1637
+ sizes where event-time boundaries may lag during bursts, but
1638
+ per-event is too noisy."
1639
+
1640
+ ### Added
1641
+
1642
+ - **`Trigger.count(n)`** — third trigger primitive alongside
1643
+ `Trigger.event()` and `Trigger.clock(seq)` /
1644
+ `Trigger.every(duration)`. Emits one rolling-window snapshot
1645
+ every `n` source events, with the counter resetting on each fire
1646
+ (so "events since the last emission," not "every Nth event modulo
1647
+ the input"):
1648
+
1649
+ ```ts
1650
+ const rolling = timings.rolling(
1651
+ '5m',
1652
+ { latency: 'p95' },
1653
+ { trigger: Trigger.count(1000) },
1654
+ );
1655
+ ```
1656
+
1657
+ - **Data-driven** — counter only advances on event ingestion, no
1658
+ `setTimeout` inside the library. The first emission fires on
1659
+ the `n`th event, not the first.
1660
+ - **Per-partition** — when applied via `partitionBy(...).rolling(...)`,
1661
+ each partition counts independently. Count does not synchronise
1662
+ emission across partitions; use `Trigger.clock` for that.
1663
+ - **Rejects non-positive integers** — `Trigger.count(0)`,
1664
+ `Trigger.count(-1)`, `Trigger.count(1.5)`, and `Trigger.count(NaN)`
1665
+ throw at construction with a clear error.
1666
+
1667
+ ### Changed
1668
+
1669
+ - **Trigger taxonomy expanded.** `Trigger` union is now
1670
+ `EventTrigger | ClockTrigger | CountTrigger`. Per-partition
1671
+ rolling overload widened to accept count triggers and route them
1672
+ to the `LivePartitionedView` per-partition path (not the synced
1673
+ rolling — count semantics across partitions are ambiguous and
1674
+ there's no killer use case for either choice yet).
1675
+
1676
+ ### Docs
1677
+
1678
+ - `live-transforms.mdx`: trigger section now lists all three
1679
+ primitives up front with a dedicated subsection on count
1680
+ semantics. JSDoc on `LiveRollingAggregation.trigger` updated to
1681
+ mention count.
1682
+ - PLAN.md: trigger-taxonomy expansion RFC sketch captured —
1683
+ documents the shipped `count` plus deferred decisions on `idle`
1684
+ (the wall-clock crossing, requires its own RFC), `any` (composite,
1685
+ ships after singletons exist), and `threshold` / `manual`
1686
+ (declined / deferred as misclassified or sugar over existing
1687
+ primitives).
1688
+
1689
+ ### Tests
1690
+
1691
+ - 7 new tests in `test/Triggers.test.ts`:
1692
+ - `Trigger.count(n)` shape and freeze
1693
+ - Non-positive integer rejection (zero, negative, fractional, NaN)
1694
+ - Emission cadence: snapshots every Nth event with correct
1695
+ rolling-window values
1696
+ - `Trigger.count(1)` behavioural equivalence to `Trigger.event()`
1697
+ - No emission during quiet periods (data-driven)
1698
+ - `rolling.value()` independent of trigger
1699
+ - Per-partition independent counting via
1700
+ `partitionBy().rolling(..., { trigger: Trigger.count(2) })`
1701
+ - Total core tests: 1070 (was 1063).
1702
+
1703
+ [0.13.2]: https://github.com/pjm17971/pond-ts/compare/v0.13.1...v0.13.2
1704
+
1705
+ ## [0.13.1] — 2026-05-01
1706
+
1707
+ Strictly additive over v0.13.0. Adds a sugar factory on `Trigger`
1708
+ following Codex feedback after adopting v0.12 triggers in the
1709
+ production webapp telemetry app: the explicit form
1710
+ (`Trigger.clock(Sequence.every('30s'))`) is "ceremony-heavy for the
1711
+ common case."
1712
+
1713
+ ### Added
1714
+
1715
+ - **`Trigger.every(duration, options?)`** — sugar for the common
1716
+ `Trigger.clock(Sequence.every(duration, options))` pattern. Removes
1717
+ the need to import `Sequence` for trigger-only use sites. Forwards
1718
+ `{ anchor }` to `Sequence.every` and inherits the same fixed-step
1719
+ validation:
1720
+
1721
+ ```ts
1722
+ // Before
1723
+ live.rolling('1m', mapping, {
1724
+ trigger: Trigger.clock(Sequence.every('30s')),
1725
+ });
1726
+
1727
+ // After
1728
+ live.rolling('1m', mapping, { trigger: Trigger.every('30s') });
1729
+
1730
+ // Anchored variant (passes through to Sequence.every):
1731
+ Trigger.every('30s', { anchor: 5_000 });
1732
+ ```
1733
+
1734
+ The explicit `Trigger.clock(seq)` form remains for callers who
1735
+ already hold a `Sequence` object (e.g. one shared across batch
1736
+ `series.aggregate(seq, ...)` and live triggers) — `Trigger.every`
1737
+ always builds a fresh `Sequence`.
1738
+
1739
+ ### Docs
1740
+
1741
+ - Telemetry recipe and live-transforms doc updated to lead with
1742
+ the sugar form. `Trigger.clock` documented as the explicit form
1743
+ for "I already have a Sequence object" cases.
1744
+ - JSDoc on `LiveRollingAggregation.trigger` and the partitioned
1745
+ rolling clock-trigger example updated to show the sugar.
1746
+
1747
+ ### Tests
1748
+
1749
+ - 3 new tests in `test/Triggers.test.ts` covering: sugar produces
1750
+ `kind: 'clock'` with correct stepMs/anchor; anchor option
1751
+ forwards correctly; behavioural equivalence between
1752
+ `Trigger.every('30s')` and `Trigger.clock(Sequence.every('30s'))`
1753
+ pinned by emission-time comparison through a real
1754
+ `LiveRollingAggregation`.
1755
+ - Total core tests: 1063 (was 1060).
1756
+
1757
+ [0.13.1]: https://github.com/pjm17971/pond-ts/compare/v0.13.0...v0.13.1
1758
+
1759
+ ## [0.13.0] — 2026-05-01
1760
+
1761
+ The "AggregateOutputMap on live" release. Closes the feature-parity
1762
+ gap between batch and live aggregation: the `{ alias: { from, using } }`
1763
+ mapping shape that batch `TimeSeries.rolling`/`aggregate` already
1764
+ accepted now works on `LiveSeries.rolling`, `LiveSeries.aggregate`,
1765
+ and the synchronised partitioned form. Multiple stats from one
1766
+ source column in a single rolling deque — no more "one rolling per
1767
+ percentile" workaround.
1768
+
1769
+ The shared runtime helper (`normalizeAggregateColumns`) was already
1770
+ doing the work for batch; this release extracts it to
1771
+ `aggregate-columns.ts` and threads the type-level overloads through
1772
+ the live surface.
1773
+
1774
+ ### Added
1775
+
1776
+ - **`AggregateOutputMap` on `LiveSeries.rolling` and
1777
+ `LiveSeries.aggregate`.** Compose multiple built-in reducers from
1778
+ one source column in a single pass:
1779
+
1780
+ ```ts
1781
+ const band = live.rolling('1m', {
1782
+ mean: { from: 'cpu', using: 'avg' },
1783
+ sd: { from: 'cpu', using: 'stdev' },
1784
+ });
1785
+ band.value(); // { mean, sd } — single deque, one walk
1786
+ ```
1787
+
1788
+ Threaded through `LiveView.rolling`/`aggregate`,
1789
+ `LiveAggregation.rolling`, `LiveRollingAggregation.aggregate`,
1790
+ `LivePartitionedSeries.rolling`, and `LivePartitionedView.rolling`
1791
+ — so chained pipelines (`live.filter(...).rolling(...)`,
1792
+ `live.partitionBy(c).fill(...).rolling(..., { trigger: ... })`)
1793
+ accept either shape.
1794
+
1795
+ - **Synchronised partitioned rolling with `AggregateOutputMap`.**
1796
+ `partitionBy(col).rolling(window, mapping, { trigger: Trigger.clock(seq) })`
1797
+ now accepts the alias form. Output schema becomes
1798
+ `[time, <partitionColumn>, ...aliasColumns]`. The collision check
1799
+ rejects when an alias output collides with the partition column
1800
+ name (compare against the alias, not the source column).
1801
+
1802
+ ### Changed
1803
+
1804
+ - **Better error message when a custom-function reducer is passed to
1805
+ live aggregation.** `LiveAggregation` already failed at construction
1806
+ via `resolveReducer(reducer)` (with a generic `unsupported aggregate
1807
+ reducer` message); now the eager built-in-name check runs first and
1808
+ emits a targeted error pointing at the `AggregateOutputMap` alias
1809
+ workaround. Same eager behavior on `LivePartitionedSyncRolling`,
1810
+ which previously failed lazily when the first partition spawned —
1811
+ now fails at construction. Aligns with `LiveRollingAggregation`'s
1812
+ long-standing eager check.
1813
+
1814
+ - **Shared `normalizeAggregateColumns` helper.** Extracted from
1815
+ `TimeSeries.ts` into `aggregate-columns.ts` and used by all three
1816
+ live accumulators (`LiveRollingAggregation`, `LiveAggregation`,
1817
+ `LivePartitionedSyncRolling`). Single source of truth for column
1818
+ normalisation; identical error messages across batch and live
1819
+ (`unknown source column`).
1820
+
1821
+ ### Constraints
1822
+
1823
+ - **Custom-function reducers remain batch-only.** Live rolling and
1824
+ live aggregation still require built-in reducer names (`'avg'`,
1825
+ `'p95'`, etc.). Custom `(values) => ...` functions don't have the
1826
+ incremental add/remove machinery the live path needs and are
1827
+ rejected at construction with a clear error pointing at the
1828
+ `AggregateOutputMap` workaround. This is the established
1829
+ recommendation: alias multiple built-ins to compose stats from
1830
+ one source column.
1831
+
1832
+ ### Fixed
1833
+
1834
+ - **`partitionBy(...).rolling(..., options)` now accepts `options` as
1835
+ a variable typed `LiveRollingOptions`, not just inline literals.**
1836
+ Pre-fix, the four narrowed overloads on
1837
+ `LivePartitionedSeries.rolling` and `LivePartitionedView.rolling`
1838
+ required TS to see the `trigger` field's discriminator at the call
1839
+ site — so a caller writing
1840
+ `const opts: LiveRollingOptions = { trigger: Trigger.event() };
1841
+ partitioned.rolling(window, mapping, opts);` got `TS2769 No
1842
+ overload matches this call`. Pre-existing hole on the partitioned
1843
+ surface; surfaced by the v0.13.0 Codex adversarial pass. Closed by
1844
+ adding catch-all overloads that accept the broader
1845
+ `LiveRollingOptions` and return the union of both trigger
1846
+ branches; the four narrowed overloads above still match inline
1847
+ literals first, so callers keep the precise return type when they
1848
+ pass the trigger inline. Pinned with `test-d/types.test-d.ts`
1849
+ coverage using both inline-literal and variable forms.
1850
+
1851
+ ### Tests
1852
+
1853
+ - 16 new tests in `test/LiveAggregateOutputMap.test.ts` covering:
1854
+ flat live rolling/aggregate with the alias form, chained-view
1855
+ rolling/aggregate, `LiveAggregation.rolling` and
1856
+ `LiveRollingAggregation.aggregate` chainable accumulators,
1857
+ per-partition rolling, synchronised partitioned rolling with
1858
+ alias outputs, output-vs-source column-collision rejection on
1859
+ the synced form, and explicit kind override.
1860
+ - 2 existing tests updated (`LiveAggregation` and
1861
+ `LiveRollingAggregation` "unknown column" → "unknown source column"
1862
+ to match the shared helper's error string).
1863
+ - Test count: 1060 (was 1044).
1864
+
1865
+ ### Docs
1866
+
1867
+ - `transforms/rolling.mdx`: live section now documents the
1868
+ `AggregateOutputMap` shape with a band-chart example, plus a
1869
+ callout that custom functions remain batch-only.
1870
+ - `recipes/telemetry-reporting.mdx`: "Want multiple percentiles?"
1871
+ section rewritten — the workaround note is gone, replaced with
1872
+ the single-pass `{ p50, p95, p99 }` pattern.
1873
+
1874
+ [0.13.0]: https://github.com/pjm17971/pond-ts/compare/v0.12.1...v0.13.0
1875
+
1876
+ ## [0.12.1] — 2026-05-01
1877
+
1878
+ Strictly additive over v0.12.0. Closes the chained-view restriction
1879
+ on synchronised partitioned rolling. The trigger option now applies
1880
+ consistently across the entire `rolling()` surface — chained sugar
1881
+ methods on the partitioned surface (`fill`, `diff`, `rate`,
1882
+ `pctChange`, `cumulative`) no longer break it.
1883
+
1884
+ ### Changed
1885
+
1886
+ - **`partitionBy(col).<chained>().rolling(window, m, { trigger: Trigger.clock(seq) })` now works.**
1887
+ Previously this threw a clear-but-restrictive error. The chain
1888
+ factory runs per partition; the sync rolling subscribes to each
1889
+ chain output instead of the raw partition events. Output schema is
1890
+ unchanged (`[time, <partitionColumn>, ...mappingColumns]`); the
1891
+ partition tag is set from the routing key, so chains that drop the
1892
+ partition column still emit correctly.
1893
+
1894
+ Motivating example — per-host gap-filling before synchronised
1895
+ ticks:
1896
+
1897
+ ```ts
1898
+ const ticks = live
1899
+ .partitionBy('host')
1900
+ .fill({ cpu: 'hold' })
1901
+ .rolling(
1902
+ '1m',
1903
+ { cpu: 'avg' },
1904
+ { trigger: Trigger.clock(Sequence.every('200ms')) },
1905
+ );
1906
+ ```
1907
+
1908
+ Coherence-of-feature fix: the trigger concept now applies wherever
1909
+ `rolling()` appears in the partitioned chain, not just in the
1910
+ one-step case. Captured in the RFC's post-implementation notes
1911
+ alongside the deferred-and-now-shipped section.
1912
+
1913
+ ### Tests
1914
+
1915
+ - 4 new tests in `test/Triggers.test.ts` covering chained-view sync
1916
+ rolling: `fill().rolling(.., trigger)`, output schema, cross-
1917
+ partition synchronisation through the chain, dispose semantics
1918
+ through the chain, and replay-on-construction with the chain factory.
1919
+ - 1 test removed (the throw-on-chained-view assertion that no longer
1920
+ applies).
1921
+ - Test count: 34 (was 30). Total core tests: 1043 (was 1039).
1922
+
1923
+ [0.12.1]: https://github.com/pjm17971/pond-ts/compare/v0.12.0...v0.12.1
1924
+
1925
+ ## [0.12.0] — 2026-05-01
1926
+
1927
+ The "triggers" release. Major redesign of how live accumulators
1928
+ control emission cadence — `Trigger` is now a first-class concept
1929
+ shaped by two converging real-world use cases (synchronised
1930
+ partitioned tick aggregation in the gRPC pipeline experiment,
1931
+ sequence-sampled rolling in webapp telemetry).
1932
+
1933
+ Two correctness audits before publish: a Layer 2 Claude review
1934
+ (column collision, dispose, late-spawn, peer-dep) and a Codex
1935
+ adversarial review (quiet-partition stale samples, pre-existing
1936
+ data replay at construction, spawn-listener cleanup). All findings
1937
+ fixed and pinned with regression tests. 1039 / 1039 tests pass.
1938
+
1939
+ ### Added
1940
+
1941
+ - **Trigger as a first-class concept.** A new `Trigger` factory
1942
+ exposed at the package root lets `LiveRollingAggregation` switch
1943
+ emission cadence without changing any other shape:
1944
+
1945
+ ```ts
1946
+ import { LiveSeries, Sequence, Trigger } from 'pond-ts';
1947
+
1948
+ // Webapp telemetry: rolling 1m p95, emit on every 30 s of event-time
1949
+ const rolling = timings.rolling(
1950
+ '1m',
1951
+ { latency: 'p95' },
1952
+ { trigger: Trigger.clock(Sequence.every('30s')) },
1953
+ );
1954
+
1955
+ rolling.on('event', (e) =>
1956
+ fetch('/api/telemetry', { method: 'POST', body: JSON.stringify(e.data()) }),
1957
+ );
1958
+ rolling.value(); // current rolling-window snapshot, independent of trigger
1959
+ ```
1960
+
1961
+ Two trigger variants in this release:
1962
+ - **`Trigger.event()`** — per-event emission. Default; the historical
1963
+ behavior of `LiveRollingAggregation` when no trigger is specified.
1964
+ - **`Trigger.clock(sequence)`** — sequence-triggered emission. One
1965
+ snapshot fires when a source event crosses an epoch-aligned
1966
+ boundary of the (fixed-step) `Sequence`. Output keyed at boundary
1967
+ instants. Calendar sequences are rejected upfront.
1968
+
1969
+ Future variants (`Trigger.count(n)`, custom predicates, compound
1970
+ triggers) are reserved but not yet shipped.
1971
+
1972
+ - **Synchronised partitioned rolling.** `LivePartitionedSeries.rolling`
1973
+ now accepts a clock trigger. The output is a
1974
+ `LiveSource<RowSchema>` whose schema is `[time, <partitionColumn>,
1975
+ ...mappingColumns]`; on every boundary crossing, one event fires
1976
+ per known partition, all sharing the same boundary timestamp.
1977
+ Synchronised across partitions by construction (the bucket index is
1978
+ shared, not per-partition).
1979
+
1980
+ ```ts
1981
+ // Dashboard tick aggregation: 100 hosts, 200ms cadence
1982
+ const ticks = live
1983
+ .partitionBy('host')
1984
+ .rolling(
1985
+ '1m',
1986
+ { cpu: 'avg' },
1987
+ { trigger: Trigger.clock(Sequence.every('200ms')) },
1988
+ );
1989
+
1990
+ ticks.on('event', (e) => {
1991
+ // e.begin() === <boundary timestamp>, same for every host this tick
1992
+ // e.get('host') === 'api-1' | 'api-2' | …
1993
+ // e.get('cpu') === <rolling avg for that host>
1994
+ });
1995
+ ```
1996
+
1997
+ Restricted to direct-after-`partitionBy` in this release: chained
1998
+ sugar (`partitionBy(c).fill(...).rolling(...)`) rejects clock
1999
+ triggers with a clear error. Lifts in a future release once a real
2000
+ use case appears.
2001
+
2002
+ Closes the gRPC experiment's M3.5 dashboard friction note (the
2003
+ hand-rolled `HostAggregator` becomes ~10 lines of pond code).
2004
+
2005
+ ### Removed (breaking — pre-1.0)
2006
+
2007
+ - **`LiveSequenceRollingAggregation`** class deleted. Its capability
2008
+ is preserved as `LiveRollingAggregation` with
2009
+ `{ trigger: Trigger.clock(sequence) }`. Migration: replace
2010
+ `live.rolling('1m', m).sample(seq)` with
2011
+ `live.rolling('1m', m, { trigger: Trigger.clock(seq) })`. Single
2012
+ rolling object now serves both backend reporting and direct
2013
+ `.value()` reads (no separate sampler reference).
2014
+ - **`.sample(sequence)`** method removed from `LiveRollingAggregation`.
2015
+ Use the trigger option above.
2016
+
2017
+ ### Changed
2018
+
2019
+ - **`LiveRollingOptions`** gains an optional `trigger?: Trigger`
2020
+ field. Default behavior (no `trigger` specified) is unchanged from
2021
+ v0.11.x — per-event emission. Backward compatible for everyone
2022
+ who didn't use `.sample()`.
2023
+
2024
+ ### Performance
2025
+
2026
+ - New benchmark `scripts/perf-triggers.mjs` covers both
2027
+ non-partitioned and synchronised partitioned cases. Headline numbers
2028
+ on a current MacBook Pro:
2029
+ - Non-partitioned: clock(30s) ~50% faster than per-event baseline
2030
+ (emission is rarer); clock(1s) similar.
2031
+ - Synchronised partitioned (100 hosts, 30k events at realistic
2032
+ rates): ~300 ns/emission at 200ms cadence; +205% over per-
2033
+ partition baseline at the high end. Well within budget for the
2034
+ motivating dashboard use case.
2035
+
2036
+ ### Notes
2037
+
2038
+ - **`docs/rfcs/triggers.md`** captures the full design rationale,
2039
+ the four sign-off questions, and the migration plan. Read this if
2040
+ you want the "why this shape" context.
2041
+
2042
+ ### Known limitations
2043
+
2044
+ - **Synchronised partitioned rolling output type is loose** —
2045
+ `LiveSource<SeriesSchema>` rather than a schema-narrowed shape.
2046
+ Runtime schema is correct; only static types widen. Tightening is
2047
+ queued for a follow-up release.
2048
+ - **Synchronised partitioned rolling rejects column-name collisions**
2049
+ between the partition column and any reducer-output column at
2050
+ construction (e.g. `partitionBy('cpu').rolling('1m', { cpu: 'avg' }, { trigger })`).
2051
+ Rename the reducer output (once `AggregateOutputMap` lands on live
2052
+ rolling) or partition by a different column.
2053
+ - **Late-spawn partitions only appear in ticks after their first event
2054
+ arrives.** A partition unknown to the sync source contributes no
2055
+ row to the current tick. Use `partitionBy(col, { groups: [...] })`
2056
+ to eagerly include partitions from construction.
2057
+
2058
+ ## [0.11.8] — 2026-04-30
2059
+
2060
+ ### Added
2061
+
2062
+ - **`rolling.sample(sequence)`** on `LiveRollingAggregation` — taps a
2063
+ rolling aggregation and emits one snapshot of the rolling state each
2064
+ time a source event crosses an epoch-aligned boundary of `sequence`.
2065
+ Closes the frontend-telemetry gap: collect high-frequency timing
2066
+ events, sample p95 latency to a backend every 30 s, while the same
2067
+ rolling drives an in-app live display (no duplicated deque).
2068
+
2069
+ ```ts
2070
+ const rolling = timings.rolling('1m', { latency: 'p95' });
2071
+
2072
+ // One sampler → backend report every 30 s of event time
2073
+ const reported = rolling.sample(Sequence.every('30s'));
2074
+ reported.on('event', (e) =>
2075
+ fetch('/api/telemetry', { method: 'POST', body: JSON.stringify(e.data()) }),
2076
+ );
2077
+
2078
+ // Same rolling drives the UI live display
2079
+ useLiveQuery(timings, () => rolling.value());
2080
+ ```
2081
+
2082
+ `sequence` must be a fixed-step `Sequence`; calendar sequences
2083
+ (`Sequence.daily()` etc.) are rejected upfront — boundary indexing
2084
+ needs a constant step.
2085
+
2086
+ Emission is **data-driven**: no `setInterval`. If the source goes
2087
+ quiet, no events fire. A single source event spanning multiple
2088
+ boundaries fires exactly one event at the new bucket. Snapshot is
2089
+ taken after the boundary-crossing event is ingested by the rolling,
2090
+ so the emitted value includes that event's contribution.
2091
+
2092
+ **Independent lifetimes.** `sample.dispose()` only detaches the
2093
+ sampler from the rolling; the rolling's lifecycle stays the user's
2094
+ responsibility. One rolling can power multiple `.sample()` cadences
2095
+ plus direct `rolling.value()` reads without coupling.
2096
+
2097
+ - **`LiveSequenceRollingAggregation` exported** from package root with
2098
+ full `LiveSource<Out>` surface and the same view-transform set as
2099
+ `LiveRollingAggregation` (`filter`, `map`, `select`, `window`,
2100
+ `diff`, `rate`, `pctChange`, `fill`, `cumulative`, `rolling`,
2101
+ `aggregate`).
2102
+
2103
+ - **Telemetry-reporting recipe** at
2104
+ `website/docs/recipes/telemetry-reporting.mdx` — end-to-end
2105
+ frontend-collection → backend-summary pattern using `.sample()`,
2106
+ plus the React in-app display via `useLiveQuery`.
2107
+
2108
+ [0.12.0]: https://github.com/pjm17971/pond-ts/compare/v0.11.8...v0.12.0
2109
+ [0.11.8]: https://github.com/pjm17971/pond-ts/compare/v0.11.7...v0.11.8
2110
+
2111
+ ## [0.11.7] — 2026-04-29
2112
+
2113
+ ### Added
2114
+
2115
+ - **`LiveView.count()` and `LiveView.eventRate()` terminal accessors.**
2116
+ Read the current event count and events-per-second over a windowed
2117
+ view directly — closes the
2118
+ `useCurrent(live, { cpu: 'count' }, { tail: '1m' }).cpu / 60`
2119
+ boilerplate surfaced by the gRPC experiment.
2120
+ ```ts
2121
+ const eventsPerSec = live.window('1m').eventRate(); // events/sec
2122
+ const eventsInWindow = live.window('1m').count();
2123
+ ```
2124
+ `eventRate()` requires a time-based window (`window('1m')`) and
2125
+ throws on count-based windows (`window(100)`) — there's no
2126
+ denominator to use. Distinct from `LiveView.rate(columns)`,
2127
+ which is the per-column derivative operator (rate-of-change of
2128
+ values).
2129
+ - `LiveView.{filter,map,select}` now propagate the parent's window
2130
+ duration to the child view, so chains like
2131
+ `live.window('1m').filter(...).eventRate()` work as expected.
2132
+ - `@pond-ts/react` ships **`useEventRate(source, '1m')`** — a
2133
+ reactive hook returning the events-per-second number, throttled
2134
+ on `'event'` like `useSnapshot`. Hooks mounted on already-
2135
+ populated sources render the actual rate on first paint via
2136
+ lazy `useState` init.
2137
+ ```tsx
2138
+ const eventsPerSec = useEventRate(liveSeries, '1m');
2139
+ // <div>EVENT RATE {eventsPerSec.toFixed(1)}/s</div>
2140
+ ```
2141
+
2142
+ [0.11.7]: https://github.com/pjm17971/pond-ts/compare/v0.11.6...v0.11.7
2143
+
2144
+ ## [0.11.6] — 2026-04-29
2145
+
2146
+ ### Added
2147
+
2148
+ - **`LiveSeries.toJSON()` return-type narrowing on `rowFormat`.**
2149
+ Overloads keyed on `rowFormat: 'array' | 'object'` so consumers
2150
+ read `result.rows` without a cast. Tuple form returns
2151
+ `TimeSeriesJsonOutputArray<S>`; object form returns
2152
+ `TimeSeriesJsonOutputObject<S>`. Both new types exported from
2153
+ `pond-ts/types`. The companion narrowing on `TimeSeries.toJSON`
2154
+ is still parked — it cascades TS2394 errors through unrelated
2155
+ overload sets in `TimeSeries.ts`. See PLAN.md.
2156
+ - New types: `TimeSeriesJsonOutputArray<S>` and
2157
+ `TimeSeriesJsonOutputObject<S>`. Use these for typed assignment
2158
+ (`const out: TimeSeriesJsonOutputArray<S> = ts.toJSON()`) or
2159
+ cast (`ts.toJSON() as TimeSeriesJsonOutputArray<S>`) until the
2160
+ `TimeSeries.toJSON` narrowing lands.
2161
+
2162
+ ### Documentation
2163
+
2164
+ - `count` reducer JSDoc clarifies that **duplicate temporal keys
2165
+ do not collapse** — multiple events sharing one `Time` key each
2166
+ contribute independently to the count. Walks the per-column
2167
+ value array, not unique keys. Behavior is consistent across
2168
+ `reduce`, `aggregate`, `rolling`, `LiveAggregation`, and
2169
+ `LiveRollingAggregation` — pinned by `test/duplicate-keys.test.ts`
2170
+ (9 tests covering every layer including the
2171
+ "dashboard-defaults" 480-events-at-8/s scenario from the gRPC
2172
+ experiment's M1 friction notes).
2173
+
2174
+ [0.11.6]: https://github.com/pjm17971/pond-ts/compare/v0.11.5...v0.11.6
2175
+
2176
+ ## [0.11.5] — 2026-04-29
2177
+
2178
+ ### Fixed
2179
+
2180
+ - Published tarballs for both `pond-ts` and `@pond-ts/react` now
2181
+ include `README.md`, `LICENSE`, and `CHANGELOG.md`. Earlier
2182
+ releases shipped only `dist/` + `package.json`, which left the
2183
+ npm page rendering as "This package does not have a README"
2184
+ despite the comprehensive root README. The repo-root files were
2185
+ invisible to `npm pack` because npm publishes from the package
2186
+ directory and only auto-includes README/LICENSE when those files
2187
+ live in the package dir itself. Each package now has a `prepack`
2188
+ step that copies them in from the repo root before build.
2189
+
2190
+ [0.11.5]: https://github.com/pjm17971/pond-ts/compare/v0.11.4...v0.11.5
2191
+
2192
+ ## [0.11.4] — 2026-04-29
2193
+
2194
+ ### Added
2195
+
2196
+ - **`LiveSeries` snapshot/append primitives** — closes the gap
2197
+ where networked `LiveSeries` setups (gRPC, WebSocket fanout) had
2198
+ to hand-roll the parallel APIs that already existed on
2199
+ `TimeSeries`.
2200
+ - **Codec-agnostic typed-tuple primitives:** `LiveSeries.toRows()`,
2201
+ `LiveSeries.toObjects()`, `LiveSeries.pushMany(rows)`,
2202
+ `Event.toRow(schema)`. Operate in `RowForSchema<S>` typed
2203
+ tuples — JSON, MessagePack, protobuf, anything else applies at
2204
+ the application boundary, not inside the library.
2205
+ - **JSON sugar layered on top:** `LiveSeries.toJSON()`,
2206
+ `LiveSeries.fromJSON(input, options?)`,
2207
+ `LiveSeries.pushJson(rows)`, `Event.toJsonRow(schema)`. Closes
2208
+ the wire→push safety hole — `pushJson` validates a
2209
+ `JsonRowForSchema<S>` against the schema at compile time, so
2210
+ schema evolution breaks the call site instead of swallowing
2211
+ via `live.push(row as never)`.
2212
+ - **`pushMany(rows)` is non-variadic.** Pair with the existing
2213
+ variadic `push(...rows)` (now a one-line wrapper); reach for
2214
+ `pushMany` when ingesting a snapshot or any large array —
2215
+ variadic spread allocates a stack frame per element and can
2216
+ blow on multi-thousand-row snapshots.
2217
+
2218
+ Surfaced by the gRPC experiment's M1 milestone
2219
+ ([pond-grpc-experiment#3](https://github.com/pjm17971/pond-grpc-experiment/pull/3)).
2220
+ See PLAN.md Phase 4 for the deferred adaptor-extraction
2221
+ framing (codec strategies parked until two real codecs exist
2222
+ in working code).
2223
+
2224
+ ### Changed
2225
+
2226
+ - `LiveSeries.push(...rows)` is now a wrapper around
2227
+ `LiveSeries.pushMany(rows)`. Behavior is identical — same
2228
+ validation, listener fires, and retention pass.
2229
+
2230
+ [0.11.4]: https://github.com/pjm17971/pond-ts/compare/v0.11.3...v0.11.4
2231
+
2232
+ ## [0.11.3] — 2026-04-28
2233
+
2234
+ ### Added
2235
+
2236
+ - **`pond-ts/types` subpath export** — type-only entry point that
2237
+ exposes the schema-shape, row-shape, and JSON-shape types
2238
+ (`SeriesSchema`, `ColumnDef`, `RowForSchema`,
2239
+ `JsonRowForSchema`, etc.) without dragging in the runtime.
2240
+ Schema-as-contract consumers — packages whose only job is to
2241
+ declare the `as const` schema flowing through producer /
2242
+ aggregator / web — can now constrain literals via
2243
+ `satisfies SeriesSchema` without adding `pond-ts` as a runtime
2244
+ dependency. Surfaced by the gRPC experiment's `packages/shared`,
2245
+ where `import { SeriesSchema } from 'pond-ts'` would have
2246
+ pulled in the whole library for one type.
2247
+
2248
+ ```ts
2249
+ import type { SeriesSchema } from 'pond-ts/types';
2250
+ export const schema = [
2251
+ { name: 'time', kind: 'time' },
2252
+ { name: 'cpu', kind: 'number' },
2253
+ ] as const satisfies SeriesSchema;
2254
+ ```
2255
+
2256
+ Existing `import { SeriesSchema } from 'pond-ts'` calls keep
2257
+ working unchanged.
2258
+
2259
+ [0.11.3]: https://github.com/pjm17971/pond-ts/compare/v0.11.2...v0.11.3
2260
+
2261
+ ## [0.11.2] — 2026-04-28
2262
+
2263
+ ### Added
2264
+
2265
+ - `minSamples` option on `TimeSeries.rolling`,
2266
+ `PartitionedTimeSeries.rolling`, `LiveRollingAggregation`, and
2267
+ the `LivePartitionedSeries` rolling sugar — suppresses output
2268
+ rows whose window contains fewer than the configured number of
2269
+ source events. Forwarded to `TimeSeries.baseline` and
2270
+ `TimeSeries.outliers` (and their per-partition variants), which
2271
+ pass it to their internal rolling pass. Defaults to `0` (no
2272
+ gate) so existing call sites are unaffected. Use it on noisy
2273
+ rolling stats (e.g. the rolling stdev that feeds
2274
+ `baseline()`'s ±σ bands) to hide the warm-up region where a
2275
+ tiny-sample stdev would collapse the band tight enough to
2276
+ false-flag normal events.
2277
+
2278
+ [0.11.2]: https://github.com/pjm17971/pond-ts/compare/v0.11.1...v0.11.2
2279
+
2280
+ ## [0.11.1] — 2026-04-27
2281
+
2282
+ Closes a packaging footgun the dashboard agent surfaced while
2283
+ upgrading from `pond-ts@0.10.1` to `pond-ts@0.11.0`.
2284
+
2285
+ When users had `@pond-ts/react@0.10.1` (which declared
2286
+ `dependencies: { "pond-ts": "^0.10.0" }`) and bumped only
2287
+ `pond-ts` to `0.11.0`, npm satisfied the react package's `^0.10.0`
2288
+ range by nesting a _second_ copy of `pond-ts@0.10.1` under
2289
+ `@pond-ts/react/node_modules/`. Two pond-ts copies meant two
2290
+ distinct `Sequence` / `Time` / etc. classes with non-shared JS
2291
+ private (`#`) brands. TypeScript surfaced this as
2292
+ `Property '#private' refers to a different member`, which is
2293
+ opaque without the package context.
2294
+
2295
+ ### Changed
2296
+
2297
+ - **`@pond-ts/react`**: moved `pond-ts` from `dependencies` to
2298
+ `peerDependencies` (range unchanged: `^0.11.0`). With peer-dep
2299
+ semantics, npm refuses to install a duplicate `pond-ts`; instead
2300
+ it warns at install time about peer-version mismatch — concrete,
2301
+ actionable feedback rather than a runtime brand-check failure.
2302
+
2303
+ This is the standard pattern for packages that wrap another
2304
+ library's classes (`react-dom` peer-deps `react`, etc.):
2305
+ `@pond-ts/react`'s hooks return and operate on `pond-ts`
2306
+ instances, so they MUST share class identity with the consumer's
2307
+ `pond-ts`.
2308
+
2309
+ **Mild break:** consumers who installed only `@pond-ts/react`
2310
+ and relied on the transitive `pond-ts` will now get an npm
2311
+ warning and need to add `pond-ts` to their direct dependencies.
2312
+ In practice anyone using `@pond-ts/react` is already importing
2313
+ `pond-ts` types/classes, so the typical setup already has it
2314
+ declared explicitly.
2315
+
2316
+ ### Notes
2317
+
2318
+ - **Why caret (`^0.11.0`) and not exact pin?** Pre-1.0 caret
2319
+ semver only accepts patches within the same minor (so
2320
+ `^0.11.0` matches 0.11.x but not 0.12.0). That already
2321
+ enforces minor-level lockstep — exact pinning would force
2322
+ consumers to bump both packages for every patch, even when one
2323
+ package's bump is a lockstep no-op.
2324
+
2325
+ [0.11.1]: https://github.com/pjm17971/pond-ts/compare/v0.11.0...v0.11.1
2326
+
2327
+ ## [0.11.0] — 2026-04-27
2328
+
2329
+ The "live partitioning" release. Closes the cross-entity
2330
+ correctness story end-to-end — the per-partition primitives we
2331
+ shipped in v0.9.0 / v0.10.0 for batch now have a live counterpart
2332
+ that handles ingestion, retention, grace, and stateful pipelines
2333
+ on multi-host streams.
2334
+
2335
+ Without this, every multi-host live pipeline (rolling avg, fill,
2336
+ diff, rate, cumulative, pctChange) silently mixes data across
2337
+ entities — the same hazard the partitionBy work resolved for
2338
+ batch, but live-side. Dashboard agent's v0.9.0 round-2 feedback
2339
+ explicitly named "LivePartitionedSeries would be the obvious next
2340
+ step" as the missing piece.
2341
+
2342
+ ### Added
2343
+
2344
+ - **`liveSeries.partitionBy(col, options?)`** — returns
2345
+ `LivePartitionedSeries<S, K>`, the live counterpart to
2346
+ `PartitionedTimeSeries`. Routes events from a source
2347
+ `LiveSource<S>` into per-partition `LiveSeries<S>` sub-buffers,
2348
+ each with its own retention, grace window, and stateful
2349
+ operator pipeline.
2350
+
2351
+ Per-partition semantics (settled in design):
2352
+ - Retention applies per partition (a chatty host can't squeeze
2353
+ a quiet one out of the buffer)
2354
+ - Grace windows apply per partition (late events touch only
2355
+ their own partition)
2356
+ - Aggregation timing is per partition (one host's rolling avg
2357
+ fires when that host has enough data)
2358
+ - Auto-spawn on new partition values; optional `groups` for
2359
+ typed declared partitions (mirrors batch typed-groups)
2360
+
2361
+ Terminals:
2362
+ - `.toMap()` → `Map<K, LiveSource<S>>` for direct per-partition
2363
+ subscription
2364
+ - `.collect()` → unified `LiveSeries<S>` (append-only fan-in)
2365
+ - `.apply(factory)` → unified `LiveSeries<R>` with per-
2366
+ partition operator chains
2367
+ - `.dispose()` cleans up source subscription, all per-partition
2368
+ pipeline subscribers, and `toMap`-created factory chains
2369
+
2370
+ - **Typed chainable sugar** — `partitioned.fill(...).rolling(...).collect()`
2371
+ matches the batch chainable view. Sugar coverage on both
2372
+ `LivePartitionedSeries` and the chained `LivePartitionedView`:
2373
+ `fill`, `diff`, `rate`, `pctChange`, `cumulative`, `rolling`.
2374
+
2375
+ ```ts
2376
+ const cpuSmoothed = live
2377
+ .partitionBy('host')
2378
+ .fill({ cpu: 'hold' })
2379
+ .rolling('1m', { cpu: 'avg', host: 'last' })
2380
+ .collect();
2381
+ ```
2382
+
2383
+ `LivePartitionedView<SBase, R, K>` is a lazy chain step holding
2384
+ a composed factory; terminals delegate to the root partitioned
2385
+ series. Auto-spawn flows through the chain — a new partition
2386
+ triggers a fresh factory invocation.
2387
+
2388
+ - **`LivePartitionedView`** exported from package root.
2389
+
2390
+ - **`ARCHITECTURE.md`** at repo root — first-pass document for
2391
+ contributors (human or AI) reading the codebase cold. Covers
2392
+ layered model, stateful primitives, recurring patterns
2393
+ (typed-groups, trusted construction via `static #foo`,
2394
+ factory-based per-partition state, append-only fan-in vs
2395
+ mirrored materialization, per-method JSDoc warnings, perf-
2396
+ check discipline), decision log, and conventions.
2397
+
2398
+ ### Changed
2399
+
2400
+ - **CLAUDE.md** points to `ARCHITECTURE.md` so future sessions
2401
+ discover it alongside `PLAN.md`.
2402
+
2403
+ ### Notes
2404
+
2405
+ - **Append-only fan-in semantics** for `collect()` and `apply()`
2406
+ on `LivePartitionedSeries` — per-partition retention/grace
2407
+ evictions do NOT propagate to the unified buffer. Documented
2408
+ via JSDoc; the unified buffer's own retention is independent.
2409
+ Use `toMap()` for current per-partition state.
2410
+
2411
+ - **Post-commit error semantics for partition rejection** — when
2412
+ the partition view throws inside the source's event listener
2413
+ (rogue value, partition ordering rejection), the source has
2414
+ already committed the event. Documented in
2415
+ `LiveSeries.partitionBy` JSDoc; recommend upstream input
2416
+ validation if source/partition atomicity matters.
2417
+
2418
+ - **Rolling drops partition column unless explicitly added.**
2419
+ `LiveSeries.rolling` (and the partitioned chain via it) only
2420
+ retains columns named in `mapping` — include `host: 'last'` (or
2421
+ similar) to keep the partition tag visible in the unified
2422
+ output. Documented in `rolling`'s JSDoc on both the
2423
+ `LivePartitionedSeries` and `LivePartitionedView` surfaces.
2424
+
2425
+ ### Performance
2426
+
2427
+ - Routing overhead measured at ~88ms for 100k events × 10 hosts
2428
+ (50ms over bare push). Apples-to-apples vs equivalent un-
2429
+ partitioned operator chains: ~1.8-2.6× cost. Constant per
2430
+ event (~0.8 µs); cardinality scales flat (Map lookup is O(1)).
2431
+ See `scripts/perf-live-partitioned.mjs`.
2432
+
2433
+ - An `_acceptEvent` private-method optimization to bypass row
2434
+ re-validation in partition routing was scoped and rejected for
2435
+ v0.11 — the benefit (~0.3-0.4 µs/event saved) is marginal for
2436
+ typical telemetry workloads (1-10k events/sec) and the cost
2437
+ (validation-bypass primitive on the public API surface) wasn't
2438
+ justified. May revisit if a high-throughput user surfaces the
2439
+ bottleneck with real workload data.
2440
+
2441
+ [0.11.0]: https://github.com/pjm17971/pond-ts/compare/v0.10.1...v0.11.0
2442
+
2443
+ ## [0.10.1] — 2026-04-27
2444
+
2445
+ Strictly additive over v0.10.0. Closes the export gap surfaced by
2446
+ the Codex CSV-cleaner v0.10 retest:
2447
+
2448
+ > `MaterializeSchema` exists in `dist/types.d.ts` but is not
2449
+ > exported from the package root, so the script had to spell out
2450
+ > the materialized schema locally for strict typing.
2451
+
2452
+ ### Added
2453
+
2454
+ - **`MaterializeSchema<S>`** now exported from the package root.
2455
+ Users typing `materialize` output (or composing it into wrapper
2456
+ utilities) can import the type directly from `pond-ts` instead
2457
+ of digging into the dist-types.
2458
+ - **`DedupeKeep<S>`** also exported (was the same gap — the type
2459
+ for the `dedupe({ keep })` resolver function shape). Closes the
2460
+ same friction for callers writing custom dedupe resolvers in
2461
+ isolation.
2462
+
2463
+ [0.10.1]: https://github.com/pjm17971/pond-ts/compare/v0.10.0...v0.10.1
2464
+
2465
+ ## [0.10.0] — 2026-04-27
2466
+
2467
+ The "round-2 dashboard agent feedback" release. After v0.9.0
2468
+ shipped the cross-entity correctness wave, three independent
2469
+ agents (Codex CSV-cleaner, fresh CSV-cleaner eval, dashboard
2470
+ agent) flagged refinements. v0.10 delivers all three:
2471
+
2472
+ - A grid-completion primitive that doesn't pre-pick a fill method
2473
+ (Codex's "regularize without filling" friction)
2474
+ - A terminal `toMap` that materializes the partition view directly
2475
+ to a Map keyed by partition value (dashboard agent's
2476
+ `.collect().groupBy(col, fn)` chain pain)
2477
+ - Typed partition declaration via `groups` for narrowed Map keys
2478
+ and declared-order iteration (dashboard agent's third
2479
+ refinement; mirrors `pivotByGroup({ groups })`)
2480
+
2481
+ Strictly additive over v0.9.x — no behavior changes for existing
2482
+ callers.
2483
+
2484
+ ### Added
2485
+
2486
+ - **`series.materialize(sequence, options?)`** — emits one
2487
+ time-keyed row per sequence bucket, populating value columns
2488
+ from a chosen source event in the bucket (or `undefined` for
2489
+ empty buckets). Does only the grid step; pairs naturally with
2490
+ `fill()` for explicit fill-policy control:
2491
+
2492
+ ```ts
2493
+ series
2494
+ .partitionBy('host')
2495
+ .materialize(Sequence.every('1m'))
2496
+ .fill({ cpu: 'linear' }, { maxGap: '3m' })
2497
+ .collect();
2498
+ ```
2499
+
2500
+ Three `select` modes: `'first'` / `'last'` (default) /
2501
+ `'nearest'` — all bucket-bounded; empty buckets emit
2502
+ `undefined` regardless. Three `sample` anchors:
2503
+ `'begin'` (default) / `'center'` / `'end'`. Output schema
2504
+ widens value columns to optional (`MaterializeSchema<S>`).
2505
+
2506
+ The `PartitionedTimeSeries.materialize` sugar auto-populates
2507
+ the partition column on every output row, including
2508
+ empty-bucket rows — without this, downstream code would need a
2509
+ `.fill({ host: 'hold' })` step that fails for partitions where
2510
+ every event sits in a long-outage gap.
2511
+
2512
+ Distinct from `align()` (which mandates a `'hold'` or
2513
+ `'linear'` fill method and returns interval-keyed) and
2514
+ `aggregate()` (which applies a per-column reducer). See
2515
+ `cleaning.mdx` for the full operator-comparison table.
2516
+
2517
+ - **`PartitionedTimeSeries.toMap(transform?)`** — terminal that
2518
+ returns `Map<key, TimeSeries<S>>` (or `Map<key, R>` with a
2519
+ transform) directly from the partition view. Replaces the
2520
+ `.collect().groupBy(col, fn)` chain dashboard code was using.
2521
+
2522
+ Three overloads cover the common shapes: bare per-partition
2523
+ `TimeSeries`, transform that returns `TimeSeries<R>`, and
2524
+ transform that returns arbitrary `R`. Map iteration order
2525
+ matches the order each partition was first encountered in the
2526
+ source events (or declared order when `groups` is set).
2527
+
2528
+ Map keys are stringified partition values for single-column
2529
+ partitions (preserving the natural string representation:
2530
+ `'api-1'`, `'eu'`, etc.), or JSON arrays for composite
2531
+ partitions (`'["api-1","eu"]'`). `undefined` partition values
2532
+ use the leading-space sentinel `' undefined'` to avoid
2533
+ collision with the literal string `'undefined'` — distinct
2534
+ from `groupBy`'s bare `'undefined'` key, which silently
2535
+ collapses the two cases. Documented as an intentional
2536
+ improvement; migrators changing from `.get('undefined')` to
2537
+ `.get(' undefined')`.
2538
+
2539
+ **3.3× faster than the `.collect().groupBy(col, fn)` chain it
2540
+ replaces** at 100k events × 10 hosts (33 ms vs 108 ms,
2541
+ measured by `scripts/perf-partitioned-toMap.mjs`).
2542
+
2543
+ - **`series.partitionBy(col, { groups })` typed declaration**
2544
+ — pre-declares the expected partition values, narrowing the
2545
+ partition view's `K` type from `string` to the literal union.
2546
+ Propagates through every sugar method's return type and through
2547
+ `toMap`'s `Map` key:
2548
+
2549
+ ```ts
2550
+ const HOSTS = ['api-1', 'api-2', 'api-3'] as const;
2551
+ const byHost = series
2552
+ .partitionBy('host', { groups: HOSTS })
2553
+ .fill({ cpu: 'linear' })
2554
+ .toMap();
2555
+ // byHost: Map<'api-1' | 'api-2' | 'api-3', TimeSeries<S>>
2556
+ ```
2557
+
2558
+ Mirrors `pivotByGroup({ groups })` — same design vocabulary,
2559
+ same discipline: declared-order iteration, empty declared
2560
+ groups produce empty entries, partition values not in `groups`
2561
+ throw at construction time, empty `groups: []` and duplicate
2562
+ values throw upfront, single-column only (composite + groups
2563
+ throws). Numeric and boolean partition columns are stringified
2564
+ by the encoder, so declared groups must be the stringified
2565
+ form (`groups: ['1', '2']` for a numeric column).
2566
+
2567
+ - **Per-method `**Multi-entity series:**` JSDoc warnings**
2568
+ remain on every stateful operator (shipped in v0.9.0); the
2569
+ v0.10 operators (`materialize`, `toMap`) inherit the same
2570
+ discoverability.
2571
+
2572
+ ### Changed
2573
+
2574
+ - **CLAUDE.md adds a perf-check policy.** New operators that walk
2575
+ events, allocate per-event, or scale with input dimensions
2576
+ must have an analytical complexity statement, a benchmark
2577
+ script (`packages/core/scripts/perf-<operator>.mjs`), and
2578
+ before/after numbers in the commit message. Surfaces in the
2579
+ Layer 1 self-review checklist. Every v0.10 PR followed this:
2580
+ `materialize` got `perf-materialize.mjs` (and two optimization
2581
+ passes that landed –41% on the partitioned variant);
2582
+ `toMap` got `perf-partitioned-toMap.mjs` (3.3× speedup
2583
+ measurement); typed `groups` got `perf-partitionby-groups.mjs`
2584
+ (zero chain-step regression via the class-private trusted
2585
+ factory).
2586
+
2587
+ [0.10.0]: https://github.com/pjm17971/pond-ts/compare/v0.9.1...v0.10.0
2588
+
2589
+ ## [0.9.1] — 2026-04-26
2590
+
2591
+ Strictly additive over v0.9.0. Closes a sugar-method type bug
2592
+ identified independently by two agents (a fresh CSV-cleaner eval
2593
+ against v0.9.0 and Codex on a v0.9.0 retest), plus folds in two
2594
+ fresh-agent doc improvements.
2595
+
2596
+ ### Fixed
2597
+
2598
+ - **`PartitionedTimeSeries.fill` now accepts `maxGap`.** PR #78
2599
+ added `maxGap` to `TimeSeries.fill` for v0.9.0 but the partitioned
2600
+ sugar's option type was not widened, so the headline v0.9.0 chain —
2601
+ `partitionBy('host').fill('linear', { maxGap: '5m' })` — failed
2602
+ type checking and forced callers into `.apply()`. The underlying
2603
+ impl already passed options through, so this is a one-line type
2604
+ widening: `{ limit?: number; maxGap?: DurationInput }`.
2605
+
2606
+ ### Added
2607
+
2608
+ - **9 new tests** under `TimeSeries.partitionBy.test.ts`:
2609
+ - 4 regression tests pinning the partitioned `fill(maxGap)` chain
2610
+ works (bare `maxGap`, all-or-nothing per-partition span,
2611
+ `limit + maxGap` composition, full `partitionBy + dedupe +
2612
+ fill(maxGap)` chain).
2613
+ - 5 composite-key round-trip tests addressing a refinement flagged
2614
+ by the dashboard agent: `partitionBy(['host', 'region'])`
2615
+ preserves both key columns in the schema, on every output event,
2616
+ keeps `(host, region)` tuples distinct (no collapse on host
2617
+ alone), and round-trips through `apply()` and the full chain.
2618
+ - **`cleaning.mdx` "Schema first — `required: false`" section.**
2619
+ Leads the page; documents why optional cells need the flag and
2620
+ surfaces the `fromJSON`/`null` workaround for the known
2621
+ `RowForSchema` variance limitation. Previously this prose only
2622
+ lived in the 0.8.2 changelog (fresh-agent feedback).
2623
+ - **`cleaning.mdx` "End-to-end multi-entity cleaning pipeline"
2624
+ section.** The unified `partitionBy + dedupe + fill(maxGap)`
2625
+ chain in one place plus a step-by-step hazard table.
2626
+ Previously split across three sections (fresh-agent feedback).
2627
+
2628
+ [0.9.1]: https://github.com/pjm17971/pond-ts/compare/v0.9.0...v0.9.1
2629
+
2630
+ ## [0.9.0] — 2026-04-26
2631
+
2632
+ The "cross-entity correctness + cleaning hygiene" release. Three
2633
+ independent CSV-cleaner agent runs (Codex, Claude, Gemini) all hit
2634
+ the same shape: stateful transforms (`fill('linear')`, `rolling`,
2635
+ `diff`, etc.) silently mix data across entities on multi-host
2636
+ series, and `fill('linear', { limit: 3 })` fabricates interpolated
2637
+ data across long outages instead of leaving the unknown unknown.
2638
+
2639
+ v0.9.0 ships three operator-level fixes plus a discoverability pass
2640
+ on every affected method's JSDoc.
2641
+
2642
+ ### Added
2643
+
2644
+ - **`series.partitionBy(col).<op>(...).collect()`** — chainable
2645
+ per-partition view over `TimeSeries`. Sugar methods for every
2646
+ stateful operator (`fill`, `align`, `rolling`, `smooth`,
2647
+ `baseline`, `outliers`, `diff`, `rate`, `pctChange`, `cumulative`,
2648
+ `shift`, `aggregate`, `dedupe`) run the underlying transform per
2649
+ partition. `.collect()` materializes back to `TimeSeries<S>`.
2650
+ `.apply(g => /* arbitrary chain */)` is the terminal escape hatch.
2651
+ One primitive covers the cross-entity hazard for every at-risk
2652
+ method, instead of adding a `partitionBy` option to each.
2653
+ - **`series.dedupe({ keep })`** — first-class deduplication with
2654
+ policies: `'first' | 'last' | 'error' | 'drop' | { min: col } |
2655
+ { max: col } | (events) => Event`. Default key is the full event
2656
+ key (`begin` for time-keyed, `begin+end` for time-range,
2657
+ `begin+end+value` for interval-keyed); default resolution is
2658
+ `'last'`. `partitionBy('host').dedupe()` is the multi-entity
2659
+ pattern.
2660
+ - **`fill(strategy, { maxGap })`** — duration-based gap cap,
2661
+ complements the existing count-based `limit`. Both compose; most
2662
+ restrictive wins.
2663
+
2664
+ ### Changed
2665
+
2666
+ - **`fill` is now all-or-nothing.** A gap either fits both caps and
2667
+ is filled entirely, or exceeds either cap and is left fully
2668
+ unfilled. Previously `limit: 3` on a 5-cell gap filled 3 cells and
2669
+ left 2 unfilled — propagating stale `'hold'` values past their
2670
+ useful lifetime and inventing misleading `'linear'` slopes across
2671
+ long outages. Existing `limit` callers see strictly more
2672
+ conservative behavior; to opt back in to partial fill, set
2673
+ `limit`/`maxGap` larger than any gap you want filled.
2674
+ - **Every stateful TimeSeries method's JSDoc** now includes a
2675
+ `**Multi-entity series:**` warning paragraph naming the operator's
2676
+ specific cross-entity hazard and pointing at the
2677
+ `partitionBy(col).<method>(...).collect()` pattern. Discoverable
2678
+ in LSP hover, IDE quick-help, and any tool that reads type
2679
+ definitions.
2680
+ - **`PartitionedTimeSeries` view** preserves partition state across
2681
+ every sugar call, so multi-step per-partition chains compose
2682
+ cleanly without re-partitioning at each step.
2683
+
2684
+ ### Fixed
2685
+
2686
+ - Pre-existing brand-check bug on `series.filter(...).diff(...)`
2687
+ and similar chains: events constructed via
2688
+ `#fromTrustedEvents` (which uses `Object.create` to bypass the
2689
+ constructor) hit a JS-private brand check on `#diffOrRate` and
2690
+ threw. Refactored to a class-static private (`static
2691
+ #diffOrRate`) — runtime-private without the per-instance brand
2692
+ failure.
2693
+
2694
+ [0.9.0]: https://github.com/pjm17971/pond-ts/compare/v0.8.2...v0.9.0
2695
+
2696
+ ## [0.8.2] — 2026-04-26
2697
+
2698
+ Strictly additive over v0.8.1. Closes friction surfaced by two
2699
+ independent agent runs against a realistic CSV-cleaning task —
2700
+ specifically, the missing fan-in primitive that forces callers out
2701
+ of the typed contract when reassembling per-host transformed
2702
+ subseries.
2703
+
2704
+ ### Added
2705
+
2706
+ - **`TimeSeries.concat([s1, s2, ...])`** — concatenates the events of
2707
+ N same-schema `TimeSeries` instances, re-sorted by key. The
2708
+ row-append / vertical-stack counterpart to `joinMany` (which
2709
+ column-merges by key). Matches `Array.prototype.concat` /
2710
+ `pandas.concat(axis=0)` / SQL `UNION ALL` semantics. Closes the
2711
+ round-trip after `groupBy(col, fn)` + per-group transforms without
2712
+ forcing callers to unwrap events back to row tuples.
2713
+
2714
+ ```ts
2715
+ const filledByHost = series.groupBy('host', (g) =>
2716
+ g.fill({ cpu: 'linear' }, { limit: 2 }),
2717
+ );
2718
+ const combined = TimeSeries.concat([...filledByHost.values()]);
2719
+ // back to one TimeSeries<S>; events from all hosts re-sorted.
2720
+ ```
2721
+
2722
+ Schemas must match column-by-column on `name` and `kind`; throws
2723
+ upfront on mismatch. Same-key events from different inputs are
2724
+ both kept (row-append, not key-dedupe).
2725
+
2726
+ Coming from pondjs: `timeSeriesListMerge`'s concatenation case
2727
+ maps to `TimeSeries.concat([...])`; its column-union case maps to
2728
+ `TimeSeries.joinMany([...])`.
2729
+
2730
+ - **`TimeSeries.fromEvents(events, { schema, name })`** — builds a
2731
+ typed series from a flat `Event[]` array. Sorts by key. Companion
2732
+ to `merge` for the case where you have raw events rather than a
2733
+ list of series.
2734
+
2735
+ - **`TimeRange.toJSON()`** returns `{ start: number, end: number }`,
2736
+ the same shape `JsonTimeRangeInput` accepts, so
2737
+ `new TimeRange(range.toJSON())` round-trips. Implicitly invoked by
2738
+ `JSON.stringify(range)`.
2739
+
2740
+ - **`TimeRange.toString()`** returns ISO-8601 `start/end` format
2741
+ (e.g. `2025-01-15T09:00:00.000Z/2025-01-15T10:00:00.000Z`) for
2742
+ debug logs and human-readable display.
2743
+
2744
+ ### Known limitation
2745
+
2746
+ Two type-level fixes flagged by the agents are tracked but deferred
2747
+ to a future variance refactor:
2748
+
2749
+ - `toJSON()` returns `TimeSeriesJsonInput<SeriesSchema>` (loose),
2750
+ not `TimeSeriesJsonInput<S>`. Cast the result at the call site
2751
+ if you need the narrow schema preserved.
2752
+ - `RowForSchema` doesn't honor `required: false`. Use `fromJSON`
2753
+ with `null` cells instead of the row-array constructor with
2754
+ `undefined`.
2755
+
2756
+ Both are real but blocked by class-wide invariance through method
2757
+ overloads. See PLAN.md "Known type-level limitation" for the full
2758
+ story.
2759
+
2760
+ ## [0.8.1] — 2026-04-26
2761
+
2762
+ Strictly additive over v0.8.0 — typed overload narrows result types when
2763
+ opted in via `groups`; untyped form is unchanged. Plus a docs reorg.
2764
+
2765
+ ### Added
2766
+
2767
+ - **`pivotByGroup` typed overload** — pass `{ groups: [...] as const }`
2768
+ and the output schema becomes literal-typed, so downstream
2769
+ `baseline` / `rolling` / `toPoints` calls narrow without `as never`
2770
+ casts. Eliminates the dashboard friction reported on v0.8.0.
2771
+
2772
+ ```ts
2773
+ const HOSTS = ['api-1', 'api-2'] as const;
2774
+ const wide = long.pivotByGroup('host', 'cpu', { groups: HOSTS });
2775
+ // wide.schema is now literal-typed:
2776
+ // [time, { name: 'api-1_cpu', kind: 'number', required: false },
2777
+ // { name: 'api-2_cpu', kind: 'number', required: false }]
2778
+ wide.baseline('api-1_cpu', { window: '1m', sigma: 2 }); // no cast
2779
+ ```
2780
+
2781
+ Behavior in the typed path: declaration order (not alphabetical),
2782
+ declared-but-empty groups still emit columns, runtime values not
2783
+ in the declared set throw upfront. Untyped form (no `groups`)
2784
+ keeps existing alphabetical / dynamic-discovery / loose-output
2785
+ behavior.
2786
+
2787
+ ### Changed
2788
+
2789
+ - **Docs site reorganized.** `Transforms` → **TimeSeries**;
2790
+ `Live` → **LiveSeries**; new **Advanced** section for charting and
2791
+ array columns. Concepts moves to `Start here`. New **Reshaping**
2792
+ page splits `pivotByGroup` / `groupBy` / `join` / `joinMany` from
2793
+ Aggregation, plus a new **Queries** page covering `at` / `first` /
2794
+ `timeRange` / `includesKey` / `intersection` / iterators / output
2795
+ forms — everything that interrogates a series rather than
2796
+ transforming it. JSON ingest renamed to **Ingest** and slotted as
2797
+ the first page under TimeSeries.
2798
+
2799
+ ## [0.8.0] — 2026-04-25
2800
+
2801
+ ### Added
2802
+
2803
+ - **`TimeSeries.pivotByGroup(groupCol, valueCol, options?)`** — long-to-wide
2804
+ reshape on a categorical column. Each distinct value of `groupCol` becomes
2805
+ its own column in the output schema named `${group}_${value}`, holding the
2806
+ value column at that timestamp. Rows sharing a timestamp collapse into one
2807
+ output row; missing `(timestamp, group)` cells are `undefined`.
2808
+
2809
+ ```ts
2810
+ // Long: { ts, cpu, host } per row
2811
+ // Wide: { ts, "api-1_cpu", "api-2_cpu", ... } per row
2812
+ long.pivotByGroup('host', 'cpu').toPoints();
2813
+ // Drops straight into <Line dataKey="api-1_cpu" /> etc.
2814
+ ```
2815
+
2816
+ Duplicate `(timestamp, group)` pairs throw by default; opt-in
2817
+ `{ aggregate: 'avg' | 'sum' | 'first' | 'last' | 'min' | 'max' | 'median' | 'p95' | ... }`
2818
+ to combine. The aggregator's output kind must match the value column's
2819
+ kind — `count`, `unique`, `topN` and other kind-changing reducers are
2820
+ rejected upfront with a clear error. Output schema is dynamic so the
2821
+ return type is `TimeSeries<SeriesSchema>` (loosely typed). Time-keyed
2822
+ input required.
2823
+
2824
+ Use `pivotByGroup` for the per-group dashboard case ("one source, many
2825
+ producers, one chart line per producer"). Use `groupBy + joinMany` when
2826
+ each group spawns multiple derived columns (e.g. per-host baseline →
2827
+ cpu/avg/upper/lower per host). At 200k events × 100 groups, runs in
2828
+ ~43 ms — at parity with hand-rolled JS that skips `TimeSeries`
2829
+ construction entirely.
2830
+
2831
+ ### Changed
2832
+
2833
+ - Charting docs lead with `series.join(other, ...).toPoints()` for
2834
+ cross-source overlays. The manual `mergeWideRows` recipe is demoted to
2835
+ "non-`TimeSeries` inputs". A new "Per-group wide rows" section covers
2836
+ `pivotByGroup` end-to-end with Recharts.
2837
+
2838
+ ### Notes
2839
+
2840
+ - **Live counterpart deferred.** No `LiveSeries.pivotByGroup` /
2841
+ `LiveSeries.merge` / `LiveSeries.join` yet — see PLAN.md "Known scope
2842
+ gap: live merge / join". Snapshot-then-batch is the workaround:
2843
+ `useSnapshot` per source + `useMemo` running a batch `pivotByGroup` or
2844
+ `join`.
2845
+
2846
+ ## [0.7.0] — 2026-04-25
2847
+
2848
+ ### Changed (breaking)
2849
+
2850
+ - **`TimeSeries.toPoints()` returns wide rows** instead of single-column
2851
+ `{ ts, value }[]`. Every event becomes one row with `ts` plus every
2852
+ value column from the schema as a top-level key:
2853
+
2854
+ ```ts
2855
+ // Before: // After:
2856
+ series.toPoints('cpu');
2857
+ series.toPoints();
2858
+ // [{ ts, value }, ...] // [{ ts, cpu, host, ... }, ...]
2859
+ ```
2860
+
2861
+ This aligns pond-ts's multi-column nature with what every chart
2862
+ library actually wants (Recharts, Observable Plot, visx all consume
2863
+ wide rows directly). Band charts, multi-series overlays, and
2864
+ `<Area>` ranged-`dataKey` patterns become a single `toPoints()`
2865
+ call instead of a manual merge.
2866
+
2867
+ **Migration:** for the common single-column case, compose with
2868
+ `select`:
2869
+
2870
+ ```ts
2871
+ const cpuPoints = series.select('cpu').toPoints();
2872
+ // [{ ts, cpu }, ...]
2873
+ ```
2874
+
2875
+ Then read the column by name (`row.cpu`) instead of the old
2876
+ `.value`. Wide form keeps every event — the old narrow form
2877
+ dropped events whose column was `undefined`; the new form preserves
2878
+ them so chart libraries can render gaps via `connectNulls={false}`.
2879
+
2880
+ **Watch out for `value`-named columns.** If your schema has a value
2881
+ column literally named `value`, the new wide rows will have a
2882
+ `value` key that looks identical to the old narrow shape — but it's
2883
+ the column-named-`value`, not the narrow-form `value`. Audit any
2884
+ `row.value` reads after upgrading; the safe migration is
2885
+ `row.<schema-column-name>`.
2886
+
2887
+ - **`TimeSeries.fromPoints()` accepts wide-row points** with a schema
2888
+ of any number of value columns. Schema's first column must still be
2889
+ `kind: 'time'`.
2890
+
2891
+ ```ts
2892
+ TimeSeries.fromPoints(
2893
+ [{ ts: 0, cpu: 0.3, host: 'api-1' }, ...],
2894
+ {
2895
+ schema: [
2896
+ { name: 'time', kind: 'time' },
2897
+ { name: 'cpu', kind: 'number' },
2898
+ { name: 'host', kind: 'string' },
2899
+ ] as const,
2900
+ },
2901
+ );
2902
+ ```
2903
+
2904
+ Previously restricted to exactly two columns with `{ ts, value }`
2905
+ rows; that form is gone.
2906
+
2907
+ ## [0.6.0] — 2026-04-25
2908
+
2909
+ ### Added
2910
+
2911
+ - **`'end'` sample option** for `align()` and `Sequence.bounded()`. Joins
2912
+ `'begin'` and `'center'` as a third anchor inside each grid step.
2913
+ Useful for end-of-period readings (close-of-day, last value before
2914
+ bucket close). Inclusion semantics are left-exclusive
2915
+ (`sample ∈ (range.begin, range.end]`) so an end-sample at exactly
2916
+ `range.begin()` doesn't pull in an interval that sits entirely
2917
+ before the range.
2918
+
2919
+ ### Type-surface change
2920
+
2921
+ - `AlignSample` and `SequenceSample` literal unions widen from
2922
+ `'begin' | 'center'` to `'begin' | 'center' | 'end'`. Pattern-matching
2923
+ consumers that exhaustively `switch` on the old two-value union
2924
+ silently miss the new arm — minor bump rather than a patch per this
2925
+ project's "patch bumps are strictly additive" rule. Update any
2926
+ `switch (sample)` blocks to handle `'end'` (or add a `default`).
2927
+
2928
+ ## [0.5.11] — 2026-04-24
2929
+
2930
+ ### Fixed
2931
+
2932
+ - **`LiveSeries` rejects `graceWindow > retention.maxAge` at construction.**
2933
+ A late event accepted within grace but older than `maxAge` would be evicted
2934
+ immediately by retention — the grace contract would be meaningless. The
2935
+ guard only fires when both options are set explicitly; default behavior is
2936
+ unchanged. `LiveAggregation` bucket closure (which inherits grace from the
2937
+ source) still behaves as before.
2938
+
2939
+ ### Changed
2940
+
2941
+ - Docs: clarified `graceWindow`'s scope in the `LiveSeriesOptions`
2942
+ docstring. Enforced at ingest and honored by `LiveAggregation` bucket
2943
+ closure; `rolling()` / `window()` live views do not re-flow late events
2944
+ through historical windows. Matches the actual pipeline behavior; full
2945
+ late-event propagation through live transforms is explicitly out of
2946
+ scope (see Akidau's Streaming 102 for the larger story).
2947
+
2948
+ ## [0.5.10] — 2026-04-24
2949
+
2950
+ ### Fixed
2951
+
2952
+ - **`baseline()` emits `undefined` for `upper` / `lower` when the rolling
2953
+ window is flat (`sd === 0`)** — matching `outliers()`'s behavior. Before,
2954
+ a zero-width band would cause a naive `value > upper || value < lower`
2955
+ filter to flag every non-equal point as anomalous inside a constant run.
2956
+ The `avg` and `sd` columns still report their true values; only the band
2957
+ edges collapse to `undefined`.
2958
+
2959
+ ### Changed
2960
+
2961
+ - Internal: consolidated a duplicate `OptionalNumberCol` type alias into
2962
+ the pre-existing `OptionalNumberColumn`. No surface change.
2963
+ - Docs: walked back an over-claim in `outliers()`'s docstring. It was
2964
+ documented as "sugar over `baseline().filter()`" but is implemented
2965
+ independently. Now says the two are conceptually equivalent.
2966
+
2967
+ ## [0.5.9] — 2026-04-23
2968
+
2969
+ ### Added
2970
+
2971
+ - **`TimeSeries.baseline(col, opts)`** — rolling-stats primitive. Runs one
2972
+ rolling pass and appends four optional number columns (`avg`, `sd`,
2973
+ `upper = avg + σ·sd`, `lower = avg - σ·sd`) to the source schema. Band
2974
+ charts read `toPoints('upper')` / `toPoints('lower')` directly; outlier
2975
+ filters compare against `upper` / `lower`. Replaces the band-plus-outliers
2976
+ two-pass pattern with one call. Custom column names via `{ names }` if the
2977
+ defaults collide.
2978
+
2979
+ [0.8.2]: https://github.com/pjm17971/pond-ts/compare/v0.8.1...v0.8.2
2980
+ [0.8.1]: https://github.com/pjm17971/pond-ts/compare/v0.8.0...v0.8.1
2981
+ [0.8.0]: https://github.com/pjm17971/pond-ts/compare/v0.7.0...v0.8.0
2982
+ [0.7.0]: https://github.com/pjm17971/pond-ts/compare/v0.6.0...v0.7.0
2983
+ [0.6.0]: https://github.com/pjm17971/pond-ts/compare/v0.5.11...v0.6.0
2984
+ [0.5.11]: https://github.com/pjm17971/pond-ts/compare/v0.5.10...v0.5.11
2985
+ [0.5.10]: https://github.com/pjm17971/pond-ts/compare/v0.5.9...v0.5.10
2986
+ [0.5.9]: https://github.com/pjm17971/pond-ts/compare/v0.5.8...v0.5.9
2987
+
2988
+ ## [0.5.8] — 2026-04-23
2989
+
2990
+ ### Added
2991
+
2992
+ - **`TimeSeries.outliers(col, { window, sigma, alignment? })`** —
2993
+ rolling-baseline outlier detection. Returns `TimeSeries<S>` filtered to
2994
+ events whose value deviates from the trailing rolling average by more than
2995
+ `sigma · rolling_stdev`. Composes directly with aggregate, groupBy, etc.
2996
+ - **`TimeSeries.prototype.toPoints(col)`** — flat `{ ts, value }[]` export
2997
+ matching conventional chart-library shape (Recharts, Observable Plot, d3).
2998
+ Filters `undefined` values; returns a frozen array.
2999
+ - **`TimeSeries.fromPoints(points, { schema, name? })`** — inverse
3000
+ constructor for round-tripping chart-style points back into pond-native
3001
+ operations. Schema must have exactly two columns.
3002
+
3003
+ [0.5.8]: https://github.com/pjm17971/pond-ts/compare/v0.5.7...v0.5.8
3004
+
3005
+ ## [0.5.7] — 2026-04-23
3006
+
3007
+ ### Added
3008
+
3009
+ - **`smooth('ema', { warmup: N })`** — drops the first `N` output rows so
3010
+ callers don't have to write `.slice(N)` after every EMA call. The smoother
3011
+ still processes those events, so kept rows are computed against a warm EMA.
3012
+ `warmup: 0` is a no-op; warmup ≥ series length returns an empty series.
3013
+
3014
+ [0.5.7]: https://github.com/pjm17971/pond-ts/compare/v0.5.6...v0.5.7
3015
+
3016
+ ## [0.5.6] — 2026-04-23
3017
+
3018
+ ### Added
3019
+
3020
+ - **`useCurrent` reference stability** — the returned record and each of its
3021
+ fields are reference-stable across renders when structurally unchanged. A
3022
+ no-op push (same aggregate values) hands back the previous references;
3023
+ downstream `useMemo([current.host], ...)` only re-runs when that specific
3024
+ field changes. Scalar fields compare via `===`; array fields compare length
3025
+ then elementwise.
3026
+
3027
+ [0.5.6]: https://github.com/pjm17971/pond-ts/compare/v0.5.5...v0.5.6
3028
+
3029
+ ## [0.5.5] — 2026-04-23
3030
+
3031
+ ### Added
3032
+
3033
+ - **Narrow return types for `rolling` + `aggregate` output-map overloads.**
3034
+ `rolling(w, { avg: { from: 'cpu', using: 'avg' }, ... })` now returns
3035
+ `TimeSeries<RollingOutputMapSchema<S, M>>` — `e.get('avg')` narrows to
3036
+ `number | undefined` instead of `ColumnValue | undefined`, and `e.key()`
3037
+ preserves the source's first-column kind. Same fix on `aggregate`'s
3038
+ output-map overload.
3039
+
3040
+ ### Fixed
3041
+
3042
+ - `min` / `max` were missing from the numeric-reducer list in `ReduceResult`
3043
+ (v0.5.2 regression). Both reducers have `outputKind: 'number'` at runtime;
3044
+ the type now agrees. `reduce({ cpu: 'max' })` narrows to `number | undefined`.
3045
+
3046
+ [0.5.5]: https://github.com/pjm17971/pond-ts/compare/v0.5.4...v0.5.5
3047
+
3048
+ ## [0.5.4] — 2026-04-23
3049
+
3050
+ ### Added
3051
+
3052
+ - **`rolling` accepts `AggregateOutputMap`** — feature parity with
3053
+ `aggregate`. Multi-reducer-per-column now works in one pass:
3054
+ ```ts
3055
+ series.rolling('1m', {
3056
+ avg: { from: 'cpu', using: 'avg' },
3057
+ sd: { from: 'cpu', using: 'stdev' },
3058
+ });
3059
+ ```
3060
+ Two new overloads on both window-only and sequence-driven forms.
3061
+
3062
+ ### Changed
3063
+
3064
+ - `rolling`'s internal column walker now routes through the shared
3065
+ `normalizeAggregateColumns` helper. Schema-column order is preserved for
3066
+ `AggregateMap` inputs so the runtime layout continues to match
3067
+ `RollingSchema<S, M>`.
3068
+
3069
+ [0.5.4]: https://github.com/pjm17971/pond-ts/compare/v0.5.3...v0.5.4
3070
+
3071
+ ## [0.5.3] — 2026-04-23
3072
+
3073
+ ### Added
3074
+
3075
+ - **Source-kind narrowing on array-output reducers in `ReduceResult`.**
3076
+ `unique` and `` `top${number}` `` now narrow their output to
3077
+ `ReadonlyArray<T>` where `T` is the source column's element type:
3078
+ ```ts
3079
+ series.reduce({ host: 'unique' }).host;
3080
+ // ^ ReadonlyArray<string> | undefined (was ReadonlyArray<ScalarValue>)
3081
+ ```
3082
+ Array-kind source columns fall back to the wide `ReadonlyArray<ScalarValue>`
3083
+ union since element kind isn't schema-visible.
3084
+
3085
+ [0.5.3]: https://github.com/pjm17971/pond-ts/compare/v0.5.2...v0.5.3
3086
+
3087
+ ## [0.5.2] — 2026-04-23
3088
+
3089
+ ### Added
3090
+
3091
+ - **`TimeSeries.reduce` per-entry type narrowing.** Numeric reducers
3092
+ (`sum`/`avg`/`count`/`median`/`stdev`/`difference`/`pNN`) narrow to
3093
+ `number | undefined`; `unique`/`top${N}` narrow to `ReadonlyArray<…> |
3094
+ undefined`; `first`/`last`/`keep` preserve the source column kind. Custom
3095
+ reducer functions and `AggregateOutputSpec` entries keep the wide
3096
+ `ColumnValue | undefined` fallback. Narrowing lives in the new
3097
+ `types-reduce.ts` — same file-split pattern used later for the output-map
3098
+ narrowing.
3099
+
3100
+ ### Changed
3101
+
3102
+ - `useCurrent` now aliases `ReduceResult<S, Mapping>` directly; the hook's
3103
+ duplicated narrowing logic is gone.
3104
+
3105
+ [0.5.2]: https://github.com/pjm17971/pond-ts/compare/v0.5.1...v0.5.2
3106
+
3107
+ ## [0.5.1] — 2026-04-23
3108
+
3109
+ ### Added
3110
+
3111
+ - **`TimeSeries.tail(duration?)`** — trailing temporal slice, the
3112
+ counterpart to `Array.slice(-n)`. Called with no argument, returns the
3113
+ whole series. Composes with every other `TimeSeries` method.
3114
+ - **`useCurrent` hook (`@pond-ts/react`)** — subscribes to a live source and
3115
+ returns the current value of a reducer mapping. Signature:
3116
+ `useCurrent(source, mapping, { tail?, throttle? })`. Stable-shape record
3117
+ even while the source is empty, so destructuring on first render is safe.
3118
+
3119
+ [0.5.1]: https://github.com/pjm17971/pond-ts/compare/v0.5.0...v0.5.1
3120
+
3121
+ ## [0.5.0] — 2026-04-23
3122
+
3123
+ ### Added
3124
+
3125
+ - **First-class `'array'` column kind.** New `ArrayValue = ReadonlyArray<ScalarValue>`
3126
+ and `ColumnValue = ScalarValue | ArrayValue` types. Array columns are inert
3127
+ with respect to numerical operators (`diff`, `rate`, `cumulative`,
3128
+ `rolling`-over-numbers skip them automatically via `NumericColumnNameForSchema`).
3129
+ - **`unique` reducer** — distinct sorted values; works in `reduce`,
3130
+ `aggregate`, and `rolling`. Flattens array-kind sources one level (set union
3131
+ across arrays in a bucket).
3132
+ - **`top(n)` reducer** — top N values by frequency with deterministic
3133
+ tie-break. String-pattern dispatch (`'top3'`, `'top10'`) parallel to `pNN`,
3134
+ plus a `top(n)` helper that returns the typed string literal. Incremental
3135
+ bucket + rolling state via a count map. Also flattens array-kind sources.
3136
+ - **Five array-prefixed operators on `TimeSeries`**:
3137
+ - `arrayContains(col, value)` — has this one
3138
+ - `arrayContainsAll(col, values)` — has every one (AND / subset)
3139
+ - `arrayContainsAny(col, values)` — has at least one (OR / intersection)
3140
+ - `arrayAggregate(col, reducer, { as?, kind? })` — per-event reduction
3141
+ reusing the full reducer registry (count, sum, avg, unique, custom, etc.).
3142
+ Replace in place or append via `as`.
3143
+ - `arrayExplode(col, { as?, kind? })` — fan each event out into one event
3144
+ per array element. Replace the array column or keep it alongside a scalar
3145
+ sibling.
3146
+ - **LiveSeries accepts `kind: 'array'`** on its schema with array cells
3147
+ frozen on push.
3148
+ - **JSON round-trip** for array cells works unchanged (toJSON / fromJSON
3149
+ pass arrays through naturally).
3150
+ - **Docs**: new `guides/arrays.mdx` reference page;
3151
+ `examples/error-rate-dashboard.mdx` scenario walkthrough backed 1:1 by an
3152
+ E2E test; `reducer-reference.mdx` expanded with concrete input/output
3153
+ examples for `unique` and `top(n)`.
3154
+
3155
+ ### Changed
3156
+
3157
+ - **`reduce()` / `ReduceResult` / `CustomAggregateReducer` return types** widened
3158
+ from `ScalarValue | undefined` to `ColumnValue | undefined`. Narrowed
3159
+ annotations (`: number | undefined`) keep working; only callers with
3160
+ explicit `: ScalarValue | undefined` annotations need to widen.
3161
+ (v0.5.2 narrows these further per-entry.)
3162
+
3163
+ [0.5.0]: https://github.com/pjm17971/pond-ts/compare/v0.4.3...v0.5.0
3164
+
3165
+ ## [0.4.3] — 2026-04-22
3166
+
3167
+ ### Added
3168
+
3169
+ - `useLiveQuery` and `useLatest` hooks in `@pond-ts/react`.
3170
+
3171
+ ### Fixed
3172
+
3173
+ - LiveView eviction mirroring (uses `EMITS_EVICT` symbol to safely detect
3174
+ evict-capable sources; avoids duck-typing that broke on `LiveAggregation`).
3175
+ - Type narrowing through `LiveAggregation` / `LiveRollingAggregation` via
3176
+ `Out` type parameter.
3177
+ - `Time.toDate()` convenience method.
3178
+ - `useWindow` under React StrictMode (view creation moved to `useEffect`).
3179
+ - `TimeSeries[Symbol.iterator]` and `toArray()` for ergonomic iteration.
3180
+ - `useSnapshot` accepts `SnapshotSource<S>` structural type (no casts for
3181
+ `LiveAggregation` input).
3182
+
3183
+ [0.4.3]: https://github.com/pjm17971/pond-ts/compare/v0.4.2...v0.4.3
3184
+
3185
+ ## [0.4.2] — 2026-04-21
3186
+
3187
+ ### Changed
3188
+
3189
+ - First release using npm OIDC Trusted Publisher (no stored tokens).
3190
+
3191
+ [0.4.2]: https://github.com/pjm17971/pond-ts/compare/v0.4.1...v0.4.2
3192
+
3193
+ ## [0.4.1] — 2026-04-21
3194
+
3195
+ Administrative — no behavioral changes.
3196
+
3197
+ [0.4.1]: https://github.com/pjm17971/pond-ts/compare/v0.4.0...v0.4.1
3198
+
3199
+ ## [0.4.0] — 2026-04-21
3200
+
3201
+ ### Added
3202
+
3203
+ - **`@pond-ts/react` package** — React hooks for live series
3204
+ (`useLiveSeries`, `useTimeSeries`, `useSnapshot`, `useWindow`, `useDerived`,
3205
+ `takeSnapshot`). Monorepo restructure completed.
3206
+ - **LiveView + LiveSource composition** — `filter`, `map`, `select`,
3207
+ `window` views that compose with `LiveAggregation` / `LiveRollingAggregation`
3208
+ via a shared `LiveSource<S>` interface.
3209
+ - **Live per-event and carry-forward transforms** — `diff`, `rate`,
3210
+ `pctChange`, `fill`, `cumulative` available as LiveView variants.
3211
+ - **Grace period on `LiveAggregation`** — delays bucket closing so
3212
+ out-of-order events within a window accumulate into their correct bucket.
3213
+ Defaults from source `LiveSeries`'s `graceWindow`.
3214
+ - **Streaming dashboard example** with E2E tests.
3215
+ - **Benchmark suite** comparing `pond-ts` vs `pondjs`.
3216
+
3217
+ [0.4.0]: https://github.com/pjm17971/pond-ts/compare/v0.3.0...v0.4.0
3218
+
3219
+ ## [0.3.0] — 2026-04-21
3220
+
3221
+ ### Added
3222
+
3223
+ - **`LiveSeries`** — mutable, append-optimized streaming buffer sharing the
3224
+ same schema type as `TimeSeries`. Retention policies (`maxEvents`,
3225
+ `maxAge`, `maxBytes`). Synchronous subscriptions (`event`, `batch`,
3226
+ `evict`). Ordering modes (`strict`, `drop`, `reorder`).
3227
+ - **`LiveAggregation`** — incremental bucketed aggregation over a
3228
+ `LiveSource`.
3229
+ - **`LiveRollingAggregation`** — sliding-window reduction over a
3230
+ `LiveSource`.
3231
+
3232
+ [0.3.0]: https://github.com/pjm17971/pond-ts/compare/v0.2.0...v0.3.0
3233
+
3234
+ ## [0.2.0] — 2026-04-16
3235
+
3236
+ ### Added
3237
+
3238
+ - **Phase 2 batch expansion**: `reduce`, `groupBy`, `diff`, `rate`, `fill`.
3239
+ - **Phase 2.5 columnar primitives**: `pctChange`, `cumulative`, `shift`,
3240
+ `bfill` fill strategy.
3241
+ - **Aggregator parity with pondjs**: `median`, `stdev`, `percentile`
3242
+ (`pNN`), `difference`, `keep`.
3243
+
3244
+ [0.2.0]: https://github.com/pjm17971/pond-ts/compare/v0.1.4...v0.2.0
3245
+
3246
+ ## [0.1.x] — 2026-04-16
3247
+
3248
+ Phase 0 (core performance) and Phase 1 (batch hardening) releases. Five
3249
+ critical O(N²) hot paths optimized (172× aggregate, 182× rolling, 15×
3250
+ movingAverage, 7.5× loess, 819× includesKey, 134× alignLinearAt).
3251
+ `toJSON`/`fromJSON` round-trip, custom aggregate reducers, edge-case
3252
+ coverage across every analytical primitive.
3253
+
3254
+ See [tag history](https://github.com/pjm17971/pond-ts/tags) for details.