Skip to content

refactor(types): centralize proposer selection on ValidatorIndex#732

Merged
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:refactor/hoist-proposer-for-slot
May 18, 2026
Merged

refactor(types): centralize proposer selection on ValidatorIndex#732
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:refactor/hoist-proposer-for-slot

Conversation

@tcoratger
Copy link
Copy Markdown
Collaborator

Summary

The round-robin arithmetic int(slot) % int(num_validators) was
duplicated between ValidatorIndex.is_proposer_for and a debug log
line in validator/service.py. Both sites now derive from one
classmethod, so the consensus-load-bearing rule cannot drift across
call sites.

What changed

types/validator.py

Added ValidatorIndex.proposer_for_slot(slot, num_validators)
classmethod:

@classmethod
def proposer_for_slot(cls, slot: Slot, num_validators: Uint64) -> "ValidatorIndex":
    """Return the validator index responsible for proposing at the given slot.

    Round-robin selection: the proposer is slot modulo registry size.
    """
    return cls(int(slot) % int(num_validators))

Rewrote is_proposer_for to delegate:

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)

subspecs/validator/service.py

Replaced the second copy of the arithmetic with a call to the
classmethod:

# before
expected_proposer = int(slot) % int(num_validators)

# after
expected_proposer = ValidatorIndex.proposer_for_slot(slot, num_validators)

tests/lean_spec/types/test_validator_utils.py

Added TestProposerForSlot with five tests covering round-robin,
wraparound, single validator, return type, and a parametric
consistency test that proves proposer_for_slot and is_proposer_for
agree at every slot for any registry size. The consistency test is
the architectural insurance: if either method drifts, it fails loudly.

Design notes

The agent originally suggested a free function. Pushed back during
review because the codebase convention is methods on types
(Checkpoint.advance_to, JustifiedSlots.is_slot_justified,
ValidatorIndex.is_valid, ValidatorIndex.compute_subnet_id).

Other placements considered:

  • Method on Slot would have read most naturally as
    slot.proposer(num) but types/validator.py already imports
    Slot, so the reverse reference would have required a forward-ref
    string or function-local import.
  • Method on Validators is the most OO reading — the registry
    knows its own size and selects its proposer. Blocked: Validators
    lives in fork-specific forks/lstar/containers/validator.py and
    the arithmetic is fork-stable.

The classmethod on ValidatorIndex is the idiomatic Python factory
pattern: a constructor that returns an instance from inputs. Lives
next to is_proposer_for, no new file, no cycle, no fork dependency.

Test plan

  • ruff check, ruff format --check, ty check all pass
  • pytest tests/lean_spec/types/test_validator_utils.py tests/lean_spec/subspecs/validator -> 109 passed
  • Consistency test verifies the classmethod and the predicate
    agree at every slot across registry sizes from 1 to 1000

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant