Skip to main content

hashiverse_lib/tools/pow_generator/
pow_generator.rs

1//! Trait, observability primitives, and shared work-stealing dispatcher for the PoW search engine.
2//!
3//! See [`crate::tools::pow_generator`] for the broader module overview. This file holds:
4//!
5//! - [`PowGenerator`] — the trait every concrete generator implements. Backends only have to
6//!   provide [`PowGenerator::pool_size`] and [`PowGenerator::run_chunk`]; the shared
7//!   dispatcher does the rest.
8//! - [`JobTracker`] + [`PowJobStatus`] — the in-flight job registry surfaced via
9//!   `PowGenerator::active_jobs()` so the UI can show users why an action is slow.
10//! - [`run_pool`] — the one shared work-stealing dispatcher both [`PowGenerator::generate`]
11//!   and [`PowGenerator::generate_best_effort`] use. Tracks the job, refeeds each pool slot
12//!   independently as it completes, and returns the instant any chunk meets `pow_min`
13//!   (discarding any in-flight chunks).
14
15use crate::tools::pow::pow_measure_from_data_hash;
16use crate::tools::pow_required_estimator::PowRequiredEstimator;
17use crate::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
18use crate::tools::types::{Hash, Pow, Salt};
19use futures::future::Either;
20use futures::stream::{FuturesUnordered, StreamExt};
21use log::trace;
22use std::collections::HashMap;
23use std::future::Future;
24use std::pin::Pin;
25use std::sync::{Arc, Mutex};
26use tokio_util::sync::CancellationToken;
27
28pub struct PowJobStatus {
29    pub label: String,
30    pub pow_min: Pow,
31    pub best_pow_so_far: Pow,
32}
33
34type JobId = u64;
35
36struct JobEntry {
37    label: String,
38    pow_min: Pow,
39    best_pow_so_far: Pow,
40}
41
42#[derive(Default)]
43pub struct JobTracker {
44    next_id: JobId,
45    jobs: HashMap<JobId, JobEntry>,
46    /// Wall-clock millis of the most recent moment a job was registered or removed. Lets a poll
47    /// detect work that started and finished entirely between two polls (0 = no work ever seen).
48    last_work_time_millis: i64,
49}
50
51impl JobTracker {
52    pub fn add(&mut self, label: &str, pow_min: Pow) -> JobId {
53        let job_id = self.next_id;
54        self.next_id += 1;
55        self.jobs.insert(job_id, JobEntry { label: label.to_string(), pow_min, best_pow_so_far: Pow(0) });
56        job_id
57    }
58
59    pub fn update(&mut self, job_id: JobId, best_pow_so_far: Pow) {
60        if let Some(entry) = self.jobs.get_mut(&job_id) {
61            entry.best_pow_so_far = best_pow_so_far;
62        }
63    }
64
65    pub fn remove(&mut self, job_id: JobId) {
66        self.jobs.remove(&job_id);
67    }
68
69    /// Stamp the most-recent-work time. Called whenever a job starts or finishes.
70    pub fn mark_work(&mut self, now_millis: i64) {
71        self.last_work_time_millis = now_millis;
72    }
73
74    /// Whether there is background PoW work happening right now, or there was within the last
75    /// `within_millis`. The latter window lets a once-a-second UI poll still light up for bursts
76    /// that began and ended between polls.
77    pub fn is_busy(&self, now_millis: i64, within_millis: i64) -> bool {
78        !self.jobs.is_empty() || (self.last_work_time_millis != 0 && now_millis - self.last_work_time_millis <= within_millis)
79    }
80
81    pub fn snapshot(&self) -> Vec<PowJobStatus> {
82        self.jobs.values().map(|entry| PowJobStatus {
83            label: entry.label.clone(),
84            pow_min: entry.pow_min,
85            best_pow_so_far: entry.best_pow_so_far,
86        }).collect()
87    }
88}
89
90/// RAII guard that registers a job on construction and removes it on drop.
91/// Guarantees cleanup on normal return, `?` propagation, panic, and future cancellation.
92struct TrackedJobGuard {
93    tracker: Arc<Mutex<JobTracker>>,
94    job_id: JobId,
95}
96
97impl TrackedJobGuard {
98    fn new(tracker: Arc<Mutex<JobTracker>>, label: &str, pow_min: Pow) -> Self {
99        let now = RealTimeProvider.current_time_millis().0;
100        let job_id = {
101            let mut tracker = tracker.lock().unwrap();
102            let job_id = tracker.add(label, pow_min);
103            tracker.mark_work(now);
104            job_id
105        };
106        Self { tracker, job_id }
107    }
108
109    fn update(&self, best_pow_so_far: Pow) {
110        self.tracker.lock().unwrap().update(self.job_id, best_pow_so_far);
111    }
112}
113
114impl Drop for TrackedJobGuard {
115    fn drop(&mut self) {
116        // Stamp on removal too: this is what keeps `is_busy` true for the brief window after the
117        // last job finishes, so a once-a-second poll doesn't miss work that just completed.
118        let now = RealTimeProvider.current_time_millis().0;
119        let mut tracker = self.tracker.lock().unwrap();
120        tracker.remove(self.job_id);
121        tracker.mark_work(now);
122    }
123}
124
125/// A pluggable engine for searching for proof-of-work solutions.
126///
127/// Proof-of-work is required on every RPC packet, on peer announcements, and on report /
128/// feedback submissions, so finding PoW is on the hot path for every outbound action a client
129/// or server takes. `PowGenerator` abstracts over the concrete way we search for PoW so the
130/// calling code stays platform-agnostic:
131///
132/// - [`crate::tools::pow_generator::native_parallel_pow_generator::NativeParallelPowGenerator`]
133///   runs chunks via `tokio::task::spawn_blocking`, one per CPU.
134/// - [`crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator`]
135///   runs chunks serially. Works on every target including WASM and is used as the fallback in
136///   tests and the in-browser default before workers are wired up.
137/// - `WasmParallelPowGenerator` (in `hashiverse-client-wasm`) dispatches chunks to pre-spawned
138///   Web Workers, one per worker.
139///
140/// Backends only have to answer two questions — how many chunks may run in parallel
141/// ([`Self::pool_size`]) and how to run one chunk on a given slot ([`Self::run_chunk`]) — and
142/// the shared [`run_pool`] dispatcher handles the rest: work-stealing refeed of fast slots,
143/// early exit the moment `pow_min` is met, and per-chunk cooperative yield. This is what
144/// keeps heterogeneous CPUs (Apple Silicon P+E, Intel hybrid, ARM big.LITTLE) from
145/// collapsing to slow-core throughput.
146///
147/// Implementations must also maintain the `active_jobs()` observability view — the UI
148/// surfaces in-progress PoW searches to end users so they understand why an action is slow.
149#[async_trait::async_trait]
150pub trait PowGenerator: Send + Sync {
151    /// How many `run_chunk` calls the dispatcher may have in flight concurrently.
152    /// `WasmParallelPowGenerator` returns its `workers.len()`; the native backend returns
153    /// the CPU count; the single-threaded backend returns 1.
154    fn pool_size(&self) -> usize;
155
156    /// Run `chunk_iterations` PoW attempts on one parallel slot. `slot` is an opaque index
157    /// in `0..pool_size()` — the wasm backend uses it to address `self.workers[slot]`; the
158    /// native and single-threaded backends ignore it. May short-circuit inside the chunk
159    /// when `pow_min` is reached (`pow_compute_batch` already does this in
160    /// `hashiverse-client-wasm/src/lib.rs`).
161    async fn run_chunk(&self, slot: usize, chunk_iterations: usize, pow_min: Pow, data_hash: Hash) -> anyhow::Result<(Salt, Pow, Hash)>;
162
163    /// Accessor for the impl's `JobTracker`. Used by the default `generate` /
164    /// `generate_best_effort` impls to register the in-flight job, and by `active_jobs()`
165    /// to snapshot it for the UI.
166    fn tracker(&self) -> &Arc<Mutex<JobTracker>>;
167
168    /// Optional token that aborts every search on this generator once cancelled: `generate` and
169    /// `generate_best_effort` then return an error, checked between chunks. Lets a process stop
170    /// promptly mid-search (e.g. a server stopped during its hours-long identity PoW) without
171    /// threading a token through every caller. Default: no token, never cancelled.
172    fn cancellation_token(&self) -> Option<&CancellationToken> {
173        None
174    }
175
176    /// Snapshot of all concurrently in-flight tracked jobs.
177    fn active_jobs(&self) -> Vec<PowJobStatus> {
178        self.tracker().lock().unwrap().snapshot()
179    }
180
181    /// Whether PoW work is happening now, or finished within the last `within_millis`. Drives the
182    /// UI "busy" indicator. Stamp and query both go through `RealTimeProvider` so they share one
183    /// wall-clock source (matching `run_pool`).
184    fn is_pow_busy(&self, within_millis: i64) -> bool {
185        let now = RealTimeProvider.current_time_millis().0;
186        self.tracker().lock().unwrap().is_busy(now, within_millis)
187    }
188
189    /// Run up to `iteration_limit` attempts via the work-stealing pool. Registers the job
190    /// in the tracker at entry. Returns the moment any chunk produces `pow >= pow_min`
191    /// (discarding chunks still in flight); otherwise returns the best result found
192    /// within `iteration_limit`.
193    async fn generate_best_effort(&self, label: &str, iteration_limit: usize, pow_min: Pow, data_hash: Hash) -> anyhow::Result<(Salt, Pow, Hash)> {
194        run_pool(self, label, Some(iteration_limit), pow_min, data_hash).await
195    }
196
197    /// Run the pool unbounded until `pow >= pow_min` is found. Registers the job in the
198    /// tracker at entry.
199    async fn generate(&self, label: &str, pow_min: Pow, data_hash: Hash) -> anyhow::Result<(Salt, Pow, Hash)> {
200        run_pool(self, label, None, pow_min, data_hash).await
201    }
202}
203
204/// Per-chunk grain size for the dispatcher. Smaller = less wasted work on early-exit and
205/// finer cooperative yield, but more per-chunk dispatch overhead. With ~7 chained hashes per
206/// attempt, 4K iterations is a few tens to a couple hundred ms per chunk on commodity
207/// hardware — small enough that a slow-core chunk finishing after a winner is found wastes
208/// at most one chunk-time of background CPU per pool slot.
209const CHUNK_ITERATIONS: usize = 4 * 1024;
210
211type SlotFuture<'a> = Pin<Box<dyn Future<Output = (usize, usize, anyhow::Result<(Salt, Pow, Hash)>)> + Send + 'a>>;
212
213/// Work-stealing dispatcher shared by both `generate` and `generate_best_effort`.
214///
215/// Algorithm:
216/// 1. Register the job in the tracker (RAII so panic / cancel still cleans up).
217/// 2. Initial fill: push one chunk onto each of `pool_size()` slots, clamped by
218///    `iteration_cap` when bounded.
219/// 3. As each chunk completes, merge its best result. If `pow_min` is met, return
220///    immediately — the `FuturesUnordered` is dropped, discarding any in-flight chunks
221///    (the underlying worker / blocking thread continues silently and its result is lost).
222/// 4. Otherwise, update the tracker / progress estimator, cooperatively yield, and refeed
223///    only the slot that just freed up. Fast slots end up processing more chunks than slow
224///    ones — no idle waiting on the slowest core.
225pub async fn run_pool<'a, G: PowGenerator + ?Sized>(
226    generator: &'a G,
227    label: &'a str,
228    iteration_cap: Option<usize>,
229    pow_min: Pow,
230    data_hash: Hash,
231) -> anyhow::Result<(Salt, Pow, Hash)> {
232    let cancellation_token = generator.cancellation_token();
233    if cancellation_token.is_some_and(|cancellation_token| cancellation_token.is_cancelled()) {
234        anyhow::bail!("proof-of-work '{}' cancelled", label);
235    }
236
237    let tracker = generator.tracker().clone();
238    let guard = TrackedJobGuard::new(tracker, label, pow_min);
239
240    let real_time_provider = RealTimeProvider;
241    let mut estimator = PowRequiredEstimator::new(real_time_provider.current_time_millis(), label, pow_min);
242
243    let pool_size = generator.pool_size().max(1);
244    let mut remaining_iterations: Option<usize> = iteration_cap;
245
246    // Seed `best` with one real PoW sample so the in-flight loop's strict `>` check is
247    // always meaningful (no zero-init placeholder to distinguish from a real Pow(0)
248    // chunk). One synchronous measurement on the dispatcher thread is cheap and
249    // guarantees the returned (salt, pow) pair satisfies pow_measure(salt) == pow.
250    // For iteration_cap == Some(0) callers, we still do this one seed PoW — it's the
251    // minimum work that yields a self-consistent result.
252    let mut best = {
253        let seed_salt = Salt::random();
254        let (seed_pow, seed_hash) = pow_measure_from_data_hash(&data_hash, &seed_salt)?;
255        (seed_salt, seed_pow, seed_hash)
256    };
257
258    guard.update(best.1);
259
260    // Were we lucky?
261    if best.1 >= pow_min {
262        return Ok(best);
263    }
264
265    let mut in_flight: FuturesUnordered<SlotFuture<'a>> = FuturesUnordered::new();
266
267    // Kick off the threads
268    for slot in 0..pool_size {
269        let chunk_size = pick_next_chunk_size(&mut remaining_iterations);
270        if chunk_size == 0 {
271            break;
272        }
273
274        in_flight.push(Box::pin(async move {
275            let chunk_result = generator.run_chunk(slot, chunk_size, pow_min, data_hash).await;
276            (slot, chunk_size, chunk_result)
277        }));
278    }
279
280    // Then, as each thread finishes, see if there is more work to do and repeat...
281    loop {
282        // Wait for the next chunk, or for cancellation. On cancellation, returning drops
283        // `in_flight`, discarding the outstanding chunks exactly as the early-exit path does.
284        let next_chunk = match cancellation_token {
285            Some(cancellation_token) => {
286                let cancelled = std::pin::pin!(cancellation_token.cancelled());
287                match futures::future::select(in_flight.next(), cancelled).await {
288                    Either::Left((next_chunk, _)) => next_chunk,
289                    Either::Right(_) => anyhow::bail!("proof-of-work '{}' cancelled", label),
290                }
291            }
292            None => in_flight.next().await,
293        };
294        let Some((slot, chunk_size, chunk_result)) = next_chunk else {
295            break;
296        };
297
298        let chunk_best = chunk_result?;
299        if chunk_best.1 > best.1 {
300            best = chunk_best;
301            guard.update(best.1);
302        }
303        if best.1 >= pow_min {
304            return Ok(best);
305        }
306        if let Some(progress) = estimator.record_batch_and_estimate(real_time_provider.current_time_millis(), chunk_size, best.1) {
307            trace!("{}", progress);
308        }
309
310        let next_chunk_size = pick_next_chunk_size(&mut remaining_iterations);
311        if next_chunk_size == 0 {
312            continue;
313        }
314        
315        in_flight.push(Box::pin(async move {
316            let chunk_result = generator.run_chunk(slot, next_chunk_size, pow_min, data_hash).await;
317            (slot, next_chunk_size, chunk_result)
318        }));
319    }
320
321    Ok(best)
322}
323
324fn pick_next_chunk_size(remaining_iterations: &mut Option<usize>) -> usize {
325    match remaining_iterations {
326        Some(0) => 0,
327        Some(remaining) => {
328            let chunk_size = (*remaining).min(CHUNK_ITERATIONS);
329            *remaining -= chunk_size;
330            chunk_size
331        }
332        None => CHUNK_ITERATIONS,
333    }
334}
335
336pub fn run_pool_chunk(chunk_iterations: usize, pow_min: Pow, data_hash: Hash) -> anyhow::Result<(Salt, Pow, Hash)> {
337    let mut best = {
338        let salt = Salt::random();
339        let (pow, hash) = pow_measure_from_data_hash(&data_hash, &salt)?;
340        (salt, pow, hash)
341    };
342
343    if best.1 >= pow_min {
344        return Ok(best);
345    }
346
347    for _ in 1..chunk_iterations {
348        let salt = Salt::random();
349        let (pow, hash) = pow_measure_from_data_hash(&data_hash, &salt)?;
350        if pow > best.1 {
351            best = (salt, pow, hash);
352            if best.1 >= pow_min {
353                return Ok(best);
354            }
355        }
356    }
357
358    Ok(best)
359}
360
361#[cfg(test)]
362mod tests {
363    use crate::tools::pow_generator::pow_generator::{JobTracker, TrackedJobGuard};
364    use crate::tools::types::Pow;
365    use std::sync::{Arc, Mutex};
366
367    #[test]
368    fn job_tracker_round_trip() {
369        let mut tracker = JobTracker::default();
370        assert!(tracker.snapshot().is_empty());
371
372        let job_a = tracker.add("rpc", Pow(18));
373        let job_b = tracker.add("post", Pow(22));
374
375        tracker.update(job_a, Pow(7));
376        tracker.update(job_b, Pow(13));
377        tracker.update(99999, Pow(255)); // unknown job_id is silently ignored
378
379        let mut snapshot = tracker.snapshot();
380        snapshot.sort_by(|a, b| a.label.cmp(&b.label));
381        assert_eq!(snapshot.len(), 2);
382        assert_eq!(snapshot[0].label, "post");
383        assert_eq!(snapshot[0].pow_min, Pow(22));
384        assert_eq!(snapshot[0].best_pow_so_far, Pow(13));
385        assert_eq!(snapshot[1].label, "rpc");
386        assert_eq!(snapshot[1].pow_min, Pow(18));
387        assert_eq!(snapshot[1].best_pow_so_far, Pow(7));
388
389        tracker.remove(job_a);
390        let remaining = tracker.snapshot();
391        assert_eq!(remaining.len(), 1);
392        assert_eq!(remaining[0].label, "post");
393
394        tracker.remove(job_b);
395        assert!(tracker.snapshot().is_empty());
396    }
397
398    #[test]
399    fn tracked_job_guard_removes_on_drop() {
400        let tracker = Arc::new(Mutex::new(JobTracker::default()));
401        {
402            let _guard = TrackedJobGuard::new(tracker.clone(), "rpc", Pow(18));
403            assert_eq!(tracker.lock().unwrap().snapshot().len(), 1);
404        }
405        assert!(tracker.lock().unwrap().snapshot().is_empty());
406    }
407
408    #[test]
409    fn tracked_job_guard_update_writes_through() {
410        let tracker = Arc::new(Mutex::new(JobTracker::default()));
411        let guard = TrackedJobGuard::new(tracker.clone(), "rpc", Pow(18));
412        guard.update(Pow(42));
413        let snapshot = tracker.lock().unwrap().snapshot();
414        assert_eq!(snapshot.len(), 1);
415        assert_eq!(snapshot[0].label, "rpc");
416        assert_eq!(snapshot[0].pow_min, Pow(18));
417        assert_eq!(snapshot[0].best_pow_so_far, Pow(42));
418    }
419
420    #[test]
421    fn is_busy_tracks_active_jobs_and_recent_work_window() {
422        let mut tracker = JobTracker::default();
423
424        // Nothing has ever happened: never busy, no matter the window.
425        assert!(!tracker.is_busy(1_000_000, 1000));
426
427        // While a job is registered, busy regardless of the time window.
428        let job = tracker.add("rpc", Pow(18));
429        tracker.mark_work(1_000_000);
430        assert!(tracker.is_busy(1_000_000, 1000));
431        assert!(tracker.is_busy(9_999_999, 1000)); // active jobs win even if the stamp is ancient
432
433        // After the job finishes we stamp the finish time; busy stays true only within the window.
434        tracker.remove(job);
435        tracker.mark_work(2_000_000);
436        assert!(tracker.is_busy(2_000_500, 1000)); // 500ms later → still within the 1s window
437        assert!(tracker.is_busy(2_001_000, 1000)); // exactly 1s later → boundary is inclusive
438        assert!(!tracker.is_busy(2_001_500, 1000)); // 1.5s later → window elapsed, idle
439    }
440
441    #[test]
442    fn tracked_job_guard_marks_work_via_real_clock() {
443        use crate::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
444
445        let tracker = Arc::new(Mutex::new(JobTracker::default()));
446
447        // While the guard is alive there's an active job, so busy holds regardless of window.
448        {
449            let _guard = TrackedJobGuard::new(tracker.clone(), "rpc", Pow(18));
450            assert!(tracker.lock().unwrap().is_busy(0, 0));
451        }
452
453        // The guard stamped the (real) finish time on drop. So a query at "now" with a 1s window is
454        // busy (work just happened), and a query well into the future is idle.
455        assert!(tracker.lock().unwrap().snapshot().is_empty());
456        let now = RealTimeProvider.current_time_millis().0;
457        assert!(tracker.lock().unwrap().is_busy(now, 1000));
458        assert!(!tracker.lock().unwrap().is_busy(now + 60_000, 1000));
459    }
460
461    #[tokio::test]
462    async fn run_pool_returns_consistent_sample_when_iteration_limit_is_zero() {
463        use crate::tools::pow::{pow_compute_data_hash, pow_measure_from_data_hash};
464        use crate::tools::pow_generator::pow_generator::PowGenerator;
465        use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
466        use crate::tools::types::Pow;
467
468        // iteration_cap=0 means "no chunked work", but run_pool still does the one seed PoW
469        // so the returned (salt, pow) is always self-consistent under pow_measure.
470        let data_hash = pow_compute_data_hash(&[b"zero-budget"]);
471        let generator = SingleThreadedPowGenerator::new();
472        let (salt, achieved_pow, _) = generator.generate_best_effort("zero", 0, Pow(255), data_hash).await.unwrap();
473        let (recomputed_pow, _) = pow_measure_from_data_hash(&data_hash, &salt).unwrap();
474        assert_eq!(recomputed_pow, achieved_pow);
475    }
476
477    #[tokio::test]
478    async fn run_pool_tracker_clears_after_completion() {
479        use crate::tools::pow::pow_compute_data_hash;
480        use crate::tools::pow_generator::pow_generator::PowGenerator;
481        use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
482        use crate::tools::types::Pow;
483
484        let data_hash = pow_compute_data_hash(&[b"tracker-cleanup"]);
485        let generator = SingleThreadedPowGenerator::new();
486        // Pow(0) succeeds on the first attempt → fast.
487        let _ = generator.generate("clean", Pow(0), data_hash).await.unwrap();
488        assert!(generator.active_jobs().is_empty());
489    }
490
491    #[tokio::test]
492    async fn run_pool_returns_as_soon_as_pow_min_is_met() {
493        use crate::tools::pow::pow_compute_data_hash;
494        use crate::tools::pow_generator::pow_generator::PowGenerator;
495        use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
496        use crate::tools::types::Pow;
497
498        const POW_MIN: Pow = Pow(8);
499        let data_hash = pow_compute_data_hash(&[b"early-exit"]);
500        let generator = SingleThreadedPowGenerator::new();
501        let (_, achieved_pow, _) = generator.generate("early", POW_MIN, data_hash).await.unwrap();
502        assert!(achieved_pow >= POW_MIN);
503    }
504
505    /// Regression: with `pow_min = 0` and a tiny chunk, the sampled salt was being
506    /// dropped in favour of the zero-init placeholder whenever the sample happened to
507    /// yield Pow(0) — leaving the returned salt and pow inconsistent under `pow_measure`.
508    /// Uses a different `data_hash` per trial so the bug can't hide behind a coincidental
509    /// `pow_measure(Salt::zero(), data_hash) == Pow(0)` for any single fixed input.
510    #[tokio::test]
511    async fn run_pool_pow_min_zero_returns_consistent_salt_and_pow() {
512        use crate::tools::pow::{pow_compute_data_hash, pow_measure_from_data_hash};
513        use crate::tools::pow_generator::pow_generator::PowGenerator;
514        use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
515        use crate::tools::types::Pow;
516
517        let generator = SingleThreadedPowGenerator::new();
518        for trial in 0u32..256 {
519            let data_hash = pow_compute_data_hash(&[&trial.to_le_bytes()]);
520            let (salt, achieved_pow, _) = generator.generate_best_effort("regression", 1, Pow(0), data_hash).await.unwrap();
521            let (recomputed_pow, _) = pow_measure_from_data_hash(&data_hash, &salt).unwrap();
522            assert_eq!(recomputed_pow, achieved_pow, "trial {}: salt and pow drifted apart", trial);
523        }
524    }
525
526    /// Regression: when the entire iteration budget is spent without ever beating Pow(0),
527    /// run_pool used to return the zero-init placeholder (Salt::zero, Pow(0), Hash::zero)
528    /// because its `chunk_best.1 > best.1` check could never accept a chunk with pow == 0.
529    /// Now exercises both layers: run_chunk produces a real (salt, pow=0, hash) sample
530    /// (~50% of the time for tiny chunks), and run_pool must accept it.
531    #[tokio::test]
532    async fn run_pool_returns_consistent_sample_when_budget_exhausted() {
533        use crate::tools::pow::{pow_compute_data_hash, pow_measure_from_data_hash};
534        use crate::tools::pow_generator::pow_generator::PowGenerator;
535        use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
536        use crate::tools::types::Pow;
537
538        let generator = SingleThreadedPowGenerator::new();
539        for trial in 0u32..256 {
540            let data_hash = pow_compute_data_hash(&[b"exhaust", &trial.to_le_bytes()]);
541            // Pow(255) is unreachable in a single iteration → budget always exhausts.
542            let (salt, achieved_pow, _) = generator.generate_best_effort("exhaust", 1, Pow(255), data_hash).await.unwrap();
543            let (recomputed_pow, _) = pow_measure_from_data_hash(&data_hash, &salt).unwrap();
544            assert_eq!(recomputed_pow, achieved_pow, "trial {}: returned salt does not produce returned pow", trial);
545        }
546    }
547}