Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions hathor/feature_activation/feature_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,22 @@ def _calculate_new_state(
feature: Feature,
previous_state: FeatureState
) -> FeatureState:
"""Returns the new feature state based on the new block, the criteria, and the previous state."""
"""
Returns the new feature state based on the new boundary block, the criteria, and the previous state.

This method must only be called for boundary blocks, and calling it with a non-boundary block will raise
an AssertionError. Non-boundary blocks never calculate their own state, they get it from their parent block
instead.
"""
height = boundary_block.get_height()
criteria = self._feature_settings.features.get(feature)
evaluation_interval = self._feature_settings.evaluation_interval

if not criteria:
return FeatureState.DEFINED

assert not boundary_block.is_genesis, 'cannot calculate new state for genesis'
assert height % self._feature_settings.evaluation_interval == 0, (
'cannot calculate new state for a non-boundary block'
)
assert height % evaluation_interval == 0, 'cannot calculate new state for a non-boundary block'

if previous_state is FeatureState.DEFINED:
if height >= criteria.start_height:
Expand All @@ -92,32 +97,36 @@ def _calculate_new_state(
return FeatureState.DEFINED

if previous_state is FeatureState.STARTED:
if height >= criteria.timeout_height and not criteria.activate_on_timeout:
if height >= criteria.timeout_height and not criteria.lock_in_on_timeout:
return FeatureState.FAILED

if (
height >= criteria.timeout_height
and criteria.activate_on_timeout
and height >= criteria.minimum_activation_height
):
return FeatureState.ACTIVE

# Get the count for this block's parent. Since this is a boundary block, its parent count represents the
# previous evaluation interval count.
parent_block = boundary_block.get_block_parent()
counts = parent_block.get_feature_activation_bit_counts()
count = counts[criteria.bit]
threshold = criteria.get_threshold(self._feature_settings)

if (
height < criteria.timeout_height
and count >= threshold
and height >= criteria.minimum_activation_height
):
return FeatureState.ACTIVE
if height < criteria.timeout_height and count >= threshold:
return FeatureState.LOCKED_IN

if (height + evaluation_interval >= criteria.timeout_height) and criteria.lock_in_on_timeout:
return FeatureState.MUST_SIGNAL

return FeatureState.STARTED

if previous_state is FeatureState.MUST_SIGNAL:
# The MUST_SIGNAL state is defined to always take exactly one evaluation interval. Since this method is
# only called for boundary blocks, it is guaranteed that after exactly one evaluation interval in
# MUST_SIGNAL, the feature will transition to LOCKED_IN.
return FeatureState.LOCKED_IN

if previous_state is FeatureState.LOCKED_IN:
if height >= criteria.minimum_activation_height:
return FeatureState.ACTIVE

return FeatureState.LOCKED_IN

if previous_state is FeatureState.ACTIVE:
return FeatureState.ACTIVE

Expand Down
27 changes: 9 additions & 18 deletions hathor/feature_activation/model/criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class Criteria(BaseModel, validate_all=True):

minimum_activation_height: the height of the first block at which the feature is allowed to become active.

activate_on_timeout: whether the feature should be activated even if the activation criteria are not met when
lock_in_on_timeout: whether the feature should be activated even if the activation criteria are not met when
the timeout_height is reached, effectively forcing activation.

version: the client version of hathor-core at which this feature was defined.
Expand All @@ -55,7 +55,7 @@ class Criteria(BaseModel, validate_all=True):
timeout_height: NonNegativeInt
threshold: Optional[NonNegativeInt] = None
minimum_activation_height: NonNegativeInt = 0
activate_on_timeout: bool = False
lock_in_on_timeout: bool = False
version: str = Field(..., regex=version.BUILD_VERSION_REGEX)

def get_threshold(self, feature_settings: 'FeatureSettings') -> int:
Expand All @@ -75,11 +75,16 @@ def _validate_bit(cls, bit: int) -> int:
@validator('timeout_height')
def _validate_timeout_height(cls, timeout_height: int, values: dict[str, Any]) -> int:
"""Validates that the timeout_height is greater than the start_height."""
assert Criteria.evaluation_interval is not None, 'Criteria.evaluation_interval must be set'

start_height = values.get('start_height')
assert start_height is not None, 'start_height must be set'

if timeout_height <= start_height:
raise ValueError(f'timeout_height must be greater than start_height: {timeout_height} <= {start_height}')
minimum_timeout_height = start_height + 2 * Criteria.evaluation_interval

if timeout_height < minimum_timeout_height:
raise ValueError(f'timeout_height must be at least two evaluation intervals after the start_height: '
f'{timeout_height} < {minimum_timeout_height}')

return timeout_height

Expand All @@ -95,20 +100,6 @@ def _validate_threshold(cls, threshold: Optional[int]) -> Optional[int]:

return threshold

@validator('minimum_activation_height')
def _validate_minimum_activation_height(cls, minimum_activation_height: int, values: dict[str, Any]) -> int:
"""Validates that the minimum_activation_height is not greater than the timeout_height."""
timeout_height = values.get('timeout_height')
assert timeout_height is not None, 'timeout_height must be set'

if minimum_activation_height > timeout_height:
raise ValueError(
f'minimum_activation_height must not be greater than timeout_height: '
f'{minimum_activation_height} > {timeout_height}'
)

return minimum_activation_height

@validator('start_height', 'timeout_height', 'minimum_activation_height')
def _validate_evaluation_interval_multiple(cls, value: int) -> int:
"""Validates that the value is a multiple of evaluation_interval."""
Expand Down
10 changes: 6 additions & 4 deletions hathor/feature_activation/model/feature_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ class FeatureState(Enum):
FAILED: Represents that a certain feature is not and will never be activated.
"""

DEFINED = 0
STARTED = 1
ACTIVE = 2
FAILED = 3
DEFINED = 'DEFINED'
STARTED = 'STARTED'
MUST_SIGNAL = 'MUST_SIGNAL'
LOCKED_IN = 'LOCKED_IN'
ACTIVE = 'ACTIVE'
FAILED = 'FAILED'
10 changes: 5 additions & 5 deletions hathor/feature_activation/resources/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def render_GET(self, request: Request) -> bytes:
threshold_percentage = threshold_count / self._feature_settings.evaluation_interval
acceptance_percentage = None

if state is FeatureState.STARTED:
if state in [FeatureState.STARTED, FeatureState.MUST_SIGNAL]:
acceptance_count = bit_counts[criteria.bit]
acceptance_percentage = acceptance_count / self._feature_settings.evaluation_interval

Expand All @@ -70,7 +70,7 @@ def render_GET(self, request: Request) -> bytes:
start_height=criteria.start_height,
minimum_activation_height=criteria.minimum_activation_height,
timeout_height=criteria.timeout_height,
activate_on_timeout=criteria.activate_on_timeout,
lock_in_on_timeout=criteria.lock_in_on_timeout,
version=criteria.version
)

Expand All @@ -93,7 +93,7 @@ class GetFeatureResponse(Response, use_enum_values=True):
start_height: int
minimum_activation_height: int
timeout_height: int
activate_on_timeout: bool
lock_in_on_timeout: bool
version: str


Expand Down Expand Up @@ -128,7 +128,7 @@ class GetFeaturesResponse(Response):
'start_height': 0,
'minimum_activation_height': 0,
'timeout_height': 100,
'activate_on_timeout': False,
'lock_in_on_timeout': False,
'version': '0.1.0'
},
{
Expand All @@ -139,7 +139,7 @@ class GetFeaturesResponse(Response):
'start_height': 200,
'minimum_activation_height': 0,
'timeout_height': 300,
'activate_on_timeout': False,
'lock_in_on_timeout': False,
'version': '0.2.0'
}
]
Expand Down
15 changes: 7 additions & 8 deletions tests/feature_activation/test_criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
VALID_CRITERIA = dict(
bit=0,
start_height=1000,
timeout_height=2000,
timeout_height=3000,
threshold=0,
minimum_activation_height=0,
activate_on_timeout=False,
lock_in_on_timeout=False,
version='0.0.0'
)

Expand All @@ -43,7 +43,7 @@ class TestCriteria:
timeout_height=102_000,
threshold=1000,
minimum_activation_height=101_000,
activate_on_timeout=True,
lock_in_on_timeout=True,
version='0.52.3'
)
]
Expand Down Expand Up @@ -91,10 +91,10 @@ def test_start_height(self, start_height, error):
[
(-10, 'ensure this value is greater than or equal to 0'),
(-1, 'ensure this value is greater than or equal to 0'),
(1, 'timeout_height must be greater than start_height: 1 <= 1000'),
(45, 'timeout_height must be greater than start_height: 45 <= 1000'),
(100, 'timeout_height must be greater than start_height: 100 <= 1000'),
(1111, 'Should be a multiple of evaluation_interval: 1111 % 1000 != 0')
(1, 'timeout_height must be at least two evaluation intervals after the start_height: 1 < 3000'),
(45, 'timeout_height must be at least two evaluation intervals after the start_height: 45 < 3000'),
(100, 'timeout_height must be at least two evaluation intervals after the start_height: 100 < 3000'),
(3111, 'Should be a multiple of evaluation_interval: 3111 % 1000 != 0')
]
)
def test_timeout_height(self, timeout_height, error):
Expand Down Expand Up @@ -130,7 +130,6 @@ def test_threshold(self, threshold, error):
(1, 'Should be a multiple of evaluation_interval: 1 % 1000 != 0'),
(45, 'Should be a multiple of evaluation_interval: 45 % 1000 != 0'),
(100, 'Should be a multiple of evaluation_interval: 100 % 1000 != 0'),
(10_000, 'minimum_activation_height must not be greater than timeout_height: 10000 > 2000')
]
)
def test_minimum_activation_height(self, minimum_activation_height, error):
Expand Down
Loading