A Fair Sample in 100 Slots, Forever
After 10,000 events, every event has a 1% chance of occupying a 100-slot buffer. After a billion, the buffer is still fair and still holds 100.
Series parts
- Part 1 A One-Line Sampler Can Quietly Double the Odds
- Part 2 When Randomness Should Take Sides
- Part 3 The Raffle Changes After Every Winner
- Part 4 A Fair Sample in 100 Slots, Forever
- Part 5 The Hidden Full-Table Scan in `ORDER BY random()`
- Part 6 How Wrong Can a Random Sample Be?
- Part 7 Small Data Structures That Lie Just Enough
- Part 8 When Luck Is Part of the Proof
- Part 9 I Found a Probability Bug in My Old TSP Solver
- Part 10 Softmax Is Not Confidence
- Part 11 The Useful Fiction of Hidden Causes
- Part 12 A Latent Space Is Not Automatically a Place
- Part 13 Before a Token Is Chosen, Context Has to Move
- Part 14 Who Actually Chooses the Next Token?
- Part 15 One Token, Zero Materialized Logits
- Part 1 A One-Line Sampler Can Quietly Double the Odds
- Part 2 When Randomness Should Take Sides
- Part 3 The Raffle Changes After Every Winner
- Part 4 A Fair Sample in 100 Slots, Forever
- Part 5 The Hidden Full-Table Scan in `ORDER BY random()`
- Part 6 How Wrong Can a Random Sample Be?
- Part 7 Small Data Structures That Lie Just Enough
- Part 8 When Luck Is Part of the Proof
- Part 9 I Found a Probability Bug in My Old TSP Solver
- Part 10 Softmax Is Not Confidence
- Part 11 The Useful Fiction of Hidden Causes
- Part 12 A Latent Space Is Not Automatically a Place
- Part 13 Before a Token Is Chosen, Context Has to Move
- Part 14 Who Actually Chooses the Next Token?
- Part 15 One Token, Zero Materialized Logits
On this page
After 10,000 log lines, every line can have exactly a 1% chance of occupying a 100-slot buffer. After a billion lines, the buffer still holds 100 and every line still has the same inclusion probability: one in ten million.
I find that result mildly absurd. The stream never tells you its final length, old items cannot be revisited, and memory never grows. Reservoir sampling still keeps a uniform sample without replacement.1
The problem in one sentence
Items arrive one by one: . You do not know how many there will be. After seeing items, you want exactly k of them in memory, with every item seen so far having probability
If you have seen 10,000 log lines and your reservoir size is 100, each line should have a 1% chance of being in memory right now. Not just the recent ones. Not just the lucky early ones. All of them.
The algorithm
The simplest version is often called Algorithm R.
- Put the first
kitems directly into the reservoir. - When the -st item arrives, keep it with probability .
- If you keep it, evict one of the current
kreservoir items uniformly at random.
No history, no estimate of the final stream length, and no growing state.
export function reservoirSample<T>(
stream: Iterable<T>,
k: number
): T[] {
if (!Number.isInteger(k) || k < 0) {
throw new Error("Sample size must be a non-negative integer");
}
const reservoir: T[] = [];
let seen = 0;
for (const item of stream) {
seen += 1;
if (reservoir.length < k) {
reservoir.push(item);
continue;
}
const j = Math.floor(Math.random() * seen);
if (j < k) {
reservoir[j] = item;
}
}
return reservoir;
} The key line is const j = Math.floor(Math.random() * seen).
If j < k, the new item enters the reservoir. Otherwise it is ignored. That single line silently implements the “keep with probability / seen” step.
Why it works
The proof is a short induction.
Take the moment when you have seen items and your reservoir is already fair. Now item arrives. It should be included with probability , and that is exactly what the algorithm does.
What about an older item already in the reservoir? It was there with probability . Once the new item arrives, it survives unless two things happen: the new item is accepted (probability ), and this specific old item is chosen for eviction (probability ).
The probability of getting kicked out is
The survival probability is , so the old item remains with probability
The old items and the new item all end up with the same inclusion probability.
Step through items one at a time above, or let it auto-play. Watch the reservoir fill up greedily at first, then become increasingly selective. Switch to the fairness view and run thousands of simulations; the inclusion histogram flattens until the invariant becomes visible.
The cost hidden by the proof
Algorithm R examines every item and generates a random integer for every item after the first k. That is optimal if the application must inspect each record anyway, but sampling itself can become the bottleneck when k is tiny and the stream is fast.
Vitter’s 1985 paper is remembered for faster reservoir algorithms that skip over runs of rejected records instead of flipping a fresh decision for each one. The simple version above remains the best explanation of the invariant. It is not the end of the performance story.
A reservoir is fair, not balanced
Reservoir sampling is not trying to make the buffer look aesthetically balanced, upweight rare classes, preserve recent items, or track concept drift. Its job is narrower: maintain a uniform sample without replacement from a stream of unknown length.
If your only goal is “give me a representative size- sample of an unknown-length stream,” that neutrality is exactly the point.
When fairness is not the right goal
In continual learning, observability, or product analytics, a buffer may not want to mirror the raw stream exactly. If the stream is highly imbalanced, bursty, or temporally correlated, a plain uniform reservoir preserves those distortions.
At that point you have changed the problem. You may want a recency window, a stratified sampler, a replay buffer that overrepresents rare classes, or a sampler that tracks a target distribution. Those are valid designs, but they are no longer the neutral guarantee above.
Classic reservoir sampling asks for fairness with respect to the stream. Many production buffers need usefulness with respect to a task. Confusing those two goals produces a perfectly fair buffer that answers the wrong question.