Skip to content

Commit faf7a4d

Browse files
tcoratgerclaude
andauthored
refactor(spec): typed rejections replace prose-matched classification (leanEthereum#871)
Spec rejections previously surfaced as bare assertions whose English messages the testing framework substring-matched into language-neutral reasons. Any reword silently re-bucketed vectors or crashed fills, and distinct conditions collapsed into shared buckets. Spec side: - Add a typed rejection error carrying its reason. It subclasses the assertion error so existing rejection handlers keep working. - Convert every rejection assertion across state transition, fork choice, signatures, validator duties, and participation to raise the typed error. Messages are preserved verbatim. - Split the block-level proof failure out of the signature bucket: a failing multi-message block proof now emits its own reason, distinct from a failing gossip attestation signature. - Rename the reasons module to errors, reflecting that it now carries the error type alongside the vocabulary. Framework side: - The classifier reads the structured reason field; the fragment table is deleted. The aggregation error keeps its by-type fallback. - Every step expected to fail must declare its expected rejection, and the invalid-anchor path requires one as well. A vector saying only "reject this" would let a client reject for the wrong reason. - Exception catches narrow to spec rejections and aggregation errors. Harness bugs now propagate instead of being emitted as vectors. - Block steps gain a flag controlling whether the store clock advances to the block's slot before import, emitted on the filled step so replaying clients know the timing. New vectors pin that block import has no arrival-time gate: an early block imports and becomes head. - The unsupported signature fields on state transition specs fail at construction instead of deep inside generation, and the class documents that signatures are verified upstream. - The duplicated decode-failure assertion logic collapses into one base helper that resolves the emitted reason centrally. Documentation rules 6-8 added: one-line docstrings by default, no question-style docstring openings, no member enumeration in container docstrings. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 87d2667 commit faf7a4d

20 files changed

Lines changed: 409 additions & 215 deletions

File tree

.claude/rules/documentation.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ If removing the comment would not confuse a future reader, do not write it.
7474
Never remove or rewrite a comment or docstring unless the documented behavior actually changed.
7575
Removing valid docs creates diff noise and destroys context.
7676

77+
### 6. One-line docstrings by default
78+
79+
A class, function, or constant of ordinary complexity gets exactly one summary line.
80+
Add a body only when a non-obvious design choice or invariant must be explained.
81+
A body that restates what the summary already implies is noise.
82+
83+
### 7. Docstring summaries are noun phrases, not questions
84+
85+
Never open a docstring with "Why ..." or any question-style phrasing.
86+
Name the thing plainly, the way a human would label it.
87+
88+
### 8. Never enumerate the container's contents in its docstring
89+
90+
Do not list members, variants, or fields in a class or enum docstring.
91+
The list rots the moment the class changes.
92+
Stay generic; each member documents itself.
93+
94+
Bad:
95+
```python
96+
class RejectionReason(StrEnum):
97+
"""Why the spec rejects an invalid block, attestation, or wire message."""
98+
```
99+
100+
Good:
101+
```python
102+
class RejectionReason(StrEnum):
103+
"""Language-neutral reason the spec rejects an invalid input."""
104+
```
105+
77106
## Style requirements
78107

79108
### Sentences
Lines changed: 8 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,8 @@
11
"""Classification of spec rejections into language-neutral reasons."""
22

3-
from lean_spec.spec.forks import RejectionReason
3+
from lean_spec.spec.forks import RejectionReason, SpecRejectionError
44
from lean_spec.spec.forks.lstar.containers import AggregationError
55

6-
_REASON_BY_MESSAGE_FRAGMENT: list[tuple[str, RejectionReason]] = [
7-
("Target slot must be in the future", RejectionReason.BLOCK_SLOT_NOT_IN_FUTURE),
8-
("Block is older than latest header", RejectionReason.BLOCK_OLDER_THAN_LATEST_HEADER),
9-
("Block slot mismatch", RejectionReason.BLOCK_SLOT_MISMATCH),
10-
("Block parent root mismatch", RejectionReason.PARENT_ROOT_MISMATCH),
11-
("Invalid block state root", RejectionReason.STATE_ROOT_MISMATCH),
12-
("Parent state not found", RejectionReason.UNKNOWN_PARENT_BLOCK),
13-
("Sync parent chain", RejectionReason.UNKNOWN_PARENT_BLOCK),
14-
("Proposer index out of range", RejectionReason.PROPOSER_INDEX_OUT_OF_RANGE),
15-
("is not the proposer for slot", RejectionReason.WRONG_PROPOSER),
16-
("Incorrect block proposer", RejectionReason.WRONG_PROPOSER),
17-
(
18-
"Aggregated attestation must reference at least one validator",
19-
RejectionReason.EMPTY_AGGREGATION_BITS,
20-
),
21-
("distinct AttestationData entries", RejectionReason.TOO_MANY_ATTESTATION_DATA),
22-
("duplicate AttestationData", RejectionReason.DUPLICATE_ATTESTATION_DATA),
23-
("Unknown source block", RejectionReason.UNKNOWN_SOURCE_BLOCK),
24-
("Unknown target block", RejectionReason.UNKNOWN_TARGET_BLOCK),
25-
("Unknown head block", RejectionReason.UNKNOWN_HEAD_BLOCK),
26-
("Source checkpoint slot must not exceed target", RejectionReason.SOURCE_AFTER_TARGET),
27-
("Head checkpoint must not be older than target", RejectionReason.HEAD_OLDER_THAN_TARGET),
28-
("Source checkpoint slot mismatch", RejectionReason.SOURCE_SLOT_MISMATCH),
29-
("Target checkpoint slot mismatch", RejectionReason.TARGET_SLOT_MISMATCH),
30-
("Head checkpoint slot mismatch", RejectionReason.HEAD_SLOT_MISMATCH),
31-
(
32-
"Source checkpoint must be ancestor of target",
33-
RejectionReason.SOURCE_NOT_ANCESTOR_OF_TARGET,
34-
),
35-
(
36-
"Target checkpoint must be ancestor of head",
37-
RejectionReason.TARGET_NOT_ANCESTOR_OF_HEAD,
38-
),
39-
("Attestation too far in future", RejectionReason.ATTESTATION_TOO_FAR_IN_FUTURE),
40-
("not found in state", RejectionReason.VALIDATOR_NOT_IN_STATE),
41-
("Validator index out of range", RejectionReason.VALIDATOR_INDEX_OUT_OF_RANGE),
42-
("Signature verification failed", RejectionReason.INVALID_SIGNATURE),
43-
("Block proof verification failed", RejectionReason.INVALID_SIGNATURE),
44-
("Anchor block state root must match", RejectionReason.ANCHOR_STATE_ROOT_MISMATCH),
45-
]
46-
"""
47-
Ordered mapping from spec rejection messages to reasons.
48-
49-
The first fragment contained in the exception message wins.
50-
Fragments mirror the spec's assertion messages one-to-one.
51-
"""
52-
536

547
def classify_rejection(exception: Exception) -> RejectionReason:
558
"""
@@ -62,19 +15,18 @@ def classify_rejection(exception: Exception) -> RejectionReason:
6215
The reason emitted into the test vector.
6316
6417
Raises:
65-
ValueError: If the rejection is not in the vocabulary yet.
18+
ValueError: If the exception carries no reason.
6619
"""
20+
# Typed rejections carry their reason directly.
21+
if isinstance(exception, SpecRejectionError):
22+
return exception.reason
23+
6724
# Aggregate proof failures carry library-specific messages.
6825
# The type alone identifies them as signature verification failures.
6926
if isinstance(exception, AggregationError):
7027
return RejectionReason.INVALID_SIGNATURE
7128

72-
message = str(exception)
73-
for message_fragment, reason in _REASON_BY_MESSAGE_FRAGMENT:
74-
if message_fragment in message:
75-
return reason
76-
7729
raise ValueError(
78-
f"no rejection reason mapped for {type(exception).__name__}: {message}\n"
79-
"Add the new rejection to the reason vocabulary and this mapping."
30+
f"no rejection reason carried by {type(exception).__name__}: {exception}\n"
31+
"Spec rejections must raise the typed rejection error with a reason."
8032
)

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,34 @@ def resolve_rejection_reason(self, exception_raised: Exception) -> RejectionReas
234234
f"but the test expects {self.expected_rejection.reason}"
235235
)
236236
return classified_reason
237+
238+
def assert_decode_rejection(
239+
self,
240+
exception_raised: Exception | None,
241+
decoder_name: str,
242+
) -> RejectionReason:
243+
"""
244+
Check a decode-failure outcome and resolve the emitted reason.
245+
246+
Decode failures never reach the rejection classifier.
247+
The authored expectation is the only source of the emitted reason.
248+
249+
Args:
250+
exception_raised: The exception the decoder raised, or None on success.
251+
decoder_name: Decoder label for failure messages.
252+
253+
Returns:
254+
The reason emitted into the test vector.
255+
256+
Raises:
257+
ValueError: When the authored expectation is missing.
258+
AssertionError: When decoding succeeds or contradicts the expectation.
259+
"""
260+
if self.expected_rejection is None:
261+
raise ValueError("decode-failure vectors require expected_rejection to be set")
262+
if exception_raised is None:
263+
raise AssertionError(
264+
f"Expected {decoder_name} to reject the input, but decoding succeeded"
265+
)
266+
self.assert_expected_outcome(exception_raised)
267+
return self.expected_rejection.reason

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

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,16 @@
2626
from lean_spec.config import LEAN_ENV
2727
from lean_spec.node.chain.clock import SlotClock
2828
from lean_spec.spec.crypto.merkleization import hash_tree_root
29-
from lean_spec.spec.forks import Interval, RejectionReason, Slot, ValidatorIndex
29+
from lean_spec.spec.forks import (
30+
Interval,
31+
RejectionReason,
32+
Slot,
33+
SpecRejectionError,
34+
ValidatorIndex,
35+
)
3036
from lean_spec.spec.forks.lstar.containers import (
3137
AggregatedAttestations,
38+
AggregationError,
3239
Block,
3340
BlockBody,
3441
SignedAggregatedAttestation,
@@ -302,14 +309,15 @@ def generate(self) -> ForkChoiceFixture:
302309
)
303310
block_registry[step.block.label] = filled_block
304311

305-
# Advance time to the block's slot.
306-
# Store rejects blocks from the future.
312+
# Advance time to the block's slot unless the test
313+
# delivers the block ahead of the store clock.
307314
# This tick includes a block (has proposal).
308315
# Always act as aggregator to ensure gossip signatures are aggregated
309-
target_interval = Interval.from_slot(filled_block.slot)
310-
store, _ = spec.on_tick(
311-
store, target_interval, has_proposal=True, is_aggregator=True
312-
)
316+
if step.tick_to_slot:
317+
target_interval = Interval.from_slot(filled_block.slot)
318+
store, _ = spec.on_tick(
319+
store, target_interval, has_proposal=True, is_aggregator=True
320+
)
313321

314322
# Process the block through Store.
315323
# This validates, applies state transition, and updates the store's head.
@@ -360,9 +368,10 @@ def generate(self) -> ForkChoiceFixture:
360368
old_head=old_head,
361369
)
362370

363-
except Exception as exception:
371+
except (SpecRejectionError, AggregationError) as exception:
364372
# Handle expected failures.
365-
# Steps marked valid=False should raise exceptions.
373+
# Steps marked valid=False should raise spec rejections.
374+
# Harness bugs raise other types and propagate unswallowed.
366375
if step.valid:
367376
raise AssertionError(
368377
f"Step {step_index} ({type(step).__name__}) "
@@ -404,6 +413,7 @@ def generate(self) -> ForkChoiceFixture:
404413
rejection_reason=rejection_reason,
405414
checks=step.checks,
406415
store_snapshot=store_snapshot,
416+
tick_to_slot=step.tick_to_slot,
407417
block=filled_block,
408418
block_root_label=step.block.label,
409419
)
@@ -462,18 +472,19 @@ def _generate_invalid_anchor(
462472
"steps must be empty when anchor_valid is False: "
463473
"Store.from_anchor is expected to fail before any step can run"
464474
)
475+
# Why: a vector saying only "reject this anchor" lets a client
476+
# reject for the wrong reason and still pass.
477+
assert self.expected_rejection is not None, (
478+
"anchor_valid=False requires expected_rejection to be set"
479+
)
465480
try:
466481
spec.create_store(
467482
self.anchor_state,
468483
anchor_block,
469484
validator_index=ValidatorIndex(0),
470485
)
471-
except AssertionError as exception:
472-
expected_substring = (
473-
self.expected_rejection.message_substring
474-
if self.expected_rejection is not None
475-
else None
476-
)
486+
except SpecRejectionError as exception:
487+
expected_substring = self.expected_rejection.message_substring
477488
if expected_substring is not None and expected_substring not in str(exception):
478489
raise AssertionError(
479490
"Store.from_anchor failed with wrong error.\n"

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

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -774,32 +774,12 @@ class NetworkingCodecTest(BaseTestSpec):
774774
def generate(self) -> NetworkingCodecFixture:
775775
"""Run the codec case and emit the vector."""
776776
if isinstance(self.codec, DecodeFailure):
777-
return self._generate_decode_failure(self.codec)
778-
return NetworkingCodecFixture(codec=self.codec, output=self.codec.run())
779-
780-
def _generate_decode_failure(self, decode_failure: DecodeFailure) -> NetworkingCodecFixture:
781-
"""
782-
Assert the decoder rejects the malformed input and emit the rejection vector.
783-
784-
Raises:
785-
AssertionError: If the decoder succeeds or the rejection
786-
contradicts the authored expectation.
787-
ValueError: If the expected rejection is unset.
788-
"""
789-
if self.expected_rejection is None:
790-
raise ValueError("decode_failure codec requires expected_rejection to be set")
791-
792-
exception_raised = decode_failure.attempt_decode()
793-
if exception_raised is None:
794-
raise AssertionError(
795-
f"Expected {decode_failure.decoder!r} decode to reject the input, "
796-
"but decode succeeded"
777+
# Emit the language-neutral reason clients assert against.
778+
return NetworkingCodecFixture(
779+
codec=self.codec,
780+
output=DecodeFailureOutput(decoder=self.codec.decoder),
781+
rejection_reason=self.assert_decode_rejection(
782+
self.codec.attempt_decode(), self.codec.decoder
783+
),
797784
)
798-
self.assert_expected_outcome(exception_raised)
799-
800-
# Emit the language-neutral reason clients assert against.
801-
return NetworkingCodecFixture(
802-
codec=decode_failure,
803-
output=DecodeFailureOutput(decoder=decode_failure.decoder),
804-
rejection_reason=self.expected_rejection.reason,
805-
)
785+
return NetworkingCodecFixture(codec=self.codec, output=self.codec.run())

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

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,6 @@ def _generate_decode_failure(self) -> SSZFixture:
140140
AssertionError: If the decoder succeeds.
141141
ValueError: If `raw_bytes` is missing.
142142
"""
143-
assert self.expected_rejection is not None
144143
if self.raw_bytes is None:
145144
raise ValueError("raw_bytes is required when expected_rejection is set")
146145

@@ -152,18 +151,13 @@ def _generate_decode_failure(self) -> SSZFixture:
152151
except Exception as exception:
153152
exception_raised = exception
154153

155-
if exception_raised is None:
156-
raise AssertionError(
157-
f"Expected {decoder.__name__}.decode_bytes to reject the input, "
158-
"but decode succeeded"
159-
)
160-
self.assert_expected_outcome(exception_raised)
161-
162154
return SSZFixture(
163155
type_name=self.type_name,
164156
value=self.value,
165157
raw_bytes=self.raw_bytes,
166158
serialized="0x" + raw.hex(),
167159
root="",
168-
rejection_reason=self.expected_rejection.reason,
160+
rejection_reason=self.assert_decode_rejection(
161+
exception_raised, f"{decoder.__name__}.decode_bytes"
162+
),
169163
)

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
from typing import ClassVar
44

5-
from pydantic import Field
5+
from pydantic import Field, model_validator
66

77
from consensus_testing.genesis import generate_pre_state
88
from consensus_testing.keys import XmssKeyManager
99
from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec
1010
from consensus_testing.test_types import AggregatedAttestationSpec, BlockSpec, StateExpectation
1111
from lean_spec.spec.crypto.merkleization import hash_tree_root
12-
from lean_spec.spec.forks import AggregationBits
12+
from lean_spec.spec.forks import AggregationBits, SpecRejectionError
1313
from lean_spec.spec.forks.lstar.containers import (
1414
AggregatedAttestation,
1515
AggregatedAttestations,
@@ -62,6 +62,10 @@ class StateTransitionTest(BaseTestSpec):
6262
- Invalid blocks
6363
6464
Tests everything through the main state_transition() public API.
65+
66+
The state transition assumes signatures were verified upstream.
67+
Invalid-signature scenarios belong to the fork choice and signature
68+
verification formats, never to this one.
6569
"""
6670

6771
format_name: ClassVar[str] = "state_transition_test"
@@ -94,6 +98,19 @@ class StateTransitionTest(BaseTestSpec):
9498
If None, no post-state validation is performed (e.g., for invalid tests).
9599
"""
96100

101+
@model_validator(mode="after")
102+
def validate_signatures_are_out_of_scope(self) -> "StateTransitionTest":
103+
"""Reject signature-flavored attestation fields at construction."""
104+
for block_spec in self.blocks:
105+
for attestation_spec in block_spec.attestations or []:
106+
if not attestation_spec.valid_signature or attestation_spec.signer_ids is not None:
107+
raise ValueError(
108+
"state transition assumes signatures were verified upstream; "
109+
"author invalid-signature scenarios through the fork choice "
110+
"or signature verification formats"
111+
)
112+
return self
113+
97114
def generate(self) -> StateTransitionFixture:
98115
"""
99116
Generate the fixture by running the spec.
@@ -140,7 +157,7 @@ def generate(self) -> StateTransitionFixture:
140157
state = spec.state_transition(state, block=block)
141158

142159
actual_post_state = state
143-
except (AssertionError, ValueError) as exception:
160+
except SpecRejectionError as exception:
144161
exception_raised = exception
145162

146163
# Validate exception expectations
@@ -316,13 +333,6 @@ def _build_aggregated_payloads_from_spec(
316333
payloads: dict[AttestationData, set[SingleMessageAggregate]] = {}
317334

318335
for spec in attestation_specs:
319-
if not spec.valid_signature:
320-
raise NotImplementedError(
321-
"valid_signature=False not yet supported in StateTransitionTest"
322-
)
323-
if spec.signer_ids is not None:
324-
raise NotImplementedError("signer_ids not yet supported in StateTransitionTest")
325-
326336
attestation_data = spec.build_attestation_data(block_registry, state.latest_justified)
327337
proof = key_manager.sign_and_aggregate(spec.validator_indices, attestation_data)
328338
payloads.setdefault(attestation_data, set()).add(proof)

0 commit comments

Comments
 (0)