We've all trusted a progress bar that said "5 minutes left," walked away, and come back an hour later to find it barely moved. It's not a bug. It's built into how progress bars estimate.
Here's the core problem, and it's more interesting than it looks.
The lie every progress bar tells
A normal progress bar computes ETA like this: "I've done 2,000 of 10,000 items in 10 minutes, so the remaining 8,000 will take about 40 more." That assumes the rest of the job looks like the part you already ran.
That assumption breaks exactly when it matters most, which is when the expensive work is at the end:
for tile in satellite_tiles: # first 8,000 are small; last 2,000 are huge
process(tile)
At 80% done, a plain bar happily reports "3 minutes left," then the heavy tail hits and it's actually 3 hours. The bar never saw it coming, because everything it learned came from the cheap early items.
I tested this across a range of workload shapes. A plain count-based ETA is routinely 60 to 280 percent off on uneven jobs:
shape actual plain bar off
heavy-tail 3.9s 1.6s 60%
heavy-middle 4.3s 1.3s 70%
heavy-front 4.3s 16.4s 278%
random-spikes 3.6s 6.0s 64%
The fix: stop extrapolating the future, sample it
Here's the idea I couldn't shake. For a loop over a known set of items, the future work already exists. You just haven't run it yet. So instead of guessing what the remaining 8,000 items look like from the 2,000 you saw, why not measure a tiny representative sample of the ones you haven't run?
This is design-based survey sampling, the same statistical machinery ecologists use to estimate a population (fish, trees, sharks) from a small, carefully chosen sample. You draw a stratified or systematic sample of the remaining items, measure their true cost, and form a Horvitz-Thompson estimate of the total. Because the sample is chosen by design, the estimate is unbiased and comes with a real margin of error rather than a blind guess.
I actually first used this exact approach years ago in an ecology paper on adaptive-cluster sampling. The insight transfers cleanly: when the thing you care about is concentrated in places you haven't looked yet, a well-designed sample beats extrapolating from what you happened to see first.
The result: sampling about 2 percent of the remaining work cuts the error to near zero on the same hard cases:
shape plain bar RunScope
heavy-tail 60% 0%
heavy-middle 70% 0%
heavy-front 278% 1%
random-spikes 64% 3%
uniform 0% 0% (does no harm on easy jobs)
Meet RunScope
I packaged this into a small, dependency-free library called RunScope. It's a drop-in that gives you an honest range instead of a fake exact number.
pip install runscope
import runscope
for item in runscope.track(items, key="my_job"):
process(item)
my_job |########------------| 38% 17492/48000 | 1h20m left (1h12m-1h31m) | high | [Padawan]
It has three modes, and you never pick. It uses the best it can:
- Padawan: a smarter current-run estimate that reacts to slowdowns instead of hanging.
- Master: after you run the same job a few times, it learns that job's behavior and calibrates automatically.
- Jedi: for uneven or back-loaded jobs, it samples a little of the future work to predict the heavy tail before it hits.
There's a tqdm drop-in too:
from runscope import trange
for i in trange(10000, key="my_job"):
...
The honest caveat
RunScope is for enumerable work: loops over files, records, images, tiles, simulations, parameter grids. It does not try to predict the runtime of an arbitrary opaque function. And when there genuinely isn't enough information to estimate honestly, it says so instead of inventing a number. That restraint is the whole point.
Try it, or roast it
This is my first open-source release, and I'd genuinely love your feedback. Issues and PRs very welcome.
If you've ever been burned by a lying ETA, give it a spin and tell me where it holds up and where it doesn't.
Top comments (0)