From f70b9e0a7deed7fc8d78b6bd9f8476010f2c0f2c Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Mon, 18 May 2026 17:52:51 +0200 Subject: [PATCH] refactor(types): centralize proposer selection on ValidatorIndex The round-robin arithmetic `int(slot) % int(num_validators)` was duplicated between `ValidatorIndex.is_proposer_for` and the debug log line in `validator/service.py`. Both sites now derive from one classmethod, so the rule cannot drift. Adds `ValidatorIndex.proposer_for_slot(slot, num_validators)` as a classmethod factory. Returns a `ValidatorIndex`, type lives on the type it returns. `is_proposer_for` becomes a one-line predicate that delegates to the classmethod. Picked over a free function or a method on `Slot`/`Validators`: - Free function felt procedural in an OO codebase where every other selection helper in `types/` is a method. - Method on `Slot` would have needed a forward reference to `ValidatorIndex` and a circular-import workaround. - Method on `Validators` lives in fork-specific code, but the arithmetic is fork-stable. The classmethod sits next to `is_proposer_for` in `types/validator.py`, no new file, no cycle, no fork dependency. A parametric consistency test verifies the classmethod and the predicate agree at every slot for any registry size. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lean_spec/subspecs/validator/service.py | 2 +- src/lean_spec/types/validator.py | 21 +++++---- tests/lean_spec/types/test_validator_utils.py | 44 +++++++++++++++++++ 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/lean_spec/subspecs/validator/service.py b/src/lean_spec/subspecs/validator/service.py index b129b87ce..2075eb5fa 100644 --- a/src/lean_spec/subspecs/validator/service.py +++ b/src/lean_spec/subspecs/validator/service.py @@ -277,7 +277,7 @@ async def _maybe_produce_block(self, slot: Slot) -> None: return my_indices = list(self.registry.indices()) - expected_proposer = int(slot) % int(num_validators) + expected_proposer = ValidatorIndex.proposer_for_slot(slot, num_validators) logger.debug( "Block production check: slot=%d num_validators=%d expected_proposer=%d my_indices=%s", slot, diff --git a/src/lean_spec/types/validator.py b/src/lean_spec/types/validator.py index e454a3042..45308dbdd 100644 --- a/src/lean_spec/types/validator.py +++ b/src/lean_spec/types/validator.py @@ -1,9 +1,4 @@ -"""Validator-side scalar types — fork-stable. - -Defines the integer-keyed validator identifier and the networking subnet id. -The XMSS-bound `Validator` container itself stays in the fork package because -its key shape is signature-scheme specific. -""" +"""Validator-side scalar types""" from lean_spec.types.slot import Slot from lean_spec.types.uint import Uint64 @@ -16,13 +11,17 @@ class SubnetId(Uint64): class ValidatorIndex(Uint64): """Represents a validator's unique index as a 64-bit unsigned integer.""" - def is_proposer_for(self, slot: Slot, num_validators: Uint64) -> bool: - """ - Check if this validator is the proposer for the given slot. + @classmethod + def proposer_for_slot(cls, slot: Slot, num_validators: Uint64) -> "ValidatorIndex": + """Return the validator index responsible for proposing at the given slot. - Uses round-robin proposer selection per the lean protocol spec. + Round-robin selection: the proposer is slot modulo registry size. """ - return int(slot) % int(num_validators) == int(self) + return cls(int(slot) % int(num_validators)) + + def is_proposer_for(self, slot: Slot, num_validators: Uint64) -> bool: + """Check if this validator is the proposer for the given slot.""" + return self == ValidatorIndex.proposer_for_slot(slot, num_validators) def is_valid(self, num_validators: Uint64) -> bool: """Check if this index is within valid bounds for a registry of given size.""" diff --git a/tests/lean_spec/types/test_validator_utils.py b/tests/lean_spec/types/test_validator_utils.py index 31600be1a..756cd2e2d 100644 --- a/tests/lean_spec/types/test_validator_utils.py +++ b/tests/lean_spec/types/test_validator_utils.py @@ -5,6 +5,50 @@ from lean_spec.types import Slot, Uint64, ValidatorIndex +class TestProposerForSlot: + """Tests for the ValidatorIndex.proposer_for_slot classmethod.""" + + def test_round_robin_assigns_slot_modulo_registry(self) -> None: + """The proposer index for slot s is s modulo registry size.""" + num_validators = Uint64(10) + + assert ValidatorIndex.proposer_for_slot(Slot(0), num_validators) == ValidatorIndex(0) + assert ValidatorIndex.proposer_for_slot(Slot(7), num_validators) == ValidatorIndex(7) + assert ValidatorIndex.proposer_for_slot(Slot(9), num_validators) == ValidatorIndex(9) + + def test_wraparound_past_registry_size(self) -> None: + """Slots past the registry size wrap back to index 0 and continue.""" + num_validators = Uint64(10) + + assert ValidatorIndex.proposer_for_slot(Slot(10), num_validators) == ValidatorIndex(0) + assert ValidatorIndex.proposer_for_slot(Slot(23), num_validators) == ValidatorIndex(3) + assert ValidatorIndex.proposer_for_slot(Slot(100), num_validators) == ValidatorIndex(0) + + def test_single_validator_always_proposes(self) -> None: + """A one-validator registry sees the same index at every slot.""" + num_validators = Uint64(1) + only = ValidatorIndex(0) + + for slot_num in (0, 1, 42, 1_000_000): + assert ValidatorIndex.proposer_for_slot(Slot(slot_num), num_validators) == only + + def test_return_type_is_validator_index(self) -> None: + """The classmethod returns a ValidatorIndex, not a plain int.""" + result = ValidatorIndex.proposer_for_slot(Slot(5), Uint64(7)) + assert isinstance(result, ValidatorIndex) + + @pytest.mark.parametrize("num_validators", [1, 2, 5, 10, 100, 1000]) + def test_matches_is_proposer_for(self, num_validators: int) -> None: + """The classmethod and the predicate always agree on the chosen proposer.""" + registry_size = Uint64(num_validators) + for slot_num in range(min(20, num_validators * 2)): + slot = Slot(slot_num) + chosen = ValidatorIndex.proposer_for_slot(slot, registry_size) + for validator_idx in range(num_validators): + candidate = ValidatorIndex(validator_idx) + assert candidate.is_proposer_for(slot, registry_size) == (candidate == chosen) + + class TestValidatorIndexIsProposerFor: """Test the is_proposer_for method on ValidatorIndex."""