diff --git a/neurons/base/validator.py b/neurons/base/validator.py index 32fea9d5..57663b8b 100644 --- a/neurons/base/validator.py +++ b/neurons/base/validator.py @@ -288,23 +288,23 @@ def resync_metagraph(self): return bt.logging.info('Metagraph updated, re-syncing hotkeys, dendrite pool and moving averages') - # Zero out all hotkeys that have been replaced. - for uid, hotkey in enumerate(self.hotkeys): - if hotkey != self.metagraph.hotkeys[uid]: - self.scores[uid] = 0 # hotkey has been replaced - - # Check to see if the metagraph has changed size. - # If so, we need to add new hotkeys and moving averages. - if len(self.hotkeys) < len(self.metagraph.hotkeys): - # Update the size of the moving average scores. - new_moving_average = np.zeros((self.metagraph.n)) - min_len = min(len(self.hotkeys), len(self.scores)) - new_moving_average[:min_len] = self.scores[:min_len] - self.scores = new_moving_average - - # Update the hotkeys. + self.scores = self._align_scores_to_metagraph(self.scores, self.hotkeys) self.hotkeys = copy.deepcopy(self.metagraph.hotkeys) + def _align_scores_to_metagraph(self, scores: np.ndarray, hotkeys: list) -> np.ndarray: + """Resize scores to the live metagraph and zero slots whose hotkey was replaced.""" + n = int(self.metagraph.n) + scores_arr = np.asarray(scores, dtype=np.float32) + aligned = np.zeros(n, dtype=np.float32) + min_len = min(len(scores_arr), n) + aligned[:min_len] = scores_arr[:min_len] + + for uid in range(min(len(hotkeys), n)): + if hotkeys[uid] != self.metagraph.hotkeys[uid]: + aligned[uid] = 0.0 + + return aligned + def update_scores(self, rewards: np.ndarray, uids: set[int], blacklisted_uids: List[int] = None): """Performs exponential moving average on the scores based on the rewards received from the miners.""" @@ -380,9 +380,10 @@ def load_state(self): state_path = self.config.neuron.full_path + '/state.npz' try: state = np.load(state_path) + loaded_hotkeys = list(state['hotkeys']) self.step = int(state['step']) - self.scores = state['scores'] - self.hotkeys = list(state['hotkeys']) + self.scores = self._align_scores_to_metagraph(state['scores'], loaded_hotkeys) + self.hotkeys = copy.deepcopy(self.metagraph.hotkeys) bt.logging.success(f'Successfully loaded validator state from {state_path}') except FileNotFoundError: bt.logging.warning(f'No state file found at {state_path}, starting with fresh state') diff --git a/tests/validator/test_validator_state_alignment.py b/tests/validator/test_validator_state_alignment.py new file mode 100644 index 00000000..5f404a26 --- /dev/null +++ b/tests/validator/test_validator_state_alignment.py @@ -0,0 +1,77 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius + +"""Regression tests for validator state alignment after metagraph size changes.""" + +from unittest.mock import MagicMock + +import numpy as np + +from neurons.base.validator import BaseValidatorNeuron + + +class _ValidatorStub: + _align_scores_to_metagraph = BaseValidatorNeuron._align_scores_to_metagraph + load_state = BaseValidatorNeuron.load_state + + metagraph: MagicMock + config: MagicMock + step: int + scores: np.ndarray + hotkeys: list[str] + + def __init__(self, n: int, hotkeys: list[str]): + self.metagraph = MagicMock() + self.metagraph.n = n + self.metagraph.hotkeys = hotkeys + + +def test_align_scores_grows_with_metagraph(): + validator = _ValidatorStub(n=4, hotkeys=['h0', 'h1', 'h2', 'h3']) + scores = np.array([0.1, 0.2, 0.3], dtype=np.float32) + + aligned = validator._align_scores_to_metagraph(scores, ['h0', 'h1', 'h2']) + + assert aligned.shape == (4,) + assert np.allclose(aligned[:3], [0.1, 0.2, 0.3]) + assert aligned[3] == 0.0 + + +def test_align_scores_shrinks_with_metagraph(): + validator = _ValidatorStub(n=2, hotkeys=['h0', 'h1']) + scores = np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32) + + aligned = validator._align_scores_to_metagraph(scores, ['h0', 'h1', 'h2', 'h3']) + + assert aligned.shape == (2,) + assert np.allclose(aligned, [0.1, 0.2]) + + +def test_align_scores_zeros_replaced_hotkey(): + validator = _ValidatorStub(n=3, hotkeys=['h0', 'new_h1', 'h2']) + scores = np.array([0.1, 0.5, 0.4], dtype=np.float32) + + aligned = validator._align_scores_to_metagraph(scores, ['h0', 'old_h1', 'h2']) + + assert np.allclose(aligned, [0.1, 0.0, 0.4]) + + +def test_load_state_realigns_persisted_scores_to_current_metagraph(tmp_path, monkeypatch): + validator = _ValidatorStub(n=4, hotkeys=['h0', 'h1', 'h2', 'h3']) + validator.config = MagicMock() + validator.config.neuron.full_path = str(tmp_path) + state_path = tmp_path / 'state.npz' + np.savez( + state_path, + step=42, + scores=np.array([0.1, 0.2, 0.3], dtype=np.float32), + hotkeys=['h0', 'h1', 'h2'], + ) + + validator.load_state() + + assert validator.step == 42 + assert validator.scores.shape == (4,) + assert np.allclose(validator.scores[:3], [0.1, 0.2, 0.3]) + assert validator.scores[3] == 0.0 + assert validator.hotkeys == ['h0', 'h1', 'h2', 'h3']