diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e372efa..0a332f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 850e7424..63e799cf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.14.0 +1.14.1 diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py index aba598b8..6222e525 100644 --- a/build/dashboard/mining_dashboard/config/config.py +++ b/build/dashboard/mining_dashboard/config/config.py @@ -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 diff --git a/build/dashboard/mining_dashboard/service/algo_service.py b/build/dashboard/mining_dashboard/service/algo_service.py index 6971b6e7..b0f59516 100644 --- a/build/dashboard/mining_dashboard/service/algo_service.py +++ b/build/dashboard/mining_dashboard/service/algo_service.py @@ -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, @@ -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 @@ -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 @@ -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): diff --git a/build/dashboard/mining_dashboard/sim/donation_model.py b/build/dashboard/mining_dashboard/sim/donation_model.py index e7bc5965..283ae8ab 100644 --- a/build/dashboard/mining_dashboard/sim/donation_model.py +++ b/build/dashboard/mining_dashboard/sim/donation_model.py @@ -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. @@ -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, diff --git a/build/dashboard/pyproject.toml b/build/dashboard/pyproject.toml index 5c71fe7f..88a285a9 100644 --- a/build/dashboard/pyproject.toml +++ b/build/dashboard/pyproject.toml @@ -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" diff --git a/build/dashboard/tests/service/test_algo_service.py b/build/dashboard/tests/service/test_algo_service.py index 7b7544b0..18814d40 100644 --- a/build/dashboard/tests/service/test_algo_service.py +++ b/build/dashboard/tests/service/test_algo_service.py @@ -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) @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/build/dashboard/tests/sim/test_donation_model.py b/build/dashboard/tests/sim/test_donation_model.py index c540de78..35ea60b0 100644 --- a/build/dashboard/tests/sim/test_donation_model.py +++ b/build/dashboard/tests/sim/test_donation_model.py @@ -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, diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock index 2a1d312c..2b70e3fb 100644 --- a/build/dashboard/uv.lock +++ b/build/dashboard/uv.lock @@ -782,7 +782,7 @@ wheels = [ [[package]] name = "mining-dashboard" -version = "1.14.0" +version = "1.14.1" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/docs/architecture.md b/docs/architecture.md index 4d534fc0..ab5c3f25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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.