From 2bca62a6a9984297633af42d9e19d1f328d2f7f2 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 05:46:57 +0000 Subject: pool: decay a miner's hashrate estimate when its shares stop arriving m->hashRate is an EWMA re-sampled only on the valid-share path, with no time-based term: a miner that stops hashing keeps its last estimate for as long as the connection stays open, and RefreshStats sums every such stale estimate into gStats.totalHashRate, which is what the pool status and the nostr announcement publish. The announced pool hashrate ran several times over ground truth because miners that had gone quiet were still contributing their last sample. Decay on time in the event loop's miner sweep. Per miner, compute the expected share interval -- ExpectedHashesForDifficulty(m->difficulty) divided by its current rate estimate -- and give it a grace of four such intervals with no share (at the vardiff target of one share per 15s that is about a minute of silence). Past the grace, decay the estimate with a half-life of one more expected interval, computed from absolute time so the curve does not depend on the loop's tick rate. The interval is clamped to [1s, 1h] so a fast miner at the vardiff floor, whose expected interval is milliseconds, is not decayed for ordinary share-delivery jitter, and a very slow miner's grace cannot stretch past the point where its estimate means anything. Per-share crediting and ExpectedHashesForDifficulty are untouched: this changes only what is reported about a miner that has stopped reporting itself. Closes #7 --- src/rpc.cpp | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index f1a7153..23e25ef 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -2484,6 +2484,26 @@ static const double VARDIFF_MAX = 1e12; static const int64 VARDIFF_STALL_SECS = 45; // 3x share target -- reacts faster without false-triggering on normal variance static const double VARDIFF_STALL_CUT = 4.0; // divide difficulty by this much +// #7: m->hashRate is an EWMA sampled only when a share arrives, so a miner +// that stops hashing keeps its last estimate forever and RefreshStats sums +// it into the announced pool total for as long as the connection sits open +// -- the published pool hashrate ran several times over ground truth +// because of exactly this. The decay pass in PoolEventLoop applies this +// policy: a miner gets a grace of HASHRATE_IDLE_MISSES expected share +// intervals (expected interval = expected hashes per share at its current +// difficulty / its last rate estimate) with no share, then its estimate +// decays with a half-life of one more such interval, computed from +// absolute time so the result does not depend on how often the loop ticks. +// The interval is clamped to [HASHRATE_MIN_INTERVAL, HASHRATE_MAX_INTERVAL] +// so a fast miner at the vardiff floor (expected interval of milliseconds) +// is not punished for ordinary share-delivery jitter, and a very slow +// miner's grace does not stretch past the point where its estimate means +// anything. At the vardiff target of one share per 15s, a stopped miner's +// announced rate is gone within minutes. +static const double HASHRATE_IDLE_MISSES = 4.0; // grace, in expected share intervals +static const double HASHRATE_MIN_INTERVAL = 1.0; // seconds; floor for the expected interval +static const double HASHRATE_MAX_INTERVAL = 3600.0; // seconds; ceiling for the expected interval + struct Miner { btf_socket_t fd; bool authorised = false; @@ -4900,6 +4920,26 @@ static void PoolEventLoop() } } + // 3.6. Hashrate decay (#7) -- see the policy comment at + // HASHRATE_IDLE_MISSES. The EWMA is only re-sampled on share arrival, + // so without this pass a miner that stops hashing keeps its last + // estimate forever and the summed pool hashrate RefreshStats (and + // the nostr announcement built from it) publishes is fiction. + for (Miner* m : gMiners) { + if (m->hashRate <= 0.0 || m->lastShareTime <= 0) continue; + double expected = ExpectedHashesForDifficulty(m->difficulty) / + std::max(m->hashRate, 1e-9); + if (expected < HASHRATE_MIN_INTERVAL) expected = HASHRATE_MIN_INTERVAL; + if (expected > HASHRATE_MAX_INTERVAL) expected = HASHRATE_MAX_INTERVAL; + double silent = (double)(GetTime() - m->lastShareTime); + double grace = HASHRATE_IDLE_MISSES * expected; + if (silent <= grace) continue; + // Half-life of one expected interval past the grace period. + // Written off absolute time, not per-tick, so the decay curve is + // the same however often this loop runs. + m->hashRate *= pow(0.5, (silent - grace) / expected); + } + // 4. Select over miner sockets if (!gMiners.empty()) { fd_set fds; FD_ZERO(&fds); -- cgit v1.2.3