Windowing
A window is a subset of events you want to answer a question over. "Average the last minute," "count events in this 5-minute bucket," "what's the running median over the trailing 100 events" — all windowing questions, all answered by reducing a window of events to one or more values.
Pond has five windowing modes. The choice depends on two axes: how the window is defined and when the answer fires.
| Mode | Window definition | Output cadence | Operator |
|---|---|---|---|
| Full window | The whole series | Once | series.reduce(mapping) |
| Fixed buckets | Each Sequence bucket | Once per bucket | series.aggregate(seq, mapping) |
| Rolling (batch) | Trailing window per event | Once per source event | series.rolling(window, mapping) |
| Rolling (live) | Trailing window per event | Per source event OR per trigger | live.rolling(window, mapping, { trigger? }) |
| Multi-window (live) | Several trailing windows over one source | Per trigger, one merged event | live.rolling({ '1m': m1, '200ms': m2 }, { trigger }) |
Full window
Reduce the entire series to a single record.
series.reduce({
cpu: 'avg',
hosts: 'unique',
});
// → { cpu: 0.41, hosts: ['api-1', 'api-2', ...] }
One walk over all events; one result. Use when the question is "summarise this whole dataset" — daily reports, post-hoc analysis, test assertions.
reduce returns a plain record, not a series. There's nothing to
chain afterwards because there's no temporal axis left.
Fixed buckets
Bucket events onto a Sequence grid and reduce each
bucket independently. Each bucket is independent — events in bucket
A don't influence bucket B. The output keys are Intervals aligned
to the sequence's boundary grid. Use when the question is
"summarise per N-second period."
Fixed buckets — batch
series.aggregate(Sequence.every('5m'), {
cpu: 'avg',
cpu_max: { from: 'cpu', using: 'max' },
});
// → new TimeSeries: one Interval-keyed event per 5-minute bucket
One walk over the source, one output series. The bucket boundaries are determined entirely by the sequence — events that fall into the same bucket are reduced together; empty buckets emit per the reducer's empty-bucket policy.
Fixed buckets — live
live.aggregate(Sequence.every('5m'), {
cpu: 'avg',
});
// → LiveAggregation: emits 'close' events as buckets finalize
A live aggregation closes a bucket once the watermark advances past
its end — events arrive incrementally, buckets finalise
incrementally. The accumulator emits a 'close' event when each
bucket finalises and a 'bucket' event continuously while the
current bucket is still open. rolling.value()-style continuous
reads are also available via .snapshot().
Two streams from one accumulator: the close stream is the durable "this bucket is final" signal (good for backend reporting); the bucket stream is the provisional "in-progress" signal (good for live-display previews of the partial value). Same accumulator, same bucket state, no duplicated work.
Rolling windows (batch)
A trailing window slides over the series; one output event per source event with the window's reduction at that point.
series.rolling('1m', { cpu: 'avg' });
// → new TimeSeries: one event per source event, keyed at the source's key
// value is the avg of the trailing 1-minute window
Window size can be a duration ('1m') or an integer count (100).
Output is one-to-one with input events; the window slides by one
event each step. Use when the question is "smoothed value at each
point" — moving averages, rolling percentiles, anomaly bands.
A grid-form variant emits at sequence points instead of per source event:
series.rolling(Sequence.every('30s'), '5m', { cpu: 'p95' });
// → one event per 30s grid point, value reduces the trailing 5m window
Use the grid form for chart sampling — fixed output cadence independent of where source events land.
Rolling windows (live)
The streaming counterpart. Each source event produces an output, OR output cadence is controlled by a trigger.
import { Trigger } from 'pond-ts';
// Per source event (default — Trigger.event())
live.rolling('1m', { cpu: 'avg' });
// Per sequence boundary crossing
live.rolling('1m', { cpu: 'avg' }, { trigger: Trigger.every('30s') });
// Every N source events
live.rolling('1m', { cpu: 'avg' }, { trigger: Trigger.count(1000) });
The window is maintained incrementally — events add as they
arrive, remove as they age out. Per-event cost is O(1) for
built-in reducers. The trigger controls when a snapshot fires;
the window is always current. See Triggers.
Output stream-shapes match the trigger:
| Trigger | Output |
|---|---|
event (default) | One event per source event |
clock / every | One event per epoch-aligned boundary crossing |
count(n) | One event per n source events |
rolling.value() always reads the current snapshot regardless of
trigger — handy for live-display patterns where backend reporting
fires at the trigger cadence but the in-app view reads continuously.
Multi-window rolling (live)
When you want several trailing windows over the same source —
e.g. a 1-minute baseline of avg/stdev plus a 200-ms current-tick
window of raw samples for anomaly detection — pass a record-of-
mappings instead of a single (window, mapping) pair:
const fused = live.rolling(
{
'1m': {
cpu_avg: { from: 'cpu', using: 'avg' },
cpu_sd: { from: 'cpu', using: 'stdev' },
},
'200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
},
{ trigger: Trigger.every('200ms') },
);
fused.on('event', (e) => {
// All four columns from both windows on a single event.
const z = (s: number) => (s - e.get('cpu_avg')) / e.get('cpu_sd');
// …
});
Properties:
- One ingest pass. Every source event updates every window's reducer state in one pass over a single shared deque. Two-rolling shapes (which is what users wrote before this primitive existed) pay each per-event pipeline cost twice.
- One merged output stream. All windows' columns concatenated
into one record per trigger fire. Consumer code is one event
handler, not a per-
(ts, key)join. - Single trigger across all windows by design. Per-window
cadence is rare; users who need it fall back to two
rolling()calls. The simpler API wins for the common case. - Time-based windows only. Object keys are duration strings
(
'1m','200ms','5s'). Count-based windows (live.rolling(100, ...)) stay on the single-window form and aren't mixable with time-windows here. - Duplicate output column names across windows are rejected at construction. Each output column name is unique across the merged schema.
The same form composes with partitionBy(...) and works through
chained pipelines:
const fused = live
.partitionBy('host')
.fill({ cpu: 'hold' })
.rolling(
{
'1m': { cpu_avg: { from: 'cpu', using: 'avg' } },
'200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
},
{ trigger: Trigger.every('200ms') },
);
// One merged event per partition per boundary; partition column
// auto-injected at the front of the schema.
On partitionBy(...).rolling({...}, opts), clock trigger is
required — synced cross-partition emission needs a single shared
boundary detector.
For per-window options, the value form switches from a bare mapping to an elaborated wrapper:
live.rolling(
{
'1m': { cpu_avg: { from: 'cpu', using: 'avg' } },
'200ms': {
mapping: { cpu_samples: { from: 'cpu', using: 'samples' } },
minSamples: 5, // per-window override
},
},
{ trigger, minSamples: 2 }, // top-level default
);
See Rolling for the full reference.
Choosing the mode
| Question | Reach for |
|---|---|
| "Summarise everything" | reduce |
| "Per-bucket aggregate" | aggregate(seq) |
| "Smoothed value at each event" | rolling(window) |
| "Smoothed value at fixed grid points" | rolling(seq, win) |
| "Streaming smoothed value, per event" | live.rolling(window) |
| "Streaming snapshot at fixed reporting cadence" | live.rolling(window, m, { trigger: Trigger.every(...) }) |
| "Streaming snapshot every N events" | live.rolling(window, m, { trigger: Trigger.count(n) }) |
| "Several windows over one source, one merged emit" | live.rolling({ '1m': m1, '200ms': m2 }, { trigger }) |
Multiple stats per window
All five modes accept the AggregateOutputMap shape — multiple
named outputs, each reducing the same or different source columns:
series.aggregate(Sequence.every('1m'), {
mean: { from: 'cpu', using: 'avg' },
sd: { from: 'cpu', using: 'stdev' },
n: { from: 'cpu', using: 'count' },
hi: { from: 'cpu', using: 'max' },
});
One walk, four reducers, one output schema with four columns.
Pond's runtime executes the four reducers side-by-side over the
same window — the per-event cost is O(reducers × 1) for built-in
reducers, not O(reducers × N) from running multiple aggregations.
Multiple-stats-per-window vs multi-window. The example above
runs four reducers over one window (1-minute buckets). Multi-
window rolling runs reducers over several windows (1m + 200ms,
say) in one ingest pass. The two compose: each window in a
multi-window rolling can carry its own AggregateOutputMap mapping.
Bounded windows on streaming sources
A LiveSeries retains data based on its retention policy
({ maxEvents } or { maxAge }); operators consume the buffer up
to that limit. A LiveView.window(duration) adds a tighter
event-grid view on top of the buffer for read-side patterns:
const recent = live.window('1m');
recent.length; // number of events in the last 1m
recent.eventRate(); // events-per-second over the window
window is observation-only — it doesn't drive aggregation. Use
live.rolling(window, mapping) when you need the window as the
reduction range.
What windowing doesn't do
- Late-event reflow. Events arriving out-of-order land at their insertion point; rolling and aggregation do not recompute historical windows. See Late data.
- Cross-window state sharing. Two rollings over the same source
with different windows are independent — each maintains its own
deque and reducer state. (A
tapsub-window primitive that shares the parent's deque is parked in PLAN.md.) - Per-key (partitioned) windowing. A windowed operator on
LiveSeriesorTimeSeriesruns over the whole series. To window per partition, see Partitioning.
Where this shows up
- Operator pages —
aggregate,reduce,rolling,align. - Live transforms —
Live transforms covers
LiveAggregation,LiveRollingAggregation, and the trigger-based emission story; the trigger reference lives at Triggering. - Reducers — every windowing operator accepts the same reducer registry. See Reducer reference.
- Sequences and triggers —
aggregateand grid-formrollingconsume aSequence; live rolling's emission cadence is shaped by Triggers.