@@ -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
514549impl 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