From f9ea292a70ca50c2a1aa19de7ad63c05a1ade095 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 9 Sep 2026 19:48:51 +0300 Subject: [PATCH] fix: a hashrate estimate now carries its error bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leaderboard credited a miner with "1.55 GH/s" on the strength of one block, last seen four hours earlier, to three significant figures. Block finding is Poisson: k blocks carries a relative standard error of 1/√k, so one block is ±100% and it takes twenty-five to reach ±20%. The estimator was unbiased and the presentation was not honest, which is the half that matters to a reader. `miner_hashrate` now returns the count's uncertainty with the figure, and the leaderboard and miner page render it — dimmed below four blocks, where the error bar is wider than half the value. Checked first that the arithmetic itself is sound, because the obvious suspicion was the formula. It is the chain's own: `qpow-math::is_valid_nonce` accepts when `hash < U512::MAX / difficulty`, `verify_nonce_internal` passes it what `get_difficulty()` returns, and the hash is uniform — 200,000 samples of `hash_squeeze_twice` gave 0.4995 of 2^512 against a uniform 0.5, and 12.48% below 2^509 against 12.5%. Acceptance is 1/difficulty, so expected trials is difficulty, exactly. The two network estimators — difficulty over tip interval, and summed work over window span — also agree within 5% on live data. What that leaves is recorded in CLAUDE.md and is not an observer bug: our three miners measure 1.66 GH/s (1.39 instantaneous), while the chain credited the same preimage 5.3 GH/s over 29 blocks. Sampling does not explain 14 sigma. `miner_cpu_hash_rate` is 0 on all three — every hash is on GPU — and the miner's CPU path calls `qpow_math::get_nonce_hash` directly while the CUDA kernel is a separate implementation with its own counter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- CLAUDE.md | 25 +++++++ crates/blackbeard-api/src/routes.rs | 4 +- crates/blackbeard-core/src/hashrate.rs | 95 ++++++++++++++++++++++--- crates/blackbeard-core/src/window.rs | 6 +- crates/blackbeard-entities/src/miner.rs | 9 +++ web/src/api/generated/LeaderboardRow.ts | 11 +++ web/src/components/Leaderboard.tsx | 21 +++++- web/src/components/MinerPanel.tsx | 28 +++++++- web/src/index.css | 24 +++++++ web/src/lib/format.ts | 25 +++++++ 10 files changed, 230 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e39927..ade5c1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,31 @@ The window's duration comes from `authored_at`, not `observed_at`: a stretch this process caught up on carries observation times seconds apart for blocks minutes apart, and dividing real work by that invents hashrate. +**`difficulty` really is expected hashes, and this has been checked three +ways.** Before "correcting" any hashrate figure, know that the formula is the +chain's own: `qpow-math::is_valid_nonce` accepts when `hash < U512::MAX / +difficulty`, `pallet_qpow::verify_nonce_internal` passes it exactly what +`get_difficulty()` returns, and the hash is uniform — 200,000 samples of +`qp_poseidon_core::hash_squeeze_twice` gave a mean of 0.4995 of 2^512 against a +uniform 0.5, and 12.48% below 2^509 against 12.5%. So acceptance is 1/difficulty +and expected trials is difficulty, exactly. + +**A per-miner hashrate is a Poisson estimate and needs its error bar.** The +estimator is unbiased; that is not the same as accurate. `k` blocks carries a +relative standard error of `1/√k` — ±100% at one block, ±71% at two, and only +±20% at twenty-five. The leaderboard printed three significant figures off a +single block for a miner last seen four hours earlier. `miner_hashrate` returns +the count's error alongside the figure and the UI renders it; do not drop it. + +**Our own miners disagree with the chain by ~3.5× and it is not the observer.** +Measured 2026-09-09: beast 1.116, benjy 0.461, quadbrat 0.082 GH/s — 1.66 GH/s +total, and 1.39 GH/s instantaneous over a 90 s sample. Over 29 blocks (±18%) in +6.9 h the chain credited the same preimage 1.32e14 expected hashes, i.e. +5.3 GH/s. Sampling does not explain a 14-sigma gap. `miner_cpu_hash_rate` is +**0** on all three hosts — every hash is on GPU — and while the miner's CPU path +calls `qpow_math::get_nonce_hash` directly, the CUDA kernel is a separate +implementation with its own counter. That is where to look, not here. + **Windows are block counts, never durations.** See `readme.md`. Anything that turns `Window` into a time range is wrong. diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 5eb5dbf..f7ed0af 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -644,7 +644,9 @@ async fn miner( hashrate_estimate: blackbeard_core::hashrate::miner_hashrate( work, bucket.as_secs_f64(), - ), + mine, + ) + .map(|e| e.per_second), }) .collect(); diff --git a/crates/blackbeard-core/src/hashrate.rs b/crates/blackbeard-core/src/hashrate.rs index 9a59269..fc1a1b9 100644 --- a/crates/blackbeard-core/src/hashrate.rs +++ b/crates/blackbeard-core/src/hashrate.rs @@ -72,6 +72,34 @@ impl Interval { } } +/// A hashrate estimate, with how much to trust it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Estimate { + /// Hashes per second. + pub per_second: f64, + /// Relative standard error, as a fraction. `0.5` means ±50%. + pub relative_error: f64, +} + +impl Estimate { + /// The low and high ends of one standard error. + pub fn range(&self) -> (f64, f64) { + let e = self.relative_error.clamp(0.0, 0.95); + (self.per_second * (1.0 - e), self.per_second * (1.0 + e)) + } +} + +/// Below this many blocks an estimate is worth less than the number of digits +/// it would print. +/// +/// Winning blocks is a Poisson process, so `k` blocks carries a relative +/// standard error of `1/√k`: one block is ±100%, two is ±71%, four is ±50%. A +/// point estimate from one block is not a measurement of anything, and the +/// leaderboard was printing three significant figures for exactly that — +/// crediting a miner last seen four hours earlier, on the strength of a single +/// block, with 1.55 GH/s. +pub const MIN_SAMPLE_BLOCKS: u32 = 4; + /// A miner's hashrate, from the work its blocks actually cost. /// /// Difficulty **is** expected hashes per block, so the difficulty of every @@ -88,20 +116,64 @@ impl Interval { /// cannot make that mistake, because each block carries the difficulty in force /// when it was won. /// -/// It remains an estimate over a finite window: winning blocks is a Poisson -/// process, and a miner that won two blocks instead of its expected one reads -/// twice as fast. What it no longer is, is systematically wrong. -pub fn miner_hashrate(work: f64, window_seconds: f64) -> Option { - if window_seconds <= 0.0 || !work.is_finite() || work <= 0.0 { +/// ## What the error bar is for +/// +/// The estimator is unbiased, and that is not the same as accurate. Block +/// finding is Poisson: a miner whose true rate should win one block in the +/// window and wins two reads exactly twice as fast, and there is no way to tell +/// those apart from inside the window. `k` blocks means a relative standard +/// error of `1/√k`, which is ±100% at one block and only reaches ±20% at +/// twenty-five. +/// +/// So the count comes back with the figure. A caller that renders the number +/// without it is making a claim the data does not support — which is precisely +/// what a leaderboard row saying "7.64 GH/s" off two blocks was doing, against +/// a miner measuring 1.5 GH/s at its own hardware. +pub fn miner_hashrate(work: f64, window_seconds: f64, blocks: u32) -> Option { + if window_seconds <= 0.0 || !work.is_finite() || work <= 0.0 || blocks == 0 { return None; } - Some(work / window_seconds) + Some(Estimate { + per_second: work / window_seconds, + relative_error: 1.0 / f64::from(blocks).sqrt(), + }) } #[cfg(test)] mod tests { use super::*; + /// The estimator is unbiased and still wrong by a factor of two at two + /// blocks. The error bar is the only thing that says so. + #[test] + fn an_estimate_carries_the_uncertainty_of_its_sample() { + let one = miner_hashrate(1.0e13, 1000.0, 1).expect("one block is still an estimate"); + assert_eq!(one.per_second, 1.0e10); + assert_eq!(one.relative_error, 1.0); + + let hundred = miner_hashrate(1.0e13, 1000.0, 100).expect("estimates"); + assert_eq!(hundred.per_second, 1.0e10); + assert!((hundred.relative_error - 0.1).abs() < 1e-12); + + // Same figure, wildly different claim. + assert!(one.relative_error > hundred.relative_error * 9.0); + } + + #[test] + fn a_range_never_goes_negative() { + // One block is ±100%, which would put the low end at exactly zero and + // anything noisier below it. A hashrate cannot be negative and a range + // that says so is worse than no range. + let (low, high) = miner_hashrate(1.0e13, 1000.0, 1).unwrap().range(); + assert!(low > 0.0, "{low}"); + assert!(high > low); + } + + #[test] + fn nothing_is_estimated_from_no_blocks() { + assert!(miner_hashrate(1.0e13, 1000.0, 0).is_none()); + } + #[test] fn u512_to_f64_survives_astronomical_difficulty() { // 2^128, well past f64's exact-integer range but comfortably inside its @@ -143,15 +215,18 @@ mod tests { #[test] fn a_miners_hashrate_is_the_work_its_blocks_cost() { // Two blocks of 1.2e9 expected hashes each, found over 12 seconds. - assert_eq!(miner_hashrate(2.4e9, 12.0), Some(2.0e8)); + assert_eq!( + miner_hashrate(2.4e9, 12.0, 2).map(|e| e.per_second), + Some(2.0e8) + ); } #[test] fn work_over_no_time_is_not_a_hashrate() { // A window that spans nothing divides by zero; an infinite hashrate on // the leaderboard is worse than an absent one. - assert_eq!(miner_hashrate(1.0e9, 0.0), None); - assert_eq!(miner_hashrate(0.0, 60.0), None); + assert!(miner_hashrate(1.0e9, 0.0, 1).is_none()); + assert!(miner_hashrate(0.0, 60.0, 1).is_none()); } #[test] @@ -161,7 +236,7 @@ mod tests { // multiplied its whole-window share by today's figure; work-based // reports what it actually did, whatever the network has since become. let early_work = 5.0 * 1.0e11; - let over_an_hour = miner_hashrate(early_work, 3600.0).unwrap(); + let over_an_hour = miner_hashrate(early_work, 3600.0, 5).unwrap().per_second; assert!( (over_an_hour - 1.388e8).abs() < 1.0e6, "{over_an_hour} should reflect the work, not the current network" diff --git a/crates/blackbeard-core/src/window.rs b/crates/blackbeard-core/src/window.rs index fe673f9..ce2e67e 100644 --- a/crates/blackbeard-core/src/window.rs +++ b/crates/blackbeard-core/src/window.rs @@ -245,6 +245,8 @@ where .enumerate() .map(|(i, (miner, tally))| { let (display, address, attribution, confidence, attribution_votes) = attribute(miner); + let estimate = window_seconds + .and_then(|secs| crate::hashrate::miner_hashrate(tally.work, secs, tally.blocks)); LeaderboardRow { rank: i as u32 + 1, miner: miner.clone(), @@ -259,8 +261,8 @@ where } else { f64::from(tally.blocks) / f64::from(window_total) }, - hashrate_estimate: window_seconds - .and_then(|secs| crate::hashrate::miner_hashrate(tally.work, secs)), + hashrate_estimate: estimate.map(|e| e.per_second), + hashrate_relative_error: estimate.map(|e| e.relative_error as f32), last_block_height: tally.last_block_height, last_seen: tally.last_seen, best_streak: tally.best_streak, diff --git a/crates/blackbeard-entities/src/miner.rs b/crates/blackbeard-entities/src/miner.rs index 9542ac9..df6695b 100644 --- a/crates/blackbeard-entities/src/miner.rs +++ b/crates/blackbeard-entities/src/miner.rs @@ -115,6 +115,15 @@ pub struct LeaderboardRow { /// window, not a measurement of the miner's hardware — a lucky small miner /// will over-read and an unlucky large one will under-read. pub hashrate_estimate: Option, + /// Relative standard error on that figure, as a fraction — `0.71` means + /// ±71%. Block finding is Poisson, so this is `1/√blocks`: one block is + /// ±100% and it takes twenty-five to reach ±20%. + /// + /// Carried beside the estimate rather than folded into it because they are + /// different claims. Rendering the number without it is how a row came to + /// say "7.64 GH/s" off two blocks, for a miner measuring 1.5 GH/s at its + /// own hardware. + pub hashrate_relative_error: Option, /// Height of this miner's most recent block in the window. #[ts(type = "number")] pub last_block_height: u64, diff --git a/web/src/api/generated/LeaderboardRow.ts b/web/src/api/generated/LeaderboardRow.ts index 74f2cff..adf0e87 100644 --- a/web/src/api/generated/LeaderboardRow.ts +++ b/web/src/api/generated/LeaderboardRow.ts @@ -57,6 +57,17 @@ share: number, * will over-read and an unlucky large one will under-read. */ hashrate_estimate: number | null, +/** + * Relative standard error on that figure, as a fraction — `0.71` means + * ±71%. Block finding is Poisson, so this is `1/√blocks`: one block is + * ±100% and it takes twenty-five to reach ±20%. + * + * Carried beside the estimate rather than folded into it because they are + * different claims. Rendering the number without it is how a row came to + * say "7.64 GH/s" off two blocks, for a miner measuring 1.5 GH/s at its + * own hardware. + */ +hashrate_relative_error: number | null, /** * Height of this miner's most recent block in the window. */ diff --git a/web/src/components/Leaderboard.tsx b/web/src/components/Leaderboard.tsx index 572bcd8..c59e6a4 100644 --- a/web/src/components/Leaderboard.tsx +++ b/web/src/components/Leaderboard.tsx @@ -14,7 +14,7 @@ import { Link } from 'react-router-dom' import type { LeaderboardRow } from '../api/generated/LeaderboardRow' -import { ago, hashrate, share } from '../lib/format' +import { ago, hashrate, share, tooFewBlocks, uncertainty } from '../lib/format' import { href } from '../lib/routes' import { usePinnedMiners } from '../lib/store' @@ -116,7 +116,24 @@ function Row({ 0 ? (row.share / max) * 100 : 0}%` }} /> - {hashrate(row.hashrate_estimate)} + + + + {hashrate(row.hashrate_estimate)} + + {/* The sample behind the figure. A row resting on one or two blocks + is a coin toss dressed as a measurement, and saying so costs one + small line. */} + {uncertainty(row.hashrate_relative_error) && ( + + {uncertainty(row.hashrate_relative_error)} + + )} + + {row.best_streak > 1 ? `×${row.best_streak}` : '—'} diff --git a/web/src/components/MinerPanel.tsx b/web/src/components/MinerPanel.tsx index 714a95a..fc1fc05 100644 --- a/web/src/components/MinerPanel.tsx +++ b/web/src/components/MinerPanel.tsx @@ -13,7 +13,16 @@ import { Link } from 'react-router-dom' import type { MinerDetail } from '../api/generated/MinerDetail' import type { Window as WindowName } from '../api/generated/Window' import { RequestFailed, fetchMiner } from '../api/rest' -import { ago, bigNumber, hashrate, height, share, shortMiner } from '../lib/format' +import { + ago, + bigNumber, + hashrate, + height, + share, + shortMiner, + tooFewBlocks, + uncertainty, +} from '../lib/format' import { href } from '../lib/routes' import { usePinnedMiners } from '../lib/store' import { ShareChart } from './ShareChart' @@ -117,8 +126,21 @@ export function MinerPanel({
Est. hashrate
-
{hashrate(row?.hashrate_estimate)}
-
from work its blocks cost
+
+ {hashrate(row?.hashrate_estimate)} +
+ {/* The note carries the error bar, because on this page it is + the headline number and a bare figure off two blocks is a + coin toss with three significant figures. */} +
+ {uncertainty(row?.hashrate_relative_error) + ? `${uncertainty(row?.hashrate_relative_error)} over ${row?.blocks ?? 0} block${row?.blocks === 1 ? '' : 's'}` + : 'from work its blocks cost'} +
Blocks observed
diff --git a/web/src/index.css b/web/src/index.css index 429862a..2eebf51 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1274,3 +1274,27 @@ tr.mine .share-bar > i { border-color: var(--accent); background: rgba(216, 69, 58, 0.12); } + +/* A hashrate and how far to trust it. The error rides beside the figure rather + than under it: the two are one statement, and separating them invites reading + the number alone. */ +.rate-cell { + display: inline-flex; + align-items: baseline; + gap: 6px; + justify-content: flex-end; +} + +.rate-error { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; +} + +/* Under four blocks the error bar is wider than half the value. Dimming says + "this is a guess" in the same register the `node?` chip does for names — + without hiding a figure somebody may still want. */ +.rate-weak { + color: var(--text-muted); +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 8f7c272..173cea5 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -138,3 +138,28 @@ export function when(iso: string | null | undefined): string { minute: '2-digit', }) } + +/** + * How much to trust a hashrate, from the sample it was estimated over. + * + * Block finding is Poisson, so `k` blocks carries a relative standard error of + * `1/√k`: one block is ±100%, four is ±50%, twenty-five is ±20%. The API sends + * that fraction beside every estimate. + * + * Rendered as a plain ± because the alternative — three significant figures and + * silence — is a claim the data does not support. A miner measuring 1.5 GH/s at + * its own hardware was shown as "7.64 GH/s" on the strength of two blocks, and + * nothing on the row said that number was a coin toss. + */ +export function uncertainty(relativeError: number | null | undefined): string | null { + if (relativeError === null || relativeError === undefined) return null + if (relativeError < 0.2) return null // ±20% or better: the figure stands on its own + return `± ${Math.round(relativeError * 100)}%` +} + +/** Whether an estimate rests on too few blocks to print as a number. */ +export function tooFewBlocks(relativeError: number | null | undefined): boolean { + // 1/√4 = 0.5. Below four blocks the error bar is wider than the value is + // useful, and a range says more than a midpoint does. + return relativeError !== null && relativeError !== undefined && relativeError > 0.5 +}