From 5aa0dc6feb4f862dcc19134cae7dce19f4b4abc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:30:06 -0300 Subject: [PATCH 1/6] feat(lstar): add tiered block-production strategy as a selectable alternative The proposer can fill a block by two interchangeable algorithms that share the build-block contract. The default round-based fixed-point selection stays the active strategy; a tiered greedy scorer that ranks finalize over justify over build is added as a dormant alternative. Both live as separate mixins so a fork composes exactly one. Selecting the tiered scorer is a one-line import swap in the fork facade, after which the two block-production test vectors must be regenerated. Keeping the default unchanged leaves all committed vectors byte-identical. --- .../forks/lstar/block_production_tiered.py | 425 ++++++++++++++++++ src/lean_spec/spec/forks/lstar/spec.py | 14 + 2 files changed, 439 insertions(+) create mode 100644 src/lean_spec/spec/forks/lstar/block_production_tiered.py diff --git a/src/lean_spec/spec/forks/lstar/block_production_tiered.py b/src/lean_spec/spec/forks/lstar/block_production_tiered.py new file mode 100644 index 000000000..3f22743f1 --- /dev/null +++ b/src/lean_spec/spec/forks/lstar/block_production_tiered.py @@ -0,0 +1,425 @@ +""" +Lstar fork — proposer-side block building, tiered greedy strategy. + +This is an alternative to the round-based fixed-point strategy. +Exactly one block-production mixin is composed into the fork at a time. +The two are interchangeable: same contract, different selection policy. +""" + +from collections import defaultdict +from collections.abc import Set as AbstractSet +from dataclasses import dataclass +from enum import IntEnum + +from lean_spec.spec.crypto.merkleization import hash_tree_root +from lean_spec.spec.crypto.xmss.containers import PublicKey +from lean_spec.spec.forks.lstar._base import LstarSpecBase +from lean_spec.spec.forks.lstar.aggregation import select_proofs_for_coverage +from lean_spec.spec.forks.lstar.config import ( + MAX_ATTESTATIONS_DATA, +) +from lean_spec.spec.forks.lstar.containers import ( + AggregatedAttestation, + AttestationData, + Block, + JustifiedSlots, + SingleMessageAggregate, + Slot, + State, + ValidatorIndex, +) +from lean_spec.spec.ssz import ZERO_HASH, Boolean, Bytes32 + + +class _Tier(IntEnum): + """ + Selection tier for a candidate attestation data entry. + + Declared in priority order: a lower value wins. + """ + + FINALIZE = 1 + """Applying the entry crosses two-thirds on target and finalizes the source.""" + JUSTIFY = 2 + """Applying the entry crosses two-thirds on target but does not finalize.""" + BUILD = 3 + """Adds marginal new voters toward target's two-thirds supermajority.""" + + +@dataclass(frozen=True) +class _EntryScore: + """ + Tiered score for a candidate attestation data entry during block building. + + Lower tier wins. + Within a tier, more new voters wins, then a smaller target slot, then a + smaller attestation slot, then the entry's data root for determinism. + """ + + tier: _Tier + new_voter_count: int + target_slot: Slot + attestation_slot: Slot + + def ordering_key(self, data_root: Bytes32) -> tuple[int, int, int, int, bytes]: + """Sort key where the smallest tuple is the best candidate.""" + return ( + int(self.tier), + -self.new_voter_count, + int(self.target_slot), + int(self.attestation_slot), + bytes(data_root), + ) + + +class TieredBlockProductionMixin(LstarSpecBase): + """Proposer-side block building for the lstar fork, tiered greedy strategy.""" + + def build_block( + self, + state: State, + slot: Slot, + proposer_index: ValidatorIndex, + parent_root: Bytes32, + known_block_roots: AbstractSet[Bytes32], + aggregated_payloads: dict[AttestationData, set[SingleMessageAggregate]] | None = None, + ) -> tuple[Block, State, list[AggregatedAttestation], list[SingleMessageAggregate]]: + """ + Build a valid block on top of the given pre-state. + + # Overview + + A proposer fills a block with attestation votes, then records the post-state root. + + Selection is circular: + + - A vote may only build from an already-justified source. + - Yet including votes is the act that justifies those sources. + + So the eligible set grows as votes are added, and the proposer selects in rounds. + + # Algorithm + + Each round repeats these steps: + + 1. Score every remaining candidate against the projected post-state. + 2. Pick the best one: finalize beats justify beats build. + 3. Project justification and finalization forward, unlocking dependents. + + The rounds stop at the data-entry cap or once nothing scores. + Projection replaces trial state transitions, so the real transition + runs only once at the end to seal the state root. + + Args: + state: Pre-state the block builds on. + slot: Slot the new block occupies. + proposer_index: Validator proposing the block. + parent_root: Root of the parent block. + known_block_roots: Block roots the proposer has seen and may vote on. + aggregated_payloads: Candidate proofs grouped by the data they attest to. + + Returns: + The final block, its post-state, the included attestations, + and the merged proof backing each one. + """ + aggregated_attestations: list[AggregatedAttestation] = [] + aggregated_signatures: list[SingleMessageAggregate] = [] + + # Advance the pre-state to this block's slot once. + advanced_state = self.process_slots(state, slot) + + if aggregated_payloads: + # Tiered greedy selection. + # + # Each round scores remaining candidates against a projected post-state + # and picks the best: finalize beats justify beats build. + # Justification and finalization are projected incrementally so dependent + # attestations become eligible on the next round without re-running the + # state transition. + # Selection stops at the data-entry cap or when no remaining candidate scores. + selected_attestations_with_proofs: list[ + tuple[AggregatedAttestation, SingleMessageAggregate] + ] = [] + + # Assemble the chain as it will look once this block is applied. + # + # 1. History up to the parent. + # 2. The parent root at its own slot. + # 3. A zero hash for each slot skipped before this block. + # + # Precondition: the new slot must lie strictly after the parent slot. + # Without the guard, unsigned subtraction underflows and the empty-slot + # padding allocates an astronomically large list. + parent_slot = state.latest_block_header.slot + assert slot > parent_slot, ( + f"Cannot build block at slot {slot} <= parent slot {parent_slot}" + ) + num_empty_slots = int(slot - parent_slot - Slot(1)) + extended_historical_block_hashes: list[Bytes32] = ( + list(state.historical_block_hashes) + [parent_root] + [ZERO_HASH] * num_empty_slots + ) + validator_count = len(state.validators) + + # Projected post-state, updated incrementally as entries are selected. + finalized_slot = state.latest_finalized.slot + justified_slots = state.justified_slots.extend_to_slot(finalized_slot, slot - Slot(1)) + + # Seed the running voter map from the on-chain justification bitlist. + # + # The state stores one bit per tracked-root and validator pair. + # The bit at index (root_index * N + validator_index) means that + # validator voted for that tracked root, where N is the validator count. + # Seeding from these bits lets scoring count on-chain voters toward the + # two-thirds threshold. + votes_by_target_root: dict[Bytes32, set[ValidatorIndex]] = {} + for root_index, on_chain_target_root in enumerate(state.justifications_roots): + votes_by_target_root[on_chain_target_root] = { + ValidatorIndex(validator_index) + for validator_index in range(validator_count) + if state.justifications_validators[ + root_index * validator_count + validator_index + ] + } + processed_attestation_data: set[AttestationData] = set() + + for _ in range(int(MAX_ATTESTATIONS_DATA)): + # Scan every remaining candidate and keep the best ordering key. + # + # Skips entries already processed, those failing the projected-chain + # filters, and those with zero new voters. + # A smaller ordering key wins. + best_candidate: tuple[AttestationData, _EntryScore, set[ValidatorIndex]] | None = ( + None + ) + best_candidate_key: tuple[int, int, int, int, bytes] | None = None + for candidate_data, proofs in aggregated_payloads.items(): + if candidate_data in processed_attestation_data: + continue + + # Validate the candidate against the projected chain view. + # + # Mirrors the vote-validity rules: head must be known, source must be + # justified, source and target must match the candidate-block chain view, + # target must be after source, target must not already be justified, and + # target must be justifiable relative to the projected finalized slot. + # + # Chain-match runs before the justified-slot queries: it rejects + # checkpoints whose slot is past the chain view, which keeps the bounded + # justification queries from raising IndexError. + if candidate_data.head.root not in known_block_roots: + continue + if not candidate_data.lies_on_chain(extended_historical_block_hashes): + continue + if not justified_slots.is_slot_justified( + finalized_slot, candidate_data.source.slot + ): + continue + + # A genesis self-vote anchors both source and target at slot 0. + # It cannot justify or finalize, but it carries fork-choice signal, + # so selection treats it specially. + is_genesis_self_vote = candidate_data.source.slot == Slot( + 0 + ) and candidate_data.target.slot == Slot(0) + + # Genesis self-votes are exempt from the target-after-source and + # target-already-justified checks. + # The state transition drops them, but they carry fork-choice signal. + if not is_genesis_self_vote: + if candidate_data.target.slot <= candidate_data.source.slot: + continue + if justified_slots.is_slot_justified( + finalized_slot, candidate_data.target.slot + ): + continue + if not candidate_data.target.slot.is_justifiable_after(finalized_slot): + continue + + # New voters: participants across all proofs not already recorded + # for the target. + prior_voters = votes_by_target_root.get(candidate_data.target.root, set()) + new_voters: set[ValidatorIndex] = set() + for proof in proofs: + for validator_index in proof.participants.to_validator_indices(): + if validator_index not in prior_voters: + new_voters.add(validator_index) + + # An entry adding no validators cannot improve the target, so skip it. + if not new_voters: + continue + + # Threshold: total voters (prior plus new) crossing two-thirds. + total_voters = len(prior_voters) + len(new_voters) + crosses_two_thirds = 3 * total_voters >= 2 * validator_count + + # 3SF-mini finalization requires no slot strictly between source and + # target to still be justifiable. + # Source and target must be consecutive justified checkpoints in the + # projected post-state. + # + # The source must lie strictly past the projected finalized boundary. + # A source at or behind the boundary is already final. + # It may still justify a newer target, but it must not re-finalize. + # This mirrors the state transition, which advances finalization only + # when the source slot is strictly greater than the finalized slot. + # Scanning from one past the source also keeps every queried slot + # strictly above the boundary, where justifiability is defined. + finalizes_source = ( + crosses_two_thirds + and candidate_data.source.slot > finalized_slot + and all( + not Slot(intermediate_slot).is_justifiable_after(finalized_slot) + for intermediate_slot in range( + int(candidate_data.source.slot) + 1, + int(candidate_data.target.slot), + ) + ) + ) + + # A genesis self-vote cannot justify or finalize and is always BUILD tier. + if is_genesis_self_vote or not crosses_two_thirds: + tier = _Tier.BUILD + elif finalizes_source: + tier = _Tier.FINALIZE + else: + tier = _Tier.JUSTIFY + + candidate_score = _EntryScore( + tier=tier, + new_voter_count=len(new_voters), + target_slot=candidate_data.target.slot, + attestation_slot=candidate_data.slot, + ) + candidate_key = candidate_score.ordering_key(hash_tree_root(candidate_data)) + if best_candidate_key is None or candidate_key < best_candidate_key: + best_candidate = (candidate_data, candidate_score, new_voters) + best_candidate_key = candidate_key + + if best_candidate is None: + break + attestation_data, entry_score, selected_new_voters = best_candidate + processed_attestation_data.add(attestation_data) + + # Pack proofs that maximize new validator coverage for this entry. + selected_proofs, _ = select_proofs_for_coverage( + aggregated_payloads[attestation_data] + ) + for proof in selected_proofs: + selected_attestations_with_proofs.append( + ( + self.aggregated_attestation_class( + aggregation_bits=proof.participants, + data=attestation_data, + ), + proof, + ) + ) + + target_root = attestation_data.target.root + + # Project justification and finalization. Finalize implies justify. + if entry_score.tier <= _Tier.JUSTIFY: + justified_slots = justified_slots.extend_to_slot( + finalized_slot, attestation_data.target.slot + ) + + # The justifiable filter and the extension above guarantee an + # in-range index, so mark the target justified directly. + target_justified_index = attestation_data.target.slot.justified_index_after( + finalized_slot + ) + assert target_justified_index is not None + updated_justified_bits = list(justified_slots.data) + updated_justified_bits[target_justified_index] = Boolean(True) + justified_slots = JustifiedSlots(data=updated_justified_bits) + + # A justified target can no longer be a candidate target, so its + # voter bucket is irrelevant for further scoring. + votes_by_target_root.pop(target_root, None) + else: + # BUILD tier: the target stays a candidate, so record its new + # voters to push it toward the threshold on a later round. + votes_by_target_root.setdefault(target_root, set()).update(selected_new_voters) + if entry_score.tier == _Tier.FINALIZE: + # The finalize tier requires a source strictly past the boundary, + # so the window always advances by at least one slot. + # Drop the leading bits that fell behind the new finalized boundary. + new_finalized_slot = attestation_data.source.slot + finalized_slot_advance = int(new_finalized_slot) - int(finalized_slot) + justified_slots = JustifiedSlots( + data=justified_slots.data[finalized_slot_advance:] + ) + finalized_slot = new_finalized_slot + + for attestation, proof in selected_attestations_with_proofs: + aggregated_attestations.append(attestation) + aggregated_signatures.append(proof) + + # Collapse each attestation data down to a single proof. + # + # - The coverage picker may emit several proofs for one data entry. + # - A block must carry one attestation per data, over the union of voters. + + # Group every proof under the data it attests to. + # Strict pairing guards against the two lists drifting out of sync. + signatures_by_attestation_data: defaultdict[ + AttestationData, list[SingleMessageAggregate] + ] = defaultdict(list) + for attestation, signature in zip( + aggregated_attestations, aggregated_signatures, strict=True + ): + signatures_by_attestation_data[attestation.data].append(signature) + + # Rebuild the output lists, one entry per distinct data. + aggregated_attestations = [] + aggregated_signatures = [] + for attestation_data, grouped_signatures in signatures_by_attestation_data.items(): + if len(grouped_signatures) == 1: + # One proof already covers this data, so use it as-is. + signature = grouped_signatures[0] + else: + # Fold the proofs into one, each kept as a child. + # Verifying a child needs the public keys of the voters it covers. + children = [ + ( + proof, + [ + PublicKey.decode_bytes( + state.validators[validator_index].attestation_public_key + ) + for validator_index in proof.participants.to_validator_indices() + ], + ) + for proof in grouped_signatures + ] + # Merge over the union of voters; no new raw signatures are added. + signature = SingleMessageAggregate.aggregate( + children=children, + raw_xmss=[], + message=hash_tree_root(attestation_data), + slot=attestation_data.slot, + ) + + aggregated_signatures.append(signature) + aggregated_attestations.append( + self.aggregated_attestation_class( + aggregation_bits=signature.participants, data=attestation_data + ) + ) + + # Assemble the block carrying the chosen attestations. + final_block = self.block_class( + slot=slot, + proposer_index=proposer_index, + parent_root=parent_root, + state_root=Bytes32.zero(), + body=self.block_body_class( + attestations=self.aggregated_attestations_class(data=aggregated_attestations), + ), + ) + + # Compute the post-state to obtain the state root. + post_state = self.process_block(advanced_state, final_block) + final_block = final_block.model_copy(update={"state_root": hash_tree_root(post_state)}) + + return final_block, post_state, aggregated_attestations, aggregated_signatures diff --git a/src/lean_spec/spec/forks/lstar/spec.py b/src/lean_spec/spec/forks/lstar/spec.py index 91c0fbc45..9efe9bf70 100644 --- a/src/lean_spec/spec/forks/lstar/spec.py +++ b/src/lean_spec/spec/forks/lstar/spec.py @@ -4,7 +4,21 @@ from lean_spec.spec.forks.lstar._base import LstarSpecBase, LstarStore from lean_spec.spec.forks.lstar.aggregation import AggregationMixin + +# Proposer block-building strategy. +# Two interchangeable algorithms share the build-block contract. +# Exactly one is composed into the fork; swap the active import to switch. +# +# - The default round-based fixed-point selection. +# - A tiered greedy scorer that ranks finalize over justify over build. +# +# Switching changes the two block-production test vectors, so regenerate after a swap: +# uv run fill --fork=lstar --clean -n auto from lean_spec.spec.forks.lstar.block_production import BlockProductionMixin + +# from lean_spec.spec.forks.lstar.block_production_tiered import ( +# TieredBlockProductionMixin as BlockProductionMixin, +# ) from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, AggregatedAttestations, From 3488222479830c12b188576c1e302a5857230460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:55:40 -0300 Subject: [PATCH 2/6] test(lstar): make block-production fixtures generate under either mixin Two fork-choice fillers asserted the simple builder's exact block body, so generation aborted when the tiered builder was wired in. Both now succeed whichever block-production mixin the fork composes. The max-attestations filler adopts a single-voter, justifiable-target scenario where the entry cap alone bounds the body, so both builders fill to exactly the limit. The self-heals filler keeps one scenario but selects the divergent block body by the active strategy: the fixed-point builder keeps a redundant vote, the tiered builder drops it. --- .../test_attestation_source_divergence.py | 37 ++++++++++++----- .../fork_choice/test_block_production.py | 41 ++++++++++++------- 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py b/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py index 689788ada..6b0757dbd 100644 --- a/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py +++ b/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py @@ -11,9 +11,17 @@ StoreChecks, ) from lean_spec.spec.forks import Slot, ValidatorIndex +from lean_spec.spec.forks.lstar.block_production_tiered import TieredBlockProductionMixin +from lean_spec.spec.forks.lstar.spec import LstarSpec pytestmark = pytest.mark.valid_until("Lstar") +# The proposer strategy wired into the fork decides how the divergent block body below +# is filled. +# The default fixed-point builder keeps a vote even when it adds no new voters. +# The tiered builder drops such a redundant vote. +TIERED_BLOCK_PRODUCTION_ACTIVE = issubclass(LstarSpec, TieredBlockProductionMixin) + def test_justified_divergence_self_heals_in_next_block( fork_choice_test: ForkChoiceTestFiller, @@ -44,7 +52,23 @@ def test_justified_divergence_self_heals_in_next_block( - block_5 pulls the slot-1 votes from the pool and includes them. - the head chain justifies slot 1, matching the node. - finalized stays at slot 0. + - the included vote count depends on the wired proposer strategy. + - the default builder also keeps V0's redundant slot-2 vote. + - the tiered builder drops it, since it adds no new voters. """ + # Only the block body diverges by strategy; head, justified, and finalized match. + if TIERED_BLOCK_PRODUCTION_ACTIVE: + block_5_attestation_count = 1 + block_5_attestations = [ + AggregatedAttestationCheck(participants={1, 2, 3}, target_slot=Slot(1)), + ] + else: + block_5_attestation_count = 2 + block_5_attestations = [ + AggregatedAttestationCheck(participants={1, 2, 3}, target_slot=Slot(1)), + AggregatedAttestationCheck(participants={0}, target_slot=Slot(2)), + ] + fork_choice_test( steps=[ BlockStep( @@ -103,17 +127,8 @@ def test_justified_divergence_self_heals_in_next_block( head_root_label="block_5", latest_justified_slot=Slot(1), latest_justified_root_label="common", - block_attestation_count=2, - block_attestations=[ - AggregatedAttestationCheck( - participants={1, 2, 3}, - target_slot=Slot(1), - ), - AggregatedAttestationCheck( - participants={0}, - target_slot=Slot(2), - ), - ], + block_attestation_count=block_5_attestation_count, + block_attestations=block_5_attestations, ), ), ], diff --git a/tests/consensus/lstar/fork_choice/test_block_production.py b/tests/consensus/lstar/fork_choice/test_block_production.py index fc72e4fa1..a972f574f 100644 --- a/tests/consensus/lstar/fork_choice/test_block_production.py +++ b/tests/consensus/lstar/fork_choice/test_block_production.py @@ -188,12 +188,14 @@ def test_produce_block_enforces_max_attestations_data_limit( Given ----- - - 3 attesting validators. - the chain: - genesis -> block_1(1) -> ... -> one block past the limit - - one vote per target block arrives by gossip. + genesis -> block_1(1) -> ... -> the highest justifiable target slot + - one vote from V0 per justifiable target slot arrives by gossip. - each vote names a different target. - this yields one more distinct attestation data entry than the limit allows. + - only justifiable targets are used, so every entry is a real candidate. + - a single voter never reaches the supermajority (2/3). + - no entry justifies its target, so the cap alone bounds the count. When ---- @@ -201,8 +203,8 @@ def test_produce_block_enforces_max_attestations_data_limit( Then ---- - - the builder sorts entries by target slot and stops at the limit. - - the entries with the highest target slots are dropped. + - entries are ordered by target slot. + - the entry with the highest target slot is dropped. - the produced block holds exactly the maximum number of votes. Timing @@ -212,32 +214,43 @@ def test_produce_block_enforces_max_attestations_data_limit( - a tick to the next slot start moves the votes into the known pool. """ limit = int(MAX_ATTESTATIONS_DATA) - num_target_blocks = limit + 1 - block_production_slot = num_target_blocks + 1 - validators = [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)] - aggregate_interval = num_target_blocks * int(INTERVALS_PER_SLOT) + 2 + # The first limit + 1 slots that are justifiable after genesis. + # One more than the cap, so exactly one candidate must be dropped. + target_slots: list[int] = [] + candidate_slot = 1 + while len(target_slots) < limit + 1: + if Slot(candidate_slot).is_justifiable_after(Slot(0)): + target_slots.append(candidate_slot) + candidate_slot += 1 + + # Build a contiguous chain up to the highest target slot, then produce one + # slot later. Every target block must exist on-chain to be a valid target. + chain_length = target_slots[-1] + block_production_slot = chain_length + 1 + + aggregate_interval = chain_length * int(INTERVALS_PER_SLOT) + 2 aggregate_time = math.ceil(aggregate_interval * int(MILLISECONDS_PER_INTERVAL) / 1000) next_slot_time = block_production_slot * int(SECONDS_PER_SLOT) chain_steps: list[BlockStep] = [ BlockStep( block=BlockSpec(slot=Slot(n), label=f"block_{n}"), - checks=(StoreChecks(head_slot=Slot(n)) if n == 1 or n == num_target_blocks else None), + checks=(StoreChecks(head_slot=Slot(n)) if n == 1 or n == chain_length else None), ) - for n in range(1, num_target_blocks + 1) + for n in range(1, chain_length + 1) ] attestation_steps: list[GossipAggregatedAttestationStep] = [ GossipAggregatedAttestationStep( attestation=AggregatedAttestationSpec( - validator_indices=validators, - slot=Slot(num_target_blocks), + validator_indices=[ValidatorIndex(0)], + slot=Slot(chain_length), target_slot=Slot(n), target_root_label=f"block_{n}", ), ) - for n in range(1, num_target_blocks + 1) + for n in target_slots ] fork_choice_test( From a977e00d0c81542048bd4ff4ea1c8193ebd0ce5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:05:36 -0300 Subject: [PATCH 3/6] test(lstar): assert only mixin-independent outcomes in self-heals filler The filler branched its block-body assertion on which block-production mixin the fork composed, coupling a test vector to a proposer-strategy choice. Drop the body-count assertion and the strategy probe. The justified slot is the strategy-independent witness of the self-heal: reaching slot 1 is only possible once the block incorporates the slot-1 votes. The exact body composition is a proposer choice, not a consensus outcome, so it is no longer asserted. --- .../test_attestation_source_divergence.py | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py b/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py index 6b0757dbd..ddd6b6997 100644 --- a/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py +++ b/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py @@ -3,7 +3,6 @@ import pytest from consensus_testing import ( - AggregatedAttestationCheck, AggregatedAttestationSpec, BlockSpec, BlockStep, @@ -11,17 +10,9 @@ StoreChecks, ) from lean_spec.spec.forks import Slot, ValidatorIndex -from lean_spec.spec.forks.lstar.block_production_tiered import TieredBlockProductionMixin -from lean_spec.spec.forks.lstar.spec import LstarSpec pytestmark = pytest.mark.valid_until("Lstar") -# The proposer strategy wired into the fork decides how the divergent block body below -# is filled. -# The default fixed-point builder keeps a vote even when it adds no new voters. -# The tiered builder drops such a redundant vote. -TIERED_BLOCK_PRODUCTION_ACTIVE = issubclass(LstarSpec, TieredBlockProductionMixin) - def test_justified_divergence_self_heals_in_next_block( fork_choice_test: ForkChoiceTestFiller, @@ -52,23 +43,12 @@ def test_justified_divergence_self_heals_in_next_block( - block_5 pulls the slot-1 votes from the pool and includes them. - the head chain justifies slot 1, matching the node. - finalized stays at slot 0. - - the included vote count depends on the wired proposer strategy. - - the default builder also keeps V0's redundant slot-2 vote. - - the tiered builder drops it, since it adds no new voters. - """ - # Only the block body diverges by strategy; head, justified, and finalized match. - if TIERED_BLOCK_PRODUCTION_ACTIVE: - block_5_attestation_count = 1 - block_5_attestations = [ - AggregatedAttestationCheck(participants={1, 2, 3}, target_slot=Slot(1)), - ] - else: - block_5_attestation_count = 2 - block_5_attestations = [ - AggregatedAttestationCheck(participants={1, 2, 3}, target_slot=Slot(1)), - AggregatedAttestationCheck(participants={0}, target_slot=Slot(2)), - ] + The justified slot is the strategy-independent witness of the self-heal. + Reaching slot 1 is only possible once block_5 incorporates the slot-1 votes, + so the block body is not asserted directly: its exact composition is a + proposer-strategy choice, not a consensus outcome. + """ fork_choice_test( steps=[ BlockStep( @@ -127,8 +107,6 @@ def test_justified_divergence_self_heals_in_next_block( head_root_label="block_5", latest_justified_slot=Slot(1), latest_justified_root_label="common", - block_attestation_count=block_5_attestation_count, - block_attestations=block_5_attestations, ), ), ], From f30bcebeaf7e0df89326c4ca8ea92d6a1dbbd366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:07:42 -0300 Subject: [PATCH 4/6] docs: remove comment --- .../lstar/fork_choice/test_attestation_source_divergence.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py b/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py index ddd6b6997..4b284ac81 100644 --- a/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py +++ b/tests/consensus/lstar/fork_choice/test_attestation_source_divergence.py @@ -43,11 +43,6 @@ def test_justified_divergence_self_heals_in_next_block( - block_5 pulls the slot-1 votes from the pool and includes them. - the head chain justifies slot 1, matching the node. - finalized stays at slot 0. - - The justified slot is the strategy-independent witness of the self-heal. - Reaching slot 1 is only possible once block_5 incorporates the slot-1 votes, - so the block body is not asserted directly: its exact composition is a - proposer-strategy choice, not a consensus outcome. """ fork_choice_test( steps=[ From 09b2b0e756095bede8572825f540af708193d8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:14:45 -0300 Subject: [PATCH 5/6] chore(vulture): whitelist the dormant tiered block-production mixin A fork composes exactly one block-production mixin, and the tiered strategy is selected by swapping a single import in the fork facade. By default it has no call site, so vulture reports it as an unused class. It is a real, selectable strategy, not dead code, so whitelist it like the other statically invisible uses. --- vulture_whitelist.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index b4b508ca5..887ea4f49 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -72,6 +72,11 @@ # A single-fork tree has no transition yet, so there is no call site. _.upgrade_state +# Alternative proposer block-building strategy for the lstar fork. +# A fork composes exactly one block-production mixin; this one is selected by +# swapping a single import in the fork facade, so by default it has no call site. +TieredBlockProductionMixin + # Signature parameters mandated by external protocols we cannot rename. # The pydantic core-schema hook, the pytest session-finish hook, and the # prometheus metric stub interface. From b2ab3c0b235694dd846c80185d5cc846bb362451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:33 -0300 Subject: [PATCH 6/6] feat(block-production): prioritize newer attestation targets in tiered scoring The tiered greedy builder broke ties on the smallest target and attestation slot. Prefer the largest instead so the projected justified slot advances as close to the tip as possible, shortening recovery from a justification or finalization stall (mirrors the Zeam devnet4 fix). The threshold-crossing tiers (finalize, justify) now rank a larger target slot above new-voter coverage, since they justify regardless; the build tier keeps coverage first since it only adds marginal voters toward the threshold. Claude-Session: https://claude.ai/code/session_01WHwxcstQNAwYfYfaMmF7a2 --- .../forks/lstar/block_production_tiered.py | 31 ++++++++++++++++--- .../fork_choice/test_block_production.py | 4 +-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/lean_spec/spec/forks/lstar/block_production_tiered.py b/src/lean_spec/spec/forks/lstar/block_production_tiered.py index 3f22743f1..d1b847f82 100644 --- a/src/lean_spec/spec/forks/lstar/block_production_tiered.py +++ b/src/lean_spec/spec/forks/lstar/block_production_tiered.py @@ -52,8 +52,18 @@ class _EntryScore: Tiered score for a candidate attestation data entry during block building. Lower tier wins. - Within a tier, more new voters wins, then a smaller target slot, then a - smaller attestation slot, then the entry's data root for determinism. + The remaining order depends on the tier. + + The finalize and justify tiers already cross the threshold on their target. + So they rank a larger target slot first, then a larger attestation slot, + then more new voters, then the entry's data root for determinism. + Pushing the justified slot as far forward as possible shortens recovery + from a justification or finalization stall. + + The build tier only adds marginal voters toward the threshold. + So coverage matters more than reaching for a distant slot. + It ranks more new voters first, then a larger target slot, then a larger + attestation slot, then the entry's data root for determinism. """ tier: _Tier @@ -63,11 +73,22 @@ class _EntryScore: def ordering_key(self, data_root: Bytes32) -> tuple[int, int, int, int, bytes]: """Sort key where the smallest tuple is the best candidate.""" + larger_target_slot = -int(self.target_slot) + larger_attestation_slot = -int(self.attestation_slot) + more_new_voters = -self.new_voter_count + if self.tier is _Tier.BUILD: + return ( + int(self.tier), + more_new_voters, + larger_target_slot, + larger_attestation_slot, + bytes(data_root), + ) return ( int(self.tier), - -self.new_voter_count, - int(self.target_slot), - int(self.attestation_slot), + larger_target_slot, + larger_attestation_slot, + more_new_voters, bytes(data_root), ) diff --git a/tests/consensus/lstar/fork_choice/test_block_production.py b/tests/consensus/lstar/fork_choice/test_block_production.py index a972f574f..31634948a 100644 --- a/tests/consensus/lstar/fork_choice/test_block_production.py +++ b/tests/consensus/lstar/fork_choice/test_block_production.py @@ -203,8 +203,8 @@ def test_produce_block_enforces_max_attestations_data_limit( Then ---- - - entries are ordered by target slot. - - the entry with the highest target slot is dropped. + - entries are ordered by descending target slot. + - the entry with the lowest target slot is dropped. - the produced block holds exactly the maximum number of votes. Timing