Sampling
sample({ stride }) and sample({ reservoir }) thin a TimeSeries to a
smaller subset of events, without changing the schema. Two strategies for
two questions:
- Stride (
{ stride: N }) — keep every Nth event, uniform-over-time. Cheap, deterministic, the right pick for sliding-window stats over a thinned stream. - Reservoir (
{ reservoir: { size: K } }) — random K-of-N events, uniformly drawn. The right pick for visualization (uncorrelated scatter points without aggregation's grid collapse) and population summaries.
A static TimeSeries — the faint dots are the whole source, the teal dots the kept subset from source.sample(...). stride keeps every Nth (evenly spaced); reservoir draws a random K-of-N. Slide the value to keep more or fewer.
What's covered:
- When to use which — the decision table.
- Stride — deterministic 1-in-N thinning.
- Reservoir — random K-of-N via Algorithm R.
- Visualization —
series.sample({ reservoir }).toRows()as the canonical scatter shape. - Multi-entity series — see
partitionBy— per-partition sample state. - Live counterpart — when to reach for the streaming variant instead.
When to use which
Picking the wrong strategy is the highest-leverage bug this page can prevent. The use case maps cleanly onto one of the two:
| Use case | Stride | Reservoir |
|---|---|---|
| Sliding-window stats (rolling avg / percentiles) | ✅ default | ⚠️ random correlation across windows |
| Population summary over the snapshot | ⚠️ regular-spacing artifact | ✅ default |
| Visualization (scatter plot, sparkline samples) | ⚠️ regular-spacing artifact | ✅ default |
| Top-K / unique reducers | ❌ misses singletons | ⚠️ also misses, with extra randomness |
Rule of thumb: if downstream is aggregate / rolling, reach for
stride. If downstream is toRows() for a chart or a population-level
reducer, reach for reservoir.
Stride: deterministic 1-in-N
- TypeScript
- Rust
const sampled = series.sample({ stride: 10 });
// length === Math.floor(series.length / 10)
// keeps the 10th, 20th, 30th, ... events (1-indexed)
use pond_rs::{Column, Sample, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let series = TimeSeries::new(
"cpu",
(0..95).map(|i| i * 1_000).collect(),
vec![Column::required(
"cpu",
(0..95).map(f64::from).collect::<Vec<_>>(),
)],
)?;
let sampled = series.sample(Sample::Stride(10))?;
// 95 / 10 rounded down; keeps the 10th, 20th, 30th, ... rows.
assert_eq!(sampled.len(), 9);
assert_eq!(&sampled.key().begin()[..3], &[9_000, 19_000, 29_000]);
assert!(series.sample(Sample::Stride(0)).is_err());
Ok(())
}
Cheap (O(N) time, single pass, no RNG). Uniform-over-time, so any
windowed statistic computed downstream is also uniform-over-time —
which is why stride is the right default for sliding-window stats.
The trade-off is the regular-spacing artifact: a periodic source signal whose period is a multiple of the stride disappears entirely into the gaps. In practice this rarely matters for monitoring data (noise is usually broad-spectrum), but for any periodic source — heartbeat events, scheduled probes — pick a stride that's coprime with the period or use reservoir.
stride must be a positive integer; non-integer or <= 0 strides
throw at construction.
Reservoir: random K-of-N
- TypeScript
- Rust
const sampled = series.sample({ reservoir: { size: 500 } });
// length === Math.min(500, series.length)
// each event has probability ~K/N of being included, drawn uniformly
use pond_rs::{Column, Sample, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
let series = TimeSeries::new(
"cpu",
(0..95).map(|i| i * 1_000).collect(),
vec![Column::required(
"cpu",
(0..95).map(f64::from).collect::<Vec<_>>(),
)],
)?;
let sampled = series.sample(Sample::Reservoir { size: 20, seed: 42 })?;
assert_eq!(sampled.len(), 20);
// The same seed picks the same rows.
let again = series.sample(Sample::Reservoir { size: 20, seed: 42 })?;
assert_eq!(sampled.key().begin(), again.key().begin());
// Asking for more rows than there are keeps the whole series.
let all = series.sample(Sample::Reservoir {
size: 500,
seed: 42,
})?;
assert_eq!(all.len(), 95);
Ok(())
}
pond-rs's reservoir takes a seed, so a run can be repeated; pond-ts's has none. The kept rows stay in their order in the series, where pond-ts sorts them by key, so rows that share a time can come out in a different order.
Single-pass Vitter's Algorithm R:
fill the reservoir with the first K events, then for each subsequent
event at index i, replace a random reservoir slot with probability
K / (i + 1). Output is sorted by key on the way out so TimeSeries's
chronological invariant is preserved.
Each event in the source has the same probability of being in the
output (K/N for N >= K, 1 otherwise). Reducer outputs over the
sampled series approximate the population reduction, with standard
error ~σ / sqrt(K) for the sample mean.
size must be a positive integer; non-integer or <= 0 sizes throw
at construction. When K >= N, all events are returned in order
(no sampling).
Sample-rate caveats
Reducer outputs reflect the sampled stream, not the source:
const sampled = series.sample({ reservoir: { size: 500 } });
const counts = sampled.aggregate(Sequence.every('1m'), {
events: { from: 'cpu', using: 'count' },
});
// counts.value().events approximates true count × (K / N)
// → multiply by N/K to estimate true counts
Multiply count-shaped outputs by N / K (or by stride for stride
sampling) to get population-level estimates.
Visualization
The canonical scatter shape:
const points = series.sample({ reservoir: { size: 500 } }).toRows();
// 500 points, drawn uniformly from the source, in chronological order.
This beats aggregate(Sequence.every('1m'), { ... }) for visualization
because:
- No grid collapse. Every point is a real source observation, not a bucket reduction. The chart preserves the variability of the underlying data.
- No regular-spacing artifact. Reservoir's draw is uncorrelated with any periodic structure in the source.
- Fixed point count. A 500-event scatter renders the same way regardless of whether the source has 10k or 10M events. Memory and render time stay constant.
For sparkline / line shapes where ordering matters, the chronological
sort that sample({ reservoir }) already does means the output is
chart-ready without further work.
Multi-entity series — see partitionBy
A bare sample({ stride: 10 }) on a multi-entity series mixes events
across entities — and on a structured input (events arriving in
round-robin host order), it silently keeps the same subset of entities
and drops the rest. The fix is the same as with every other multi-entity
operator: partition first, then sample.
- TypeScript
- Rust
const sampled = metrics.partitionBy('host').sample({ stride: 10 }).collect();
// Each host's events are thinned independently.
use pond_rs::{Column, Sample, TimeSeries};
fn main() -> Result<(), pond_rs::PondError> {
// Three hosts reporting in turn, 20 readings each.
let metrics = TimeSeries::new(
"metrics",
(0..60).map(|i| (i / 3) * 1_000).collect(),
vec![
Column::required(
"host",
(0..60).map(|i| ["a", "b", "c"][i % 3]).collect::<Vec<_>>(),
),
Column::required("cpu", (0..60).map(f64::from).collect::<Vec<_>>()),
],
)?;
// Bare stride on round-robin rows keeps one host and drops the others.
let bare = metrics.sample(Sample::Stride(3))?;
let hosts = bare.column("host")?.strings()?;
assert!(hosts.iter().all(|h| h == Some("c")));
// Partition first: each host's rows are thinned on their own.
let sampled = metrics
.partition_by(&["host"])?
.sample(Sample::Stride(10))?
.collect()?;
assert_eq!(sampled.len(), 6); // 2 per host
let hosts: Vec<_> = sampled.column("host")?.strings()?.iter().collect();
assert_eq!(
hosts,
[
Some("a"),
Some("b"),
Some("c"),
Some("a"),
Some("b"),
Some("c")
]
);
Ok(())
}
On a partitioned series, Sample::Reservoir seeds each partition from your seed, so those picks repeat too.
Each partition gets its own stride counter (or its own K-event reservoir
for { reservoir: { size: K } }) — bounded, predictable per-partition
state. For 80 hosts × K=100 reservoir, that's 8000 events of state.
This is the same multi-entity consideration that applies to rolling,
aggregate, fill, diff, rate, cumulative, scan, and pctChange —
see Cleaning data → Multi-entity series
for the broader pattern.
Live counterpart
LiveSeries.sample, LiveView.sample, LivePartitionedSeries.sample,
and LivePartitionedView.sample accept { stride: N }. On a series or
view they return a LiveView<S> with its own stride counter; on a
partitioned series or view they return a LivePartitionedView, one
counter per partition, so call .collect() before listening to it. v0.17.0
ships stride only on the live side; reservoir is queued for a
later release once the streaming RFC's exact-removal eviction channel
lands.
Live stride: points stream in and live.sample({ stride }) keeps every Nth. The kept subset (teal) is stable — a kept point stays kept as it scrolls, only new arrivals get a keep/drop decision (the per-instance counter).
- TypeScript
- Rust
// Live: per-host stride feeding per-host rolling
live.partitionBy('host').sample({ stride: 10 }).rolling('5m', mapping);
// Snapshot: reservoir for visualization
series.sample({ reservoir: { size: 500 } }).toRows();
use pond_rs::live::{CollectOptions, LiveSeries, PartitionOptions};
use pond_rs::{Agg, ColumnDef, ColumnKind, Reducer, Sample};
fn main() -> Result<(), pond_rs::PondError> {
let mut live = LiveSeries::new(
"metrics",
vec![
ColumnDef::required("time", ColumnKind::Time),
ColumnDef::required("host", ColumnKind::String),
ColumnDef::required("cpu", ColumnKind::Number),
],
)?;
// Per-host stride feeding per-host rolling.
let hosts = live.partition_by(live.root(), "host", &PartitionOptions::new())?;
let per_host = live.per_partition(hosts, |live, host| {
let thinned = live.sample(host, Sample::Stride(2))?;
Ok(live
.rolling(thinned, "5s", &[Agg::of("cpu", Reducer::Avg)])?
.id())
})?;
let out = live.collect(per_host, &CollectOptions::new())?;
// Hosts a and b in turn; cpu is the push number.
for i in 0..12_i64 {
let host = if i % 2 == 0 { "a" } else { "b" };
live.push(
(i / 2) * 1_000,
[Some(host.into()), Some((i as f64).into())],
)?;
}
// Host a keeps its 2nd, 4th and 6th events (cpu 2, 6, 10), host b
// its 3, 7 and 11.
let got: Vec<_> = live[out]
.events()
.map(|e| (e.begin(), e.number("cpu").unwrap()))
.collect();
assert_eq!(
got,
[
(1_000, Some(2.0)),
(1_000, Some(3.0)),
(3_000, Some(4.0)),
(3_000, Some(5.0)),
(5_000, Some(6.0)),
(5_000, Some(7.0)),
]
);
Ok(())
}
As in pond-ts, a live sample takes a stride only (Sample::Stride); a reservoir is an error.
For the live story (firehose framing, the multi-entity bias trap, the
LiveView chainable surface), see
Live transforms → Sampling.
See also
- Cleaning —
dedupe/fill/materializeand the broader multi-entity story - Aggregation — bucketed reduction (the alternative to sample-then-render for charts where bucket-level rollups are wanted)
- Rolling — the primary downstream consumer of stride-sampled streams
- Live transforms → Sampling — the streaming variant