fix: a hashrate estimate now carries its error bar
deploy / build (push) Successful in 7m25s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 15s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-09 19:48:51 +03:00
co-authored by Claude Opus 5
parent edce856263
commit f9ea292a70
10 changed files with 230 additions and 18 deletions
+25
View File
@@ -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.
+3 -1
View File
@@ -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();
+85 -10
View File
@@ -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<f64> {
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<Estimate> {
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"
+4 -2
View File
@@ -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,
+9
View File
@@ -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<f64>,
/// 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<f32>,
/// Height of this miner's most recent block in the window.
#[ts(type = "number")]
pub last_block_height: u64,
+11
View File
@@ -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.
*/
+19 -2
View File
@@ -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({
<i style={{ width: `${max > 0 ? (row.share / max) * 100 : 0}%` }} />
</div>
</td>
<td className="numeral">{hashrate(row.hashrate_estimate)}</td>
<td className="numeral">
<span className="rate-cell">
<span className={tooFewBlocks(row.hashrate_relative_error) ? 'rate-weak' : undefined}>
{hashrate(row.hashrate_estimate)}
</span>
{/* 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) && (
<span
className="rate-error"
title={`Estimated from ${row.blocks} block${row.blocks === 1 ? '' : 's'} in this window. Block finding is Poisson, so the relative error is 1/√blocks — it takes 25 blocks to reach ±20%.`}
>
{uncertainty(row.hashrate_relative_error)}
</span>
)}
</span>
</td>
<td className="numeral streak" title="Longest run of consecutive blocks in this window">
{row.best_streak > 1 ? `×${row.best_streak}` : '—'}
</td>
+25 -3
View File
@@ -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({
</div>
<div className="stat">
<div className="stat-label">Est. hashrate</div>
<div className="stat-value">{hashrate(row?.hashrate_estimate)}</div>
<div className="stat-note">from work its blocks cost</div>
<div
className={
tooFewBlocks(row?.hashrate_relative_error) ? 'stat-value rate-weak' : 'stat-value'
}
>
{hashrate(row?.hashrate_estimate)}
</div>
{/* 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. */}
<div className="stat-note">
{uncertainty(row?.hashrate_relative_error)
? `${uncertainty(row?.hashrate_relative_error)} over ${row?.blocks ?? 0} block${row?.blocks === 1 ? '' : 's'}`
: 'from work its blocks cost'}
</div>
</div>
<div className="stat">
<div className="stat-label">Blocks observed</div>
+24
View File
@@ -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);
}
+25
View File
@@ -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
}