Skip to main content

grit_lib/progress/
estimate.rs

1//! One overall completion estimate across the phases of a fetch or clone.
2//!
3//! A clone runs as a sequence of phases — transfer the pack, index it, resolve
4//! its deltas, check out files — each with its own unit and total.
5//! [`OverallEstimator`] follows the same [`ProgressPhase`] events a sink sees
6//! and turns them into a single fraction and a time-left estimate: phases that
7//! are under way are projected from their own measured pace, and phases that
8//! have not started are sized from what is already known (the pack's object
9//! and delta counts) at typical [`PhaseRates`].
10//!
11//! The estimator never reads the clock; every call takes the time elapsed
12//! since the operation began.
13
14use std::time::Duration;
15
16use super::{ProgressPhase, ProgressUnit, ReceivedObjects};
17
18/// Typical throughput used to size phases that have not started yet.
19///
20/// Phases already under way are projected from their own measured pace, so
21/// these only shape the estimate until each phase begins.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct PhaseRates {
24    /// Objects indexed per second.
25    pub indexing: f64,
26    /// Deltas resolved per second.
27    pub resolving: f64,
28    /// Files checked out per second.
29    pub checkout: f64,
30}
31
32impl Default for PhaseRates {
33    /// Rates measured from `grit clone` of large public repositories on a
34    /// current laptop.
35    fn default() -> Self {
36        Self {
37            indexing: 250_000.0,
38            resolving: 300_000.0,
39            checkout: 10_000.0,
40        }
41    }
42}
43
44/// Share of deltas assumed in a pack whose delta count is not known yet.
45const ASSUMED_DELTA_SHARE: f64 = 0.7;
46/// Work-tree files assumed per pack object before checkout reports its total.
47const ASSUMED_FILES_PER_OBJECT: f64 = 0.015;
48/// Below this share of a phase done, its measured pace is too noisy to trust
49/// over the typical rate.
50const TRUST_MEASURED_AFTER: f64 = 0.1;
51/// The fraction reported while any phase is still ahead.
52const MAX_UNFINISHED_FRACTION: f64 = 0.99;
53
54/// A snapshot of overall progress.
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct OverallEstimate {
57    /// Share of the whole operation done, from `0.0` to `1.0`. It never goes
58    /// down between calls, and stays below `1.0` until every phase finished.
59    pub fraction: f64,
60    /// Estimated time until the operation finishes.
61    pub remaining: Duration,
62}
63
64/// The phases of a clone, in the order they run.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66enum Stage {
67    Transfer,
68    Indexing,
69    Resolving,
70    Checkout,
71}
72
73const STAGES: [Stage; 4] = [
74    Stage::Transfer,
75    Stage::Indexing,
76    Stage::Resolving,
77    Stage::Checkout,
78];
79
80impl Stage {
81    fn of(phase: ProgressPhase) -> Option<Self> {
82        match phase {
83            ProgressPhase::ReceivingObjects
84            | ProgressPhase::EnumeratingObjects
85            | ProgressPhase::CopyingObjects => Some(Self::Transfer),
86            ProgressPhase::IndexingObjects => Some(Self::Indexing),
87            ProgressPhase::ResolvingDeltas => Some(Self::Resolving),
88            ProgressPhase::CheckingOutFiles => Some(Self::Checkout),
89            // Shown by its own display; the estimate covers the fetch after it.
90            ProgressPhase::TransferringBundle => None,
91            // Not part of a clone.
92            ProgressPhase::StagingFiles => None,
93        }
94    }
95}
96
97#[derive(Debug, Clone, Copy)]
98struct Active {
99    stage: Stage,
100    /// Total for the phase in its own unit; `None` for a byte-counted receive.
101    total: Option<u64>,
102    current: u64,
103    started: Duration,
104}
105
106/// Combines per-phase progress into one overall estimate.
107///
108/// Phases under way are projected from their own measured pace; phases not
109/// yet started are sized from the pack's object and delta counts at typical
110/// [`PhaseRates`]. The estimator never reads the clock: every call takes the
111/// time elapsed since the operation began.
112///
113/// ```rust
114/// use std::time::Duration;
115/// use grit_lib::progress::{OverallEstimator, ProgressPhase, ProgressUnit, ReceivedObjects};
116///
117/// let secs = Duration::from_secs;
118/// let mut est = OverallEstimator::new(true);
119/// est.start(ProgressPhase::ReceivingObjects, ProgressUnit::Bytes, None, secs(1));
120/// est.received_objects(ReceivedObjects { received: 500, total: 1_000, deltas: 300 }, secs(3));
121/// let now = est.estimate(secs(3)).unwrap();
122/// assert!(now.fraction > 0.3 && now.fraction < 0.7);
123/// ```
124#[derive(Debug, Clone)]
125pub struct OverallEstimator {
126    rates: PhaseRates,
127    checkout: bool,
128    objects: Option<u64>,
129    deltas: Option<u64>,
130    files: Option<u64>,
131    received: Option<ReceivedObjects>,
132    active: Option<Active>,
133    /// The furthest stage that has finished.
134    finished: Option<Stage>,
135    best: f64,
136}
137
138impl OverallEstimator {
139    /// Create an estimator with the default [`PhaseRates`].
140    ///
141    /// # Parameters
142    ///
143    /// - `checkout`: whether a work-tree checkout follows the transfer (true
144    ///   for a non-bare clone, false for a fetch).
145    #[must_use]
146    pub fn new(checkout: bool) -> Self {
147        Self {
148            rates: PhaseRates::default(),
149            checkout,
150            objects: None,
151            deltas: None,
152            files: None,
153            received: None,
154            active: None,
155            finished: None,
156            best: 0.0,
157        }
158    }
159
160    /// Use `rates` to size phases that have not started.
161    #[must_use]
162    pub fn with_rates(mut self, rates: PhaseRates) -> Self {
163        self.rates = rates;
164        self
165    }
166
167    /// A phase began at `at`, counting `unit`s towards `total` when known.
168    ///
169    /// Starting a phase ends the previous one.
170    pub fn start(
171        &mut self,
172        phase: ProgressPhase,
173        unit: ProgressUnit,
174        total: Option<u64>,
175        at: Duration,
176    ) {
177        self.finish(at);
178        let Some(stage) = Stage::of(phase) else {
179            return;
180        };
181        let total = total.filter(|_| unit != ProgressUnit::Bytes);
182        match stage {
183            Stage::Transfer | Stage::Indexing => {
184                if total.is_some() {
185                    self.objects = total;
186                }
187            }
188            Stage::Resolving => self.deltas = total,
189            Stage::Checkout => self.files = total,
190        }
191        self.active = Some(Active {
192            stage,
193            total,
194            current: 0,
195            started: at,
196        });
197    }
198
199    /// The current phase reached `current` units.
200    pub fn set(&mut self, current: u64) {
201        if let Some(active) = &mut self.active {
202            active.current = current;
203        }
204    }
205
206    /// The current phase advanced by `units`.
207    pub fn inc(&mut self, units: u64) {
208        if let Some(active) = &mut self.active {
209            active.current = active.current.saturating_add(units);
210        }
211    }
212
213    /// The pack being received now holds `counts` objects.
214    ///
215    /// Sizes the later phases: the pack's object total drives indexing, and
216    /// the share of deltas seen so far projects how many will need resolving.
217    pub fn received_objects(&mut self, counts: ReceivedObjects, _at: Duration) {
218        self.objects = Some(counts.total);
219        if counts.received > 0 {
220            let share = counts.deltas as f64 / counts.received as f64;
221            self.deltas = Some((share * counts.total as f64).round() as u64);
222        }
223        self.received = Some(counts);
224    }
225
226    /// The current phase finished at `at`.
227    pub fn finish(&mut self, _at: Duration) {
228        if let Some(active) = self.active.take() {
229            self.finished = self.finished.max(Some(active.stage));
230        }
231    }
232
233    /// Overall progress at `at`, or `None` while there is not enough to go on
234    /// (before the transfer has reported any objects).
235    pub fn estimate(&mut self, at: Duration) -> Option<OverallEstimate> {
236        let remaining = self.remaining_secs(at)?;
237        let elapsed = at.as_secs_f64();
238        let all_done = self.active.is_none() && self.finished == Some(self.last_stage());
239        let raw = if all_done {
240            1.0
241        } else if elapsed + remaining <= 0.0 {
242            0.0
243        } else {
244            (elapsed / (elapsed + remaining)).min(MAX_UNFINISHED_FRACTION)
245        };
246        self.best = self.best.max(raw);
247        Some(OverallEstimate {
248            fraction: self.best,
249            remaining: Duration::from_secs_f64(remaining.max(0.0)),
250        })
251    }
252
253    fn last_stage(&self) -> Stage {
254        if self.checkout {
255            Stage::Checkout
256        } else {
257            Stage::Resolving
258        }
259    }
260
261    fn remaining_secs(&self, at: Duration) -> Option<f64> {
262        let mut total = 0.0;
263        for stage in STAGES {
264            if stage > self.last_stage() || Some(stage) <= self.finished {
265                continue;
266            }
267            match self.active {
268                Some(active) if active.stage == stage => total += self.active_secs(active, at)?,
269                // Started later than this stage, so this one was skipped.
270                Some(active) if active.stage > stage => {}
271                _ => total += self.pending_secs(stage)?,
272            }
273        }
274        Some(total)
275    }
276
277    /// Seconds left in the phase under way.
278    fn active_secs(&self, active: Active, at: Duration) -> Option<f64> {
279        let elapsed = at.saturating_sub(active.started).as_secs_f64();
280        let (done, total) = match (active.stage, active.total) {
281            (_, Some(total)) => (active.current, total),
282            (Stage::Transfer, None) => {
283                let counts = self.received?;
284                (counts.received, counts.total)
285            }
286            (_, None) => return self.pending_secs(active.stage),
287        };
288        let left = total.saturating_sub(done) as f64;
289        if left == 0.0 {
290            return Some(0.0);
291        }
292        let share = if total == 0 {
293            1.0
294        } else {
295            done as f64 / total as f64
296        };
297        let typical = self.rate(active.stage).map(|rate| left / rate);
298        if share >= TRUST_MEASURED_AFTER || typical.is_none() {
299            if done == 0 {
300                return None;
301            }
302            return Some(elapsed * left / done as f64);
303        }
304        typical
305    }
306
307    /// Seconds a phase that has not started is expected to take.
308    fn pending_secs(&self, stage: Stage) -> Option<f64> {
309        let objects = self.objects? as f64;
310        let units = match stage {
311            // Nothing predicts how fast the network will be.
312            Stage::Transfer => return None,
313            Stage::Indexing => objects,
314            Stage::Resolving => self
315                .deltas
316                .map_or(objects * ASSUMED_DELTA_SHARE, |d| d as f64),
317            Stage::Checkout => self
318                .files
319                .map_or(objects * ASSUMED_FILES_PER_OBJECT, |f| f as f64),
320        };
321        Some(units / self.rate(stage)?)
322    }
323
324    fn rate(&self, stage: Stage) -> Option<f64> {
325        let rate = match stage {
326            Stage::Transfer => return None,
327            Stage::Indexing => self.rates.indexing,
328            Stage::Resolving => self.rates.resolving,
329            Stage::Checkout => self.rates.checkout,
330        };
331        (rate > 0.0).then_some(rate)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn secs(s: f64) -> Duration {
340        Duration::from_secs_f64(s)
341    }
342
343    fn rates() -> PhaseRates {
344        PhaseRates {
345            indexing: 1_000.0,
346            resolving: 1_000.0,
347            checkout: 100.0,
348        }
349    }
350
351    fn receiving(est: &mut OverallEstimator, at: f64) {
352        est.start(
353            ProgressPhase::ReceivingObjects,
354            ProgressUnit::Bytes,
355            None,
356            secs(at),
357        );
358    }
359
360    #[test]
361    fn no_estimate_until_objects_are_counted() {
362        let mut est = OverallEstimator::new(true);
363        assert!(est.estimate(secs(1.0)).is_none());
364        receiving(&mut est, 1.0);
365        est.inc(4096);
366        assert!(
367            est.estimate(secs(2.0)).is_none(),
368            "bytes alone are not enough"
369        );
370    }
371
372    #[test]
373    fn receive_projects_its_pace_and_sizes_later_phases() {
374        let mut est = OverallEstimator::new(true).with_rates(rates());
375        receiving(&mut est, 0.0);
376        // Half of 2,000 objects in 4s; 60% deltas so far.
377        est.received_objects(
378            ReceivedObjects {
379                received: 1_000,
380                total: 2_000,
381                deltas: 600,
382            },
383            secs(4.0),
384        );
385        let e = est.estimate(secs(4.0)).unwrap();
386        // 4s more receiving + 2s indexing + 1.2s deltas + 0.3s checkout.
387        assert!(
388            (e.remaining.as_secs_f64() - 7.5).abs() < 1e-6,
389            "{:?}",
390            e.remaining
391        );
392        assert!((e.fraction - 4.0 / 11.5).abs() < 1e-6, "{}", e.fraction);
393    }
394
395    #[test]
396    fn active_phase_uses_measured_pace_once_trustworthy() {
397        let mut est = OverallEstimator::new(false).with_rates(rates());
398        est.start(
399            ProgressPhase::IndexingObjects,
400            ProgressUnit::Objects,
401            Some(1_000),
402            secs(10.0),
403        );
404        // Early on, the typical rate wins: 990 left at 1,000/s.
405        est.set(10);
406        let early = est.estimate(secs(10.5)).unwrap();
407        assert!((early.remaining.as_secs_f64() - (0.99 + 0.7)).abs() < 1e-6);
408        // At 50% after 2s, 2s more indexing at the measured pace.
409        est.set(500);
410        let later = est.estimate(secs(12.0)).unwrap();
411        assert!((later.remaining.as_secs_f64() - (2.0 + 0.7)).abs() < 1e-6);
412    }
413
414    #[test]
415    fn known_totals_replace_assumptions() {
416        let mut est = OverallEstimator::new(true).with_rates(rates());
417        est.start(
418            ProgressPhase::ResolvingDeltas,
419            ProgressUnit::Objects,
420            Some(0),
421            secs(1.0),
422        );
423        est.finish(secs(1.0));
424        est.start(
425            ProgressPhase::CheckingOutFiles,
426            ProgressUnit::Files,
427            Some(200),
428            secs(1.0),
429        );
430        // Earlier stages count as done; 200 files at 100/s.
431        let e = est.estimate(secs(1.0)).unwrap();
432        assert!((e.remaining.as_secs_f64() - 2.0).abs() < 1e-6);
433    }
434
435    #[test]
436    fn fraction_never_goes_backwards_and_reaches_one_when_done() {
437        let mut est = OverallEstimator::new(false).with_rates(rates());
438        receiving(&mut est, 0.0);
439        est.received_objects(
440            ReceivedObjects {
441                received: 900,
442                total: 1_000,
443                deltas: 0,
444            },
445            secs(9.0),
446        );
447        let high = est.estimate(secs(9.0)).unwrap().fraction;
448        // The last tenth stalls: the projection gets worse, the bar holds.
449        let stalled = est.estimate(secs(30.0)).unwrap().fraction;
450        assert!(stalled >= high);
451        est.start(
452            ProgressPhase::IndexingObjects,
453            ProgressUnit::Objects,
454            Some(1_000),
455            secs(30.0),
456        );
457        est.set(1_000);
458        est.start(
459            ProgressPhase::ResolvingDeltas,
460            ProgressUnit::Objects,
461            Some(0),
462            secs(31.0),
463        );
464        let unfinished = est.estimate(secs(31.0)).unwrap().fraction;
465        assert!(unfinished < 1.0);
466        est.finish(secs(31.0));
467        let done = est.estimate(secs(31.0)).unwrap();
468        assert_eq!(done.fraction, 1.0);
469        assert_eq!(done.remaining, Duration::ZERO);
470    }
471
472    #[test]
473    fn local_copy_with_known_total_is_estimated() {
474        let mut est = OverallEstimator::new(false).with_rates(rates());
475        est.start(
476            ProgressPhase::CopyingObjects,
477            ProgressUnit::Objects,
478            Some(100),
479            secs(0.0),
480        );
481        est.set(50);
482        let e = est.estimate(secs(1.0)).unwrap();
483        // 1s more copying + 0.1s indexing + 0.07s deltas.
484        assert!((e.remaining.as_secs_f64() - 1.17).abs() < 1e-6, "{e:?}");
485    }
486}