Skip to content

Commit 1589f87

Browse files
tcoratgerclaude
andauthored
refactor(types): centralize proposer selection on ValidatorIndex (leanEthereum#732)
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) <noreply@anthropic.com>
1 parent 08868e7 commit 1589f87

3 files changed

Lines changed: 55 additions & 12 deletions

File tree

src/lean_spec/subspecs/validator/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ async def _maybe_produce_block(self, slot: Slot) -> None:
277277
return
278278

279279
my_indices = list(self.registry.indices())
280-
expected_proposer = int(slot) % int(num_validators)
280+
expected_proposer = ValidatorIndex.proposer_for_slot(slot, num_validators)
281281
logger.debug(
282282
"Block production check: slot=%d num_validators=%d expected_proposer=%d my_indices=%s",
283283
slot,

src/lean_spec/types/validator.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
"""Validator-side scalar types — fork-stable.
2-
3-
Defines the integer-keyed validator identifier and the networking subnet id.
4-
The XMSS-bound `Validator` container itself stays in the fork package because
5-
its key shape is signature-scheme specific.
6-
"""
1+
"""Validator-side scalar types"""
72

83
from lean_spec.types.slot import Slot
94
from lean_spec.types.uint import Uint64
@@ -16,13 +11,17 @@ class SubnetId(Uint64):
1611
class ValidatorIndex(Uint64):
1712
"""Represents a validator's unique index as a 64-bit unsigned integer."""
1813

19-
def is_proposer_for(self, slot: Slot, num_validators: Uint64) -> bool:
20-
"""
21-
Check if this validator is the proposer for the given slot.
14+
@classmethod
15+
def proposer_for_slot(cls, slot: Slot, num_validators: Uint64) -> "ValidatorIndex":
16+
"""Return the validator index responsible for proposing at the given slot.
2217
23-
Uses round-robin proposer selection per the lean protocol spec.
18+
Round-robin selection: the proposer is slot modulo registry size.
2419
"""
25-
return int(slot) % int(num_validators) == int(self)
20+
return cls(int(slot) % int(num_validators))
21+
22+
def is_proposer_for(self, slot: Slot, num_validators: Uint64) -> bool:
23+
"""Check if this validator is the proposer for the given slot."""
24+
return self == ValidatorIndex.proposer_for_slot(slot, num_validators)
2625

2726
def is_valid(self, num_validators: Uint64) -> bool:
2827
"""Check if this index is within valid bounds for a registry of given size."""

tests/lean_spec/types/test_validator_utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,50 @@
55
from lean_spec.types import Slot, Uint64, ValidatorIndex
66

77

8+
class TestProposerForSlot:
9+
"""Tests for the ValidatorIndex.proposer_for_slot classmethod."""
10+
11+
def test_round_robin_assigns_slot_modulo_registry(self) -> None:
12+
"""The proposer index for slot s is s modulo registry size."""
13+
num_validators = Uint64(10)
14+
15+
assert ValidatorIndex.proposer_for_slot(Slot(0), num_validators) == ValidatorIndex(0)
16+
assert ValidatorIndex.proposer_for_slot(Slot(7), num_validators) == ValidatorIndex(7)
17+
assert ValidatorIndex.proposer_for_slot(Slot(9), num_validators) == ValidatorIndex(9)
18+
19+
def test_wraparound_past_registry_size(self) -> None:
20+
"""Slots past the registry size wrap back to index 0 and continue."""
21+
num_validators = Uint64(10)
22+
23+
assert ValidatorIndex.proposer_for_slot(Slot(10), num_validators) == ValidatorIndex(0)
24+
assert ValidatorIndex.proposer_for_slot(Slot(23), num_validators) == ValidatorIndex(3)
25+
assert ValidatorIndex.proposer_for_slot(Slot(100), num_validators) == ValidatorIndex(0)
26+
27+
def test_single_validator_always_proposes(self) -> None:
28+
"""A one-validator registry sees the same index at every slot."""
29+
num_validators = Uint64(1)
30+
only = ValidatorIndex(0)
31+
32+
for slot_num in (0, 1, 42, 1_000_000):
33+
assert ValidatorIndex.proposer_for_slot(Slot(slot_num), num_validators) == only
34+
35+
def test_return_type_is_validator_index(self) -> None:
36+
"""The classmethod returns a ValidatorIndex, not a plain int."""
37+
result = ValidatorIndex.proposer_for_slot(Slot(5), Uint64(7))
38+
assert isinstance(result, ValidatorIndex)
39+
40+
@pytest.mark.parametrize("num_validators", [1, 2, 5, 10, 100, 1000])
41+
def test_matches_is_proposer_for(self, num_validators: int) -> None:
42+
"""The classmethod and the predicate always agree on the chosen proposer."""
43+
registry_size = Uint64(num_validators)
44+
for slot_num in range(min(20, num_validators * 2)):
45+
slot = Slot(slot_num)
46+
chosen = ValidatorIndex.proposer_for_slot(slot, registry_size)
47+
for validator_idx in range(num_validators):
48+
candidate = ValidatorIndex(validator_idx)
49+
assert candidate.is_proposer_for(slot, registry_size) == (candidate == chosen)
50+
51+
852
class TestValidatorIndexIsProposerFor:
953
"""Test the is_proposer_for method on ValidatorIndex."""
1054

0 commit comments

Comments
 (0)