Skip to main content

hashiverse_lib/tools/
pow_required_estimator.rs

1//! # Progress reporting and ETA estimation for long PoW searches
2//!
3//! A book-keeping helper for [`crate::tools::pow_generator`]: tracks how many
4//! attempts have been made across repeated batches, what the best-so-far `pow` is, and
5//! produces a human-readable log line including an ETA.
6//!
7//! Because PoW search is memoryless (geometric), past attempts give no information about
8//! how many attempts remain — the *expected remaining* is always `2^pow_required` no
9//! matter how long you've already been trying. The estimator uses rate of attempts per
10//! second to turn that into wall-clock seconds. For a geometric distribution the standard
11//! deviation equals the mean, so "30s ± 30s" ETAs are the norm; this is an honest report,
12//! not a bug.
13//!
14//! The `best_pow_so_far` field doubles as a sanity check: after `n` attempts the
15//! expected maximum leading-zero count is `log2(n) - 0.83`. A value wildly below that
16//! suggests a broken RNG or hash chain.
17
18use crate::tools::time::{DurationMillis, TimeMillis, MILLIS_IN_MILLISECOND, MILLIS_IN_SECOND};
19use crate::tools::types::Pow;
20
21/// Tracks progress across repeated chunks inside
22/// [`crate::tools::pow_generator::pow_generator::run_pool`] and produces a log-friendly
23/// ETA estimate.
24///
25/// PoW search is a memoryless geometric process: the *expected remaining* attempts
26/// is always `2^pow_required` regardless of how many have already been tried.
27/// Consequently:
28///   ETA_remaining = 2^pow_required / rate
29///   std_deviation  = 2^pow_required / rate  (equals the mean for a geometric distribution)
30///
31/// We deliberately do *not* subtract elapsed: doing so would make ETA go negative on
32/// unlucky runs that have already exceeded their expectation, which contradicts the
33/// memoryless property.
34///
35/// `best_pow_so_far` is a sanity check: after `n` iterations the expected maximum
36/// leading-zero count is `log2(n) - 0.83`.  A value far below that suggests a
37/// broken RNG or hash chain.
38pub struct PowRequiredEstimator {
39    description: String,
40    pow_required: Pow,
41    total_iterations: usize,
42    best_pow_so_far: Pow,
43    started_at_millis: TimeMillis,
44    next_report_millis: TimeMillis,
45}
46
47const REPORTING_PERIOD_MILLIS: DurationMillis = MILLIS_IN_SECOND.const_mul(1);
48
49impl PowRequiredEstimator {
50    pub fn new(started_at_millis: TimeMillis, description: &str, pow_required: Pow) -> Self {
51        Self {
52            description: description.to_string(),
53            pow_required,
54            total_iterations: 0,
55            best_pow_so_far: Pow(0),
56            started_at_millis,
57            next_report_millis: started_at_millis + REPORTING_PERIOD_MILLIS,
58        }
59    }
60
61    fn report_large_number(num: usize) -> String {
62        let num = num as f64;
63
64        if num > 1e9 {
65            return format!("{:.2}B", num / 1e9);
66        }
67        if num > 1e6 {
68            return format!("{:.2}M", num / 1e6);
69        }
70        if num > 1e3 {
71            return format!("{:.2}k", num / 1e3);
72        }
73
74        format!("{}", num)
75    }
76
77    /// Record the results of one batch and return a progress string suitable for logging.
78    pub fn record_batch_and_estimate(&mut self, current_time_millis: TimeMillis, iterations_in_batch: usize, best_pow_in_batch: Pow) -> Option<String> {
79        self.total_iterations += iterations_in_batch;
80        if best_pow_in_batch > self.best_pow_so_far {
81            self.best_pow_so_far = best_pow_in_batch;
82        }
83
84        // Throttle reporting
85        if current_time_millis < self.next_report_millis {
86            return None;
87        }
88        self.next_report_millis = current_time_millis + REPORTING_PERIOD_MILLIS;
89
90        let elapsed_duration_millis = current_time_millis - self.started_at_millis;
91
92        if elapsed_duration_millis < MILLIS_IN_MILLISECOND || self.total_iterations == 0 {
93            return Some(format!(
94                "{}: PoW {}/{} bits | {} | {} iters | too early to estimate",
95                self.description,
96                self.best_pow_so_far.0,
97                self.pow_required.0,
98                elapsed_duration_millis,
99                Self::report_large_number(self.total_iterations)
100            ));
101        }
102
103        let iterations_per_second = 1000.0 * self.total_iterations as f64 / elapsed_duration_millis.0 as f64;
104        // Memoryless: expected remaining attempts is always 2^pow_required, independent of
105        // attempts already made. Do NOT subtract elapsed — that would drive ETA negative for
106        // unlucky runs past their expectation.
107        let expected_total_iterations = (2.0f64).powi(self.pow_required.0 as i32);
108        let expected_remaining_duration = DurationMillis((1000.0 * expected_total_iterations / iterations_per_second) as i64);
109        // Std deviation of a geometric distribution equals its mean
110        let eta_one_sigma = expected_remaining_duration;
111        let progress_pct = self.total_iterations as f64 / expected_total_iterations * 100.0;
112
113        Some(format!(
114            "{}: PoW {}/{} bits | {} | {} iters | {}/s | {:.1}% of expected | ETA ~{} \u{00b1}{}",
115            self.description,
116            self.best_pow_so_far.0,
117            self.pow_required.0,
118            elapsed_duration_millis,
119            Self::report_large_number(self.total_iterations),
120            Self::report_large_number(iterations_per_second as usize),
121            progress_pct,
122            expected_remaining_duration,
123            eta_one_sigma,
124        ))
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn make_estimator() -> PowRequiredEstimator {
133        PowRequiredEstimator::new(TimeMillis(0), "test", Pow(24))
134    }
135
136    #[test]
137    fn record_batch_accumulates_iterations_and_tracks_best_pow() {
138        let mut estimator = make_estimator();
139
140        estimator.record_batch_and_estimate(TimeMillis(100), 1024, Pow(10));
141        assert_eq!(estimator.total_iterations, 1024);
142        assert_eq!(estimator.best_pow_so_far, Pow(10));
143
144        estimator.record_batch_and_estimate(TimeMillis(200), 1024, Pow(8)); // worse — should not update best
145        assert_eq!(estimator.total_iterations, 2048);
146        assert_eq!(estimator.best_pow_so_far, Pow(10));
147
148        estimator.record_batch_and_estimate(TimeMillis(300), 1024, Pow(15)); // better — should update best
149        assert_eq!(estimator.total_iterations, 3072);
150        assert_eq!(estimator.best_pow_so_far, Pow(15));
151    }
152
153    #[test]
154    fn progress_string_contains_key_fields() {
155        let mut estimator = make_estimator();
156        let output = estimator.record_batch_and_estimate(TimeMillis(1000), 65536, Pow(18));
157        assert!(output.is_some());
158
159        let output = output.unwrap();
160        assert!(output.contains("test"), "should include description: {}", output);
161        assert!(output.contains("18/24"), "should show best/required bits: {}", output);
162        assert!(output.contains("1s"), "should show elapsed time: {}", output);
163        assert!(output.contains("65.54"), "should show iteration count: {}", output);
164        assert!(output.contains("ETA"), "should show ETA: {}", output);
165    }
166
167    #[test]
168    fn progress_string_before_any_elapsed_time() {
169        let mut estimator = make_estimator();
170        let output = estimator.record_batch_and_estimate(TimeMillis(100), 1024, Pow(5));
171        assert!(output.is_none());
172    }
173
174    #[test]
175    fn eta_stays_non_negative_past_expected_iterations() {
176        // pow_required = 4 -> expected total iterations = 16. Push well past that to simulate
177        // an unlucky run; the memoryless ETA must still be non-negative.
178        let mut estimator = PowRequiredEstimator::new(TimeMillis(0), "test", Pow(4));
179
180        let output = estimator
181            .record_batch_and_estimate(TimeMillis(1000), 100, Pow(3))
182            .expect("should emit a report once the reporting period has elapsed");
183
184        let after_eta = output.split("ETA ~").nth(1).expect("output should contain ETA marker");
185        let eta_str = after_eta.split(' ').next().expect("ETA value should be the first token after the marker");
186        let eta = DurationMillis::parse(eta_str).expect("ETA value should parse as a DurationMillis");
187
188        assert!(eta.0 >= 0, "ETA must be non-negative past expected iterations; got {:?} in {:?}", eta_str, output);
189    }
190}