Skip to main content

gglib_core/download/
rate.rs

1//! Time-decayed download rate and ETA estimation.
2//!
3//! This is the single owner of all download speed / ETA math. The download
4//! manager owns one [`RateEstimator`] per shard group and ships the values it
5//! produces on the wire; every renderer (CLI progress bars, Tauri GUI, web UI)
6//! displays those values verbatim. Renderers must never re-derive a rate from
7//! byte deltas — doing so is what let the CLI and the GUI disagree with each
8//! other, and with the operating system's own network monitor.
9//!
10//! # Why not an exponentially weighted average of instantaneous rates?
11//!
12//! Progress arrives in bursts. On the `hf-xet` fast path the byte counter comes
13//! from `stat`ing the partially-written file, and the chunk cache flushes to
14//! disk in large steps even while the network rate is perfectly flat. Dividing
15//! a burst by the short interval it landed in yields an enormous instantaneous
16//! rate, and weighting that into a running average still leaks a visible spike.
17//!
18//! Instead this decays *bytes* and *elapsed time* separately and reports their
19//! ratio:
20//!
21//! ```text
22//! decay       = exp(-dt / TAU)
23//! accum_bytes = accum_bytes * decay + delta_bytes
24//! accum_time  = accum_time  * decay + dt
25//! rate        = accum_bytes / accum_time
26//! ```
27//!
28//! A burst adds to the numerator *and* the denominator, so it contributes
29//! exactly its own weight and can never spike. Intervals that carry no bytes
30//! still add to `accum_time`, so a stall decays the reported rate toward zero
31//! rather than freezing it at the last value seen.
32//!
33//! Two further properties matter for how the manager drives this:
34//!
35//! * The first sample only establishes a baseline and yields no rate. A resumed
36//!   download reports its whole on-disk size in the first event; counting that
37//!   as bytes transferred "just now" is what produced multi-GB/s readings.
38//! * A byte count that moves backwards re-baselines instead of underflowing.
39//!   Per-shard counters restart at zero on every shard, so the manager feeds
40//!   aggregate bytes; this is the safety net for the fallback path where shard
41//!   sizes are unknown and the aggregate is not monotonic.
42
43use std::time::Instant;
44
45/// Time constant for the rate average.
46///
47/// The reported rate reflects roughly the last `RATE_TAU` seconds of transfer.
48/// This has to be long: `hf-xet` can flush to disk only every couple of
49/// seconds, and the residual ripple for a burst arriving every `P` seconds is
50/// approximately `±P / (2 * RATE_TAU)`. At 15s a 2-second burst period ripples
51/// by under 7%, which reads as steady; at 5s the same input ripples by 20%,
52/// which is exactly the jitter this module exists to remove.
53///
54/// 15s is also what `indicatif`'s own estimator uses for its weighting horizon.
55/// The cost is response time: a genuine change in throughput is tracked with a
56/// 15s time constant, which is imperceptible against a multi-minute download.
57const RATE_TAU: f64 = 15.0;
58
59/// Time constant for the ETA average.
60///
61/// Shorter than [`RATE_TAU`]: the ETA already inherits that smoothing through
62/// the rate it divides by, and this only removes the last of the twitch caused
63/// by the remaining-bytes term.
64const ETA_TAU: f64 = 5.0;
65
66/// Minimum accumulated observation time before any rate is reported.
67///
68/// Below this the average is dominated by whatever the first interval happened
69/// to contain, so [`RateEstimator::rate_bps`] reports `None` and callers render
70/// a placeholder instead of a number that is about to change by an order of
71/// magnitude.
72const WARMUP_SECS: f64 = 1.5;
73
74/// Time-decayed estimate of download throughput and time remaining.
75///
76/// Feed it cumulative byte counts with [`record`](Self::record) — on *every*
77/// tick, including ticks where the count has not changed, since those are what
78/// make a stalled transfer decay toward zero.
79#[derive(Debug, Clone)]
80pub struct RateEstimator {
81    /// Decayed sum of bytes transferred.
82    accum_bytes: f64,
83    /// Decayed sum of elapsed time, in seconds.
84    accum_secs: f64,
85    /// Cumulative byte count at the previous sample; `None` before the first.
86    prev_bytes: Option<u64>,
87    /// Timestamp of the previous sample.
88    prev_at: Instant,
89    /// Smoothed seconds remaining; `None` when unknown or complete.
90    smoothed_eta: Option<f64>,
91}
92
93impl RateEstimator {
94    /// Create an estimator with its baseline at `now`.
95    #[must_use]
96    pub const fn new(now: Instant) -> Self {
97        Self {
98            accum_bytes: 0.0,
99            accum_secs: 0.0,
100            prev_bytes: None,
101            prev_at: now,
102            smoothed_eta: None,
103        }
104    }
105
106    /// Record a cumulative byte count.
107    ///
108    /// `downloaded` and `total` are cumulative totals for the whole artifact,
109    /// not per-tick deltas. `total` may be `0` when the size is not yet known,
110    /// in which case no ETA is produced.
111    ///
112    /// Call this on every tick of the progress bridge. Ticks where `downloaded`
113    /// has not moved are meaningful samples: they are how a stall pulls the
114    /// reported rate down.
115    pub fn record(&mut self, downloaded: u64, total: u64, now: Instant) {
116        let dt = now.saturating_duration_since(self.prev_at).as_secs_f64();
117        self.prev_at = now;
118
119        let Some(prev) = self.prev_bytes else {
120            // First sample: establish the baseline only. Whatever is already on
121            // disk was not transferred during this interval.
122            self.prev_bytes = Some(downloaded);
123            return;
124        };
125
126        if downloaded < prev {
127            // Counter moved backwards (per-shard counters restart at zero, and
128            // the unknown-shard-size fallback is not monotonic). Re-baseline
129            // without emitting a sample, keeping the accumulated average so the
130            // user sees no discontinuity at a shard boundary.
131            self.prev_bytes = Some(downloaded);
132            return;
133        }
134
135        self.prev_bytes = Some(downloaded);
136
137        if dt > 0.0 {
138            let decay = (-dt / RATE_TAU).exp();
139            // Byte deltas are far below f64's exact-integer range.
140            #[allow(clippy::cast_precision_loss)]
141            let delta = (downloaded - prev) as f64;
142            self.accum_bytes = self.accum_bytes.mul_add(decay, delta);
143            self.accum_secs = self.accum_secs.mul_add(decay, dt);
144        }
145
146        self.update_eta(downloaded, total, dt);
147    }
148
149    /// Current throughput in bytes per second.
150    ///
151    /// `None` until enough time has been observed for the average to mean
152    /// anything — callers should render a placeholder rather than a zero.
153    #[must_use]
154    pub fn rate_bps(&self) -> Option<f64> {
155        if self.accum_secs < WARMUP_SECS {
156            return None;
157        }
158        let rate = self.accum_bytes / self.accum_secs;
159        (rate.is_finite() && rate > 0.0).then_some(rate)
160    }
161
162    /// Smoothed estimate of the seconds remaining.
163    ///
164    /// `None` when the total size is unknown, the transfer is complete, or no
165    /// rate has been established yet.
166    #[must_use]
167    pub const fn eta_seconds(&self) -> Option<f64> {
168        self.smoothed_eta
169    }
170
171    /// Fold the latest raw ETA into the smoothed one.
172    fn update_eta(&mut self, downloaded: u64, total: u64, dt: f64) {
173        let Some(rate) = self.rate_bps() else {
174            return;
175        };
176        if total == 0 || downloaded >= total {
177            self.smoothed_eta = None;
178            return;
179        }
180
181        // Byte counts are far below f64's exact-integer range.
182        #[allow(clippy::cast_precision_loss)]
183        let remaining = (total - downloaded) as f64;
184        let raw = remaining / rate;
185        if !raw.is_finite() {
186            return;
187        }
188
189        self.smoothed_eta = Some(self.smoothed_eta.map_or(raw, |prev| {
190            let alpha = 1.0 - (-dt / ETA_TAU).exp();
191            alpha.mul_add(raw - prev, prev)
192        }));
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use std::time::Duration;
200
201    /// Drive the estimator at `rate` bytes/sec for `steps` ticks of `dt`.
202    fn drive(
203        est: &mut RateEstimator,
204        start: Instant,
205        total: u64,
206        rate: f64,
207        dt: f64,
208        steps: u32,
209    ) -> Instant {
210        let mut now = start;
211        let mut bytes = est.prev_bytes.unwrap_or(0);
212        for _ in 0..steps {
213            now += Duration::from_secs_f64(dt);
214            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
215            let step = (rate * dt) as u64;
216            bytes += step;
217            est.record(bytes, total, now);
218        }
219        now
220    }
221
222    #[test]
223    fn first_sample_never_reports_a_rate() {
224        // A resumed download reports 2 GiB already on disk in its first event.
225        let now = Instant::now();
226        let mut est = RateEstimator::new(now);
227        est.record(2 * 1024 * 1024 * 1024, 4 * 1024 * 1024 * 1024, now);
228        assert_eq!(
229            est.rate_bps(),
230            None,
231            "baseline sample must not yield a rate"
232        );
233    }
234
235    #[test]
236    fn converges_to_a_constant_rate() {
237        let start = Instant::now();
238        let mut est = RateEstimator::new(start);
239        est.record(0, u64::MAX, start);
240        drive(&mut est, start, u64::MAX, 100_000_000.0, 0.25, 240);
241
242        let rate = est.rate_bps().expect("rate after 60s of steady transfer");
243        let error = (rate - 100_000_000.0).abs() / 100_000_000.0;
244        assert!(error < 0.02, "expected within 2% of 100 MB/s, got {rate}");
245    }
246
247    #[test]
248    fn bursty_input_reads_as_steady() {
249        // All bytes for a 2s window land in a single 250ms tick — the shape the
250        // hf-xet stat poller actually produces when the chunk cache flushes.
251        // The mean is 50 MB/s and the display must not swing around it.
252        let start = Instant::now();
253        let mut est = RateEstimator::new(start);
254        est.record(0, u64::MAX, start);
255
256        let mut now = start;
257        let mut bytes = 0u64;
258        let (mut low, mut high) = (f64::MAX, 0.0f64);
259
260        for i in 0..320 {
261            now += Duration::from_secs_f64(0.25);
262            if i % 8 == 0 {
263                bytes += 100_000_000; // 100 MB every 2s
264            }
265            est.record(bytes, u64::MAX, now);
266
267            // Ignore the ramp; measure the steady state over the last 20s.
268            if i >= 240 {
269                if let Some(r) = est.rate_bps() {
270                    low = low.min(r);
271                    high = high.max(r);
272                }
273            }
274        }
275
276        for (label, value) in [("min", low), ("max", high)] {
277            let error = (value - 50_000_000.0).abs() / 50_000_000.0;
278            assert!(
279                error < 0.15,
280                "steady-state {label} was {value}, more than 15% off the 50 MB/s mean"
281            );
282        }
283    }
284
285    #[test]
286    fn stall_decays_the_rate_toward_zero() {
287        let start = Instant::now();
288        let mut est = RateEstimator::new(start);
289        est.record(0, u64::MAX, start);
290        let mut now = drive(&mut est, start, u64::MAX, 100_000_000.0, 0.25, 120);
291        let before = est.rate_bps().expect("rate before the stall");
292
293        // Bytes stop moving; ticks keep arriving. This is the case the old
294        // estimator got wrong — it only sampled when bytes changed, so a stall
295        // froze the speed and left the ETA counting down against nothing.
296        let stalled_at = est.prev_bytes.unwrap();
297        for _ in 0..240 {
298            now += Duration::from_secs_f64(0.25);
299            est.record(stalled_at, u64::MAX, now);
300        }
301
302        let after = est.rate_bps().unwrap_or(0.0);
303        assert!(
304            after < before * 0.05,
305            "60s stall should decay {before} to near zero, got {after}"
306        );
307    }
308
309    #[test]
310    fn shard_boundary_does_not_disturb_the_rate() {
311        let start = Instant::now();
312        let mut est = RateEstimator::new(start);
313        est.record(0, u64::MAX, start);
314        let now = drive(&mut est, start, u64::MAX, 100_000_000.0, 0.25, 160);
315        let before = est.rate_bps().expect("rate at the end of shard 1");
316
317        // Next shard: the per-shard counter restarts at zero.
318        est.record(0, u64::MAX, now + Duration::from_secs_f64(0.25));
319        let after = est.rate_bps().expect("rate immediately after the boundary");
320
321        let change = (after - before).abs() / before;
322        assert!(
323            change < 0.10,
324            "boundary changed the rate from {before} to {after}"
325        );
326    }
327
328    #[test]
329    fn eta_is_none_until_a_rate_exists() {
330        let start = Instant::now();
331        let mut est = RateEstimator::new(start);
332        est.record(0, 1_000_000_000, start);
333        est.record(1_000_000, 1_000_000_000, start + Duration::from_millis(250));
334        assert_eq!(est.eta_seconds(), None, "no ETA before warmup");
335    }
336
337    #[test]
338    fn eta_counts_down_on_a_steady_transfer() {
339        let start = Instant::now();
340        let mut est = RateEstimator::new(start);
341        let total = 10_000_000_000u64; // 10 GB at 100 MB/s = 100s
342        est.record(0, total, start);
343        drive(&mut est, start, total, 100_000_000.0, 0.25, 240);
344
345        let eta = est.eta_seconds().expect("ETA on a steady transfer");
346        // 60s elapsed, 6 GB done, 4 GB left at 100 MB/s ≈ 40s.
347        assert!(
348            (eta - 40.0).abs() < 5.0,
349            "expected ~40s remaining, got {eta}"
350        );
351    }
352
353    #[test]
354    fn eta_clears_on_completion() {
355        let start = Instant::now();
356        let mut est = RateEstimator::new(start);
357        let total = 10_000_000_000u64;
358        est.record(0, total, start);
359        let now = drive(&mut est, start, total, 100_000_000.0, 0.25, 40);
360        assert!(est.eta_seconds().is_some(), "ETA while in flight");
361
362        est.record(total, total, now + Duration::from_millis(250));
363        assert_eq!(est.eta_seconds(), None, "complete transfer has no ETA");
364    }
365
366    #[test]
367    fn zero_total_yields_no_eta() {
368        let start = Instant::now();
369        let mut est = RateEstimator::new(start);
370        est.record(0, 0, start);
371        drive(&mut est, start, 0, 50_000_000.0, 0.25, 40);
372        assert!(
373            est.rate_bps().is_some(),
374            "rate is known even without a total"
375        );
376        assert_eq!(est.eta_seconds(), None, "unknown total means no ETA");
377    }
378}