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 +}