feat(aggregation): score recursive aggregation through a per-aggregator subnet window - #613
Conversation
Groundwork for giving each aggregator a distinct slice of the shared proof pool. Pure functions with no call sites yet.
Sizes subnet_reach's working set by participants rather than by the uncapped committee count, drops a provably dead branch in contains_subnet, canonicalizes the stored width so derived equality matches containment, and covers the zero-committee guards.
Values a candidate child by the in-window validators it newly covers rather than by total coverage, so aggregators on different duty subnets pick different children. Every caller still passes the vacuous single-committee window, so behavior is unchanged until the window is derived for real.
The rule was justified by a raw-signature trim that reads a value resolve_job discards. It actually keeps the marginal-coverage score honest across greedy rounds. Also folds the zero-score guard into the search, dropping a panic path, and covers the known-proof fallback.
Narrows an aggregator to the widest level it owns in the slot, rotating the owner so no duty subnet is permanently the one sitting out. Not wired to a caller yet.
Restores the width floor as a property of the function rather than a precondition on a caller that does not exist yet, matching how the sibling primitives answer degenerate inputs. Documents that the ladder truncates at odd committee counts and that a ragged wrap can hand two duty subnets overlapping windows.
Width comes from the best reach in the candidate's own proof pool, so every aggregator derives the same width for a data root and the merge tree stays in step. The duty subnet is still a placeholder pending the CLI wiring.
A hardcoded duty subnet gave every aggregator the same window, which both kept the duplicated work this change exists to remove and made proofs outside that window unmergeable as children. Also registers the window metrics at startup and observes the width per candidate, so the counter reads 0 rather than absent while the rotation is off.
Moves the duty subnet and the redundancy-skipping flag onto BlockChainConfig so the binary can supply them, with the previous inline fallback relocated to main.rs unchanged.
Nothing validates the upper bound of --aggregate-subnet-ids, and at a width that does not divide the committee count an out-of-range duty subnet rotates on different slots from its reduced twin. Also marks the two main.rs placeholders that the CLI wiring will replace.
The duty subnet is the first --aggregate-subnet-ids value, so operators place co-located aggregators on different subnets deliberately rather than having every node derive the same one from its subscriptions.
The first --aggregate-subnet-ids entry became load-bearing without its help text saying so, and a node that silently fell back to the lowest subscribed subnet was indistinguishable from one an operator placed deliberately.
Round 1 produces four proofs with distinct coverage, round 2 merges them to the full validator set. Also pins that a single committee ignores the duty subnet, so the window is a no-op there.
The width pins once the best pool proof reaches half the committees, not all of them, so a pinned sample means the window no longer restricts selection rather than that the pool is saturated.
The test guarding the pre-rotation reduction passed without it, since SubnetWindow::new's own fold masked the difference at a committee count the width divides. Also covers the redundancy-skipping path end to end.
… a merge A window is a contiguous run of subnets but the pool need not be contiguous in subnet space, so a strided aggregator placement could leave a window holding one proof and drop a merge the unwindowed selection would have made. The window can now only improve on that selection, never regress below it.
A derived duty subnet is identical on every node whose validators span all subnets, so the rotation narrows every aggregator in lockstep rather than taking turns, and the widest level gets no producer at all in most slots.
One session per slot, and the current slot's pool is empty at snapshot time, so a data root gets about one windowed merge rather than the multi-round climb the comments implied. The widening still matters across slots for a data root that stays live.
🤖 Kimi Code ReviewI'll review this PR focusing on the aggregation window feature for reducing redundant proof work across co-located aggregators. Overall AssessmentThis is a well-designed feature with good documentation, comprehensive tests, and careful attention to edge cases. The core logic for subnet-windowed aggregation and the optional redundancy-skipping rotation is sound. I found a few issues to address. Issues Found1. Potential Division by Zero in
|
| Item | Severity | File:Line | Description |
|---|---|---|---|
| 1 | Info | aggregation.rs:912 |
Division by zero properly guarded; no change needed |
| 2 | Info | main.rs:855 |
HashSet + min() is correct; comment slightly overstates |
| 3 | None | metrics.rs |
All metrics properly initialized ✓ |
| 4 | None | aggregation.rs:256 |
narrowed logic correct per design |
| 5 | None | aggregation.rs:780 |
Wrap-around arithmetic correct |
| 6 | High | aggregation.rs:2326 |
Verify max_by_key tie-breaking behavior — test expects "last" but std may return "first" |
| 7 | Low | aggregation.rs:636 |
Fallback metric counts attempts not successes; clarify if intended |
| 8 | None | aggregation.rs:326 |
Per-candidate metrics correct per docs |
| 9 | None | aggregation.rs:198 |
Copy derive correct |
| 10 | None | cli.rs:112 |
Explicit false default acceptable |
Recommended Changes
Item 6 — Verify tie-breaking behavior
Add an explicit comment or use a deterministic tie-breaker:
// In select_proofs_greedily, aggregation.rs:935
// If scores tie, prefer the proof with more total participants, then
// lower pool index for determinism:
.max_by_key(|(i, p)| {
let score = in_window_new_coverage(p, &covered, window);
(score, p.participant_count(), !(*i == 0)) // or similar total order
})Or if the current behavior is verified correct, document which max_by_key implementation provides the "last wins" semantics.
Item 7 — Clarify fallback metric (optional)
// aggregation.rs:636 — consider:
if resolve_job(...).is_some() {
metrics::inc_aggregation_window_fallback();
// return it
}Or update metric description to "attempted fallback" rather than "fell back."
Positive Notes
- Excellent test coverage: The 400+ lines of new tests cover normal operation, edge cases (zero committees, wrap-around, empty pools), and the redundancy rotation.
- Good documentation: Architecture doc explains the feature clearly; metrics doc explains how to interpret the new histogram.
- Defensive programming:
committee_count == 0guards throughout,width.max(1)floor, explicit modulo reduction of duty subnet. - Clean separation:
record_window_metricskept pure;SubnetWindowis a well-encapsulated primitive. - Backward compatibility:
vacuous_window_config()ensures existing tests continue to validate the same behavior withcommittee_count = 1.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code Review
I did not find other obvious correctness or security issues in the touched paths. I could not run the Rust tests here because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewReview: Subnet-windowed aggregation (PR #613)Went through the diff in detail ( Consensus safety — confirmed the core safety claim: a windowed aggregate still binds exactly Coverage regression guard — Rotation correctness — hand-verified Zero-division guards — Out-of-range duty subnet — Minor observations (non-blocking):
Overall: correctness reasoning is sound, the test suite (especially Automated review by Claude (Anthropic) · sonnet · custom prompt |
… proof The width was derived from the widest proof anywhere in the candidate's pool. That made a single sparse proof, one validator in each of many subnets, set the width to the full committee count for every aggregator and switch the window off network-wide. It also said nothing about whether the aggregator had any stake in the level it was being sized for. Derive it from the *anchor* instead: the largest-coverage proof in the pool that touches the aggregator's own duty subnet. Coverage rather than reach picks it, so a sparse proof no longer dominates. Requiring it to touch the duty subnet makes "no anchor" mean "no peer has covered my subnet", which is exactly when this node's raw signatures are irreplaceable, and the narrowest window then leaves it aggregating those rather than merging proofs it cannot add to. The width is no longer uniform across the network, so two aggregators reading one lopsided pool can end up with nesting windows instead of tiling ones. That costs a round of climbing, not correctness. Also change what --skip-redundant-aggregation does with a level it does not own. It used to halve the width until it owned one, which at any committee count above 4 lands on a level nested inside an owner's window and duplicates the owner's merge anyway. It now sits the candidate out entirely, so the job budget goes to the next-best AttestationData. The full-width fallback is disabled under the flag for the same reason: every width below the committee count has several owners, so retrying there would rebuild the duplication the flag exists to remove. lean_aggregation_narrowed_total becomes lean_aggregation_skipped_redundant_total, counting candidates handed to another duty subnet rather than windows narrowed.
🤖 Kimi Code ReviewI'll review this PR for the subnet-windowed aggregation feature, focusing on correctness, security, performance, and Rust best practices. High-Level AssessmentThis is a well-structured feature adding subnet-windowed aggregation to prevent duplicate proof generation across co-located aggregators. The code is generally clean with good test coverage and clear documentation. I found several issues ranging from minor to moderate severity. Issues Found1. Potential Division by Zero in
|
| Item | Severity | File | Line | Description |
|---|---|---|---|---|
| 1 | Moderate | aggregation.rs |
~1004 | owns_width semantics with unclamped width |
| 2 | Minor | aggregation.rs |
Multiple | Scattered committee_count == 0 checks |
| 3 | Minor | aggregation.rs |
~1050 | Double HashedAttestationData clone in fallback |
| 4 | Minor | aggregation.rs |
~560 | resolve_job docstring missing window param docs |
| 5 | Minor (pre-existing) | metrics.rs |
~316 | unwrap() on metric registration |
| 6 | Moderate | aggregation.rs |
~2680 | Brittle test assertion on tie-breaking |
| 7 | Verify | aggregation.rs |
~2560 | insert_test_block helper existence |
Positive Acknowledgments
- Excellent test coverage with property-based reasoning (tiling, rotation, fallback)
- Good documentation in
architecture.mdexplaining the feature's trade-offs - The
metered_window_for_candidateseparation keeps pure logic testable - Warning log for dangerous CLI flag combination is operator-friendly
subnet_reachearly-exit optimization is well-commentedAggregationWindowConfiggrouping is cleaner than passing loose parameters
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewFindings:
The aggregation tests added here are otherwise thoughtful and cover the windowing logic well. I could not run the test suite in this environment because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewAll call sites are consistently updated. Comprehensive review complete — this PR is well-tested and defensively coded (all division/modulo-by-zero paths guarded). Findings below are minor. Review: Subnet-windowed aggregation (PR 613)OverallThis is a well-designed, well-documented change. The core algorithm (contiguous cyclic window as a scoring lens rather than a filter, anchor-derived width, full-window fallback, redundancy-skip rotation) is sound, and the "no consensus impact" claim holds: a windowed aggregate is still Minor observations
No blocking issues found. The fallback-to-full-window safety net and the disabling of that fallback under Automated review by Claude (Anthropic) · sonnet · custom prompt |
…w path Cleanup pass over the subnet-window code, no behaviour change. `anchor_key` walked a proof's bits twice: once for coverage, then again inside `subnet_reach` for the reach. `validator_indices` visits every index up to the bitfield's length rather than just the set ones, so the second pass doubled the per-proof cost for every anchor candidate. Both numbers now fall out of one pass, which also removes the hand-rolled modulo membership test in favour of the subnet set the pass already builds. `subnet_reach` had no other caller, so it goes; its tests move onto `anchor_key`. Greedy child selection cloned each chosen `SingleMessageAggregate`, proof bytes included, only for `resolve_child_pubkeys` to clone those bytes again. Selection now hands back borrows and the accepted children clone once. This matters more than it used to: when a window declines a merge, `resolve_job_with_window_fallback` runs the whole selection a second time. The two candidate loops in `snapshot_aggregation_inputs` carried an identical copy of the store-read, window-derive, resolve sequence; `build_candidate` now holds it once. The fallback's full-width window gets a named `SubnetWindow::full` constructor, dropping a `.max(1)` that could never fire: at a committee count of 0 both containment methods return before reading the width.
| if primary.is_some() || config.skip_redundant { | ||
| return primary; | ||
| } | ||
| metrics::inc_aggregation_window_fallback(); |
There was a problem hiding this comment.
This retry runs, and the counter increments, whenever the first resolve_job returns None, but that also happens for reasons the window had nothing to do with: a gossip group holding a single raw signature and no children (every minority or late vote), or a candidate whose derived width already equals the committee count, where SubnetWindow::full scores identically and cannot recover anything.
So lean_aggregation_window_fallback_total will climb on any healthy deployment, which contradicts the metrics doc ("should stay at or near zero on a well-tiled deployment"), and the second selection pass runs on every non-viable candidate.
Suggestion:
- return early when
window.width() == config.committee_count(the retry is a no-op there), and - increment the counter only when the full-width retry actually yields a job, since that is the event the metric claims to count.
There was a problem hiding this comment.
Right on both counts, fixed in f33b308.
The retry now returns early in the two cases where it is provably a no-op, and the counter only increments when the retry yields a job:
window.width() >= committee_count: a window as wide as the committee admits every subnet, soSubnetWindow::fullis the same lens.>=rather than==so acommittee_countof 0 folds in too.- empty proof pool: no child to admit at any width. This is the single-raw-signature case you named, and it is the ordinary shape of a minority or late vote.
What is left, a group whose pool is non-empty but whose windowed selection came up with one child, is exactly what the fallback exists for, so the retry there is genuine work. resolve_job can still decline it, hence counting recoveries rather than attempts.
Pulled the predicate out as fallback_can_recover so the three cases are unit-testable without a metrics registry. The metric's description and docs/metrics.md now say "recoveries, not attempts", which makes the "at or near zero on a well-tiled deployment" reading true.
| // receiver-count guard in `emit` makes every emission a no-op. | ||
| let events = EventBus::default(); | ||
|
|
||
| let aggregation_duty_subnet = resolve_aggregation_duty_subnet( |
There was a problem hiding this comment.
The duty subnet is later reduced modulo committee_count in window_for_candidate, but the subscription set built at line 263 keeps the raw --aggregate-subnet-ids values and the swarm subscribes to those raw topics.
With --attestation-committee-count 4 --aggregate-subnet-ids 5 this node subscribes to topic 5, which no validator publishes on, and aggregates as duty subnet 1, which it does not listen to. The startup log prints 5, so the mismatch is invisible to the operator.
Rather than reducing silently, reject any --aggregate-subnet-ids value at or above the committee count here, next to the existing attestation_committee_count >= 1 check. That fixes the pre-existing dead subscription too, and the modulo in window_for_candidate plus window_for_candidate_reduces_an_out_of_range_duty_subnet can then go.
There was a problem hiding this comment.
Agreed, the silent reduction was the wrong call. 61e21ab adds validate_aggregate_subnet_ids, called from run_node right after the committee count resolves (it cannot live in clap, since the count comes from the flag or validator-config.yaml). Every id is checked, not just the first: only the first becomes the duty subnet, but all of them become subscriptions. That closes the pre-existing dead subscription too.
One deviation from your suggestion: I kept the modulo in window_for_candidate and its test. AggregationWindowConfig and snapshot_aggregation_inputs are pub, so the blockchain crate cannot lean on the binary's validation, and SubnetWindow::new reduces start regardless. Dropping the reduction only in window_for_candidate would leave the ownership test running on the raw value while the window ran on the reduced one, which is precisely the desync that test pins. So it is all-or-nothing, and three lines guarding a public API seemed the better side to land on. The comment there now says the binary is what actually enforces it.
| /// | ||
| /// Worth enabling when leanVM prover CPU is the bottleneck on co-located | ||
| /// aggregators. The cost is that a level in a given slot has few | ||
| /// producers, so a node that is down or late forfeits that slot's merge at |
There was a problem hiding this comment.
The cost is larger than "a node that is down or late". Ownership is duty_subnet % width == slot % width, so a level can have zero owners while every configured node is healthy: with duty subnets {0, 2} at committee count 4, width 4 has no owner in odd slots, and since the full-width fallback is off under this flag, that merge level is dropped for the slot.
The rotation only guarantees an owner at every level when every subnet has an aggregator with that duty subnet. Worth stating that as a deployment precondition here and in the architecture doc, so an operator does not read the flag as safe on a sparse placement.
There was a problem hiding this comment.
Confirmed your example: duty subnets {0, 2}, committee count 4, slot 1 gives 1 % 4 == 1, which neither 0 nor 2 matches, so width 4 has no owner in odd slots and the fallback is off. The old wording framed that as a node being down or late, which is wrong.
8ac6dfc rewrites the flag's help and adds a paragraph to docs/architecture.md, stating the precondition explicitly: give every subnet below --attestation-committee-count an aggregator holding it as its duty subnet, otherwise leave the flag off. The loss is called structural rather than a consequence of an unhealthy node.
| /// aggregator's window to the full committee set. | ||
| /// | ||
| /// Only one session runs per slot (see [`snapshot_aggregation_inputs`]), and | ||
| /// this candidate's own pool is still empty at that point: a produced |
There was a problem hiding this comment.
This is an assumption about timing, not an invariant the code enforces. If a peer's aggregate for the current slot lands before this node's snapshot (clock skew, or a session that started early via EarlyAggregationCheck), the current-slot candidate gains an anchor and a width of 2. Under --skip-redundant-aggregation that means half the aggregators sit the current slot out, and their raw signatures miss the next block.
In the intended topology, distinct duty subnets with distinct subscriptions, a round-one peer proof never touches this node's subnet, so it does not bite there. It would with overlapping subscriptions. A sentence here (or in the PR description's cadence section) noting the assumption and when it fails would save the next reader the derivation.
There was a problem hiding this comment.
Fair, "always derives the narrowest width" claimed more than the code guarantees. 8ac6dfc softens it to "in practice" and adds the derivation you asked for: it is a timing expectation, a peer aggregate for the current slot landing before this node's snapshot (clock skew, or a session started early via EarlyAggregationCheck) gives the candidate an anchor and width 2, under --skip-redundant-aggregation the duty subnets that do not own width 2 sit the slot out and their raw signatures miss the next block, the intended topology keeps it true because a round-one peer proof never touches this node's subnet, and overlapping subscriptions are where it fails.
… subnet for The flag feeds two consumers that read an out-of-range value differently. The P2P swarm subscribes to the raw id, while the aggregation window reduces it modulo the committee count, so `--attestation-committee-count 4 --aggregate-subnet-ids 5` subscribes to a topic no validator publishes on and aggregates as duty subnet 1, which the node does not listen to. The startup log prints 5 for both, so the split is invisible to the operator. Refusing to start is the only reading of that configuration that cannot silently mean something else, and it closes the dead subscription that predates the duty subnet. The check lives in `run_node` rather than clap because the committee count is only known once the CLI flag and the validator config have both been consulted. The reduction inside `window_for_candidate` stays: `AggregationWindowConfig` is public, and `SubnetWindow::new` reduces `start` regardless, so dropping it would leave the ownership test running on an unreduced value.
…ery miss `lean_aggregation_window_fallback_total` is documented as the signal that the aggregator placement is too sparse for the committee count, so it has to count merges the window dropped. It was incrementing on every `resolve_job` miss instead, and `resolve_job` declines for reasons the window has no part in: a gossip group holding a single raw signature and no children, which is every minority or late vote. The counter therefore climbed on any healthy deployment, contradicting its own reading, and the second selection pass ran on candidates no width could have saved. Return before the retry in the two cases where it is provably a no-op, a window already as wide as the committee and an empty proof pool, and increment only when the full-width retry actually yields a job.
Both came out of review, and both are assumptions a reader has to
re-derive from the arithmetic today.
`--skip-redundant-aggregation` costs more than "a node that is down or
late". Ownership is `duty_subnet % width == slot % width`, so a width can
have no owner at all while every configured aggregator is healthy: with
duty subnets {0, 2} at committee count 4, nothing owns width 4 in an odd
slot, and the flag disables the full-width fallback, so that merge level
is dropped for the slot. The rotation only covers every level when every
subnet has an aggregator holding it as its duty subnet, which is a
deployment precondition rather than a property of the flag.
"A current-slot candidate always derives the narrowest width" is a timing
expectation, not an invariant. A peer's aggregate for the current slot
landing before this node's snapshot, under clock skew or an early
session, gives that candidate an anchor and a width of 2. The intended
topology keeps it true (a round-one peer proof never touches this node's
subnet), overlapping subscriptions are where it fails.
Summary
Every aggregator on the network currently does the same aggregation work. In a healthy slot all validators vote for the same head, so there is one hot
AttestationData; candidates are scored deterministically from head state, andselect_proofs_greedilypicks the two highest-coverage children from a pool every aggregator sees, since aggregates gossip on one global topic. So all aggregators select the same two children and produce the same merged proof, and every copy of that leanVM work past the first is wasted.If every aggregator anchors on the pool's best proof
r0, then aggregatoripublishesout_i = r0 ∪ x_i, andout_i ∪ out_j = out_i ∪ x_j. Merging two published proofs gains nothing over merging one of them with a raw pool proof.This gives each aggregator a duty subnet and a window of subnets starting there, used as a scoring lens on child selection, so different aggregators merge different children.
Design
{s, s+1, ...}an aggregator is responsible for, starting at its duty subnet.covered, which keeps the marginal-coverage score honest across greedy rounds.min(2 * reach(anchor), C), where the anchor is the largest-coverage proof in the candidate's pool that touches the aggregator's own duty subnet, and a proof's reach is how many distinct subnets it touches. Coverage rather than reach picks the anchor, so a sparse proof holding one validator in each of many subnets no longer sets the width for everybody. Requiring the anchor to touch the duty subnet makes "no anchor" mean "no peer has covered my subnet", which is exactly when this node's raw signatures are irreplaceable: the window then sits at its narrowest and the aggregator works on those instead of merging proofs it cannot add to. Deriving the width rather than choosing it is essential either way, since windows nest and "widest viable window" would collapse to the full committee set for everyone.--aggregate-subnet-idsvalue, else the lowest subscribed subnet, else 0. Logged at startup so a collision is diagnosable. The node refuses to start on an--aggregate-subnet-idsvalue at or above the committee count: subscriptions use the raw id while the window reduces it, so an out-of-range value would subscribe to a topic no validator publishes on and aggregate as a subnet the node does not listen to.--skip-redundant-aggregation(opt-in): an aggregator sits out any candidate whose derived width it does not own this slot (duty_subnet % w == slot % w), and the freed job goes to the next-bestAttestationDatarather than to a narrower merge of the same one. Ownership rotates with the slot, so no duty subnet is permanently the one sitting out, and width 1 is owned by everyone, so a candidate with no anchor on this node's subnet is never skipped.{0, 2}and committee count 4, nothing owns width 4 in an odd slot, and the fallback is off, so that merge level is dropped for the slot. Leave the flag unset on a placement that does not cover every subnet.Safety
raw_ids ∪ accepted_child_idsand its bits derive from that same set, so it is valid with fewer participants: less fork-choice weight, never wrong weight.--skip-redundant-aggregation: every width below the committee count has several owners, so retrying there would rebuild exactly the duplication the flag buys away.attestation_committee_count = 1is a provable no-op: the window contains every validator, so in-window coverage equals total coverage and the break condition is identical to before, tie-break order included.mainnode's wide proof can raise a branch node's anchor reach where it touches that node's duty subnet, which opens its window, so a partial rollout makes the feature weaker rather than inconsistent.Known trade-off
Because the anchor must touch the duty subnet, the derived width is no longer uniform across the network. Two aggregators reading one lopsided pool can derive different widths, so their windows nest rather than tile. That costs a round of climbing, not correctness, and it is the direct price of tying the window to work the aggregator can actually contribute to.
Cadence, in practice
There is one aggregation session per slot, and the current slot's pool is empty at snapshot time since produced aggregates are held until the interval-2 boundary. So the window bites on the stale candidate, and a data root gets about one windowed merge rather than a multi-round climb. The widening matters across slots for a data root that stays live.
That emptiness is a timing expectation, not an invariant. A peer's aggregate for the current slot landing before this node's snapshot, under clock skew or a session started early via
EarlyAggregationCheck, gives the current-slot candidate an anchor and a width of 2; under--skip-redundant-aggregationthe duty subnets that do not own width 2 then sit that slot out and their raw signatures miss the next block. On the intended topology (distinct duty subnets, distinct subscriptions) a round-one peer proof never touches this node's subnet, so it holds anyway. Overlapping subscriptions are where it fails.Metrics
lean_aggregation_window_width(Histogram): width derived per candidate. Climbs from 1 as the anchor climbs; pinned at the committee count means the window no longer restricts selection. Stuck at 1 while the network is aggregating means the pool holds nothing on this node's duty subnet, so it is only aggregating its own raw signatures.lean_aggregation_skipped_redundant_total: candidates handed to another duty subnet by the redundancy-skipping rotation. Only increments with the flag on.lean_aggregation_window_fallback_total: merges the window would have dropped, recovered by retrying selection at the full committee set. Counts recoveries, not attempts: a candidate no window could have made viable, a lone raw signature or a single-proof group, never reaches the retry. A persistently rising value therefore means the aggregator placement is too sparse for the committee count. Stays flat entirely under--skip-redundant-aggregation.Test Plan
cargo test --workspace --profile release-fast: 695 tests passmake lintclean,make fmtclean,make docsbuildsAttestationData--aggregate-subnet-idsvalue at or above the committee count is refused at startup, past the first id tooattestation_committee_count = 4and aggregators on distinct duty subnets, confirmlean_aggregation_window_widthclimbs rather than pinning at 4, fleet-wide aggregation CPU drops against a control, and finality is unaffectedlean_aggregation_window_fallback_totalstays flat on the intended placementNotes for review
keep_best_proof_per_data) keeps one proof perAttestationDataand drops the rest. Aggregators now emit proofs with distinct coverage rather than near-identical ones, so that path may drop more useful coverage than before. Bounded, since the window is sized to fit two children of the anchor's current reach, so a windowed proof is the same size as before;--enable-proposer-aggregationremoves the exposure entirely. Declared out of scope here but worth measuring.--skip-redundant-aggregationset and no explicit--aggregate-subnet-ids, every aggregator on a subnet-spanning topology derives duty subnet 0 and sits out in lockstep instead of taking turns. The node warns at startup when that combination is configured.