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
18+ //! [`PROPOSER_MAX_AGGREGATION_JOBS`] in the slot before one of our validators
19+ //! proposes.
1820
1921use std:: collections:: { HashMap , HashSet } ;
2022use std:: time:: { Duration , Instant , SystemTime } ;
@@ -176,7 +178,14 @@ impl Message for EarlyAggregationCheck {
176178/// leanVM prover work against [`AGGREGATION_DEADLINE`]: the greedy loop in
177179/// [`snapshot_aggregation_inputs`] stops after this many rounds even if
178180/// scoring candidates remain.
179- const MAX_AGGREGATION_JOBS : usize = 3 ;
181+ pub ( crate ) const MAX_AGGREGATION_JOBS : usize = 3 ;
182+
183+ /// Job cap for a session running in the slot before one of our validators
184+ /// proposes. The interval-4 build runs its own leanVM proofs for the block it
185+ /// is about to publish; keeping this slot's aggregation to a single job leaves
186+ /// the prover to that build instead of racing it. The one job we do run is the
187+ /// best-scoring candidate, so the highest-value coverage is retained.
188+ pub ( crate ) const PROPOSER_MAX_AGGREGATION_JOBS : usize = 1 ;
180189
181190/// Build a snapshot of everything needed to aggregate. Runs on the actor
182191/// thread, touches the store, does no heavy cryptography. Returns `None` when
@@ -190,17 +199,22 @@ const MAX_AGGREGATION_JOBS: usize = 3;
190199/// (`store.iter_gossip_signatures()`) and payload-only groups
191200/// (`store.new_payload_keys()` not already a gossip candidate, requiring
192201/// at least two existing proofs to merge).
193- /// 2. **Greedy loop**, at most [`MAX_AGGREGATION_JOBS`] rounds: each round
202+ /// 2. **Greedy loop**, at most `max_jobs` rounds: each round
194203/// scores every unselected candidate against the projected state and
195204/// keeps the lowest ordering key (current-slot before stale, then
196205/// Finalize > Justify > Build, mirroring the block builder). The winning
197206/// [`AggregationJob`] is emitted as-is; the projection is updated with its
198207/// realized coverage.
199208///
200209/// Stops early when no remaining candidate scores (converged).
210+ ///
211+ /// `max_jobs` is [`MAX_AGGREGATION_JOBS`] for an ordinary session and
212+ /// [`PROPOSER_MAX_AGGREGATION_JOBS`] when the caller is about to build a block
213+ /// at interval 4 (see `BlockChainServer::start_aggregation_session`).
201214pub fn snapshot_aggregation_inputs (
202215 store : & Store ,
203216 current_slot : u64 ,
217+ max_jobs : usize ,
204218) -> Option < AggregationSnapshot > {
205219 let gossip_groups = store. iter_gossip_signatures ( ) ;
206220 let new_payload_keys = store. new_payload_keys ( ) ;
@@ -269,9 +283,8 @@ pub fn snapshot_aggregation_inputs(
269283
270284 let mut projected = block_builder:: ProjectedState :: from_head_state ( & head_state) ;
271285
272- let mut jobs: Vec < AggregationJob > =
273- Vec :: with_capacity ( MAX_AGGREGATION_JOBS . min ( groups_considered) ) ;
274- for _round in 0 ..MAX_AGGREGATION_JOBS {
286+ let mut jobs: Vec < AggregationJob > = Vec :: with_capacity ( max_jobs. min ( groups_considered) ) ;
287+ for _round in 0 ..max_jobs {
275288 let Some ( ( data_root, score) ) = pick_best_candidate (
276289 & candidates,
277290 & projected,
@@ -1189,7 +1202,7 @@ mod tests {
11891202 fn snapshot_returns_none_for_empty_store ( ) {
11901203 let hashes = vec ! [ H256 ( [ 1u8 ; 32 ] ) ] ;
11911204 let store = new_test_store ( make_head_state ( 0 , 4 , & hashes) ) ;
1192- assert ! ( snapshot_aggregation_inputs( & store, 0 ) . is_none( ) ) ;
1205+ assert ! ( snapshot_aggregation_inputs( & store, 0 , MAX_AGGREGATION_JOBS ) . is_none( ) ) ;
11931206 }
11941207
11951208 /// A single gossip signature with no other material to merge is dropped
@@ -1218,7 +1231,7 @@ mod tests {
12181231 let hashed = HashedAttestationData :: new ( att_data) ;
12191232 store. insert_gossip_signature ( hashed, 0 , dummy_sig ( ) ) ;
12201233
1221- assert ! ( snapshot_aggregation_inputs( & store, 0 ) . is_none( ) ) ;
1234+ assert ! ( snapshot_aggregation_inputs( & store, 0 , MAX_AGGREGATION_JOBS ) . is_none( ) ) ;
12221235 }
12231236
12241237 /// A group whose target is already justified (here: at or behind the
@@ -1261,7 +1274,7 @@ mod tests {
12611274 store. insert_gossip_signature ( hashed, 1 , dummy_sig ( ) ) ;
12621275
12631276 assert ! (
1264- snapshot_aggregation_inputs( & store, 999 ) . is_none( ) ,
1277+ snapshot_aggregation_inputs( & store, 999 , MAX_AGGREGATION_JOBS ) . is_none( ) ,
12651278 "a group targeting an already-justified slot must never become a job"
12661279 ) ;
12671280 }
@@ -1317,7 +1330,7 @@ mod tests {
13171330 store. insert_gossip_signature ( hashed. clone ( ) , 0 , dummy_sig ( ) ) ;
13181331 store. insert_gossip_signature ( hashed, 1 , dummy_sig ( ) ) ;
13191332
1320- let snapshot = snapshot_aggregation_inputs ( & store, HEAD_SLOT )
1333+ let snapshot = snapshot_aggregation_inputs ( & store, HEAD_SLOT , MAX_AGGREGATION_JOBS )
13211334 . expect ( "a vote for the current head must produce a job (chain view covers the tip)" ) ;
13221335 assert_eq ! ( snapshot. jobs. len( ) , 1 ) ;
13231336 assert_eq ! (
@@ -1327,23 +1340,26 @@ mod tests {
13271340 ) ;
13281341 }
13291342
1330- /// With more scoring candidates than `MAX_AGGREGATION_JOBS`, exactly that
1331- /// many jobs are produced — the best `MAX_AGGREGATION_JOBS` by ordering
1332- /// key. Five Build-tier candidates (2 raw sigs each, well under the 2/3
1333- /// threshold) differ only by `target_slot`; Build-tier ordering prefers
1334- /// larger `target_slot` on a new_voters tie, so the top three by slot win.
1335- #[ test]
1336- fn snapshot_caps_jobs_at_max_aggregation_jobs ( ) {
1343+ /// Number of competing candidates built by
1344+ /// [`store_with_competing_build_tier_groups`]; more than either job cap so
1345+ /// both cap tests actually bind.
1346+ const NUM_GROUPS : usize = 5 ;
1347+
1348+ /// Store holding `NUM_GROUPS` competing Build-tier candidates (2 raw sigs
1349+ /// each, well under the 2/3 threshold) that differ only by `target_slot`
1350+ /// (`1..=NUM_GROUPS`, all justifiable at delta <= 5). Build-tier ordering
1351+ /// prefers larger `target_slot` on a new_voters tie, so selection takes
1352+ /// them highest-slot-first.
1353+ fn store_with_competing_build_tier_groups ( ) -> Store {
13371354 const NUM_VALIDATORS : usize = 10 ;
13381355 const HEAD_SLOT : u64 = 10 ;
1339- const NUM_GROUPS : usize = 5 ;
13401356
13411357 let hashes: Vec < H256 > = ( 0 ..HEAD_SLOT ) . map ( |i| H256 ( [ ( i + 1 ) as u8 ; 32 ] ) ) . collect ( ) ;
13421358 let mut store = new_test_store ( make_head_state ( HEAD_SLOT , NUM_VALIDATORS , & hashes) ) ;
13431359 insert_test_block ( & mut store, hashes[ 0 ] , 0 , H256 :: ZERO ) ;
13441360
13451361 for i in 0 ..NUM_GROUPS {
1346- let target_slot = i as u64 + 1 ; // 1..=5, all justifiable (delta <= 5)
1362+ let target_slot = i as u64 + 1 ;
13471363 let att_data = AttestationData {
13481364 slot : target_slot,
13491365 head : Checkpoint {
@@ -1365,7 +1381,18 @@ mod tests {
13651381 store. insert_gossip_signature ( hashed, ( 2 * i + 1 ) as u64 , dummy_sig ( ) ) ;
13661382 }
13671383
1368- let snapshot = snapshot_aggregation_inputs ( & store, 999 ) . expect ( "should produce jobs" ) ;
1384+ store
1385+ }
1386+
1387+ /// With more scoring candidates than `MAX_AGGREGATION_JOBS`, exactly that
1388+ /// many jobs are produced — the best `MAX_AGGREGATION_JOBS` by ordering
1389+ /// key, i.e. the top three by `target_slot`.
1390+ #[ test]
1391+ fn snapshot_caps_jobs_at_max_aggregation_jobs ( ) {
1392+ let store = store_with_competing_build_tier_groups ( ) ;
1393+
1394+ let snapshot = snapshot_aggregation_inputs ( & store, 999 , MAX_AGGREGATION_JOBS )
1395+ . expect ( "should produce jobs" ) ;
13691396 assert_eq ! ( snapshot. groups_considered, NUM_GROUPS ) ;
13701397 assert_eq ! ( snapshot. jobs. len( ) , MAX_AGGREGATION_JOBS ) ;
13711398
@@ -1380,4 +1407,24 @@ mod tests {
13801407 "the three highest target_slot groups win the new_voters tie"
13811408 ) ;
13821409 }
1410+
1411+ /// The proposer cap yields exactly one job from the same pool, and it is
1412+ /// the single best-scoring candidate — the one the uncapped selection also
1413+ /// picks first (highest `target_slot`). Every other candidate is still
1414+ /// counted in `groups_considered`, so the cap is visibly a selection bound
1415+ /// rather than a narrower candidate pool.
1416+ #[ test]
1417+ fn snapshot_caps_jobs_at_one_for_proposer ( ) {
1418+ let store = store_with_competing_build_tier_groups ( ) ;
1419+
1420+ let snapshot = snapshot_aggregation_inputs ( & store, 999 , PROPOSER_MAX_AGGREGATION_JOBS )
1421+ . expect ( "should produce a job" ) ;
1422+ assert_eq ! ( snapshot. groups_considered, NUM_GROUPS ) ;
1423+ assert_eq ! ( snapshot. jobs. len( ) , PROPOSER_MAX_AGGREGATION_JOBS ) ;
1424+ assert_eq ! (
1425+ snapshot. jobs[ 0 ] . hashed. data( ) . target. slot,
1426+ NUM_GROUPS as u64 ,
1427+ "the single job is the best-scoring candidate, not an arbitrary one"
1428+ ) ;
1429+ }
13831430}
0 commit comments