diff --git a/allways/constants.py b/allways/constants.py index ad7c5d5..96eb6a3 100644 --- a/allways/constants.py +++ b/allways/constants.py @@ -63,6 +63,10 @@ VOLUME_WEIGHT_ALPHA: float = 0.5 # Closed swaps required for full credibility (0 → 100% linear ramp). CREDIBILITY_RAMP_OBSERVATIONS: int = 10 +# More than this many timed-out swaps within CREDIBILITY_WINDOW_BLOCKS hard-zeros +# a miner's credibility (and thus their whole reward) until the old timeouts age +# out of the rolling window. 0-2 tolerated; the 3rd timeout zeros credibility. +CREDIBILITY_MAX_TIMEOUTS: int = 2 # ─── Emission Recycling ──────────────────────────────────── RECYCLE_UID = 53 # Subnet owner UID diff --git a/allways/validator/scoring.py b/allways/validator/scoring.py index 4f33ad1..79b0075 100644 --- a/allways/validator/scoring.py +++ b/allways/validator/scoring.py @@ -17,6 +17,7 @@ from allways.chains import canonical_pair from allways.constants import ( + CREDIBILITY_MAX_TIMEOUTS, CREDIBILITY_RAMP_OBSERVATIONS, CREDIBILITY_WINDOW_BLOCKS, DIRECTION_POOLS, @@ -249,6 +250,7 @@ def calculate_miner_rewards(self: Validator) -> Tuple[np.ndarray, Set[int]]: wt.record_credibility( closed_swaps=sum(success_stats.get(hotkey, (0, 0))), ramp_target=CREDIBILITY_RAMP_OBSERVATIONS, + timed_out=success_stats.get(hotkey, (0, 0))[1], ) crown_share_dir = blocks / total_crown_dir vol_dir = volumes_dir.get(hotkey, 0) @@ -384,9 +386,14 @@ def success_rate(stats: Optional[Tuple[int, int]]) -> float: def credibility_ramp(stats: Optional[Tuple[int, int]]) -> float: """Linear ramp to full credibility at CREDIBILITY_RAMP_OBSERVATIONS closed - swaps. Applied linearly to the reward, not cubed. Zero observations → 0.""" + swaps. Applied linearly to the reward, not cubed. Zero observations → 0. + More than CREDIBILITY_MAX_TIMEOUTS timed-out swaps in the window hard-zeros + credibility — a rolling penalty that recovers as old timeouts age out.""" if not stats or stats == (0, 0): return 0.0 + _completed, timed_out = stats + if timed_out > CREDIBILITY_MAX_TIMEOUTS: + return 0.0 return min(1.0, sum(stats) / CREDIBILITY_RAMP_OBSERVATIONS) diff --git a/allways/validator/scoring_trace.py b/allways/validator/scoring_trace.py index 113620e..cf00026 100644 --- a/allways/validator/scoring_trace.py +++ b/allways/validator/scoring_trace.py @@ -10,7 +10,12 @@ import bittensor as bt import numpy as np -from allways.constants import CREDIBILITY_RAMP_OBSERVATIONS, RECYCLE_UID, TAO_TO_RAO +from allways.constants import ( + CREDIBILITY_MAX_TIMEOUTS, + CREDIBILITY_RAMP_OBSERVATIONS, + RECYCLE_UID, + TAO_TO_RAO, +) if TYPE_CHECKING: from allways.validator.scoring import DirectionTrace @@ -45,9 +50,12 @@ def record_volume(self, vol_rao: int, total_volume_rao: int, crown_share: float, self.participation = min(1.0, self.volume_share / crown_share) if crown_share > 0 else 1.0 self.volume_factor = factor - def record_credibility(self, closed_swaps: int, ramp_target: int) -> None: + def record_credibility(self, closed_swaps: int, ramp_target: int, timed_out: int = 0) -> None: self.closed_swaps = closed_swaps - self.credibility_ramp = min(1.0, closed_swaps / ramp_target) if ramp_target > 0 else 1.0 + if timed_out > CREDIBILITY_MAX_TIMEOUTS: + self.credibility_ramp = 0.0 + else: + self.credibility_ramp = min(1.0, closed_swaps / ramp_target) if ramp_target > 0 else 1.0 def log_scoring_trace( diff --git a/tests/test_scoring_v1.py b/tests/test_scoring_v1.py index 859e0e9..e3a95e2 100644 --- a/tests/test_scoring_v1.py +++ b/tests/test_scoring_v1.py @@ -158,14 +158,20 @@ def test_none_is_zero(self): assert credibility_ramp((0, 0)) == 0.0 def test_scales_linearly_below_threshold(self): - """1 closed swap → ramp 0.1; 5 closed → 0.5. Counts timeouts too.""" + """1 closed swap → ramp 0.1; 5 closed → 0.5. Counts tolerated timeouts too.""" assert credibility_ramp((1, 0)) == 0.1 assert credibility_ramp((5, 0)) == 0.5 - assert credibility_ramp((2, 3)) == 0.5 + assert credibility_ramp((3, 2)) == 0.5 # 2 timeouts tolerated def test_caps_at_full_ramp(self): assert credibility_ramp((10, 0)) == 1.0 - assert credibility_ramp((90, 10)) == 1.0 + + def test_excess_timeouts_hard_zero(self): + """More than CREDIBILITY_MAX_TIMEOUTS (2) timeouts → 0, regardless of volume.""" + assert credibility_ramp((8, 2)) == 1.0 # 2 tolerated, fully ramped + assert credibility_ramp((8, 3)) == 0.0 # 3rd timeout zeros it + assert credibility_ramp((90, 10)) == 0.0 # high volume can't rescue it + assert credibility_ramp((0, 3)) == 0.0 class TestCrownHoldersHelper: @@ -1693,6 +1699,15 @@ def test_record_credibility_zero_target_is_unity(self): wt.record_credibility(closed_swaps=5, ramp_target=0) assert wt.credibility_ramp == 1.0 + def test_record_credibility_excess_timeouts_zero(self): + """Trace mirrors the reward rule: >3 timeouts → ramp shown as 0.""" + from allways.validator.scoring_trace import WeightingTrace + + wt = WeightingTrace() + wt.record_credibility(closed_swaps=100, ramp_target=10, timed_out=4) + assert wt.closed_swaps == 100 + assert wt.credibility_ramp == 0.0 + class TestVolumeWeighting: """End-to-end volume weighting via calculate_miner_rewards.""" @@ -2036,8 +2051,27 @@ def test_full_ramp_at_threshold(self, tmp_path: Path): np.testing.assert_allclose(rewards[0], POOL_BTC_TAO, atol=1e-6) v.state_store.close() - def test_timeouts_advance_ramp_but_hurt_raw_rate(self, tmp_path: Path): - """5 completed + 5 timed_out → ramp = 1.0, raw = 0.5 → 0.5³ × 1.0 = 0.125.""" + def test_tolerated_timeouts_advance_ramp_but_hurt_raw_rate(self, tmp_path: Path): + """8 completed + 2 timed_out (within the 2-timeout tolerance) → ramp = 1.0, + raw = 0.8 → 0.8³ × 1.0.""" + hotkeys = pad_hotkeys_to_cover_recycle(['hk_a']) + v = make_validator(tmp_path, hotkeys, baseline_credibility=False) + self.seed_btc_tao_crown(v, 'hk_a') + for i in range(8): + v.state_store.insert_swap_outcome( + swap_id=i + 1, miner_hotkey='hk_a', completed=True, resolved_block=100 + i + ) + for i in range(2): + v.state_store.insert_swap_outcome( + swap_id=100 + i, miner_hotkey='hk_a', completed=False, resolved_block=200 + i + ) + rewards, _ = calculate_miner_rewards(v) + np.testing.assert_allclose(rewards[0], POOL_BTC_TAO * 0.8**3, atol=1e-6) + v.state_store.close() + + def test_excess_timeouts_zero_reward(self, tmp_path: Path): + """5 completed + 5 timed_out → 5 timeouts > 3 → credibility hard-zeroed → + reward 0 despite a high raw fulfillment count.""" hotkeys = pad_hotkeys_to_cover_recycle(['hk_a']) v = make_validator(tmp_path, hotkeys, baseline_credibility=False) self.seed_btc_tao_crown(v, 'hk_a') @@ -2050,7 +2084,7 @@ def test_timeouts_advance_ramp_but_hurt_raw_rate(self, tmp_path: Path): swap_id=100 + i, miner_hotkey='hk_a', completed=False, resolved_block=200 + i ) rewards, _ = calculate_miner_rewards(v) - np.testing.assert_allclose(rewards[0], POOL_BTC_TAO * 0.5**3, atol=1e-6) + np.testing.assert_allclose(rewards[0], 0.0, atol=1e-6) v.state_store.close() def test_unearned_ramp_portion_recycles(self, tmp_path: Path):