1414//! aggregation material once (raw-first + trim, see [`resolve_job`]), then a
1515//! pure in-memory loop scores and orders candidates by consensus value
1616//! (current-slot before stale, then Finalize > Justify > Build), emitting at
17- //! most [`MAX_AGGREGATION_JOBS`] jobs.
17+ //! most `max_jobs` jobs — [`MAX_AGGREGATION_JOBS`] normally, dropping to a
18+ //! single job in the slot before one of our validators proposes.
1819
1920use std:: collections:: { HashMap , HashSet } ;
2021use std:: time:: { Duration , Instant , SystemTime } ;
@@ -176,7 +177,7 @@ impl Message for EarlyAggregationCheck {
176177/// leanVM prover work against [`AGGREGATION_DEADLINE`]: the greedy loop in
177178/// [`snapshot_aggregation_inputs`] stops after this many rounds even if
178179/// scoring candidates remain.
179- const MAX_AGGREGATION_JOBS : usize = 3 ;
180+ pub ( crate ) const MAX_AGGREGATION_JOBS : usize = 2 ;
180181
181182/// Build a snapshot of everything needed to aggregate. Runs on the actor
182183/// thread, touches the store, does no heavy cryptography. Returns `None` when
@@ -190,17 +191,22 @@ const MAX_AGGREGATION_JOBS: usize = 3;
190191/// (`store.iter_gossip_signatures()`) and payload-only groups
191192/// (`store.new_payload_keys()` not already a gossip candidate, requiring
192193/// at least two existing proofs to merge).
193- /// 2. **Greedy loop**, at most [`MAX_AGGREGATION_JOBS`] rounds: each round
194+ /// 2. **Greedy loop**, at most `max_jobs` rounds: each round
194195/// scores every unselected candidate against the projected state and
195196/// keeps the lowest ordering key (current-slot before stale, then
196197/// Finalize > Justify > Build, mirroring the block builder). The winning
197198/// [`AggregationJob`] is emitted as-is; the projection is updated with its
198199/// realized coverage.
199200///
200201/// Stops early when no remaining candidate scores (converged).
202+ ///
203+ /// `max_jobs` is [`MAX_AGGREGATION_JOBS`] for an ordinary session and `1` when
204+ /// the caller is about to build a block at interval 4 (see
205+ /// `BlockChainServer::start_aggregation_session`).
201206pub fn snapshot_aggregation_inputs (
202207 store : & Store ,
203208 current_slot : u64 ,
209+ max_jobs : usize ,
204210) -> Option < AggregationSnapshot > {
205211 let gossip_groups = store. iter_gossip_signatures ( ) ;
206212 let new_payload_keys = store. new_payload_keys ( ) ;
@@ -269,9 +275,8 @@ pub fn snapshot_aggregation_inputs(
269275
270276 let mut projected = block_builder:: ProjectedState :: from_head_state ( & head_state) ;
271277
272- let mut jobs: Vec < AggregationJob > =
273- Vec :: with_capacity ( MAX_AGGREGATION_JOBS . min ( groups_considered) ) ;
274- for _round in 0 ..MAX_AGGREGATION_JOBS {
278+ let mut jobs: Vec < AggregationJob > = Vec :: with_capacity ( max_jobs. min ( groups_considered) ) ;
279+ for _round in 0 ..max_jobs {
275280 let Some ( ( data_root, score) ) = pick_best_candidate (
276281 & candidates,
277282 & projected,
@@ -1183,7 +1188,7 @@ mod tests {
11831188 fn snapshot_returns_none_for_empty_store ( ) {
11841189 let hashes = vec ! [ H256 ( [ 1u8 ; 32 ] ) ] ;
11851190 let store = new_test_store ( make_head_state ( 0 , 4 , & hashes) ) ;
1186- assert ! ( snapshot_aggregation_inputs( & store, 0 ) . is_none( ) ) ;
1191+ assert ! ( snapshot_aggregation_inputs( & store, 0 , MAX_AGGREGATION_JOBS ) . is_none( ) ) ;
11871192 }
11881193
11891194 /// A single gossip signature with no other material to merge is dropped
@@ -1212,7 +1217,7 @@ mod tests {
12121217 let hashed = HashedAttestationData :: new ( att_data) ;
12131218 store. insert_gossip_signature ( hashed, 0 , dummy_sig ( ) ) ;
12141219
1215- assert ! ( snapshot_aggregation_inputs( & store, 0 ) . is_none( ) ) ;
1220+ assert ! ( snapshot_aggregation_inputs( & store, 0 , MAX_AGGREGATION_JOBS ) . is_none( ) ) ;
12161221 }
12171222
12181223 /// A group whose target is already justified (here: at or behind the
@@ -1255,7 +1260,7 @@ mod tests {
12551260 store. insert_gossip_signature ( hashed, 1 , dummy_sig ( ) ) ;
12561261
12571262 assert ! (
1258- snapshot_aggregation_inputs( & store, 999 ) . is_none( ) ,
1263+ snapshot_aggregation_inputs( & store, 999 , MAX_AGGREGATION_JOBS ) . is_none( ) ,
12591264 "a group targeting an already-justified slot must never become a job"
12601265 ) ;
12611266 }
@@ -1311,7 +1316,7 @@ mod tests {
13111316 store. insert_gossip_signature ( hashed. clone ( ) , 0 , dummy_sig ( ) ) ;
13121317 store. insert_gossip_signature ( hashed, 1 , dummy_sig ( ) ) ;
13131318
1314- let snapshot = snapshot_aggregation_inputs ( & store, HEAD_SLOT )
1319+ let snapshot = snapshot_aggregation_inputs ( & store, HEAD_SLOT , MAX_AGGREGATION_JOBS )
13151320 . expect ( "a vote for the current head must produce a job (chain view covers the tip)" ) ;
13161321 assert_eq ! ( snapshot. jobs. len( ) , 1 ) ;
13171322 assert_eq ! (
@@ -1321,23 +1326,26 @@ mod tests {
13211326 ) ;
13221327 }
13231328
1324- /// With more scoring candidates than `MAX_AGGREGATION_JOBS`, exactly that
1325- /// many jobs are produced — the best `MAX_AGGREGATION_JOBS` by ordering
1326- /// key. Five Build-tier candidates (2 raw sigs each, well under the 2/3
1327- /// threshold) differ only by `target_slot`; Build-tier ordering prefers
1328- /// larger `target_slot` on a new_voters tie, so the top three by slot win.
1329- #[ test]
1330- fn snapshot_caps_jobs_at_max_aggregation_jobs ( ) {
1329+ /// Number of competing candidates built by
1330+ /// [`store_with_competing_build_tier_groups`]; more than either job cap so
1331+ /// both cap tests actually bind.
1332+ const NUM_GROUPS : usize = 5 ;
1333+
1334+ /// Store holding `NUM_GROUPS` competing Build-tier candidates (2 raw sigs
1335+ /// each, well under the 2/3 threshold) that differ only by `target_slot`
1336+ /// (`1..=NUM_GROUPS`, all justifiable at delta <= 5). Build-tier ordering
1337+ /// prefers larger `target_slot` on a new_voters tie, so selection takes
1338+ /// them highest-slot-first.
1339+ fn store_with_competing_build_tier_groups ( ) -> Store {
13311340 const NUM_VALIDATORS : usize = 10 ;
13321341 const HEAD_SLOT : u64 = 10 ;
1333- const NUM_GROUPS : usize = 5 ;
13341342
13351343 let hashes: Vec < H256 > = ( 0 ..HEAD_SLOT ) . map ( |i| H256 ( [ ( i + 1 ) as u8 ; 32 ] ) ) . collect ( ) ;
13361344 let mut store = new_test_store ( make_head_state ( HEAD_SLOT , NUM_VALIDATORS , & hashes) ) ;
13371345 insert_test_block ( & mut store, hashes[ 0 ] , 0 , H256 :: ZERO ) ;
13381346
13391347 for i in 0 ..NUM_GROUPS {
1340- let target_slot = i as u64 + 1 ; // 1..=5, all justifiable (delta <= 5)
1348+ let target_slot = i as u64 + 1 ;
13411349 let att_data = AttestationData {
13421350 slot : target_slot,
13431351 head : Checkpoint {
@@ -1359,7 +1367,18 @@ mod tests {
13591367 store. insert_gossip_signature ( hashed, ( 2 * i + 1 ) as u64 , dummy_sig ( ) ) ;
13601368 }
13611369
1362- let snapshot = snapshot_aggregation_inputs ( & store, 999 ) . expect ( "should produce jobs" ) ;
1370+ store
1371+ }
1372+
1373+ /// With more scoring candidates than `MAX_AGGREGATION_JOBS`, exactly that
1374+ /// many jobs are produced — the best `MAX_AGGREGATION_JOBS` by ordering
1375+ /// key, i.e. the top two by `target_slot`.
1376+ #[ test]
1377+ fn snapshot_caps_jobs_at_max_aggregation_jobs ( ) {
1378+ let store = store_with_competing_build_tier_groups ( ) ;
1379+
1380+ let snapshot = snapshot_aggregation_inputs ( & store, 999 , MAX_AGGREGATION_JOBS )
1381+ . expect ( "should produce jobs" ) ;
13631382 assert_eq ! ( snapshot. groups_considered, NUM_GROUPS ) ;
13641383 assert_eq ! ( snapshot. jobs. len( ) , MAX_AGGREGATION_JOBS ) ;
13651384
@@ -1370,8 +1389,27 @@ mod tests {
13701389 . collect ( ) ;
13711390 assert_eq ! (
13721391 selected_targets,
1373- HashSet :: from( [ 3 , 4 , 5 ] ) ,
1374- "the three highest target_slot groups win the new_voters tie"
1392+ HashSet :: from( [ 4 , 5 ] ) ,
1393+ "the two highest target_slot groups win the new_voters tie"
1394+ ) ;
1395+ }
1396+
1397+ /// The proposer cap (`max_jobs = 1`) yields exactly one job from the same
1398+ /// pool, and it is the single best-scoring candidate — the one the uncapped
1399+ /// selection also picks first (highest `target_slot`). Every other candidate
1400+ /// is still counted in `groups_considered`, so the cap is visibly a
1401+ /// selection bound rather than a narrower candidate pool.
1402+ #[ test]
1403+ fn snapshot_caps_jobs_at_one_for_proposer ( ) {
1404+ let store = store_with_competing_build_tier_groups ( ) ;
1405+
1406+ let snapshot = snapshot_aggregation_inputs ( & store, 999 , 1 ) . expect ( "should produce a job" ) ;
1407+ assert_eq ! ( snapshot. groups_considered, NUM_GROUPS ) ;
1408+ assert_eq ! ( snapshot. jobs. len( ) , 1 ) ;
1409+ assert_eq ! (
1410+ snapshot. jobs[ 0 ] . hashed. data( ) . target. slot,
1411+ NUM_GROUPS as u64 ,
1412+ "the single job is the best-scoring candidate, not an arbitrary one"
13751413 ) ;
13761414 }
13771415}
0 commit comments