Skip to content

Commit 5bf27fd

Browse files
tcoratgerclaude
andauthored
refactor: add Interval.from_unix_time and Interval.from_slot classmethods (leanEthereum#544)
Centralizes two repeated conversion patterns into the Interval class: - from_unix_time: replaces inline (unix - genesis) * 1000 // MS_PER_INTERVAL - from_slot: replaces inline slot * INTERVALS_PER_SLOT (was duplicated in 6 places) Adds 14 unit tests covering both classmethods, including a consistency check that from_slot and from_unix_time agree at slot boundaries. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7aee50b commit 5bf27fd

7 files changed

Lines changed: 163 additions & 25 deletions

File tree

packages/testing/src/consensus_testing/test_fixtures/fork_choice.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@
1212
from pydantic import model_validator
1313

1414
from lean_spec.subspecs.chain.clock import Interval
15-
from lean_spec.subspecs.chain.config import (
16-
INTERVALS_PER_SLOT,
17-
MILLISECONDS_PER_INTERVAL,
18-
)
1915
from lean_spec.subspecs.containers.block import (
2016
Block,
2117
BlockBody,
@@ -44,8 +40,6 @@
4440
)
4541
from .base import BaseConsensusFixture
4642

47-
DEFAULT_VALIDATOR_ID = ValidatorIndex(0)
48-
4943

5044
class ForkChoiceTest(BaseConsensusFixture):
5145
"""
@@ -211,7 +205,7 @@ def make_fixture(self) -> Self:
211205
store = Store.from_anchor(
212206
self.anchor_state,
213207
self.anchor_block,
214-
validator_id=DEFAULT_VALIDATOR_ID,
208+
validator_id=ValidatorIndex(0),
215209
)
216210

217211
# Block registry for fork creation
@@ -235,8 +229,9 @@ def make_fixture(self) -> Self:
235229
#
236230
# TickStep.time is a Unix timestamp in seconds.
237231
# Convert to intervals since genesis for the store.
238-
delta_ms = (Uint64(step.time) - store.config.genesis_time) * Uint64(1000)
239-
target_interval = Interval(delta_ms // MILLISECONDS_PER_INTERVAL)
232+
target_interval = Interval.from_unix_time(
233+
Uint64(step.time), store.config.genesis_time
234+
)
240235
store, _ = store.on_tick(
241236
target_interval, has_proposal=False, is_aggregator=True
242237
)
@@ -266,7 +261,7 @@ def make_fixture(self) -> Self:
266261
# Store rejects blocks from the future.
267262
# This tick includes a block (has proposal).
268263
# Always act as aggregator to ensure gossip signatures are aggregated
269-
target_interval = Interval(block.slot * INTERVALS_PER_SLOT)
264+
target_interval = Interval.from_slot(block.slot)
270265
store, _ = store.on_tick(
271266
target_interval, has_proposal=True, is_aggregator=True
272267
)

src/lean_spec/subspecs/chain/clock.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,58 @@
1818
from lean_spec.subspecs.containers.slot import Slot
1919
from lean_spec.types import Uint64
2020

21-
from .config import MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, SECONDS_PER_SLOT
21+
from .config import (
22+
INTERVALS_PER_SLOT,
23+
MILLISECONDS_PER_INTERVAL,
24+
MILLISECONDS_PER_SLOT,
25+
SECONDS_PER_SLOT,
26+
)
2227

2328

2429
class Interval(Uint64):
2530
"""Interval count since genesis (matches ``Store.time``)."""
2631

32+
@classmethod
33+
def from_unix_time(cls, unix_seconds: Uint64, genesis_time: Uint64) -> Interval:
34+
"""
35+
Convert a Unix timestamp to a total interval count since genesis.
36+
37+
Useful when external inputs provide absolute timestamps but the
38+
store tracks time as intervals (800 ms each, 5 per slot).
39+
40+
Args:
41+
unix_seconds: Absolute Unix timestamp in seconds.
42+
genesis_time: Genesis Unix timestamp in seconds.
43+
44+
Returns:
45+
Total intervals elapsed between genesis and the given time.
46+
"""
47+
# Convert the elapsed seconds to milliseconds.
48+
#
49+
# The store measures time at sub-second granularity (800 ms intervals),
50+
# so second-precision input must be scaled up before dividing.
51+
delta_ms = (unix_seconds - genesis_time) * Uint64(1000)
52+
53+
# Truncate to whole intervals.
54+
return cls(delta_ms // MILLISECONDS_PER_INTERVAL)
55+
56+
@classmethod
57+
def from_slot(cls, slot: Uint64) -> Interval:
58+
"""
59+
Convert a slot number to the interval at that slot's start.
60+
61+
Each slot spans a fixed number of intervals.
62+
This gives the first interval of the given slot.
63+
64+
Args:
65+
slot: Slot number since genesis.
66+
67+
Returns:
68+
Interval count at the start of the given slot.
69+
"""
70+
# Slot boundaries fall on exact multiples of the interval count.
71+
return cls(slot * INTERVALS_PER_SLOT)
72+
2773

2874
@dataclass(frozen=True, slots=True)
2975
class SlotClock:

src/lean_spec/subspecs/forkchoice/store.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ def from_anchor(
221221
# The state internally might have zero-hash checkpoints (if genesis),
222222
# but the Store must treat the anchor block as the justified/finalized point.
223223
return cls(
224-
time=Interval(anchor_slot * INTERVALS_PER_SLOT),
224+
time=Interval.from_slot(anchor_slot),
225225
config=state.config,
226226
head=anchor_root,
227227
safe_target=anchor_root,
@@ -1191,7 +1191,7 @@ def get_proposal_head(self, slot: Slot) -> tuple["Store", Bytes32]:
11911191
Tuple of (new Store with updated time, head root for building).
11921192
"""
11931193
# Advance time to this slot's first interval
1194-
target_interval = Interval(slot * INTERVALS_PER_SLOT)
1194+
target_interval = Interval.from_slot(slot)
11951195
store, _ = self.on_tick(target_interval, True)
11961196

11971197
# Process any pending attestations before proposal

src/lean_spec/subspecs/node/node.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@
2222
from lean_spec.subspecs.api import ApiServer, ApiServerConfig
2323
from lean_spec.subspecs.chain import SlotClock
2424
from lean_spec.subspecs.chain.clock import Interval
25-
from lean_spec.subspecs.chain.config import (
26-
ATTESTATION_COMMITTEE_COUNT,
27-
INTERVALS_PER_SLOT,
28-
)
25+
from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT
2926
from lean_spec.subspecs.chain.service import ChainService
3027
from lean_spec.subspecs.containers import Block, BlockBody, SignedBlock, State
3128
from lean_spec.subspecs.containers.attestation import SignedAttestation
@@ -393,7 +390,7 @@ def _try_load_store_from_database(
393390
# Instead, derive time from wall clock, floored by the block's slot.
394391
clock = SlotClock(genesis_time=genesis_time or _ZERO_TIME, time_fn=time_fn)
395392
wall_clock_intervals = clock.total_intervals()
396-
block_intervals = Interval(head_block.slot * INTERVALS_PER_SLOT)
393+
block_intervals = Interval.from_slot(head_block.slot)
397394
store_time = max(wall_clock_intervals, block_intervals)
398395

399396
# Reconstruct minimal store from persisted data.

tests/lean_spec/helpers/builders.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from consensus_testing.keys import XmssKeyManager
1313

1414
from lean_spec.subspecs.chain.clock import Interval, SlotClock
15-
from lean_spec.subspecs.chain.config import INTERVALS_PER_SLOT
1615
from lean_spec.subspecs.containers import (
1716
AttestationData,
1817
Block,
@@ -464,7 +463,7 @@ def make_signed_block_from_store(
464463
),
465464
)
466465

467-
target_interval = Interval(block.slot * INTERVALS_PER_SLOT)
466+
target_interval = Interval.from_slot(block.slot)
468467
advanced_store, _ = store.on_tick(target_interval, has_proposal=True)
469468

470469
return advanced_store, signed_block

tests/lean_spec/subspecs/chain/test_clock.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,119 @@
11
"""Tests for the SlotClock time-to-slot converter."""
22

3+
from __future__ import annotations
4+
35
import pytest
46

57
from lean_spec.subspecs.chain import Interval, SlotClock
68
from lean_spec.subspecs.chain.config import (
9+
INTERVALS_PER_SLOT,
710
MILLISECONDS_PER_INTERVAL,
11+
MILLISECONDS_PER_SLOT,
812
SECONDS_PER_SLOT,
913
)
1014
from lean_spec.subspecs.containers import Slot
1115
from lean_spec.types import Uint64
1216

17+
GENESIS_TIME = Uint64(1_700_000_000)
18+
19+
20+
class TestIntervalFromUnixTime:
21+
"""Tests for Interval.from_unix_time()."""
22+
23+
def test_at_genesis(self) -> None:
24+
"""Returns interval 0 when unix_seconds equals genesis_time."""
25+
assert Interval.from_unix_time(GENESIS_TIME, GENESIS_TIME) == Interval(0)
26+
27+
def test_one_second_after_genesis(self) -> None:
28+
"""One second equals 1000ms, yielding 1000 // 800 = 1 interval."""
29+
result = Interval.from_unix_time(GENESIS_TIME + Uint64(1), GENESIS_TIME)
30+
assert result == Interval(1)
31+
32+
def test_one_slot_after_genesis(self) -> None:
33+
"""One full slot (4s = 4000ms) yields 4000 // 800 = 5 intervals."""
34+
result = Interval.from_unix_time(GENESIS_TIME + SECONDS_PER_SLOT, GENESIS_TIME)
35+
expected = Interval(int(MILLISECONDS_PER_SLOT // MILLISECONDS_PER_INTERVAL))
36+
assert result == expected
37+
38+
def test_sub_interval_rounds_down(self) -> None:
39+
"""Partial intervals are truncated by integer division.
40+
41+
At 0 full seconds past genesis the delta_ms is 0, so the result is 0.
42+
The method only accepts whole-second Uint64 values, so sub-interval
43+
precision only surfaces when 1000ms is not a multiple of the interval.
44+
Here we verify the floor behaviour across the first few seconds.
45+
"""
46+
# 0s -> 0ms -> 0 intervals
47+
assert Interval.from_unix_time(GENESIS_TIME, GENESIS_TIME) == Interval(0)
48+
# 1s -> 1000ms -> 1000 // 800 = 1 (remainder 200ms truncated)
49+
assert Interval.from_unix_time(GENESIS_TIME + Uint64(1), GENESIS_TIME) == Interval(1)
50+
# 2s -> 2000ms -> 2000 // 800 = 2 (remainder 400ms truncated)
51+
assert Interval.from_unix_time(GENESIS_TIME + Uint64(2), GENESIS_TIME) == Interval(2)
52+
# 3s -> 3000ms -> 3000 // 800 = 3 (remainder 600ms truncated)
53+
assert Interval.from_unix_time(GENESIS_TIME + Uint64(3), GENESIS_TIME) == Interval(3)
54+
55+
def test_multiple_slots(self) -> None:
56+
"""Ten slots (40s = 40000ms) yields 40000 // 800 = 50 intervals."""
57+
ten_slots = Uint64(10) * SECONDS_PER_SLOT
58+
result = Interval.from_unix_time(GENESIS_TIME + ten_slots, GENESIS_TIME)
59+
expected_intervals = (ten_slots * Uint64(1000)) // MILLISECONDS_PER_INTERVAL
60+
assert result == Interval(expected_intervals)
61+
62+
def test_return_type_is_interval(self) -> None:
63+
"""Return value is an Interval instance, not a plain Uint64."""
64+
result = Interval.from_unix_time(GENESIS_TIME + Uint64(5), GENESIS_TIME)
65+
assert isinstance(result, Interval)
66+
67+
def test_large_time_delta(self) -> None:
68+
"""Works correctly with a large time delta (one day = 86400s)."""
69+
one_day = Uint64(86400)
70+
result = Interval.from_unix_time(GENESIS_TIME + one_day, GENESIS_TIME)
71+
expected = Interval((one_day * Uint64(1000)) // MILLISECONDS_PER_INTERVAL)
72+
assert result == expected
73+
74+
def test_genesis_time_zero(self) -> None:
75+
"""Works when genesis_time is zero."""
76+
result = Interval.from_unix_time(Uint64(4), Uint64(0))
77+
# 4s = 4000ms -> 4000 // 800 = 5
78+
assert result == Interval(5)
79+
80+
81+
class TestIntervalFromSlot:
82+
"""Tests for Interval.from_slot()."""
83+
84+
def test_slot_zero(self) -> None:
85+
"""Slot 0 maps to interval 0."""
86+
assert Interval.from_slot(Uint64(0)) == Interval(0)
87+
88+
def test_slot_one(self) -> None:
89+
"""Slot 1 maps to interval equal to INTERVALS_PER_SLOT."""
90+
assert Interval.from_slot(Uint64(1)) == Interval(INTERVALS_PER_SLOT)
91+
92+
def test_slot_three(self) -> None:
93+
"""Slot 3 maps to interval 3 * INTERVALS_PER_SLOT."""
94+
assert Interval.from_slot(Uint64(3)) == Interval(Uint64(3) * INTERVALS_PER_SLOT)
95+
96+
def test_multiple_slots(self) -> None:
97+
"""Each slot N maps to interval N * INTERVALS_PER_SLOT."""
98+
for n in range(10):
99+
slot = Uint64(n)
100+
assert Interval.from_slot(slot) == Interval(slot * INTERVALS_PER_SLOT)
101+
102+
def test_return_type_is_interval(self) -> None:
103+
"""Return value is an Interval instance, not a plain Uint64."""
104+
result = Interval.from_slot(Uint64(2))
105+
assert isinstance(result, Interval)
106+
107+
def test_consistent_with_from_unix_time(self) -> None:
108+
"""from_slot(N) equals from_unix_time at genesis + N * SECONDS_PER_SLOT."""
109+
for n in range(5):
110+
slot = Uint64(n)
111+
from_slot_result = Interval.from_slot(slot)
112+
from_unix_result = Interval.from_unix_time(
113+
GENESIS_TIME + slot * SECONDS_PER_SLOT, GENESIS_TIME
114+
)
115+
assert from_slot_result == from_unix_result
116+
13117

14118
class TestCurrentSlot:
15119
"""Tests for current_slot()."""

tests/lean_spec/subspecs/forkchoice/test_attestation_target.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
from consensus_testing.keys import XmssKeyManager
77

88
from lean_spec.subspecs.chain.clock import Interval
9-
from lean_spec.subspecs.chain.config import (
10-
INTERVALS_PER_SLOT,
11-
JUSTIFICATION_LOOKBACK_SLOTS,
12-
)
9+
from lean_spec.subspecs.chain.config import JUSTIFICATION_LOOKBACK_SLOTS
1310
from lean_spec.subspecs.containers import (
1411
Attestation,
1512
AttestationData,
@@ -567,7 +564,7 @@ def test_attestation_target_after_on_block(
567564

568565
# Process block via on_block on a fresh consumer store
569566
consumer_store = observer_store
570-
target_interval = Interval(block.slot * INTERVALS_PER_SLOT)
567+
target_interval = Interval.from_slot(block.slot)
571568
consumer_store, _ = consumer_store.on_tick(target_interval, has_proposal=True)
572569
consumer_store = consumer_store.on_block(signed_block)
573570

0 commit comments

Comments
 (0)