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}