Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ Pithead ships as **one product, one version** — the version lives in the top-l
[`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut
per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).

## [1.14.1] - 2026-07-24

### Fixed

- **Won XvB raffle rounds no longer terminate on the controller's own thin margin (#769).** XvB ends
a won bonus round if your credited 1h average dips below the round minimum while the round runs.
The donation controller held that average only ~1% above the whale threshold — inside the credited
average's own measured noise — so rounds died mid-flight and paid a fraction of their value. Two
guards: the cushion above the tier threshold widens to 5% (capped at 5 kH/s, so a whale-tier stack
now deliberately donates a few kH/s more than before), and for 90 minutes after a recorded raffle
win the controller refuses to ease the donation down. Safety behaviour is unchanged: the VIP
reserve, stale-read hold, and prolonged-outage decay all still override the win-protection hold.

## [1.14.0] - 2026-07-23

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.14.0
1.14.1
18 changes: 14 additions & 4 deletions build/dashboard/mining_dashboard/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,10 +660,20 @@ def _telegram_event_enabled(name, default=True):

# Cushion held above the tier threshold: the controller targets the 1h average at
# threshold * (1 + pct), capped in ABSOLUTE H/s. The raffle terminates a win if the
# 1h average dips below the round minimum, so we sit a small, noise-covering margin
# above it — not a percentage of the tier (which wastes more the higher the tier).
XVB_MAINT_MARGIN_PCT = float(os.environ.get("XVB_MAINT_MARGIN_PCT", 0.03))
XVB_MAINT_MARGIN_ABS_CAP = float(os.environ.get("XVB_MAINT_MARGIN_ABS_CAP", 1000))
# 1h average dips below the round minimum, so the cushion must exceed the credited
# average's own noise. Measured on a live whale-tier fleet (#769): XvB's credited 1h
# dipped ~2.5 kH/s below the setpoint, sailing straight through the old 1 kH/s cap
# and killing won rounds. 5% capped at 5 kH/s clears that observed noise at every
# tier while still capping the waste a flat percentage would cost the higher tiers.
XVB_MAINT_MARGIN_PCT = float(os.environ.get("XVB_MAINT_MARGIN_PCT", 0.05))
XVB_MAINT_MARGIN_ABS_CAP = float(os.environ.get("XVB_MAINT_MARGIN_ABS_CAP", 5000))

# How long after a recorded raffle win the controller treats the won round as
# possibly still live and refuses to steer the donation DOWN (#769): a round runs
# ~60 min and then until the bonus finds a share, so 90 min covers the round plus
# its tail. During this hold only downward calibration steps are skipped — upward
# steps, the VIP reserve clamp, and the stale-read fail-safes all still apply.
XVB_WIN_ROUND_HOLD_S = float(os.environ.get("XVB_WIN_ROUND_HOLD_S", 5400))

# Integral gain of the calibration loop. Each cycle the donated fraction is nudged
# by gain * (reference - measured_1h) / current_hr. Low by design: the 1h average
Expand Down
39 changes: 35 additions & 4 deletions build/dashboard/mining_dashboard/service/algo_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
XVB_TIME_ALGO_MS,
XVB_TOR_ENABLED,
XVB_TOR_SOCKS5,
XVB_WIN_ROUND_HOLD_S,
)
from mining_dashboard.helper.utils import (
DEFAULT_PPLNS_WINDOW,
Expand Down Expand Up @@ -256,11 +257,27 @@ def _stats_age(self, xvb_stats):

def _reference_hr(self, target_hr):
"""Hashrate the controller holds XvB's 1h average at: the tier threshold
plus a small, noise-covering cushion (capped in absolute H/s). The raffle
terminates a win if the 1h average dips below the round minimum, so we sit
a hair above it — never a fat percentage that wastes p2pool hashrate."""
plus a noise-covering cushion (capped in absolute H/s). The raffle
terminates a win if the 1h average dips below the round minimum, so the
cushion must clear the credited average's measured noise (#769) — while
the cap keeps a flat percentage from wasting p2pool hashrate at high tiers."""
return target_hr + min(target_hr * self.maint_margin_pct, self.maint_margin_abs_cap)

def _won_round_live(self, now=None):
"""Whether a won raffle round may still be running: any recorded win newer
than ``XVB_WIN_ROUND_HOLD_S``. Steering the donation down during a live won
round can sag the credited 1h average through the round minimum and
terminate the round (#769), so ``_advance_controller`` skips downward steps
while this holds. Fails open to False (normal steering) on any read error —
the hold is a yield optimization, never a safety path."""
try:
since = (now if now is not None else time.time()) - XVB_WIN_ROUND_HOLD_S
wins = self.state_manager.get_raffle_wins(since=since)
except Exception as e:
logger.debug(f"Raffle-win read failed; steering normally: {e}")
return False
return isinstance(wins, list) and len(wins) > 0

def _max_donation_fraction(self, current_hr, window_duration, p2pool_stats):
"""
Largest fraction of the cycle we may donate while keeping p2pool eligible
Expand Down Expand Up @@ -293,6 +310,10 @@ def _advance_controller(self, current_hr, target_hr, avg_1h, max_fraction):
scales our donation — and it can't wind up: the gain is small and the
fraction is clamped to ``[0, max_fraction]`` (the VIP reserve), so a
still-ramping or stale 1h read can only drift it slowly within bounds.

While a won raffle round may still be live (``_won_round_live``), downward
steps are skipped so the controller never helps the 1h average sag through
the round minimum (#769). Upward steps and the clamp still apply.
"""
if current_hr <= 0:
return
Expand All @@ -305,7 +326,17 @@ def _advance_controller(self, current_hr, target_hr, avg_1h, max_fraction):
return

error = self._reference_hr(target_hr) - avg_1h
self.donation_fraction += self.control_gain * error / current_hr
step = self.control_gain * error / current_hr
if step < 0 and self._won_round_live():
# A won round is (possibly) live: easing off now is how the credited 1h
# average sags through the round minimum and forfeits the round (#769).
# Hold the fraction; normal steering resumes once the hold window passes.
logger.info(
"Won raffle round may still be live: holding donation fraction at "
f"{self.donation_fraction:.3f} instead of easing off"
)
return
self.donation_fraction += step
self.donation_fraction = max(0.0, min(self.donation_fraction, max_fraction))

def _seed_donation_fraction(self, target_hr, current_hr, max_fraction):
Expand Down
7 changes: 6 additions & 1 deletion build/dashboard/mining_dashboard/sim/donation_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ def get_xvb_stats(self):
def get_xvb_standby(self):
return None

def get_raffle_wins(self, since=0.0):
# The sim models steady-state convergence, never a live won round, so the
# in-round hold (#769) stays inactive.
return []


def make_algo_controller(algo, p2pool_difficulty=0) -> Controller:
"""Adapt a real `AlgoService` into a `decide(...) -> fraction` callable.
Expand Down Expand Up @@ -530,7 +535,7 @@ def run_algo(scenario: Scenario, donation_level="vip", **tuning) -> SimResult:
name="worker drop below tier mid-run, then recovery",
target_hr=10_000,
current_hr=46_300,
warm_avg=10_300,
warm_avg=10_500,
drop_at=CYCLES_PER_DAY,
drop_until=CYCLES_PER_DAY + 18,
drop_factor=0.2,
Expand Down
2 changes: 1 addition & 1 deletion build/dashboard/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "mining-dashboard"
# Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version
# (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version
# from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only.
version = "1.14.0"
version = "1.14.1"
description = "Monitoring dashboard and XvB switching engine for Pithead"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
108 changes: 101 additions & 7 deletions build/dashboard/tests/service/test_algo_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ def algo():
# the warm-resume seed falls through to feedforward. Warm-resume tests override these.
state_manager.get_xvb_stats.return_value = {"commanded_fraction": 0.0}
state_manager.get_xvb_standby.return_value = None
# No recorded raffle wins by default, so the in-round hold (#769) is inactive
# and the calibration loop steers freely. Hold tests override this.
state_manager.get_raffle_wins.return_value = []
proxy_client = MagicMock() # called via asyncio.to_thread -> sync methods
data_service = MagicMock()
data_service.workers_rejected = False # not rejecting workers (Issue #31 guard off)
Expand Down Expand Up @@ -112,8 +115,8 @@ def test_cold_start_seeds_feedforward(self, algo):
RECENT_SHARES,
)
assert mode in ("SPLIT", "XVB")
# reference 10_300 / 46_300 ~ 0.222 of the cycle.
assert algo.donation_fraction == pytest.approx(10_300 / 46_300, rel=0.05)
# reference 10_500 / 46_300 ~ 0.227 of the cycle.
assert algo.donation_fraction == pytest.approx(10_500 / 46_300, rel=0.05)

def test_loop_ramps_up_when_below_reference(self, algo):
"""Calling repeatedly with the 1h average below reference integrates the
Expand Down Expand Up @@ -237,9 +240,12 @@ class TestHelpers:
def test_reference_cushion_is_absolute_capped(self, algo):
# Cushion above target is capped in ABSOLUTE H/s, so a huge tier doesn't
# waste a percentage of a huge number.
assert algo._reference_hr(1_000_000) == pytest.approx(1_000_000 + 1_000)
# Small tier uses the percentage (3% of 10k = 300).
assert algo._reference_hr(10_000) == pytest.approx(10_300)
assert algo._reference_hr(1_000_000) == pytest.approx(1_000_000 + 5_000)
# Small tier uses the percentage (5% of 10k = 500).
assert algo._reference_hr(10_000) == pytest.approx(10_500)
# Whale sits exactly at the cap: 5% of 100k = 5k, the measured credited
# noise (~2.5 kH/s dips) stays clear of the round minimum.
assert algo._reference_hr(100_000) == pytest.approx(105_000)

def test_fraction_to_ms_zero_and_positive(self, algo):
assert algo._fraction_to_ms(0) == 0
Expand Down Expand Up @@ -681,8 +687,8 @@ def test_seed_cold_start_uses_feedforward_when_no_state(self, algo):
# Fresh install: no own state, no standby -> the original feedforward seed, unchanged.
algo.state_manager.get_xvb_stats.return_value = {"commanded_fraction": 0.0}
algo.state_manager.get_xvb_standby.return_value = None
# reference = 1000 + min(1000*0.03, 1000) = 1030; feedforward = 1030 / current_hr.
assert algo._seed_donation_fraction(1000, 2000, 0.85) == pytest.approx(1030 / 2000)
# reference = 1000 + min(1000*0.05, 5000) = 1050; feedforward = 1050 / current_hr.
assert algo._seed_donation_fraction(1000, 2000, 0.85) == pytest.approx(1050 / 2000)

def test_get_decision_seeds_from_standby_on_failover(self, algo):
# Integration: the first real donating cycle on a warm backup adopts the standby fraction
Expand All @@ -699,3 +705,91 @@ def test_get_decision_seeds_from_standby_on_failover(self, algo):
RECENT_SHARES,
)
assert algo.donation_fraction == pytest.approx(0.35)


class TestWonRoundHold:
"""In-round donation hold (#769): while a won raffle round may still be live,
the calibration loop must never steer the donation DOWN — a controller-assisted
sag of the credited 1h average through the round minimum terminates the round."""

def _live_win(self):
return [
{
"ts": time.time() - 600,
"hashrate": 5e6,
"height": 1,
"block_id": "x",
"tier": "donor_whale",
}
]

def test_downward_step_held_while_round_live(self, algo):
algo.state_manager.get_raffle_wins.return_value = self._live_win()
algo.donation_fraction = 0.5
# 1h average far above reference -> error negative -> would normally trim.
algo._advance_controller(46_300, 10_000, 200_000, 0.85)
assert algo.donation_fraction == 0.5 # held, not trimmed

def test_upward_step_still_ramps_while_round_live(self, algo):
algo.state_manager.get_raffle_wins.return_value = self._live_win()
algo.donation_fraction = 0.2
# Below reference -> catch-up must not be blocked by the hold.
algo._advance_controller(46_300, 10_000, 0, 0.85)
assert algo.donation_fraction > 0.2

def test_upward_step_still_clamped_to_reserve_while_round_live(self, algo):
algo.state_manager.get_raffle_wins.return_value = self._live_win()
algo.donation_fraction = 0.5
for _ in range(100):
algo._advance_controller(46_300, 10_000, 0, 0.6)
assert algo.donation_fraction == pytest.approx(0.6) # VIP reserve still wins

def test_downward_step_applies_when_no_wins(self, algo):
algo.donation_fraction = 0.5
algo._advance_controller(46_300, 10_000, 200_000, 0.85)
assert algo.donation_fraction < 0.5

def test_query_window_bounds_round_liveness(self, algo):
# The liveness read asks storage only for wins inside the hold window —
# storage filters on ts, so the controller's contract is the `since` bound.
now = time.time()
algo.state_manager.get_raffle_wins.return_value = []
assert algo._won_round_live(now=now) is False
since = algo.state_manager.get_raffle_wins.call_args.kwargs["since"]
from mining_dashboard.config.config import XVB_WIN_ROUND_HOLD_S

assert since == pytest.approx(now - XVB_WIN_ROUND_HOLD_S)

def test_liveness_fails_open_on_storage_error(self, algo):
# A broken read must never freeze the controller: hold off, steer normally.
algo.state_manager.get_raffle_wins.side_effect = RuntimeError("db locked")
algo.donation_fraction = 0.5
algo._advance_controller(46_300, 10_000, 200_000, 0.85)
assert algo.donation_fraction < 0.5 # trimmed as if no round were live

def test_liveness_treats_non_list_as_not_live(self, algo):
algo.state_manager.get_raffle_wins.return_value = None
assert algo._won_round_live() is False

def test_seed_unaffected_by_live_round(self, algo):
# First advance seeds the loop; the hold only gates steering afterwards.
algo.state_manager.get_raffle_wins.return_value = self._live_win()
assert algo.donation_fraction is None
algo._advance_controller(46_300, 10_000, 200_000, 0.85)
assert algo.donation_fraction is not None

def test_stale_decay_still_wins_over_hold(self, algo):
# The prolonged-staleness fail-safe outranks the hold: donating blind
# through an outage is the bigger risk, live round or not.
algo.state_manager.get_raffle_wins.return_value = self._live_win()
algo.donation_fraction = 0.4
with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True):
algo.get_decision(
46_300,
46_300,
POOL_STATS,
P2P_MAIN,
{"avg_1h": 200_000, "avg_24h": 0, "fail_count": 0, "last_update": _decay_ts()},
RECENT_SHARES,
)
assert algo.donation_fraction < 0.4 # decayed despite the live round
2 changes: 1 addition & 1 deletion build/dashboard/tests/sim/test_donation_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def test_recovers_after_worker_drop(self):
name="drop",
target_hr=10_000,
current_hr=46_300,
warm_avg=10_300,
warm_avg=10_500,
measurement="fixed",
p2pool_difficulty=DIFFICULTY,
cycles=4 * CYCLES_PER_DAY,
Expand Down
2 changes: 1 addition & 1 deletion build/dashboard/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ rounds. It donates the minimum needed to hold the target tier and routes the res
to P2Pool. The controller edits the proxy config only; your workers keep their existing connection
to `3333` and need no changes.

3. **Round protection.** XvB terminates a won bonus round if your credited 1h average drops below
the round minimum while the round runs, so the controller guards wins two ways. It holds the 1h
average a cushion above the tier threshold (5%, capped at 5 kH/s) rather than exactly on it,
because XvB's credited average wanders a few kH/s below the setpoint even when your donation is
steady. And for 90 minutes after a recorded raffle win — a round plus its tail — it refuses to
ease the donation down, so a mid-round dip is never controller-assisted. Both guards spend a
little extra donation to keep the round alive; the bonus a completed round mines to your wallet
is worth far more than the cushion costs.

The result: the chosen XvB tier holds with minimal donation, and remaining hashrate mines Monero +
Tari on P2Pool. The dashboard's hashrate chart shades the P2Pool/XvB split over time.

Expand Down