diff --git a/sv2/channels-sv2/src/vardiff/classic.rs b/sv2/channels-sv2/src/vardiff/classic.rs index 16d3f080d7..73b22aab26 100644 --- a/sv2/channels-sv2/src/vardiff/classic.rs +++ b/sv2/channels-sv2/src/vardiff/classic.rs @@ -2,14 +2,43 @@ use crate::target::hash_rate_from_target; use bitcoin::Target; use tracing::debug; +use super::{error::VardiffError, Vardiff}; + /// Default minimum hashrate (H/s) if not specified. const DEFAULT_MIN_HASHRATE: f32 = 1.0; -use super::{error::VardiffError, Vardiff}; - -/// Represents the dynamic state for a variable difficulty (Vardiff) connection. +/// Decline-safe adaptive EWMA vardiff algorithm (the champion). +/// +/// Three inline stages per evaluation tick. The parameters here are the +/// decline-safety-selected champion: the gentlest composition that never enters +/// the over-difficulty death-spiral on a sustained decline (selected by a minimax +/// over the decline-safety gate, not by a scalar fitness score). Behaviorally +/// identical to the `champion_composed` reference in the research framework (#2154). +/// +/// 1. **Estimator** — EWMA-smoothed share rate (tau=360s, tick=60s) converts +/// observed shares into a hashrate belief via `hash_rate_from_target`. The 360s +/// time constant is the floor of the τ-safety-valley: shorter windows fail the +/// slow-decline gate at sparse rates; longer ones lag without benefit. /// -/// Tracks performance and adjusts the mining target to achieve a desired share rate. +/// 2. **Boundary** — Adaptive threshold selection based on the miner's +/// configured shares-per-minute: +/// - Below `spm_threshold` (6): PoissonCI — wide confidence interval +/// prevents premature fires on sparse data (small miners / bitaxe). +/// - At or above `spm_threshold`: sign-persistence CUSUM — a sequential-testing +/// boundary with two protections of the dangerous (tightening) direction: +/// an asymmetric multiplier (tightening requires `cusum_tighten_multiplier`× +/// more evidence) and a sign-persistence discount (the threshold relaxes only +/// after `consecutive` same-direction ticks accumulate, so a single lucky +/// streak cannot trip a tightening). This is dangerous-direction protection: +/// tightening into a falling miner is the move that spirals, so the boundary +/// resists it as a constraint. (NOTE: this is *not* justified by "tightening +/// rejects in-flight shares" — it does not; the pool validates each share +/// against the per-job target snapshotted at job creation. The justification +/// is decline-safety, not lost work.) +/// +/// 3. **Update** — Accelerating partial retarget: `eta` starts at 0.2 and +/// ramps by `acceleration` per consecutive same-direction fire, capping at 0.6. +/// Direction reversals reset the counter to `eta_base`. #[derive(Debug)] pub struct VardiffState { /// Count of shares received since the last difficulty adjustment. @@ -18,30 +47,74 @@ pub struct VardiffState { pub timestamp_of_last_update: u64, /// The lowest hashrate (H/s) the system will allow; values below this are clamped. pub min_allowed_hashrate: f32, + + // -- EWMA estimator state -- + tick_secs: u64, + tau_secs: u64, + rate: f64, + n_ticks: u32, + + // -- Adaptive boundary params -- + spm_threshold: u32, + poisson_z: f64, + poisson_margin: f64, + cusum_sensitivity: f64, + cusum_floor: f64, + cusum_tighten_multiplier: f64, + cusum_reference_spm: f64, + /// Threshold reduction per consecutive same-sign tick (fractional). + cusum_sign_persistence_discount: f64, + /// Maximum total sign-persistence discount (fractional cap). + cusum_max_sign_discount: f64, + /// Sign of the last boundary observation (realized vs target): +1 / -1 / 0. + cusum_last_sign: i8, + /// Count of consecutive same-sign boundary observations. + cusum_consecutive: u32, + + // -- Accelerating partial retarget state -- + eta_base: f32, + eta_max: f32, + acceleration: f32, + consecutive_same_direction: u32, + last_direction: i8, } impl VardiffState { /// Creates a new `VardiffState` with the default minimum hashrate. - /// - /// # Arguments - /// * `estimated_hashrate` - The initial hashrate estimate. pub fn new() -> Result { Self::new_with_min(DEFAULT_MIN_HASHRATE) } /// Creates a new `VardiffState` with a specific minimum hashrate. - /// - /// # Arguments - /// * `min_allowed_hashrate` - The minimum hashrate to enforce. pub fn new_with_min(min_allowed_hashrate: f32) -> Result { - let timestamp_secs = std::time::SystemTime::now() + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_secs(); - Ok(VardiffState { + Ok(Self { shares_since_last_update: 0, - timestamp_of_last_update: timestamp_secs, + timestamp_of_last_update: now, min_allowed_hashrate, + tick_secs: 60, + tau_secs: 360, + rate: 0.0, + n_ticks: 0, + spm_threshold: 6, + poisson_z: 2.576, + poisson_margin: 0.05, + cusum_sensitivity: 1.5, + cusum_floor: 0.05, + cusum_tighten_multiplier: 8.0, + cusum_reference_spm: 30.0, + cusum_sign_persistence_discount: 0.06, + cusum_max_sign_discount: 0.6, + cusum_last_sign: 0, + cusum_consecutive: 0, + eta_base: 0.2, + eta_max: 0.6, + acceleration: 0.05, + consecutive_same_direction: 0, + last_direction: 0, }) } @@ -49,6 +122,153 @@ impl VardiffState { pub fn set_shares_since_last_update(&mut self, shares_since_last_update: u32) { self.shares_since_last_update = shares_since_last_update; } + + /// Test-only: the sign-persistence boundary state `(last_sign, consecutive)`. + /// Exposed so a reset-cleanliness test can assert these fields are zeroed + /// directly (their effect on the threshold is too small to observe via fire + /// behavior — see `reset_counter_clears_sign_persistence_state`). + #[cfg(test)] + pub(crate) fn cusum_sign_state(&self) -> (i8, u32) { + (self.cusum_last_sign, self.cusum_consecutive) + } + + fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after UNIX_EPOCH") + .as_secs() + } + + fn ewma_alpha(&self) -> f64 { + (-(self.tick_secs as f64) / (self.tau_secs as f64)).exp() + } + + /// Stage 1: Flush pending shares into the EWMA and produce a hashrate estimate. + fn estimate(&mut self, hashrate: f32, target: &Target, shares_per_minute: f32) -> (f64, f32) { + let n = self.shares_since_last_update as f64; + + let rate = if self.n_ticks == 0 { + n + } else { + let alpha = self.ewma_alpha(); + alpha * self.rate + (1.0 - alpha) * n + }; + + self.rate = rate; + self.n_ticks += 1; + self.shares_since_last_update = 0; + + let realized_share_per_min = rate * (60.0 / self.tick_secs as f64); + + let h_estimate = + match hash_rate_from_target(target.to_le_bytes().into(), realized_share_per_min) { + Ok(h) => h as f32, + Err(_) => hashrate * realized_share_per_min as f32 / shares_per_minute, + }; + + (realized_share_per_min, h_estimate) + } + + /// Stage 2: Compute decision threshold. Takes `&mut self` because the + /// sign-persistence CUSUM updates its consecutive-tick state each evaluation. + fn threshold(&mut self, dt_secs: u64, shares_per_minute: f32, realized_spm: f64) -> f64 { + if (shares_per_minute as u32) < self.spm_threshold { + self.poisson_threshold(dt_secs, shares_per_minute, realized_spm) + } else { + self.cusum_threshold(dt_secs, shares_per_minute, realized_spm) + } + } + + fn poisson_threshold(&self, dt_secs: u64, shares_per_minute: f32, realized_spm: f64) -> f64 { + let lambda_bar = (shares_per_minute as f64 / 60.0) * dt_secs as f64; + if lambda_bar <= 0.0 { + return 100.0; + } + let bound_fraction = + (self.poisson_z * lambda_bar.sqrt() + 0.5) / lambda_bar + self.poisson_margin; + let base = bound_fraction * 100.0; + + let would_tighten = realized_spm > shares_per_minute as f64; + if would_tighten { + base * self.cusum_tighten_multiplier + } else { + base + } + } + + fn cusum_threshold(&mut self, dt_secs: u64, shares_per_minute: f32, realized_spm: f64) -> f64 { + let n_ticks = (dt_secs as f64 / self.tick_secs as f64).max(1.0); + + let spm_factor = ((shares_per_minute as f64) / self.cusum_reference_spm).sqrt(); + let sensitivity = self.cusum_sensitivity * spm_factor; + + let base_threshold = (sensitivity / n_ticks) + self.cusum_floor; + + // Asymmetric tighten multiplier: tightening is the dangerous direction. + let would_tighten = realized_spm > shares_per_minute as f64; + let asymmetric_threshold = if would_tighten { + base_threshold * self.cusum_tighten_multiplier + } else { + base_threshold + }; + + // Sign-persistence: the threshold relaxes only after consecutive + // same-direction ticks accumulate, so a single lucky streak can't trip a + // (tightening) fire. Discount = discount_per_tick × (consecutive − 1), + // capped at max_sign_discount. + let current_sign: i8 = if realized_spm > shares_per_minute as f64 { + 1 + } else { + -1 + }; + let consecutive = if current_sign == self.cusum_last_sign { + self.cusum_consecutive = self.cusum_consecutive.saturating_add(1); + self.cusum_consecutive + } else { + self.cusum_last_sign = current_sign; + self.cusum_consecutive = 1; + 1 + }; + let discount = (self.cusum_sign_persistence_discount * (consecutive - 1) as f64) + .min(self.cusum_max_sign_discount); + + asymmetric_threshold * (1.0 - discount) * 100.0 + } + + /// Stage 3: Compute new hashrate with accelerating partial retarget. + fn compute_new_hashrate(&mut self, h_estimate: f32, current_hashrate: f32) -> f32 { + let direction: i8 = if h_estimate > current_hashrate { 1 } else { -1 }; + + let consecutive = if direction == self.last_direction { + self.consecutive_same_direction += 1; + self.consecutive_same_direction + } else { + self.last_direction = direction; + self.consecutive_same_direction = 1; + 1 + }; + + let eta = (self.eta_base + self.acceleration * (consecutive - 1) as f32).min(self.eta_max); + current_hashrate + eta * (h_estimate - current_hashrate) + } + + /// Rescale the EWMA after a fire so the rate reflects the new difficulty. + fn rescale_ewma(&mut self, new_hashrate: f32, old_hashrate: f32) { + if old_hashrate <= 0.0 || new_hashrate <= 0.0 { + self.rate = 0.0; + self.n_ticks = 0; + self.shares_since_last_update = 0; + return; + } + + let ratio = new_hashrate as f64 / old_hashrate as f64; + if ratio > 0.0 && ratio.is_finite() { + self.rate /= ratio; + } else { + self.rate = 0.0; + self.n_ticks = 0; + } + } } impl Vardiff for VardiffState { @@ -60,136 +280,85 @@ impl Vardiff for VardiffState { self.shares_since_last_update } - fn min_allowed_hashrate(&self) -> f32 { - self.min_allowed_hashrate - } - - /// Sets the timestamp of the last update. - fn set_timestamp_of_last_update(&mut self, timestamp_of_last_update: u64) { - self.timestamp_of_last_update = timestamp_of_last_update; + fn set_timestamp_of_last_update(&mut self, ts: u64) { + self.timestamp_of_last_update = ts; } - /// Increments the share counter by one. fn increment_shares_since_last_update(&mut self) { self.shares_since_last_update += 1; } - /// Resets the share counter and updates the timestamp to now. + fn min_allowed_hashrate(&self) -> f32 { + self.min_allowed_hashrate + } + fn reset_counter(&mut self) -> Result<(), VardiffError> { - let timestamp_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); - self.set_timestamp_of_last_update(timestamp_secs); - self.set_shares_since_last_update(0); + self.timestamp_of_last_update = Self::now_secs(); + self.rate = 0.0; + self.shares_since_last_update = 0; + self.n_ticks = 0; + self.consecutive_same_direction = 0; + self.last_direction = 0; + // Sign-persistence boundary state must also reset, else a stale consecutive + // count would carry an accumulated discount into the next cycle — relaxing + // the *tightening* threshold (the dangerous direction the champion resists). + self.cusum_last_sign = 0; + self.cusum_consecutive = 0; Ok(()) } - /// Checks channel performance and potentially updates the hashrate and target. - /// - /// It calculates the realized share rate since the last update. If the - /// deviation from the target rate is significant enough (based on internal, - /// time-sensitive thresholds), it estimates a new hashrate and applies it. - /// - /// It returns `Ok(Some(new_hashrate))` when an update occurs, - /// `Ok(None)` when conditions don't warrant an update, and - /// `Err` for actual processing errors. fn try_vardiff( &mut self, hashrate: f32, target: &Target, shares_per_minute: f32, ) -> Result, VardiffError> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(VardiffError::TimeError)? - .as_secs(); - - let delta_time = now - self.timestamp_of_last_update; + let now = Self::now_secs(); + let dt = now.saturating_sub(self.timestamp_of_last_update); - if delta_time <= 15 { + if dt <= 15 { return Ok(None); } - let realized_share_per_min = - self.shares_since_last_update as f64 / (delta_time as f64 / 60.0); + // Stage 1: EWMA estimate + let (realized_spm, h_estimate) = self.estimate(hashrate, target, shares_per_minute); - debug!( - target: "vardiff", - "Hashrate update check triggered: - - Elapsed time: {}s - - Shares since last update: {} - - Realized shares per minute: {:.4} - - Current miner target: {:?}", - delta_time, - self.shares_since_last_update, - realized_share_per_min, - target - ); - - let mut new_hashrate = match hash_rate_from_target( - target.to_le_bytes().into(), - realized_share_per_min, - ) { - Ok(hashrate) => hashrate as f32, - Err(e) => { - debug!( - target: "vardiff", - "Target->Hashrate conversion failed: {:?}. Falling back using previous hashrate and realized_shares_per_minute", e - ); - hashrate * realized_share_per_min as f32 / shares_per_minute - } + // Deviation: |ratio - 1| × 100 + let delta = if hashrate > 0.0 { + ((h_estimate as f64 / hashrate as f64) - 1.0).abs() * 100.0 + } else { + 0.0 }; - let hashrate_delta = new_hashrate - hashrate; - let hashrate_delta_percentage = (hashrate_delta.abs() / hashrate) * 100.0; + // Stage 2: Boundary + let threshold = self.threshold(dt, shares_per_minute, realized_spm); debug!( target: "vardiff", - "Calculated new hashrate: {:.2} H/s (Δ {:.2}%, previous {:.2} H/s)", - new_hashrate, - hashrate_delta_percentage, - hashrate, + "dt={}s, realized_spm={:.2}, h_estimate={:.2}, delta={:.1}%, threshold={:.1}%", + dt, realized_spm, h_estimate, delta, threshold, ); - let should_update = match hashrate_delta_percentage { - pct if pct >= 100.0 => true, - pct if pct >= 60.0 && delta_time >= 60 => true, - pct if pct >= 50.0 && delta_time >= 120 => true, - pct if pct >= 45.0 && delta_time >= 180 => true, - pct if pct >= 30.0 && delta_time >= 240 => true, - pct if pct >= 15.0 && delta_time >= 300 => true, - _ => false, - }; - - if !should_update { + if delta < threshold { return Ok(None); } - // realized_share_per_min is 0.0 when d.difficulty_mgmt.shares_since_last_update is 0 - // so it's safe to compare realized_share_per_min with == 0.0 - if realized_share_per_min == 0.0 { - new_hashrate = match delta_time { - dt if dt <= 30 => hashrate / 1.5, - dt if dt < 60 => hashrate / 2.0, - _ => hashrate / 3.0, - }; - } else if hashrate_delta_percentage > 1000.0 { - new_hashrate = match delta_time { - dt if dt <= 30 => hashrate * 10.0, - dt if dt < 60 => hashrate * 5.0, - _ => hashrate * 3.0, - }; - } + // Stage 3: Update + let mut new_hashrate = self.compute_new_hashrate(h_estimate, hashrate); + if new_hashrate < self.min_allowed_hashrate { - debug!( - target: "vardiff", - "New hashrate {:.2} H/s below minimum threshold {:.2} H/s — clamping", - new_hashrate, - self.min_allowed_hashrate - ); new_hashrate = self.min_allowed_hashrate; } - self.reset_counter()?; + + debug!( + target: "vardiff", + "Firing: {:.2} -> {:.2} H/s (consecutive={})", + hashrate, new_hashrate, self.consecutive_same_direction, + ); + + // Post-fire: rescale EWMA and reset timestamp + self.rescale_ewma(new_hashrate, hashrate); + self.timestamp_of_last_update = now; Ok(Some(new_hashrate)) } diff --git a/sv2/channels-sv2/src/vardiff/mod.rs b/sv2/channels-sv2/src/vardiff/mod.rs index 0993496b52..1774ad4bf0 100644 --- a/sv2/channels-sv2/src/vardiff/mod.rs +++ b/sv2/channels-sv2/src/vardiff/mod.rs @@ -7,6 +7,9 @@ pub mod error; #[cfg(test)] pub mod test; +/// Default minimum hashrate (H/s) if not specified. +const DEFAULT_MIN_HASHRATE: f32 = 1.0; + /// Trait defining the interface for a Vardiff implementation. pub trait Vardiff: Debug + Send + Sync { /// Gets the timestamp of the last update. @@ -36,3 +39,24 @@ pub trait Vardiff: Debug + Send + Sync { /// Gets the minimum allowed hashrate (H/s). fn min_allowed_hashrate(&self) -> f32; } + +/// Constructs the recommended production vardiff: the decline-safety champion. +/// +/// Returns a [`Box`] wrapping the adaptive EWMA algorithm: +/// - EWMA estimator with tau=360s for smoothed hashrate estimation +/// - Adaptive boundary: PoissonCI below SPM 6, sign-persistence CUSUM at SPM 6+ +/// (tightening requires 8× the evidence of loosening — dangerous-direction +/// protection, not a lost-work cost) +/// - Accelerating partial retarget (eta 0.2 → 0.6 over consecutive fires) +pub fn default() -> Box { + default_with_min(DEFAULT_MIN_HASHRATE) +} + +/// Constructs the recommended production vardiff with a specific minimum +/// hashrate floor. +pub fn default_with_min(min_allowed_hashrate: f32) -> Box { + Box::new( + classic::VardiffState::new_with_min(min_allowed_hashrate) + .expect("VardiffState construction should not fail"), + ) +} diff --git a/sv2/channels-sv2/src/vardiff/test/classic.rs b/sv2/channels-sv2/src/vardiff/test/classic.rs index 8aa7689c32..9e4d061d4a 100644 --- a/sv2/channels-sv2/src/vardiff/test/classic.rs +++ b/sv2/channels-sv2/src/vardiff/test/classic.rs @@ -1,117 +1,386 @@ -/// Classic implementation test suite -use crate::vardiff::test::{ - simulate_shares_and_wait, TEST_MIN_ALLOWED_HASHRATE, TEST_SHARES_PER_MINUTE, -}; -use crate::{target::hash_rate_to_target, vardiff::VardiffError, VardiffState}; - -use super::{ - test_increment_and_reset_shares, test_try_vardiff_low_hashrate_decrease_target, - test_try_vardiff_no_shares_30_to_60s_decrease, - test_try_vardiff_no_shares_less_than_30s_decrease, - test_try_vardiff_no_shares_more_than_60s_decrease, - test_try_vardiff_stable_hashrate_minimal_change_or_no_change, - test_try_vardiff_with_less_spm_than_expected, test_try_vardiff_with_shares_30_to_60s, - test_try_vardiff_with_shares_less_than_30, test_try_vardiff_with_shares_more_than_60s, Vardiff, -}; - -fn new_test_vardiff_state() -> Result { - VardiffState::new_with_min(TEST_MIN_ALLOWED_HASHRATE) +use crate::target::hash_rate_to_target; +use crate::vardiff::classic::VardiffState; +use crate::vardiff::Vardiff; +use bitcoin::Target; + +const TEST_MIN_HASHRATE: f32 = 1.0; +const TEST_SHARES_PER_MINUTE: f32 = 12.0; +const TEST_HASHRATE: f32 = 1.0e12; + +fn add_shares(v: &mut VardiffState, n: u32) { + for _ in 0..n { + v.increment_shares_since_last_update(); + } } -#[test] -fn test_initialization_and_getters() { - let vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); +fn make_vardiff() -> VardiffState { + VardiffState::new_with_min(TEST_MIN_HASHRATE).expect("Failed to create VardiffState") +} - assert_eq!(vardiff.min_allowed_hashrate(), TEST_MIN_ALLOWED_HASHRATE); - assert_eq!(vardiff.shares_since_last_update(), 0); +/// Simulate elapsed time by backdating the timestamp. +fn simulate_elapsed(v: &mut VardiffState, secs: u64) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + v.set_timestamp_of_last_update(now - secs); } #[test] -fn test_increment_and_reset_shares_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_increment_and_reset_shares(&mut vardiff) +fn new_state_has_zero_shares() { + let v = make_vardiff(); + assert_eq!(v.shares_since_last_update(), 0); + assert_eq!(v.min_allowed_hashrate(), TEST_MIN_HASHRATE); } #[test] -fn test_try_vardiff_stable_hashrate_minimal_change_or_no_change_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_stable_hashrate_minimal_change_or_no_change(&mut vardiff); +fn increment_shares_accumulates() { + let mut v = make_vardiff(); + v.increment_shares_since_last_update(); + v.increment_shares_since_last_update(); + assert_eq!(v.shares_since_last_update(), 2); } #[test] -pub fn test_try_vardiff_low_hashrate_decrease_target_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_low_hashrate_decrease_target(&mut vardiff); +fn add_shares_bulk() { + let mut v = make_vardiff(); + add_shares(&mut v, 42); + assert_eq!(v.shares_since_last_update(), 42); } #[test] -pub fn test_try_vardiff_with_shares_less_than_30_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_with_shares_less_than_30(&mut vardiff); +fn reset_counter_zeroes_state() { + let mut v = make_vardiff(); + add_shares(&mut v, 10); + v.reset_counter().unwrap(); + assert_eq!(v.shares_since_last_update(), 0); } #[test] -pub fn test_try_vardiff_with_shares_30_to_60s_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_with_shares_30_to_60s(&mut vardiff); +fn reset_counter_clears_sign_persistence_state() { + // reset_counter must clear the sign-persistence CUSUM state (cusum_last_sign / + // cusum_consecutive), not just the share counter — when state was added to the + // struct, its reset was added too. This is a STATE-CLEANLINESS invariant + // asserted DIRECTLY (via the cusum_sign_state test accessor), deliberately NOT + // via fire behavior. Severity is honest hygiene, not a live bug: (1) the carried + // discount (≤0.6) is too small to change whether a tick fires in any scenario we + // could construct — fresh and stale-reset controllers fire on the same tick; and + // (2) reset_counter is Vardiff-trait API surface, not called in the production + // channel path. A behavioral "fires earlier" test would have no teeth (verified — + // it passes with OR without the fix), so it would be a guard that guards nothing. + // The direct field assertion below DOES fail if the two cusum_* resets are + // removed, which is the real, teeth-bearing invariant. + let spm = 30.0f32; + let target: Target = hash_rate_to_target(TEST_HASHRATE.into(), spm.into()) + .unwrap() + .into(); + let mut v = make_vardiff(); + // Accumulate sign-persistence with several same-direction ticks. + for _ in 0..6 { + add_shares(&mut v, (spm as u32) * 5); + simulate_elapsed(&mut v, 60); + let _ = v.try_vardiff(TEST_HASHRATE, &target, spm).unwrap(); + } + // Pre-reset: state has accumulated (consecutive > 0, sign set). + let (_, consecutive_before) = v.cusum_sign_state(); + assert!( + consecutive_before > 0, + "precondition: sign-persistence should have accumulated before reset (got 0)" + ); + + v.reset_counter().unwrap(); + + let (sign_after, consecutive_after) = v.cusum_sign_state(); + assert_eq!( + (sign_after, consecutive_after), + (0, 0), + "reset_counter must zero the sign-persistence state (got sign={sign_after}, \ + consecutive={consecutive_after}); leaving it stale carries an accumulated \ + discount into the next cycle" + ); } #[test] -pub fn test_try_vardiff_with_shares_more_than_60s_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_with_shares_more_than_60s(&mut vardiff); +fn no_fire_within_15s() { + let mut v = make_vardiff(); + let target = hash_rate_to_target(TEST_HASHRATE.into(), TEST_SHARES_PER_MINUTE.into()) + .unwrap() + .into(); + add_shares(&mut v, 100); + simulate_elapsed(&mut v, 10); + let result = v + .try_vardiff(TEST_HASHRATE, &target, TEST_SHARES_PER_MINUTE) + .unwrap(); + assert_eq!(result, None); } #[test] -pub fn test_try_vardiff_no_shares_30_to_60s_decrease_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_no_shares_30_to_60s_decrease(&mut vardiff); +fn fires_when_miner_is_much_faster() { + // The champion deliberately requires SUSTAINED evidence to tighten (raise + // difficulty): an 8x tighten-multiplier plus a slow EWMA(360) mean a single + // tick of a fast miner does NOT fire — tightening into a possibly-transient + // spike is the dangerous direction. Across repeated same-direction ticks the + // EWMA catches up and the sign-persistence discount relaxes the threshold, so + // a genuinely-faster miner is caught within a few minutes. Drive several ticks. + let mut v = make_vardiff(); + let target = hash_rate_to_target(TEST_HASHRATE.into(), TEST_SHARES_PER_MINUTE.into()) + .unwrap() + .into(); + // Miner sustaining 5x expected rate (60 shares / 60s vs 12 spm target). + let mut fired = None; + for _ in 0..12 { + add_shares(&mut v, 60); + simulate_elapsed(&mut v, 60); + if let Some(new_h) = v + .try_vardiff(TEST_HASHRATE, &target, TEST_SHARES_PER_MINUTE) + .unwrap() + { + fired = Some(new_h); + break; + } + } + let new_h = fired.expect("a sustained 5x-faster miner should fire within a few minutes"); + assert!( + new_h > TEST_HASHRATE, + "Hashrate should increase when miner is sustainedly faster: got {}", + new_h + ); } #[test] -pub fn test_try_vardiff_no_shares_more_than_60s_decrease_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_no_shares_more_than_60s_decrease(&mut vardiff); +fn fires_when_miner_is_much_slower() { + let mut v = make_vardiff(); + let target = hash_rate_to_target(TEST_HASHRATE.into(), TEST_SHARES_PER_MINUTE.into()) + .unwrap() + .into(); + // 3 shares over 300s → EWMA rate=3 → realized_spm=3 (vs expected 12). + // delta = |3/12 - 1| * 100 = 75%. + // CUSUM loosening threshold at 300s/5 ticks: ~24%. 75% > 24% → fires. + add_shares(&mut v, 3); + simulate_elapsed(&mut v, 300); + let result = v + .try_vardiff(TEST_HASHRATE, &target, TEST_SHARES_PER_MINUTE) + .unwrap(); + assert!(result.is_some(), "Should fire on 75% deviation at 300s"); + let new_h = result.unwrap(); + assert!( + new_h < TEST_HASHRATE, + "Hashrate should decrease when miner is slower: got {}", + new_h + ); } #[test] -pub fn test_try_vardiff_no_shares_less_than_30s_decrease_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_no_shares_less_than_30s_decrease(&mut vardiff); +fn no_fire_on_stable_rate() { + let mut v = make_vardiff(); + let target = hash_rate_to_target(TEST_HASHRATE.into(), TEST_SHARES_PER_MINUTE.into()) + .unwrap() + .into(); + // Miner at exactly expected rate + add_shares(&mut v, 12); + simulate_elapsed(&mut v, 60); + let result = v + .try_vardiff(TEST_HASHRATE, &target, TEST_SHARES_PER_MINUTE) + .unwrap(); + assert_eq!(result, None, "Should not fire when rate matches target"); } #[test] -fn test_try_vardiff_with_less_spm_than_expected_classic() { - let mut vardiff = new_test_vardiff_state().expect("Failed to create VardiffState"); - test_try_vardiff_with_less_spm_than_expected(&mut vardiff); +fn partial_retarget_moves_toward_estimate_not_fully() { + let mut v = make_vardiff(); + let target = hash_rate_to_target(TEST_HASHRATE.into(), TEST_SHARES_PER_MINUTE.into()) + .unwrap() + .into(); + // Miner at 3x rate over 300s. EWMA rate=36 → realized_spm=36. + // h_estimate ≈ 3x current. Champion tightening threshold at n=5 ticks: + // sensitivity 1.5·√(12/30)≈0.95, base ≈ 0.95/5 + 0.05 ≈ 0.24, ×8 (tighten + // multiplier) ≈ 192%. Delta = |3-1|·100 = 200% > 192% → fires (a deliberately + // narrow margin: the 8× makes even a 3× tightening only just clear the bar). + // EWMA first tick: rate = pending = 36. realized_spm = 36 * 60/60 = 36. + // That's 3x the 12 SPM target. + add_shares(&mut v, 36); + simulate_elapsed(&mut v, 300); + let result = v + .try_vardiff(TEST_HASHRATE, &target, TEST_SHARES_PER_MINUTE) + .unwrap(); + assert!(result.is_some(), "Should fire on 3x deviation at 300s"); + let new_h = result.unwrap(); + // With eta=0.2: new ≈ 1x + 0.2*(3x - 1x) = 1.4x + assert!(new_h > TEST_HASHRATE, "Should increase: got {}", new_h); + // The key property: partial retarget doesn't jump all the way to 3x + assert!( + new_h < TEST_HASHRATE * 2.0, + "eta=0.2 should keep new hashrate below 2x (full retarget would be ~3x): got {}", + new_h + ); } #[test] -fn test_try_vardiff_hashrate_clamps_to_minimum() { - let hashrate = TEST_MIN_ALLOWED_HASHRATE * 1.5; - let target = hash_rate_to_target(hashrate.into(), TEST_SHARES_PER_MINUTE.into()) +fn consecutive_fires_accelerate_eta() { + let mut v = make_vardiff(); + let target = hash_rate_to_target(TEST_HASHRATE.into(), TEST_SHARES_PER_MINUTE.into()) .unwrap() .into(); - let mut vardiff = VardiffState::new_with_min(TEST_MIN_ALLOWED_HASHRATE) - .expect("Failed to create VardiffState"); + // First fire (eta=0.2): 5x rate over 300s crosses the CUSUM tightening threshold. + add_shares(&mut v, 60); + simulate_elapsed(&mut v, 300); + let h1 = v + .try_vardiff(TEST_HASHRATE, &target, TEST_SHARES_PER_MINUTE) + .unwrap() + .expect("First fire should trigger on 5x deviation at 300s"); - let simulation_duration_secs = 16; - simulate_shares_and_wait(&mut vardiff, 0, simulation_duration_secs); + assert!( + h1 > TEST_HASHRATE, + "First fire should increase hashrate: got {}", + h1 + ); - let result = vardiff + // Second fire in same direction: after rescale, the EWMA rate is adjusted + // but with 60 new shares the miner is still clearly faster. Use 300s again + // so the boundary is lenient enough. + let target2 = hash_rate_to_target(h1.into(), TEST_SHARES_PER_MINUTE.into()) + .unwrap() + .into(); + add_shares(&mut v, 60); + simulate_elapsed(&mut v, 300); + let h2 = v + .try_vardiff(h1, &target2, TEST_SHARES_PER_MINUTE) + .unwrap() + .expect("Second fire should trigger — same direction, eta should accelerate"); + + assert!( + h2 > h1, + "Second fire should continue increasing: got {} (was {})", + h2, + h1 + ); + + // The acceleration property: the second same-direction fire ramps eta by the + // champion's acceleration (0.05) from eta_base 0.2 to 0.25 (capping at 0.6 over + // many consecutive fires), so the second step is a larger fraction of the + // remaining gap than a non-accelerating retarget would take. + let step1_fraction = (h1 - TEST_HASHRATE) / TEST_HASHRATE; + let step2_fraction = (h2 - h1) / h1; + assert!( + step2_fraction > step1_fraction * 0.5, + "Second step fraction ({:.4}) should be meaningful relative to first ({:.4})", + step2_fraction, + step1_fraction + ); +} + +#[test] +fn min_hashrate_floor_enforced() { + let mut v = VardiffState::new_with_min(100.0).expect("Failed to create VardiffState"); + let hashrate = 150.0f32; + let target = hash_rate_to_target(hashrate.into(), TEST_SHARES_PER_MINUTE.into()) + .unwrap() + .into(); + // Zero shares — estimate will be very low + add_shares(&mut v, 0); + simulate_elapsed(&mut v, 300); + let result = v .try_vardiff(hashrate, &target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - assert!(result.is_some(), "Hashrate should update"); - let new_hashrate = result.unwrap(); + .unwrap(); + if let Some(new_h) = result { + assert!( + new_h >= 100.0, + "Should clamp to min_allowed_hashrate: got {}", + new_h + ); + } +} +#[test] +fn poisson_boundary_used_at_low_spm() { + let mut v = make_vardiff(); + let spm = 4.0f32; // Below the champion's spm_threshold of 6 → PoissonCI branch + let target = hash_rate_to_target(TEST_HASHRATE.into(), spm.into()) + .unwrap() + .into(); + // Moderate deviation — PoissonCI should be more conservative (higher threshold) + add_shares(&mut v, 6); // 1.5x rate at SPM 4 over 60s + simulate_elapsed(&mut v, 60); + let result = v.try_vardiff(TEST_HASHRATE, &target, spm).unwrap(); + // At SPM 6 with dt=60s, PoissonCI threshold is quite high (~50%+), + // so a 1.5x deviation (50%) may not fire + // This verifies the conservative behavior at low SPM assert_eq!( - new_hashrate, TEST_MIN_ALLOWED_HASHRATE, - "Hashrate should be clamped to minimum" + result, None, + "PoissonCI should be conservative at low SPM — 1.5x should not fire at dt=60s" ); - assert_eq!( - new_hashrate, TEST_MIN_ALLOWED_HASHRATE, - "Stored hashrate should be clamped" +} + +#[test] +fn cusum_boundary_used_at_high_spm() { + let mut v = make_vardiff(); + let spm = 30.0f32; // Above the champion's spm_threshold of 6 → CUSUM branch + let target = hash_rate_to_target(TEST_HASHRATE.into(), spm.into()) + .unwrap() + .into(); + // 2x rate at SPM 30 over 300s — CUSUM should fire (tighter boundary) + add_shares(&mut v, 300); // 60 spm realized, 2x the target + simulate_elapsed(&mut v, 300); + let result = v.try_vardiff(TEST_HASHRATE, &target, spm).unwrap(); + assert!( + result.is_some(), + "CUSUM should fire at high SPM with 2x deviation over 300s" ); - assert_eq!(vardiff.shares_since_last_update(), 0); +} + +#[test] +fn asymmetric_cusum_tightening_is_harder_than_loosening() { + // At high SPM, tightening (miner faster) requires more evidence than + // loosening (miner slower) due to the champion's tighten_multiplier = 8.0 + // (the decline-safety-selected asymmetry; the earlier fitness-selected + // contender used 3.0 — decline-safety demands stronger tightening reluctance). + let spm = 30.0f32; + let target: Target = hash_rate_to_target(TEST_HASHRATE.into(), spm.into()) + .unwrap() + .into(); + + // The asymmetry: tightening requires 8x the evidence of loosening. The property + // we assert is direction-asymmetry under SYMMETRIC-magnitude moves: a deep + // loosening (miner drops to 0.2x) fires within a few minutes, while a + // comparable-magnitude tightening (miner jumps to 5x) does NOT fire in the same + // window — the 8x multiplier makes tightening into a possibly-transient spike + // the deliberately-reluctant direction. We drive each per-tick and compare. + // (Shallow 0.5x/2x moves don't cross even the loosening threshold at spm=30 + // with the slow EWMA(360); the asymmetry shows on deviations large enough to + // fire at all.) + fn fires_within( + shares_per_tick: u32, + spm: f32, + target: &Target, + max_ticks: u32, + ) -> Option { + let mut v = make_vardiff(); + for t in 1..=max_ticks { + add_shares(&mut v, shares_per_tick); + simulate_elapsed(&mut v, 60); + if v.try_vardiff(TEST_HASHRATE, target, spm).unwrap().is_some() { + return Some(t); + } + } + None + } + + // Loosening: miner sustains 0.2x rate (6 spm vs 30 target) — the safe direction. + let loosen = fires_within((spm as u32) / 5, spm, &target, 20); + // Tightening: miner sustains 5x rate (150 spm) — the dangerous direction, 8x harder. + let tighten = fires_within((spm as u32) * 5, spm, &target, 20); + + let lt = loosen.expect("a deep loosening (0.2x) should fire within 20 ticks"); + match tighten { + // If tightening also fires, loosening must have fired no later (8x harder). + Some(tt) => assert!( + lt <= tt, + "loosening should fire no later than tightening (8x harder): \ + loosen={lt} ticks, tighten={tt} ticks" + ), + // Expected: a comparable-magnitude tightening does NOT fire in the window — + // that is the dangerous-direction reluctance, working as designed. + None => {} + } } diff --git a/sv2/channels-sv2/src/vardiff/test/mod.rs b/sv2/channels-sv2/src/vardiff/test/mod.rs index de2c536731..4b44f4b583 100644 --- a/sv2/channels-sv2/src/vardiff/test/mod.rs +++ b/sv2/channels-sv2/src/vardiff/test/mod.rs @@ -1,403 +1 @@ -/// Contains a generic test implementation that is agnostic to the Vardiff implementation, -/// providing methods to verify the correctness of any specific implementation. -use std::{thread, time::Duration}; - mod classic; - -use super::Vardiff; -use crate::target::hash_rate_to_target; -use bitcoin::Target; - -pub const TEST_INITIAL_HASHRATE: f32 = 1000.0; -pub const TEST_SHARES_PER_MINUTE: f32 = 10.0; -pub const TEST_MIN_ALLOWED_HASHRATE: f32 = 10.0; - -// Helper function to simulate a number of shares being found over a given duration. -pub fn simulate_shares_and_wait( - vardiff: &mut V, - num_shares: u32, - wait_duration_secs: u64, -) { - for _ in 0..num_shares { - vardiff.increment_shares_since_last_update(); - } - - // Rather than waiting for wait_duration, - // we are performing time magic and going - // back in time. - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - - wait_duration_secs; - - vardiff.set_timestamp_of_last_update(now); -} - -// Verifies that the share counter can be incremented and reset correctly. -pub fn test_increment_and_reset_shares(vardiff: &mut V) { - let initial_timestamp = vardiff.last_update_timestamp(); - - vardiff.increment_shares_since_last_update(); - assert_eq!(vardiff.shares_since_last_update(), 1); - - vardiff.increment_shares_since_last_update(); - assert_eq!(vardiff.shares_since_last_update(), 2); - - thread::sleep(Duration::from_secs(1)); - - vardiff.reset_counter().expect("Failed to reset counter"); - assert_eq!(vardiff.shares_since_last_update(), 0); - - assert!( - vardiff.last_update_timestamp() > initial_timestamp, - "Timestamp should update on reset" - ); -} - -// Ensures that `try_vardiff` results in a minimal or no change when the hashrate is stable. -pub fn test_try_vardiff_stable_hashrate_minimal_change_or_no_change(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let iniital_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - let simulation_duration_secs = 5; - let expected_shares_for_duration = 1; - - simulate_shares_and_wait( - vardiff, - expected_shares_for_duration, - simulation_duration_secs, - ); - - let result = vardiff - .try_vardiff(initial_hashrate, &iniital_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - - if let Some(new_hashrate) = result { - let diff_percentage = ((new_hashrate - initial_hashrate).abs() / initial_hashrate) * 100.0; - println!( - "Stable hashrate test: new hashrate {new_hashrate}, initial {initial_hashrate}, diff_pct {diff_percentage}" - ); - assert!( - diff_percentage < 20.0, - "Change should be minimal for stable rate if any" - ); - assert_eq!(vardiff.shares_since_last_update(), 0) - } else { - assert_eq!(None, result); - } -} - -// Tests if a high share submission rate correctly increases the difficulty (lowers the target). -pub fn test_try_vardiff_low_hashrate_decrease_target(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let initial_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - let simulation_duration = 16; - simulate_shares_and_wait(vardiff, 16, simulation_duration); - - let result = vardiff - .try_vardiff(initial_hashrate, &initial_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - assert!( - result.is_some(), - "Hashrate should update due to low share count" - ); - let new_hashrate = result.unwrap(); - - // As estimated shares per minute is 10 - // with current setup realized shares per minute is 60 - // comes under no special case - assert_eq!(new_hashrate, 6.0 * initial_hashrate); - let target: Target = hash_rate_to_target(new_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - println!("target: {target:?}"); - assert!( - target < initial_target, - "Target should become harder (larger value)" - ); - assert_eq!(vardiff.shares_since_last_update(), 0); -} - -// Checks the difficulty adjustment logic for a high share rate within a 30-second window. -pub fn test_try_vardiff_with_shares_less_than_30(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let initial_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - let simulation_duration = 16; - simulate_shares_and_wait(vardiff, 500, simulation_duration); - - let result = vardiff - .try_vardiff(initial_hashrate, &initial_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - assert!( - result.is_some(), - "Hashrate should update due to low share count" - ); - let new_hashrate = result.unwrap(); - - // This logic checks the `dt <= 30` case, which multiple by 10 - assert_eq!(new_hashrate, 10.0 * initial_hashrate); - - let target: Target = hash_rate_to_target(new_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - assert!( - target < initial_target, - "Target should become harder (larger value)" - ); - assert_eq!(vardiff.shares_since_last_update(), 0); -} - -// Checks the difficulty adjustment logic for a high share rate within a 30 to 60-second window. -pub fn test_try_vardiff_with_shares_30_to_60s(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let initial_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - let simulation_duration = 31; - simulate_shares_and_wait(vardiff, 5000, simulation_duration); - - let result = vardiff - .try_vardiff(initial_hashrate, &initial_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - assert!( - result.is_some(), - "Hashrate should update due to low share count" - ); - let new_hashrate = result.unwrap(); - - // This logic checks the `dt < 60` case, which multiple by 5 - assert_eq!(new_hashrate, 5.0 * initial_hashrate); - let target: Target = hash_rate_to_target(new_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - assert!( - target < initial_target, - "Target should become harder (larger value)" - ); - assert_eq!(vardiff.shares_since_last_update(), 0); -} - -// Checks the difficulty adjustment logic for a high share rate over a 60-second window. -pub fn test_try_vardiff_with_shares_more_than_60s(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let initial_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - let simulation_duration = 60; - simulate_shares_and_wait(vardiff, 1000, simulation_duration); - - let result = vardiff - .try_vardiff(initial_hashrate, &initial_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - assert!( - result.is_some(), - "Hashrate should update due to low share count" - ); - let new_hashrate = result.unwrap(); - - // This logic checks the `dt >= 60` case, which multiple by 3 - assert_eq!(new_hashrate, 3.0 * initial_hashrate); - let target: Target = hash_rate_to_target(new_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - assert!( - target < initial_target, - "Target should become harder (larger value)" - ); - assert_eq!(vardiff.shares_since_last_update(), 0); -} - -// Verifies that difficulty decreases when no shares are found within a 30-second window. -fn test_try_vardiff_no_shares_less_than_30s_decrease(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let initial_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - let simulation_duration = 16; - simulate_shares_and_wait(vardiff, 0, simulation_duration); - - let result = vardiff - .try_vardiff(initial_hashrate, &initial_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - assert!(result.is_some(), "Hashrate should update"); - let new_hashrate = result.unwrap(); - - // This logic checks the `dt < 30` case, which divides by 1.5 - let expected_hashrate = initial_hashrate / 1.5; - assert!( - (new_hashrate - expected_hashrate).abs() < 0.01, - "Hashrate should be initial / 1.5. Got: {}, Expected: {}", - new_hashrate, - expected_hashrate - ); - assert_eq!(vardiff.shares_since_last_update(), 0); -} - -// Verifies that difficulty decreases when no shares are found within a 30 to 60-second window. -fn test_try_vardiff_no_shares_30_to_60s_decrease(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let initial_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - let simulation_duration = 31; - simulate_shares_and_wait(vardiff, 0, simulation_duration); - - let result = vardiff - .try_vardiff(initial_hashrate, &initial_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - let new_hashrate = result.expect("Hashrate should have updated"); - - // This logic checks the `dt < 60` case, which divides by 2.0 - let expected_hashrate = initial_hashrate / 2.0; - assert!( - (new_hashrate - expected_hashrate).abs() < 0.01, - "Hashrate should be initial / 2. Got: {}, Expected: {}", - new_hashrate, - expected_hashrate - ); - assert_eq!(vardiff.shares_since_last_update(), 0); -} - -// Verifies that difficulty decreases when no shares are found over a 60-second window. -fn test_try_vardiff_no_shares_more_than_60s_decrease(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let initial_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - let simulation_duration = 60; - simulate_shares_and_wait(vardiff, 0, simulation_duration); - - let result = vardiff - .try_vardiff(initial_hashrate, &initial_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed"); - let new_hashrate = result.expect("Hashrate should have updated"); - - // This logic checks the `dt >= 60` case, which divides by 3.0 - let expected_hashrate = initial_hashrate / 3.0; - assert!( - (new_hashrate - expected_hashrate).abs() < 0.01, - "Hashrate should be initial / 3. Got: {}, Expected: {}", - new_hashrate, - expected_hashrate - ); - assert_eq!(vardiff.shares_since_last_update(), 0); -} - -fn test_try_vardiff_with_less_spm_than_expected(vardiff: &mut V) { - let initial_hashrate = TEST_INITIAL_HASHRATE; - let initial_target = - hash_rate_to_target(initial_hashrate.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - assert_eq!(initial_hashrate, 1000.0); - - let simulation_duration = 60; - // testing case when realized_shares_per_minute / shares_per_minute = 0.4 - simulate_shares_and_wait(vardiff, 4, simulation_duration); - - let hashrate_after_60s = vardiff - .try_vardiff(initial_hashrate, &initial_target, TEST_SHARES_PER_MINUTE) - .expect("try_vardiff failed") - .unwrap(); - let target_after_60s: Target = - hash_rate_to_target(hashrate_after_60s.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - assert_eq!(hashrate_after_60s, 400.0); - - let simulation_duration = 120; - // testing case when realized_shares_per_minute / shares_per_minute = 0.5 - simulate_shares_and_wait(vardiff, 10, simulation_duration); - - let hashrate_after_120s = vardiff - .try_vardiff( - hashrate_after_60s, - &target_after_60s, - TEST_SHARES_PER_MINUTE, - ) - .expect("try_vardiff failed") - .unwrap(); - let target_after_120s: Target = - hash_rate_to_target(hashrate_after_120s.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - assert_eq!(hashrate_after_120s, 200.0); - - let simulation_duration = 180; - // testing case when realized_shares_per_minute / shares_per_minute = 0.55 - simulate_shares_and_wait(vardiff, 16, simulation_duration); - - let hashrate_after_180s = vardiff - .try_vardiff( - hashrate_after_120s, - &target_after_120s, - TEST_SHARES_PER_MINUTE, - ) - .expect("try_vardiff failed") - .unwrap(); - let target_after_180s: Target = - hash_rate_to_target(hashrate_after_180s.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - assert_eq!(hashrate_after_180s, 106.0); - - let simulation_duration = 240; - // testing case when realized_shares_per_minute / shares_per_minute = 0.7 - simulate_shares_and_wait(vardiff, 28, simulation_duration); - - let hashrate_after_240s = vardiff - .try_vardiff( - hashrate_after_180s, - &target_after_180s, - TEST_SHARES_PER_MINUTE, - ) - .expect("try_vardiff failed") - .unwrap(); - let target_after_240s: Target = - hash_rate_to_target(hashrate_after_240s.into(), TEST_SHARES_PER_MINUTE.into()) - .unwrap() - .into(); - - assert_eq!(hashrate_after_240s, 74.2); - - let simulation_duration = 300; - // testing case when realized_shares_per_minute / shares_per_minute = 0.85 - simulate_shares_and_wait(vardiff, 42, simulation_duration); - - let hashrate_after_300s = vardiff - .try_vardiff( - hashrate_after_240s, - &target_after_240s, - TEST_SHARES_PER_MINUTE, - ) - .expect("try_vardiff failed") - .unwrap(); - - assert_eq!(hashrate_after_300s, 62.327995); -}