pond-ts 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,10 +7,239 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7
7
  file covers both packages. Pre-1.0: minor bumps may include new features and
8
8
  type-level changes; patch bumps are strictly additive.
9
9
 
10
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.14.2...HEAD
10
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.15.0...HEAD
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.15.0] — 2026-05-05
15
+
16
+ The "fused multi-window rolling" release. Shipping the primitive
17
+ that closes the gRPC experiment's V6→V7 architectural cliff: a
18
+ keyed-form overload on `live.rolling()` that maintains N windows
19
+ in one ingest pass over a single shared deque, emits one merged
20
+ event per trigger boundary, and (on the partitioned variant) eats
21
+ the doubled `#routeEvent` / `#evictPartition` / `_pushTrustedEvents`
22
+ hops V7 surfaced.
23
+
24
+ Two independent signals motivated this: the gRPC profile-diff
25
+ (PR #19 in `pond-grpc-experiment`) and the buffer-as-window
26
+ persona's metric-agent call site
27
+ (`series.rolling(RETENTION, mapping, ...)` as workaround). Both
28
+ point at one primitive; both shipped together. RFC #20 in
29
+ `pond-grpc-experiment` is the design record.
30
+
31
+ ### Added
32
+
33
+ - **Keyed-form fused rolling on `LiveSeries.rolling`,
34
+ `LiveView.rolling`, and `LivePartitionedSeries.rolling`.** Pass
35
+ a record of `{ duration: mapping }` instead of `(window, mapping)`
36
+ to declare multiple windows; the rolling maintains them all in
37
+ one ingest pass:
38
+
39
+ ```ts
40
+ const fused = byHost.rolling(
41
+ {
42
+ '1m': {
43
+ cpu_avg: { from: 'cpu', using: 'avg' },
44
+ cpu_sd: { from: 'cpu', using: 'stdev' },
45
+ },
46
+ '200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
47
+ },
48
+ { trigger: Trigger.every('200ms') },
49
+ );
50
+ // fused emits one merged event per boundary with all four
51
+ // columns; one ingest pass per source event.
52
+ ```
53
+
54
+ - **Output: one merged stream.** All declared windows' columns
55
+ concatenated into one record per trigger fire — not N
56
+ accumulators or N streams. User code collapses to one event
57
+ handler (the V7 → V8 migration in the gRPC experiment drops
58
+ ~30 lines of `pendingByTs` / `partsFor` / `tryEmit` join
59
+ machinery).
60
+ - **Constraints.** Time-based windows only (object keys are
61
+ duration strings); single trigger across all windows by
62
+ design (per-window cadence falls back to two `rolling()`
63
+ calls, paying the V7 cost). On partitioned series, clock
64
+ trigger is required.
65
+ - **Per-window options.** Use the elaborated value form
66
+ (`{ mapping, minSamples }`) when one window needs different
67
+ options from the rest; bare-mapping value stays clean for
68
+ the common case.
69
+ - **Duplicate output column names** across windows are rejected
70
+ at construction with a clear error. Partition column auto-
71
+ injection is unified across all windows.
72
+ - **Single-window equivalence pin.**
73
+ `live.rolling('1m', mapping, opts)` and
74
+ `live.rolling({ '1m': mapping }, opts)` produce identical
75
+ output (locked down by tests).
76
+
77
+ - **`LiveFusedRolling<S, Out>`** — non-partitioned class, exposed
78
+ on the public surface via `live.rolling({...}, opts)`.
79
+ - **`LivePartitionedFusedRolling<S, K, Out>`** — synchronised-cross-
80
+ partition class, exposed via `byHost.rolling({...}, { trigger })`.
81
+ - **Type-level surface:** `FusedMapping<S>`, `FusedMappingValue<S>`,
82
+ `FusedMappingElaborated<S>`, `FusedRollingSchema<S, FM>`,
83
+ `FusedPartitionedRollingSchema<S, ByCol, FM>`, and
84
+ `DurationString` — all exported from `pond-ts`. Output column
85
+ kinds narrow correctly through `event.get('cpu_avg')` to
86
+ `number | undefined`.
87
+
88
+ ### Performance
89
+
90
+ `packages/core/scripts/perf-fused-rolling.mjs` — bench against
91
+ gRPC RFC #20 acceptance criteria. Headline numbers (median of 3
92
+ runs, `node --expose-gc`):
93
+
94
+ ```
95
+ Partitioned, 100k events × 100 hosts (the gRPC use case):
96
+ wall (ms) heap (MB)
97
+ single rolling baseline 95.20 74.33
98
+ two separate rollings (V7 shape) 141.12 101.71
99
+ fused two-window (V8 shape) 112.36 68.46
100
+
101
+ Fused vs V7 shape: -20.4% wall, -32.7% heap
102
+ Fused vs baseline: +18.0% wall, -7.9% heap
103
+
104
+ Partitioned, 100k events × 1000 hosts (saturation):
105
+ wall (ms) heap (MB)
106
+ two separate rollings (V7 shape) 700.35 556.56
107
+ fused two-window (V8 shape) 446.21 309.25
108
+
109
+ Fused vs V7 shape: -36.3% wall, -44.4% heap
110
+ ```
111
+
112
+ **Scaling beyond two windows — the architectural argument
113
+ verified.** Every per-event pond hop runs ONCE in fused vs N times
114
+ in N separate rollings. The bench scales N from 2 to 5 windows
115
+ over the same 100k-events × 100-hosts source:
116
+
117
+ ```
118
+ Separate (ms) Fused (ms) Wall delta
119
+ N = 2 152.91 102.91 -32.7%
120
+ N = 3 186.63 79.89 -57.2%
121
+ N = 4 245.42 107.51 -56.2%
122
+ N = 5 279.79 118.90 -57.5%
123
+
124
+ Separate (MB) Fused (MB) Heap delta
125
+ N = 2 108.13 72.20 -33.2%
126
+ N = 3 93.30 43.08 -53.8%
127
+ N = 4 113.69 47.19 -58.5%
128
+ N = 5 137.17 47.12 -65.6%
129
+ ```
130
+
131
+ Fused stays roughly constant (~100ms) across N=2..5; separate
132
+ scales linearly. At N=5: **2.4× faster wall, 34% of the heap.**
133
+
134
+ The architectural cliff is closed and the win compounds with N.
135
+ Fused rolling's per-event cost is O(1) in the number of windows
136
+ for pipeline overhead — only O(N) for the unavoidable per-window
137
+ reducer-state updates (which separate also pays). Heap is
138
+ dominated by the saved per-rolling deque + per-partition state.
139
+
140
+ ### Notes on what this does NOT include
141
+
142
+ - **`live.reduce(mapping)` sugar.** Designed in PLAN as
143
+ `live.rolling({ buffer: mapping }, { history: false })`; the
144
+ `'buffer'` sentinel is reserved at the type level but throws at
145
+ runtime for now. Lands with the buffer-as-window Tier 1 PR.
146
+ - **`TimeSeries.rolling` snapshot-side parity.** The keyed-form
147
+ overload is live-side only in v0.15.0; batch-side comes in a
148
+ follow-up.
149
+ - **Path A (share `LiveSeries` buffer).** Currently Path B (own
150
+ deque) — fused rolling subscribes via `'event'` and maintains
151
+ its own per-partition deque. Path A is a transparent perf
152
+ follow-up; same API.
153
+ - **Compile-time uniqueness check on output columns.** Runtime
154
+ check is in place; the type-level `CheckUniqueOutputs` helper
155
+ is parked as a follow-up. Same with tightening `DurationString`
156
+ to reject `'1min'`-style typos at the type level (today's
157
+ template-literal type is permissive; runtime `parseDuration`
158
+ catches malformed durations).
159
+
160
+ ### Migration
161
+
162
+ Existing `live.rolling(window, mapping, opts)` calls are
163
+ unchanged. The keyed form is opt-in and additive. Two-rolling
164
+ patterns can migrate by collapsing to one fused call:
165
+
166
+ ```ts
167
+ // Before:
168
+ const baseline = byHost.rolling('1m', m1, { trigger });
169
+ const slice = byHost.rolling('200ms', m2, { trigger });
170
+ // Then a per-(ts, host) join over both event streams …
171
+
172
+ // After:
173
+ const fused = byHost.rolling({ '1m': m1, '200ms': m2 }, { trigger });
174
+ fused.on('event', (e) => {
175
+ // All columns from both windows on one event.
176
+ });
177
+ ```
178
+
179
+ [0.15.0]: https://github.com/pjm17971/pond-ts/compare/v0.14.3...v0.15.0
180
+
181
+ ## [0.14.3] — 2026-05-04
182
+
183
+ A targeted allocation fix in the `'samples'` reducer's rolling-state
184
+ implementation. Motivated by gRPC experiment V7 numbers — at the
185
+ ceiling regime (1k partitions × 1k events/s, 1M target) the all-
186
+ pond pipeline using `samples()` regressed throughput ~19% vs V6's
187
+ hybrid pond-rolling + manual-deque pattern, with +17% heap at
188
+ moderate loads. Per-event cost analysis pointed at a 1-element
189
+ `ScalarValue[]` allocation per scalar `add()` — one wasted
190
+ allocation per event compounding under sustained kHz × N-partition
191
+ load.
192
+
193
+ ### Changed
194
+
195
+ - **`samples.rollingState()` skips array wrap for scalar source
196
+ columns.** Scalar values (the common case at saturation) now
197
+ store directly into the keyed map; only array-kind sources
198
+ build a sub-array (because `remove(index)` needs to drop a
199
+ single event's contributions together). Snapshot branches on
200
+ `Array.isArray` to flatten the mixed map.
201
+
202
+ ```
203
+ Focused micro-bench (5M scalar add+remove cycles):
204
+ median (ms) min (ms) max (ms)
205
+ baseline (v0.14.2) 239.85 236.62 244.58
206
+ v0.14.3 209.09 207.42 215.26
207
+ delta −12.8% −12.3% −12.0%
208
+
209
+ Integration bench (100k events × N hosts, full pipeline):
210
+ Tight wall-clock parity within run-to-run noise across all
211
+ scenarios (samples 1m/5s, scalar/array). Allocation pressure
212
+ isn't the dominant cost at this scale; the optimization
213
+ compounds only at saturation regimes where GC pressure stacks.
214
+ ```
215
+
216
+ Behavior is preserved bit-for-bit — every existing
217
+ `samples-reducer.test.ts` assertion passes without modification.
218
+
219
+ ### Added
220
+
221
+ - `packages/core/scripts/perf-samples-reducer.mjs` — benchmark
222
+ covering the focused micro-bench + four integration scenarios
223
+ (scalar moderate / scalar high-cardinality / scalar high-churn
224
+ / array source) with a comparison anchor against `'avg'` on
225
+ the same shape. Run with `node --expose-gc` for heap numbers.
226
+
227
+ ### Note on saturation regimes
228
+
229
+ V7's regression isn't fully closed by this fix. The remaining gap
230
+ is architectural — V7 routes events through two full
231
+ `LiveRollingAggregation` pipelines (Map ops + reducer state +
232
+ trigger dispatch + subscriber fan-out per pipeline), where V6's
233
+ hybrid had one pond rolling for stats plus a passive
234
+ `array.push` listener for raw values. At the kHz × 1k-partition
235
+ saturation regime, the manual-deque pattern is genuinely the
236
+ right shape; pond's `samples` is for typical loads where per-
237
+ event overhead is invisible. A shared-buffer primitive (parked
238
+ as `tap()` in PLAN.md) would close the saturation gap; out of
239
+ scope for v0.14.3.
240
+
241
+ [0.14.3]: https://github.com/pjm17971/pond-ts/compare/v0.14.2...v0.14.3
242
+
14
243
  ## [0.14.2] — 2026-05-03
15
244
 
16
245
  Hotfix over v0.14.1 — closes a type-narrowing gap on the new
@@ -91,7 +320,7 @@ either). Two related changes ship together to close both gaps.
91
320
  '1m',
92
321
  {
93
322
  mean: { from: 'cpu', using: 'avg' },
94
- sd: { from: 'cpu', using: 'stdev' },
323
+ sd: { from: 'cpu', using: 'stdev' },
95
324
  },
96
325
  { trigger: Trigger.every('30s') },
97
326
  );
@@ -121,7 +350,6 @@ either). Two related changes ship together to close both gaps.
121
350
  function-typed reducers. New `bucketStateFor` and `rollingStateFor`
122
351
  helpers in `reducers/index.ts` route built-ins to their dedicated
123
352
  O(1) machinery and wrap custom functions in a generic adapter:
124
-
125
353
  - **Bucket adapter** (`LiveAggregation`): buffers values, calls
126
354
  the function once at `snapshot()` time. O(N) per snapshot.
127
355
  - **Rolling adapter** (`LiveRollingAggregation`,
@@ -140,7 +368,7 @@ either). Two related changes ship together to close both gaps.
140
368
 
141
369
  Pre-v0.14.1, calling `live.rolling(...)` with a custom-function
142
370
  reducer threw `TypeError: live rolling reducer for output 'X' must
143
- be a built-in name; ...`. Post-v0.14.1, the same call constructs
371
+ be a built-in name; ...`. Post-v0.14.1, the same call constructs
144
372
  successfully and runs.
145
373
 
146
374
  ### Tests
@@ -180,13 +408,13 @@ re-allocations). Both root-caused, both fixed.
180
408
  Benchmark deltas on `scripts/perf-live-partitioned.mjs`
181
409
  (100k events, median ms):
182
410
 
183
- | Scenario | Before | After | Δ |
184
- | ------------------------------------- | -----: | -----: | ------: |
185
- | bare `LiveSeries.push` | 41.11 | 30.08 | **−27%** |
186
- | `partitionBy('host')` routing (10) | 83.14 | 39.10 | **−53%** |
187
- | `partitionBy + collect()` | 124.82 | 49.96 | **−60%** |
188
- | `partitionBy + apply(fill)` | 120.53 | 49.64 | **−59%** |
189
- | `partitionBy('host')` routing (1000) | 105.92 | 43.23 | **−59%** |
411
+ | Scenario | Before | After | Δ |
412
+ | ------------------------------------ | -----: | ----: | -------: |
413
+ | bare `LiveSeries.push` | 41.11 | 30.08 | **−27%** |
414
+ | `partitionBy('host')` routing (10) | 83.14 | 39.10 | **−53%** |
415
+ | `partitionBy + collect()` | 124.82 | 49.96 | **−60%** |
416
+ | `partitionBy + apply(fill)` | 120.53 | 49.64 | **−59%** |
417
+ | `partitionBy('host')` routing (1000) | 105.92 | 43.23 | **−59%** |
190
418
 
191
419
  The bare-push delta is from the byte-estimate removal; the
192
420
  partition-routing deltas are from the trusted-pipeline path that
@@ -429,7 +657,7 @@ the live surface.
429
657
  - **Better error message when a custom-function reducer is passed to
430
658
  live aggregation.** `LiveAggregation` already failed at construction
431
659
  via `resolveReducer(reducer)` (with a generic `unsupported aggregate
432
- reducer` message); now the eager built-in-name check runs first and
660
+ reducer` message); now the eager built-in-name check runs first and
433
661
  emits a targeted error pointing at the `AggregateOutputMap` alias
434
662
  workaround. Same eager behavior on `LivePartitionedSyncRolling`,
435
663
  which previously failed lazily when the first partition spawned —
@@ -463,8 +691,8 @@ the live surface.
463
691
  required TS to see the `trigger` field's discriminator at the call
464
692
  site — so a caller writing
465
693
  `const opts: LiveRollingOptions = { trigger: Trigger.event() };
466
- partitioned.rolling(window, mapping, opts);` got `TS2769 No
467
- overload matches this call`. Pre-existing hole on the partitioned
694
+ partitioned.rolling(window, mapping, opts);` got `TS2769 No
695
+ overload matches this call`. Pre-existing hole on the partitioned
468
696
  surface; surfaced by the v0.13.0 Codex adversarial pass. Closed by
469
697
  adding catch-all overloads that accept the broader
470
698
  `LiveRollingOptions` and return the union of both trigger
@@ -0,0 +1,55 @@
1
+ import type { ColumnValue, EventForSchema, LiveSource, SeriesSchema } from './types.js';
2
+ import type { FusedMapping } from './types-fused-rolling.js';
3
+ import type { LiveRollingOptions } from './LiveRollingAggregation.js';
4
+ type EventListener = (event: any) => void;
5
+ /**
6
+ * Multi-window rolling that maintains N windows in one ingest pass
7
+ * over a single shared deque. Replaces the workaround of multiple
8
+ * separate `LiveRollingAggregation`s sharing the same source — the
9
+ * gRPC experiment's V6→V7 profile-diff (PR #19) showed every per-
10
+ * event pond hop roughly doubled when running two rollings.
11
+ *
12
+ * Each declared window has:
13
+ * - Its own resolved duration (clipped to retention; see PLAN.md)
14
+ * - Its own column-spec list and reducer states
15
+ * - Its own head cursor into the shared deque
16
+ *
17
+ * Output is ONE merged stream: one event per trigger boundary, with
18
+ * all windows' columns concatenated into one record. Duplicate
19
+ * output column names across windows are rejected at construction
20
+ * with a clear error (compile-time detection is queued as a
21
+ * follow-up).
22
+ *
23
+ * **Single trigger.** All windows share the configured trigger.
24
+ * Per-window cadence is explicitly NOT supported — that's what
25
+ * fusion saves. Users who need per-window cadence fall back to two
26
+ * separate `rolling()` calls and pay the V7 cost.
27
+ *
28
+ * **Time-based windows only.** Object keys are duration strings.
29
+ * Count-based windows stay on the existing single-window
30
+ * `LiveRollingAggregation`. This constraint keeps the
31
+ * window-clip-to-retention rule and boundary-detection logic clean.
32
+ *
33
+ * Public API: constructed via the `live.rolling(fusedMapping, opts)`
34
+ * keyed-form overload on `LiveSeries` / `LiveView`. User code
35
+ * doesn't import this class directly.
36
+ */
37
+ export declare class LiveFusedRolling<S extends SeriesSchema, Out extends SeriesSchema = SeriesSchema> implements LiveSource<Out> {
38
+ #private;
39
+ readonly name: string;
40
+ readonly schema: Out;
41
+ constructor(source: LiveSource<S>, fusedMapping: FusedMapping<S>, options?: LiveRollingOptions);
42
+ get length(): number;
43
+ at(index: number): EventForSchema<Out> | undefined;
44
+ /**
45
+ * Read the current merged snapshot — every window's reducer
46
+ * outputs concatenated into one record. Useful for live-display
47
+ * patterns where the consumer wants the latest values without
48
+ * waiting for the next trigger fire.
49
+ */
50
+ value(): Record<string, ColumnValue | undefined>;
51
+ on(type: 'event', fn: EventListener): () => void;
52
+ dispose(): void;
53
+ }
54
+ export {};
55
+ //# sourceMappingURL=LiveFusedRolling.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LiveFusedRolling.d.ts","sourceRoot":"","sources":["../src/LiveFusedRolling.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAGV,WAAW,EACX,cAAc,EACd,UAAU,EACV,YAAY,EACb,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,YAAY,EAAqB,MAAM,0BAA0B,CAAC;AAChF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAwCtE,KAAK,aAAa,GAAG,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,qBAAa,gBAAgB,CAC3B,CAAC,SAAS,YAAY,EACtB,GAAG,SAAS,YAAY,GAAG,YAAY,CACvC,YAAW,UAAU,CAAC,GAAG,CAAC;;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;gBAoCnB,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,EACrB,YAAY,EAAE,YAAY,CAAC,CAAC,CAAC,EAC7B,OAAO,GAAE,kBAAuB;IAyGlC,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,CAAC,GAAG,CAAC,GAAG,SAAS;IAKlD;;;;;OAKG;IACH,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAAC;IAahD,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,aAAa,GAAG,MAAM,IAAI;IAYhD,OAAO,IAAI,IAAI;CA0IhB"}