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