Skip to content

Commit 7fd7108

Browse files
tcoratgerclaude
andauthored
refactor(lstar): polish block production, consolidate its tests (leanEthereum#831)
Improve the proposer-side block builder and bring its unit tests up to reference-spec quality. block_production.py: - Collapse the genesis justified-checkpoint branch into a ternary. - Rename vague locals to descriptive names (selected -> selected_proofs, current_justified -> current_justified_checkpoint, found_entries -> found_new_entries, proof_groups -> signatures_by_attestation_data). - Rewrite the docstring and inline comments to the documentation rules: one sentence per line, no backticks, no identifier names in prose, phase-labeled steps with the load-bearing rationale preserved. CLAUDE.md: - Add a CRITICAL rule requiring descriptive, self-documenting names, distinct from the existing no-abbreviations rule. Tests: - Add tests/lean_spec/spec/forks/lstar/test_block_production.py as the canonical home (mirror rule), with eight decoupled scenarios covering every build_block path, full-object assertions, and line-by-line docs. - Remove the migrated build_block tests from state/test_state_aggregation.py, leaving only its aggregate() tests. - Delete the slow monolithic justification-gap test; its mechanism is covered by the fast unit fixed-point test and the consensus filler. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e653aba commit 7fd7108

5 files changed

Lines changed: 669 additions & 577 deletions

File tree

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ subspecifications that the Lean Ethereum protocol relies on.
5757
or any on-the-wire string. Rename the Python identifier, never the serialized contract.
5858
- When a fully-expanded name becomes unwieldy, prefer a shorter but still complete phrasing
5959
(drop redundant words) rather than re-introducing an abbreviation.
60+
- **CRITICAL - DESCRIPTIVE, SELF-DOCUMENTING NAMES**: This is a STRICT requirement, separate from
61+
the no-abbreviations rule above. Every identifier must let the reader understand what it holds by
62+
reading it alone, without scanning the surrounding code. A name that is fully spelled out but
63+
still vague is NOT acceptable. This applies to source, tests, and `packages/`.
64+
- BAN vague placeholder names that describe nothing: `selected`, `result`, `data`, `value`,
65+
`item`, `temp`, `obj`, `info`, `payload` (when unqualified), `current`, `entry`, `thing`,
66+
`out`, `ret`, single-letter names (except a conventional math index `i`/`j` in a tight numeric
67+
loop). Name the THING, not its role: `selected``selected_proofs`, `result`
68+
`post_state` / `merged_signature`, `current``current_justified_checkpoint`.
69+
- Encode the type or domain meaning when a bare word is ambiguous. A variable holding a
70+
`Checkpoint` is `justified_checkpoint`, not `justified`; a bitfield of justified slots is
71+
`justified_slots`, not `slots`; a boolean is a predicate phrase (`found_new_entries`,
72+
`is_genesis_self_vote`), never a noun.
73+
- NEVER reuse one vague name for two different things in the same scope. If an inner loop holds
74+
something different from an outer variable of the same name, rename it (e.g. payload proofs vs.
75+
grouped signatures → `proofs` and `grouped_signatures`).
76+
- The bar: a reviewer reading any single line in isolation should know what each name refers to.
77+
If they would have to scroll up to find out, the name is wrong.
6078
- **CRITICAL - TEST STRUCTURE MIRRORS SOURCE STRUCTURE**: This is a STRICT requirement. The
6179
test tree under `tests/lean_spec/` mirrors the source tree under `src/lean_spec/` one-to-one.
6280
A source module `src/lean_spec/<path>/<name>.py` has its unit tests in

src/lean_spec/spec/forks/lstar/block_production.py

Lines changed: 114 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -37,119 +37,155 @@ def build_block(
3737
"""
3838
Build a valid block on top of the given pre-state.
3939
40-
Computes the post-state and creates a block with the correct state root.
40+
# Overview
4141
42-
Uses a fixed-point algorithm: finds attestation_data entries whose source
43-
matches the current justified checkpoint, greedily selects proofs maximizing
44-
new validator coverage, then applies the STF. If justification advances,
45-
repeats with the new checkpoint.
42+
A proposer packs attestations into a block and records the post-state root.
43+
A vote is eligible only if the point it builds from is already justified.
44+
Including a vote can justify a new checkpoint.
45+
That, in turn, makes further votes eligible.
46+
47+
# Algorithm
48+
49+
Selection runs as a fixed point:
50+
51+
1. Anchor on the checkpoint this chain currently treats as justified.
52+
2. Greedily pick proofs covering the most new validators.
53+
3. Apply the state transition to a trial block.
54+
4. If justification or finalization advanced, repeat from the new checkpoint.
55+
5. Stop when a full pass adds nothing.
56+
57+
The loop is bounded: justification and finalization only move forward,
58+
and the set of chosen entries only grows.
59+
60+
Args:
61+
state: Pre-state the block builds on.
62+
slot: Slot the new block occupies.
63+
proposer_index: Validator proposing the block.
64+
parent_root: Root of the parent block.
65+
known_block_roots: Block roots the proposer has seen and may vote on.
66+
aggregated_payloads: Candidate proofs grouped by the data they attest to.
67+
68+
Returns:
69+
The final block, its post-state, the included attestations,
70+
and the merged proof backing each one.
4671
"""
4772
aggregated_attestations: list[AggregatedAttestation] = []
4873
aggregated_signatures: list[SingleMessageAggregate] = []
4974

5075
if aggregated_payloads:
51-
# Fixed-point loop: find attestation_data entries matching the current
52-
# justified checkpoint and greedily select proofs. Processing attestations
53-
# may advance justification, unlocking more entries.
54-
# When building on top of genesis (slot 0), process_block_header
55-
# updates the justified root to parent_root. Apply the same
56-
# derivation here so attestation sources match.
57-
if state.latest_block_header.slot == Slot(0):
58-
current_justified = Checkpoint(slot=Slot(0), root=parent_root)
59-
else:
60-
current_justified = state.latest_justified
61-
62-
# Track the justified-slot bitfield to skip already-justified targets.
76+
# Anchor on the checkpoint this chain treats as justified.
6377
#
64-
# Extend the bitfield to cover every slot we might query.
65-
# The range runs from the finalized boundary up to slot - 1 inclusive.
78+
# Building directly on genesis is special.
79+
# Header processing justifies the parent at slot 0.
80+
# Anchor on that same checkpoint so eligible sources match.
81+
current_justified_checkpoint = (
82+
Checkpoint(slot=Slot(0), root=parent_root)
83+
if state.latest_block_header.slot == Slot(0)
84+
else state.latest_justified
85+
)
86+
87+
# Track which slots are already justified.
88+
#
89+
# Extend the window so every slot the loop may query is covered.
90+
# It spans the finalized boundary up to the slot before this block.
6691
current_finalized_slot = state.latest_finalized.slot
6792
current_justified_slots = state.justified_slots.extend_to_slot(
6893
current_finalized_slot, slot - Slot(1)
6994
)
7095

71-
# Build the chain view as it will appear on the candidate block.
96+
# Assemble the chain as it will look once this block is applied.
7297
#
73-
# The view is the recorded history up to the parent.
74-
# Then comes the parent root at the parent's slot.
75-
# Then zero-hash entries for any skipped slots up to the new block.
76-
# The chain-match helper uses this view to validate source and target roots.
98+
# 1. History up to the parent.
99+
# 2. The parent root at its own slot.
100+
# 3. A zero hash for each slot skipped before this block.
101+
# 4. Source and target roots are validated against this view.
77102
num_empty_slots = int(slot - state.latest_block_header.slot - Slot(1))
78103
extended_historical_block_hashes: list[Bytes32] = (
79104
list(state.historical_block_hashes) + [parent_root] + [ZERO_HASH] * num_empty_slots
80105
)
81106

82107
processed_attestation_data: set[AttestationData] = set()
83108

109+
# Fixed-point selection.
110+
#
111+
# - Each pass scans every candidate once, in target-slot order.
112+
# - Accepting an entry may advance justification and unlock more.
113+
# - Re-scan until a pass finds nothing new.
84114
while True:
85-
found_entries = False
115+
found_new_entries = False
86116

117+
# Visit candidates in target-slot order.
118+
# Earlier targets justify first and unlock later ones.
87119
for attestation_data, proofs in sorted(
88120
aggregated_payloads.items(), key=lambda item: item[0].target.slot
89121
):
90122
if attestation_data in processed_attestation_data:
91123
continue
92124

125+
# Stop once the block holds the maximum distinct data entries.
126+
# This cap is a proposer-side budget, not a consensus rule.
93127
if Uint8(len(processed_attestation_data)) >= MAX_ATTESTATIONS_DATA:
94128
break
95129

130+
# Skip votes whose head block the proposer has not seen.
96131
if attestation_data.head.root not in known_block_roots:
97132
continue
98133

99-
# Chain-match runs first.
134+
# Reject votes that do not match this chain.
100135
#
101-
# It rejects checkpoints whose slot is past the chain view.
102-
# That prevents the bounded queries below from indexing out of range.
136+
# This also rejects any checkpoint past the chain view.
137+
# That keeps the bounded lookups below in range.
103138
if not attestation_data_matches_chain(
104139
attestation_data, extended_historical_block_hashes
105140
):
106141
continue
107142

108-
# The source slot must already be justified on this chain.
143+
# A vote may only build from an already-justified source.
109144
if not current_justified_slots.is_slot_justified(
110145
current_finalized_slot, attestation_data.source.slot
111146
):
112147
continue
113148

114-
# Genesis-anchored votes have source.slot = target.slot = 0.
149+
# Genesis self-votes have source and target both at slot 0.
115150
#
116-
# They cannot advance justification: the state transition drops them.
117-
# They still carry head-vote weight for fork choice.
118-
# Including them in the body propagates them into peers' payload pool.
119-
# The bypass below keeps them past the target-already-justified check,
120-
# since slot 0 is implicitly justified and would otherwise filter them.
151+
# - The state transition drops them: they justify nothing.
152+
# - They still carry head weight for fork choice.
153+
# - Including them propagates them to peers.
154+
# - Slot 0 counts as justified, so the next check would drop them.
155+
# - This flag lets them through.
121156
is_genesis_self_vote = attestation_data.source.slot == Slot(0) and (
122157
attestation_data.target.slot == Slot(0)
123158
)
124159

125-
# Skip attestations whose target slot is already justified.
160+
# Skip votes whose target slot is already justified.
126161
#
127-
# Justification adds nothing for them.
128-
# Entries the state transition will later drop are still kept here.
129-
# They carry head-vote weight for fork choice.
162+
# A justified target gains nothing from more votes.
163+
# Genesis self-votes are exempt, kept for their head weight.
130164
if not is_genesis_self_vote and current_justified_slots.is_slot_justified(
131165
current_finalized_slot, attestation_data.target.slot
132166
):
133167
continue
134168

135169
processed_attestation_data.add(attestation_data)
170+
found_new_entries = True
136171

137-
found_entries = True
138-
139-
selected, _ = select_proofs_for_coverage(proofs)
140-
aggregated_signatures.extend(selected)
141-
for proof in selected:
172+
# Choose proofs covering the most validators.
173+
# Emit one attestation per chosen proof.
174+
selected_proofs, _ = select_proofs_for_coverage(proofs)
175+
aggregated_signatures.extend(selected_proofs)
176+
for proof in selected_proofs:
142177
aggregated_attestations.append(
143178
self.aggregated_attestation_class(
144179
aggregation_bits=proof.participants,
145180
data=attestation_data,
146181
)
147182
)
148183

149-
if not found_entries:
184+
if not found_new_entries:
150185
break
151186

152-
# Build candidate block and check if justification changed.
187+
# Apply the state transition to a trial block.
188+
# Its post-state reveals whether this pass advanced justification.
153189
candidate_block = self.block_class(
154190
slot=slot,
155191
proposer_index=proposer_index,
@@ -163,43 +199,48 @@ def build_block(
163199
)
164200
post_state = self.process_block(self.process_slots(state, slot), candidate_block)
165201

166-
# Re-run the filter when justification or finalization advanced.
202+
# Repeat only if justification or finalization moved.
167203
#
168-
# Both quantities are monotonic in 3SF-mini, so the loop is bounded.
169-
# Finalization advancement shifts the justified window forward.
170-
# That can unlock attestations whose target slot was outside it before.
204+
# - Both advance monotonically, so the loop is bounded.
205+
# - A finalization step slides the justified window forward.
206+
# - That can make previously out-of-range targets eligible.
171207
if (
172-
post_state.latest_justified != current_justified
208+
post_state.latest_justified != current_justified_checkpoint
173209
or post_state.latest_finalized.slot != current_finalized_slot
174210
):
175-
current_justified = post_state.latest_justified
211+
current_justified_checkpoint = post_state.latest_justified
176212
current_justified_slots = post_state.justified_slots
177213
current_finalized_slot = post_state.latest_finalized.slot
214+
# The chain view never changes between passes.
215+
# Earlier block hashes are fixed once written.
216+
# Attestation processing does not rewrite them.
178217
continue
179218

180219
break
181220

182-
# Compact: merge all proofs sharing the same AttestationData into one
183-
# using recursive children aggregation.
221+
# Collapse each attestation data down to a single proof.
184222
#
185-
# During the fixed-point loop above, multiple proofs may have been
186-
# selected for the same AttestationData across iterations. Group them
187-
# and merge each group into a single recursive proof.
188-
proof_groups: dict[AttestationData, list[SingleMessageAggregate]] = {}
223+
# - The coverage picker may emit several proofs for one data in a pass.
224+
# - A block must carry one attestation per data, over the union of voters.
225+
226+
# Group every proof under the data it attests to.
227+
# Strict pairing guards against the two lists drifting out of sync.
228+
signatures_by_attestation_data: dict[AttestationData, list[SingleMessageAggregate]] = {}
189229
for attestation, signature in zip(
190230
aggregated_attestations, aggregated_signatures, strict=True
191231
):
192-
proof_groups.setdefault(attestation.data, []).append(signature)
232+
signatures_by_attestation_data.setdefault(attestation.data, []).append(signature)
193233

234+
# Rebuild the output lists, one entry per distinct data.
194235
aggregated_attestations = []
195236
aggregated_signatures = []
196-
for attestation_data, proofs in proof_groups.items():
197-
if len(proofs) == 1:
198-
signature = proofs[0]
237+
for attestation_data, grouped_signatures in signatures_by_attestation_data.items():
238+
if len(grouped_signatures) == 1:
239+
# One proof already covers this data, so use it as-is.
240+
signature = grouped_signatures[0]
199241
else:
200-
# Multiple proofs for the same data were aggregated separately.
201-
# Merge them into one recursive proof using children-only
202-
# aggregation (no new raw signatures).
242+
# Fold the proofs into one, each kept as a child.
243+
# Verifying a child needs the public keys of the voters it covers.
203244
children = [
204245
(
205246
proof,
@@ -208,22 +249,24 @@ def build_block(
208249
for validator_index in proof.participants.to_validator_indices()
209250
],
210251
)
211-
for proof in proofs
252+
for proof in grouped_signatures
212253
]
254+
# Merge over the union of voters; no new raw signatures are added.
213255
signature = SingleMessageAggregate.aggregate(
214256
children=children,
215257
raw_xmss=[],
216258
message=hash_tree_root(attestation_data),
217259
slot=attestation_data.slot,
218260
)
261+
219262
aggregated_signatures.append(signature)
220263
aggregated_attestations.append(
221264
self.aggregated_attestation_class(
222265
aggregation_bits=signature.participants, data=attestation_data
223266
)
224267
)
225268

226-
# Create the final block with selected attestations.
269+
# Assemble the block carrying the chosen attestations.
227270
final_block = self.block_class(
228271
slot=slot,
229272
proposer_index=proposer_index,
@@ -234,7 +277,10 @@ def build_block(
234277
),
235278
)
236279

237-
# Recompute state from the final block.
280+
# Recompute the post-state to obtain the state root.
281+
#
282+
# Merging proofs keeps the same voters, so the post-state is unchanged.
283+
# Only the body's shape differs, so just the root is needed.
238284
post_state = self.process_block(self.process_slots(state, slot), final_block)
239285
final_block.state_root = hash_tree_root(post_state)
240286

0 commit comments

Comments
 (0)