Skip to content

Commit 0277f5c

Browse files
committed
refactor(blockchain): share ProjectedState and coverage-based score_entry
Pull the round-by-round justification/finalization projection out of select_attestations into a reusable ProjectedState with from_head_state() and advance(), and change score_entry to take a resolved `coverage` set (moving the proof->coverage union to the caller) so one scorer serves both a proposer's proof union and an aggregator's realized raw + child coverage. Widen score_entry, entry_passes_filters, Tier, EntryScore, OrderingKey, and EntryScore::ordering_key to pub(crate). Block-building behavior is unchanged: advance()'s body is the former inline projection (the current_votes update simply moves after the trace it never fed), and pick_best_candidate now unions proof participants into `coverage` before scoring, which is the exact set the old score_entry derived internally. This exposes the projection and scoring primitives so interval-2 aggregation can reuse them instead of duplicating the logic.
1 parent c61165a commit 0277f5c

1 file changed

Lines changed: 108 additions & 73 deletions

File tree

crates/blockchain/src/block_builder.rs

Lines changed: 108 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,7 @@ fn select_attestations(
202202
// Running per-target-root voter set, seeded from state and updated
203203
// incrementally as entries are selected. Mirrors the role of Eth2
204204
// participation flags in Prysm/Lighthouse-style packing.
205-
let mut projected = ProjectedState {
206-
justified_slots: head_state.justified_slots.clone(),
207-
finalized_slot: head_state.latest_finalized.slot,
208-
current_votes: build_running_votes(head_state),
209-
};
205+
let mut projected = ProjectedState::from_head_state(head_state);
210206
let mut processed_data_roots: HashSet<H256> = HashSet::new();
211207

212208
// A block may carry at most `MAX_ATTESTATIONS_DATA` distinct entries
@@ -231,12 +227,6 @@ fn select_attestations(
231227
extend_proofs_greedily(proofs, &mut selected, att_data);
232228

233229
let target_root = att_data.target.root;
234-
projected
235-
.current_votes
236-
.entry(target_root)
237-
.or_default()
238-
.extend(new_voters);
239-
240230
trace!(
241231
tier = ?score.tier,
242232
new_voters = score.new_voters,
@@ -247,29 +237,7 @@ fn select_attestations(
247237
"selected"
248238
);
249239

250-
// Project justification / finalization. Finalize implies Justify
251-
// (target is justified, AND source is finalized).
252-
if score.tier <= Tier::Justify {
253-
justified_slots_ops::extend_to_slot(
254-
&mut projected.justified_slots,
255-
projected.finalized_slot,
256-
att_data.target.slot,
257-
);
258-
justified_slots_ops::set_justified(
259-
&mut projected.justified_slots,
260-
projected.finalized_slot,
261-
att_data.target.slot,
262-
);
263-
// Justified target's voter bucket is no longer relevant for
264-
// scoring (no further entry can target it: filter rejects).
265-
projected.current_votes.remove(&target_root);
266-
}
267-
if score.tier == Tier::Finalize {
268-
let new_finalized = att_data.source.slot;
269-
let delta = new_finalized.saturating_sub(projected.finalized_slot) as usize;
270-
justified_slots_ops::shift_window(&mut projected.justified_slots, delta);
271-
projected.finalized_slot = new_finalized;
272-
}
240+
projected.advance(score.tier, att_data, new_voters);
273241
}
274242

275243
selected
@@ -305,9 +273,13 @@ fn pick_best_candidate(
305273
continue;
306274
}
307275

276+
let coverage: HashSet<u64> = proofs
277+
.iter()
278+
.flat_map(|proof| proof.participant_indices())
279+
.collect();
308280
let Some((score, new_voters)) = score_entry(
309281
att_data,
310-
proofs,
282+
&coverage,
311283
&projected.current_votes,
312284
projected.finalized_slot,
313285
chain.validator_count,
@@ -336,13 +308,75 @@ struct ChainContext<'a> {
336308
validator_count: usize,
337309
}
338310

339-
/// Mutable projection of the post-state that `select_attestations` maintains
340-
/// across rounds: which slots are justified, which slot is finalized, and the
341-
/// running per-target-root voter set.
342-
struct ProjectedState {
343-
justified_slots: JustifiedSlots,
344-
finalized_slot: u64,
345-
current_votes: HashMap<H256, HashSet<u64>>,
311+
/// Mutable projection of the post-state that a tiered greedy selector
312+
/// maintains across rounds: which slots are justified, which slot is
313+
/// finalized, and the running per-target-root voter set.
314+
///
315+
/// Shared by `select_attestations` (block proposal) and
316+
/// `aggregation::snapshot_aggregation_inputs` (interval-2 aggregation) so the
317+
/// two selectors project justification/finalization identically. The
318+
/// aggregator's projection is optimistic (a produced proof is not a processed
319+
/// block), but that only affects the ordering of prover work within the
320+
/// deadline, never the correctness of any produced proof.
321+
pub(crate) struct ProjectedState {
322+
pub(crate) justified_slots: JustifiedSlots,
323+
pub(crate) finalized_slot: u64,
324+
pub(crate) current_votes: HashMap<H256, HashSet<u64>>,
325+
}
326+
327+
impl ProjectedState {
328+
/// Seed the projection from the head state: justification/finalization as
329+
/// of the head, and the running voter set derived from the state's
330+
/// justification bitfields (see [`build_running_votes`]).
331+
pub(crate) fn from_head_state(head_state: &State) -> Self {
332+
Self {
333+
justified_slots: head_state.justified_slots.clone(),
334+
finalized_slot: head_state.latest_finalized.slot,
335+
current_votes: build_running_votes(head_state),
336+
}
337+
}
338+
339+
/// Fold a selected entry into the projection: record its voters under the
340+
/// entry's `target.root`, then advance justification/finalization per
341+
/// `tier` (Finalize implies Justify). `new_voters` is the entry's marginal
342+
/// voter set (block builder) or realized coverage (aggregator); the
343+
/// resulting per-target voter set is the same union either way.
344+
pub(crate) fn advance(
345+
&mut self,
346+
tier: Tier,
347+
att_data: &AttestationData,
348+
new_voters: impl IntoIterator<Item = u64>,
349+
) {
350+
let target_root = att_data.target.root;
351+
self.current_votes
352+
.entry(target_root)
353+
.or_default()
354+
.extend(new_voters);
355+
356+
// Finalize implies Justify (target is justified, AND source is
357+
// finalized).
358+
if tier <= Tier::Justify {
359+
justified_slots_ops::extend_to_slot(
360+
&mut self.justified_slots,
361+
self.finalized_slot,
362+
att_data.target.slot,
363+
);
364+
justified_slots_ops::set_justified(
365+
&mut self.justified_slots,
366+
self.finalized_slot,
367+
att_data.target.slot,
368+
);
369+
// Justified target's voter bucket is no longer relevant for
370+
// scoring (no further entry can target it: filter rejects).
371+
self.current_votes.remove(&target_root);
372+
}
373+
if tier == Tier::Finalize {
374+
let new_finalized = att_data.source.slot;
375+
let delta = new_finalized.saturating_sub(self.finalized_slot) as usize;
376+
justified_slots_ops::shift_window(&mut self.justified_slots, delta);
377+
self.finalized_slot = new_finalized;
378+
}
379+
}
346380
}
347381

348382
/// Validate a candidate entry against the projected chain view.
@@ -355,7 +389,7 @@ struct ProjectedState {
355389
/// slot 0) is exempt from the `target.slot > source.slot` and
356390
/// `target_already_justified` checks since fork-choice bootstrapping needs
357391
/// it; STF will silently drop it, but it carries fork-choice signal.
358-
fn entry_passes_filters(
392+
pub(crate) fn entry_passes_filters(
359393
att_data: &AttestationData,
360394
known_block_roots: &HashSet<H256>,
361395
extended_historical_block_hashes: &[H256],
@@ -396,37 +430,36 @@ fn entry_passes_filters(
396430
Ok(())
397431
}
398432

399-
/// Score a single candidate entry under the current projected state.
433+
/// Score a single candidate entry from its realized validator `coverage`,
434+
/// under the current projected state.
400435
///
401-
/// Returns `None` if the entry has zero new validators relative to the
402-
/// running voter set for its `target.root` (no marginal value, drop). On
403-
/// `Some`, the returned `HashSet` is the set of new voters contributed by
404-
/// this entry (caller uses it to update the running voter map without
405-
/// re-scanning aggregation bits). A genesis self-vote cannot justify or
406-
/// finalize and is always scored as tier 3.
407-
fn score_entry(
436+
/// Returns `None` if `coverage` contributes zero validators relative to the
437+
/// running voter set for `att_data.target.root` (no marginal value, drop).
438+
/// On `Some`, the returned `HashSet` is the subset of `coverage` that is new
439+
/// (caller uses it to update the running voter map without re-scanning
440+
/// `coverage`). A genesis self-vote cannot justify or finalize and is always
441+
/// scored as tier 3.
442+
///
443+
/// The caller resolves `coverage` and passes it in: block building unions a
444+
/// data's proof participants (see `pick_best_candidate`); committee-signature
445+
/// aggregation passes a job's realized raw + child participants. Keeping the
446+
/// proof->coverage transform at the call site lets both share one scorer
447+
/// without either duplicating the tiering.
448+
pub(crate) fn score_entry(
408449
att_data: &AttestationData,
409-
proofs: &[SingleMessageAggregate],
450+
coverage: &HashSet<u64>,
410451
current_votes: &HashMap<H256, HashSet<u64>>,
411452
projected_finalized_slot: u64,
412453
validator_count: usize,
413454
) -> Option<(EntryScore, HashSet<u64>)> {
414455
let prior_voters = current_votes.get(&att_data.target.root);
415456
let prior_count = prior_voters.map_or(0, HashSet::len);
416457

417-
// Collect voters that this entry adds on top of prior_voters. Avoids
418-
// cloning prior_voters; the inner contains() makes this O(participants)
419-
// per candidate per round. `extend_proofs_greedily` selects proofs until
420-
// none contribute new voters, so its final coverage equals this set
421-
// unioned with prior_voters.
422-
let mut new_voters: HashSet<u64> = HashSet::new();
423-
for proof in proofs {
424-
for vid in proof.participant_indices() {
425-
if prior_voters.is_none_or(|prior| !prior.contains(&vid)) {
426-
new_voters.insert(vid);
427-
}
428-
}
429-
}
458+
let new_voters: HashSet<u64> = coverage
459+
.iter()
460+
.copied()
461+
.filter(|vid| prior_voters.is_none_or(|prior| !prior.contains(vid)))
462+
.collect();
430463
if new_voters.is_empty() {
431464
return None;
432465
}
@@ -471,7 +504,7 @@ fn score_entry(
471504
/// output (`tier = Finalize` is clearer than `tier = 1`).
472505
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
473506
#[repr(u8)]
474-
enum Tier {
507+
pub(crate) enum Tier {
475508
/// Applying the entry crosses 2/3 on target AND finalizes the source
476509
/// (no slot strictly between source.slot and target.slot is still
477510
/// justifiable given projected finalized_slot).
@@ -499,23 +532,25 @@ enum Tier {
499532
///
500533
/// In both tiers `data_root` (ascending) is the final deterministic tiebreak.
501534
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502-
struct EntryScore {
503-
tier: Tier,
504-
new_voters: usize,
535+
pub(crate) struct EntryScore {
536+
pub(crate) tier: Tier,
537+
pub(crate) new_voters: usize,
538+
/// Read only inside [`EntryScore::ordering_key`]; kept private.
505539
target_slot: u64,
540+
/// Read only inside [`EntryScore::ordering_key`]; kept private.
506541
att_slot: u64,
507542
}
508543

509544
/// Total order over candidate entries; the smallest value is the best pick.
510545
/// `tier` leads, then three tier-dependent `Reverse`-encoded priorities, then
511546
/// `data_root` as the deterministic tiebreak. See [`EntryScore::ordering_key`].
512-
type OrderingKey = (Tier, Reverse<u64>, Reverse<u64>, Reverse<u64>, H256);
547+
pub(crate) type OrderingKey = (Tier, Reverse<u64>, Reverse<u64>, Reverse<u64>, H256);
513548

514549
impl EntryScore {
515550
/// Sort key where the smallest tuple is the best candidate. `tier` always
516551
/// leads; the remaining three slots carry tier-dependent priorities (see
517552
/// the type-level docs), all encoded as `Reverse` so "larger is better".
518-
fn ordering_key(&self, data_root: H256) -> OrderingKey {
553+
pub(crate) fn ordering_key(&self, data_root: H256) -> OrderingKey {
519554
let more_new_voters = Reverse(self.new_voters as u64);
520555
let newer_target = Reverse(self.target_slot);
521556
let newer_att = Reverse(self.att_slot);
@@ -902,11 +937,11 @@ mod tests {
902937
};
903938

904939
// Supermajority (3 of 4) so the entry crosses 2/3.
905-
let proofs = vec![SingleMessageAggregate::empty(make_bits(&[0, 1, 2]))];
940+
let coverage: HashSet<u64> = HashSet::from([0, 1, 2]);
906941

907942
let (score, _) = score_entry(
908943
&att_data,
909-
&proofs,
944+
&coverage,
910945
&HashMap::new(),
911946
FINALIZED_SLOT,
912947
NUM_VALIDATORS,

0 commit comments

Comments
 (0)